Skip to content

perf(fence): avoid repeated spec parsing and pointless subtree scans - #424

Open
lws2004 wants to merge 1 commit into
omdsh-dev:mainfrom
lws2004:perf/fence-takeover
Open

lws2004 wants to merge 1 commit into
omdsh-dev:mainfrom
lws2004:perf/fence-takeover

Conversation

@lws2004

@lws2004 lws2004 commented Nov 5, 2026

Copy link
Copy Markdown

What

Three independent costs in the DOM fence channel. All three show up on a transcript that renders many fences at once (session switch / history load); the first and third also affect any sweep on a long transcript.

1. Every sweep re-parsed every fence body

isGenericGenuiFence runs repairFenceJson + JSON.parse + validateCanonicalGenuiSpec + diagnoseUnknownGenuiFields + JSON.stringify(normalizeGenuiSpec(...)) + JSON.stringify(value). The same body is re-parsed several times per sweep (discovery, ordinal computation, takeover), and every sweep re-parses every body again. On a 30-fence transcript that is dozens of full parses per frame.

2. Every changed node got a full subtree scan

enqueueMutationTarget calls findFenceCandidates(element, { includeScope: true }) for every structural change. A session switch marks every message row dirty — most of them prose or tool cards with no code block inside — so the scan is wasted work for the majority of targets.

3. Takeover budget stayed at 8ms even with dozens of queued fences

SWEEP_BUDGET_MS is 8ms, sized for streaming growth. After a session switch the queue holds dozens of candidates and each React mount costs tens of milliseconds, so the queue drains at roughly one fence per frame and the rest of the transcript waits.

How

  • Cache isGenericGenuiBody(raw) by body text in a Map bounded at 500 entries (cleared wholesale when full). The body is the full input to the verdict, so the cache is exact, not heuristic.
  • Pre-filter structural targets: skip the subtree scan when the element contains neither a <pre> nor the literal <dsh-ui> text. A later insertion of a <pre> fires its own mutation, so no fence can be missed.
  • Raise the per-sweep budget from 8ms to 40ms only when pendingCandidates.size > 4 (batch takeover). Streaming and single-fence paths keep 8ms.

Verification

  • tsc -p tsconfig.json — clean
  • tsdown — all bundles build
  • vitest run — 413 pre-existing environment failures (jsdom/localStorage) before and after; 1693 passing on both sides, identical failure set.
  • Measured on a local 0.15 fork (DSH 0.2.1-rc.3, sessions with 7–30 fences) that also carried a local-only observer-scoping change: single-fence takeover 1906ms → 67ms; 30-fence session switch 5678ms → 2589ms. These are the combined effect in that setup, not an isolated benchmark of these three changes.

Scope

Only src/client/dom-fence.tsx (+43 / −7). Independent of #423 — both touch the same file but different regions; either order merges cleanly. Happy to split this or drop the 40ms budget change if you prefer to keep per-frame work capped.

Three independent costs in the DOM fence channel, all measured on a
30-fence transcript:

1. Every sweep re-parsed each fence body (JSON.parse + canonical spec
   validation + two JSON.stringify) for discovery, ordinal computation and
   takeover. Now cached by body text (bounded at 500 entries).

2. Every changed node got a full subtree scan even when the subtree held no
   code block at all. Session switches mark every message row dirty, and most
   of them are prose or tool cards; a cheap 'has a <pre> or tag-text
   <dsh-ui>' pre-filter skips them. A later insertion of a <pre> fires its
   own mutation, so nothing is missed.

3. Takeover budget was 8ms per frame even when dozens of fences were queued
   after a session switch. When the queue exceeds 4 candidates the budget
   rises to 40ms so one frame can make progress on several fences; the user
   is already waiting for the page at that point.

This branch has not been deployed

No deployments
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