You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
On the elision-OFF boot path (WEBJS_ELIDE=0), a component intermittently fails to register in the browser: customElements.define never runs for <my-counter>, so the SSR-rendered markup is present but inert. A module fetch 404s in the same page load.
This is what has been redding the differential elision (#181) e2e block on and off since 2026-08-02 (main runs for 235d0fb0, 4822e0d1, b349c9cc, plus 4 of 5 runs on the branch for #1220). Before #1220 it surfaced as the misleading assertion counter increments identically on vs off (on=4, off=3), which reads like a counting bug and is really "the OFF page never hydrated, so the click hit a button with no listener". With #1220's diagnostics it reports honestly:
OFF page never upgraded <my-counter> within 15s
(customElements.define DID NOT run, host present, instance NOT upgraded)
Reproduced locally, on an idle 24-core box, without any CPU load or artificial delay: load / from a dev server started with WEBJS_ELIDE=0 and read back customElements.get('my-counter'). It came back false with a console Failed to load resource: 404 on one run and true with no 404 on the next, same code, same machine, minutes apart. So it is a genuine intermittent, not CI-specific and not load-dependent.
The mechanism is NOT established. Do not treat the title's framing as settled; see the falsified hypotheses below.
Design / approach
No fix is proposed yet, because the cause is not known. The first deliverable is a RELIABLE reproduction: a way to make the 404 happen on demand, and identification of WHICH module 404s. Everything else follows from that.
The structural fact that makes this path special is worth starting from. The two builds boot completely differently:
ON (elision default) OFF (WEBJS_ELIDE=0)
import "/components/counter.ts" import "/app/page.ts"
import "/modules/chat/.../chat-box.ts" import "/app/layout.ts"
import "/components/theme-toggle.ts"
ON emits the interactive component LEAVES directly (the import-only-page optimization, #605). OFF ships the page and layout modules whole and reaches components transitively. So on OFF the counter registers only if app/page.ts loads and executes first, which is a longer chain with more modules (13 modulepreloads against ON's 6) and more ways to fail. Any 404 in that graph takes the whole thing down, including the registration.
A related question to settle while in here, independent of the 404: examples/blog/app/page.ts imports two server-only modules,
Both are .server.ts WITHOUT 'use server', so their browser stubs throw at module load (AGENTS.md invariant 1). With elision ON the page is elided and never ships, so webjs check's no-server-import-in-browser-module rule correctly does not flag it. With elision OFF the page ships and those stubs are served (HTTP 200, body containing a throw), so the page module's own body cannot complete in the browser. Whether that is the app's bug or the framework's is a design call: should WEBJS_ELIDE=0 ship a page whose server-only imports are guaranteed to throw, or should the elision-off path still strip them / still apply the import-only-page optimization? Note this is ordering-dependent and probably NOT the 404 cause on its own: ES modules evaluate imports in source order, and #components/counter.ts is imported on line 2 while the first .server.ts is on line 8, so the counter's register() should already have run before the throw.
Implementation notes (for the implementing agent)
Hypotheses already FALSIFIED. Do not redo these.
The 404 gate races the lazy import-graph derivation. It does not. packages/server/src/dev.js:1477 does await ensureReady() with the comment "Build all whole-app analysis on the first request (memoized), before any SSR, module serve, gate check, action dispatch, or middleware runs." The gate cannot be consulted against a half-built Set.
Two dev servers on one app dir fight over .webjs/routes.d.ts, and each write triggers the other's fs.watch rebuild, invalidating the graph. The e2e block does run two servers against examples/blog, and dev.js:706-727 does write that file, but it is written to a watcher-ignored path (see the comment at dev.js:720), so it triggers no rebuild.
A cold dev server's first request is too slow and the test's fixed sleep expired. Measured: cold first GET is about 1290ms ON and 1840ms OFF on an idle box, and goto(waitUntil: 'domcontentloaded') already waits for deferred module scripts anyway (verified by injecting a 4s delay on the counter module: goto took 4076ms and the element was upgraded when it returned). Timing is not the cause.
Where to look
packages/server/src/dev.js:2144-2158: the module-serve 404 gate, including the dev hint for "exists on disk but is not reachable from any browser-bound entry". If the 404 body carries that hint, the module was genuinely judged unservable and the question becomes why the graph lacks it; if it does NOT, the 404 came from somewhere else and that narrows it a lot.
packages/server/src/dev.js:832-890: ensureReady() and the two-stage warm (deterministic analysis, then best-effort vendor). Note the analysis "never re-runs unless a rebuild invalidates it", so understanding what CAN invalidate it mid-flight is the live thread.
packages/server/src/module-graph.js: the graph the gate consults.
packages/server/src/dev.js:2742 and the elision entry collection: how the browser-bound entry set is assembled, which differs between the ON and OFF paths.
How to reproduce what is known so far
Start the blog with WEBJS_ELIDE=0 via packages/cli/bin/webjs.js dev, drive / with puppeteer-core (CHROMIUM_PATH or /usr/bin/chromium), listen on page.on('response') for status >= 400 and on page.on('console'), then read back customElements.get('my-counter'). Repeat, since it does not fire every run. Capturing the 404'd URL is the single most valuable next data point and is currently missing: the one local reproduction logged the console error but not the URL.
Landmines
The blog example needs its DB before it will render at all: cd examples/blog && npm run db:migrate && npm run db:seed, else / is a 500 "no such table: posts" error page and any probe measures the error page instead of the app. A fresh git worktree has no db/dev.db (gitignored).
A fresh worktree also has no node_modules and no packages/core/dist. Symlinking the primary checkout's node_modules makes @webjsdev/* resolve to THAT checkout, so edits to packages/server/src/** in your worktree will NOT be loaded by a dev server you spawn. This silently invalidated a counterfactual during the fix(e2e): the differential-elision block reports its failure as an off-by-one #1220 work: an injected defect in component-elision.js had no effect and the test "passed", which looked like the guard failing. Check readlink -f node_modules/@webjsdev/server before trusting any framework-source experiment.
packages/server needs its own node_modules for ws@8; the root symlink alone gives ws@7 and about 50 spawn-based server tests fail with "does not provide an export named 'WebSocketServer'".
e2e is gated behind WEBJS_E2E=1 and is NOT part of npm test.
Invariants to respect
packages/ is plain .js with JSDoc; no .ts files there.
AGENTS.md invariant 1 (server-only code stays out of the browser) is exactly what the .server.ts question above is about.
AGENTS.md invariant 11 (prose punctuation, WebJs brand casing) for any prose added.
Tests + docs surfaces
Unit / integration in packages/server/test/** for whatever the gate fix turns out to be, with a counterfactual.
The e2e guard already exists at test/e2e/e2e.test.mjsdifferential elision (#181); it should go from intermittently red to reliably green, and its failure message is the diagnostic to read.
Bun parity: the dev server request path is runtime-sensitive (node:http vs Bun.serve), so a fix here needs test/bun/** coverage and the Bun matrix run.
Docs: only if the elision-off contract changes (for example if elision-off starts stripping server-only imports), in which case AGENTS.md and the skill's references/components.md describe the elision model.
Acceptance criteria
The 404'd URL is identified and recorded on the PR
The failure reproduces ON DEMAND, not opportunistically
The mechanism is stated with evidence, and any hypothesis listed above as falsified is not silently reintroduced
The elision-OFF boot path registers its components reliably
A decision is recorded on whether WEBJS_ELIDE=0 should ship a page carrying throw-at-load .server.ts imports
WEBJS_E2E=1 node --test test/e2e/e2e.test.mjs passes over 20+ consecutive runs
A counterfactual proves the new test coverage fails without the fix
Bun parity covered for any change to the dev server request path
Problem
On the elision-OFF boot path (
WEBJS_ELIDE=0), a component intermittently fails to register in the browser:customElements.definenever runs for<my-counter>, so the SSR-rendered markup is present but inert. A module fetch 404s in the same page load.This is what has been redding the
differential elision (#181)e2e block on and off since 2026-08-02 (mainruns for235d0fb0,4822e0d1,b349c9cc, plus 4 of 5 runs on the branch for #1220). Before #1220 it surfaced as the misleading assertioncounter increments identically on vs off (on=4, off=3), which reads like a counting bug and is really "the OFF page never hydrated, so the click hit a button with no listener". With #1220's diagnostics it reports honestly:Reproduced locally, on an idle 24-core box, without any CPU load or artificial delay: load
/from a dev server started withWEBJS_ELIDE=0and read backcustomElements.get('my-counter'). It came backfalsewith a consoleFailed to load resource: 404on one run andtruewith no 404 on the next, same code, same machine, minutes apart. So it is a genuine intermittent, not CI-specific and not load-dependent.The mechanism is NOT established. Do not treat the title's framing as settled; see the falsified hypotheses below.
Design / approach
No fix is proposed yet, because the cause is not known. The first deliverable is a RELIABLE reproduction: a way to make the 404 happen on demand, and identification of WHICH module 404s. Everything else follows from that.
The structural fact that makes this path special is worth starting from. The two builds boot completely differently:
ON emits the interactive component LEAVES directly (the import-only-page optimization, #605). OFF ships the page and layout modules whole and reaches components transitively. So on OFF the counter registers only if
app/page.tsloads and executes first, which is a longer chain with more modules (13 modulepreloads against ON's 6) and more ways to fail. Any 404 in that graph takes the whole thing down, including the registration.A related question to settle while in here, independent of the 404:
examples/blog/app/page.tsimports two server-only modules,Both are
.server.tsWITHOUT'use server', so their browser stubs throw at module load (AGENTS.md invariant 1). With elision ON the page is elided and never ships, sowebjs check'sno-server-import-in-browser-modulerule correctly does not flag it. With elision OFF the page ships and those stubs are served (HTTP 200, body containing athrow), so the page module's own body cannot complete in the browser. Whether that is the app's bug or the framework's is a design call: shouldWEBJS_ELIDE=0ship a page whose server-only imports are guaranteed to throw, or should the elision-off path still strip them / still apply the import-only-page optimization? Note this is ordering-dependent and probably NOT the 404 cause on its own: ES modules evaluate imports in source order, and#components/counter.tsis imported on line 2 while the first.server.tsis on line 8, so the counter'sregister()should already have run before the throw.Implementation notes (for the implementing agent)
Hypotheses already FALSIFIED. Do not redo these.
packages/server/src/dev.js:1477doesawait ensureReady()with the comment "Build all whole-app analysis on the first request (memoized), before any SSR, module serve, gate check, action dispatch, or middleware runs." The gate cannot be consulted against a half-built Set..webjs/routes.d.ts, and each write triggers the other'sfs.watchrebuild, invalidating the graph. The e2e block does run two servers againstexamples/blog, anddev.js:706-727does write that file, but it is written to a watcher-ignored path (see the comment atdev.js:720), so it triggers no rebuild.goto(waitUntil: 'domcontentloaded')already waits for deferred module scripts anyway (verified by injecting a 4s delay on the counter module:gototook 4076ms and the element was upgraded when it returned). Timing is not the cause.Where to look
packages/server/src/dev.js:2144-2158: the module-serve 404 gate, including the dev hint for "exists on disk but is not reachable from any browser-bound entry". If the 404 body carries that hint, the module was genuinely judged unservable and the question becomes why the graph lacks it; if it does NOT, the 404 came from somewhere else and that narrows it a lot.packages/server/src/dev.js:832-890:ensureReady()and the two-stage warm (deterministic analysis, then best-effort vendor). Note the analysis "never re-runs unless a rebuild invalidates it", so understanding what CAN invalidate it mid-flight is the live thread.packages/server/src/module-graph.js: the graph the gate consults.packages/server/src/dev.js:2742and the elision entry collection: how the browser-bound entry set is assembled, which differs between the ON and OFF paths.How to reproduce what is known so far
Start the blog with
WEBJS_ELIDE=0viapackages/cli/bin/webjs.js dev, drive/with puppeteer-core (CHROMIUM_PATHor/usr/bin/chromium), listen onpage.on('response')for status >= 400 and onpage.on('console'), then read backcustomElements.get('my-counter'). Repeat, since it does not fire every run. Capturing the 404'd URL is the single most valuable next data point and is currently missing: the one local reproduction logged the console error but not the URL.Landmines
cd examples/blog && npm run db:migrate && npm run db:seed, else/is a 500 "no such table: posts" error page and any probe measures the error page instead of the app. A fresh git worktree has nodb/dev.db(gitignored).node_modulesand nopackages/core/dist. Symlinking the primary checkout'snode_modulesmakes@webjsdev/*resolve to THAT checkout, so edits topackages/server/src/**in your worktree will NOT be loaded by a dev server you spawn. This silently invalidated a counterfactual during the fix(e2e): the differential-elision block reports its failure as an off-by-one #1220 work: an injected defect incomponent-elision.jshad no effect and the test "passed", which looked like the guard failing. Checkreadlink -f node_modules/@webjsdev/serverbefore trusting any framework-source experiment.packages/serverneeds its ownnode_modulesforws@8; the root symlink alone givesws@7and about 50 spawn-based server tests fail with "does not provide an export named 'WebSocketServer'".WEBJS_E2E=1and is NOT part ofnpm test.Invariants to respect
packages/is plain.jswith JSDoc; no.tsfiles there..server.tsquestion above is about.WebJsbrand casing) for any prose added.Tests + docs surfaces
packages/server/test/**for whatever the gate fix turns out to be, with a counterfactual.test/e2e/e2e.test.mjsdifferential elision (#181); it should go from intermittently red to reliably green, and its failure message is the diagnostic to read.Bun.serve), so a fix here needstest/bun/**coverage and the Bun matrix run.AGENTS.mdand the skill'sreferences/components.mddescribe the elision model.Acceptance criteria
WEBJS_ELIDE=0should ship a page carrying throw-at-load.server.tsimportsWEBJS_E2E=1 node --test test/e2e/e2e.test.mjspasses over 20+ consecutive runs