Skip to content

feat: elide page/layout modules that only import components#609

Merged
vivek7405 merged 5 commits into
mainfrom
feat/import-only-route-elision
Jun 19, 2026
Merged

feat: elide page/layout modules that only import components#609
vivek7405 merged 5 commits into
mainfrom
feat/import-only-route-elision

Conversation

@vivek7405

@vivek7405 vivek7405 commented Jun 19, 2026

Copy link
Copy Markdown
Collaborator

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.ts plus layout.ts purely to reach
counter.ts.

This adds an import-only route-module class to analyzeElision: a page or
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.

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-router import, a reactive helper) keeps the whole module, because
dropping 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.js splices the component URLs in place of the module; dev.js threads the
new set into the elision fingerprint (a verdict flip busts ?v) and the vendor
scan exclusion (an SSR-only page import is no longer vendored).

Test plan

  • Unit (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).
  • Integration (import-only-serve.test.js, real createRequestHandler render):
    an import-only page boots its component directly with the page AND inert layout
    modules dropped; a client-router layout keeps its module.
  • Differential elision stays green: SSR output is byte-identical on vs off; only
    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.)
  • Counterfactual: disabling the import-only emit reds the integration tests (the
    page module reappears in the boot); restoring goes green.
  • Dogfood (four apps): blog e2e 89/89 on the seeded DB (incl. soft-nav and
    component-registration tests); website / 200 (6 preloads), docs / 302 then
    /docs/components 200 (5 preloads), ui-website / 200 (4 preloads); every boot
    entry-import and modulepreload resolves, none broken. The blog's pages ship
    whole (they import muted-text, a raw custom element that is a client-effecting
    non-component), so the synthetic integration test carries the positive
    import-only coverage while the blog differential confirms no breakage.

Docs / surfaces

  • packages/server/AGENTS.md invariant 7 + the root AGENTS execution-model note: updated with the import-only class.
  • Other surfaces (root AGENTS, docs site, scaffold, MCP, editors): N/A, this is
    an internal boot-optimization with no public API / authoring-surface change
    (the elision section already frames elision by interactivity signals).

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).
@vivek7405 vivek7405 self-assigned this Jun 19, 2026
t added 2 commits June 19, 2026 11:35
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).
@vivek7405

Copy link
Copy Markdown
Collaborator Author

Decision: import-only does NOT exempt the no-server-import lint

While wiring this up I hit the interaction with no-server-import-in-browser-module. A page that imports a server-only .server.ts UTILITY (a throw-at-load stub) plus a component now classifies as import-only, so its module is dropped from the boot and the server import never actually reaches the browser. The tempting move was to teach the lint to skip import-only modules the way it skips inert ones.

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 use server / middleware, is still the right fix), even though import-only happens to make it non-crashing at runtime. Net: no false-negative risk, and the optimization is purely additive on the serve side.

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

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.

@vivek7405 vivek7405 marked this pull request as ready for review June 19, 2026 06:34
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.
@vivek7405

Copy link
Copy Markdown
Collaborator Author

Gotcha: routeFor() is a SECOND boot-URL computation that must mirror ssr.js

The boot module set is computed in TWO places: ssr.js (the actual <script type=module> + modulepreloads in the body) and dev.js routeFor() (the 103 Early Hints Link: rel=modulepreload header, emitted BEFORE the body renders). I first applied the import-only splice only to ssr.js, so for an import-only route the Early Hints preheated the page/layout modules the body no longer imports while missing the components it does, the exact double-fetch the content-hash EARLY-HINTS / NESTED-IMPORTS tests guard against. Both computations now apply the same drop-inert + splice-import-only transform. If a future change touches one boot-assembly site, it has to touch the other; the content-hash tests are the tripwire (they parity-check routeFor against the body).

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

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.

@vivek7405 vivek7405 merged commit c89183b into main Jun 19, 2026
9 checks passed
@vivek7405 vivek7405 deleted the feat/import-only-route-elision branch June 19, 2026 09:26
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.

Elide page/layout modules that ship only to import interactive components

1 participant