Repository navigation
Description
What happens
Removing a row from a keyed For runs that row's lifecycle cleanup under the list's owned computation. An ordinary setter from the cleanup throws REACTIVE_WRITE_IN_OWNED_SCOPE, followed by REACTIVITY_HALTED. The row remains mounted and subsequent list updates are ignored. The identical cleanup unmounted by Show succeeds.
This reproduces with the documented component lifecycle form, onSettled(() => () => setCount(n => n + 1)), and with low-level onCleanup. It uses no async source, router, external UI library, SSR or hydration.
Minimal shape
import { render } from '@solidjs/web';
import { createSignal, flush, For, onSettled } from 'solid-js';
const [rows, setRows] = createSignal([1, 2]);
const [count, setCount] = createSignal(0);
function Row() {
onSettled(() => () => setCount(n => n + 1));
return <i>row</i>;
}
render(() => <For each={rows()}>{() => <Row />}</For>, document.body);
flush();
setRows([2]);
flush(); // development: owned-write error, then reactivity haltedExpected: one row remains, the release count becomes 1, and a later append works. An internal control-flow computation should not become the lifecycle callback's write context.
Executable case and version evidence
Fixed conformance case 058, including source, desired-behavior assertions and Show/ownedWrite controls.
After its frozen install, run:
TARGET=rc15 bun test ./findings/058-list-cleanup-write/repro.test.ts- Published core/web/signals/compiler
2.0.0-rc.15(4937f0a2bfddd45a65738c189075aff577530073): both For cleanup variants fail in development; Show and ownedWrite controls pass. - Source-built current
nextat2172a37687e92dd91758476423f08cb4a12bcb0c: the same development failure. - Current HEAD production: the disposal/control cases pass because the owned-write guard is development-only. This report does not claim a production crash.
- Real system Google Chrome; native compiler; plain public JSX with no type bypass.
Raw versioned receipts and logs.
Source boundary and candidate repair
packages/signals/src/map.ts disposes removed row owners inside the list recomputation. core/owner.ts calls registered cleanups and final effect-returned cleanups directly, inheriting that ambient context. The setter guard then treats the cleanup as a computation write.
A local source experiment runs just the actual cleanup callbacks through runWithOwner(null, callback), restoring the caller's context/tracking afterward. It does not relax the setter guard or add a disposal-depth exemption. The original browser repro becomes green; ordinary computation writes and a computation nested inside cleanup remain rejected. Using the same Node/Vitest 4.1.6 runner, the upstream signals suite is 323 files / 5387 passes / 3 expected failures / 2 skips before and after. This is a tested direction, not a claim that the repair is upstream-reviewed.
Dedupe and application impact
Searched open and closed issues in solid, solid-router and solid-start. #4089 is cleanup rejoining an already-landed transaction; #3601 is reentrant double cleanup. This case needs neither a transaction nor reentrant disposal.
The real application trigger was deleting a row from its own open menu: dismissable-layer teardown publishes shared state to still-mounted UI. It currently requires deferred writes or ownedWrite declarations in library internals merely to survive list removal.
Activity
fix(signals): a removed row's cleanup can write during teardown (#4165)…
ryansolid commentedon Oct 20, 2026
Generated by Claude Code