diff --git a/packages/core/src/runtime/init.startsOnce.test.ts b/packages/core/src/runtime/init.startsOnce.test.ts new file mode 100644 index 0000000000..f2aca79d66 --- /dev/null +++ b/packages/core/src/runtime/init.startsOnce.test.ts @@ -0,0 +1,57 @@ +// @vitest-environment happy-dom + +import { afterEach, expect, it, vi } from "vitest"; +import { initSandboxRuntimeModular } from "./init"; +import { createMockTimeline, resetRuntimeFixtureDom } from "./runtimeSeekFixture.test-helpers"; + +afterEach(() => { + window.__hfRuntimeTeardown?.(); + vi.restoreAllMocks(); + resetRuntimeFixtureDom(); +}); + +it("reads a far clip's start once while playing, not on every frame", () => { + resetRuntimeFixtureDom(); + let now = 0; + let id = 0; + const callbacks = new Map(); + vi.spyOn(performance, "now").mockImplementation(() => now); + vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback) => { + callbacks.set(++id, callback); + return id; + }); + vi.spyOn(window, "cancelAnimationFrame").mockImplementation((key) => { + callbacks.delete(key); + }); + const step = (frames: number) => { + for (let i = 0; i < frames; i++) { + now += 33; + const pending = [...callbacks.values()]; + callbacks.clear(); + for (const callback of pending) callback(now); + } + }; + const words = Array.from( + { length: 300 }, + (_, i) => ``, + ); + document.body.innerHTML = `
${words.join("")}
`; + window.__timelines = { main: createMockTimeline(60) }; + initSandboxRuntimeModular(); + const player = window.__player!; + const far = document.getElementById("w299")!; + const startReads = vi.fn(); + const getAttribute = far.getAttribute.bind(far); + far.getAttribute = (name: string) => { + if (name === "data-start") startReads(); + return getAttribute(name); + }; + + player.play(); + step(5); + const afterWarmUp = startReads.mock.calls.length; + step(60); + + expect(player.isPlaying()).toBe(true); + expect(startReads.mock.calls.length - afterWarmUp).toBe(0); +}); diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index 60496a46ef..13889f84c8 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -2948,12 +2948,23 @@ export function initSandboxRuntimeModular(): void { // A clip's first visible style pass is slow; playing, each hidden clip gets it once just before its start. const clipsNeverStyledVisible = new Set(); const clipsStyledVisible = new WeakSet(); - const styleClipsDueVisibleOnce = (t: number) => { + let neverStyledStarts = { revision: -1, starts: new Map() }; + const neverStyledStart = (el: StylableElement, revision: number): number => { + if (neverStyledStarts.revision !== revision) + neverStyledStarts = { revision, starts: new Map() }; + let start = neverStyledStarts.starts.get(el); + if (start === undefined) { + start = resolveStartForElement(el, Number.NaN); + neverStyledStarts.starts.set(el, start); + } + return start; + }; + const styleClipsDueVisibleOnce = (t: number, revision: number) => { if (clipsNeverStyledVisible.size === 0 || !clock.isPlaying()) return; const clips: StylableElement[] = []; for (const el of clipsNeverStyledVisible) { - const start = resolveStartForElement(el, Number.NaN); - if (el.isConnected && el.hasAttribute(CLIP_HIDDEN_ATTR) && !(start <= t + LOOKAHEAD_SECONDS)) + const start = neverStyledStart(el, revision); + if (!(start <= t + LOOKAHEAD_SECONDS) && el.isConnected && el.hasAttribute(CLIP_HIDDEN_ATTR)) continue; clipsNeverStyledVisible.delete(el); if (el.isConnected && el.hasAttribute(CLIP_HIDDEN_ATTR) && start > t) clips.push(el); @@ -5290,7 +5301,7 @@ export function initSandboxRuntimeModular(): void { const revision = opts?.revision ?? readCompositionTimingRevision(); if (revision !== tweensRenderedThrough.revision) tweensRenderedThrough = { revision, time: Number.NEGATIVE_INFINITY }; - styleClipsDueVisibleOnce(t); + styleClipsDueVisibleOnce(t, revision); const firstRenders = t > tweensRenderedThrough.time; // Only clips holding a tween that renders for the first time on this seek need their authored style read. const firstRendered = diff --git a/packages/studio/src/player/hooks/timelineDomBinding.test.ts b/packages/studio/src/player/hooks/timelineDomBinding.test.ts index 36ddda72c8..18e14b4277 100644 --- a/packages/studio/src/player/hooks/timelineDomBinding.test.ts +++ b/packages/studio/src/player/hooks/timelineDomBinding.test.ts @@ -167,3 +167,24 @@ describe("live DOM manifest hydration", () => { expect(findTimelineDomNodeForClip(doc, clip({ start: 9 }), 0)?.id).toBe("stable"); }); }); + +describe("selector occurrence indexes while hydrating many clips", () => { + it("queries the document once for a class many clips share, not once per clip", () => { + const count = 200; + const spans = Array.from( + { length: count }, + (_, i) => + ``, + ).join(""); + const doc = documentWith(spans); + const query = vi.spyOn(doc, "querySelectorAll"); + const clips = Array.from({ length: count }, (_, i) => clip({ tagName: "span", start: i })); + + const elements = buildTimelineElementsFromClips(clips, doc); + + expect(elements).toHaveLength(count); + expect(query.mock.calls.filter(([selector]) => selector === ".w").length).toBeLessThanOrEqual( + 1, + ); + }); +}); diff --git a/packages/studio/src/player/hooks/timelineSyncHydration.ts b/packages/studio/src/player/hooks/timelineSyncHydration.ts index b05c14a70c..1b2e46312b 100644 --- a/packages/studio/src/player/hooks/timelineSyncHydration.ts +++ b/packages/studio/src/player/hooks/timelineSyncHydration.ts @@ -9,6 +9,7 @@ * an argument, so each is callable — and readable — on its own. */ +import { withSelectorIndexPass } from "../../utils/sourceScopedSelectorIndex"; import { createTimelineDomNodeResolver, findClipElementById } from "../lib/timelineElementHelpers"; import { usePlayerStore } from "../store/playerStore"; import type { TimelineElement, DomClipChild, SubCompositionHostState } from "../store/playerStore"; @@ -214,15 +215,17 @@ export function buildTimelineElementsFromClips( iframeDoc: Document | null, ): TimelineElement[] { const resolveHost = iframeDoc ? createTimelineDomNodeResolver(iframeDoc) : null; - return clips.map((clip, index) => { - const hostEl = resolveHost?.(clip, index) ?? null; - return createTimelineElementFromManifestClip({ - clip, - fallbackIndex: index, - doc: iframeDoc, - hostEl, + const build = () => + clips.map((clip, index) => { + const hostEl = resolveHost?.(clip, index) ?? null; + return createTimelineElementFromManifestClip({ + clip, + fallbackIndex: index, + doc: iframeDoc, + hostEl, + }); }); - }); + return iframeDoc ? withSelectorIndexPass(iframeDoc, build) : build(); } /** diff --git a/packages/studio/src/player/lib/timelineDOM.ts b/packages/studio/src/player/lib/timelineDOM.ts index 1aa1ae8dbf..ffa1db6550 100644 --- a/packages/studio/src/player/lib/timelineDOM.ts +++ b/packages/studio/src/player/lib/timelineDOM.ts @@ -10,6 +10,7 @@ import type { TimelineElement } from "../store/playerStore"; import type { ClipManifestClip, IframeWindow, TimelineLike } from "./playbackTypes"; +import { withSelectorIndexPass } from "../../utils/sourceScopedSelectorIndex"; import { resolveCssStackingContextId } from "@hyperframes/core/runtime/stacking-context"; import { readClipTiming } from "@hyperframes/core/composition-contract"; import { readMasterFxChain } from "@hyperframes/core/audio-groups"; @@ -234,6 +235,14 @@ export function parseTimelineFromDOM( doc: Document, rootDuration: number, timelines?: Readonly>, +): TimelineElement[] { + return withSelectorIndexPass(doc, () => scanTimelineFromDOM(doc, rootDuration, timelines)); +} + +function scanTimelineFromDOM( + doc: Document, + rootDuration: number, + timelines?: Readonly>, ): TimelineElement[] { const rootComp = doc.querySelector("[data-composition-id]"); const nodes = doc.querySelectorAll("[data-start]");