Skip to content

feat: make SSR action seeding observable and assert determinism in dev - #1311

Draft
vivek7405 wants to merge 18 commits into
mainfrom
feat/seed-observability
Draft

feat: make SSR action seeding observable and assert determinism in dev#1311
vivek7405 wants to merge 18 commits into
mainfrom
feat/seed-observability

Conversation

@vivek7405

@vivek7405 vivek7405 commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator

Closes #1309

Summary

SSR action seeding (#472) is fail-open by construction, so its real production risk is not wrong data, it is a silent performance regression. A miss is indistinguishable from a hit from the outside, so a refactor that breaks seeding for an entire app produces no error, no warning, and no log line. The app just quietly re-issues one RPC per async component on every first load, which is exactly what the feature exists to remove.

This makes that observable in dev, and closes the one shape where a hit could disagree with the paint.

What changed

  • X-Webjs-Seed, dev only. off when seeding is disabled, html-cache on a Add a server HTML response cache with TTL and on-demand revalidation #241 cache hit, collected=<m>, emitted=<n> on a buffered render, collected=<m>, emitted=0, streamed on a streamed one. off stays distinct from collected=0 so a seeding-disabled app never looks like a seeding-broken one, and emitted diverges from collected exactly when the serializer threw and dropped the whole block, which was previously invisible.
  • Folded into the existing access log as a seed field. No new log line.
  • One browser warning per page view, only on a defect, naming which of the three causes applies (streamed page, no seeds at all, keys unmatched). Silent when every call hit, because a healthy line every navigation trains developers to filter the channel out.
  • The client dev gate is a server-stamped data-webjs-dev marker, never process.env.NODE_ENV. esbuild folds that comparison to a constant in the built core bundle and publicEnvShim defines window.process.env on every page, so a NODE_ENV guard there is not merely dropped, it silently inverts. Rationale in a comment on this PR.
  • ingest flips to last-write-wins. First-write-wins protected a case that cannot occur (a hit deletes its key); what it actually did was prefer an unconsumed seed from a render that is no longer on screen, reachable on a soft nav, a background revalidation, and a back/forward restore.
  • A dev determinism assertion in recordSeed, compared on the full hash/fn/argsKey so a legitimate second call with different args cannot false-fire, deduped per action function, and in its own try/catch so a diagnostic failure can never drop a seed.
  • seedStats() exported from @webjsdev/core and declared in the .d.ts overlay.

Deliberately excluded, per the issue: no webjs doctor check (nothing static to report), no new webjs.* config key, no per-element data-webjs-seed emission or seeding of streamed regions, and no change to the key format, the wire, the facade, or the seed default.

Test plan

Layer Result
Unit (server) packages/server/test/seed/* green, including the new seed-observability.test.js
Unit (client) packages/core/test/seed/action-seed-client.test.js green
Full Node suite 3957 pass, 0 fail
Browser packages/core/test/seed/browser/action-seed-client.test.js, 6 tests green on Chromium, Firefox, and WebKit; full suite 0 failures on all three
e2e new test/e2e/dev-seed-observability.test.mjs (3 tests) plus the existing e2e.test.mjs
Bun matrix node scripts/run-bun-tests.js: 293 pass, 27 documented node-only skips, 0 genuine failures. test/bun/seed.mjs green under both node and bun
Conventions webjs check and webjs doctor clean on website and examples/blog
Dogfood website boots 200 in dist mode on /, /docs/{configuration,data-fetching,server-actions}, /ui, /ui/button; blog boots 200 on /; no broken modulepreloads; no X-Webjs-Seed header and no data-webjs-dev marker on any prod response

Counterfactuals run (each toggled at b8923e11, confirmed red, then restored):

  1. ingest back to first-write-wins reds both last-write-wins client tests.
  2. Keying the determinism check on hash/fn instead of the full key reds the different-args test.
  3. Removing the inner try/catch around the assertion reds the fail-open test.
  4. Dropping the opts.dev guard on the header reds the prod-leak test.

Docs

Surface Change
.agents/skills/webjs/references/data-and-actions.md New "SSR action seeding, and how to tell it is working" section. Seeding was documented nowhere in the skill, which matters most: the reader who has to recognise a broken seed from a console line is an agent building an app. Covers the correctness boundary, the determinism rule as an authoring constraint, a table for each header value, the three console causes with the fix for each, the streamed-page exception, and the kill switch
AGENTS.md Corrected the seeding sentences: the real correctness boundary in place of the bare fail-open clause, plus the dev observability
packages/server/AGENTS.md, packages/core/AGENTS.md Module-map rows for the changed signatures, the determinism assertion, the marker, and last-write-wins
website/app/docs/configuration/page.ts New "SSR action seeding" section documenting webjs.seed / WEBJS_SEED (absent from the page before) and the dev diagnostics
website/app/docs/data-fetching/page.ts, website/app/docs/server-actions/page.ts Corrected the closing correctness claim and added the dev observability
Scaffold, MCP, editor plugins N/A: no generated code changes, no introspection-tool projection changes, and nothing an editor highlights or resolves. seedStats is exempted in gallery-coverage.json as a dev diagnostic rather than something an app writes code against
blog/ssr-action-seeding-no-refetch.md N/A by design: its "no path where a stale or mismatched seed gets served" line was already inaccurate at HEAD for the cross-render carry-over case, and the last-write-wins flip makes it true, so the post stops being stale rather than becoming stale

A seed miss is indistinguishable from a hit from the outside, so a refactor
that breaks seeding for a whole app produces no error, no warning, and no log
line. The app just quietly re-issues one RPC per async component on every
first load, which is exactly what the feature exists to remove.

Dev now reports it. `X-Webjs-Seed` carries `off` / `html-cache` /
`collected=<m>, emitted=<n>` / `... streamed`, folded into the existing access
log line as a `seed` field. The browser logs one warning per page view when a
hydration action call missed, naming which of the three causes applies. That
gate comes from a server-stamped `data-webjs-dev` marker, never from
`process.env.NODE_ENV`, which esbuild folds to a constant in the built core
bundle and `publicEnvShim` then inverts.

`ingest` also flips to last-write-wins. First-write-wins protected a case that
cannot occur (a hit deletes its key), and its real effect was to hand a
component a value from a render no longer on screen after a soft nav.

Refs #1309
@vivek7405 vivek7405 self-assigned this Aug 6, 2026
Unit coverage at both ends: the server's three header shapes plus prod
silence, and the client's marker-gated report with its defect-only rule.
Each carries the counterfactual the issue named: comparing on the full
key rather than hash/fn, prod byte-identity for buildSeedScript, the
prod-silence case for the client gate, and fault injection proving a
throwing console.warn still records the seed.
The node unit file drives a hand-rolled fake DOM, which cannot stand in
for the contract the client depends on: that the selector matches the
block the server emits, that removal detaches, and that the real
requestIdleCallback fires the report once per scan batch.
The headline criterion is a browser one: a developer whose seeding broke
must see it without opening the network tab. The fixture's miss page
reaches its miss honestly, through a connectedCallback that SSR never
runs, so the id the client asks for is one the server render never used.
The skill documented seeding nowhere, which matters most: an agent
building an app is the reader who has to recognise a broken seed from a
console line. The new reference section is written for that reader, with
the three causes and the fix for each, and the determinism rule stated
as an authoring constraint rather than an aside.
@vivek7405

Copy link
Copy Markdown
Collaborator Author

Design rationale: why the client dev gate is a server-stamped marker, not process.env.NODE_ENV

This was the sharpest constraint in the whole change and the answer is not the obvious one, so it is worth writing down before someone "simplifies" it back.

scripts/build-framework-dist.js runs esbuild with platform: 'browser', minify: true, and no define, and esbuild then substitutes process.env.NODE_ENV with the literal "production". You can see the result in the shipped bundle: the client router's fallback warning compiled down to typeof process<"u"&&process.env||cn(...). The third conjunct of typeof process !== 'undefined' && process.env && process.env.NODE_ENV === 'production' folded to a constant, so the guard now reads "does a process.env object exist". And publicEnvShim defines window.process.env on every page in both modes, so that guard is always true in a real app and the warning never fires either way.

So a NODE_ENV gate in this bundle is not merely dropped, it silently inverts. That is why the dev signal has to come from the server, on the page: buildSeedScript stamps data-webjs-dev on the block in dev, and scanSeeds reads it. It also explains the odd-looking decision to emit an EMPTY block in dev on a page that seeded nothing. Without it the client has no marker to report against and cannot distinguish "seeding is off" from "seeding produced nothing", which is exactly the diagnosis a developer needs.

Consequence worth noting: dev and prod HTML now differ by that empty block, so content-hash.test.js's dev/prod parity check normalizes it away, the same way it already normalizes the dev reload script and the env shim.

Why the counters are not gated at all. WebJs is no-build on the server, so there is no dead-code elimination there, and the one build that does exist cannot express a dev gate for the reason above. Rather than pretend, the cost is stated: four integer increments on a path that already awaits stringify(args) and usually a fetch. Only the REPORTING is gated.

@vivek7405 vivek7405 left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The header, the access-log field, and the determinism assertion all hold up, and the data-webjs-dev marker is the right answer to the no-build dev-gate problem. The part I would keep an eye on is the client reporter, because its whole value rests on never crying wolf, and two things there let it do exactly that.

The measurement window is the bigger one. The design argues, correctly, that a miss after hydration is CORRECT behaviour and must not be reported. The code says so in a comment and the docs say so in prose, but the counters do not: the snapshot is taken when a report ENDS rather than when its window STARTS, so every legitimate post-hydration refetch is banked and charged to the next page you navigate to. Use an app normally, click a link, get a warning about page B for page A's refetches. That is precisely the channel-poisoning the defect-only rule exists to prevent, and it turns the feature against itself.

The second is narrower but has the same shape: the marker is sticky and the schedule gate reads it rather than asking whether THIS scan found one, so a back/forward restore (whose snapshot carries no block, because the first scan already removed it) inherits the previous page's marker and reports a cause that is not just unhelpful but actively wrong advice.

Also two wiring gaps: the access-log field list is enumerated in two places that were not updated, and the new e2e is not in ci.yml or test:e2e, so the assertion the PR calls its headline criterion never runs again after merge.

Comment thread packages/core/src/action-seed-client.js Outdated
Comment thread packages/core/src/action-seed-client.js Outdated
Comment thread packages/server/src/dev.js
Comment thread test/e2e/dev-seed-observability.test.mjs
… last report

The window is snapshotted when the report is SCHEDULED rather than when
it runs, so a post-hydration miss (correct behaviour, since the seed is
consume-once) no longer banks up and gets charged to the next page on the
next soft navigation. Scheduling is also gated on THIS scan having found a
marker rather than on one ever having been seen, so a back/forward
restore, whose snapshot carries no seed block, cannot report a cause read
off the previous page. An epoch token retires a callback whose state was
reset under it.

Wires the dev-seed e2e into ci.yml and test:e2e, and brings the two
surfaces enumerating the access-log fields in line with the new seed
field.
…ndow

Two ways the report could name a cause its own numbers contradict.

The merged count read stats.ingested, which counts only NEW keys, so a
scan whose seeds all REPLACE unconsumed ones measured as zero. Revisiting
a page whose seeding component elided is exactly that shape, and it
printed "1 seed(s) on this page ... the page carried no seeds at all".

The marker was still a module global, so a second scan landing inside a
pending window overwrote it without scheduling anything, and the report
named a cause read off a page the window never measured. It is part of
the window snapshot now, and the global is gone.

@vivek7405 vivek7405 left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Delta pass over 08c2b073. The window fix itself holds, but moving the snapshot to schedule time exposed two more places where the report can name a cause its own numbers contradict, and both are worth the round.

The first is a counting error I introduced with last-write-wins and then depended on: the merged count reads stats.ingested, which only counts NEW keys, so a scan whose seeds all REPLACE unconsumed ones measures as zero. That is not a corner case, it is precisely the shape last-write-wins was added for, and the line it produces contradicts itself inside one sentence.

The second is the same wrong-cause class the last commit fixed for the back/forward path, left in place for the marker: the counters became part of the window but the marker stayed a module global, so a scan landing inside a pending window overwrites it without scheduling anything.

Both are fixed and the module global is gone, which kills the class rather than the two instances.

Comment thread packages/core/src/action-seed-client.js
Comment thread packages/core/src/action-seed-client.js Outdated
The last two commits fixed this defect class one field at a time: the
counters, then the marker. The mechanism was the model itself. A scan
landing inside a pending window was dropped, so it got no report of its
own, and its seeds and calls still landed in the earlier window's numbers.
A healthy page collected the next page's correct misses and printed a
defect line; a page with no seeds inherited the next page's and lost the
branch that says so.

Each scan now closes the previous window and opens its own. The close runs
BEFORE the ingest loops, since the moment this page's content arrives is
where the previous page's numbers stop. A markerless scan still closes,
because the navigation happened either way.

@vivek7405 vivek7405 left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Delta pass over 0647a4ba, and it caught the thing I should have caught myself: I had been fixing this defect class one FIELD at a time. First the counters moved into the window, then the marker, and the finding is that the counts half was still open through the same mechanism the marker half had just been closed through. Three rounds on the same family is the signal that the model was wrong rather than the fields.

It was. A scan landing inside a pending window was dropped entirely, so it got no report of its own, AND its seeds and calls still landed in the earlier window's numbers. Both directions are real: a page carrying no seeds inherits the next page's and loses the branch that exists to say so, and a HEALTHY page collects the next page's correct, expected misses and prints a defect line while the page that actually streamed gets no report at all.

Fixed by changing the model rather than patching another field: each scan closes the previous window and opens its own, and the close runs BEFORE the ingest loops, because the moment this page's content arrives is exactly where the previous page's numbers stop. Closing after the ingest was my first attempt and the new test caught it, which is a reasonable sign the test is measuring the right thing.

Comment thread packages/core/src/action-seed-client.js
…ng one

The last-write-wins flip opened a real stale-data hole, reproduced in a
browser: a hit returning a value from a page no longer on screen, which is
the exact hole this PR exists to close.

The initial scan is lazy, so a page whose async components all elided
never triggers it, and its seed block sits after the body content outside
every boundary range, so no swap removes it. On the next soft navigation
applySwap ingests the incoming page's seeds from a detached parse, then
the incoming component's first takeSeed finally fires the lazy scan over
the LIVE document and ingests the outgoing page's block on top. For a key
both renders share, last-write-wins then hands the component the older
value. First-write-wins happened to mask this.

Draining the live document first, in ingest order, is what makes
last-write-wins correct: outgoing values go in before incoming ones, so a
shared key ends up holding the render whose paint is on screen. Keeping
them rather than discarding them still answers an in-flight render from
the outgoing page.

@vivek7405 vivek7405 left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Delta pass over 91d6dc5c, and this one found a real correctness bug that the last-write-wins flip introduced, not a diagnostics problem. I reproduced it in Chromium, Firefox and WebKit before touching anything: a hit returning a value from a page that is no longer on screen, which is the exact hole this PR exists to close.

The chain is four things that are each individually fine. The initial scan is lazy, so a page whose async components all elided never triggers it. That page's seed block sits after the body content, outside every boundary range, so no swap removes it and it stays in the live DOM. applySwap scans a DETACHED parse, so it never marks the live document as scanned. Then the incoming component's first takeSeed finally runs the lazy scan over the LIVE document and ingests the outgoing page's block on top of the incoming one, and for a key both renders share, last-write-wins hands the component the older value. First-write-wins masked this, which is why it surfaced only now.

The fix is ordering rather than a new rule: drain the live document BEFORE ingesting the incoming page, so outgoing values go in first and a shared key ends up holding the render whose paint is on screen. Keeping them rather than discarding them still answers an in-flight async render() from the page being navigated away from, and costs nothing. The repro is now a browser test and goes red when the drain is removed.

Also corrected the comment whose back/forward premise was wrong in exactly the case that produces a leftover block, and a test comment describing the early-return the previous commit deleted.

Comment thread packages/core/src/action-seed-client.js Outdated
…ains a live subtree

Three gaps the last commit left. A <webjs-frame> self-load routes its
subtree through applySwap, and ssr.js returns that subtree before the seed
block is appended, so the parse carries no marker; suppressing the lazy
scan then left the whole page with no report at all, which is the silent
failure this feature exists to remove. The window now falls back to the
drained live page's own marker and merged count.

The drain is also scoped to a DETACHED root. scanSeeds is a public export,
and a live subtree passed to it would have been stripped wholesale before
it could be read.

Also corrects two comments that contradicted the code, one of which an
earlier edit had silently failed to apply, and brings the core module-map
row up to date with the ordering the last-write-wins argument rests on.

@vivek7405 vivek7405 left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Delta pass over abd2aab6. Five findings, all real, and the first is the one that matters: suppressing the lazy scan on ANY explicit-root scan meant a <webjs-frame> self-load, whose subtree ssr.js returns BEFORE the seed block is appended, opened no window and left the whole page it sits on with no report at all. Seeds kept working, only the diagnostic died, which is exactly the silent failure this PR exists to remove. The window now falls back to the drained live page's own marker.

Second: scanSeeds is a public export and the drain would have stripped a LIVE subtree passed to it before the caller could read it. Scoped to a detached root now.

Then two comments contradicting the code, and the core module-map row left stale across three commits.

The coverage point was the useful one, and chasing it turned up something worse than the gap itself. The e2e drives a real client-router soft navigation now, off a fixture page whose async component genuinely elides, with a sentinel proving the navigation was soft rather than a document load. Writing that test is how I found that the e2e exercises the BUILT core bundle rather than src, so the first version of it was not discriminating at all: it passed with the drain removed. Rebuilt, it goes red on that revert as it should. CI was never affected, since dist is gitignored and built fresh there.

Comment thread packages/core/src/action-seed-client.js Outdated
Comment thread packages/core/src/action-seed-client.js Outdated
@vivek7405

Copy link
Copy Markdown
Collaborator Author

Context: the e2e exercises the BUILT core bundle, not src, and that changes how to read a green run

Worth writing down because it cost me a false-green and it is not obvious from the test file.

test/e2e/dev-seed-observability.test.mjs spawns a real webjs dev, so the browser resolves @webjsdev/core through the importmap to packages/core/dist/webjs-core-browser.js. Editing packages/core/src/action-seed-client.js therefore changes NOTHING the e2e sees until node scripts/build-framework-dist.js runs. The node unit tests and the web-test-runner browser tests both import src directly, so they are unaffected; only the e2e has this property.

The consequence is sharp for a counterfactual. The first version of the soft-navigation test passed with the drain deliberately removed, which reads as "the test does not discriminate" and would normally mean rewriting the test. The test was fine. It was measuring a bundle built several commits earlier. Rebuilt, the same revert produces one leftover #__webjs-seeds block in the live DOM after the navigation and the assertion fails, which is what it should have done the first time.

CI is not affected: dist is gitignored and the Build job produces it fresh from the branch. This is a local-verification hazard only, but it is the kind that makes a local run look like evidence when it is not. Anyone re-checking this PR's client behaviour locally should rebuild the bundle first.

…ds are dropped

A <webjs-frame> swap goes through applySwap like a page navigation, and a
frame response cannot be told apart from a page that seeded nothing: ssr.js
returns the subtree before the seed block is appended, so it arrives with
no block, no marker, and no X-Webjs-Seed header. Both guesses failed in
turn. Treating it as a page killed the report for the whole surrounding
page; attributing the window to the live page instead made it print one of
three causes, none of which describes a frame response, so a confident
misdiagnosis replaced silence. The router now says which it is, and a
frame swap leaves every bit of page state alone.

The outgoing page's leftover block is now stripped WITHOUT being ingested.
Ingesting it ordered the values correctly but kept them forever: the block
went unconsumed because its components elided, so nothing will ever call
takeSeed for those keys and only a hit deletes one. That grew the store by
a page payload per navigation, in production, and padded the unconsumed
figure in the dev line with keys from pages already left.

@vivek7405 vivek7405 left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Delta pass over 7818d948, and it overturns the fix in that commit rather than refining it, which is the right call.

I had made a markerless swap fall back to the live page's marker so a frame swap would not kill the report. That trades silence for a CONFIDENT MISDIAGNOSIS: the reporter can only name three causes, none of them is "a frame response carries no seed block", and the frame response has no X-Webjs-Seed header to cross-check against either, so the developer is told to go check a 'use server' directive that is already correct. Worse than saying nothing, and squarely against the point of the feature.

The root problem is that a frame response is genuinely indistinguishable from a page that seeded nothing, because ssr.js returns the subtree before the seed block is appended. Both of my guesses failed, in opposite directions, so the router now says which it is and a frame swap leaves every bit of page state alone.

The unbounded-growth finding is also right and is mine: the outgoing block went unconsumed BECAUSE its components elided, so ingesting it kept keys nothing would ever consume, in production, growing by a page payload per navigation. Stripping without ingesting fixes it and simplifies the correctness argument, at the cost of an in-flight render from the outgoing page missing, which is a round-trip rather than wrong data.

This is the fifth delta round, which is the cap. These fixes are on the branch UNREVIEWED, so the PR stays a draft and I am not calling it ready.

Comment thread packages/core/src/action-seed-client.js
Comment thread packages/core/src/action-seed-client.js Outdated
…ly names a cause it can prove

Five findings from a whole-diff read, and the first reopens the hole this
PR exists to close, by a second route.

Stripping the outgoing block from the DOM was not enough. A page carrying
an elided component alongside a shipping one has its WHOLE block ingested
by the lazy scan the shipping one triggers, so the elided component's keys
sit in the store with no carrier left to strip and nothing that will ever
consume them. Navigate to a page that emits no seed for such a key and a
component calling the same action with the same arguments gets the
departed page's value over the fresh paint. A navigation now evicts the
store too.

The browser also warned as a defect for correct code. Every action call
routes through the seed lookup, including ones never SSR-invoked and never
seedable (a mutation, a Task autorun, a connectedCallback read), so on a
page that emitted no seeds a miss is not evidence of anything. It now
reports only the causes it can prove and leaves that case to the server
header, where collected=0 is unambiguous.

A serializer drop, the one failure the counts exist to expose, emitted no
dev marker, so the browser said nothing at all; it now names it.

Also declares the scanSeeds options argument in the .d.ts overlay, and
corrects five doc surfaces that stated the guarantee more strongly than
the code provided.

@vivek7405 vivek7405 left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Final read over the whole diff, and it found the stale-seed hole still OPEN by a second route, which is the finding that matters most on this PR.

Stripping the outgoing block from the DOM only covers a page nothing scanned. A page carrying an elided component ALONGSIDE a shipping one has its whole block ingested by the lazy scan the shipping one triggers, so the elided component's keys are in the store with no carrier left to strip and nothing that will ever consume them. Navigate to a page that emits no seed for such a key and a component calling the same action with the same arguments gets the departed page's value over the fresh paint. I reproduced it before fixing it. A navigation now evicts the store as well as the DOM, and four doc surfaces that stated the guarantee more strongly than the code provided are corrected rather than left to be technically-almost-true.

The second one I would call the most valuable, because it was invisible to every test: the browser warned as a DEFECT for correct code. Every action call routes through the seed lookup, including ones never SSR-invoked and never seedable (a mutation, a Task autorun, a connectedCallback read), so on a page that emitted no seeds a miss is not evidence of anything. It told such a developer to check a 'use server' directive that is already right, on every page view, with no way to silence it. That is precisely the false alarm the defect-only rule exists to prevent, so the client now reports only causes it can prove and leaves the ambiguous case to the server header, where collected=0 is unambiguous.

Also: a serializer drop, the one failure the counts exist to expose, emitted no dev marker and so printed nothing at all; the .d.ts had not caught up with the scanSeeds options argument; and the blog line claiming no stale path could ever be served is corrected.

All five fixed here, none filed.

Comment thread packages/core/src/action-seed-client.js
Comment thread packages/core/src/action-seed-client.js
Comment thread packages/server/src/action-seed.js Outdated
…st everywhere

The drop marker added in the previous commit is a truthy string, and the
header inferred emitted from exactly that, so in dev a serializer drop
reported collected=N, emitted=N. Zero seeds shipped and the header claimed
all of them did, on the one failure these counts exist to expose. It also
falsified the client's own warning text, which tells the developer the
response reports collected above emitted. The drop block is a named export
now and the emitter excludes it, with a header-level regression test; the
gap that let this through was that the drop was only ever tested through
buildSeedScript in isolation, never through the response.

The rest is sync. Narrowing the client to provable causes changed the
contract in five prose surfaces that still listed the old three causes and
still said silence means every call hit, and two comments plus a test
comment still argued from paths the store eviction forecloses.
…s tests

A frame request only gets a sliced subtree when the id was found and the
render did not stream. Otherwise ssr.js falls through and serves the WHOLE
page, seed block included, and the router dispatches webjs:frame-missing
and throws that response away. The carve-out ingested it, so a discarded
response's seeds entered the store, where last-write-wins hands them to a
component on the page still on screen. It discards now, which is right for
both shapes: an isolable frame carries nothing to begin with.

The drop cause, which the previous commit made load-bearing, had no test
at any layer; it now has a unit and a browser one. The header test's
payload assertion was vacuous (it checked the whole document for a string
that fixture never renders) and now asserts the block's own body is empty.

Also the sixth prose surface: server-actions still carried the pre-narrowing
contract.
Every claim about the frame path was written for the isolable case and
then reused for the fallthrough, where it does not hold. ssr.js slices a
bare subtree only when the id was found and the render did not stream, so
a frame response does not reliably arrive with no block: a streamed render
and a #241 cache hit both swap SUCCESSFULLY off a full page, and the
cached one carries real seeds. Only a missing id discards the response.

Discarding is still right for all of them, but for the reason that the
block describes the whole page rather than the region being swapped in,
not because there is nothing there. The comment, the module-map row, and
an e2e comment still arguing from the replaced ingest-ordering rationale
now say that.
The narrowing was half done. Dropping the no-seeds branch removed the
false alarm only where the page seeded nothing; the surviving branch
warned on ANY miss once the page had seeded something, and the argument
for why a miss proves nothing never depended on that. A page with one
seeded async component plus any mutation, Task autorun or
connectedCallback read hydrating alongside it warned on every view and
told a developer whose keys are fine to go audit their arguments.

A miss is provable in exactly one shape: the page seeded that same action
under DIFFERENT arguments. Then the action is demonstrably reachable and
seeded, and the call still asked for a key the page does not carry. Track
the hash/fn set the page seeded and report only that subset, plus the two
the server asserts outright (streamed, drop). A miss on an action never
seeded at all is left to the header, where collected states it plainly.
applySwap scanned as its first statement, but it can still throw the
response away much later: a background revalidation with no trustworthy
boundary plan returns 'discard' and the caller drops it. By then the scan
had cleared the visible page's own unconsumed seeds and ingested a render
that is never painted, keyed for the same actions and args that page uses,
since it is a revalidation of the same URL. The next async render() on the
page still on screen then hit on data disagreeing with the HTML.

The scan is a closure called at each of the three commit points (frame
swap, boundary swap, in-place full-body swap) and at neither discard
branch. It still precedes every graft, which is the ordering the seeds
need to be available on upgrade.

Also declares keyMisses in the .d.ts, the JSDoc and the skill reference,
corrects openWindow's annotation, and stops the warning line from stating
the total while reporting the provable subset.
Changing the numerator to the provable subset without changing the
predicate made the line understate real traffic: a page mixing one key
mismatch with one mutation sent two calls to the network and reported
one. A server-asserted cause explains every miss, so it keeps the total
and the round-trip claim; a key mismatch now reports its own count against
its own claim and never speaks for the misses it cannot account for.

Also corrects a comment claiming a frame response carries no X-Webjs-Seed
(true only for the isolable case, since the three fallthrough shapes carry
the surrounding page's header) and brings the core module-map row up to
the narrowed warning contract and the six-field seedStats.

@vivek7405 vivek7405 left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clean pass over the whole diff. Nothing found.

That is the first round in this cycle to come back with nothing, and it is worth saying what the cycle actually cost, because the shape of it is the useful record. Thirteen reviews, and the single defect class behind most of them was one sentence: a hit cannot show the user something different from the HTML they are already looking at. Four separate mechanisms reached that same failure, and each was only visible once the previous one was closed.

  1. The outgoing page's seed block still sitting in the DOM, ingested LAST by the lazy scan.
  2. What that page had ALREADY ingested, which stripping the DOM carrier does not touch.
  3. A frame request that fell through to a full-page response, whose block the frame carve-out was ingesting.
  4. A background revalidation discarded AFTER applySwap had already scanned it.

Every one of those was opened or exposed by the last-write-wins flip, which is a one-line change. That is the honest summary of this PR: the observability half was straightforward, and the correctness half took the rest.

The other recurring theme was the diagnostic accusing correct code. It warned on a page that seeded nothing, then on any miss once a page seeded something, and both were wrong for the same reason: every action call routes through the seed lookup, including mutations and client-only reads that could never have been seeded. It now reports only what it can prove, which is a miss on an action the page DID seed under other arguments, plus the two the server asserts outright.

All findings across the cycle were fixed on this branch. None was deferred, rejected, or filed elsewhere.

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.

feat: make SSR action seeding observable and assert determinism in dev

1 participant