Skip to content

dogfood: elision false-positives pin page.ts/layout.ts (# alias, inline-script globals, new Set) #623

@vivek7405

Description

@vivek7405

Problem

Pages and layouts never hydrate, so the elision analyser can drop their modules from the browser (INERT #179 / IMPORT-ONLY #605) whenever their only browser job is carrying components. In practice almost no real page/layout qualifies, because three analyser checks produce FALSE POSITIVES on route modules, pinning page.ts / layout.ts to the network tab. Confirmed by instrumenting analyzeElision (packages/server/src/component-elision.js) against the in-repo apps:

website/app/layout.ts  selfFlags=clientGlobalOrBare  closure=[theme-toggle[comp]]
website/app/page.ts    selfFlags=none                closure=[_lib/tier.ts[NON-comp]]
blog/app/layout.ts     selfFlags=clientRouter,clientGlobalOrBare  closure=[theme-toggle[comp]]
blog/app/page.ts       selfFlags=clientGlobalOrBare   closure=[counter,muted-text,chat-box (comp), utils/cn.ts[NON-comp]]

Three distinct bugs, each independently enough to pin a module:

A. #-alias side-effect imports are misread as npm packages (clear bug, hits every modern app)

importsSideEffectNonCorePackage skips . / / / @webjsdev/core / node: specifiers, but NOT # path aliases (#555). So import '#components/counter.ts'; (the recommended way to register a component from a page/layout) is treated as a bare npm side-effect import and flags the module client-effecting. Proven:

SIDE_EFFECT_BARE_IMPORT_RE on `import '#components/counter.ts';` => returns '#components/counter.ts' (treated as non-core npm)

This is a real misclassification: #components/... resolves to a LOCAL file (Node package.json "imports", the scaffold's catch-all "#*": "./*"), not an npm package. It pins page.ts in EVERY app that uses the #components/... registration convention. It may ALSO mislead the vendor bare-import scan (scanBareImports) into trying to resolve #components/... against jspm; verify and fix there too if so.

B. module-scope new Set/Map data constants read as side effects

hasModuleScopeSideEffect returns true on any top-level new (/(?<![.\w])(?:new|await)\s/). A pure data constant like export const TIER_2_NAMES = new Set([...]) (ui-website app/_lib/tier.ts) therefore flags the util client-effecting, and since the page reaches it, the page ships whole. A new Set([literals]) / new Map([...]) in an export const initializer is pure data, not client work.

C. inline-<script> client globals in a page/layout TEMPLATE read as module client work

CLIENT_GLOBAL_RE (window|document|localStorage|...) runs on the comment-masked source with STRINGS/TEMPLATES KEPT. A layout's inline <script> (theme no-FOUC bootstrap, menu handler) contains document / localStorage / addEventListener, so the layout MODULE is flagged. But a page/layout never hydrates: that inline script runs from the SSR'd HTML, and loading layout.ts as a module never executes the template's script text. So this is a provable false positive on route modules. It pins layout.ts in all four apps.

Design / approach

The analyser is a denylist biased to ship (a false "ship" only misses an optimisation, a false "elide" breaks a component). For ROUTE MODULES the safety direction is even more forgiving: a page/layout never hydrates, so dropping it can never break interactivity (inline scripts stay in the SSR'd HTML, components re-emit directly). The only real client work a route module can do is genuine MODULE-SCOPE code (a real top-level document.x, a real bare npm side-effect import, a client-router import). So:

  • A (general). Teach the side-effect / bare-import detectors about # aliases. Expand a #-prefixed specifier through the app's package.json "imports" map (the module graph already does this via appImportsMap / expandImportAlias in packages/server/src/module-graph.js) and treat a local-resolving alias like a relative import (skip it). Reuse that helper rather than re-deriving. Also audit scanBareImports (vendor) for the same gap.
  • C (route-module-scoped). For ROUTE modules (pages/layouts), run the client-global / event / module-scope-side-effect scans on the TEMPLATE-REDACTED source (redactStringsAndTemplates), so template content (inline scripts, @event in a page template that SSR drops anyway, client globals in returned HTML) does not flag the module, while a genuine module-scope document.x OUTSIDE any template still does. Do NOT change component detection (a component's template events ARE its interactivity signal). Scope this relaxation to route modules only.
  • B (careful, general). Relax hasModuleScopeSideEffect so a new <PureBuiltin>(...) (Set / Map / Date / RegExp / WeakSet / WeakMap / Array) in an export const / const initializer is not a disqualifying side effect. Keep flagging a bare top-level new SomethingElse() / await / call. This is the riskiest change; gate it tightly and lean on the differential test.

All three must preserve the invariant that elision never changes observable output.

Implementation notes (for the implementing agent)

  • Where: packages/server/src/component-elision.js (importsSideEffectNonCorePackage, CLIENT_GLOBAL_RE usage in the per-file scan loop around L800-820, hasModuleScopeSideEffect); reuse appImportsMap / expandImportAlias from packages/server/src/module-graph.js; audit scanBareImports (vendor path) for the #-alias gap. The route-module-vs-component distinction is available: the analyser already knows routeModules vs componentFiles.
  • How to verify (the win is an elision VERDICT change, invisible to npm test):
    • Re-instrument or add a unit test asserting each of blog app/page.ts, blog app/layout.ts, ui-website app/page.ts, ui-website app/layout.ts becomes IMPORT-ONLY or INERT after the fix (with the cn fix dogfood: ship a class-name utility (cn) in @webjsdev/core #619 + router-import removal dogfood: auto-enable client router via webjs-core-browser.js, drop layout imports #620 also applied for the blog cases, see Dependencies).
    • e2e/network-probe: page.ts / layout.ts ABSENT from the blog home boot, interactive leaves still present, with a counterfactual.
    • The differential-elision test (test/elision/differential-elision.test.js) is the safety net: SSR HTML + post-hydration DOM/behaviour must stay identical on vs off. Each of A/B/C must keep it green.
  • Landmines:
    • Conservative-safety invariant (server AGENTS invariant 7): bias to ship. B especially must not over-relax (a new EventSource() / new WebSocket() at module scope IS client work; only inert data builtins are safe).
    • C must stay route-module-scoped. A component that references a client global only inside its template is rare but real; do not change component verdicts.
    • The #-alias expansion must match how the graph resolves it, or the side-effect verdict and the import-graph will disagree.
  • Dependencies: this is the KEYSTONE for getting page.ts / layout.ts out of the network. It compounds with dogfood: ship a class-name utility (cn) in @webjsdev/core #619 (blog cn.ts dead customElements code) and dogfood: auto-enable client router via webjs-core-browser.js, drop layout imports #620 (auto client router, removes the blog/website/docs layout clientRouter self-flag). Land all three to fully drop the blog's modules; A+B+C alone fully drop ui-website's.
  • Docs: update agent-docs/components.md (elision deep-dive) + the relevant packages/server/AGENTS.md invariant 7 prose to describe route-module template handling and #-alias handling. Run webjs-doc-sync.

Acceptance criteria

  • A #-alias side-effect import (import '#components/x.ts') no longer flags a page/layout client-effecting; verified with a unit test + the offending-spec proof as counterfactual
  • A page/layout whose only browser job is registering #-imported components becomes IMPORT-ONLY (blog home, with dogfood: ship a class-name utility (cn) in @webjsdev/core #619 + dogfood: auto-enable client router via webjs-core-browser.js, drop layout imports #620 applied)
  • A layout pinned only by inline-<script> client globals becomes IMPORT-ONLY/INERT (all four apps)
  • A module-scope new Set/Map(...) data constant no longer disqualifies an importing page (ui-website home -> import-only/inert)
  • scanBareImports does not send #-alias specifiers to the vendor resolver
  • Differential elision stays output-identical; e2e network-probe proves page.ts/layout.ts are no longer fetched, with counterfactuals
  • agent-docs + server AGENTS.md updated; webjs-doc-sync run

Metadata

Metadata

Assignees

Labels

bugSomething isn't working

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