Skip to content

Commit 1ddf9cc

Browse files
authored
Merge pull request #2563 from heygen-com/via/thumbnail-id-escape
fix(studio,runtime): CSS.escape ids so digit-leading selectors don't crash
2 parents a7c0fa4 + d05c899 commit 1ddf9cc

5 files changed

Lines changed: 164 additions & 5 deletions

File tree

.fallowrc.jsonc

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -583,6 +583,16 @@
583583
"packages/studio/src/components/editor/PropertyPanel.test.tsx",
584584
"packages/studio/src/components/editor/propertyPanelFlatStyleSections.test.tsx",
585585
"packages/studio/src/components/editor/propertyPanelFlatMotionSection.test.tsx",
586+
// Thumbnail id-escape fix (via/thumbnail-id-escape): getElementScreenshotClip's
587+
// matches/rect/pad clip-computation body has a pre-existing 19-line clone with
588+
// the inline `page.evaluate` block in vite.browser.ts (the dev-server thumbnail
589+
// path). Adding a try/catch guard around the querySelectorAll call shifts the
590+
// clone's line numbers, re-flagging the inherited duplication. Extracting the
591+
// shared body into a common helper would require the browser-side page.evaluate
592+
// to import from a Node-side module, which puppeteer's serialization boundary
593+
// makes non-trivial.
594+
"packages/studio-server/src/helpers/screenshotClip.ts",
595+
"packages/studio/vite.browser.ts",
586596
],
587597
},
588598
"health": {
@@ -798,6 +808,16 @@
798808
// `waitForCompletion` is untouched. Line-shift fingerprint re-flags
799809
// the inherited complexity.
800810
"packages/cli/src/commands/lambda/render.ts",
811+
// Thumbnail id-escape fix (via/thumbnail-id-escape): picker.ts has five
812+
// pre-existing inherited-complexity findings (isEffectivelyHidden,
813+
// isPickableElement, buildElementLabel, getPickCandidatesFromPoint,
814+
// pickManyAtPoint — all in this file at the parent SHA). This PR's only
815+
// logic edit inside picker.ts is a single-line change to buildElementSelector
816+
// (`#${htmlEl.id}` → `#${CSS.escape(htmlEl.id)}`) plus a three-line comment;
817+
// the added lines shift every function below buildElementSelector, and Fallow's
818+
// file-level fingerprint invalidation re-flags the inherited findings even at
819+
// unchanged line numbers.
820+
"packages/core/src/runtime/picker.ts",
801821
],
802822
},
803823
}

packages/core/src/runtime/picker.test.ts

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,30 @@
1-
import { describe, it, expect, vi, afterEach } from "vitest";
1+
import { describe, it, expect, vi, afterEach, beforeAll } from "vitest";
22
import { createPickerModule } from "./picker";
33

4+
// jsdom does not implement CSS.escape — polyfill a compact spec-adjacent
5+
// version. Parallel (simpler) polyfills already live in compositionLoader.test.ts
6+
// / startResolver.test.ts, but they don't handle the leading-digit case this
7+
// test needs. Each test file runs in an isolated environment, so we duplicate
8+
// rather than import.
9+
beforeAll(() => {
10+
const css = globalThis.CSS as { escape?: (input: string) => string } | undefined;
11+
if (!css || typeof css.escape !== "function") {
12+
(globalThis as { CSS?: { escape: (input: string) => string } }).CSS = {
13+
...(css ?? {}),
14+
escape: (value: string) => {
15+
// Non-word chars get a leading backslash (spec-adjacent).
16+
const escaped = value.replace(/([^\w-])/g, "\\$1");
17+
// A leading digit must be encoded as `\<hex> ` (space terminator) per CSS spec.
18+
const first = value.charCodeAt(0);
19+
if (first >= 48 && first <= 57) {
20+
return `\\${first.toString(16)} ${escaped.slice(1)}`;
21+
}
22+
return escaped;
23+
},
24+
};
25+
}
26+
});
27+
428
function createMockPostMessage() {
529
return vi.fn();
630
}
@@ -181,4 +205,52 @@ describe("createPickerModule", () => {
181205
expect(document.body.classList.contains("__hf-pick-active")).toBe(true);
182206
});
183207
});
208+
209+
describe("buildElementSelector escapes digit-leading ids", () => {
210+
it('produces a CSS-valid selector for id="0" and picks the element back', () => {
211+
// Regression: a user's HTML with id="0" (or any digit-leading id) used
212+
// to produce the raw selector "#0", which is invalid per the CSS spec —
213+
// downstream querySelector calls threw SyntaxError. buildElementSelector
214+
// now CSS.escapes the id.
215+
const picker = createPickerModule({ postMessage: createMockPostMessage() });
216+
picker.installPickerApi();
217+
const el = document.createElement("div");
218+
el.id = "0";
219+
Object.assign(el.style, {
220+
position: "absolute",
221+
left: "0px",
222+
top: "0px",
223+
width: "40px",
224+
height: "40px",
225+
});
226+
document.body.appendChild(el);
227+
228+
// Force elementsFromPoint to hit our div so we exercise the real code
229+
// path that calls buildElementSelector via extractElementInfo.
230+
const originalElementsFromPoint = document.elementsFromPoint;
231+
Object.defineProperty(document, "elementsFromPoint", {
232+
configurable: true,
233+
value: () => [el],
234+
});
235+
try {
236+
const api = (
237+
window as {
238+
__HF_PICKER_API?: {
239+
pickAtPoint?: (x: number, y: number) => { selector: string } | null;
240+
};
241+
}
242+
).__HF_PICKER_API;
243+
const picked = api?.pickAtPoint?.(10, 10);
244+
expect(picked?.selector).toBe("#\\30 ");
245+
// And the round trip must find the element back through querySelector.
246+
expect(() => document.querySelector(picked?.selector ?? "")).not.toThrow();
247+
expect(document.querySelector(picked?.selector ?? "")).toBe(el);
248+
} finally {
249+
Object.defineProperty(document, "elementsFromPoint", {
250+
configurable: true,
251+
value: originalElementsFromPoint,
252+
});
253+
}
254+
});
255+
});
184256
});

packages/core/src/runtime/picker.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,10 @@ export function createPickerModule(deps: PickerModuleDeps): PickerModule {
9595

9696
function buildElementSelector(el: Element): string {
9797
const htmlEl = el as HTMLElement;
98-
if (htmlEl.id) return `#${htmlEl.id}`;
98+
// Escape the ID so digit-leading or otherwise CSS-illegal ids (e.g. `#0`,
99+
// `#1`) produce valid selectors — `document.querySelector("#0")` throws
100+
// SyntaxError per the CSS spec. Sibling branches below already escape.
101+
if (htmlEl.id) return `#${CSS.escape(htmlEl.id)}`;
99102
const compositionId = el.getAttribute("data-composition-id");
100103
if (compositionId) return `[data-composition-id="${CSS.escape(compositionId)}"]`;
101104
const compositionSrc = el.getAttribute("data-composition-src");
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { afterEach, describe, expect, it } from "vitest";
2+
import { getElementScreenshotClip } from "./screenshotClip";
3+
4+
afterEach(() => {
5+
document.body.innerHTML = "";
6+
});
7+
8+
describe("getElementScreenshotClip", () => {
9+
it("returns undefined (not throws) when the selector is CSS-invalid", () => {
10+
// Regression: an HTML element with `id="0"` produces the selector `#0`,
11+
// which is invalid per the CSS spec — `document.querySelectorAll('#0')`
12+
// throws SyntaxError. Puppeteer surfaces that as a page.evaluate error,
13+
// which used to bubble up and fail the whole thumbnail. The clip helper
14+
// now swallows the SyntaxError so callers fall back to a full-page shot.
15+
const el = document.createElement("div");
16+
el.id = "0";
17+
Object.assign(el.style, {
18+
width: "100px",
19+
height: "80px",
20+
});
21+
document.body.appendChild(el);
22+
23+
expect(() => getElementScreenshotClip("#0")).not.toThrow();
24+
expect(getElementScreenshotClip("#0")).toBeUndefined();
25+
});
26+
27+
it("returns undefined (not throws) for garbage selectors", () => {
28+
expect(() => getElementScreenshotClip("::: garbage :::")).not.toThrow();
29+
expect(getElementScreenshotClip("::: garbage :::")).toBeUndefined();
30+
});
31+
32+
it("returns a clip for a well-formed selector matching a visible element", () => {
33+
const el = document.createElement("div");
34+
el.id = "hero";
35+
el.getBoundingClientRect = () =>
36+
({
37+
left: 10,
38+
top: 20,
39+
width: 100,
40+
height: 80,
41+
right: 110,
42+
bottom: 100,
43+
x: 10,
44+
y: 20,
45+
toJSON: () => ({}),
46+
}) as DOMRect;
47+
document.body.appendChild(el);
48+
49+
const clip = getElementScreenshotClip("#hero");
50+
expect(clip).toBeDefined();
51+
expect(clip?.width).toBeGreaterThan(0);
52+
expect(clip?.height).toBeGreaterThan(0);
53+
});
54+
});

packages/studio-server/src/helpers/screenshotClip.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,19 @@ export function getElementScreenshotClip(
99
selector: string,
1010
selectorIndex?: number,
1111
): ScreenshotClip | undefined {
12-
const matches = Array.from(document.querySelectorAll(selector)).filter(
13-
(el): el is HTMLElement => el instanceof HTMLElement,
14-
);
12+
// Guard against invalid CSS selectors (e.g. `#0` — a digit-leading id from
13+
// user HTML that upstream producers forgot to CSS.escape). querySelectorAll
14+
// throws SyntaxError on those, which bubbles out of page.evaluate and fails
15+
// the whole thumbnail. Returning undefined here falls back to a full-page
16+
// screenshot, so the user still sees a thumbnail instead of a broken image.
17+
let matches: HTMLElement[];
18+
try {
19+
matches = Array.from(document.querySelectorAll(selector)).filter(
20+
(el): el is HTMLElement => el instanceof HTMLElement,
21+
);
22+
} catch {
23+
return undefined;
24+
}
1525
const safeIndex = Math.max(0, Math.min(matches.length - 1, Math.floor(selectorIndex ?? 0)));
1626
const el = matches[safeIndex] ?? null;
1727
if (!(el instanceof HTMLElement)) return undefined;

0 commit comments

Comments
 (0)