Repository navigation
[2.0 / next] A live render-effect reader changes whether a continuation sibling write is held #4146
Description
Describe the bug
On current next, a later plain write to a store property is held until an action finishes without a reader, but becomes visible immediately with a live createRenderEffect reading that property. The action's write to a sibling property remains held in both cases.
The only difference is the reader established before the action. A one-off plain read, a one-off latest() read, and an effect disposed before the action all behave like the no-reader case.
This changes the value returned by an ordinary unowned property read, not just when an effect runs. Adding a debug display of card.text can therefore change when other code sees that field's update.
Your Example Website or App
Self-contained Node reproduction below; only the locally built @solidjs/signals runtime is used. No DOM, network, compiler, or application dependencies are required.
Platform / tested revision
solidjs/solidbranchnext, commitd2ad8b288886a5c7171e6746ccc019ad1d08d8ab, checked on 2026-10-19.- Fresh source checkout; built with
pnpm --filter @solidjs/signals run build. The runtime source was not modified. This is a source build at that commit, not a test of the published rc.15 tarball (the source manifest also says2.0.0-rc.15). - Reproduces in both development and production artifacts. The script verifies which build loaded.
- macOS 27.0.1 / arm64, Node.js 24.21.0, pnpm 11.1.1.
- The same five-way comparison also reproduces on published
@solidjs/signals2.0.0-rc.14 and 2.0.0-rc.15, dev and prod.
Steps to reproduce
- Check out the revision above and build signals:
git clone --branch next https://github.com/solidjs/solid.git
cd solid
git checkout d2ad8b288886a5c7171e6746ccc019ad1d08d8ab
pnpm --filter @solidjs/signals install --frozen-lockfile --ignore-scripts
pnpm --filter @solidjs/signals run build- Save the script below as
packages/signals/repro-continuation-reader.mjs. - Run both builds:
cd packages/signals
node repro-continuation-reader.mjs dev
node repro-continuation-reader.mjs prodThe timeline is identical in each fresh root:
- A single synchronous generator action writes optimistic
card.version = 1, then yields gate 1. - Release gate 1. Its continuation writes truth
committed = 1andcard.version = 1, then yields gate 2, which remains unresolved. - Verify the plain version is
0andlatest(version)is1. - Outside the action, write truth
card.text = 'a', drain scheduled work, and flush. - Read text/version before releasing gate 2, then complete the action and check the final values.
Complete executable reproduction (five reader configurations)
// Run from packages/signals after building this checkout: node repro-continuation-reader.mjs dev|prod
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
const mode = process.argv[2] || 'dev';
assert.ok(['dev', 'prod'].includes(mode));
const { version } = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8'));
const { action, createStore, createOptimisticStore, createRenderEffect, createRoot, flush, latest } =
await import(mode === 'dev' ? './dist/dev.js' : './dist/prod/index.js');
assert.equal(String(action).includes('ACTION_CALLED_IN_OWNED_SCOPE'), mode === 'dev');
function gate() {
let release;
const promise = new Promise(resolve => { release = resolve; });
return { promise, release };
}
async function settle() {
for (let i = 0; i < 8; i++) await Promise.resolve();
await new Promise(resolve => setTimeout(resolve, 0));
flush();
for (let i = 0; i < 4; i++) await Promise.resolve();
flush();
}
const results = [];
for (const reader of ['none', 'plain', 'latest', 'effect', 'disposed-effect']) {
const first = gate(), last = gate();
let dispose;
const m = createRoot(stop => {
dispose = stop;
const [state, setState] = createStore({ committed: 0, cards: [{ version: 0, text: '' }] });
const [, setView] = createOptimisticStore(state);
const observations = [];
const watchText = () => createRenderEffect(() => state.cards[0].text, text => { observations.push(text); });
if (reader === 'plain') void state.cards[0].text;
if (reader === 'latest') latest(() => state.cards[0].text);
if (reader === 'effect') watchText();
if (reader === 'disposed-effect') {
const stopReader = createRoot(stop => { watchText(); return stop; });
flush();
stopReader();
}
const run = action(function* () {
setView(s => { s.cards[0].version = 1; });
yield first.promise;
setState(s => { s.committed = 1; s.cards[0].version = 1; });
yield last.promise;
});
return { state, setState, observations, run };
});
flush();
const completion = m.run();
try {
flush();
first.release();
await settle();
assert.equal(m.state.cards[0].version, 0);
assert.equal(latest(() => m.state.cards[0].version), 1);
m.setState(s => { s.cards[0].text = 'a'; });
await settle();
const pending = { text: m.state.cards[0].text, version: m.state.cards[0].version, observations: [...m.observations] };
assert.equal(pending.text, reader === 'effect' ? 'a' : '');
assert.equal(pending.version, 0);
last.release();
await completion;
await settle();
assert.equal(m.state.cards[0].text, 'a');
assert.equal(m.state.cards[0].version, 1);
results.push({ reader, pending, final: { text: m.state.cards[0].text, version: m.state.cards[0].version } });
} finally {
first.release(); last.release();
await completion;
await settle();
dispose(); flush();
}
}
console.log(JSON.stringify({ version, build: mode, results }, null, 2));The intermediate text assertion intentionally records the current differing behavior; it is not a proposed expected contract.
Actual result
Both builds produce:
| Reader established before the action | Plain text while gate 2 is open |
Plain version |
Text effect observations so far |
|---|---|---|---|
| None | '' |
0 |
none |
| One plain read | '' |
0 |
none |
One latest() read |
'' |
0 |
none |
| Live render effect reading text | 'a' |
0 |
['', 'a'] |
| Render effect, disposed before the action | '' |
0 |
[''] |
After releasing gate 2, all five cases end at text 'a', version 1. Gates control action progress; no request-duration timeout is used to decide when an action has finished. These are signals/effect observations, not browser-paint assertions.
Expected behavior
I would expect adding/removing a passive reader of text not to change whether this separate write is visible to ordinary unowned reads while the sibling's continuation write remains held. If this difference is intentional, which rule determines the hold membership in this shape?
I understand that active render readers participate in Solid's hold model; this report is specifically about the different visibility of the same plain sibling write under otherwise identical scheduling. The store implementation's introductory comment also describes node existence as unobservable, so it would help to clarify whether that invariant applies here.
Additional context
Related in theme to #3141 (optimistic-store observer invariance) and #3706 (key-scoped holds), but this reproduction uses one synchronous generator action, two explicit yield gates, a plain truth store with a chained optimistic view, and a later ordinary write to another property of the same card. It does not use a generator-backed store source, a derived drag memo, or overlapping actions.
No root cause or runtime fix is claimed here; the reproduction and the dev/prod comparison are confirmed.
Activity
fix(signals): golf the #4146 held-key leaf (+80 B min on store scenar…
size: raise store caps for the #4146 held-key leaf
docs(signals): regenerate RULES-INDEX for the golfed #4146 leaf
fix(signals): share slotKept, give every held-container write its leaf (
size: re-set #4146 caps from CI after the golf
fix(signals): an unread key written beside a held sibling publishes a…
ryansolid commentedon Oct 19, 2026
Generated by Claude Code