Skip to content

App-module modulepreload over-fetches a dropped page's SSR-only subtree #780

Description

@vivek7405

Problem

The app-module modulepreload walk in deduplicatedPreloads (packages/server/src/ssr.js) emits a <link rel="modulepreload"> for a module reached ONLY through a page/layout dropped from the boot (an inert page, or an import-only page whose own module is substituted by its components). The canonical case is a dropped page's SSR-only RELATIVE HELPER. The page imports ./fmt.js for server render only, the page module is dropped from moduleUrls (inert or import-only), yet fmt.js still gets a preload hint because the walk roots are the RAW route entries, not the shipped set.

This is the exact app-module analog of the vendor over-fetch fixed in #754 (PR #776), and was flagged by the PR #776 round-2 independent review as a pre-existing issue to tighten in a follow-up.

It is a wasted-roundtrip and network-tab-pollution bug (over-fetch), not a correctness or security bug. The hinted module is never imported by anything that ships, so the browser preloads a module it then never executes. It does NOT cause under-fetch (a shipped module is still hinted), so first paint is unaffected. The cost is a speculative fetch of a dead module plus a misleading network tab.

Design / approach

reachedVendorSpecifiers (the #754 vendor walk) was fixed to walk from shippedRoots = moduleUrls.map(absolutePath) (the boot's actually-shipped page/layout module set, which already drops inert modules and expands an import-only page into its components) plus the rendered components. deduplicatedPreloads should walk from the SAME shipped-roots set instead of the raw [route.file, ...route.layouts].

Concretely, the call site (ssr.js around L183 to L191) passes [route.file, ...route.layouts] as the entryFiles arg to deduplicatedPreloads. Change it to pass the shippedRoots array already computed around L200 to L201 for the vendor walk (hoist that computation above both call sites so both share it). Inside deduplicatedPreloads, the graph walk around L1512 to L1524 combines entryFiles plus componentUrls as allEntries. Once entryFiles is the shipped set, a dropped page is no longer a walk root, so its SSR-only subtree (direct or via a relative helper) is never reached and never preloaded.

Note deduplicatedPreloads already seeds seen = new Set(moduleUrls), so the dropped module's OWN url is excluded from the result today. The gap is purely the TRANSITIVE deps reachable only through the dropped root. The shipped-roots change closes exactly that gap, matching the vendor walk's semantics so the two walks stay consistent (a later divergence between "which app modules ship" and "which vendors ship" would be a bug).

Implementation notes (for the implementing agent)

  • Where to edit:
    • packages/server/src/ssr.js, the deduplicatedPreloads call (around L183 to L191). Pass shippedRoots (the absolute-path array built around L200 to L201) instead of [route.file, ...route.layouts]. Hoist the shippedRoots computation above the deduplicatedPreloads call so both it and the reachedVendorSpecifiers call consume the one array.
    • deduplicatedPreloads (around L1488). The entryFiles param now carries shipped absolute paths already. Confirm the resolve(appDir, url...) round-trip for componentUrls is unchanged, and that seen = new Set(moduleUrls) still excludes the shipped page/layout urls themselves (it should, moduleUrls is unchanged).
    • Update the function JSDoc to state the roots are the shipped set (mirror the reachedVendorSpecifiers doc around L1537 to L1563, which already documents this for vendors).
  • Landmines and gotchas:
    • route.file and route.layouts are the RAW entries (an inert page is still in this list). moduleUrls is the post-drop, post-expand SHIPPED set. The whole bug is the gap between these two. Do not "simplify" by feeding raw entries back in.
    • An import-only page substitutes its components into moduleUrls, so the shipped roots already REACH the page's shipping components (their vendors plus app deps are still hinted). Verify the import-only under-fetch case stays covered (a shipping component reachable only via an import-only page is still preloaded).
    • A component shared between a shipped route and a dropped page is reached via the shipped route's root, so it is still (correctly) preloaded. Removing the dropped page as a root must NOT drop a shared shipped component.
    • transitiveDeps stops at a .server.* or action-index boundary but returns the boundary file itself. The existing byName and byIndex server-file filters must stay.
    • This is an elision-verdict change (what gets a preload hint), invisible to npm test first paint. The proof is a network-tab or preload-list assertion, like IMPORTANT: vendor deps waterfall over the CDN (no modulepreload for reached vendor entries) #754.
  • Invariants to respect: AGENTS.md execution model (a page/layout that ships only because it imports components is import-only, Elide page/layout modules that ship only to import interactive components #605; a pure-carrier page is dropped, dogfood: guide AI agents to keep pages/layouts out of the browser network #621). The preload set must match the SHIPPED module set exactly. No hint for a dropped-subtree module (over-fetch), no missing hint for a shipped module (under-fetch).
  • Tests plus docs surfaces:

Acceptance criteria

  • deduplicatedPreloads walks from the shipped-module roots (moduleUrls-derived absolute paths) plus rendered components, not the raw route entries
  • A dropped page's SSR-only relative helper (and SSR-only direct app import) receives NO modulepreload hint
  • A shipped module (including a component reachable only via an import-only page, and a component shared with a shipped route) STILL receives its hint (no under-fetch)
  • A counterfactual proves the over-fetch test fires (revert shipped-roots, test reds)
  • Tests cover the new behaviour at unit and (where applicable) e2e network-probe layers
  • The deduplicatedPreloads JSDoc documents the shipped-roots semantics, consistent with reachedVendorSpecifiers
  • Docs and AGENTS.md updated if the documented preload surface changed

Metadata

Metadata

Assignees

Labels

enhancementNew feature or request

Type

No type

Fields

No fields configured for issues without a type.

Projects

Status
Todo

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions