Problem
webjs's stated model is "Client navigation: automatic, nothing to opt into" (root AGENTS.md), yet enabling the client router today requires a layout to write an explicit side-effect import: import '@webjsdev/core/client-router'. All four in-repo apps (blog, website, docs, ui-website) carry this line in their root layout. It is boilerplate that contradicts the "automatic" promise, and it has a concrete cost: that import is a client side effect, so the elision analyser marks the layout client-effecting (importsClientRouter -> clientRouterFiles, packages/server/src/component-elision.js), and the root layout always ships whole and is fetched on every page (the user observed layout.ts in the network tab for this reason).
The router code already rides the shipped browser bundle: ./client-router and the bare @webjsdev/core specifier both resolve to dist/webjs-core-browser.js in prod (packages/core/package.json exports), and index-browser.js:41 already re-exports the router's named API from src/router-client.js, which auto-enables on import via a module-end enableClientRouter() call. So the router is, in effect, often already present whenever any component loads. The explicit layout import is largely a historical opt-in seam.
Design / approach
Make the client router load automatically as part of webjs-core-browser.js (the bundle every page that ships any component already loads), and remove the explicit import '@webjsdev/core/client-router' from layouts. Concretely:
- Ensure
enableClientRouter() runs when webjs-core-browser.js is evaluated (it already does via the router-client.js module-end call that index-browser.js re-exports from, but verify the dist bundle preserves that top-level side effect rather than tree-shaking it as unused).
- Provide an explicit OPT-OUT, since making it automatic removes the opt-in seam. A
package.json webjs.clientRouter: false config key (read like the other webjs.* keys) for apps that genuinely want MPA / native navigation, plus the existing disableClientRouter() for programmatic control.
- Remove
import '@webjsdev/core/client-router' from all four monorepo apps' root layouts.
- Update the elision analyser so the layout no longer needs the client-router import to be correct: with the import gone, a layout whose only client work is shipping components becomes import-only and drops from the boot, while the router still loads via the components'
@webjsdev/core import. Confirm a layout with NO components at all still behaves correctly (a zero-JS page does native navigation, which is the same as today).
Implementation notes (for the implementing agent)
- Where to edit:
packages/core/src/router-client.js (the module-end enableClientRouter() auto-enable already exists, near the file end after the test-only exports). packages/core/index-browser.js:41 already re-exports it.
- The bundle build (
packages/core/scripts / build:dist): VERIFY dist/webjs-core-browser.js keeps the router's top-level enableClientRouter() side effect. If package.json sideEffects or the bundler tree-shakes it because the named exports look unused, the auto-enable silently dies. This is the single biggest landmine.
- Opt-out config: add a
webjs.clientRouter key in the THREE lockstep places (server AGENTS "The webjs package.json config block"): the JSON Schema packages/server/webjs-config.schema.json, the type packages/core/src/webjs-config.d.ts, and a reader; gate the auto-enable on it. Add to the KNOWN_KEYS drift test.
- Elision:
packages/server/src/component-elision.js (importsClientRouter, clientRouterFiles, the import-only logic around L1005). With the layout import removed the layout should naturally fall out of clientRouterFiles; confirm it then qualifies as import-only / inert as appropriate.
- The four apps' root layouts:
examples/blog/app/layout.ts, plus website/, docs/, and packages/ui/packages/website/ (ui-website) layouts. Grep each for @webjsdev/core/client-router and remove.
- Landmines:
- DEV vs PROD resolution differs: in src/dev mode
@webjsdev/core -> index-browser.js and /client-router -> src/router-client.js (granular); in dist mode both collapse onto dist/webjs-core-browser.js. The auto-enable must hold in BOTH. ESM re-export evaluates the re-exported module, so index-browser.js importing the router named exports already evaluates router-client.js (and its module-end enable) in dev; verify the same in the bundle.
enableClientRouter() adds listeners to document / document.body. The router already guards typeof document. Ensure the bundle script runs after the DOM is parseable (module scripts are deferred, so body is present).
- Idempotency:
enableClientRouter() must be safe to call once per page load and a no-op if already enabled (it tracks an enabled flag). Verify a page that still has a stray import does not double-bind.
- Do NOT regress the post-deploy hard-reload / build-id behaviour (server AGENTS invariant 3) or the partial-nav children-slot walking; this change is purely about WHERE the router is enabled, not how it navigates.
- Invariants: "Client navigation: automatic" (root AGENTS.md); the
webjs.* three-place lockstep (server AGENTS); elision output-identical (server AGENTS invariant 7); no AI attribution, feature-branch workflow.
- Tests + docs:
- e2e (the headline): a blog page with the layout import REMOVED still does client-side navigation (no full reload on link click), across all four apps where feasible. Run
WEBJS_E2E=1 for the blog.
- browser test for auto-enable from a bare
@webjsdev/core load with no /client-router import.
- An opt-out test:
webjs.clientRouter: false yields native navigation.
- Elision test: the root layout becomes import-only/inert once the import is gone (counterfactual: keep the import, layout ships whole).
- Verify all 4 dogfood apps (memory: boot blog e2e + website/docs/ui-website via createRequestHandler GET) and report evidence in the PR.
- Docs: root AGENTS.md (the "Pages and layouts do NOT hydrate ... enabling the client router via import" note becomes "automatic"),
agent-docs/advanced.md client-router section, the docs site + website. Run webjs-doc-sync.
Acceptance criteria
Problem
webjs's stated model is "Client navigation: automatic, nothing to opt into" (root AGENTS.md), yet enabling the client router today requires a layout to write an explicit side-effect import:
import '@webjsdev/core/client-router'. All four in-repo apps (blog, website, docs, ui-website) carry this line in their root layout. It is boilerplate that contradicts the "automatic" promise, and it has a concrete cost: that import is a client side effect, so the elision analyser marks the layout client-effecting (importsClientRouter->clientRouterFiles,packages/server/src/component-elision.js), and the root layout always ships whole and is fetched on every page (the user observedlayout.tsin the network tab for this reason).The router code already rides the shipped browser bundle:
./client-routerand the bare@webjsdev/corespecifier both resolve todist/webjs-core-browser.jsin prod (packages/core/package.jsonexports), andindex-browser.js:41already re-exports the router's named API fromsrc/router-client.js, which auto-enables on import via a module-endenableClientRouter()call. So the router is, in effect, often already present whenever any component loads. The explicit layout import is largely a historical opt-in seam.Design / approach
Make the client router load automatically as part of
webjs-core-browser.js(the bundle every page that ships any component already loads), and remove the explicitimport '@webjsdev/core/client-router'from layouts. Concretely:enableClientRouter()runs whenwebjs-core-browser.jsis evaluated (it already does via therouter-client.jsmodule-end call thatindex-browser.jsre-exports from, but verify the dist bundle preserves that top-level side effect rather than tree-shaking it as unused).package.jsonwebjs.clientRouter: falseconfig key (read like the otherwebjs.*keys) for apps that genuinely want MPA / native navigation, plus the existingdisableClientRouter()for programmatic control.import '@webjsdev/core/client-router'from all four monorepo apps' root layouts.@webjsdev/coreimport. Confirm a layout with NO components at all still behaves correctly (a zero-JS page does native navigation, which is the same as today).Implementation notes (for the implementing agent)
packages/core/src/router-client.js(the module-endenableClientRouter()auto-enable already exists, near the file end after the test-only exports).packages/core/index-browser.js:41already re-exports it.packages/core/scripts/build:dist): VERIFYdist/webjs-core-browser.jskeeps the router's top-levelenableClientRouter()side effect. Ifpackage.jsonsideEffectsor the bundler tree-shakes it because the named exports look unused, the auto-enable silently dies. This is the single biggest landmine.webjs.clientRouterkey in the THREE lockstep places (server AGENTS "The webjs package.json config block"): the JSON Schemapackages/server/webjs-config.schema.json, the typepackages/core/src/webjs-config.d.ts, and a reader; gate the auto-enable on it. Add to theKNOWN_KEYSdrift test.packages/server/src/component-elision.js(importsClientRouter,clientRouterFiles, the import-only logic around L1005). With the layout import removed the layout should naturally fall out ofclientRouterFiles; confirm it then qualifies as import-only / inert as appropriate.examples/blog/app/layout.ts, pluswebsite/,docs/, andpackages/ui/packages/website/(ui-website) layouts. Grep each for@webjsdev/core/client-routerand remove.@webjsdev/core->index-browser.jsand/client-router->src/router-client.js(granular); in dist mode both collapse ontodist/webjs-core-browser.js. The auto-enable must hold in BOTH. ESM re-export evaluates the re-exported module, soindex-browser.jsimporting the router named exports already evaluatesrouter-client.js(and its module-end enable) in dev; verify the same in the bundle.enableClientRouter()adds listeners todocument/document.body. The router already guardstypeof document. Ensure the bundle script runs after the DOM is parseable (module scripts are deferred, so body is present).enableClientRouter()must be safe to call once per page load and a no-op if already enabled (it tracks anenabledflag). Verify a page that still has a stray import does not double-bind.webjs.*three-place lockstep (server AGENTS); elision output-identical (server AGENTS invariant 7); no AI attribution, feature-branch workflow.WEBJS_E2E=1for the blog.@webjsdev/coreload with no/client-routerimport.webjs.clientRouter: falseyields native navigation.agent-docs/advanced.mdclient-router section, the docs site + website. Run webjs-doc-sync.Acceptance criteria
webjs-core-browser.jswith no explicit@webjsdev/core/client-routerimport, in dev AND prod (dist) resolutionimport '@webjsdev/core/client-router'is removed from all four monorepo apps' root layoutswebjs.clientRouter: falseopt-out disables it (config in schema + type + reader, with the drift test updated)