Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions packages/studio/src/components/editor/domEditingLayers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -257,3 +257,51 @@ describe("collectDomEditLayerItems item budget", () => {
expect(collectDomEditLayerItems(documentWith(200), opts, 80)).toHaveLength(80);
});
});

describe("collectDomEditLayerItems selector-index cost", () => {
// Attached, unlike the fixture above: a detached subtree is invisible to
// document.querySelectorAll, so the occurrence lookup would find nothing.
function attachedRootWithSharedClass(count: number): HTMLElement {
const root = document.createElement("div");
root.setAttribute("data-composition-id", "index.html");
for (let i = 0; i < count; i++) {
const child = document.createElement("div");
child.className = "box";
root.append(child);
}
document.body.append(root);
return root;
}

/** Class-selector document queries made by one walk over `count` sibling cards. */
function classSelectorQueries(count: number): number {
const root = attachedRootWithSharedClass(count);
const doc = root.ownerDocument;
const real = doc.querySelectorAll.bind(doc);
let calls = 0;
Object.defineProperty(doc, "querySelectorAll", {
configurable: true,
value: (selector: string) => {
if (selector.startsWith(".")) calls += 1;
return real(selector);
},
});
try {
expect(collectDomEditLayerItems(root, opts)).toHaveLength(count);
return calls;
} finally {
delete (doc as Partial<Document>).querySelectorAll;
root.remove();
}
}

// The occurrence index is resolved here, for every item, so an unshared index
// costs one whole-document query per element — quadratic once a composition
// repeats a card or tile class. Owning the pass here rather than at each call
// site is what keeps the layers panel, the marquee and the agent's look tool
// linear too; invariance across a 4x fixture fails for any per-element term.
it("resolves a shared selector once per walk, not once per element", () => {
expect(classSelectorQueries(48)).toBe(classSelectorQueries(12));
expect(classSelectorQueries(12)).toBe(1);
});
});
12 changes: 10 additions & 2 deletions packages/studio/src/components/editor/domEditingLayers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import {
getSelectionCandidate,
} from "./domEditingElement";
import { isCompositionRootLayer } from "./domEditingRootLayer";
import { withSelectorIndexPass } from "../../utils/sourceScopedSelectorIndex";

export function isEditableTextLeaf(el: HTMLElement): boolean {
return isTextBearingTag(el.tagName.toLowerCase()) && el.children.length === 0;
Expand Down Expand Up @@ -475,8 +476,15 @@ export function collectDomEditLayerItems(
}
};

// Drilled into a group → show only its members; otherwise the whole tree.
for (const el of groupScopedLayerRoots(root, options.activeGroupElement ?? null)) visit(el, 0);
// Every item resolves its selector's occurrence index, and unshared that is a
// whole-document query per element — quadratic once a composition repeats a
// card or tile class. The walk is one synchronous read of a document it does
// not mutate, so one index per selector serves the whole of it. The pass lives
// here rather than in each caller because this function owns the loop.
withSelectorIndexPass(root.ownerDocument, () => {
// Drilled into a group → show only its members; otherwise the whole tree.
for (const el of groupScopedLayerRoots(root, options.activeGroupElement ?? null)) visit(el, 0);
});
return items;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
// @vitest-environment happy-dom

import type React from "react";
import { afterEach, describe, expect, it } from "vitest";
import type { OffCanvasRect } from "./OffCanvasIndicators";
import { recomputeOffCanvasIndicators } from "./offCanvasIndicatorGeometry";

const realGetBoundingClientRect = Element.prototype.getBoundingClientRect;
afterEach(() => {
Element.prototype.getBoundingClientRect = realGetBoundingClientRect;
});

interface Rebuild {
/** querySelectorAll calls on the preview document, per selector. */
queriesBySelector: Map<string, number>;
/** The indicator keys, which carry each element's selector occurrence index. */
keys: string[];
}

/**
* One real rebuild over a preview whose `cardCount` cards all share `.box`,
* counting the preview document's queries. Everything below the entry point is
* production code: the layer walk, the patch targets, and the selector
* occurrence indices that end up in each indicator's key.
*/
function rebuildWithSharedSelector(cardCount: number): Rebuild {
const iframe = document.createElement("iframe");
document.body.append(iframe);
const doc = iframe.contentDocument;
if (!doc) throw new Error("Expected iframe content document");

const cards = Array.from(
{ length: cardCount },
(_unused, i) => `<div class="box"><span class="label">card ${i}</span></div>`,
).join("");
doc.body.innerHTML = `<div data-composition-id="root" data-width="800" data-height="450">${cards}</div>`;

const overlay = document.createElement("div");
document.body.append(overlay);

// Cards sit left of the composition, so every one of them is off-canvas and
// reaches the indicator list; everything else measures empty and does not.
Element.prototype.getBoundingClientRect = function (): DOMRect {
if (this === iframe || this === overlay) return new DOMRect(0, 0, 800, 450);
if (this instanceof doc.defaultView!.Element && this.classList.contains("box")) {
return new DOMRect(-500, 40, 100, 40);
}
return new DOMRect(0, 0, 0, 0);
};

const queriesBySelector = new Map<string, number>();
const realQuerySelectorAll = doc.querySelectorAll.bind(doc);
Object.defineProperty(doc, "querySelectorAll", {
configurable: true,
value: (selector: string) => {
queriesBySelector.set(selector, (queriesBySelector.get(selector) ?? 0) + 1);
return realQuerySelectorAll(selector);
},
});

const sigRef = { current: "" } as React.MutableRefObject<string>;
const elementsRef = { current: new Map<string, HTMLElement>() } as React.MutableRefObject<
Map<string, HTMLElement>
>;
let rects: OffCanvasRect[] = [];

recomputeOffCanvasIndicators(
iframe,
overlay,
doc,
{ left: 0, top: 0, width: 800, height: 450 },
"index.html",
sigRef,
elementsRef,
(next) => {
rects = next;
},
);

iframe.remove();
overlay.remove();
return { queriesBySelector, keys: rects.map((rect) => rect.key) };
}

/** The queries that resolve a class selector's occurrence index — the work this
* guards. Excluded: the per-element `[data-composition-id]` ancestor lookup and
* the stylesheet scan, which are linear per element and a separate seam. */
function classSelectorQueries(rebuild: Rebuild): Array<[string, number]> {
return [...rebuild.queriesBySelector].filter(([selector]) => selector.startsWith(".")).sort();
}

describe("recomputeOffCanvasIndicators selector-index cost", () => {
// The defect this guards: the occurrence index used to be resolved with its
// own whole-document querySelectorAll PER element, so n cards sharing a class
// cost n queries and n² source-file resolutions. A threshold would pass on a
// small fixture while a real composition runs hundreds of cards. Invariance
// across a 4x fixture cannot — it fails for any per-element term at all.
it("resolves shared selectors with the same number of queries at n and at 4n", () => {
const small = rebuildWithSharedSelector(12);
const large = rebuildWithSharedSelector(48);

expect(classSelectorQueries(large)).toEqual(classSelectorQueries(small));
expect(classSelectorQueries(small)).toEqual([
[".box", 1],
[".label", 1],
]);
});

it("still numbers every shared-selector element in document order", () => {
for (const cardCount of [12, 48]) {
const { keys } = rebuildWithSharedSelector(cardCount);
expect(keys).toEqual(
Array.from({ length: cardCount }, (_unused, i) => `index.html:.box:${i}`),
);
}
});
});
148 changes: 148 additions & 0 deletions packages/studio/src/utils/sourceScopedSelectorIndex.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
// @vitest-environment happy-dom

import { describe, expect, it } from "vitest";
import { getSourceScopedSelectorIndex, withSelectorIndexPass } from "./sourceScopedSelectorIndex";

// The per-element algorithm this helper used before it grew a per-pass index,
// transcribed verbatim. It is the independent source the memoized results are
// checked against — including the misses, which are `indexOf` returning -1.
function referenceIndex(
doc: Document,
el: Element,
selector: string | undefined,
sourceFile: string | undefined,
resolveSourceFile: (candidate: Element) => string | undefined,
): number | undefined {
if (!selector || selector.startsWith("#") || selector.startsWith("[data-composition-id=")) {
return undefined;
}
try {
const scope = sourceFile ?? "index.html";
const matches = Array.from(doc.querySelectorAll(selector)).filter(
(candidate) => (resolveSourceFile(candidate) ?? "index.html") === scope,
);
const matchIndex = matches.indexOf(el);
return matchIndex >= 0 ? matchIndex : undefined;
} catch {
return undefined;
}
}

const bySourceFile = (candidate: Element): string | undefined =>
candidate.closest("[data-composition-file]")?.getAttribute("data-composition-file") ?? undefined;

// Two source files sharing one class, plus an unscoped element, is the case
// source-file scoping exists for: raw document order would number these 0..4.
function mixedSourceDocument(): Document {
const doc = document.implementation.createHTMLDocument("mixed");
doc.body.innerHTML = `
<div data-composition-id="root">
<section data-composition-file="a.html">
<p class="box" id="a0">a0</p>
<p class="box" id="a1">a1</p>
</section>
<section data-composition-file="b.html">
<p class="box" id="b0">b0</p>
<p class="box" id="b1">b1</p>
</section>
<p class="box" id="root0">root0</p>
</div>
`;
return doc;
}

describe("getSourceScopedSelectorIndex", () => {
it("numbers occurrences within each source file, not across the flattened document", () => {
const doc = mixedSourceDocument();
const read = (id: string, sourceFile: string | undefined) =>
getSourceScopedSelectorIndex(doc, doc.getElementById(id)!, ".box", sourceFile, bySourceFile);

expect(read("a0", "a.html")).toBe(0);
expect(read("a1", "a.html")).toBe(1);
expect(read("b0", "b.html")).toBe(0);
expect(read("b1", "b.html")).toBe(1);
expect(read("root0", undefined)).toBe(0);
// Asking for an element under the wrong scope is a miss, not a neighbour's index.
expect(read("b0", "a.html")).toBeUndefined();
});

it("matches the per-element reference for every element, inside a pass and outside it", () => {
const doc = mixedSourceDocument();
const boxes = Array.from(doc.querySelectorAll(".box"));
const scopes = [undefined, "index.html", "a.html", "b.html", "missing.html"];
const cases = boxes.flatMap((el) => scopes.map((scope) => ({ el, scope })));

const expected = cases.map(({ el, scope }) =>
referenceIndex(doc, el, ".box", scope, bySourceFile),
);

const outsidePass = cases.map(({ el, scope }) =>
getSourceScopedSelectorIndex(doc, el, ".box", scope, bySourceFile),
);
const insidePass = withSelectorIndexPass(doc, () =>
cases.map(({ el, scope }) =>
getSourceScopedSelectorIndex(doc, el, ".box", scope, bySourceFile),
),
);

expect(outsidePass).toEqual(expected);
expect(insidePass).toEqual(expected);
});

it("returns undefined for the selectors that carry their own identity", () => {
const doc = mixedSourceDocument();
const el = doc.getElementById("a0")!;
for (const selector of [undefined, "#a0", '[data-composition-id="root"]']) {
expect(
getSourceScopedSelectorIndex(doc, el, selector, "a.html", bySourceFile),
).toBeUndefined();
expect(
withSelectorIndexPass(doc, () =>
getSourceScopedSelectorIndex(doc, el, selector, "a.html", bySourceFile),
),
).toBeUndefined();
}
});

it("returns undefined for an invalid selector rather than throwing", () => {
const doc = mixedSourceDocument();
const el = doc.getElementById("a0")!;
expect(getSourceScopedSelectorIndex(doc, el, ".((", "a.html", bySourceFile)).toBeUndefined();
expect(
withSelectorIndexPass(doc, () =>
getSourceScopedSelectorIndex(doc, el, ".((", "a.html", bySourceFile),
),
).toBeUndefined();
});

it("does not reuse one document's index for another, and restores the outer pass", () => {
const inner = mixedSourceDocument();
const outer = mixedSourceDocument();
// A nested pass over a different document must not answer from, or poison,
// the outer document's index.
const nested = withSelectorIndexPass(outer, () =>
withSelectorIndexPass(inner, () =>
getSourceScopedSelectorIndex(
inner,
inner.getElementById("b1")!,
".box",
"b.html",
bySourceFile,
),
),
);
expect(nested).toBe(1);

const afterNested = withSelectorIndexPass(outer, () => {
withSelectorIndexPass(inner, () => undefined);
return getSourceScopedSelectorIndex(
outer,
outer.getElementById("a1")!,
".box",
"a.html",
bySourceFile,
);
});
expect(afterNested).toBe(1);
});
});
Loading
Loading