docs: measure the runtime and the generated client modules
Adds a per-subsystem measurement of reactive.js, made by minifying it repeatedly with one subsystem removed rather than counting source bytes. This corrects the earlier audit on both figures and on the conclusion drawn from them. Component controllers are 23,722 bytes minified / 6,660 gzipped -- 30.6% of transfer, not the "about 18%" previously claimed -- and splitting them out saves 6.6 kB gzipped on a typical page, not "3-4 kB". Measured against the example app, / and /login use none of the ten controllers and /layout uses one, so most pages download and parse the lot for nothing. The larger finding is that the runtime is not where the weight is. One page parses 490,212 decoded bytes across 11 generated client modules while transferring 21,026, and the largest module is 89.8% duplicated lines: the state-restore prologue appears 162 times because client-codegen.ts inlines the sync into every peer alias of every client function. Gzip hides it on the wire, but parse cost follows decoded bytes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -466,6 +466,148 @@ confusing ways.
|
||||
|
||||
---
|
||||
|
||||
## 4.6 Runtime and client-module size — measured
|
||||
|
||||
A per-subsystem measurement of `reactive.js` and of the generated client
|
||||
modules, made by minifying the runtime repeatedly with one subsystem removed
|
||||
each time. Source-byte share was not used: it overstates code that minifies
|
||||
well and understates code that does not, and the split/keep decision turns on
|
||||
the real number.
|
||||
|
||||
**This corrects the earlier audit**, which claimed component controllers were
|
||||
"about 18%" of the runtime and concluded that splitting saves "3-4 kB gzipped".
|
||||
Both figures were wrong, and the conclusion that followed from them was wrong.
|
||||
|
||||
### The runtime today
|
||||
|
||||
`reactive.js` is **70,101 bytes minified, 21,736 gzipped**. Removing each
|
||||
subsystem and re-minifying gives its true cost:
|
||||
|
||||
| Subsystem | Minified | Share |
|
||||
| ----------------- | -------- | ----- |
|
||||
| Select/combobox | 7,852 | 11.2% |
|
||||
| PinInput | 3,734 | 5.3% |
|
||||
| async boundaries | 3,151 | 4.5% |
|
||||
| Navbar | 2,419 | 3.5% |
|
||||
| splitters | 2,083 | 3.0% |
|
||||
| roving focus | 1,646 | 2.3% |
|
||||
| modal dialogs | 1,611 | 2.3% |
|
||||
| csr fetch | 1,547 | 2.2% |
|
||||
| anchored overlays | 1,419 | 2.0% |
|
||||
| scrollspy | 1,373 | 2.0% |
|
||||
| preferences | 1,045 | 1.5% |
|
||||
| toast | 534 | 0.8% |
|
||||
|
||||
Component-specific controllers (everything except async boundaries and csr
|
||||
fetch, which are framework features) total **23,722 minified / 6,660 gzipped —
|
||||
30.6% of what a visitor downloads.** The irreducible core is 46,379 minified /
|
||||
15,076 gzipped: the expression engine, the scope and reactivity core, and loop
|
||||
diffing.
|
||||
|
||||
### How much of it a page actually uses
|
||||
|
||||
Measured against the example app by checking which controller markers appear in
|
||||
the served HTML:
|
||||
|
||||
| Page | Controllers used |
|
||||
| ------------- | ------------------ |
|
||||
| `/` | **0 of 10** |
|
||||
| `/login` | **0 of 10** |
|
||||
| `/layout` | 1 of 10 (splitter) |
|
||||
| `/navigation` | 5 of 10 |
|
||||
|
||||
A typical page downloads and parses 6.6 kB gzipped of controller code it never
|
||||
executes. The Select controller — the single largest item at 7,852 bytes — is
|
||||
used by none of the pages above.
|
||||
|
||||
**Change.** Split the component controllers out of the core runtime and load
|
||||
them on demand, keyed on the marker attribute that already gates each one
|
||||
(`data-wrn-select`, `data-wrn-splitter`, `data-wrn-scrollspy` and so on). The
|
||||
gating logic exists; only the loading boundary is missing. Keep the core
|
||||
runtime as one immutable-cached file.
|
||||
|
||||
**How to test.** Assert the core bundle size, and per page assert that a
|
||||
controller chunk is requested only when its marker is present:
|
||||
|
||||
```bash
|
||||
bun run scripts/lib/measure-runtime-size.ts # core must stay under budget
|
||||
```
|
||||
|
||||
Plus a browser check on `/`: zero controller chunks requested.
|
||||
|
||||
### The bigger problem: generated client modules
|
||||
|
||||
The runtime is not where the weight is. On `/navigation`:
|
||||
|
||||
- **490,212 bytes decoded** across 11 client modules, **21,026 transferred** —
|
||||
a 23:1 compression ratio.
|
||||
- The largest single module is **269,117 bytes** decoded, of which **89.8% is
|
||||
duplicated lines**.
|
||||
- One line appears **162 times**: `brand = context.state.brand; topLinks =
|
||||
context.state.topLinks; ...` — the full state-restore prologue.
|
||||
|
||||
Gzip hides this on the wire, but **parse and compile cost scales with decoded
|
||||
bytes, not transferred bytes**. Half a megabyte of JavaScript is parsed to run
|
||||
one page.
|
||||
|
||||
**Cause.** `packages/compiler/src/client-codegen.ts:228-264` inlines the state
|
||||
sync into _every peer-function alias, in every client function_. Each alias
|
||||
emits `syncStateToContext` once and `syncStateFromContext` three times — the
|
||||
catch path, the promise `finally`, and the synchronous path. The output is
|
||||
O(functions x peers x state variables). With ~19 state variables and 81 peer
|
||||
aliases in that module, that is several thousand generated assignments.
|
||||
|
||||
**Change.** Hoist the sync out of the per-alias wrapper. The cheapest version
|
||||
with no change to how bodies are written: emit **one** pair of closures per
|
||||
client function and have every peer alias call them, instead of inlining the
|
||||
sync per alias:
|
||||
|
||||
```js
|
||||
const __flush = () => {
|
||||
context.state.brand = brand; /* ... */
|
||||
};
|
||||
const __restore = () => {
|
||||
brand = context.state.brand; /* ... */
|
||||
};
|
||||
const __peer =
|
||||
(name) =>
|
||||
(...args) => {
|
||||
__flush();
|
||||
let r;
|
||||
try {
|
||||
r = context.functions[name](...args);
|
||||
} catch (e) {
|
||||
__restore();
|
||||
throw e;
|
||||
}
|
||||
if (r && typeof r.then === "function") return Promise.resolve(r).finally(__restore);
|
||||
__restore();
|
||||
return r;
|
||||
};
|
||||
const doThing = __peer("doThing");
|
||||
```
|
||||
|
||||
That removes the peer multiplier — the dominant factor — and takes the 81
|
||||
copies down to roughly one per function. It is a codegen change only, with no
|
||||
change to semantics or to how anyone writes a component.
|
||||
|
||||
A larger follow-up, if the first is not enough: keep state in a single object
|
||||
and rewrite state identifiers in the body to reference it, which removes the
|
||||
per-variable multiplier as well. That one needs the body transform and should
|
||||
be measured before it is attempted.
|
||||
|
||||
**How to test.** Pin decoded size, because gzip hides regressions here:
|
||||
|
||||
```bash
|
||||
bun run build
|
||||
# assert the largest generated client module is under budget, DECODED not gzipped
|
||||
```
|
||||
|
||||
Add the ratio itself as a signal: any module compressing better than about 10:1
|
||||
is duplicating itself and should fail the check. Existing behaviour is covered
|
||||
by the current suite, so correctness is the 1,427 tests; this is purely a size
|
||||
assertion on top.
|
||||
|
||||
## 5. Order of work
|
||||
|
||||
Ranked by return, not by size. The first item changes the cost of every item
|
||||
|
||||
Reference in New Issue
Block a user