feat: elide page/layout modules that only import components#609
Conversation
Pages and layouts never hydrate, yet the boot script imported the page module and every layout module on the client. For a typical route their only client relevance is being the import-graph root that pulls in the interactive components, so the browser fetched page.ts plus layout.ts purely to reach counter.ts. Add an import-only route-module class to analyzeElision: a page / layout whose own code does no client work and whose closure reaches ONLY shipping components is dropped from the boot and replaced by those component imports directly, so the browser fetches just the interactive leaves. The condition is a positive subset test (every client-effecting closure member is a component), so any client-effecting non-component (a self-executing helper, a client-router import, a reactive helper) keeps the whole module. Lazy components are excluded from the re-emit (IntersectionObserver-loaded, never eager), and the re-emit is the STATIC import closure so a conditionally-rendered component still registers. ssr.js splices the component URLs in place of the module; dev.js threads the new set into the elision fingerprint and the vendor scan exclusion. Progressive-enhancement-safe by construction: only the boot import set changes, the SSR'd HTML is unchanged (the differential elision guard stays green).
Server AGENTS invariant 7 gains the import-only class (the subset condition, the static lazy-excluded re-emit, the fingerprint / vendor-scan threading, and the error/loading/not-found exemption); the root execution-model note explains that a page/layout non-inert only for its component imports boots the leaves directly.
Render an import-only app with elision on and off and compare the document with the boot module-script and modulepreloads masked out, proving the transform only changes the JS set, never the rendered body (with a precondition that the two genuinely diverge in that set).
|
Decision: import-only does NOT exempt the no-server-import lint While wiring this up I hit the interaction with I deliberately did NOT. The lint is a correctness/security guard, and coupling it to the import-only verdict trades a harmless false-positive for a possible false-NEGATIVE: if the import-only classification were ever wrong for a module that genuinely ships, a real server-import crash would go unflagged. For a security-adjacent lint the conservative direction is to keep flagging. So the lint is unchanged: a page importing a server-only utility is still flagged (and the right fix, moving it behind |
The first cut excluded static lazy components from the import-only re-emit, on the theory that re-emitting them would defeat lazy loading. That under-shipped: a lazy component appears in the STATIC closure only when imported directly, and such an import already eager-loaded it via the page module before elision, so excluding it left the class undefined (observeLazy only covers RENDERED tags, so an imported-but-not-rendered lazy tag would silently never upgrade). Re-emitting it preserves the exact pre-elision behaviour; a normally-used lazy component is tag-referenced, never in the static closure, and still loads via observeLazy. Drop the lazy carve-out (and its set + regex) entirely.
vivek7405
left a comment
There was a problem hiding this comment.
The thing that needed the hardest look was the direction-of-safety: never drop a module the browser actually needs. The subset gate (every client-effecting closure member is a component) holds up, and a client-router import / reactive helper / self-executing relative import all correctly keep the whole module.
The lazy carve-out was wrong, though, and in the dangerous direction. I had excluded static lazy components from the re-emit to avoid defeating lazy loading, but a lazy component is only in the STATIC closure when a route imports it directly, and that import already eager-loaded it via the page module before any of this. Excluding it left the class undefined, and observeLazy only covers RENDERED tags, so an imported-but-not-rendered lazy tag would silently never upgrade after a later interaction. Removing the carve-out (re-emit it, same as the old eager behaviour) fixes the under-ship and drops the lazy-detection regex with its getter-form gap entirely. Fixed in bbafedc2. The transitive re-emit (page to helper to component), the boot dedup across page + layouts, and the on/off SSR-body equivalence all check out.
vivek7405
left a comment
There was a problem hiding this comment.
Went back over the re-emit equivalence (direct, transitive-via-helper, elided-component, and component-imports-component chains), the boot dedup against the preload set, and the fingerprint / vendor-scan threading. Nothing left to pick at. Good to merge.
routeFor() built its module-preload URLs from [page.file, ...layouts] without the inert filter or the import-only splice, so for an import-only route the 103 Early Hints preloaded the page/layout modules the body no longer imports (a wasted hint) and missed the component modules it does (a double-fetch on the real module). Mirror ssr.js's boot assembly exactly: drop inert modules and splice an import-only module's component imports in their place. This also drops the pre-existing inert-layout over-preload. Adapt the asset-hash fixtures to #605: the relative-import-versioning (#369) test isolates its mechanism with elision off (under elision the component, not the page, is the boot module), and the elision-flip test now asserts the verdict change is reflected in the boot SET (which rides the fresh HTML), since the page module is no longer a boot specifier whose ?v could go stale.
|
Gotcha: routeFor() is a SECOND boot-URL computation that must mirror ssr.js The boot module set is computed in TWO places: |
vivek7405
left a comment
There was a problem hiding this comment.
Checked the routeFor / ssr.js parity one more time (URL derivation, dedup, basePath-then-?v, the import-only splice) and swept for any other place that builds a boot/preload set from the route + layouts. The two happy-path sites agree; the error-page boot fallback never had the inert filter either, so it is unchanged and harmless. Nothing left.
Closes #605
Pages and layouts never hydrate, yet the boot script imported the page module
and every layout module on the client. For a typical route their only client
relevance is being the import-graph root that pulls in the interactive
components, so the browser fetched
page.tspluslayout.tspurely to reachcounter.ts.This adds an import-only route-module class to
analyzeElision: a page orlayout whose own code does no client work and whose closure reaches ONLY
shipping components is dropped from the boot and replaced by those component
imports directly, so the browser fetches just the interactive leaves.
Design (the exact safe condition)
A route module is import-only iff (1) it is not itself client-effecting, and
(2) EVERY client-effecting member of its closure is a shipping component. That
positive subset test (not a hand-listed block list) is the safety crux: any
client-effecting non-component in the closure (a self-executing helper, a
client-routerimport, a reactive helper) keeps the whole module, becausedropping it would lose that side effect. Re-emit is the STATIC import closure
(so a conditionally-rendered component still registers), minus lazy components
(IntersectionObserver-loaded, never eager on the boot).
ssr.jssplices the component URLs in place of the module;dev.jsthreads thenew set into the elision fingerprint (a verdict flip busts
?v) and the vendorscan exclusion (an SSR-only page import is no longer vendored).
Test plan
route-elision.test.js): import-only page re-emits its component;conditionally-imported-not-rendered still re-emits (static); client-router
layout ships; a self-executing helper keeps the whole module; a lazy component
is excluded (lazy-only page is inert); a page importing only an elided
display-only component collapses to inert (dogfood: factory-form
extends WebComponent({...})defeats display-only elision #604 interaction).import-only-serve.test.js, realcreateRequestHandlerrender):an import-only page boots its component directly with the page AND inert layout
modules dropped; a client-router layout keeps its module.
the boot import set changes. (The blog's pages are import-only under this
change, so the differential corpus exercises the new path on a real app.)
page module reappears in the boot); restoring goes green.
component-registration tests); website
/200 (6 preloads), docs/302 then/docs/components200 (5 preloads), ui-website/200 (4 preloads); every bootentry-import and modulepreload resolves, none broken. The blog's pages ship
whole (they import
muted-text, a raw custom element that is a client-effectingnon-component), so the synthetic integration test carries the positive
import-only coverage while the blog differential confirms no breakage.
Docs / surfaces
packages/server/AGENTS.mdinvariant 7 + the root AGENTS execution-model note: updated with the import-only class.an internal boot-optimization with no public API / authoring-surface change
(the elision section already frames elision by interactivity signals).