Skip to content

perf(studio): clicking an element in the preview selects it without restyling the whole film - #4707

Merged
miguel-heygen merged 2 commits into
mainfrom
perf/studio-selection-latency
Sep 29, 2026
Merged

miguel-heygen merged 2 commits into
mainfrom
perf/studio-selection-latency

Conversation

@miguel-heygen

@miguel-heygen miguel-heygen commented Sep 28, 2026 •

Copy link
Copy Markdown
Collaborator

What

Clicking or hovering an element in Studio's preview no longer restyles the whole film. On a 1,426-element text film, click-to-selected drops from 219 ms to 120 ms (median) and the inspector fills in at 68 ms instead of 163 ms. Nothing changes in what gets selected, except two edge cases noted below where the new result is the one Chrome itself gives.

Why

Selecting by hand in the preview felt slow, sometimes too slow to edit with. Every canvas hit test (a click runs up to four, a hover runs one per pointer move) added a * { pointer-events: auto !important } style element to the preview document, called elementsFromPoint, and removed it. That had two costs that grow with the number of elements:

  • each insert and each removal invalidated style on every element, so the next elementsFromPoint and the next getComputedStyle each recalculated style for the whole document (a trace showed 18 full-document recalcs of 1,414 elements per click);
  • the head mutation reached the off-canvas indicators' MutationObserver, which threw its layer-walk cache away and rebuilt it from scratch on the next frame.

The override was added before the author pointer-events filter existed. Since that filter landed, every element the override let into the hit list (those with computed pointer-events: none) is removed again, so the override did the expensive part and the filter undid it.

How

getPreviewTargetFromPointer and getAllPreviewTargetsFromPointer call the browser's own elementsFromPoint and keep the existing author pointer-events filter. The override helpers are deleted.

Behaviour: on the heavy film, a grid of 5,280 points over six playhead times gave the same pick and the same click-through list from the old and new code, with no duplicates. A side-by-side run of both versions in headless Chrome found two edge cases that now follow the browser: a pointer-events: none child painted under its parent (z-index: -1) no longer hides the parent from selection, and SVG shapes with keyword pointer-events values (fill, bounding-box) hit-test the way Chrome defines them instead of as auto.

Test plan

  • New test in studioPreviewHelpers.test.ts: both hit tests run with no style added to the preview document and no DOM mutation. Fails on the old code (style count [1, 1, 1], and separately the mutation list), passes on this branch. The file: 12 passed.
  • Latency, measured on the built CLI Studio (hyperframes preview) with headless Chrome: pointerdown to the frame after the selection box and the inspector both show the new element, 24 clicks per run plus 8 drag starts, text-heavy repo film (packages/producer/tests/spanish-empire-cdn-inline, 1,426 elements) and a light one (sub-comp-t0, 33 elements).
  • CPU profile of the same 24 clicks, before and after (see numbers below).

Measurements

Built CLI Studio, headless Chrome on a shared 8-core Linux box. A click is timed from pointerdown to the frame after both the selection box and the inspector show the new element; a drag start from the first pointermove to the first frame the box moved. Only runs whose 1-minute load stayed under 16 at start and end are pooled.

Film Build Clicks (runs) Click to selected, median / p95 Inspector filled, median / p95 Drag start, median / p95 (n)
Text-heavy, 1,426 elements v0.8.86 58 (3) 219 / 403 ms 163 / 331 ms 74 / 417 ms (14)
Text-heavy, 1,426 elements this branch 34 (2) 120 / 412 ms 68 / 260 ms 52 / 837 ms (8)
Light, 33 elements v0.8.86 72 (3) 80 / 184 ms 38 / 147 ms 28 / 34 ms (24)
Light, 33 elements this branch 48 (2) 94 / 234 ms 43 / 199 ms 26 / 53 ms (16)

The cost grows with the number of elements: the light film is the same before and after within run-to-run spread (single-run medians range from 72 to 154 ms on both sides). The p95 column is dominated by the first clicks of each run and by other work on the box; runs that went over load 16 were dropped, and in those the text film's run medians were 237 to 516 ms before and 87 to 182 ms after.

Trace of 10 clicks on the text film (per click):

v0.8.86 this branch
Full-document style recalculations 19 (1,414 elements each) 0 (largest touches 317 elements)
Style recalculation time 58 ms 4 ms
Main-thread script time 222 ms 72 ms

CPU profile of the same 24 clicks and 8 drag starts (total ms over the run, source-mapped; the box was busier during the before run, so compare ratios):

Function v0.8.86 this branch
getPreviewTargetFromPointer (studioPreviewHelpers.ts) 2,463 89
elementsFromPoint (native, self) 1,472 67
hasAuthorPointerEventsNone (studioPreviewHelpers.ts, self) 1,384 3
recomputeOffCanvasIndicators (offCanvasIndicatorGeometry.ts) 2,764 209
All non-idle script 11,615 3,471

What is left on a click is mostly the network round trip for the source probe and React rendering the inspector; that is a separate change.

Before

v0.8.86: 10 clicks on the text film, the latency of each shown on the page (median 262 ms in this recording).

Before: v0.8.86, last of 10 clicks

studio-select-before.mp4

After

This branch: the same clicks (median 120 ms in this recording).

After: this branch, last of 10 clicks

studio-select-after.mp4

…estyling the whole film

Every hit test added a `* { pointer-events: auto !important }` style element to the
preview document and removed it again. Each insert and removal restyled every element,
and the head mutation made the off-canvas indicators rebuild their layer walk from
scratch. The override only let pointer-events:none elements into the hit list, and the
author pointer-events filter removed them again, so the hit test now uses the browser's
own result: the candidates are what Chrome itself hit-tests.
@miguel-heygen
miguel-heygen marked this pull request as ready for review September 29, 2026 01:32

@somanshreddy somanshreddy left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified claims

  1. Diff scope confirmed. packages/studio/src/utils/studioPreviewHelpers.ts (-80/+37): forcePointerEventsAuto/removePointerEventsOverride/POINTER_EVENTS_OVERRIDE_ID and the try/finally wrapping them are deleted from both getPreviewTargetFromPointer and getAllPreviewTargetsFromPointer. The rest of the hit-test chain — native elementsFromPoint → filterAuthorInteractiveTargets → full-bleed check → findGroupAtPoint fallback → doc.elementFromPoint fallback — is preserved verbatim, just de-nested out of the removed try/finally. useInlineTextEditing.tsx only rewords a comment (no logic change). One new test added to studioPreviewHelpers.test.ts.

  2. The redundancy claim that makes the removal safe is independently confirmed, not just trusted from the PR body. The PR argues the override predates filterAuthorInteractiveTargets/hasAuthorPointerEventsNone ("the author pointer-events filter"), so by the time both existed together, the override did the expensive part (force everything into the hit list) and the filter immediately undid it (excluded pointer-events:none elements again) — pure redundant round-tripping. I checked this against real GitHub history via gh api repos/heygen-com/hyperframes/contents/<path>?ref=<sha> (bypassing local shallow-clone grafting, which falsely collapses history on this box): forcePointerEventsAuto already existed at 091137e3c (2026-06-22, "instant, flicker-free manual editing"); hasAuthorPointerEventsNone was introduced later, at 02e9d6142 (2026-07-04, "make canvas selection hit intended elements #1907"). Confirmed true, not just plausible narrative.

  3. Test suite passes. bunx vitest run src/utils/studioPreviewHelpers.test.ts in packages/studio: 12/12 pass, including the pre-existing pointer-events:none ancestor-inheritance tests. (Those mock doc.elementsFromPoint directly, so they don't exercise real-browser native pointer-events exclusion — that part rests on the PR's own disclosed headless-Chrome validation.)

  4. Mutation-tested the new regression test. "hit-tests without adding styles to the preview document" asserts zero style/adoptedStyleSheet count at hit-test time and zero DOM mutations via a live MutationObserver. I reintroduced the deleted override in a scratch edit and reran: this test failed exactly as expected (stylesAtHitTest went from [0,0,0] to [1,1,0]), all 11 other tests still passed. Confirms it's a real regression guard, not mock-theater. Reverted immediately after.

  5. CI is genuinely green. statusCheckRollup: zero non-SUCCESS/non-SKIPPED checks, zero IN_PROGRESS/QUEUED. mergeable: MERGEABLE, mergeStateStatus: BLOCKED (single approval clears the gate).

Transparency note (non-blocking)

I did not independently reproduce the two disclosed native-Chrome behavioral differences (a pointer-events:none child at z-index:-1 no longer occluding its parent; SVG keyword pointer-events values hit-testing per real CSS semantics instead of forced auto) — that requires the same real-browser headless setup the author used. Resting on their own side-by-side validation (5,280 points × 6 playhead times, only these two differences found), same as their measured latency numbers.

Verdict

Approve. The core safety claim (override redundant once the filter existed) is independently verified against real commit history, not just narrative. The diff is a clean, mechanical removal that preserves the rest of the hit-test chain untouched. The new test is a genuine, mutation-tested regression guard. All tests pass, CI is fully green. No blocking issues.

@miguel-heygen
miguel-heygen added this pull request to the merge queue Sep 29, 2026
Merged via the queue into main with commit 1bede47 Sep 29, 2026
127 checks passed
@miguel-heygen
miguel-heygen deleted the perf/studio-selection-latency branch September 29, 2026 02:22
felipecaldas added a commit to felipecaldas/hyperframes that referenced this pull request Sep 29, 2026
Upstream v0.8.89..v0.8.90: 3 commits, Studio-only (heygen-com#4707, heygen-com#4718, release).
Clean merge, no conflicts. Render packages (core, engine, parsers, player,
producer, shader-transitions) unchanged between the tags.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.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.

2 participants