Skip to content

test(e2e): stop the elision-off block depending on a live CDN - #1229

Draft
vivek7405 wants to merge 10 commits into
mainfrom
fix/elision-off-boot-404
Draft

test(e2e): stop the elision-off block depending on a live CDN#1229
vivek7405 wants to merge 10 commits into
mainfrom
fix/elision-off-boot-404

Conversation

@vivek7405

@vivek7405 vivek7405 commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator

Closes #1228

Summary

The differential elision (#181) e2e block redded on and off from 2026-08-02 with OFF page never upgraded <my-counter>, which reads as an elision defect and is not one. The elision-OFF build's hydration depends on the public internet twice and the ON build does not depend on it at all, so any jspm hiccup takes down every component on the OFF page and gets reported as elision dropping a module.

Elision ON drops components/vendor-badge.ts, the blog's only vendor consumer, so scanBareImports finds nothing, api.jspm.io is never called, dayjs never enters the importmap, and the browser never contacts a third party. That is the #170 property and another test already asserts it. Elision OFF ships that component, so the same page picks up a blocking api.jspm.io/generate POST on the server's cold first request and a https://ga.jspm.io/... module fetch inside app/page.ts's graph in the browser. An ES module graph instantiates as a unit, so a failure at either point means app/page.ts never evaluates and nothing it imports registers. #components/counter.ts is imported on line 2 and fetches successfully every time, yet customElements.define never runs for it, which is exactly why the symptom pointed at elision.

The OFF server now boots with test/e2e/fixtures/stub-jspm.mjs preloaded. It answers the api.jspm.io/generate call from this repo's node_modules and points dayjs at a data: URL carrying those bytes, which closes both holes at once because the URL the browser fetches is whatever that map says. The ON server is untouched, since it resolves nothing. The stub serves only the packages in its LOCAL_VENDORS map and passes anything else through to the real API, so a vendor added later is never silently faked.

The hydration failure message also stopped guessing. define never running means the module graph did not evaluate, so it now says that and lists the off-origin and failed resources on the page instead of naming elision as the likely cause.

Measured asymmetry, same app, back to back:

ELIDE=1 (ON)   vendor entries = []
               analysis warm in 230ms (... vendor 8)
               cross-origin preloads: []

ELIDE=0 (OFF)  vendor entries = [["dayjs","https://ga.jspm.io/npm:dayjs@1.11.21/dayjs.min.js"]]
               analysis warm in 837ms (... vendor 691)
               cross-origin preloads: ["https://ga.jspm.io/npm:dayjs@1.11.21/dayjs.min.js"]

Full evidence, including both failure halves reproduced on demand and the false lead that a bare curl of the CDN url returns 404, is in the first comment on this PR. The .server.ts design call the issue asked for is recorded in the second.

Deliberately not done

  • No framework change. Nothing in packages/ is wrong here. A vendor package fetched over the internet is a single point of failure for a page's whole hydration, which is inherent to importmaps without a bundler, and the framework already ships the production answer (webjs vendor pin).
  • No vendor pin in the blog. In-repo apps resolve vendors live on purpose, and a committed .webjs/vendor/importmap.json broke the Add e2e network probes for vendor-never-fetched + inert-route zero-JS elision #170 elision e2e once already.
  • WEBJS_ELIDE=0 still ships the page's throw-at-load .server.ts imports. Reasoning in the second comment.

Test plan

  • differential elision (#181) green over 25 consecutive runs
  • Counterfactual, dated to 9adc972f: with the stub wiring removed and the jspm API failing, the block reds with the exact CI message, and the third test still passes, matching CI's failure fingerprint precisely. With the stub wired, the same outage is a no-op.
  • Counterfactual, dated to ea37bb96: making localImportsFor answer a partial map instead of refusing reds two of the unit tests.
  • Unit: test/repo-health/e2e-vendor-stub{,-module}.test.mjs, six tests, covering the install parsing, the refusal, the pass-through, and that the emitted data: URL really is a module that formats a date.
  • Bun parity: WEBJS_E2E_RUNTIME=bun runs the block green, bun --preload was verified to load the stub into the server process, and the Bun matrix passes over test/repo-health with the one node-only file skipped by DENYLIST. Bun honours neither --import nor NODE_OPTIONS, so a Node-only spelling would have skipped the Bun e2e job without failing it.
  • Full e2e suite (Node): the elision block passes. Five failures remain in E2E: form actions, which reproduce identically on a clean checkout of main at this same commit (/feedback/triage 500s there too) and pass in CI, so they are a local environment problem on this machine, not a regression from this branch.
  • npm test: seven failures, all of which also fail on a clean checkout of main at this commit, and all of which pass in CI's Unit + integration job.
  • Browser layer: N/A, no client-facing code changed
  • Dogfood four-app boot check: N/A, nothing under packages/ changed
  • Docs: framework-dev.md updated. No other surface, since no user-facing behaviour changed

@vivek7405 vivek7405 self-assigned this Aug 3, 2026
@vivek7405

Copy link
Copy Markdown
Collaborator Author

Mechanism: the elision-OFF page hydrates only if the public internet answers, and the ON page never asks

The title's framing is wrong and the issue said not to trust it, so here is what the evidence actually shows. There is no server bug and no gate race. The OFF build makes two network calls to jspm that the ON build does not make at all, and either one failing kills hydration for the whole page, not just for the component that needed it.

Measured on both builds, same app, same machine, back to back:

ELIDE=1 (ON)   vendor entries = []
               analysis warm in 230ms (... vendor 8)
               cross-origin preloads: []

ELIDE=0 (OFF)  vendor entries = [["dayjs","https://ga.jspm.io/npm:dayjs@1.11.21/dayjs.min.js"]]
               analysis warm in 837ms (... vendor 691)
               cross-origin preloads: ["https://ga.jspm.io/npm:dayjs@1.11.21/dayjs.min.js"]

components/vendor-badge.ts is the blog's only vendor consumer and it imports dayjs. ON elides it, so scanBareImports finds nothing, no api.jspm.io call is made, dayjs never enters the importmap, and the browser never contacts a third party. That is the #170 property, and the e2e already asserts it. OFF ships vendor-badge, so the same page acquires two internet dependencies:

  1. Server side, on the cold first request. ensureReady blocks on a live api.jspm.io/generate POST (the 691ms above, bounded by a 10s timeout). A failure or timeout returns an empty map, so the first response's importmap carries no dayjs.
  2. Browser side, inside the page's module graph. https://ga.jspm.io/npm:dayjs@1.11.21/dayjs.min.js is fetched as part of app/page.ts's graph, under an SRI hash, and is also emitted as a cross-origin modulepreload.

ES module instantiation is all or nothing over the whole graph, so a failure at either point means app/page.ts never evaluates, and nothing it imports evaluates either. #components/counter.ts is imported on line 2 and is fetched successfully every time, but customElements.define never runs for it. That is the reported state exactly: define did not run, host present, instance not upgraded.

Both halves reproduce on demand.

Failing the browser fetch (page.setRequestInterception aborting jspm):

ELIDE=0: upgraded=false  bad=[FAILED net::ERR_FAILED https://ga.jspm.io/npm:dayjs@1.11.21/dayjs.min.js]
         console: Failed to load resource: net::ERR_FAILED
ELIDE=1: upgraded=true   bad=[]

Failing the server's resolve (a --import shim rejecting api.jspm.io):

ELIDE=0: upgraded=false, every same-origin module 200/304, no 404 anywhere
         pageerror: Failed to resolve module specifier "dayjs"
ELIDE=1: upgraded=true

Note the second one produces the reported symptom with no 404 at all, which is worth knowing: an OFF run can die this way and leave nothing in the network tab to find. The 404 in the original report is the first mode.

This accounts for every property the issue lists. It is OFF-only, because ON makes neither call. It is independent of CPU load, because it is a network dependency rather than a race. It flips between runs minutes apart on one idle machine, because CDN edges and API endpoints do that. And it is not CI-specific.

Falsified along the way, so nobody re-chases it. curl https://ga.jspm.io/npm:dayjs@1.11.21/dayjs.min.js returns 404 right now, reproducibly, which looks like the smoking gun and is not. Adding Accept-Encoding: gzip, deflate, br returns 200, so the edge is 404ing only the identity-encoding variant, and every browser sends an encoding header. 200 fetches out of 200 with browser-shaped headers came back clean. So the CDN is healthy from here; that curl result is an artifact.

Local runs where the failure does NOT appear, for the record: 25 warm loads, 15 cold-server loads, 8 two-server runs shaped like the e2e block, 20 runs of the block pinned to two cores, and two full e2e suites (one pinned to two cores). The differential block passed in all of them. Nothing about CPU budget or server lifecycle moves this, which is consistent with the cause being off-box.

… CDN

The differential elision block redded on and off from 2026-08-02 with
"OFF page never upgraded <my-counter>", which read as an elision defect
and was not one. Elision ON drops components/vendor-badge.ts, the blog's
only vendor consumer, so the ON server never calls api.jspm.io and the ON
page never fetches from a CDN. Elision OFF ships that component, which
adds two internet dependencies the ON page does not have: a blocking
api.jspm.io/generate POST on the server's cold first request, and a
ga.jspm.io module fetch inside app/page.ts's graph in the browser.

A module graph instantiates as a unit, so a failure at either point means
app/page.ts never evaluates and nothing it imports registers, including
the counter these tests drive. The counter's own module fetches fine
every time, which is why the symptom pointed at elision.

Both halves reproduced on demand by failing each call in turn. Stubbing
the API call closes both, since the URL the browser fetches is whatever
that map says; pointing dayjs at a data: URL carrying this repo's own
copy leaves nothing for the network to break. The stub serves only the
packages it lists and passes anything else through to the real API, so a
vendor added later is never silently faked.

The hydration failure message now names the off-origin and failed
resources on the page, since "define never ran" means the graph did not
evaluate, not that the component's own module was missing.
A vendor added to the blog later needs a LOCAL_VENDORS entry or the block
silently goes back to depending on jspm, and the preload flag differs per
runtime, so a Node-only spelling would skip the Bun e2e job without
failing it. Neither is discoverable from the diff.
@vivek7405

Copy link
Copy Markdown
Collaborator Author

Decision: WEBJS_ELIDE=0 keeps shipping the page's throw-at-load .server.ts imports

The issue asked for a call on this, so here it is. examples/blog/app/page.ts imports two .server.ts modules with no 'use server', whose browser stubs throw at module load. With elision on the page is elided and never ships, so no-server-import-in-browser-module correctly stays quiet. With elision off the page ships and those stubs are served. Leaving that alone.

Two reasons.

The flag's whole job is to serve the un-elided build so the differential block has something to compare against. Teaching WEBJS_ELIDE=0 to strip server-only imports would make it a different transform rather than no transform, and the ON-vs-OFF comparison gets weaker the more the two builds converge. The same objection kills the stronger version of the idea (applying the import-only-page optimization regardless of the flag), which would make the OFF boot byte-identical to the ON boot and leave the test comparing nothing.

And the throw is harmless where it lands. ES modules evaluate post-order, so every one of app/page.ts's component imports has already run by the time the first .server.ts stub is reached on line 8. #components/counter.ts is on line 2. Registration is complete before anything throws, which is why this was never the cause of the inert counter and why the issue was right to flag it as ordering-dependent.

Worth being precise about what this does NOT say. The stub still throws, so app/page.ts's own body never completes under WEBJS_ELIDE=0. Nothing in the blog depends on that body running client-side (a page never hydrates), so it costs nothing here. An app that genuinely runs with elision disabled and expects its page module's top-level side effects to execute would notice, and the answer for that app is the one the framework already gives: put client-only behaviour in a component, and reach server-only code through a 'use server' action whose stub is a working RPC.

The first cut counted resolved entries against install count, which got
two cases wrong. An unparseable body parsed to zero installs and zero
imports, which compared equal, so it answered with an EMPTY map. That is
the silent death the fixture exists to prevent: an absent importmap entry
is an unresolved-bare-specifier error that takes down the whole page
graph. And two installs of one package (a bare install plus a subpath)
collapsed to one key and read as unserviceable, sending a request the
repo could have served to the network.

It now refuses explicitly, per install, and a subpath install counts as
unserviceable since it needs its own entry pointing at its own file.

The unit test also pins the load-bearing trick, that dayjs.min.js is UMD
and takes its global branch in module scope, by importing the emitted
data: URL and formatting a date through it. A map naming a module that
exports nothing would pass every structural check and still leave the
page unhydrated.
The fixture read node_modules/dayjs at the workspace root, which only
works while npm hoists it there. If a future install kept it under
examples/blog/node_modules the read would fail, the stub would pass
through, and the block would quietly go back to depending on jspm with
nothing failing to say so. It now resolves the package from the blog's
own package.json and reads whatever its main names.
Bun reads a data: URL specifier of this size as a filesystem path and
raises NameTooLong, so importing the stub's emitted module fails there
while every other assertion about it runs fine. That is a limitation of
importing a data: URL from Bun, not of the fixture, whose URL is imported
by Chromium and is exercised for real by the Bun e2e job.

Per the matrix's own rule for a file that mixes runtime-agnostic and
node-only tests, the node-only one moves to its own file and that file
goes on the DENYLIST with its reason and where Bun covers the behaviour.

@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.

Read the whole diff cold. The mechanism holds up and the fixture is the right shape: stubbing the API call rather than the CDN fetch is what makes it cover both halves, and refusing to answer a partial map is the detail that would otherwise reintroduce the exact silent failure being fixed. Splitting the node-only data: URL import onto the DENYLIST is the convention the matrix asks for, and passing the preload flag as argv rather than NODE_OPTIONS is what keeps the Bun e2e job honest.

Two things wrong, one of them outside the diff.

The PR title is fix(server): stop the elision-off boot from 404ing a module, and this PR touches zero files under packages/, so the server scope names a package it does not change. The subject also asserts a boot that 404s a module, which the body and the code comments now both refute. Six commits means the squash subject comes from the title, so that description lands on main permanently. It needs to be a test: change that says what it actually does.

The other is inline.

Comment thread test/e2e/fixtures/stub-jspm.mjs Outdated
@vivek7405 vivek7405 changed the title fix(server): stop the elision-off boot from 404ing a module test(e2e): stop the elision-off block depending on a live CDN Aug 3, 2026
The module-import assertion moved to e2e-vendor-stub-module.test.mjs when
it was split off the Bun matrix, but the fixture kept naming the file it
left, which now says in its own words that it does not carry it.
@vivek7405

Copy link
Copy Markdown
Collaborator Author

Title fix: the server scope named a package this PR does not touch

Retitled to test(e2e): stop the elision-off block depending on a live CDN. The old subject claimed a fix(server) for a boot that 404s a module, and this branch changes nothing under packages/ and the cause was never a 404. With six commits the squash subject comes from the title, so that would have landed on main as a permanent, false description of the change.

@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 the comment repoint. The cross-references line up everywhere now, but reading the guard that comment points at turned up a real problem in it, plus one more stale label of the same kind the repoint was fixing.

Both inline.

Comment thread test/repo-health/e2e-vendor-stub-module.test.mjs Outdated
Comment thread test/repo-health/e2e-vendor-stub.test.mjs Outdated
dayjs formats in local time and nothing pins TZ, so asserting an epoch
formatted to 'Jan 1, 2025' only held at or east of UTC: America/New_York
and America/Los_Angeles both produced 'Dec 31, 2024'. CI stayed green
because GitHub runners default to UTC, so this was an npm test failure
reserved for contributors in the Americas, in a test whose whole job is
to fail for exactly one reason. Parsing a date STRING round-trips through
local midnight in every zone, and the utc plugin is not an option since a
data: URL cannot resolve it.

Also relabels the empty-install case, which sends parseable JSON and
never reaches the guard around init.body, and adds the cases that do
reach it: no body, a non-string body, and malformed JSON. Each must pass
through, since answering any of them with an empty map is an importmap
missing every entry the page needs.

@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 the timezone fix. The fix itself holds, and the three new pass-through cases each reach the guard they name.

One thing it did not go far enough on, path-level because the line is unchanged and the API will not anchor there: test/e2e/e2e.test.mjs:1832 carries the same defect. assert.match(badgeText, /\b2026\b/, ...) reads a badge that examples/blog/components/vendor-badge.ts:25 formats from 2026-01-01T00:00:00.000Z, a UTC instant, and dayjs formats in local time, so west of UTC the badge SSRs as Dec 31, 2025 and the match fails. TZ=America/Los_Angeles reproduces it. Pre-existing rather than introduced by this PR, but it is the same green-on-UTC-runners-only profile, in a file this PR edits, and it defeats the point of making this block deterministic off CI.

vendor-badge formatted a UTC instant, and dayjs formats in local time, so
west of UTC the badge SSR'd as Dec 31, 2025 and the e2e assertion on its
year failed. Same green-on-UTC-runners-only profile as the vendor-stub
guard fixed a commit ago, reproduced with TZ=America/Los_Angeles.

A release date is a date, not an instant, so a plain date string is also
the more honest value: it parses as local midnight and formats to the
same day wherever the app is served from.
@vivek7405

Copy link
Copy Markdown
Collaborator Author

Scope call: fixed the pre-existing timezone bug rather than deferring it

test/e2e/e2e.test.mjs:1832 is not a line this PR changes, so deferring it would have been defensible. Fixing it here instead, for two reasons. It is the same defect this branch already fixed one commit earlier, so leaving the second instance means the next person rediscovers it from scratch. And the PR's whole claim is that this block stops being green-only-on-CI, which is not true while another assertion in it is green only on a UTC host.

Fixed at the source in 124530a4 rather than in the assertion: a release date is a date, not an instant, so the badge now reads the same wherever the app is served from. Verified under UTC, America/Los_Angeles, and Pacific/Midway, with the counterfactual (restoring the instant) redding it under Los Angeles.

@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 the badge fix. The fix is right and the year assertion now holds in every zone.

One leftover, path-level since the line is unchanged and the API will not anchor there: examples/blog/components/vendor-badge.ts:7 still says the component formats a fixed EPOCH, which is exactly what that commit stopped it doing, while the comment 18 lines below now says the opposite in as many words. The file contradicts itself about the one value the commit changed.

The docstring still described the value the previous commit replaced,
while the comment beside that value said the opposite.
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.

fix(server): elision-OFF boot intermittently 404s a module, leaving components inert

1 participant