Skip to content

perf(studio): resolve manifest clips through one pass-scoped DOM index - #2941

Draft
miguel-heygen wants to merge 1 commit into
perf/studio-load-and-openfrom
perf/studio-load-u4
Draft

perf(studio): resolve manifest clips through one pass-scoped DOM index#2941
miguel-heygen wants to merge 1 commit into
perf/studio-load-and-openfrom
perf/studio-load-u4

Conversation

@miguel-heygen

Copy link
Copy Markdown
Collaborator

PR 2 of 5 · base perf/studio-load-and-open (#2940)

Opening a 3,000-clip project spent 2,880 ms turning the clip manifest into timeline elements. This makes it 10 ms.

The root cause: two ideas of what a clip's identity is

The runtime mints clip ids as stableClipId(el) = el.id ?? el.getAttribute("data-hf-id"). Studio looked them up with doc.getElementById(id) — which matches the id attribute and never data-hf-id. In a real sample only 13 of 58 authored clips carry a DOM id, so for most clips the indexed lookup missed and fell through to a whole-document scan, per clip, at three separate call sites.

What the profile actually said

Not what I expected. isTimelineIgnoredElement was 5,292 ms of self time — 40% of all CPU during open. It runs el.closest() against a four-selector list, and getTimelineDomNodes applies it to every [data-start] node, roughly four times per clip. Counted by patching Element.prototype.closest in the page:

37,175,144 closest() calls to open one project. Plus 18,959 querySelectorAlls. Over 3,001 nodes. In the measured fixture the ignore-filter rejects zero of them.

The same work done once measures 1.3 ms in the live page.

The change

One TimelineDomIndex built per processTimelineMessage pass from a single document walk, threaded to all three emitters: node resolution (now keyed on data-hf-id as well as id), the source-scoped selector-occurrence index, and the composition branch that re-resolves a host when compositionSrc is absent. Pass-scoped, never memoised on the document — the preview DOM mutates during editing and a stale index would resolve clips to detached nodes. usedNodes claiming and the full fallback chain are preserved.

Two constraints a naive index gets wrong

  • The manifest does not always carry an id. timeline.ts computes id: stableClipId(node) ?? nodeCompositionId ?? null with no ordinal fallback, unlike clipTree.ts. Clips with no id, no data-hf-id and no composition id arrive with clip.id === null and genuinely need the selector-occurrence path. An id-map-only index silently breaks exactly those.
  • Identity must not change. mergeTimelineElementsPreservingDowngrades and timelineElementsChanged diff purely on key ?? id. A different identity reads as element replacement and silently drops selection, expandedClipIds, the keyframe cache and lint findings — a data-loss bug wearing a performance fix's clothes.

The parity test guards that second one, and it is worth knowing what it cost: an earlier revision passed hostEl in directly, so it compared only the identity builder and could not have caught the indexed path resolving a clip to a different node — the exact failure it exists to detect. Both arms now run the full derivation. That took the test from 5 s to ~85 s, because the un-indexed control arm is the quadratic. It gets cheaper only if someone reintroduces the quadratic, which is itself the signal.

Result

Same machine, control run at HEAD~1 reproducing the recorded baseline within 1%, which is what makes the delta attributable rather than machine luck:

metric (median) control @Head~1 this PR
hf.studio.manifest-derive 2,900.0 ms 9.8 ms
hf.studio.project-open 4,323.1 ms 1,314.5 ms

Virtualization-off arm: derive 2,926 → 10.3 ms, open 4,915 → 2,250 ms. Independently reproduced: derive 10.2 ms, open 1,403 ms.

The query-count test asserts a 500-clip derivation (including a composition clip with no compositionSrc) calls doc.querySelectorAll exactly once and doc.querySelector zero times.

timeline-virtualization.mjs passes both arms unchanged. stableClipId in packages/core is untouched.

Preview is now 91% of open time — PR 3 addresses it.

Opening a project ran up to three whole-document scans per clip. The
manifest identifies clips with `stableClipId` (`el.id` OR `data-hf-id`)
while the studio lookup asked `getElementById`, which never matches the
second half — so every hf-id clip missed the fast path and fell through
to `getTimelineDomNodes`, a fresh `[data-start]` scan whose
`isTimelineIgnoredElement` filter walks `closest()` over every node. At
3,000 clips that is 37M ancestor walks rejecting nothing.

Build one index per `processTimelineMessage` pass and thread it to all
three emitters: node resolution, the source-scoped selector-occurrence
index, and the composition branch that re-resolves a host when the
manifest carries no `compositionSrc`. The index is derived from a single
document walk and is discarded with the pass, so an edit that mutates the
preview DOM cannot resolve against stale nodes.

Identity is unchanged. `usedNodes` claiming still runs through the
resolver, the existing `data-composition-id` / class / attribute-match
fallback chain is intact for clips the index cannot resolve, the source
file of an element is derived to match `closest()` exactly, and
`getSourceScopedSelectorIndex` keeps its indexless signature for callers
that have no index.

The load-bearing test runs the FULL derivation twice over a generated
3,000-clip project — node resolution included, not a pre-resolved host —
and asserts every `id` and `key` is byte-identical, covering the clips
whose `clip.id` is null. A divergence there would silently drop
selection, expanded state, the keyframe cache and lint findings.
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