Skip to content

fix(state): surface an initialization failure instead of hanging silently - #264

Merged
mogera551 merged 5 commits into
mainfrom
fix/state-init-failure-diagnostics
Sep 12, 2026
Merged

mogera551 merged 5 commits into
mainfrom
fix/state-init-failure-diagnostics

Conversation

@mogera551

Copy link
Copy Markdown
Contributor

Closes #257.

The defect

connectedCallback had one unguarded await this._initialize(). A throw from anywhere inside initialization skipped every promise settlement, so connectedCallbackPromise and initializePromise stayed pending forever with no diagnostic. The author saw a page that never rendered, and renderToString() / mount() never returned. The re-set path threw synchronously, so the two paths disagreed about the same mistake.

The fix

Initialization failures now land: one console.error, the initialize and loading promises resolved, the connectedCallback promise marked handled and rejected with the original error object rather than a wrapper. initializePromise still resolves, so one element's mistake does not drag the rest of the page's bindings down through waitForStateInitialize's Promise.all.

The covered throw sites were derived from the code, not from a shape matrix: the seven $ declaration validators, the four source-loading paths, the SSR data merge, the DCC and bind-component setup raises, and the "one <wcs-state> per root" rule.

Three consequences land with it:

  • getBindingsReady(root) rejects for a root whose state element failed, instead of reporting "ready" for a page with no bindings.
  • A volume already waiting on a root that fails settles with its own report instead of waiting forever.
  • setInitialState() on an element that already failed raises a replace-this-element diagnostic instead of doing nothing.

Two things this PR deliberately does not do

A detach during load is not a failure. An intermediate round classified it as one, which put a diagnostic, a permanent rejection and element poisoning on pages with no author error at all. A pooled element (remove then append) stayed rejected forever even after it initialized and rendered, and @wcstack/server and @wcstack/testing await that promise. That classification was reversed: the connection ends quietly, as on main, and the next connect settles the promises.

The volume slot lifecycle is out of scope. A round added a slot release so a corrected root could adopt a volume again, and it produced two measured defects: releasing by the current mount attribute freed another live element's reservation, and a volume detached mid-load kept its slot with no path left to free it. The reservation has had no release path since before this work, so it is an ownership bug of its own. It is removed here and filed separately; the docs now state what is true, which is that an orphaned volume does not re-graft and the page has to be reloaded.

Behaviour changes worth knowing

  • Where a second root <wcs-state> used to kill the whole page, the first element's page now renders and only the duplicate is refused. That second element stays unregistered but keeps the state it loaded, so remove it. Moving a healthy element in the DOM is not a duplicate and is never refused.
  • When an element is re-appended before its source resolves, $connectedCallback and $streams each start exactly once for the live connection. main started $streams zero times in that shape.

Verification

Five rounds. Each round was reviewed by three adversarial agents with separate lenses, and the first four rounds each turned up a measured regression on pages with no author error — which is why the detach classification was reversed and the slot work was cut. The lens that found them was running the same probe against this branch and against main and comparing the numbers.

Gate Result
npm run test:coverage 285 files / 3269 tests, exit 0
Coverage 99.57 statements / 98.61 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 pins for 欠陥9 now assert the contract instead of the hang. The per-site behaviour is pinned in packages/state/__tests__/integration.initFailureDiagnostics.test.ts, which the README and CHANGELOG point at rather than restating.

🤖 Generated with Claude Code

mogera551 and others added 5 commits September 12, 2026 08:36
…ntly

A declaration or source error on the first mount used to leave
connectedCallbackPromise pending forever with no diagnostic (#257).
The one unguarded `await this._initialize()` now lands failures: one
console.error, initialize and loading promises resolved, the
connectedCallback promise marked handled and rejected with the
original error object.

Every throw site reachable from initialization was derived from the
code: the seven `$` declaration validators, the four source-loading
paths, the SSR data merge, and the "one <wcs-state> per root" raise.

Consequences that land with it:
- getBindingsReady on a failed root rejects instead of reporting ready.
- Volumes waiting on a failed root settle with one report each instead
  of waiting forever.
- setInitialState on a failed element raises a replace-this-element
  diagnostic instead of doing nothing.

The known-defect pins for 欠陥9 now assert the contract instead of the
hang. The re-set path is untouched and still throws synchronously.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Round 2 of #257, from three adversarial reviews of the first landing.

- Re-registering the SAME element on a root is idempotent, so a DOM
  move during load no longer rejects a healthy element.
- The failed-root marker lives only while the failed element is still
  attached, so a corrected root adopts volumes that connected in the
  recovery window.
- The pre-initialize awaits (bind-component, DCC) land the same way;
  fail-fast paths that already resolve their own promises pass through
  untouched.
- The rootNode-getter raise is enumerated and pinned.
- The duplicate-root message now tells the author the second element
  stays unregistered and must be removed.

Docs: the volume wording narrowed to what the code does, the stray
blank line under the 2.3.0 heading removed, and an SSR design-doc line
citation refreshed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Round 3 of #257. Round 2 classified an element detached mid-load as an
initialization failure, which put a diagnostic, a permanent rejection
and element poisoning on pages with no author error at all. A pooled
element (remove then append) stayed rejected forever even after it
initialized and rendered, and @wcstack/server and @wcstack/testing
await that promise.

- `_initialize` reports completion instead of dereferencing a root node
  that is gone; the connection ends quietly and the next connect
  settles the promises, matching main.
- The `$connectedCallback` call carries the generation guard that
  startWatch and startStreams already had, so a DOM move during load no
  longer runs the author's callback twice.
- Volume slot reservations are released when a volume is orphaned or
  when a settled, never-grafted volume leaves the DOM.
- The README and CHANGELOG now split the failure sites by what they do
  to connectedCallbackPromise rather than by feature name.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Round 4 of #257. Round 3 added a slot release so a corrected root could
adopt a volume again, and it produced two measured defects: releasing
by the current mount attribute freed another live element's
reservation, and a volume detached mid-load kept its slot with no path
left to free it. The reservation has had no release path since before
this work, so it is an ownership bug of its own rather than part of
this fix.

- The release and its call sites are removed; reserveVolumeSlot and
  the ledger are back to their previous behaviour.
- Round 2's orphan landing stays: volumes waiting on a root that fails
  settle with one report each.
- The docs no longer claim a slot is released, and say instead that an
  orphaned volume does not re-graft when a corrected root appears.
- The failure-site enumerations now include the silent mount fail-fast
  class and the fifth raw bind-component raise.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…guarantees

Rounds 5 and 6 of #257. Each round of prose about which failure site
rejects, resolves loudly, or resolves silently introduced a new false
claim: a slot release that does not happen, a volume source failure
called a rejection, a silence that is not silent, a companion report
that usually is not there.

The classification is now written at the grain that is robustly true.
A root element's initialization failure rejects and reports once. A
volume never rejects; some volume failures report nothing of their
own, and the error leaves as the discarded connectedCallback promise.
A detach during load rejects nothing. Readers who need the exact
behaviour of one site are pointed at the test that pins every case.

The SSR design note now names the function it actually quotes instead
of a line number that keeps drifting.

Documentation only: no source behaviour, no test assertion and no test
title changed, and the suite stays at 3269 tests.

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.

state: an invalid $-declaration on first mount hangs initialization silently (X5)

1 participant