Skip to content

Commit a51580b

Browse files
Copilotdummdidumm
andcommitted
Add failing tests that reproduce issue #17263 - derived with SvelteSet reconnection bug
Co-authored-by: dummdidumm <5968653+dummdidumm@users.noreply.github.com>
1 parent 153e91d commit a51580b

File tree

1 file changed

+318
-0
lines changed
  • packages/svelte/tests/signals

1 file changed

+318
-0
lines changed

packages/svelte/tests/signals/test.ts

Lines changed: 318 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3081,4 +3081,322 @@ describe('signals', () => {
30813081
destroy3();
30823082
};
30833083
});
3084+
3085+
test('derived property on object depending on SvelteSet - immediate read after update', () => {
3086+
// Reproduction from issue #17263:
3087+
// An item has an `expanded` derived property that depends on expanded_ids.has(id)
3088+
// Reading item.expanded immediately after modifying expanded_ids should reflect the change
3089+
const log: any[] = [];
3090+
3091+
return () => {
3092+
const expanded_ids = new SvelteSet<number>();
3093+
3094+
// Create an "item" with a derived expanded property
3095+
function create_item(id: number) {
3096+
return {
3097+
id,
3098+
get expanded() {
3099+
return expanded_ids.has(id);
3100+
}
3101+
};
3102+
}
3103+
3104+
const item = create_item(1);
3105+
3106+
// Expand function
3107+
function on_expand(id: number) {
3108+
expanded_ids.add(id);
3109+
}
3110+
3111+
// Collapse function
3112+
function on_collapse(id: number) {
3113+
expanded_ids.delete(id);
3114+
}
3115+
3116+
// Toggle function - this is where the bug manifests
3117+
function toggle_expansion() {
3118+
if (item.expanded) {
3119+
on_collapse(item.id);
3120+
} else {
3121+
on_expand(item.id);
3122+
}
3123+
// Reading immediately after modification - this is the bug trigger
3124+
log.push(item.expanded);
3125+
}
3126+
3127+
// Create an effect that reads item.expanded
3128+
let destroy = effect_root(() => {
3129+
render_effect(() => {
3130+
// Just to establish reactivity
3131+
item.expanded;
3132+
});
3133+
});
3134+
3135+
flushSync();
3136+
3137+
// Initially not expanded
3138+
assert.equal(item.expanded, false, 'initially not expanded');
3139+
3140+
// Toggle to expand
3141+
toggle_expansion();
3142+
// Should now be true
3143+
assert.deepEqual(log, [true], 'should be expanded after toggle');
3144+
3145+
// Toggle to collapse
3146+
toggle_expansion();
3147+
// Should now be false
3148+
assert.deepEqual(log, [true, false], 'should be collapsed after second toggle');
3149+
3150+
destroy();
3151+
};
3152+
});
3153+
3154+
test('derived depending on SvelteSet - item object with derived getter', () => {
3155+
// More direct reproduction of the issue pattern
3156+
const log: any[] = [];
3157+
3158+
return () => {
3159+
const expanded_ids = new SvelteSet<number>();
3160+
3161+
// Simulate the item structure from the reproduction
3162+
class Item {
3163+
id: number;
3164+
constructor(id: number) {
3165+
this.id = id;
3166+
}
3167+
get expanded() {
3168+
return expanded_ids.has(this.id);
3169+
}
3170+
}
3171+
3172+
const item = new Item(1);
3173+
3174+
let destroy = effect_root(() => {
3175+
render_effect(() => {
3176+
log.push(`effect: ${item.expanded}`);
3177+
});
3178+
});
3179+
3180+
flushSync();
3181+
assert.deepEqual(log, ['effect: false']);
3182+
3183+
// Add to set
3184+
expanded_ids.add(1);
3185+
// Immediately read the derived
3186+
const valueAfterAdd = item.expanded;
3187+
log.push(`immediate: ${valueAfterAdd}`);
3188+
3189+
flushSync();
3190+
3191+
// The immediate read should have returned true
3192+
assert.equal(valueAfterAdd, true, 'immediate read after add should be true');
3193+
3194+
// Effect should have run
3195+
assert.ok(log.includes('effect: true'), 'effect should have seen true');
3196+
3197+
// Delete from set
3198+
expanded_ids.delete(1);
3199+
const valueAfterDelete = item.expanded;
3200+
log.push(`immediate: ${valueAfterDelete}`);
3201+
3202+
flushSync();
3203+
3204+
assert.equal(valueAfterDelete, false, 'immediate read after delete should be false');
3205+
3206+
destroy();
3207+
};
3208+
});
3209+
3210+
test('derived on SvelteSet with object property - toggle pattern', () => {
3211+
// Exact pattern from the buggy reproduction
3212+
return () => {
3213+
const expanded_ids = new SvelteSet<number>();
3214+
3215+
const items = [
3216+
{
3217+
id: 1,
3218+
get expanded() {
3219+
return expanded_ids.has(this.id);
3220+
}
3221+
},
3222+
{
3223+
id: 2,
3224+
get expanded() {
3225+
return expanded_ids.has(this.id);
3226+
}
3227+
}
3228+
];
3229+
3230+
function on_expand(id: number) {
3231+
expanded_ids.add(id);
3232+
}
3233+
3234+
function on_collapse(id: number) {
3235+
expanded_ids.delete(id);
3236+
}
3237+
3238+
// The buggy toggle function that reads item.expanded after modification
3239+
function buggy_toggle(item: (typeof items)[0]) {
3240+
if (item.expanded) {
3241+
on_collapse(item.id);
3242+
} else {
3243+
on_expand(item.id);
3244+
}
3245+
// This is the problematic pattern - reading immediately after modification
3246+
return item.expanded;
3247+
}
3248+
3249+
let destroy = effect_root(() => {
3250+
render_effect(() => {
3251+
// Establish reactivity
3252+
items.forEach((i) => i.expanded);
3253+
});
3254+
});
3255+
3256+
flushSync();
3257+
3258+
// Toggle item 1 (expand)
3259+
const result1 = buggy_toggle(items[0]);
3260+
assert.equal(result1, true, 'after toggle expand, should read true');
3261+
assert.equal(expanded_ids.has(1), true, 'set should contain id 1');
3262+
3263+
// Toggle item 1 again (collapse)
3264+
const result2 = buggy_toggle(items[0]);
3265+
assert.equal(result2, false, 'after toggle collapse, should read false');
3266+
assert.equal(expanded_ids.has(1), false, 'set should not contain id 1');
3267+
3268+
// Toggle item 2 (expand)
3269+
const result3 = buggy_toggle(items[1]);
3270+
assert.equal(result3, true, 'after toggle expand item 2, should read true');
3271+
3272+
destroy();
3273+
};
3274+
});
3275+
3276+
test('derived passed as prop - item with expanded derived from SvelteSet', () => {
3277+
// Simulates the parent-child component pattern from issue #17263
3278+
// Parent creates item with expanded derived, passes to child
3279+
// Child reads item.expanded after modifying the set
3280+
return () => {
3281+
const expanded_ids = new SvelteSet<number>();
3282+
3283+
// Parent creates the item (like in App.svelte)
3284+
function create_item(id: number) {
3285+
const d = derived(() => expanded_ids.has(id));
3286+
return {
3287+
id,
3288+
get expanded() {
3289+
return $.get(d);
3290+
}
3291+
};
3292+
}
3293+
3294+
const item = create_item(1);
3295+
3296+
// Parent's expand/collapse functions
3297+
function on_expand(id: number) {
3298+
expanded_ids.add(id);
3299+
}
3300+
function on_collapse(id: number) {
3301+
expanded_ids.delete(id);
3302+
}
3303+
3304+
// Simulate child component receiving item as prop
3305+
let childDestroy: () => void;
3306+
3307+
// Parent effect (creates the item and passes to child)
3308+
let parentDestroy = effect_root(() => {
3309+
render_effect(() => {
3310+
// Parent might read item.expanded in template
3311+
item.expanded;
3312+
});
3313+
});
3314+
3315+
flushSync();
3316+
3317+
// Child effect - simulates child component
3318+
childDestroy = effect_root(() => {
3319+
render_effect(() => {
3320+
// Child reads item.expanded
3321+
item.expanded;
3322+
});
3323+
});
3324+
3325+
flushSync();
3326+
3327+
// Child's buggy toggle function
3328+
function buggy_on_toggle_expansion() {
3329+
if (item.expanded) {
3330+
on_collapse(item.id);
3331+
} else {
3332+
on_expand(item.id);
3333+
}
3334+
// This triggers the bug - reading immediately after modification
3335+
return item.expanded;
3336+
}
3337+
3338+
// Test the toggle
3339+
const result1 = buggy_on_toggle_expansion();
3340+
assert.equal(result1, true, 'after expand, should read true');
3341+
3342+
const result2 = buggy_on_toggle_expansion();
3343+
assert.equal(result2, false, 'after collapse, should read false');
3344+
3345+
const result3 = buggy_on_toggle_expansion();
3346+
assert.equal(result3, true, 'after expand again, should read true');
3347+
3348+
childDestroy();
3349+
parentDestroy();
3350+
};
3351+
});
3352+
3353+
test('derived with SvelteSet - disconnected then reconnected', () => {
3354+
// Test the scenario where the derived might become disconnected
3355+
return () => {
3356+
const expanded_ids = new SvelteSet<number>();
3357+
3358+
const d = derived(() => expanded_ids.has(1));
3359+
3360+
// First effect - connects the derived
3361+
let destroy1 = effect_root(() => {
3362+
render_effect(() => {
3363+
$.get(d);
3364+
});
3365+
});
3366+
3367+
flushSync();
3368+
assert.equal($.get(d), false);
3369+
3370+
// Destroy the effect - disconnects the derived
3371+
destroy1();
3372+
flushSync();
3373+
3374+
// Modify the set while derived is disconnected
3375+
expanded_ids.add(1);
3376+
3377+
// Create new effect - reconnects the derived
3378+
let destroy2 = effect_root(() => {
3379+
render_effect(() => {
3380+
$.get(d);
3381+
});
3382+
});
3383+
3384+
flushSync();
3385+
3386+
// The derived should reflect the updated value
3387+
assert.equal($.get(d), true, 'derived should see updated value after reconnection');
3388+
3389+
// Modify again
3390+
expanded_ids.delete(1);
3391+
const immediateValue = $.get(d);
3392+
assert.equal(immediateValue, false, 'immediate read should see deleted');
3393+
3394+
// Add back
3395+
expanded_ids.add(1);
3396+
const immediateValue2 = $.get(d);
3397+
assert.equal(immediateValue2, true, 'immediate read should see added');
3398+
3399+
destroy2();
3400+
};
3401+
});
30843402
});

0 commit comments

Comments
 (0)