Skip to content

test(v4): measure and guard mounting at page scale - #829

Merged
titouanmathis merged 6 commits into
mainfrom
feature/v4-mount-benchmarks
Aug 16, 2026
Merged

test(v4): measure and guard mounting at page scale#829
titouanmathis merged 6 commits into
mainfrom
feature/v4-mount-benchmarks

Conversation

@titouanmathis

@titouanmathis titouanmathis commented Aug 16, 2026

Copy link
Copy Markdown
Contributor

v4 had no performance coverage in CI at all.github/workflows/benchmarks.yml runs the v3 suite only, and the three v4 benchmark files ran nowhere but a developer's machine. This adds the missing at-scale dimension, turns the findings into blocking assertions, and gives CI a way to notice a regression.

This PR only measures. It changes nothing in the mounting path.

The numbers

Chromium via Playwright, median of nine rounds on a developer machine. µs per component, so a non-linear curve would be legible as a number.

Mounting one insertion of N components

scenario 100 1 000 5 000
control — declared but unregistered 4.0 1.9 4.4
flat 21.0 18.0 17.1
nested 4 deep 18.0 16.3 18.2
realistic — 5 refs, 3 options, 4 handlers 140.0 101.6 108.0
data-mount="in-view" — a controller per element 8.0 50.6 49.2
responsive option — breakpoint cascade per mount 28.0 22.1 26.5

Per-component cost does not grow. Flat mounting goes 21 → 18 → 17 µs from 100 to 5 000; 50× the components cost 50× the time. In absolute terms, 5 000 flat components settle in 85 ms and 5 000 realistic ones in 540 ms.

Nesting is free. Four-deep costs 1.06× flat at 5 000 and 0.91× at 1 000 — inside the noise. Nothing orchestrates a child's mount from its parent, and this is now the benchmark that keeps it that way.

Weight multiplier. A realistic component — five refs, three declared options, four handlers, a mounted() reading both — costs 6.3× a bare one. The declared surface, not the count, is what makes a page expensive.

in-view costs 2.9× an eager mount and never mounts anything: it is the IntersectionObserver controller per element. It is also the noisiest row, because whenDOMSettled() deliberately does not await visibility conditions, so intersection delivery lands partly outside the measured window — that is why 100 elements read cheaper than 1 000.

A responsive option costs 1.5× a plain mount (26.5 vs 17.1 µs).

Batching and teardown

N 1 insertion 10 insertions
100 12.0 19.0
1 000 15.1 15.8
5 000 18.4 17.7

Observer batching does not change per-component cost above 1 000. Ten separate morphs cost what one insertion costs.

Destroy throughput, flat: 4.0 / 4.2 / 4.9 µs per component at 100 / 1 000 / 5 000 — roughly a quarter of what mounting costs.

What the measurement corrected

The brief's premise was that mounting does not chunk. Half of it does.

applyMountStrategy() posts one background task per element for the eager strategy, and Scheduler.#drainBackground() runs them under a 5 ms budget. Mounting is already time-sliced. What is not sliced is the observer batch itself: processMutations() scans an inserted subtree and tears down a removed one in one synchronous pass.

So the blocking cost of a page tracks its DOM node count, not its component weight — and it is a much smaller share of the total than the settle times above suggest. Long-task probe, best of three:

insertion settle longest task
10 000 flat 178 ms none
12 000 flat 199 ms 51 ms
2 000 realistic 181 ms none
4 000 realistic 363 ms 59 ms
remove 10 000 flat 40 ms none
remove 15 000 flat 61 ms 53 ms

The cliff sits near 12 000 inserted DOM nodes and 13 000 removed ones. Mounting 4 000 realistic components takes 363 ms in total but blocks for only 59 ms of it. Control: a deliberate 120 ms busy loop reports 122 ms through the same observer, so the absence of entries is real.

Whether to chunk processMutations is still your call — but the numbers say the un-chunked pass is a scan, not the mounting, and it takes ~12 000 nodes to become a problem.

The guards

src/mount-at-scale.spec.ts, in the normal browser test suite, blocking:

guard measured threshold why that number
500 realistic components produce no long task none 0 the un-chunked pass costs ~7 ms here; reaching 50 ms needs a machine 7× slower
2 000 flat components produce no long task none 0 same node count, same headroom
settle time, both of the above 45/24 ms 400 ms a catastrophe ceiling, ~8× the local best — the wall clock of a shared runner is too soft for anything tighter
per-component cost at 5 000 vs at 1 000 1.11–1.57 <3× a ratio, so it does not care how fast the runner is. Placed between noise and signal rather than above noise: linear reads ≤1.6 even fully contended, quadratic would read 5
four-deep vs flat, 5 000 components 0.90–1.02 <1.8× reintroducing parent→child orchestration means a second pass that mounts children from each parent, so it would at least double this

How the repeats reduce, per signal. Wall time takes the minimum — it is a cost, and the least interrupted run is the best estimate of the work. A long task takes the second worst, because it is a defect, not a cost. Review caught that taking the minimum of both made the blocking guards unable to fail; the maximum would have been the opposite mistake. Measured across the cliff, four repeats each:

flat components repeats (ms) min second worst max
2 000 (guarded) 0, 0, 0, 0 0 0 0
9 000 0, 0, 0, 67 0 0 67
11 000 0, 0, 51, 58 0 51 58
12 000 50, 53, 60, 61 50 60 61

At 11 000 the minimum passes a page that blocks half the time; at 9 000 the maximum fails on one spurious sample. The second worst is the only reduction that separates them. Noise is absorbed in how many repeats blocked, never by allowing some blocking, so the assertion still reads toBe(0). At the guarded sizes it costs nothing: 240 repeats across ten runs, quiet and fully contended, every one clean.

Each threshold is a ceiling that tolerates one bad repeat, so a failure needs a consistent signal. All four guards pass four consecutive local runs, three more with all eight cores saturated by busy loops, and CI.

The ubuntu-latest runner turned out to be faster than the machine these thresholds were set on — 5 000 flat components settle in 58 ms there against 85 ms here, 5 000 realistic in 336 ms against 540 ms — so the wall-clock headroom is wider in CI than the table assumes.

