diff --git a/packages/lint/src/rules/media.ts b/packages/lint/src/rules/media.ts index d9640478df7..b5044ef1113 100644 --- a/packages/lint/src/rules/media.ts +++ b/packages/lint/src/rules/media.ts @@ -391,6 +391,7 @@ function findRuntimeMediaSrcMutationFindings(ctx: LintContext): HyperframeLintFi const findings: HyperframeLintFinding[] = []; for (const script of ctx.scripts) { for (const mutation of extractMediaSrcMutations(script.content)) { + if (!mutation.selector) continue; let targets: Element[]; try { const id = /^#[A-Za-z_][\w-]*$/.test(mutation.selector) ? mutation.selector.slice(1) : null; diff --git a/packages/parsers/src/mediaSrcMutation.ts b/packages/parsers/src/mediaSrcMutation.ts index 942ecfd4370..52a274f8cab 100644 --- a/packages/parsers/src/mediaSrcMutation.ts +++ b/packages/parsers/src/mediaSrcMutation.ts @@ -2,7 +2,8 @@ import * as acorn from "acorn"; import * as acornWalk from "acorn-walk"; export interface MediaSrcMutation { - selector: string; + /** Null when the write's target is not a statically resolvable DOM lookup. */ + selector: string | null; operation: "src_assignment" | "set_attribute"; raw: string; } @@ -206,7 +207,7 @@ function selectorFromDomLookup( return method === "getElementById" ? `#${value}` : value; } -/** Find literal source writes whose target is a statically resolvable DOM lookup. */ +/** Find source writes, with the target's selector when it is a statically resolvable DOM lookup. */ export function extractMediaSrcMutations(script: string): MediaSrcMutation[] { try { const ast = parseProgram(script); @@ -220,10 +221,8 @@ export function extractMediaSrcMutations(script: string): MediaSrcMutation[] { memberName(node.left) !== "src" ) return; - const selector = lookupSelector(node.left.object, scopeAt(ancestors)); - if (!selector) return; mutations.push({ - selector, + selector: lookupSelector(node.left.object, scopeAt(ancestors)) ?? null, operation: "src_assignment", raw: script.slice(node.start, node.end), }); @@ -232,10 +231,8 @@ export function extractMediaSrcMutations(script: string): MediaSrcMutation[] { if (node.callee.type !== "MemberExpression" || memberName(node.callee) !== "setAttribute") return; if (literalString(node.arguments[0])?.toLowerCase() !== "src") return; - const selector = lookupSelector(node.callee.object, scopeAt(ancestors)); - if (!selector) return; mutations.push({ - selector, + selector: lookupSelector(node.callee.object, scopeAt(ancestors)) ?? null, operation: "set_attribute", raw: script.slice(node.start, node.end), }); diff --git a/packages/producer/src/services/render/stages/probeStage.test.ts b/packages/producer/src/services/render/stages/probeStage.test.ts index fe927ed223f..420ed418ab6 100644 --- a/packages/producer/src/services/render/stages/probeStage.test.ts +++ b/packages/producer/src/services/render/stages/probeStage.test.ts @@ -1113,6 +1113,43 @@ describe("runProbeStage — forceScreenshot threading", () => { expect(capturedCfgs).toHaveLength(0); }); + it("probes a src write whose target is resolved at runtime when the document has media", async () => { + resetRetryMocks(); + capturedCfgs.length = 0; + const { runProbeStage } = await import("./probeStage.js"); + const input = makeProbeInput({}); + input.composition.duration = 5; + input.compiled.html = ` + `; + + await runProbeStage(input); + + expect(capturedCfgs.length).toBeGreaterThan(0); + }); + + it("does not probe a src write resolved at runtime when the document has no media", async () => { + resetRetryMocks(); + capturedCfgs.length = 0; + const { runProbeStage } = await import("./probeStage.js"); + const input = makeProbeInput({}); + input.composition.duration = 5; + input.compiled.html = ` + `; + + await runProbeStage(input); + + expect(capturedCfgs).toHaveLength(0); + }); + it("probes when a source child of existing media is mutated", async () => { resetRetryMocks(); capturedCfgs.length = 0; diff --git a/packages/producer/src/services/render/stages/probeStage.ts b/packages/producer/src/services/render/stages/probeStage.ts index 83865f499aa..a8ee2300609 100644 --- a/packages/producer/src/services/render/stages/probeStage.ts +++ b/packages/producer/src/services/render/stages/probeStage.ts @@ -289,8 +289,15 @@ function hasRuntimeMediaChanges(html: string): boolean { if (name === "video" || name === "audio") return true; return name === "source" && element.closest("video, audio") !== null; }; + const hasManagedMedia = document.querySelector("video, audio") !== null; for (const script of scriptBodies) { for (const mutation of extractMediaSrcMutations(script)) { + // A write whose target can't be resolved statically, such as one in a loop + // over querySelectorAll, may land on any media in the document. + if (!mutation.selector) { + if (hasManagedMedia) return true; + continue; + } try { const id = /^#[A-Za-z_][\w-]*$/.test(mutation.selector) ? mutation.selector.slice(1) : null; const idTarget = id ? document.getElementById(id) : null;