Skip to content

Commit 21dd402

Browse files
authored
fix: ref on dynamic elements with reactive props (#772)
1 parent 099f2da commit 21dd402

File tree

3 files changed

+159
-6
lines changed

3 files changed

+159
-6
lines changed
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'ripple': patch
3+
---
4+
5+
Fix ref handling for dynamic elements with reactive spread props to avoid
6+
read-only/proxy symbol errors and prevent unnecessary ref teardown/recreation.

packages/ripple/src/runtime/internal/client/render.js

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/** @import { Block } from '#client' */
22

33
import { destroy_block, ref } from './blocks.js';
4-
import { REF_PROP } from './constants.js';
4+
import { DESTROYED, REF_PROP } from './constants.js';
55
import {
66
get_descriptors,
77
get_own_property_symbols,
@@ -252,6 +252,9 @@ export function apply_element_spread(element, fn) {
252252
/** @type {Record<string | symbol, (() => void) | undefined>} */
253253
var remove_listeners = {};
254254

255+
/** @type {Record<symbol, any>} */
256+
var prev_symbols = {};
257+
255258
return () => {
256259
var next = fn();
257260

@@ -262,19 +265,28 @@ export function apply_element_spread(element, fn) {
262265
}
263266
}
264267

268+
/** @type {Record<symbol, any>} */
269+
var current_symbols = {};
270+
265271
for (const symbol of get_own_property_symbols(next)) {
266272
var ref_fn = next[symbol];
267-
268-
if (symbol.description === REF_PROP && (!(symbol in prev) || ref_fn !== prev[symbol])) {
269-
if (effects[symbol]) {
273+
current_symbols[symbol] = ref_fn;
274+
275+
if (
276+
symbol.description === REF_PROP &&
277+
(!(symbol in prev_symbols) ||
278+
ref_fn !== prev_symbols[symbol] ||
279+
(effects[symbol] && (effects[symbol].f & DESTROYED) !== 0))
280+
) {
281+
if (effects[symbol] && (effects[symbol].f & DESTROYED) === 0) {
270282
destroy_block(effects[symbol]);
271283
}
272284
effects[symbol] = ref(element, () => ref_fn);
273285
}
274-
275-
next[symbol] = ref_fn;
276286
}
277287

288+
prev_symbols = current_symbols;
289+
278290
for (let key in remove_listeners) {
279291
// Remove event listeners that are no longer present
280292
if (!(key in next) && remove_listeners[key]) {

packages/ripple/tests/client/dynamic-elements.test.ripple

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)