Two guards flaked, CI caught both, and neither was fixed by moving a number. Both were measurement bugs in the same family — a ratio of two things that were not measured under the same conditions.

The linearity check compared 500 against 4 000 and read 2.22 on the runner against 1.0 here: taking the best of several repeats systematically favours the shorter side, because a 14 ms run slips between interruptions that an 80 ms run absorbs. Comparable magnitudes shrink it; it cannot be removed entirely, since differing sizes are what the guard is for, so its threshold is now placed between the measured noise ceiling (1.57 fully contended) and the signal (5 if quadratic).

The nesting check read 1.91 on the runner while the benchmark suite put the same pair at 1.01 on the same runner class — the tell that it was the harness, not the code. The spec measured one side to completion and then the other, so drift between the blocks landed in the ratio. It now alternates the two sides, exactly as bench-diff alternates base and head, and reads 0.898–1.024 across ten runs quiet and fully contended, against 0.82–1.15 before.

Deliberately not asserted: that the cliff exists at 12 000. A faster machine would never reach it and a slower one would reach it early, so it would fail on runner speed rather than on code. The figure lives in a comment in the file instead; the ratio guard is the machine-independent half of the same protection.

CI: same-runner base-vs-head, no service

.github/actions/bench-diff — a local composite action mirroring weareikko/export-size, which this repo already uses. Measure head; check the base sha into a subdirectory of the same runner; measure it with the same action-provided script; diff; upsert a sticky comment. .github/workflows/benchmarks-v4.yml wires it up with a paths: filter. benchmarks.yml is untouched — a workflow-level paths: filter is the only way to keep this off unrelated PRs, and v3's CodSpeed job has to keep running.

What a byte counter does not need and a stopwatch does:

  • Alternate, do not run sequentially. Export-size measures head then base; for timings that puts all thermal drift on one side. Sides alternate within a round and the order flips between rounds.

  • Median of round medians, never a mean.

  • A resolution floor. Chromium clamps performance.now() to 100 µs, so a benchmark under 5 ms is reported but never flagged.

  • A measured threshold, below.

  • Comment on regressions, fail on broken measurements. A timing threshold that fails a build on a shared runner gets deleted, so regressions are reported. A measurement that did not happen is the opposite — a green report of a comparison that never ran — so absent and broken are separated by counting output:

    side every round measured none measured some measured
    head compare fail fail
    base compare empty base, warn, comment says so fail

    A base with no benchmark suite is real — it is the state of this very PR — so that one case degrades, and the comment says which case it is. Base install/prepare are never suppressed, and a report that cannot be parsed fails rather than becoming an empty baseline. The hard performance gate remains the guard specs.

The noise floor

Six identical runs of the same commit, aggregated as three interleaved rounds per side and compared as if they were base and head:

statistic value
median |change| 4.5%
p90 12.5%
worst 33.3%

Every case above 13 % is a benchmark under 2 ms, which the 5 ms floor already excludes. At threshold: 25 and floor: 5, running that same-commit data through the real comment script reports zero changes — which is the point of the exercise.

With three interleaved rounds per side on one runner, this reports a regression of 25 % or more on a benchmark of 5 ms or more. Below that, it cannot tell a regression from the runner, and says so in the comment.

It runs, on this PR

The job is green and has posted its comment above. Because these benchmarks do not exist on main yet, the base side legitimately produces nothing and the run degrades to "everything is new" — the same failure mode export-size designs for, exercised for real on the first try. Three head rounds took 66 s — 22 s each on the runner — and the whole job 2 min 9 s. Once main carries these benchmarks it is six runs rather than three, so roughly 4 min steady state.

The first attempt is worth recording, because it is the failure this design is prone to. BENCH_JSON="$out" <command> is a prefix assignment, so the "$BENCH_JSON" written inside the command was expanded by the calling shell before the assignment existed: six unbound variable errors, and a green check, because each side is allowed to fail. Fixed by exporting it as its own command, and by making an empty head an error rather than an empty report. A base that cannot build still degrades gracefully; an action that measured nothing now says so.

What was rejected, and why

option real browser shared-runner noise maintenance account/token one local command
same-runner base/head diff (chosen) yes measured 4.5 % / 12.5 % p90 one composite action, ~250 lines of plain node none npm run bench:v4
plain vitest bench, nothing tracked yes n/a none none yes
CodSpeed simulation no n/a low yes no
CodSpeed walltime yes cross-machine — the one thing walltime cannot fix without dedicated runners low yes no
Tachometer yes best-in-class statistics a second harness: it drives its own pages and cannot consume a vitest bench suite none separate command
Playwright harness reading performance yes same as chosen we build and own all of it none yes
hyperfine around a headless run yes poor — measures process wall time, so browser startup dominates low none yes

Incidentally, the CodSpeed Performance Analysis check on this very pull request has now reported fail → fail → pass → fail across four pushes, on a branch whose packages/js-toolkit content never changed at all; the first failure came with CodSpeed's own warning that "different runtime environments" were compared. That is the noise a same-runner comparison exists to remove, demonstrated for free.

CodSpeed simulation is disqualified outright: @codspeed/vitest-plugin forces pool: "forks" and instruments the Node process, while a browser-mode benchmark body runs in Chromium over CDP, so it would measure the driver. Walltime does consume vitest's own timings and would work — but it compares across machines and runs, which is precisely the noise a same-runner diff removes for free, and it wants an account and a token in a repo whose other performance tooling deliberately has neither. Tachometer has the best statistics of anything here and is the option worth revisiting if this proves too coarse; the cost today is a second benchmark harness beside the one we already run.

Plain vitest bench with no tracking was a genuinely acceptable outcome. It lost by about 250 lines of plain node.

Wall clock, and why nothing is cached

Timed on this machine:

step cost on the critical path?
npm ci, cold node_modules, warm npm cache 13.7 s yes, twice
npx playwright install chromium, cached 1.0 s once — both checkouts share ~/.cache/ms-playwright
npm run build, all packages 5.3 s no — vitest runs the browser suite from TypeScript source
benchmark run, at-scale file, sizes 1 000 + 5 000 30.2 s yes, six times
benchmark run, all four v4 bench files, full matrix 82.1 s not used in CI

The measurement dominates by roughly 6×, and the build is not on the path at all. The job is 6 × 30 s of benchmarking plus two installs: about 3.5 min of work here, ~4 min on the runner, and it samples 1 000 and 5 000 rather than the full matrix. Extending it to the other three v4 bench files would add ~55 s per run — 5.5 min across six runs — for micro-benchmarks a wall-clock diff can barely resolve, so it does not.

On caching the base side, three variants, priced:

  • Cache the base install — saves 13.7 s of a ~200 s job, ~7 %. Not worth a cache key to maintain, but harmless if wanted later.
  • Cache the base build — saves nothing. There is no build.
  • Cache the base measurement — saves ~90 s, and costs the entire guarantee. A number from an earlier main run came from a different runner under different contention, which re-imports the 10–20 % cross-machine noise this design exists to remove. That is larger than most regressions worth catching: we would keep "3× slower" and lose "15 % slower". Interleaving also becomes impossible by construction, since a stored number cannot alternate with anything.

Recommended follow-up, not in this PR: a trend tier. A per-PR diff cannot see slow drift across many merges, by construction. Storing each main result — actions/cache, or an orphan branch the way benchmark-action/github-action-benchmark does — and comparing with a deliberately wide threshold is where a cache belongs, because imprecision does not matter there.

Built to take v3 later, without doing it here

The action knows nothing about environments. It takes a directory, a command and an output path, and reads the JSON vitest emits — the same schema whether bodies run in Node, in happy-dom or in Chromium over CDP. Nothing hardcodes a browser flag, a config path or packages/v4. Whose benchmarks these are is an input, so a second suite is a step, not a branch:

- uses: ./.github/actions/bench-diff
  with:
    id: v3
    title: v3 benchmarks
    working-directory: packages/js-toolkit
    bench: npm exec vitest bench -- --config vitest.bench.config.ts --run --outputJson "$BENCH_JSON"

id namespaces the sticky comment and the temp files, so the two suites keep two comments rather than overwriting each other. That parameterisation is in this PR precisely because retrofitting it after a second caller exists is expensive; the second caller is not.

.github/workflows/benchmarks.yml and the CodSpeed job are untouched. The migration is its own change, to be judged on this one working first.

What migrating v3 would cost. CodSpeed's simulation mode measures instruction counts under CPU simulation rather than wall time. It is near-deterministic and resolves single-digit percentages; a wall-clock diff on a shared runner resolves ~25 % and cannot be made to do better by design. For micro-benchmarks — one $emit, one ref read — that is a real loss, and it is the honest argument for keeping CodSpeed on v3.

Whether v3's benchmarks still earn it, measured. Of the last 12 merged pull requests, not one changed a single file under packages/js-toolkit/. CodSpeed nonetheless failed on five of them — #823, #824, #826, #827, #828 — and all five were merged anyway. This PR reproduced it live, four times over. Its v3 content never changed by one byte, and the CodSpeed check read fail (-14.94 %, with its own "different runtime environments" warning) → fail → pass → fail across four pushes. Precision you cannot act on is not precision. A check that is always dismissed is worse than no check, because it teaches everyone to dismiss the next one — and that, more than the noise, is the case for the migration.

My reading: v4 supersedes that package, and the v3 benchmarks defend code that is no longer being changed. Retiring the job is defensible on its own; if any of it is worth keeping, port the two or three benchmarks that still describe a live decision and accept the coarser resolution.

Extraction

Yes, once it has run on a few PRs. The action is already generic and the workflow passes everything in. Moving it to weareikko/bench-diff beside export-size would be a move, not a rewrite.

Also

  • vitest.bench.config.js referred to a CodSpeed suite in packages/tests, which does not exist in this repository. Replaced with what actually guards these.
  • V4_BENCH_SIZES narrows the at-scale matrix so CI can sample it.
  • npm run bench:v4 prints the µs-per-component table locally.
  • *.fixtures.ts joins specs, benchmarks and test utilities as source-only, enforced by check:package.

Verified: npm run lint, npm run lint:types, npm run test:v4 (1 066 tests), npm run check:package.

🤖 Generated with Claude Code

https://claude.ai/code/session_011nNdFD3aQhzfdm3EsCSbS9

@github-actions

github-actions Bot commented Aug 16, 2026

Copy link
Copy Markdown

Code Review

Risk: Low — No concrete defects were found in the changed code; the performance guards and CI comparison workflow are safe to merge.

Adds browser-based, page-scale v4 mounting benchmarks, shared fixtures, blocking regression guards, and a reusable action that compares interleaved base and head measurements. The workflow also distinguishes absent baseline suites from broken benchmark runs and reports timing differences without making noisy wall-clock thresholds block CI.


Review usage: 25,481 in (3,056 cached) / 664 out tokens — $0.0162 (openrouter/openai/gpt-5.6-luna, thinking: low)

Reviewed by @weareikko/code-review v0.9.5 for commit 4f2857e.

Previous review runs

Previous run archived 2026-08-16T14:31:50Z

Code Review

Risk: Low — No concrete defects were found in the reviewed changes; the MR is safe to merge aside from any environment-specific benchmark instability.

Adds page-scale Chromium benchmarks and blocking mounting guards for v4, sharing fixtures between throughput measurements and regression tests. It also adds a composite action and workflow that alternates base/head benchmark runs, aggregates reports, and posts pull-request comparisons while failing broken measurements.


Review usage: 105,498 in (80,405 cached) / 1,206 out tokens — $0.0233 (openrouter/openai/gpt-5.6-luna, thinking: low)

Reviewed by @weareikko/code-review v0.9.5 for commit ebb3d48.

Previous run archived 2026-08-16T14:21:47Z

Code Review

Risk: Medium — issues that should be addressed before merge.

Adds a reusable composite action for alternating base/head benchmark runs, aggregates Vitest JSON output, and updates a sticky pull-request comment. It also adds Chromium at-scale mounting benchmarks and blocking page-scale guards for v4, while excluding fixture files from builds and packages.

1 issue found:

  • issue.github/actions/bench-diff/action.yml:157 — Propagate benchmark report failures to the job

Still open from earlier reviews (2 findings):

  • packages/v4/src/mount-at-scale.spec.ts:151issue: Selecting the best long-task result hides regressions
  • .github/actions/bench-diff/action.yml:119issue: Do not ignore base setup failures

Review usage: 39,742 in (3,056 cached) / 980 out tokens — $0.0260 (openrouter/openai/gpt-5.6-luna, thinking: low)

Reviewed by @weareikko/code-review v0.9.5 for commit 2ee80d6.

Previous run archived 2026-08-16T14:17:58Z

Code Review

Risk: Medium — issues that should be addressed before merge.

Adds a reusable composite action for alternating base/head Vitest benchmark runs, report aggregation, and sticky pull-request comments. It also adds Chromium mounting benchmarks, scale fixtures, regression guards, and a v4 benchmark workflow.

1 issue found:

  • issue.github/actions/bench-diff/action.yml:114 — Do not ignore base setup failures

Still open from earlier reviews (2 findings):

  • .github/actions/bench-diff/action.yml:103issue: Expand the benchmark output path after setting the environment
  • packages/v4/src/mount-at-scale.spec.ts:121issue: Selecting the best long-task result hides regressions

Review usage: 73,028 in (38,914 cached) / 1,885 out tokens — $0.0289 (openrouter/openai/gpt-5.6-luna, thinking: low)

Reviewed by @weareikko/code-review v0.9.5 for commit e144747.

Previous run archived 2026-08-16T13:56:33Z

Code Review

Risk: Medium — issues that should be addressed before merge.

The MR introduces shared scale fixtures, Vitest browser benchmarks, page-scale mounting assertions, and a composite GitHub Action that compares interleaved base/head benchmark rounds in a sticky pull-request comment. It also excludes fixture files from the v4 build and package checks.

1 issue found:

  • issuepackages/v4/src/mount-at-scale.spec.ts:121 — Selecting the best long-task result hides regressions

Still open from earlier reviews (1 finding):

  • .github/actions/bench-diff/action.yml:86issue: Expand the benchmark output path after setting the environment

Review usage: 185,625 in (150,668 cached) / 2,484 out tokens — $0.0368 (openrouter/openai/gpt-5.6-luna, thinking: low)

Reviewed by @weareikko/code-review v0.9.5 for commit 93010d7.

Previous run archived 2026-08-16T13:47:25Z

Code Review

Risk: Low — The change adds v4 browser-scale benchmarks, blocking scale guards, and a non-blocking base-versus-head benchmark report in CI; no concrete defects were found in the reviewed diff.

The new fixtures are shared by the benchmark and guard suites, covering mounting, nesting, batching, responsive options, visibility controllers, and teardown at larger page sizes. The composite action measures both commits on one runner, aggregates round medians, and updates a pull-request comment without making timing regressions block CI.

Still open from earlier reviews (1 finding):

  • .github/actions/bench-diff/action.yml:86issue: Expand the benchmark output path after setting the environment

Review usage: 120,647 in (102,765 cached) / 1,918 out tokens — $0.0221 (openrouter/openai/gpt-5.6-luna, thinking: low)

Reviewed by @weareikko/code-review v0.9.5 for commit 78351b6.

Previous run archived 2026-08-16T13:45:08Z

Code Review

Risk: Low — no blocking issues; safe to merge aside from nits.

This MR adds a reusable benchmark-diff composite action, a v4 Chromium benchmark workflow, at-scale mounting fixtures, and blocking performance specs. It also excludes fixture files from package builds and adds a local v4 reporting command.

Still open from earlier reviews (1 finding):

  • .github/actions/bench-diff/action.yml:86issue: Expand the benchmark output path after setting the environment

Review usage: 112,786 in (79,733 cached) / 2,034 out tokens — $0.0307 (openrouter/openai/gpt-5.6-luna, thinking: low)

Reviewed by @weareikko/code-review v0.9.5 for commit 322d876.

Previous run archived 2026-08-16T13:38:39Z

Code Review

Risk: Low — no blocking issues; safe to merge aside from nits.

Adds Chromium benchmarks and scale-oriented mounting guards for v4, plus a reusable GitHub Action that alternates base and head measurements and comments on timing differences. It also excludes fixture files from the package build and adds a local v4 benchmark reporting command.

Still open from earlier reviews (1 finding):

  • .github/actions/bench-diff/action.yml:86issue: Expand the benchmark output path after setting the environment

Review usage: 33,687 in (3,056 cached) / 1,062 out tokens — $0.0225 (openrouter/openai/gpt-5.6-luna, thinking: low)

Reviewed by @weareikko/code-review v0.9.5 for commit 2ebc9a4.

Previous run archived 2026-08-16T13:34:47Z

Code Review

Risk: Medium — issues that should be addressed before merge.

Adds page-scale Chromium benchmarks and blocking mounting guards for v4, plus a composite action that compares base and head benchmark results in pull requests. The intended CI coverage is currently ineffective because each benchmark invocation fails to resolve its output path and failures are explicitly ignored.

1 issue found:

  • issue.github/actions/bench-diff/action.yml:86 — Expand the benchmark output path after setting the environment

Review usage: 32,964 in / 1,019 out tokens — $0.0237 (openrouter/openai/gpt-5.6-luna, thinking: low)

Reviewed by @weareikko/code-review v0.9.5 for commit 8d3824f.

Comment thread .github/actions/bench-diff/action.yml
@github-actions

Copy link
Copy Markdown

Export size

Bundled per export with peer dependencies left external, dynamic imports excluded and the output minified; sizes are gzipped.

✅ No export size changes.

Unchanged (388)

@studiometa/js-toolkit

Export Size (gzip) Diff
(barrel) 17.44 kB
AbstractService 598 B
Base 9.06 kB
ComponentLoader 2.31 kB
DEFAULT_DIAGNOSTIC_PREFIX 102 B
DragService 2.02 kB
IDLE_TIMEOUT 57 B
KeyService 935 B
LoadService 666 B
MutationService 849 B
PointerService 1.13 kB
RafService 1020 B
ResizeService 1.12 kB
ScrollService 1.36 kB
VISIBLE_ROOT_MARGIN 72 B
autoload 2.4 kB
closestComponent 419 B
composeManifests 119 B
createApp 996 B
defineFeatures 326 B
defineManifest 512 B
fromMetaGlob 228 B
fromWebpackContext 131 B
getClosestParent 197 B
getDirectChildren 202 B
getInstanceFromElement 125 B
getInstances 187 B
getScopedGroups 104 B
importOnInteraction 926 B
importOnMediaQuery 243 B
importWhenIdle 225 B
importWhenPrefersMotion 271 B
importWhenVisible 935 B
isDirectChild 218 B
logTree 551 B
queryComponent 594 B
queryComponentAll 601 B
readEagerTokens 201 B
registerComponent 305 B
registerComponents 356 B
registerManifest 2.87 kB
registerManifests 2.89 kB
useDrag 2.05 kB
useKey 943 B
useLoad 676 B
useMutation 876 B
usePointer 1.15 kB
useRaf 1 kB
useResize 1.13 kB
useScroll 1.36 kB
utils 10.05 kB
utils/Queue 269 B
utils/SmartQueue 440 B
utils/addClass 240 B
utils/addStyle 239 B
utils/animate 3.34 kB
utils/boundingRectToCircle 206 B
utils/cache 208 B
utils/camelCase 405 B
utils/clamp 98 B
utils/clamp01 114 B
utils/collideCircleCircle 129 B
utils/collideCircleRect 192 B
utils/collidePointCircle 128 B
utils/collidePointRect 122 B
utils/collideRectRect 128 B
utils/createEaseInOut 123 B
utils/createEaseOut 91 B
utils/createElement 635 B
utils/createLocalStorage 1.32 kB
utils/createLocalStorageProvider 296 B
utils/createMemoryStorageProvider 174 B
utils/createNoopProvider 128 B
utils/createRange 115 B
utils/createSessionStorage 1.32 kB
utils/createSessionStorageProvider 288 B
utils/createStorage 1.3 kB
utils/createUrlSearchParamsInHashProvider 461 B
utils/createUrlSearchParamsInHashStorage 1.35 kB
utils/createUrlSearchParamsProvider 429 B
utils/createUrlSearchParamsStorage 1.34 kB
utils/damp 106 B
utils/dashCase 404 B
utils/debounce 122 B
utils/domScheduler 310 B
utils/ease 519 B
utils/easeInCirc 285 B
utils/easeInCubic 287 B
utils/easeInExpo 286 B
utils/easeInOutCirc 288 B
utils/easeInOutCubic 289 B
utils/easeInOutExpo 288 B
utils/easeInOutQuad 288 B
utils/easeInOutQuart 289 B
utils/easeInOutQuint 289 B
utils/easeInOutSine 288 B
utils/easeInQuad 285 B
utils/easeInQuart 286 B
utils/easeInQuint 286 B
utils/easeInSine 285 B
utils/easeLinear 77 B
utils/easeOutCirc 286 B
utils/easeOutCubic 288 B
utils/easeOutExpo 286 B
utils/easeOutQuad 286 B
utils/easeOutQuart 286 B
utils/easeOutQuint 286 B
utils/easeOutSine 286 B
utils/endsWith 128 B
utils/fold 168 B
utils/getAncestorWhere 123 B
utils/getAncestorWhereUntil 148 B
utils/getComponentResolver 140 B
utils/getOffsetSizes 194 B
utils/hasWindow 88 B
utils/historyPush 524 B
utils/historyReplace 526 B
utils/inertiaFinalValue 169 B
utils/isArray 63 B
utils/isBoolean 78 B
utils/isDefined 75 B
utils/isDev 78 B
utils/isEmpty 206 B
utils/isEmptyString 108 B
utils/isFunction 79 B
utils/isNull 68 B
utils/isNumber 91 B
utils/isObject 108 B
utils/isString 77 B
utils/keyCodes 122 B
utils/lerp 84 B
utils/loadElement 220 B
utils/loadIframe 241 B
utils/loadImage 241 B
utils/loadLink 237 B
utils/loadScript 251 B
utils/localStorageProvider 839 B
utils/lowerCase 404 B
utils/map 93 B
utils/matrix 136 B
utils/mean 126 B
utils/memo 130 B
utils/memoize 228 B
utils/memoryStorageProvider 843 B
utils/nextFrame 179 B
utils/nextMicrotask 133 B
utils/nextTick 148 B
utils/noop 62 B
utils/noopValue 76 B
utils/objectToURLSearchParams 322 B
utils/pascalCase 407 B
utils/random 93 B
utils/randomInt 113 B
utils/randomItem 234 B
utils/removeClass 242 B
utils/removeStyle 243 B
utils/round 95 B
utils/saveActiveElement 92 B
utils/scrollTo 2.31 kB
utils/sessionStorageProvider 838 B
utils/smoothTo 476 B
utils/snakeCase 406 B
utils/spring 154 B
utils/startsWith 125 B
utils/throttle 125 B
utils/toggleClass 242 B
utils/transform 347 B
utils/transition 1010 B
utils/trapFocus 441 B
utils/tween 1.72 kB
utils/untrapFocus 120 B
utils/upperCase 404 B
utils/urlSearchParamsInHashProvider 845 B
utils/urlSearchParamsProvider 839 B
utils/useScheduler 309 B
utils/wait 103 B
utils/withLeadingCharacters 135 B
utils/withLeadingSlash 142 B
utils/withTrailingCharacters 135 B
utils/withTrailingSlash 142 B
utils/withoutLeadingCharacters 122 B
utils/withoutLeadingCharactersRecursive 165 B
utils/withoutLeadingSlash 133 B
utils/withoutTrailingCharacters 122 B
utils/withoutTrailingCharactersRecursive 165 B
utils/withoutTrailingSlash 133 B
utils/wrap 122 B
version 56 B
withBreakpointManager 1.54 kB
withBreakpointObserver 1.71 kB
withDrag 2.18 kB
withExtraConfig 163 B
withFreezedOptions 187 B
withGroup 455 B
withIntersectionObserver 303 B
withMountOnMediaQuery 393 B
withMountWhenInView 347 B
withMountWhenPrefersMotion 431 B
withMutation 1010 B
withName 109 B
withRelativePointer 1.29 kB
withResponsiveOptions 2.4 kB
withScrolledInView 3.05 kB

@studiometa/js-toolkit-v4

Export Size (gzip) Diff
(barrel) 20.51 kB
BREAKPOINTS 776 B
Base 8.09 kB
DIAGNOSTICS 629 B
DRAG_MODES 162 B
EVENTS 155 B
MOUNT_ATTRIBUTE 69 B
SWAP_MODES 129 B
children 243 B
component 10.55 kB
createContext 472 B
createFallbackProvider 1.34 kB
createGroup 1.06 kB
createLocalStorage 2.34 kB
createLocalStorageProvider 1.23 kB
createMemoryStorageProvider 1.23 kB
createService 630 B
createServiceMixin 509 B
createSessionStorage 2.34 kB
createSessionStorageProvider 1.23 kB
createStorage 2.32 kB
createUrlSearchParamsInHashProvider 1.23 kB
createUrlSearchParamsInHashStorage 2.36 kB
createUrlSearchParamsProvider 1.23 kB
createUrlSearchParamsStorage 2.36 kB
defaultScheduler 1.5 kB
defineManifest 983 B
domUpdate 1.23 kB
emitExtendable 1.09 kB
fromMetaGlob 203 B
fromWebpackContext 131 B
getBreakpoints 776 B
getInstances 2.77 kB
inject 175 B
injectContext 675 B
injectContextSync 634 B
jsonSerializer 95 B
localStorageProvider 1.22 kB
memoryStorageProvider 1.23 kB
nextFrame 115 B
on 8.42 kB
perTarget 176 B
provide 178 B
provideContext 704 B
provideRootContext 748 B
read 127 B
registerComponent 10.47 kB
registerComponents 10.48 kB
registerManifest 10.56 kB
sessionStorageProvider 1.22 kB
setBreakpoints 805 B
signal 920 B
subscribeContext 1.45 kB
swap 2.63 kB
toggle 176 B
until 172 B
urlSearchParamsInHashProvider 1.22 kB
urlSearchParamsProvider 1.22 kB
useBreakpoint 1.44 kB
useDrag 3.22 kB
useInView 1.31 kB
useMediaQuery 1.05 kB
useMutation 1.29 kB
usePointer 1.72 kB
usePrefersReducedMotion 1.08 kB
useRaf 1.93 kB
useResize 1.3 kB
useScroll 2.54 kB
useScrollProgress 3.51 kB
useWindowScroll 2.52 kB
useWindowSize 1.3 kB
utils 8.65 kB
utils/DEFAULT_DAMP_FACTOR 109 B
utils/INERTIA_FRAME 97 B
utils/MAX_SPRING_RATIO 100 B
utils/SCROLL_AXES 100 B
utils/TRANSFORM_PROPS 137 B
utils/TRANSITION_OPTIONS 132 B
utils/camelCase 449 B
utils/capitalize 119 B
utils/clamp 133 B
utils/clamp01 149 B
utils/clampDampFactor 157 B
utils/createEaseInOut 120 B
utils/createEaseOut 91 B
utils/createElement 638 B
utils/createRange 205 B
utils/damp 211 B
utils/debounce 121 B
utils/decayOver 162 B
utils/deepmerge 312 B
utils/easeInCirc 94 B
utils/easeInCubic 81 B
utils/easeInExpo 97 B
utils/easeInOutCirc 150 B
utils/easeInOutCubic 141 B
utils/easeInOutExpo 150 B
utils/easeInOutQuad 139 B
utils/easeInOutQuart 140 B
utils/easeInOutQuint 140 B
utils/easeInOutSine 156 B
utils/easeInQuad 80 B
utils/easeInQuart 81 B
utils/easeInQuint 81 B
utils/easeInSine 104 B
utils/easeLinear 77 B
utils/easeOutCirc 121 B
utils/easeOutCubic 111 B
utils/easeOutExpo 124 B
utils/easeOutQuad 110 B
utils/easeOutQuart 112 B
utils/easeOutQuint 111 B
utils/easeOutSine 132 B
utils/enterTransition 679 B
utils/fold 200 B
utils/getOffsetSizes 268 B
utils/historyPush 380 B
utils/historyReplace 381 B
utils/inertiaDecay 199 B
utils/inertiaFinalValue 187 B
utils/inertiaStep 232 B
utils/inertiaTimeConstant 178 B
utils/isBoolean 90 B
utils/isDefined 87 B
utils/isFunction 86 B
utils/isNull 78 B
utils/isNumber 103 B
utils/isObject 115 B
utils/isString 89 B
utils/kebabCase 421 B
utils/leaveTransition 679 B
utils/lerp 120 B
utils/loadImage 245 B
utils/loadLink 776 B
utils/loadScript 697 B
utils/lowerCase 84 B
utils/map 128 B
utils/matrix 150 B
utils/mean 147 B
utils/memo 217 B
utils/noop 62 B
utils/noopValue 76 B
utils/objectToURLSearchParams 253 B
utils/pascalCase 434 B
utils/random 93 B
utils/randomInt 132 B
utils/randomItem 163 B
utils/round 130 B
utils/saveActiveElement 571 B
utils/scrollTo 1.72 kB
utils/selectorFor 2.72 kB
utils/setClassesOrStyles 218 B
utils/smoothTo 2.56 kB
utils/snakeCase 421 B
utils/spring 343 B
utils/throttle 151 B
utils/transform 286 B
utils/transition 577 B
utils/trapFocus 717 B
utils/untrapFocus 587 B
utils/upperCase 84 B
utils/wait 103 B
utils/withLeadingCharacters 142 B
utils/withLeadingSlash 152 B
utils/withTrailingCharacters 143 B
utils/withTrailingSlash 153 B
utils/withoutLeadingCharacters 127 B
utils/withoutLeadingCharactersRecursive 144 B
utils/withoutLeadingSlash 138 B
utils/withoutTrailingCharacters 129 B
utils/withoutTrailingCharactersRecursive 147 B
utils/withoutTrailingSlash 140 B
utils/wrap 154 B
viewTransition 1.66 kB
watchAttributes 1.98 kB
whenDOMSettled 2.31 kB
withDrag 3.61 kB
withInView 1.76 kB
withMutation 1.78 kB
withPointer 2.11 kB
withRaf 2.31 kB
withResize 1.7 kB
withScroll 2.93 kB
withScrollProgress 3.94 kB
write 125 B

@codecov

codecov Bot commented Aug 16, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 97.16%. Comparing base (bdea82a) to head (4f2857e).

Additional details and impacted files
@@           Coverage Diff           @@
##             main     #829   +/-   ##
=======================================
  Coverage   97.16%   97.16%           
=======================================
  Files         170      170           
  Lines        4133     4133           
  Branches     1151     1151           
=======================================
  Hits         4016     4016           
  Misses        106      106           
  Partials       11       11           
Flag Coverage Δ
eslint-plugin-js-toolkit 93.79% <ø> (ø)
js-toolkit 97.92% <ø> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@codspeed-hq

codspeed-hq Bot commented Aug 16, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 141 untouched benchmarks
⏩ 141 skipped benchmarks1


Comparing feature/v4-mount-benchmarks (4f2857e) with main (bdea82a)

Open in CodSpeed

Footnotes

  1. 141 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

`mount.bench.ts` measures one instance. Nothing measured a page of them,
and v4 had no performance coverage in CI at all, so the demo page that
stalled while mounting had no number to point at.

Two layers. `mount-at-scale.bench.ts` reports throughput in a real
Chromium for flat, four-deep, realistic, batched, destroyed, `in-view`
and responsive-option pages at 100, 1 000 and 5 000 components, against a
control of declared-but-unregistered elements. `mount-at-scale.spec.ts`
turns the findings into blocking guards.

The measurement corrects the premise. Eager mounting already chunks:
`applyMountStrategy()` posts one background task per element, drained
inside the scheduler's 5 ms budget. What does not chunk is the observer
batch — `processMutations()` scans an inserted subtree and tears down a
removed one in one synchronous pass — so blocking cost tracks DOM nodes,
not component weight. The long-task cliff sits near 12 000 inserted nodes
and 13 000 removed ones, and 4 000 realistic components settle in 363 ms
while blocking for only 59 ms of it.

Guards therefore stay far under that cliff wherever the threshold would
depend on machine speed, and are tight only where it does not: five times
the components must stay within 2.5x per component, and a four-deep tree
within 1.8x of a flat one. Both sides of each ratio are measured in the
tens of milliseconds, because taking the best of several repeats favours
the shorter side — a 3 ms run dodges the interruption a 70 ms run absorbs
— and a ratio inherits that bias. All four guards survive four repeats
quiet and three with every core saturated.

`V4_BENCH_SIZES` narrows the matrix so CI can compare a subset, and the
config's reference to a `packages/tests` CodSpeed suite — which does not
exist in this repository — is replaced by what actually guards these.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011nNdFD3aQhzfdm3EsCSbS9
@titouanmathis
titouanmathis force-pushed the feature/v4-mount-benchmarks branch from 78351b6 to 93010d7 Compare August 16, 2026 13:46
Comment thread packages/v4/src/mount-at-scale.spec.ts
v4 had no performance coverage in CI. It cannot join the v3 CodSpeed job:
simulation mode forces `pool: "forks"` and instruments the Node process,
while a browser-mode benchmark body runs in Chromium over CDP, so it
would measure the driver.

So this borrows the shape `weareikko/export-size` already proved in this
repository — measure head, check the base sha into a subdirectory of the
same runner, measure it with the same action-provided script, diff,
upsert a sticky comment — and adds what a stopwatch needs that a byte
counter does not. Sides alternate within a round and the order flips
between rounds, so drift lands on both. Every value is a median of round
medians. A benchmark under 5 ms is reported but never flagged, because a
sub-millisecond measurement cannot resolve a 25 % move.

The threshold is measured, not chosen. Running one commit against itself
with three interleaved rounds per side moves benchmarks by 4.5 % median
and 12.5 % p90; every case above 13 % is a benchmark under 2 ms, which
the floor already excludes. 25 % leaves room for a shared runner.

No cached baseline, deliberately. Timed here, `npm ci` is 13.7 s and no
build is needed at all — vitest runs the browser suite from TypeScript —
against ~30 s per benchmark run, six of them. Caching would save under a
tenth of the job and would reintroduce the cross-machine noise the whole
design exists to remove, since a stored number cannot be interleaved.

The action knows nothing about environments: it takes a directory, a
command and an output path, and reads the JSON vitest emits, which is the
same schema in Node, in happy-dom and in a browser. Whose benchmarks
these are is an input — `id`, `title`, `unit` — so a second suite is a
step with its own sticky comment rather than a branch inside the action.

It comments and never blocks: a wall-clock gate on a shared runner is a
gate that gets deleted. `npm run bench:v4` prints the same table locally.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011nNdFD3aQhzfdm3EsCSbS9
@titouanmathis
titouanmathis force-pushed the feature/v4-mount-benchmarks branch from 93010d7 to e144747 Compare August 16, 2026 13:55
@studiometa studiometa deleted a comment from github-actions Bot Aug 16, 2026
Comment thread .github/actions/bench-diff/action.yml
@github-actions

github-actions Bot commented Aug 16, 2026

Copy link
Copy Markdown

v4 mount benchmarks

Base and head measured on this runner, alternating over 3 rounds each; every value is the median of the round medians. Running both sides on one machine is what removes cross-machine noise — a cached baseline from another runner would put it back.

A move under 25%, or on a benchmark under 5 ms, is not reported as a change: it is inside the measured noise of a shared runner.

The base commit has no benchmark suite, so every benchmark below is new. A base that failed to build or measure would have failed this job rather than appearing here.

Group Benchmark Base Head us / component Change
destroy 1000 flat components, one removal flat - 2.60 ms 2.60 new
destroy 5000 flat components, one removal flat - 14.5 ms 2.90 new
mount 1000 components, one insertion control — declared but unregistered - 1.90 ms 1.90 new
mount 1000 components, one insertion flat - 15.6 ms 15.60 new
mount 1000 components, one insertion in-view — one controller per element - 25.3 ms 25.30 new
mount 1000 components, one insertion nested 4 deep - 12.9 ms 12.90 new
mount 1000 components, one insertion realistic — 5 refs, 3 options, 4 handlers - 74.1 ms 74.10 new
mount 1000 components, one insertion responsive option — breakpoint cascade per mount - 15.5 ms 15.50 new
mount 1000 flat components, 1 vs 10 insertions 1 insertion - 10.1 ms 10.10 new
mount 1000 flat components, 1 vs 10 insertions 10 insertions - 10.5 ms 10.50 new
mount 5000 components, one insertion control — declared but unregistered - 18.7 ms 3.74 new
mount 5000 components, one insertion flat - 60.5 ms 12.10 new
mount 5000 components, one insertion in-view — one controller per element - 158.3 ms 31.66 new
mount 5000 components, one insertion nested 4 deep - 62.5 ms 12.50 new
mount 5000 components, one insertion realistic — 5 refs, 3 options, 4 handlers - 351.5 ms 70.30 new
mount 5000 components, one insertion responsive option — breakpoint cascade per mount - 81.6 ms 16.32 new
mount 5000 flat components, 1 vs 10 insertions 1 insertion - 61.4 ms 12.28 new
mount 5000 flat components, 1 vs 10 insertions 10 insertions - 59.3 ms 11.86 new

titouanmathis and others added 2 commits August 16, 2026 14:16
`mountCost()` took the minimum of every signal, which is right for a cost
and backwards for a defect: one lucky repeat out of four made
`expect(longestTask).toBe(0)` pass on a page that blocked the main thread
in the other three.

The maximum is wrong the other way. A `longtask` entry attributes any
50 ms task in the frame, mounting's or not, so a single GC pause would
fail the build — and a guard that flakes gets deleted. Measured across
the cliff, four repeats each, the two failure modes are visible side by
side:

| flat components | repeats (ms)  | min | second worst | max |
| --------------- | ------------- | --- | ------------ | --- |
| 2 000 (guarded) | 0, 0, 0, 0    |   0 |            0 |   0 |
| 9 000           | 0, 0, 0, 67   |   0 |            0 |  67 |
| 11 000          | 0, 0, 51, 58  |   0 |           51 |  58 |
| 12 000          | 50, 53, 60, 61|  50 |           60 |  61 |

At 11 000 the minimum passes a page that blocks half the time. At 9 000
the maximum fails on one sample out of four. The second worst is the only
reduction that separates them, so that is what a long task uses; wall
time keeps the minimum, where the least interrupted run really is the
best estimate of the work.

Noise is absorbed in how many repeats blocked, never by allowing some
blocking, so the assertion still reads "no long task". At the guarded
sizes it costs nothing: 240 repeats across ten runs, quiet and with all
eight cores saturated, every one of them clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011nNdFD3aQhzfdm3EsCSbS9
`${{ inputs.install }} || true` and the same on `prepare` came from
`weareikko/export-size`, where degrading is harmless: a missing byte count
is obviously missing. A missing *baseline* is not obviously missing. It
becomes "everything is new", which reads as a successful comparison that
never happened.

Absent and broken are now different states, decided by counting output
rather than by the action knowing anything about the command it was given:

| side | every round measured | none measured                | some measured |
| ---- | -------------------- | ---------------------------- | ------------- |
| head | compare              | fail                         | fail          |
| base | compare              | empty base, warn, comment says so | fail     |

A base with no benchmark suite yet is real — it is the state of every
pull request that adds one, including this one — so it still degrades,
and the comment now says that a base which failed to build or measure
would have failed the job rather than appearing there. A base that
measured some rounds and not others is a broken suite, not an absent one,
and fails. Base `install` and `prepare` are no longer suppressed at all.

All six combinations exercised against the step's own shell.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011nNdFD3aQhzfdm3EsCSbS9
Comment thread .github/actions/bench-diff/action.yml
`bench-comment.mjs` caught every read and parse error and returned `[]`.
That fallback existed for the absent base, but an absent base is already
expressed as a report which *is* `[]`, written deliberately by the
aggregation step. So the catch only ever hid the other case: a report
that could not be read or parsed became an empty base, and the comment
announced a comparison that never happened — the same silent degradation
just removed from the base install.

Let it throw. A malformed or missing report now fails the step, while a
base that legitimately measured nothing still comes through as `[]` and
still comments.

The shell flags are spelled out too. `shell: bash` already runs with
`-e -o pipefail` — confirmed in the runner log, not just the docs — so a
failing `bench-report.mjs` did abort the step, but a script that says
only `set -u` invites the reader to conclude otherwise. It now says
`set -euo pipefail`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011nNdFD3aQhzfdm3EsCSbS9
The nesting guard read 1.91 on a CI runner against a 1.8 ceiling, while
the benchmark suite put the same pair at 1.01 on the same runner class.
The difference is the harness, not the code: the spec measured one side
to completion and then the other, so any drift between the two blocks
landed entirely in the ratio.

Measure them alternately, exactly as `bench-diff` alternates base and
head, and compare 5 000 against 5 000 rather than 2 000 so one scheduling
gap is a small fraction of each side. The nesting ratio is now 0.898 to
1.024 across ten runs, quiet and with all eight cores saturated, against
0.82 to 1.15 before.

The linearity guard keeps a bias interleaving cannot remove, because its
two sides must differ in size: the best of several repeats favours the
shorter one, since a 14 ms run slips between interruptions an 80 ms run
absorbs. Measured 1.11 to 1.20 per component quiet, up to 1.57 contended.
Its threshold therefore moves from 2.5 to 3, placed between the worst
noise and the signal it exists to catch — five times the components read
about 1.6 per component when linear and would read 5 if quadratic —
rather than just above the noise.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011nNdFD3aQhzfdm3EsCSbS9
@titouanmathis
titouanmathis merged commit 58ca803 into main Aug 16, 2026
13 checks passed
@titouanmathis
titouanmathis deleted the feature/v4-mount-benchmarks branch August 16, 2026 14:37
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.

1 participant