Skip to content

fix(state): evaluate a re-set state against its own generation - #266

Merged
mogera551 merged 3 commits into
mainfrom
fix/state-generation-reset
Sep 12, 2026
Merged

mogera551 merged 3 commits into
mainfrom
fix/state-generation-reset

Conversation

@mogera551

Copy link
Copy Markdown
Contributor

Part of #258. It fixes two of the three symptoms and pins the rest.

What was wrong

Re-setting a state object left machinery pointing at the previous generation, because the things it is keyed by are identical across generations.

X10, the cache. A wildcard-free getter's absolute address is the same object before and after a re-set, so the old value came back with dirty:false. mount({items:[1,2], get sum(){…}}) read 3, was re-set to {items:[5,6]}, and still read 3.

X7's internal half, the ledgers. The setter clears pathSet, listPaths and elementPaths, and the recursion teardown removes the edges of the previous generation's generated accessors. Nothing rebuilt them, so after a re-set a whole-list write threw Cannot expand dynamic dependency with wildcard for non-list address while the DOM moved on regardless.

What this does

  • Cache entries carry the generation they were written in, and a read only hits an entry from the current generation. The generation advances after the previous generation is torn down and before the new state object is installed.
  • The path registrations of live bindings are re-derived after the setter's clears, so the ledgers and static chains are rebuilt for the new generation. Clearing pathSet stays: it is the self-heal that restores the static edges the recursion teardown removes, and keeping it was measured to matter.
  • The rebuild exclusion accumulates across generations. Taken from the previous teardown alone, a second re-set with no read in between re-registered static edges for generated paths that were never materialized.

$watch now reaches its handler after a re-set in a shape where the write used to throw before getting there.

The generation contract, as measured

Seven $ declaration validators run in the setter. Four are pure validation and run before the generation advances, so a throw there leaves the element wholly in the previous generation. Three need what the setter installs and run after it, so a throw there leaves the new state in place with the generation already advanced. What each one leaves behind differs, and rather than describe it in prose the tests pin it: one test per validator, plus three that assert the ledger state after the throw and whether a later whole-list write succeeds.

That split is the result of three rounds of getting it wrong. Round 1 wrote the rule as universal and pinned one validator. Round 2 corrected that, said there were six validators and that the late ones landed alike; there are seven, and they do not. Round 3 removed the prose and let the tests be the enumeration.

Still open, pinned as known holes

  • A re-set does not re-apply established bindings. Reads move to the new generation; scalars, wildcard-free getters and for rows keep the first generation's text. This is the author-visible half of X7.
  • A re-set on a <wcs-state mount="…"> volume is a silent no-op for the page. The graft is a one-time copy, so the root tree and the DOM stay on the first generation with no error and no warning.
  • X6, hydration, is untouched here.

Both open symptoms are pinned with DEFECT: comments in integration.stateGenerationReset.test.ts and will be split into their own issues.

Verification

Every behavioural claim was measured on this branch and on main with the same probe, and the numbers are in the commit messages and the test titles.

Gate Result
npm run test:coverage 286 files / 3302 tests, exit 0
Coverage 99.57 statements / 98.62 branches / 100 functions / 99.75 lines, thresholds 99.5 / 98.5 / 100 / 99.5
npm run lint clean
npx tsc --noEmit clean

The known-defect pin for 欠陥8 now asserts the fix rather than the stale read.

🤖 Generated with Claude Code

mogera551 and others added 3 commits September 12, 2026 12:58
Part of #258. Re-setting a state object left two mechanisms pointing at
the previous generation.

- Cache entries carry the generation they were written in, and a read
  only hits an entry from the current generation. A wildcard-free
  getter's absolute address is the same object across generations, so
  the old value used to come back with dirty:false. The generation
  advances after the previous generation is torn down and before the
  new state object is installed.
- The path registrations of live bindings are re-derived after the
  setter's clears, so listPaths, elementPaths and the static chains are
  rebuilt for the new generation. Clearing pathSet stays: it is the
  self-heal that restores the static edges the recursion teardown
  removes. On main a whole-list write after a re-set threw while the
  DOM moved on regardless.

The known-defect pin for 欠陥8 now asserts the fix. Two symptoms stay
open and are pinned as known holes: a re-set does not re-apply any
binding, so reads move to the new generation while the DOM does not,
and a re-set on a mount volume is still a silent no-op.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Round 2 of #258. Round 1 wrote "a re-set that throws leaves the element
in the previous generation" as a general rule and pinned one validator
out of several; measurement showed some validators behave the other
way.

- $listKeys reads nothing but the incoming value, so it moved ahead of
  the generation bump. $streams and $watch measurably need what the
  setter installs and cannot move; that is now stated as what it is,
  with a test per validator instead of one test and a general claim.
- The rebuild exclusion accumulates across generations. It was taken
  from the previous teardown alone, so a second re-set with no read in
  between re-registered static edges for generated paths that were
  never materialized.
- The volume re-set is pinned as a known hole rather than fixed, and
  the unmeasurable claim about re-diagnosing the second generation is
  replaced by the measured reason, itself pinned by a test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Round 3 of #258, plus the two sentences its review found unpinned.

- $on is a seventh declaration validator, validated after the
  generation advances; it now has tests like the other six, and the
  counts in the setter comment match the code.
- The prose describing what a late validator leaves behind was wrong
  for $streams and is gone. Three tests pin the ledger state after a
  throw from each distinct position, and whether a later whole-list
  write succeeds.
- The $watch gain and the $listKeys move are pinned rather than only
  described, and the volume known-hole comment no longer argues about
  a design that was never built.
- The path-registration ledger's note no longer claims data and display
  diverge without it; measurement shows both follow while the write
  keeps throwing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant