Skip to content

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

@vivek7405

Description

@vivek7405

Problem

Pages and layouts never hydrate (their render body runs only on the server), yet the boot script still imports the page module and every layout module on the client. For a typical route, the only reason those modules ship is that they are the import-graph root that pulls in the interactive component modules. So the browser fetches page.ts plus layout.ts purely to reach counter.ts, even though the page/layout code itself does nothing client-side.

The client does NOT actually need the page/layout module in that case. It needs (a) the set of interactive component modules the route transitively imports (which the server already computes) plus (b) the layout's client-router enable. The framework could synthesize the boot script from those directly and drop the page/layout module entirely, fetching only the interactive leaves.

This is a missed optimization, not a bug. It is the page/layout-module analog of the existing display-only component elision (and distinct from #604, a missed component elision). It is mitigated today (modulepreload fetches the modules in parallel, they are thin routing adapters, HTTP/2 multiplexes), so the win is modest: a couple of small modules and their request slots per navigation, plus any SSR-only top-level page imports that currently ride along.

Design / approach

Today elision is all-or-nothing per route module. analyzeElision (packages/server/src/component-elision.js) marks a page/layout inert only when neither it nor its client closure is client-effecting (L926-939), and ssr.js drops inert modules from the boot moduleUrls (L152). A route that imports a shipping component is non-inert, so its whole module ships.

Add a THIRD category between inert and ship: import-only. A route module is import-only when it can be dropped from the boot and replaced by direct imports of the shipping components it statically reaches.

The exact safe condition (do NOT use a hand-listed block-case list)

An earlier draft of this issue listed "three block-cases" (client-router, module-scope side effect, self-registering bare import). That list is incomplete and unsafe to implement literally. The correct condition is a positive subset test:

A route module file is import-only iff:

  1. !isClientEffecting(file) (the module itself does no client work; this already folds in client-router via clientRouterFiles, and @event / non-core bare import / client global / module-scope side effect via clientGlobalOrBareFiles), AND
  2. its client closure reaches at least one shipping component, AND
  3. EVERY client-effecting member of that closure is a shipping COMPONENT file: closure.filter(isClientEffecting).every(f => componentFiles.has(f)).

Condition 3 is the safety crux. If the closure reaches ANY client-effecting non-component (a self-executing helper, a polyfill, an analytics-init module), the module must be KEPT, because dropping it would lose that side effect and it is not a registerable component we can re-emit.

If import-only, replace the module in moduleUrls with closure.filter(f => componentFiles.has(f) && mustShip.has(f)) mapped to URLs (the static shipping-component set), and drop the module file itself.

This stays progressive-enhancement-safe: the SSR'd HTML is unchanged, only the boot import set changes. Bonus: SSR-only top-level page imports (a date lib used only in the server render body) stop being fetched client-side.

Implementation notes (for the implementing agent)

  • Where to edit:
    • packages/server/src/component-elision.js analyzeElision (L715; inert build L932-939; isClientEffecting L920; componentFiles / mustShip already computed). Return a third structure: importOnlyRouteModules, a Map<routeFile, string[]> from the dropped module to the absolute paths of the static shipping components to emit in its place. Compute it in the same routeModules loop that builds inertRouteModules, reusing the SAME transitiveDeps(moduleGraph, [file], appDir, skip) closure already computed there (skip = elided components + server files).
    • packages/server/src/ssr.js boot assembly (L152). After the inert filter, for each remaining module that is import-only, splice it out and splice in its component URLs. Keep the existing dedup so a component shared across layers is emitted once. Order does not matter (components self-register independently).
    • packages/server/src/dev.js (L746-750) plumbs analyzeElision results into state; thread the new map the same way and into the ssrPage opts.
  • Correctness traps (each must have a test):
    1. Relative side-effect imports are NOT caught by importsSideEffectNonCorePackage (it continues on a . / / specifier, L345). A self-executing relative module (import './polyfill.js' whose top-level runs a call or touches a client global) is still flagged client-effecting via hasModuleScopeSideEffect / CLIENT_GLOBAL_RE and lands in clientGlobalOrBareFiles, so condition 3 (every client-effecting closure member is a component) correctly KEEPS the module. The trap is only sprung if an implementer hand-lists block-cases instead of using condition 3. Use condition 3.
    2. Re-emit the STATIC shipping-component import set, NOT the per-render rendered/preload set. ssr.js registers components by walking module imports (moduleUrls); preloads (L163) is built from suspenseCtx.usedComponents (components that RENDERED this request). Re-emitting from the rendered/preload derivation would drop a component that is imported but only conditionally rendered (a modal shown on click), so it never registers. Drive the re-emit from the static module-graph closure, not usedComponents.
    3. Exclude lazy components (static lazy = true) from the re-emit set. They are deliberately kept out of the boot and loaded via IntersectionObserver (ssr.js L158-161 + componentPreloads lazy split). Re-emitting their import eagerly defeats lazy loading.
  • Landmines:
    • The analyzer is a conservative denylist (server AGENTS.md invariant 7): a false "droppable" verdict BREAKS the page; a false "keep" only misses the optimization. Bias to keeping the module; condition 3 is the conservative gate.
    • A self-registering vendor imported via a BINDING clause (import x from 'vendor' that calls customElements.define at load) is the pre-existing cross-module-registration caveat (server AGENTS.md invariant 7); the vendor file is outside appDir so it is not in the closure. This enhancement INHERITS that caveat and must not regress it, but does not need to fix it.
    • Interaction with dogfood: factory-form extends WebComponent({...}) defeats display-only elision #604: once factory-form components elide correctly, an import-only page importing only a now-elided display component reaches NO shipping component, so it collapses to the existing inert path (zero JS). Order the verdicts so inert wins first.
  • Invariants to respect: server AGENTS.md invariant 7 (elision is conservative plus differential-verified, never changes observable output); the no-build / native-ESM model (no bundler step).
  • Tests plus docs: unit in packages/server/test/elision/; extend the differential corpus (test/elision/differential-elision.test.js plus the differential elision e2e) with the trap shapes below; add a note to the server AGENTS.md invariant 7 elision section describing the import-only category.

Acceptance criteria

  • A route whose page/layout is non-inert ONLY because it statically imports shipping components boots those component modules directly and does NOT fetch the page/layout module
  • A page that ALSO has a self-executing relative side-effect import (import './analytics.js') still ships its module (condition 3 keeps it)
  • A layout importing @webjsdev/core/client-router still ships its module
  • A page importing a component that is conditionally rendered (not shown this request) still registers that component (static re-emit, not rendered-set)
  • A lazy component reachable from an import-only route is NOT eagerly re-emitted (still IntersectionObserver-loaded)
  • SSR HTML is byte-identical with the transform on vs off (differential test); only the boot import set plus modulepreloads change
  • A counterfactual proves each trap test fires (drop a client-router layout breaks soft-nav; drop a page with a relative side-effect import loses the side effect; rendered-set re-emit drops the conditional component)
  • Tests cover the new behaviour at the SSR plus e2e layers; AGENTS.md invariant 7 note added

Deep-research verification (2026-06-19)

Traced the full client path (lazy loading, soft-nav, partial-nav, streaming, router) against the proposal. Result: SOUND with the conditions above. Two corrections / confirmations the implementer must keep in mind.

  • Lazy components: correction (the earlier "exclude lazy" trap was over-cautious). Lazy components are resolved by RENDERED TAG via the scan registry: componentPreloads (ssr.js ~L1392) calls lookupModuleUrl(tag) + isLazy(tag) and emits an observeLazy (IntersectionObserver) entry, NOT a static import. So a lazy component is loaded independently of the page's imports and does NOT appear in the page's static transitiveDeps closure in intended usage, which means the re-emit set (closure.filter(f => componentFiles.has(f) && mustShip.has(f))) excludes it automatically. A lazy component enters the closure ONLY if a developer statically imports it, which already eager-loads it today (defeating lazy regardless), so Elide page/layout modules that ship only to import interactive components #605 is no regression. NET: no new lazy-detection infrastructure is required; a defensive exclusion is optional belt-and-suspenders, not a blocker. Keep one test asserting a tag-only lazy component is still IntersectionObserver-loaded after the transform.
  • Partial-nav + soft-nav: verified safe. Both a full load and an X-Webjs-Have partial-nav response go through wrapInDocument (ssr.js L369) with moduleUrls from L152, so the boot <script type=module> (carrying the Elide page/layout modules that ship only to import interactive components #605 re-emit) is present in BOTH responses. On soft-nav the client router re-runs head scripts via cloneScriptWithCorrectNonce (router-client.js L2791, re-emits textContent so the module script executes), and ES-module imports are idempotent, so components register consistently across full load and soft nav. THE LINCHPIN is correctness-trap Fixed layer issue for search in docs #2 above: re-emit the STATIC closure, not the per-render rendered set, so a layout-imported component revealed only by a later client interaction (a modal shown on click, never SSR-rendered) still registers. Add a soft-nav e2e proving a re-emitted, conditionally-shown component upgrades after navigation.
  • error / loading / not-found: confirmed structurally safe. collectRouteModules (dev.js) passes ONLY pages + layouts to analyzeElision, so error / loading / not-found are never classified inert or import-only and are never dropped from the boot (AGENTS: always shipped). Do not change collectRouteModules to include them; add a guard test asserting an error/loading module importing a shipping component still ships its own module.
  • Router has zero dependence on page/layout module code. Nested-layout children markers (<!--wj:children:...-->) are static SSR'd comments the router walks in the live DOM (router-client.js); no page/layout module runs on the client beyond the root layout's import '@webjsdev/core/client-router' (which condition 1 already keeps shipping). So dropping a NESTED layout module never breaks soft navigation, the deepest-shared-layout swap, or scroll restoration.
  • Streaming / Suspense: verified safe. moduleUrls is emitted identically on the streaming path; streamed boundaries resolve via DOM swap and rely only on the boot-script registrations, which the re-emit preserves.

Metadata

Metadata

Assignees

Labels

enhancementNew feature or request

Type

No type
No fields configured for issues without a type.

Projects

Status
Done

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions