Skip to content

feat(core): add opt-in per-phase timing to the post-mutation flush - #55

Merged
chiefcll merged 1 commit into
mainfrom
feat/post-mutation-timing
Aug 29, 2026
Merged

feat(core): add opt-in per-phase timing to the post-mutation flush#55
chiefcll merged 1 commit into
mainfrom
feat/post-mutation-timing

Conversation

@chiefcll

@chiefcll chiefcll commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

What

runPostMutation runs as a microtask after a mutation batch and does three things in sequence: the delete flush, the layout flush, and the focus apply. None of it was observable from outside.

This adds Config.postMutationDebug plus a postMutationTiming counters object with call count, total and max, and the same pair for each phase, and a resetPostMutationTiming() so a consumer can sample over an interval.

import { Config, postMutationTiming, resetPostMutationTiming } from '@solidtv/solid';

Config.postMutationDebug = true;

setInterval(() => {
  const { calls, total, max, deleteTotal, layoutTotal, focusTotal } = postMutationTiming;
  console.log(`post-mutation ${calls} calls, ${total.toFixed(1)}ms (max ${max.toFixed(1)}ms)`,
    { deleteTotal, layoutTotal, focusTotal });
  resetPostMutationTiming();
}, 1000);

Why

From a performance investigation on a TV SoC running a virtualised row list. Mounting each new row costs ~50ms, and it lands on the frame handling the navigation keypress, dropping several vsyncs.

A linear fit across item counts put roughly two thirds of that in cost that does not scale with the number of items in the row: halving items per row cut mount cost only 11% and moved frame rate not at all. The renderer's own share was ruled out by direct measurement (node creation is 0.054ms per node, shader creation is a few percent).

A Chrome trace then caught runPostMutation at 36.7ms across five row mounts, about a third again on top of the keypress handler itself. That made it the leading suspect for the fixed cost, and there was nothing to measure it with. Hence per-phase, not just a total: the point is to find which of the three dominates.

Design

Config is this package's established runtime debug seam (debug, focusDebug, keyDebug, focusHistoryDebug) and is toggleable at runtime, which a benchmark page needs. An event was considered and rejected: this package has no emitter of its own, and adding one for a single consumer is new machinery. Counters are mutated in place rather than passed to a callback so sampling allocates nothing.

Free when off. runPostMutation reads the flag once and delegates to a separate runPostMutationTimed, so the default path never reaches a clock or takes a per-phase branch. Getting there meant extracting the three phase bodies into flushDeletes / flushLayout / flushFocus so both drivers share them. Those bodies are unchanged; please check that in review, it is the only part of this PR that touches existing behaviour.

Reviewer notes

Every mutation runs runPostMutation twice. schedulePostMutation registers both stage.reprocessUpdates(runPostMutation) and queueMicrotask(runPostMutation), and runPostMutation has no already-drained guard. The second pass early-returns from all three phases on empty queues and costs nothing, but it halves any per-call mean: the trace above showed ten calls for five mounts, so the real per-call figure was 7.34ms, not 3.67ms. The new calls counter makes this visible. Not fixed here, because removing the duplicate is a behaviour change rather than instrumentation, and it deserves its own PR.

Two other things noticed while reading this path, also left alone:

  • flushFocus on the deferredFocusElement branch calls setFocus(), which calls schedulePostMutation() again, so an autofocus costs a full second round trip through all three phases.
  • flushLayout allocates [...layoutQueue] once per outer iteration, and the outer loop runs once per ancestor level because updateLayout re-queues the parent on a size change.

Verification gap, please read. This was written and verified against v2, then rebased onto main at your request. The cherry-pick was clean (the two branches differ in elementNode.ts only in the import block, well away from these changes), but it has not been run against main.

The reason is environmental rather than anything about this change: the checkout's node_modules is installed for v2 (SolidJS 2.0) while main expects SolidJS 1.x, so 10 of 18 test files fail to transform on main regardless of this branch. Clean main and this branch produce byte-identical results with that tree installed (same 3 tsc errors, same 2 failed tests, 137 passed), so this change demonstrably adds no new failures, but its own 4 tests are among the files that cannot collect.

CI will be the first real run on main. If it goes red, the likely causes are the SolidJS 1.x/2.0 API surface used by tests/setup or the VITE_USE_NEW_FLEX switch that exists on main but not v2. The instrumentation itself times flushLayout, which calls updateLayout regardless of which flex implementation is selected, so that switch should not matter.

Tests

4 new: silent when disabled, per-phase accumulation with bounds checks, totals keep accumulating across flushes, reset zeroes everything.

pnpm run tsc clean, pnpm test 113 passed / 70 skipped, pnpm run lint 0 errors, prettier clean.

Not included

package.json and pnpm-lock.yaml are modified in the working tree, moving @solidtv/renderer from a dependency to a devDependency and bumping it to 1.7.0. That predates this work (node_modules has 1.7.0 installed from six days earlier) and is unrelated, so it is deliberately left out of this branch.

🤖 Generated with Claude Code

@chiefcll
chiefcll changed the base branch from v2 to main August 29, 2026 03:22
`runPostMutation` runs as a microtask after a mutation batch, doing three
things in sequence: the delete flush, the layout flush, and the focus apply.
Its cost was not observable from outside, which made it impossible to say
whether it was the fixed per-row cost a device investigation had cornered.

That investigation, on a TV SoC running a virtualised row list, had measured a
~50ms cost to mount each new row, landing on the frame that handles the
navigation keypress. A linear fit across item counts put roughly two thirds of
it in cost that does NOT scale with the number of items in the row: halving
items per row cut mount cost only 11% and moved frame rate not at all. The
renderer's own share was ruled out by measurement (node creation is 0.054ms per
node). A Chrome trace then caught `runPostMutation` at 36.7ms across five row
mounts, roughly a third again on top of the keypress handler itself, which put
it at the top of the list of suspects and left nothing to measure it with.

Adds `Config.postMutationDebug` and a `postMutationTiming` counters object
carrying call count, total and max, and the same pair for each of the three
phases, plus `resetPostMutationTiming()` so a consumer can sample over an
interval. `Config` is the established runtime debug seam here (`debug`,
`focusDebug`, `keyDebug`, `focusHistoryDebug`) and is toggleable at runtime,
which a benchmark page needs. Counters are mutated in place rather than passed
to a callback so sampling allocates nothing.

Off by default and free when off: `runPostMutation` reads the flag once and
delegates to a separate `runPostMutationTimed`, so the default path never
reaches a clock or takes a per-phase branch. Getting there meant extracting the
three phase bodies into `flushDeletes` / `flushLayout` / `flushFocus` so both
drivers share them; those bodies are unchanged.

Worth knowing when reading the numbers: `schedulePostMutation` registers both
`stage.reprocessUpdates(runPostMutation)` and `queueMicrotask(runPostMutation)`,
and `runPostMutation` has no already-drained guard, so every mutation runs it
twice. The second pass early-returns from all three phases on empty queues and
costs nothing, but it halves any per-call mean. The trace above showed ten calls
for five mounts, so the real per-call figure was 7.34ms rather than 3.67ms. The
new `calls` counter makes this visible; the duplicate itself is left alone here
since removing it is a behaviour change, not instrumentation.

Tests: 4 new, covering silence when disabled, per-phase accumulation, that
totals keep accumulating across flushes, and that reset zeroes everything.
113 pass, tsc clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@chiefcll
chiefcll force-pushed the feat/post-mutation-timing branch from 418f9dc to 16227e3 Compare August 29, 2026 03:25
@chiefcll
chiefcll merged commit c27ccea into main Aug 29, 2026
2 checks passed
chiefcll added a commit that referenced this pull request Aug 30, 2026
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