@@ -593,6 +593,141 @@ describe('dynamic DOM elements', () => {
593593 expect(innerScopes).toHaveLength(0);
594594 });
595595
596+ it('handles ref on dynamic element passed through component with reactive props', () => {
597+ let capturedElement: HTMLElement | null = null;
598+ let refCallCount = 0;
599+
600+ component Button(props: any) {
601+ const el = track('button');
602+ <@el {...props} />
603+ }
604+
605+ component App() {
606+ let active = track(false);
607+
608+ <Button
609+ data-active={String(@active)}
610+ onClick={() => {
611+ @active = !@active;
612+ }}
613+ {ref (el: HTMLElement) => {
614+ capturedElement = el;
615+ refCallCount++;
616+ }}
617+ >
618+ {'content'}
619+ </Button>
620+ }
621+
622+ render(App);
623+ flushSync();
624+
625+ expect(capturedElement).toBeTruthy();
626+ expect(capturedElement!.tagName).toBe('BUTTON');
627+ expect(capturedElement!.getAttribute('data-active')).toBe('false');
628+ const initialRefCount = refCallCount;
629+
630+ // Click the button to trigger reactive prop update
631+ capturedElement!.click();
632+ flushSync();
633+
634+ // After clicking, the reactive prop should update without error
635+ expect(capturedElement!.getAttribute('data-active')).toBe('true');
636+ // Ref block should not have been recreated on prop update
637+ expect(refCallCount).toBe(initialRefCount);
638+ });
639+
640+ it('handles ref on dynamic element with spread props containing reactive values', () => {
641+ let capturedElement: HTMLElement | null = null;
642+
643+ component Button(props: any) {
644+ const el = track('button');
645+ <@el {...props} />
646+ }
647+
648+ component App() {
649+ let active = track(false);
650+
651+ let buttonProps = track(
652+ () => ({
653+ 'data-active': @active,
654+ }),
655+ );
656+
657+ <Button
658+ {...@buttonProps}
659+ onClick={() => {
660+ @active = !@active;
661+ }}
662+ {ref (el: HTMLElement) => {
663+ capturedElement = el;
664+ }}
665+ >
666+ {'content: '}
667+ {@active}
668+ </Button>
669+ }
670+
671+ render(App);
672+ flushSync();
673+
674+ expect(capturedElement).toBeTruthy();
675+ expect(capturedElement!.tagName).toBe('BUTTON');
676+
677+ // Click the button to trigger reactive update
678+ capturedElement!.click();
679+ flushSync();
680+
681+ // Should not throw, and ref should still be valid
682+ expect(capturedElement!.tagName).toBe('BUTTON');
683+ });
684+
685+ it('re-establishes ref with cleanup after parent block re-runs', () => {
686+ let cleanupCount = 0;
687+ let refCallCount = 0;
688+ let capturedElement: HTMLElement | null = null;
689+
690+ component Button(props: any) {
691+ const el = track('button');
692+ <@el {...props} />
693+ }
694+
695+ component App() {
696+ let active = track(false);
697+
698+ <Button
699+ data-active={String(@active)}
700+ onClick={() => {
701+ @active = !@active;
702+ }}
703+ {ref (el: HTMLElement) => {
704+ capturedElement = el;
705+ refCallCount++;
706+ return () => {
707+ cleanupCount++;
708+ };
709+ }}
710+ >
711+ {'content'}
712+ </Button>
713+ }
714+
715+ render(App);
716+ flushSync();
717+
718+ expect(capturedElement).toBeTruthy();
719+ expect(refCallCount).toBe(1);
720+ expect(cleanupCount).toBe(0);
721+
722+ // Click to trigger reactive prop update
723+ capturedElement!.click();
724+ flushSync();
725+
726+ // Ref with cleanup should be re-established after parent teardown cycle
727+ expect(capturedElement!.getAttribute('data-active')).toBe('true');
728+ expect(refCallCount).toBeGreaterThanOrEqual(1);
729+ });
730+
596731 it('should remove and add back a text node in a conditional statement with a tracked', () => {
597732 component App() {
598733 let b = track(true);
0 commit comments