From d5928892aebef2ab187ea1ba8215cd3986d84635 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sat, 24 Oct 2026 23:42:28 -0400 Subject: [PATCH 1/2] perf(core): text edits no longer rebuild the preview's timing caches --- .../src/runtime/init.timingMutations.test.ts | 92 +++++++++++++++++ packages/core/src/runtime/init.ts | 27 +++-- .../core/src/runtime/timingMutations.test.ts | 99 +++++++++++++++++++ packages/core/src/runtime/timingMutations.ts | 22 +++++ 4 files changed, 233 insertions(+), 7 deletions(-) create mode 100644 packages/core/src/runtime/init.timingMutations.test.ts create mode 100644 packages/core/src/runtime/timingMutations.test.ts create mode 100644 packages/core/src/runtime/timingMutations.ts diff --git a/packages/core/src/runtime/init.timingMutations.test.ts b/packages/core/src/runtime/init.timingMutations.test.ts new file mode 100644 index 00000000000..0c3db0a5307 --- /dev/null +++ b/packages/core/src/runtime/init.timingMutations.test.ts @@ -0,0 +1,92 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + createMockTimeline, + resetRuntimeFixtureDom, + stubDuration, +} from "./runtimeSeekFixture.test-helpers"; + +// A media cache refresh with no element list is a full rebuild of the clip index. +const mediaSpy = vi.hoisted(() => ({ fullRebuilds: 0 })); + +vi.mock("./media", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + refreshRuntimeMediaCache: (params: Parameters[0]) => { + if (!params?.elements) mediaSpy.fullRebuilds += 1; + return actual.refreshRuntimeMediaCache(params); + }, + }; +}); + +const { initSandboxRuntimeModular } = await import("./init"); + +describe("timing caches across DOM edits", () => { + const originalRequestAnimationFrame = window.requestAnimationFrame; + const originalCancelAnimationFrame = window.cancelAnimationFrame; + const frames: FrameRequestCallback[] = []; + const flushFrame = () => frames.splice(0, frames.length).forEach((callback) => callback(0)); + + /** Full clip-index rebuilds one seek and one frame cost after `change`. */ + const rebuildsAfter = async (change: () => void): Promise => { + // Settle what init and the previous change left pending. + window.__player?.seek(0.25); + flushFrame(); + await Promise.resolve(); + mediaSpy.fullRebuilds = 0; + change(); + await Promise.resolve(); + window.__player?.seek(0.5); + flushFrame(); + return mediaSpy.fullRebuilds; + }; + + beforeEach(() => { + resetRuntimeFixtureDom(); + window.requestAnimationFrame = ((callback: FrameRequestCallback) => { + frames.push(callback); + return frames.length; + }) as typeof window.requestAnimationFrame; + window.cancelAnimationFrame = (() => {}) as typeof window.cancelAnimationFrame; + document.body.innerHTML = + `
` + + `` + + `

Hel

`; + stubDuration(document.querySelector("video")!, 4); + window.__timelines = { main: createMockTimeline(4) }; + initSandboxRuntimeModular(); + flushFrame(); + }); + + afterEach(() => { + window.__hfRuntimeTeardown?.(); + window.requestAnimationFrame = originalRequestAnimationFrame; + window.cancelAnimationFrame = originalCancelAnimationFrame; + frames.length = 0; + delete window.__player; + delete window.__playerReady; + }); + + it("keeps the clip index when only text changes", async () => { + const caption = document.querySelector(".caption")!; + expect(await rebuildsAfter(() => (caption.textContent = "Hello"))).toBe(0); + expect(await rebuildsAfter(() => caption.append(document.createElement("span")))).toBe(0); + }); + + it("rebuilds the clip index when a clip is added or removed, or its timing changes", async () => { + const root = document.querySelector("[data-composition-id]")!; + expect( + await rebuildsAfter(() => { + const next = document.createElement("video"); + next.setAttribute("data-start", "2"); + next.setAttribute("data-duration", "1"); + stubDuration(next, 1); + root.append(next); + }), + ).toBeGreaterThan(0); + expect(await rebuildsAfter(() => document.getElementById("clip")!.remove())).toBeGreaterThan(0); + expect( + await rebuildsAfter(() => root.querySelector("video")!.setAttribute("data-start", "1")), + ).toBeGreaterThan(0); + }); +}); diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index 112f6259dd4..e9552d6989e 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -179,6 +179,7 @@ import { parseStrictFiniteTimingNumber, resolveMediaElementDurationSeconds } fro import { cutToHostSlots, MEDIA_START_BASIS_ATTR } from "../mediaTiming"; import { settleFirstFrameCompositionReadiness } from "../compositionReadiness"; import { AUTHORED_DURATION_ATTR, AUTHORED_END_ATTR } from "./authoredTiming"; +import { createTimingRecordFilter } from "./timingMutations"; import { clearRuntimeData, setRuntimeData, @@ -1333,6 +1334,13 @@ export function initSandboxRuntimeModular(): void { "id", "src", ]; + // Sizing inputs too: postTimeline applies them, and it runs when this revision moves. + const COMPOSITION_TIMING_ATTRIBUTES = [ + ...DURATION_FLOOR_INPUT_ATTRIBUTES, + "data-width", + "data-height", + ]; + const recordsMoveTiming = createTimingRecordFilter(COMPOSITION_TIMING_ATTRIBUTES); const COMPOSITION_TIMING_MEDIA_EVENTS = ["loadedmetadata", "durationchange", "emptied"] as const; let durationFloorsCache: DurationFloors | null = null; let durationFloorsRevision = -1; @@ -1351,7 +1359,8 @@ export function initSandboxRuntimeModular(): void { const queryForTransport = (selector: string): Element[] => { if (!compositionTimingObserver || renderCaptureSeekStarted) return Array.from(document.querySelectorAll(selector)); - if (compositionTimingObserver.takeRecords().length > 0) invalidateCompositionTimingCaches(); + if (recordsMoveTiming(compositionTimingObserver.takeRecords())) + invalidateCompositionTimingCaches(); const cached = queryCache.get(selector); if (cached?.revision === compositionTimingRevision) return cached.nodes; const nodes = Array.from(document.querySelectorAll(selector)); @@ -1400,7 +1409,10 @@ export function initSandboxRuntimeModular(): void { const pending = compositionTimingObserver?.takeRecords() ?? []; write(); const ours = compositionTimingObserver?.takeRecords() ?? []; - if (pending.length > 0 || ours.some((record) => record.target !== el)) + if ( + recordsMoveTiming(pending) || + recordsMoveTiming(ours.filter((record) => record.target !== el)) + ) invalidateCompositionTimingCaches(); }; @@ -1408,18 +1420,19 @@ export function initSandboxRuntimeModular(): void { const pending = compositionTimingObserver?.takeRecords() ?? []; write(); compositionTimingObserver?.takeRecords(); - if (pending.length > 0) invalidateCompositionTimingCaches(); + if (recordsMoveTiming(pending)) invalidateCompositionTimingCaches(); }; const watchCompositionTimingInputs = () => { if (compositionTimingObserver || typeof MutationObserver === "undefined") return; - compositionTimingObserver = new MutationObserver(invalidateCompositionTimingCaches); + compositionTimingObserver = new MutationObserver((records) => { + if (recordsMoveTiming(records)) invalidateCompositionTimingCaches(); + }); compositionTimingObserver.observe(document.documentElement, { childList: true, subtree: true, attributes: true, - // Sizing inputs too: postTimeline applies them, and it runs when this revision moves. - attributeFilter: [...DURATION_FLOOR_INPUT_ATTRIBUTES, "data-width", "data-height"], + attributeFilter: COMPOSITION_TIMING_ATTRIBUTES, }); // Media duration changes are published as events, not DOM mutations, and // they do not bubble — so listen in the capture phase, which reaches every @@ -1451,7 +1464,7 @@ export function initSandboxRuntimeModular(): void { // here makes the caches correct within a task, not just across tasks. // Taking the records suppresses the callback for them, which is exactly // equivalent since the callback only bumps the revision. - if (compositionTimingObserver && compositionTimingObserver.takeRecords().length > 0) { + if (compositionTimingObserver && recordsMoveTiming(compositionTimingObserver.takeRecords())) { invalidateCompositionTimingCaches(); } // `window.__timelines` is a plain object: no MutationObserver and no event diff --git a/packages/core/src/runtime/timingMutations.test.ts b/packages/core/src/runtime/timingMutations.test.ts new file mode 100644 index 00000000000..b86851f4778 --- /dev/null +++ b/packages/core/src/runtime/timingMutations.test.ts @@ -0,0 +1,99 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { createTimingRecordFilter } from "./timingMutations"; + +const recordsMoveTiming = createTimingRecordFilter(["data-start", "data-duration", "id"]); + +/** Records the timing observer would see for one synchronous change. */ +const recordsOf = (change: (root: HTMLElement) => void): MutationRecord[] => { + const root = document.getElementById("root")!; + const observer = new MutationObserver(() => {}); + observer.observe(root, { + childList: true, + subtree: true, + attributes: true, + attributeFilter: ["data-start", "data-duration", "id"], + }); + change(root); + const records = observer.takeRecords(); + observer.disconnect(); + return records; +}; + +describe("createTimingRecordFilter", () => { + afterEach(() => { + document.body.innerHTML = ""; + }); + + const mount = () => { + document.body.innerHTML = + `

Hel

` + + `
a
`; + }; + + it("ignores text replaced inside an untimed element", () => { + mount(); + const records = recordsOf((root) => { + root.querySelector(".caption")!.textContent = "Hello"; + }); + expect(records.length).toBeGreaterThan(0); + expect(recordsMoveTiming(records)).toBe(false); + }); + + it("ignores an inserted element that carries no timing", () => { + mount(); + const records = recordsOf((root) => { + root.querySelector(".clip span")!.append(document.createElement("b")); + }); + expect(recordsMoveTiming(records)).toBe(false); + }); + + it("counts an inserted clip, and a subtree holding one", () => { + mount(); + expect( + recordsMoveTiming( + recordsOf((root) => { + const clip = document.createElement("div"); + clip.setAttribute("data-start", "4"); + root.append(clip); + }), + ), + ).toBe(true); + expect( + recordsMoveTiming( + recordsOf((root) => { + const wrapper = document.createElement("section"); + wrapper.innerHTML = ``; + root.append(wrapper); + }), + ), + ).toBe(true); + }); + + it("counts a removed clip", () => { + mount(); + const records = recordsOf((root) => root.querySelector(".clip")!.remove()); + expect(recordsMoveTiming(records)).toBe(true); + }); + + it("counts an inserted element another clip's start can name by id", () => { + mount(); + const records = recordsOf((root) => { + const anchor = document.createElement("span"); + anchor.id = "intro"; + root.append(anchor); + }); + expect(recordsMoveTiming(records)).toBe(true); + }); + + it("counts a timing attribute edit and an inserted script", () => { + mount(); + expect( + recordsMoveTiming( + recordsOf((root) => root.querySelector(".clip")!.setAttribute("data-duration", "3")), + ), + ).toBe(true); + expect( + recordsMoveTiming(recordsOf((root) => root.append(document.createElement("script")))), + ).toBe(true); + }); +}); diff --git a/packages/core/src/runtime/timingMutations.ts b/packages/core/src/runtime/timingMutations.ts new file mode 100644 index 00000000000..28f08938b57 --- /dev/null +++ b/packages/core/src/runtime/timingMutations.ts @@ -0,0 +1,22 @@ +import { HF_AUDIO_GROUP_TAG } from "../audioGroups"; + +// Read for timing beyond their attributes; scripts can register timelines. +const TIMING_ELEMENTS = ["video", "audio", "source", "script", HF_AUDIO_GROUP_TAG]; + +// Attribute records arrive pre-filtered to `timingAttributes`; a child-list record counts only +// when a node it adds or removes is or holds a timing element, so text edits never do. +export function createTimingRecordFilter( + timingAttributes: readonly string[], +): (records: readonly MutationRecord[]) => boolean { + const selector = [...TIMING_ELEMENTS, ...timingAttributes.map((name) => `[${name}]`)].join(","); + const carriesTiming = (node: Node): boolean => + node.nodeType === Node.ELEMENT_NODE && + ((node as Element).matches(selector) || (node as Element).querySelector(selector) !== null); + return (records) => + records.some( + (record) => + record.type === "attributes" || + Array.from(record.addedNodes).some(carriesTiming) || + Array.from(record.removedNodes).some(carriesTiming), + ); +} From 1c06896578559ef41ac5414ee8469a968fa419d7 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sat, 24 Oct 2026 23:58:35 -0400 Subject: [PATCH 2/2] perf(core): color grading stops rescanning the page on text edits --- .../core/src/runtime/colorGrading.test.ts | 43 +++++++++++++++++++ packages/core/src/runtime/colorGrading.ts | 8 +++- packages/core/src/runtime/init.ts | 2 +- ...ations.test.ts => mutationRecords.test.ts} | 2 +- packages/core/src/runtime/mutationRecords.ts | 30 +++++++++++++ packages/core/src/runtime/timingMutations.ts | 22 ---------- 6 files changed, 82 insertions(+), 25 deletions(-) rename packages/core/src/runtime/{timingMutations.test.ts => mutationRecords.test.ts} (97%) create mode 100644 packages/core/src/runtime/mutationRecords.ts delete mode 100644 packages/core/src/runtime/timingMutations.ts diff --git a/packages/core/src/runtime/colorGrading.test.ts b/packages/core/src/runtime/colorGrading.test.ts index 4c708fab64c..d5ddb0e32c5 100644 --- a/packages/core/src/runtime/colorGrading.test.ts +++ b/packages/core/src/runtime/colorGrading.test.ts @@ -2203,6 +2203,49 @@ describe("createColorGradingRuntime", () => { expect(video.hasAttribute("data-hf-color-grading-source-hidden")).toBe(true); expect(video.style.getPropertyValue("opacity")).toBe("0"); }); + + it("does not rescan the document when only text changes", async () => { + startRuntimeWithVideo(); + const caption = document.createElement("p"); + document.body.appendChild(caption); + await Promise.resolve(); + const scan = vi.spyOn(document, "querySelectorAll"); + caption.textContent = "Hello"; + caption.append(document.createElement("span")); + await Promise.resolve(); + expect( + scan.mock.calls.filter(([selector]) => selector.includes(HF_COLOR_GRADING_ATTR)), + ).toEqual([]); + }); + + it("still grades media inserted later and drops media removed", async () => { + startRuntimeWithVideo(); + const wrapper = document.createElement("section"); + const later = makeDrawableVideo(); + later.id = "later-video"; + wrapper.appendChild(later); + document.body.appendChild(wrapper); + await Promise.resolve(); + expect(runtime!.isGraded(later)).toBe(true); + wrapper.remove(); + await Promise.resolve(); + expect(runtime!.isGraded(later)).toBe(false); + }); + + it("drops media graded through the API when a wrapper holding it is removed", async () => { + startRuntimeWithVideo(); + const wrapper = document.createElement("section"); + const live = makeDrawableVideo(); + live.id = "live-video"; + live.removeAttribute(HF_COLOR_GRADING_ATTR); + wrapper.appendChild(live); + document.body.appendChild(wrapper); + runtime!.setGrading(live, { adjust: { exposure: 0.5 } }); + expect(runtime!.isGraded(live)).toBe(true); + wrapper.remove(); + await Promise.resolve(); + expect(runtime!.isGraded(live)).toBe(false); + }); }); describe("createColorGradingRuntime after a context loss", () => { diff --git a/packages/core/src/runtime/colorGrading.ts b/packages/core/src/runtime/colorGrading.ts index 600eac0a89e..88ce8d77344 100644 --- a/packages/core/src/runtime/colorGrading.ts +++ b/packages/core/src/runtime/colorGrading.ts @@ -42,6 +42,7 @@ import { import { copyMediaVisualStyles } from "../inline-scripts/parityContract"; import { readVariablesForElement } from "./variableScope"; import { swallow } from "./diagnostics"; +import { createRecordFilter } from "./mutationRecords"; import { findInjectedRenderFrame } from "./renderFrameSibling"; import { isCanvasElement, @@ -4276,7 +4277,12 @@ export function createColorGradingRuntime(pausedMediaLease?: { }; if (document.body) { - observer = new MutationObserver(() => refresh()); + const recordsTouchGrading = createRecordFilter(COLOR_GRADING_SOURCE_SELECTOR, (node) => + Array.from(trackedElements).some((element) => node.contains(element)), + ); + observer = new MutationObserver((records) => { + if (recordsTouchGrading(records)) refresh(); + }); observer.observe(document.body, { childList: true, subtree: true, diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index e9552d6989e..566c62b9e92 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -179,7 +179,7 @@ import { parseStrictFiniteTimingNumber, resolveMediaElementDurationSeconds } fro import { cutToHostSlots, MEDIA_START_BASIS_ATTR } from "../mediaTiming"; import { settleFirstFrameCompositionReadiness } from "../compositionReadiness"; import { AUTHORED_DURATION_ATTR, AUTHORED_END_ATTR } from "./authoredTiming"; -import { createTimingRecordFilter } from "./timingMutations"; +import { createTimingRecordFilter } from "./mutationRecords"; import { clearRuntimeData, setRuntimeData, diff --git a/packages/core/src/runtime/timingMutations.test.ts b/packages/core/src/runtime/mutationRecords.test.ts similarity index 97% rename from packages/core/src/runtime/timingMutations.test.ts rename to packages/core/src/runtime/mutationRecords.test.ts index b86851f4778..1c2ce6b5aa2 100644 --- a/packages/core/src/runtime/timingMutations.test.ts +++ b/packages/core/src/runtime/mutationRecords.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it } from "vitest"; -import { createTimingRecordFilter } from "./timingMutations"; +import { createTimingRecordFilter } from "./mutationRecords"; const recordsMoveTiming = createTimingRecordFilter(["data-start", "data-duration", "id"]); diff --git a/packages/core/src/runtime/mutationRecords.ts b/packages/core/src/runtime/mutationRecords.ts new file mode 100644 index 00000000000..4434f1ef248 --- /dev/null +++ b/packages/core/src/runtime/mutationRecords.ts @@ -0,0 +1,30 @@ +import { HF_AUDIO_GROUP_TAG } from "../audioGroups"; + +// Read for timing beyond their attributes; scripts can register timelines. +const TIMING_ELEMENTS = ["video", "audio", "source", "script", HF_AUDIO_GROUP_TAG]; + +// Observers here pre-filter attribute records, so those always count. A child-list record counts +// only when a node it adds or removes matches or holds `selector`, or `holds` says it holds +// something the caller tracks; a text edit never does. +export function createRecordFilter( + selector: string, + holds: (node: Element) => boolean = () => false, +): (records: readonly MutationRecord[]) => boolean { + const touches = (node: Node): boolean => + node.nodeType === Node.ELEMENT_NODE && + ((node as Element).matches(selector) || + (node as Element).querySelector(selector) !== null || + holds(node as Element)); + return (records) => + records.some( + (record) => + record.type === "attributes" || + Array.from(record.addedNodes).some(touches) || + Array.from(record.removedNodes).some(touches), + ); +} + +export const createTimingRecordFilter = (timingAttributes: readonly string[]) => + createRecordFilter( + [...TIMING_ELEMENTS, ...timingAttributes.map((name) => `[${name}]`)].join(","), + ); diff --git a/packages/core/src/runtime/timingMutations.ts b/packages/core/src/runtime/timingMutations.ts deleted file mode 100644 index 28f08938b57..00000000000 --- a/packages/core/src/runtime/timingMutations.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { HF_AUDIO_GROUP_TAG } from "../audioGroups"; - -// Read for timing beyond their attributes; scripts can register timelines. -const TIMING_ELEMENTS = ["video", "audio", "source", "script", HF_AUDIO_GROUP_TAG]; - -// Attribute records arrive pre-filtered to `timingAttributes`; a child-list record counts only -// when a node it adds or removes is or holds a timing element, so text edits never do. -export function createTimingRecordFilter( - timingAttributes: readonly string[], -): (records: readonly MutationRecord[]) => boolean { - const selector = [...TIMING_ELEMENTS, ...timingAttributes.map((name) => `[${name}]`)].join(","); - const carriesTiming = (node: Node): boolean => - node.nodeType === Node.ELEMENT_NODE && - ((node as Element).matches(selector) || (node as Element).querySelector(selector) !== null); - return (records) => - records.some( - (record) => - record.type === "attributes" || - Array.from(record.addedNodes).some(carriesTiming) || - Array.from(record.removedNodes).some(carriesTiming), - ); -}