Skip to content

[2.0 / next] A live render-effect reader changes whether a continuation sibling write is held #4146

Closed

Description

@vendyluo

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/solid branch next, commit d2ad8b288886a5c7171e6746ccc019ad1d08d8ab, 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 says 2.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/signals 2.0.0-rc.14 and 2.0.0-rc.15, dev and prod.

Steps to reproduce

  1. 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
  1. Save the script below as packages/signals/repro-continuation-reader.mjs.
  2. Run both builds:
cd packages/signals
node repro-continuation-reader.mjs dev
node repro-continuation-reader.mjs prod

The timeline is identical in each fresh root:

  1. A single synchronous generator action writes optimistic card.version = 1, then yields gate 1.
  2. Release gate 1. Its continuation writes truth committed = 1 and card.version = 1, then yields gate 2, which remains unresolved.
  3. Verify the plain version is 0 and latest(version) is 1.
  4. Outside the action, write truth card.text = 'a', drain scheduled work, and flush.
  5. 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

  1. ryansolid commented on Oct 19, 2026

    @ryansolid
    Member

    Fixed by #4159, now merged to next with its regression test. Closing.

    — Claude via Claude Code


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions