Summary
Rebuilding a createList's contents via remove() + add() inside a createEffect(() => match(task, {...})) handler throws EffectConvergenceError, even when the net list content is stable across passes and no code writes to task's own dependencies. The same rebuild via list.set(newArray) converges fine. Root cause is two compounding gaps, reproduced against @zeix/cause-effect@1.4.0.
Reproduction
Two scripts attached (01-convergence-object-handlers.mjs, 01-convergence-bisect.mjs). Minimal shape:
const city = createState('London')
const weather = createTask(async (_prev, signal) => {
city.get()
await fetchWeather(...)
return { temp: 20 }
})
const forecast = createList([], { keyConfig: 'day' })
createEffect(() => match(weather, {
ok: (data) => {
for (const k of Array.from(forecast.keys())) forecast.remove(k)
forecast.add({ day: 'mon', high: data.temp })
}
}))
Throws EffectConvergenceError: Effects did not settle after 1000 flush passes on initial load. Wrapping the handler body in untrack(() => { ... }) avoids the error. Using forecast.set([{ day: 'mon', high: data.temp }]) instead of remove+add also avoids the error, with no untrack needed.
Bisect results (variant = operation inside the ok handler):
| variant |
operation |
result |
| a |
empty handler |
clean |
| b |
read an unrelated state |
clean |
| c |
unconditionally write an unrelated state (counter.set(counter.get()+1)) |
EffectConvergenceError (expected — genuine non-convergent loop) |
| d |
list.remove() all keys, then list.add() one equivalent item |
EffectConvergenceError |
| e |
same as d, wrapped in untrack() |
clean |
Root cause
Traced into src/nodes/effect.ts and src/nodes/list.ts:
-
match() does not isolate sync handler bodies from the caller's tracking scope. match() (effect.ts:168) invokes ok/err/nil/stale handlers synchronously inside whatever scope called match() — normally the body of a createEffect. Any signal read performed inside the handler (including implicit reads inside list/collection APIs) becomes a tracked dependency of that effect. The JSDoc only warns about .set() calls in async handlers ("Do not call .set() on a signal inside an async handler") — there's no mention that synchronous handler bodies are tracked too, or that list APIs like .keys() perform a tracked read.
Concretely: createList.keys() (list.ts:448) calls subscribe(), so Array.from(forecast.keys()) inside the ok handler links forecast into the effect's source list — with no .get() in sight to flag it during review.
-
add()/remove() unconditionally propagate + flush, with no equality/no-op short-circuit. list.ts:468 (add) and list.ts:480 (remove) always set FLAG_DIRTY | FLAG_RELINK, call propagate(), and (since batchDepth === 0 when called synchronously inside an effect) call flush() immediately — regardless of whether the resulting list content is equivalent to what it was before the mutation. Contrast with list.set() (list.ts:416), which runs diffArrays() against the previous content first and only flags changed/propagates if the content actually differs, and replace() (list.ts:494), which explicitly checks itemEquals before writing.
Combined with (1): once forecast is a tracked source of the effect, every pass performs remove(all) + add(equivalent-item), which add/remove always treat as "changed" (no equality check), so the effect is re-scheduled every pass forever — even though the logical list content is identical each time. This is different from variant c, which is a genuinely non-convergent write (+1 never stabilizes); variant d's end state is stable, but the mutation API can't recognize that.
Why this isn't just "misuse of untrack"
- Nothing in current docs says list-mutation methods used inside a sync
match/effect body get tracked as reads, or that add/remove skip the equality check set/replace have.
- The "remove all + re-add" idiom is a natural way to rebuild a collection from a freshly-fetched value (e.g. resyncing a 7-day forecast) and gives no visual signal (
.get()/.set()) that would prompt a reviewer to reach for untrack.
- The workaround that does work (
list.set(newArray)) is non-obvious as the fix unless you already understand gap 2 above; untrack() "fixes" it too but by brute-force suppressing tracking rather than addressing the underlying no-op gap.
Suggested fixes
- Give
add()/remove() (and any other structural list mutators) the same content-based no-op check set() already has, so a remove+add cycle that nets out to equivalent content doesn't perpetually re-dirty the list.
- Document, on
match() and/or createEffect(), that sync handler bodies (not just async ones) execute in the active tracking scope, and that any signal/list/collection read performed inside them becomes a dependency of the enclosing effect — with list.set()/.update() called out as the convergence-safe way to rebuild list contents inside a reactive handler, versus manual remove+add loops.
Environment
@zeix/cause-effect 1.4.0
- Node.js 25.9.0
- Reproduced in isolation (no framework/DOM involved) — originally surfaced in a weather-app example built with
@zeix/le-truc.
Summary
Rebuilding a
createList's contents viaremove()+add()inside acreateEffect(() => match(task, {...}))handler throwsEffectConvergenceError, even when the net list content is stable across passes and no code writes totask's own dependencies. The same rebuild vialist.set(newArray)converges fine. Root cause is two compounding gaps, reproduced against@zeix/cause-effect@1.4.0.Reproduction
Two scripts attached (
01-convergence-object-handlers.mjs,01-convergence-bisect.mjs). Minimal shape:Throws
EffectConvergenceError: Effects did not settle after 1000 flush passeson initial load. Wrapping the handler body inuntrack(() => { ... })avoids the error. Usingforecast.set([{ day: 'mon', high: data.temp }])instead ofremove+addalso avoids the error, with nountrackneeded.Bisect results (variant = operation inside the
okhandler):counter.set(counter.get()+1))EffectConvergenceError(expected — genuine non-convergent loop)list.remove()all keys, thenlist.add()one equivalent itemEffectConvergenceErroruntrack()Root cause
Traced into
src/nodes/effect.tsandsrc/nodes/list.ts:match()does not isolate sync handler bodies from the caller's tracking scope.match()(effect.ts:168) invokesok/err/nil/stalehandlers synchronously inside whatever scope calledmatch()— normally the body of acreateEffect. Any signal read performed inside the handler (including implicit reads inside list/collection APIs) becomes a tracked dependency of that effect. The JSDoc only warns about.set()calls in async handlers ("Do not call.set()on a signal inside an async handler") — there's no mention that synchronous handler bodies are tracked too, or that list APIs like.keys()perform a tracked read.Concretely:
createList.keys()(list.ts:448) callssubscribe(), soArray.from(forecast.keys())inside theokhandler linksforecastinto the effect's source list — with no.get()in sight to flag it during review.add()/remove()unconditionally propagate + flush, with no equality/no-op short-circuit.list.ts:468(add) andlist.ts:480(remove) always setFLAG_DIRTY | FLAG_RELINK, callpropagate(), and (sincebatchDepth === 0when called synchronously inside an effect) callflush()immediately — regardless of whether the resulting list content is equivalent to what it was before the mutation. Contrast withlist.set()(list.ts:416), which runsdiffArrays()against the previous content first and only flagschanged/propagates if the content actually differs, andreplace()(list.ts:494), which explicitly checksitemEqualsbefore writing.Combined with (1): once
forecastis a tracked source of the effect, every pass performsremove(all) + add(equivalent-item), whichadd/removealways treat as "changed" (no equality check), so the effect is re-scheduled every pass forever — even though the logical list content is identical each time. This is different from variantc, which is a genuinely non-convergent write (+1never stabilizes); variantd's end state is stable, but the mutation API can't recognize that.Why this isn't just "misuse of
untrack"match/effect body get tracked as reads, or thatadd/removeskip the equality checkset/replacehave..get()/.set()) that would prompt a reviewer to reach foruntrack.list.set(newArray)) is non-obvious as the fix unless you already understand gap 2 above;untrack()"fixes" it too but by brute-force suppressing tracking rather than addressing the underlying no-op gap.Suggested fixes
add()/remove()(and any other structural list mutators) the same content-based no-op checkset()already has, so a remove+add cycle that nets out to equivalent content doesn't perpetually re-dirty the list.match()and/orcreateEffect(), that sync handler bodies (not just async ones) execute in the active tracking scope, and that any signal/list/collection read performed inside them becomes a dependency of the enclosing effect — withlist.set()/.update()called out as the convergence-safe way to rebuild list contents inside a reactive handler, versus manualremove+addloops.Environment
@zeix/cause-effect1.4.0@zeix/le-truc.