You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
In prod (dist mode) the entire core runtime is delivered as a single non-tree-shakeable browser bundle loaded in full for ANY interactivity. The always-load browser subpaths (/directives, /context, /task, /client-router) all collapse onto one file (dist/webjs-core-browser.js), so a page whose only interactive element is a trivial counter still ships the client router plus every directive plus Task plus context. Measured footprint: ~92KB raw / ~29KB gzip. That is roughly 4-5x Lit's gzip floor (whose API webjs clones), heavier than Astro's zero-JS default, and far heavier than Qwik's ~1KB resumable boot.
From an architecture audit comparing webjs to Lit/Astro/Qwik.
Relationship to the no-build stance (read first)
Per-route APP code-splitting and a webjs build are deliberately deferred / not-goals (project memory no_backward_compat / the AGENTS.md "Deliberately deferred" list). This issue is NOT proposing an app bundler or webjs build. It is scoped to how the framework's OWN prebuilt core dist is structured, i.e. whether the always-load core surface can be more granular so a page only pays for the core features it uses. If even that is judged off-thesis, this is a legitimate wontfix candidate; it is filed because it was a major performance finding and you asked for all of them. Decide the scope explicitly.
Design / approach
Make the always-load core surface subsettable without introducing app bundling:
Today setCoreInstall(coreDir, distMode) points /directives, /context, /task, AND /client-router all at the one webjs-core-browser.js bundle in dist mode. Investigate whether the dist build can emit a small always-load base (base component + html/css + signals) plus separately-importmap-resolvable chunks for the heavier optional surfaces (the full directive set, Task, context, and especially the client router), so a page that uses none of them does not download them.
The client router is the highest-value split: clientRouter: false apps (Add webjs.clientRouter:false opt-out for the automatic client router #629) and pages with only a trivial island still load it today. Routing is auto-enabled when core loads, but the router code itself is the largest single piece (router-client.js is ~3285 LOC); a build that keeps it out of the base bundle until a page actually has client nav would cut the floor materially.
This is an importmap + dist-structuring change (multiple resolvable entry points, HTTP/2-multiplexed), consistent with the no-build model, NOT concatenation of app code.
Quantify first: produce a before/after gzip table for a trivial-counter page and a full-feature page, so the win (and any added request count) is measured, not assumed.
Implementation notes (for the implementing agent)
Where the collapse happens: packages/server/src/importmap.js, buildCoreEntries() / setCoreInstall(coreDir, distMode) (L300+). In dist mode it picks the exportsdefault (bundled dist/webjs-core-*.js) for each subpath; the always-load subpaths all resolve to dist/webjs-core-browser.js. /lazy-loader already keeps its own file (precedent for a separate on-demand chunk).
The dist build itself: dist/ is an esbuild artifact (git-ignored), built via the core package's build:dist script. Any new chunking is an esbuild config + package.jsonexports change in packages/core, plus matching buildCoreEntries logic.
dev.js selects dist mode via existsSync(coreDir/dist/webjs-core.js) && existsSync(coreDir/dist/webjs-core-browser.js); src/dev mode already serves granular src/*.js (no bundle), so this only changes prod delivery.
Landmine: the bare @webjsdev/core specifier must keep pointing at the browser entry that drops server-only modules (renderToString / renderToStream / setCspNonceProvider); do not let a split re-introduce server bytes onto the wire.
Landmine: elision already drops core entirely for a fully-static page (invariant 7); this change only affects pages that DO ship some interactivity. Confirm the interaction (a page that ships only the base must not be forced to also pull the router).
Landmine: SRI / asset-hash. New core chunks are same-origin /__webjs/core/* served files; they get ?v= fingerprinting via asset-hash.js, not cross-origin SRI. Keep the byte-identity discipline so preload hrefs match.
Measurement harness: there is prior art for measuring the dist bundle; add a size assertion so the floor cannot silently regrow.
Acceptance criteria
A before/after gzip measurement table for (a) a trivial-counter page and (b) a full-feature page is produced and recorded in the PR.
A page that uses only base component + signals does NOT download the client router or the full directive set in prod.
A page that DOES use client nav / a directive / Task still loads them correctly (over separate importmap-resolved entries, no regression).
No app bundler / webjs build introduced; the change is core-dist structuring + importmap only.
A size-regression test guards the always-load base floor.
Docs updated (agent-docs/advanced.md bundling/perf section, packages/server/AGENTS.mdimportmap.js row) and the no-build invariant left intact.
Problem
In prod (dist mode) the entire core runtime is delivered as a single non-tree-shakeable browser bundle loaded in full for ANY interactivity. The always-load browser subpaths (
/directives,/context,/task,/client-router) all collapse onto one file (dist/webjs-core-browser.js), so a page whose only interactive element is a trivial counter still ships the client router plus every directive plusTaskplus context. Measured footprint: ~92KB raw / ~29KB gzip. That is roughly 4-5x Lit's gzip floor (whose API webjs clones), heavier than Astro's zero-JS default, and far heavier than Qwik's ~1KB resumable boot.From an architecture audit comparing webjs to Lit/Astro/Qwik.
Relationship to the no-build stance (read first)
Per-route APP code-splitting and a
webjs buildare deliberately deferred / not-goals (project memoryno_backward_compat/ the AGENTS.md "Deliberately deferred" list). This issue is NOT proposing an app bundler orwebjs build. It is scoped to how the framework's OWN prebuilt core dist is structured, i.e. whether the always-load core surface can be more granular so a page only pays for the core features it uses. If even that is judged off-thesis, this is a legitimatewontfixcandidate; it is filed because it was a major performance finding and you asked for all of them. Decide the scope explicitly.Design / approach
Make the always-load core surface subsettable without introducing app bundling:
setCoreInstall(coreDir, distMode)points/directives,/context,/task, AND/client-routerall at the onewebjs-core-browser.jsbundle in dist mode. Investigate whether the dist build can emit a small always-load base (base component +html/css+ signals) plus separately-importmap-resolvable chunks for the heavier optional surfaces (the full directive set,Task, context, and especially the client router), so a page that uses none of them does not download them.clientRouter: falseapps (Add webjs.clientRouter:false opt-out for the automatic client router #629) and pages with only a trivial island still load it today. Routing is auto-enabled when core loads, but the router code itself is the largest single piece (router-client.jsis ~3285 LOC); a build that keeps it out of the base bundle until a page actually has client nav would cut the floor materially.Quantify first: produce a before/after gzip table for a trivial-counter page and a full-feature page, so the win (and any added request count) is measured, not assumed.
Implementation notes (for the implementing agent)
packages/server/src/importmap.js,buildCoreEntries()/setCoreInstall(coreDir, distMode)(L300+). In dist mode it picks theexportsdefault(bundleddist/webjs-core-*.js) for each subpath; the always-load subpaths all resolve todist/webjs-core-browser.js./lazy-loaderalready keeps its own file (precedent for a separate on-demand chunk).dist/is an esbuild artifact (git-ignored), built via the core package'sbuild:distscript. Any new chunking is an esbuild config +package.jsonexportschange inpackages/core, plus matchingbuildCoreEntrieslogic.dev.jsselects dist mode viaexistsSync(coreDir/dist/webjs-core.js) && existsSync(coreDir/dist/webjs-core-browser.js); src/dev mode already serves granularsrc/*.js(no bundle), so this only changes prod delivery.@webjsdev/corespecifier must keep pointing at the browser entry that drops server-only modules (renderToString/renderToStream/setCspNonceProvider); do not let a split re-introduce server bytes onto the wire./__webjs/core/*served files; they get?v=fingerprinting viaasset-hash.js, not cross-origin SRI. Keep the byte-identity discipline so preload hrefs match.Acceptance criteria
webjs buildintroduced; the change is core-dist structuring + importmap only.agent-docs/advanced.mdbundling/perf section,packages/server/AGENTS.mdimportmap.jsrow) and the no-build invariant left intact.