Skip to content

chore(size): measure the minified allowance against the minified recorded with each cap - #3822

Merged
ryansolid merged 5 commits into
nextfrom
size-gate-recorded-minified
Oct 6, 2026
Merged

ryansolid merged 5 commits into
nextfrom
size-gate-recorded-minified

Conversation

@ryansolid

@ryansolid ryansolid commented Oct 6, 2026 •

Copy link
Copy Markdown
Member

Follow-up to #3821, implementing the maintainer's ruling on its open question.

Problem

#3821 measured the 20 B minified allowance per PR, against each PR's base. A scenario that merged over its brotli cap on noise stays over it, because the ratchet only lowers caps. From then on, every PR was held only to +20 B over its own base, so minified growth could pile up across PRs without limit. The #3821 warning also promised "cap will be re-based at the next ratchet", which the lower-only ratchet can't do for an over-cap scenario.

The rule

Every cap now records the minified size measured when the cap was set: capMinified next to an inline limit in scenarios.js, and { "cap", "minified" } entries in floor-caps.json.

brotli vs cap head minified verdict
at or under anything pass
over ≤ recorded + MINIFIED_ALLOWANCE (20 B) pass with a warning
over more fail
over no recorded minified: ≤ 20 B over the PR's base pass with a warning (fail-safe; the summary says so)
over no recorded minified: more, or no base fail
  • Warning text (it no longer promises a re-base): over brotli cap by N B; minified M B vs R B recorded with the cap (+D B) — H B of the 20 B minified allowance left; +P B minified over this PR's base. A failure carries the same numbers, so a small PR can see that earlier growth used up the allowance.
  • Size-Exception: stays the override. check-floor-caps.mjs now freezes each floor's recorded minified the same way as its cap: raising or removing it needs the exception. It accepts the old string entries at the base, so this PR's own format change passes.
  • Ratchet (lower-only, per RC):
    • When it lowers a cap, it records the minified measured with it. That is one measurement; if the minified is higher, the ratchet lists it explicitly, and brotli still shrank.
    • For a cap it doesn't lower, it only lowers the recorded minified, or records one that is missing. It never raises one.
    • --minified-only leaves every cap alone; this PR seeds with it.
    • It lists scenarios still over their cap, or above their recorded minified, and leaves them alone.
  • Report: the PR comment gains a "minified vs recorded" column and a fail-safe note.

Seed

The values come from CI, not macOS: the size-head artifact of the Size run on the push of next @ fff1615ee (#3811; run 37443080193). They were written by ratchet.mjs --minified-only with a dated Ratchet (2026-10-06, seed, …) ledger line on each scenario, plus header and README ledger notes. Every cap is unchanged.

A first seed from c54fb10eb was replaced. #3812, #3814 and #3811 moved minified by +30 to +69 B after it, and keeping it would have failed the 7 over-cap scenarios on arrival.

scenario cap brotli @ fff1615 recorded minified
signals: core floor (floor) 7.35 KB 7,361 (+11 over) 20,133
signals: + createStore 14.56 KB 14,540 44,396
signals: + isPending/latest 9.49 KB 9,513 (+23 over) 26,828
app: render + one signal (floor) 9.86 KB 9,856 27,687
app: hydrating (no stores) (floor) 17.71 KB 17,672 52,567
app: hydrating + every store primitive family 28.87 KB 28,888 (+18 over) 91,625
app: CSR 12.86 KB 12,905 (+45 over) 36,568
app: CSR, observe tier 14.46 KB 14,449 41,091
app: CSR, observe tier + attribution 28.66 KB 28,639 86,419
app: compiled floor 10.05 KB 10,031 28,218
app: compiled CSR 25.13 KB 25,129 78,898
app: compiled hydrating 30.93 KB 30,957 (+27 over) 99,198
frames: eager client consumer 13.78 KB 13,770 43,310
page: base server components (floor) 44.84 KB 44,864 (+24 over) 145,599
page: live server components (floor) 48.51 KB 48,527 (+17 over) 157,562
server: floor (floor) 1.34 KB 1,331 3,324
server: renderToString (floor) 20.42 KB 20,412 71,813

Seven scenarios sit over their brotli cap on next; they landed with noise warnings under #3821's gate. Each now has 20 B of minified headroom against its recorded size and passes with a warning. It fails only once minified grows more than 20 B past what is recorded now. The lower-only ratchet cannot re-base these 7; they stay over until code shrinks or a PR raises the cap with a reason.

Creep dry run

These runs go through gate.mjs on the tip's CI measurement with the seeded values. Each simulated PR adds +15 B minified to every over-cap scenario, with brotli unchanged:

step minified vs recorded vs its base verdict (7 over-cap scenarios)
next @ fff1615 as is +0 +0 7 ⚠️ warn: "over brotli cap by 11 B; minified 20,133 B vs 20,133 B recorded with the cap (+0 B) — 20 B of the 20 B minified allowance left"
PR 1: +15 B +15 +15 7 ⚠️ warn: "over brotli cap by 45 B; minified 36,583 B vs 36,568 B recorded with the cap (+15 B) — 5 B of the 20 B minified allowance left; +15 B minified over this PR's base"
PR 2: +15 B on PR 1 +30 +15 7 ❌ fail: "… (+30 B) — 10 B past the 20 B minified allowance; +15 B minified over this PR's base. real growth: …"
PR 2 under #3821's per-PR rule — +15 7 ⚠️ warn: the creep this PR closes

Tests

gate.test.mjs has 12 tests, which CI runs in check. The new ones cover:

  • Creep: two successive +15 B PRs on an over-cap scenario. The first warns with 5 B of headroom left. The second fails at +30 B over the recorded size, even though it is only +15 B over its own base, and the test asserts that the per-PR fallback would have let it through.
  • Exactly at the allowance warns; one byte past fails.
  • The fail-safe path is labelled as such.
  • A recorded value works without any base measurement.

Public API changes

None. This is tooling only (scripts/size/), with no changes under packages/*/src, so there is no changeset. Internal tooling surface touched:

  • the capMinified scenario field;
  • the floor-caps.json entry format ({ cap, minified }; the string form is still read);
  • ratchet.mjs --minified-only;
  • decide() verdicts gain against, headroom and baseDelta.

🤖 Generated with Claude via Cursor

ryansolid and others added 2 commits October 5, 2026 23:50
…rded with each cap

Each cap records the minified size measured when it was set (capMinified
inline, { cap, minified } in floor-caps.json). Over the brotli cap, a scenario
fails when head minified exceeds the recorded size + 20 B, so growth cannot
creep across PRs; the base comparison stays as the fail-safe for a cap without
one. The warning states the headroom left. The ratchet pairs the recorded
minified with each lowered cap and otherwise only lowers it (--minified-only
records missing ones); check-floor-caps freezes a floor's recorded minified
like its cap.

Co-authored-by: Claude via Cursor <noreply@cursor.com>
Co-authored-by: Claude via Cursor <noreply@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
@changeset-bot

changeset-bot Bot commented Oct 6, 2026 •

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: 18939cc

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

Co-authored-by: Claude via Cursor <noreply@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
@github-actions

github-actions Bot commented Oct 6, 2026 •

Copy link
Copy Markdown

Size (brotli, eager entry chunk)

scenario head vs base minified vs base minified vs recorded cap lazy chunks (not counted)
signals: core floor (createSignal/Memo/Effect/Root/flush) 7.36 KB 0 B 0 B 0 B 7.35 KB ⚠️ over by 11 B, 20 B minified headroom
signals: + createStore 14.54 KB 0 B 0 B 0 B 14.56 KB ✅
signals: + isPending/latest 9.51 KB 0 B 0 B 0 B 9.49 KB ⚠️ over by 23 B, 20 B minified headroom
app: render + one signal (the simple-app floor) 9.86 KB 0 B 0 B 0 B 9.86 KB ✅
app: hydrating (no stores) with Show/For/Loading/Errored/lazy 17.67 KB 0 B 0 B 0 B 17.71 KB ✅ lazy-page.js 0.04 KB
app: hydrating + every store primitive family 28.89 KB 0 B 0 B 0 B 28.87 KB ⚠️ over by 18 B, 20 B minified headroom lazy-page.js 0.04 KB
app: CSR with Show/For/Loading/Errored/lazy 12.90 KB 0 B 0 B 0 B 12.86 KB ⚠️ over by 45 B, 20 B minified headroom lazy-page.js 0.04 KB
app: CSR, observe tier (same app on the observe artifacts) 14.45 KB 0 B 0 B 0 B 14.46 KB ✅ lazy-page.js 0.04 KB
app: CSR, observe tier + attribution engine enabled 28.64 KB 0 B 0 B 0 B 28.66 KB ✅ lazy-page.js 0.04 KB
app: compiled floor (one template, one text hole, one delegated click) 10.03 KB 0 B 0 B 0 B 10.05 KB ✅
app: compiled CSR (JSX todo app: spread/merge/omit, events, class/style, keyed For, Show, Loading + lazy, store) 25.13 KB 0 B 0 B 0 B 25.13 KB ✅ stats.js 0.18 KB
app: compiled hydrating (the same JSX todo app through hydrate(), compiled hydratable) 30.96 KB 0 B 0 B 0 B 30.93 KB ⚠️ over by 27 B, 20 B minified headroom stats.js 0.20 KB
frames: eager client consumer (frames client + transport, lazy codec) 13.77 KB 0 B 0 B 0 B 13.78 KB ✅
page: base server components (hydrating + dynamic + frames + sf reference) 44.86 KB 0 B 0 B 0 B 44.84 KB ⚠️ over by 24 B, 20 B minified headroom decode.js 6.07 KB, lazy-page.js 0.04 KB
page: live server components (base + live/GET + action + isPending/latest) 48.53 KB 0 B 0 B 0 B 48.51 KB ⚠️ over by 17 B, 20 B minified headroom decode.js 6.07 KB, lazy-page.js 0.04 KB
server: floor (getRequestEvent + isServer) 1.33 KB 0 B 0 B 0 B 1.34 KB ✅
server: renderToString (the server-render floor) 20.41 KB 0 B 0 B 0 B 20.42 KB ✅

⚠️ Over the brotli cap within the minified allowance (passes)

  • signals: core floor (createSignal/Memo/Effect/Root/flush): over brotli cap by 11 B; minified 20,133 B vs 20,133 B recorded with the cap (+0 B) — 20 B of the 20 B minified allowance left; +0 B minified over this PR's base
  • signals: + isPending/latest: over brotli cap by 23 B; minified 26,828 B vs 26,828 B recorded with the cap (+0 B) — 20 B of the 20 B minified allowance left; +0 B minified over this PR's base
  • app: hydrating + every store primitive family: over brotli cap by 18 B; minified 91,625 B vs 91,625 B recorded with the cap (+0 B) — 20 B of the 20 B minified allowance left; +0 B minified over this PR's base
  • app: CSR with Show/For/Loading/Errored/lazy: over brotli cap by 45 B; minified 36,568 B vs 36,568 B recorded with the cap (+0 B) — 20 B of the 20 B minified allowance left; +0 B minified over this PR's base
  • app: compiled hydrating (the same JSX todo app through hydrate(), compiled hydratable): over brotli cap by 27 B; minified 99,198 B vs 99,198 B recorded with the cap (+0 B) — 20 B of the 20 B minified allowance left; +0 B minified over this PR's base
  • page: base server components (hydrating + dynamic + frames + sf reference): over brotli cap by 24 B; minified 145,599 B vs 145,599 B recorded with the cap (+0 B) — 20 B of the 20 B minified allowance left; +0 B minified over this PR's base
  • page: live server components (base + live/GET + action + isPending/latest): over brotli cap by 17 B; minified 157,562 B vs 157,562 B recorded with the cap (+0 B) — 20 B of the 20 B minified allowance left; +0 B minified over this PR's base

Bundled with Rolldown (what Vite ships), brotli q11, decimal KB. A scenario fails only when it is over its brotli cap and its minified size is more than 20 B over the minified recorded with the cap; over the cap within that allowance is brotli layout noise and passes with a warning. Caps and their recorded minified in scripts/size/scenarios.js; the floor and page caps in floor-caps.json are frozen (lower only, or Size-Exception: in the PR body). npm run ratchet lowers caps per RC; it never raises one (scripts/size/README.md).

@coveralls

coveralls commented Oct 6, 2026 •

Copy link
Copy Markdown

Coverage Report for CI Build 37443526665

Coverage remained the same at 76.058%

Details

  • Coverage remained the same as the base build.
  • Patch coverage: No coverable lines changed in this PR.
  • No coverage regressions found.

Uncovered Changes

No uncovered changes found.

Coverage Regressions

No coverage regressions found.


Coverage Stats

Coverage Status
Relevant Lines: 1196
Covered Lines: 963
Line Coverage: 80.52%
Relevant Branches: 930
Covered Branches: 654
Branch Coverage: 70.32%
Branches in Coverage %: Yes
Coverage Strength: 28.35 hits per line

💛 - Coveralls

@codspeed

codspeed Bot commented Oct 6, 2026 •

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 188 untouched benchmarks


Comparing size-gate-recorded-minified (18939cc) with next (fff1615)

Open in CodSpeed

@nickshiro

Copy link
Copy Markdown

The PR description still has SEED_SECTION and SEED_CREEP placeholders. Could you add the seed measurement and creep dry-run results? Those would make the two key claims of this change easy to verify during review.

ryansolid and others added 2 commits October 6, 2026 02:27
#3812, #3814 and #3811 moved minified +30 to +69 B after the first seed
(c54fb10); re-seeded from scratch at the tip, every cap unchanged.

Co-authored-by: Claude via Cursor <noreply@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
@ryansolid
ryansolid merged commit b987c41 into next Oct 6, 2026
8 checks passed
ryansolid added a commit that referenced this pull request Oct 6, 2026
…3822 format)

Size-Exception (approved): hydrating + stores 28.85 KB, CSR observe 14.48 KB,
compiled CSR 25.23 KB, compiled hydrating 30.95 KB, base page 44.86 KB,
live page 48.56 KB, each CI brotli (Size run 37450070886) + 10 B with the
minified from the same run.

Co-authored-by: Cursor <cursoragent@cursor.com>
ryansolid added a commit that referenced this pull request Oct 7, 2026
…egression of #3540) (#3824)

* fix(signals): a fresh Loading over a held value shows its fallback in a flush (#3540, L2 regression)

L2 regressed A29's boundary exemption in two ways:

- In a flush, a first pass under a loading boundary that has not shown
  content joined the flush to the hold it read, so the whole tick (the
  Show that mounted the boundary included) waited for the hold. A first
  pass that reads a hold now asks the boundaries up its chain
  (GlobalQueue._fresh); one such boundary catches it, and the pass joins
  pass-scoped (passTx), as a mount outside a flush does: the boundary's,
  not the tick's. A derivation outside the boundary in the same flush
  still holds the tick. A flush that set passTx clears it at its end.
- A render effect born held under a fresh loading boundary (content bound
  in JSX: the tree never reads the held value) was not caught, so the
  boundary revealed empty content, in a flush and out of one. It is now
  collected as a reader, and the output's first pass shows the fallback
  whenever the boundary has collected readers.

Refs #3540

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(signals): the first-pass catch asks only the nearest loading boundary, and only one that has not shown content (#3540)

A revealed boundary (re-armed by `on` or not) neither catches nor records
a never-committed first pass, and no boundary past the nearest is asked:
which boundaries catch what is unchanged beyond A29's boundary exemption.
catchStatus is back to next's.

Refs #3540

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(signals): ask the nearest loading boundary through _catch; no new hook (#3540)

A pass that read a hold asks GlobalQueue._catch with STATUS_UNINITIALIZED
in its flags. Only a first pass is caught, and only by the nearest loading
boundary if it has not shown content (`_initialized`, not `_armed`): a
revealed boundary, re-armed by `on` or not, neither catches nor records
it, and no boundary past the nearest is asked. Pins the revealed-boundary,
nested and `on` shapes against next.

Refs #3540

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>

* chore(size): Size-Exception for #3824 — five caps at CI-measured + 10 B (A29 restore, #3540)

app: hydrating + every store primitive family 28.87 -> 28.90 KB,
app: compiled CSR 25.13 -> 25.22 KB, app: compiled hydrating 30.93 ->
30.97 KB, page: base server components 44.84 -> 44.90 KB, page: live
server components 48.51 -> 48.53 KB. Approved by the maintainer.

Refs #3540

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>

* chore(size): reset #3824's caps from CI with the recorded minified (#3822 format)

Size-Exception (approved): hydrating + stores 28.85 KB, CSR observe 14.48 KB,
compiled CSR 25.23 KB, compiled hydrating 30.95 KB, base page 44.86 KB,
live page 48.56 KB, each CI brotli (Size run 37450070886) + 10 B with the
minified from the same run.

Co-authored-by: Cursor <cursoragent@cursor.com>

* proto(signals): A29's boundary scope — a boundary that has not shown content owns its subtree (#3540)

Prototype for the 2026-10-06 ruling: membership is decided by the boundary
scope, not per pass. A read of a held node from under a loading boundary
that has not shown content, or that `on` re-armed, joins nothing: the reader
is the hold's stale reader (re-derived at its commit) and the read is
pending, so the boundary shows its fallback. Exempt when the boundary's own
display is the hold's (the flush joined it, the output is held by it, or
the mounting pass joined it): the boundary then appears at the commit.

Replaces the per-pass attempt (passTx exemption in joinPassTx, the
first-pass marker, the creation-branch pending report, the prune
uninitialized keep, and the output's A29 exemption). LOADING_ON_OUTSIDE_HOLD
reports only a re-arm whose swap is held.

Not for #3824 as-is; the maintainer decides from the report.

Co-authored-by: Claude via Cursor <noreply@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>

* proto(signals): an owned held computation re-derives at the read (#3540)

Semantic fuzzer (rev 19, seed 91501, branch-boundaries #870): an `on`
re-arm left the tree held by an earlier hold; the output read it, the scope
made the read pending, and the tree was recorded as a reader that had
already settled — the boundary redrew forever. A held computation read from
the scope that owns it is content, not a hold: it leaves the transaction
(`leaveHold`, shared with `recompute`'s head) and re-derives at the read.

Pins the stale-content finding (boundaries #1078) as `it.fails`: membership
decided mid-flush is not revisited when the flush later joins the hold.

Co-authored-by: Claude via Cursor <noreply@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(signals): A29's boundary scope — ownership decided at the seam, behind boundary hooks (#3540)

Lands the 2026-10-06 boundary-scope ruling. A loading boundary that has not
shown content, or that `on` re-armed, owns its subtree: a read of a hold
from under it is pending there (the boundary shows its fallback, no hold
waits for it), and the reader re-derives at the hold's commit.

- Hooks: the ownership logic lives in boundaries.ts behind
  `GlobalQueue._owns(c, el)` (asked at the hold's doors: `read`, a store
  draft, `recompute`'s head, `propagateStatus`) and the new
  `GlobalQueue._boundaryPark(t)`; core pays the null checks.
- The seam: the reads the scope made pending, and the nodes that left a
  hold, are revisited once the flush has parked (its membership final). A
  read whose hold the flush joined after it was the hold's after all and
  re-derives as its work; a swap parked with another change takes the
  hold its content read (or left) with it.
- A re-arm that finds nothing pending owns what an earlier change holds
  under it with no flight (an action's staged write): it leaves the hold
  and re-derives now (mount-under-hold `rearm-committed`).

Pins the A15 landing tear a re-arm exposes (`it.fails`; boundaries #75's
reduction shows it on next without a boundary). Open, not the scope's: a
boundary a verdict reader mounts derives in the verdict lane (the
first-pass lane rule, #3835's seam).

Co-authored-by: Claude via Cursor <noreply@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>

* docs(signals): regenerate RULES-INDEX without local probe files (#3540)

Co-authored-by: Claude via Cursor <noreply@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(signals): rebuild #3540's L2 fix on revision 1; defer re-arm

Replaces the boundary-scope machinery (ownership hook, held-node leave,
re-arm `ownHeld`, the seam park) with revision 1's fresh-mount fix
(0479053) on today's next: a first pass that reads a hold asks the
boundaries up its chain (GlobalQueue._fresh); when a loading boundary
that has not shown content catches it, the pass joins pass-scoped
(passTx) instead of joining the flush, so the mount publishes and the
boundary shows its fallback. A render effect born held under the
boundary is caught the same way. catchStatus is unchanged.

Re-arm under the boundary scope is deferred to a separate change,
pending its ruling: those tests are pinned it.fails, the re-arm
amendment text is back to next's wording with a dated note, and
async.ts, store.ts and flow.ts are back to next.

Refs #3540

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Claude via Cursor <noreply@cursor.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants