Skip to content

Commit d5a7ee5

Browse files
committed
fix(parsers): make the sub-composition scan linear, not quadratic
The shared scan ran one regex with two open-ended `[^>]*` spans across the whole file. On input full of `<` with no `>`, every `<` starts a scan to end-of-string that then backtracks. Measured on the previous implementation: 10k chars of '<' -> 41ms 20k chars of '<' -> 165ms 40k chars of '<' -> 660ms 80k chars of '<' -> 2640ms Four times the work for twice the input. Its caller allows files up to 20MB, and a 20MB run of '<' did not finish in over ten minutes. The regex itself predates this branch (it was inline in lint), but this branch is what made it matter: the scan is now a shared exported function wired synchronously into `createRenderPlan`, so it runs on every `hyperframes render` before the render starts. A truncated download or a blob of stray `<` could hang the plan step before any video is produced. That is a render-blocking failure, which is exactly what this feature was built never to risk. The scan now walks tag by tag with `indexOf` and applies a bounded attribute regex to one already-delimited tag, so no quantifier ranges over the whole file. Same inputs, after: 80k chars of '<' -> 0ms 20MB of '<' -> 27ms 20MB of real mounts -> 91ms Semantics are unchanged: the previous regex also treated `>` as a tag terminator and also required a closing `>`, so an unterminated final tag was never a match before either. Fixing it at the shared owner fixes lint's path too. The regression test needs no timing assertion to bite. A megabyte of `<` ran for minutes under the old scan, so the case simply failed on the suite timeout.
1 parent 412808a commit d5a7ee5

2 files changed

Lines changed: 71 additions & 4 deletions

File tree

packages/parsers/src/assetResolution.test.ts

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import { describe, expect, it } from "vitest";
2-
import { isUnresolvedAssetPlaceholder, maskNonScannableRanges } from "./assetResolution.js";
2+
import {
3+
collectSubCompositionSrcs,
4+
isUnresolvedAssetPlaceholder,
5+
maskNonScannableRanges,
6+
} from "./assetResolution.js";
37

48
describe("maskNonScannableRanges", () => {
59
it("masks complete comments without changing offsets", () => {
@@ -55,3 +59,44 @@ describe("isUnresolvedAssetPlaceholder", () => {
5559
}
5660
});
5761
});
62+
63+
describe("collectSubCompositionSrcs", () => {
64+
it("finds mounts inside a template, which a DOM query cannot see", () => {
65+
const html =
66+
'<!doctype html><html><body><div data-composition-src="compositions/a.html"></div>' +
67+
'<template id="t"><div data-composition-src="compositions/b.html"></div></template></body></html>';
68+
expect(collectSubCompositionSrcs(html)).toEqual(["compositions/a.html", "compositions/b.html"]);
69+
});
70+
71+
it("skips commented-out, scripted, and styled mounts", () => {
72+
const html =
73+
'<!-- <div data-composition-src="commented.html"></div> -->' +
74+
"<script>const s = '<div data-composition-src=\"scripted.html\"></div>';</script>" +
75+
'<style>/* <div data-composition-src="styled.html"></div> */</style>' +
76+
'<div data-composition-src="real.html"></div>';
77+
expect(collectSubCompositionSrcs(html)).toEqual(["real.html"]);
78+
});
79+
80+
it("skips build-time placeholders and dedupes repeats", () => {
81+
const html =
82+
'<div data-composition-src="__SCENE__"></div>' +
83+
'<div data-composition-src="{{scene}}"></div>' +
84+
'<div data-composition-src="a.html"></div><div data-composition-src="a.html"></div>';
85+
expect(collectSubCompositionSrcs(html)).toEqual(["a.html"]);
86+
});
87+
88+
it("ignores an unterminated final tag and an attribute outside any tag", () => {
89+
expect(collectSubCompositionSrcs('<div data-composition-src="a.html"')).toEqual([]);
90+
expect(collectSubCompositionSrcs('data-composition-src="a.html"')).toEqual([]);
91+
});
92+
93+
// Regression guard, and it needs no timing assertion to bite: the previous
94+
// whole-file regex had two open-ended `[^>]*` spans, which is quadratic on
95+
// input full of `<` with no `>`. At 1MB that ran for minutes, so this case
96+
// failed on the suite timeout. This scan walks tag by tag and is linear.
97+
// The function is on the render-plan path, so a truncated download or a blob
98+
// of stray `<` must not be able to hang a render before it starts.
99+
it("stays fast on a megabyte of unterminated tag openings", () => {
100+
expect(collectSubCompositionSrcs("<".repeat(1024 * 1024))).toEqual([]);
101+
});
102+
});

packages/parsers/src/assetResolution.ts

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,9 @@ export function isUnresolvedAssetPlaceholder(rawSrc: string): boolean {
4242
return /^__[A-Z_]+__$/.test(rawSrc.trim()) || hasUnresolvedTemplatingToken(rawSrc);
4343
}
4444

45+
/** `data-composition-src="..."`, matched within a single already-delimited tag. */
46+
const COMPOSITION_SRC_ATTR = /\bdata-composition-src\s*=\s*["']([^"']+)["']/i;
47+
4548
/**
4649
* Every `data-composition-src` reference in one composition file's raw text, in
4750
* document order, deduped. The single owner of "which sub-compositions does
@@ -63,14 +66,33 @@ export function isUnresolvedAssetPlaceholder(rawSrc: string): boolean {
6366
*
6467
* Comments, `<style>`, and `<script>` bodies are masked first so a
6568
* commented-out mount is not counted as a real one.
69+
*
70+
* The scan walks tag by tag with `indexOf` rather than running one regex with
71+
* two open-ended `[^>]*` spans across the whole file. That shape is quadratic:
72+
* on input full of `<` with no `>`, every `<` starts a scan to end-of-string
73+
* that then backtracks, measured at 41ms / 165ms / 660ms / 2640ms for 10k /
74+
* 20k / 40k / 80k characters. This function runs on every render (via the
75+
* render plan), so a truncated download or a blob full of stray `<` would hang
76+
* the plan step before any video is produced. Bounding each regex to one
77+
* already-delimited tag makes the whole scan linear.
6678
*/
6779
export function collectSubCompositionSrcs(html: string): string[] {
68-
const compositionSrcRe = /<[^>]*\bdata-composition-src\s*=\s*["']([^"']+)["'][^>]*>/gi;
6980
const scannable = maskNonScannableRanges(html);
7081
const srcs: string[] = [];
7182
const seen = new Set<string>();
72-
let match: RegExpExecArray | null;
73-
while ((match = compositionSrcRe.exec(scannable)) !== null) {
83+
84+
let cursor = 0;
85+
while (cursor < scannable.length) {
86+
const open = scannable.indexOf("<", cursor);
87+
if (open === -1) break;
88+
const close = scannable.indexOf(">", open + 1);
89+
// An unterminated final tag is not a tag. The previous whole-file regex
90+
// also required a closing `>`, so this drops nothing it used to find.
91+
if (close === -1) break;
92+
cursor = close + 1;
93+
94+
const match = COMPOSITION_SRC_ATTR.exec(scannable.slice(open, cursor));
95+
if (!match) continue;
7496
const src = (match[1] ?? "").trim();
7597
if (!src || seen.has(src)) continue;
7698
// __UPPER__ placeholder or late-bound templating token — not a real reference.

0 commit comments

Comments
 (0)