Composed-tree shadow DOM capture, sized invisible spacers, track re-anchoring, block fill-width slides, javascript: href sanitization
- Walker serializes the composed (flattened) tree: open shadow roots walk as host children with <slot> replaced by its assigned light nodes (or fallback), so custom-element content (product cards, web components) is captured instead of arriving childless - In-flow visibility:hidden nodes with a nonzero border box survive the prune as sized invisible placeholders - ghost spacers carry load-bearing geometry - When pruning drops leading in-flow children of a horizontally-translated track, the baked translateX is re-anchored by their aggregate margin-box width (settled carousel tracks return to origin instead of pushing real content off-screen); animation-owned transforms unaffected - Circular-shrink slide guard counts block-level fill children as width-deriving regardless of wAuto (block auto-width IS fill), pinning library-sized slide widths uniformly - javascript: hrefs emit as "#" and the link gate normalizes both sides 382 tests pass (18 new), typecheck clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
6e4921884c
commit
762855664c
@@ -11,6 +11,7 @@ import {
|
||||
PACKAGE_JSON_TW,
|
||||
PACKAGE_JSON_VITE,
|
||||
PACKAGE_JSON_VITE_TW,
|
||||
propsList,
|
||||
type ComponentRegistry,
|
||||
} from "../src/generate/app.js";
|
||||
import { DITTO_WIRE_TSX, ACCORDION_TSX } from "../src/generate/interactive.js";
|
||||
@@ -165,3 +166,34 @@ describe("lottie-web is declared when its import is emitted (fix 7)", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---- FIX 5: javascript: hrefs are sanitized to an inert '#' ----
|
||||
// React refuses to render a `javascript:*` href verbatim — it rewrites it to a long
|
||||
// `javascript:throw new Error('React has blocked a javascript: URL…')` string that no longer matches
|
||||
// the source href in the link gate. Emit an inert `#` instead (the script behaviour isn't reproduced).
|
||||
describe("javascript: hrefs are emitted as an inert '#' (FIX 5)", () => {
|
||||
const hrefOf = (n: IRNode): string | undefined => {
|
||||
const p = propsList(n, new Map(), "https://example.test/").find(([k]) => k === "href");
|
||||
return p ? JSON.parse(p[1]) : undefined;
|
||||
};
|
||||
it("rewrites a javascript: href to #", () => {
|
||||
const a = node("n1", "a", computed());
|
||||
a.attrs = { href: "Javascript:{}" };
|
||||
assert.equal(hrefOf(a), "#", "a javascript: href is sanitized to #");
|
||||
});
|
||||
it("rewrites javascript:void(0) too (case-insensitive, with args)", () => {
|
||||
const a = node("n1", "a", computed());
|
||||
a.attrs = { href: "javascript:void(0)" };
|
||||
assert.equal(hrefOf(a), "#");
|
||||
});
|
||||
it("leaves a normal in-page anchor href untouched", () => {
|
||||
const a = node("n1", "a", computed());
|
||||
a.attrs = { href: "#section" };
|
||||
assert.equal(hrefOf(a), "#section", "a real fragment link is preserved");
|
||||
});
|
||||
it("leaves an ordinary external href resolved (not collapsed to #)", () => {
|
||||
const a = node("n1", "a", computed());
|
||||
a.attrs = { href: "https://example.test/products" };
|
||||
assert.equal(hrefOf(a), "https://example.test/products");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -820,3 +820,47 @@ describe("generateCss text-wrap", () => {
|
||||
assert.ok(!/text-wrap/.test(baseRule(css, "n2")), `inherited child does not re-emit (got: ${baseRule(css, "n2")})`);
|
||||
});
|
||||
});
|
||||
|
||||
// FIX 4 — a library-sized carousel slide (a shrink-0 flex item with an injected inline `width:Npx`)
|
||||
// whose only in-flow child is a display:block box. A block box's `width:auto` IS its fill width, so
|
||||
// the sizing probe reports the child wAuto AND wFill both true. The circular-slide detector must count
|
||||
// that block-level fill child as width-DERIVING (it never establishes a width) and pin the slide's
|
||||
// captured px — otherwise the guard misreads the child as a genuine content-width source, bails, and
|
||||
// the slide content-sizes to a different width (ragged carousel rails).
|
||||
describe("generateCss circular carousel slide with a block-level fill child (FIX 4)", () => {
|
||||
// The slide's child is display:block: its auto width equals the fill width, so BOTH probe bits fire.
|
||||
const blockFill = (): RawSizing => ({ wAuto: true, wFill: true, hAuto: true, hFill: true });
|
||||
function slideRow() {
|
||||
// Uniform 220px slides at every viewport (Splide's inline width) inside a flex track.
|
||||
const mkSlide = (id: string, childId: string): IRNode => {
|
||||
const child = xNode(childId, "div", {
|
||||
375: { cs: { display: "block", position: "static" }, bbox: { x: 0, y: 0, width: 220, height: 357 }, sizing: blockFill() },
|
||||
768: { cs: { display: "block", position: "static" }, bbox: { x: 0, y: 0, width: 220, height: 357 }, sizing: blockFill() },
|
||||
1280: { cs: { display: "block", position: "static" }, bbox: { x: 0, y: 0, width: 220, height: 357 }, sizing: blockFill() },
|
||||
}, [{ text: "Men's Tops & Shirts" } as IRChild]);
|
||||
const slideCs = { display: "block", position: "static", flexShrink: "0", width: "220px" };
|
||||
return xNode(id, "div", {
|
||||
375: { cs: slideCs, bbox: { x: 0, y: 0, width: 220, height: 357 } },
|
||||
768: { cs: slideCs, bbox: { x: 0, y: 0, width: 220, height: 357 } },
|
||||
1280: { cs: slideCs, bbox: { x: 0, y: 0, width: 220, height: 357 } },
|
||||
}, [child]);
|
||||
};
|
||||
const trackCs = { display: "flex", position: "static" };
|
||||
const track = xNode("n1", "ul", {
|
||||
375: { cs: trackCs, bbox: { x: 0, y: 0, width: 375, height: 357 } },
|
||||
768: { cs: trackCs, bbox: { x: 0, y: 0, width: 768, height: 357 } },
|
||||
1280: { cs: trackCs, bbox: { x: 0, y: 0, width: 1280, height: 357 } },
|
||||
}, [mkSlide("n2", "n3"), mkSlide("n4", "n5")]);
|
||||
return xNode("n0", "body", {
|
||||
375: { bbox: { x: 0, y: 0, width: 375, height: 357 } },
|
||||
768: { bbox: { x: 0, y: 0, width: 768, height: 357 } },
|
||||
1280: { bbox: { x: 0, y: 0, width: 1280, height: 357 } },
|
||||
}, [track]);
|
||||
}
|
||||
|
||||
it("pins the slide's captured width even though its block child reports wAuto:true", () => {
|
||||
const css = generateCss(xIr(slideRow()), new Map());
|
||||
const slide = allRulesX(css, "n2");
|
||||
assert.ok(/width:220px/.test(slide), `the library-sized slide must keep its 220px width, got: ${slide}`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,24 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { letterSpacingEquivalent } from "../src/validate/gates.js";
|
||||
import { letterSpacingEquivalent, normHref } from "../src/validate/gates.js";
|
||||
|
||||
// FIX 5 — the link gate must not fail a javascript: source href against the clone's sanitized value.
|
||||
// Generation emits an inert `#` for a `javascript:*` href (React blocks the literal), so normHref
|
||||
// collapses every javascript: href — on either side — to `#`, letting the two sides match.
|
||||
describe("normHref collapses javascript: hrefs to # (FIX 5)", () => {
|
||||
const origin = "https://example.test";
|
||||
it("normalizes a javascript: source href to #", () => {
|
||||
assert.equal(normHref("Javascript:{}", origin), "#");
|
||||
assert.equal(normHref("javascript:void(0)", origin), "#");
|
||||
});
|
||||
it("makes a javascript: source match the emitted # value", () => {
|
||||
assert.equal(normHref("javascript:{}", origin), normHref("#", origin), "source and clone agree");
|
||||
});
|
||||
it("still distinguishes a real fragment from a full URL", () => {
|
||||
assert.equal(normHref("#top", origin), "#top");
|
||||
assert.equal(normHref("https://example.test/x/", origin), "https://example.test/x");
|
||||
});
|
||||
});
|
||||
|
||||
// Chromium serializes a computed `letter-spacing: 0` back as the keyword `normal`. The emitter, after
|
||||
// snapping a sub-0.1px authored tracking to 0, ships `letter-spacing: 0px` — which the CLONE then
|
||||
|
||||
@@ -182,3 +182,133 @@ describe("neutralizeAnimatedTransforms (Defect C)", () => {
|
||||
assert.equal(byVp[375].transform, "matrix(1, 0, 0, 1, -100, 0)", "finite animations own a settled end state, not a perpetual phase");
|
||||
});
|
||||
});
|
||||
|
||||
/** A RawNode with an explicit computed style and bbox (same at every viewport). Unlike `raw()` this
|
||||
* lets a test author invisible-but-laid-out boxes, transforms, margins, and positions directly. */
|
||||
function rawS(
|
||||
tag: string,
|
||||
computed: Record<string, string>,
|
||||
bbox: { x: number; y: number; width: number; height: number },
|
||||
children: RawChild[] = [],
|
||||
visible = true,
|
||||
attrs: Record<string, string> = {},
|
||||
): RawNode {
|
||||
return { tag, attrs, computed, bbox, visible, children };
|
||||
}
|
||||
|
||||
// FIX 2 — an in-flow `visibility:hidden` box with a nonzero border box is load-bearing geometry: it
|
||||
// reserves the row height its absolutely-positioned siblings paint into. It is invisible (so the plain
|
||||
// visibility prune would drop it), but dropping it collapses the column. It must survive as a sized
|
||||
// placeholder; a genuinely empty display:none-everywhere box must still prune.
|
||||
describe("IR prune keeps sized invisible (visibility:hidden) spacers (FIX 2)", () => {
|
||||
it("keeps an in-flow visibility:hidden ghost column with a nonzero bbox", () => {
|
||||
const ghost = rawS(
|
||||
"div",
|
||||
{ display: "flex", position: "static", visibility: "hidden" },
|
||||
{ x: 0, y: 0, width: 650, height: 723 },
|
||||
// its children are themselves invisible (they set no visibility:visible) — the box is a spacer
|
||||
[rawS("img", { display: "block", position: "static", visibility: "hidden" }, { x: 0, y: 0, width: 650, height: 240 }, [], false)],
|
||||
false,
|
||||
);
|
||||
// the real content is an absolutely-positioned sibling layered over the ghost's reserved space
|
||||
const painted = rawS("img", { display: "block", position: "absolute", visibility: "visible" }, { x: 0, y: 0, width: 650, height: 723 }, [], true);
|
||||
const column = rawS("div", { display: "block", position: "relative", visibility: "visible" }, { x: 0, y: 0, width: 650, height: 723 }, [ghost, painted], true);
|
||||
const body = raw("body", {}, [column]);
|
||||
|
||||
const root = buildFixtureIR(body);
|
||||
const kept = findByTag(root, "div");
|
||||
assert.ok(kept, "the column survives");
|
||||
// The ghost div (a second nested div) must still be present as a sized placeholder.
|
||||
const divs: IRNode[] = [];
|
||||
const collect = (n: IRNode): void => { if (n.tag === "div") divs.push(n); for (const c of n.children) if (!isTextChild(c)) collect(c as IRNode); };
|
||||
collect(root);
|
||||
// column + ghost = 2 divs (body is not a div)
|
||||
assert.equal(divs.length, 2, "the visibility:hidden ghost column is retained, not pruned");
|
||||
const ghostIR = divs.find((d) => d.computedByVp[1280]?.visibility === "hidden");
|
||||
assert.ok(ghostIR, "the retained ghost carries visibility:hidden");
|
||||
assert.ok(ghostIR!.bboxByVp[1280]!.height > 0, "the ghost keeps its load-bearing height");
|
||||
});
|
||||
|
||||
it("still prunes a display:none-everywhere empty box (no resurrection)", () => {
|
||||
const hidden = rawS("div", { display: "none", position: "static", visibility: "visible" }, { x: 0, y: 0, width: 0, height: 0 }, [], false);
|
||||
const body = raw("body", {}, [rawS("section", { display: "block", position: "static", visibility: "visible" }, { x: 0, y: 0, width: 640, height: 100 }, [hidden], true)]);
|
||||
const root = buildFixtureIR(body);
|
||||
assert.equal(findByTag(root, "div"), null, "a display:none-everywhere box stays pruned");
|
||||
});
|
||||
|
||||
it("does not keep an out-of-flow (absolute) visibility:hidden box as a spacer", () => {
|
||||
// Absolute boxes take no space in flow, so an invisible absolute box is not load-bearing geometry.
|
||||
const absHidden = rawS("div", { display: "block", position: "absolute", visibility: "hidden" }, { x: 0, y: 0, width: 300, height: 300 }, [], false);
|
||||
const body = raw("body", {}, [rawS("section", { display: "block", position: "static", visibility: "visible" }, { x: 0, y: 0, width: 640, height: 100 }, [absHidden], true)]);
|
||||
const root = buildFixtureIR(body);
|
||||
assert.equal(findByTag(root, "div"), null, "an out-of-flow invisible box is not kept as a spacer");
|
||||
});
|
||||
});
|
||||
|
||||
// FIX 3 — a settled loop carousel parks its track with a baked translateX and prepends invisible clone
|
||||
// slides that occupy exactly [translateX, 0]; the first REAL slide paints at x=0. Pruning drops the
|
||||
// off-screen clones, but the baked translateX would survive verbatim and push every real slide
|
||||
// offscreen-left. Re-anchor the track's translateX by the aggregate outer width of the dropped leading
|
||||
// clones so the first kept slide lands at its captured position.
|
||||
describe("IR prune re-anchors a translated track after dropping leading clones (FIX 3)", () => {
|
||||
it("re-anchors translateX toward 0 by the dropped leading clone width", () => {
|
||||
// Three off-screen leading loop clones, each 200px wide (translateX = -600 = -(3×200)); two reals.
|
||||
// The clones are OFF-SCREEN (visible:false via the off-screen test), NOT visibility:hidden — so
|
||||
// they prune and the FIX-2 sized-invisible-spacer carve-out (which requires visibility:hidden) does
|
||||
// not resurrect them. This mirrors a real settled Splide loop's leading clones.
|
||||
const clone = (i: number): RawNode =>
|
||||
rawS("li", { display: "block", position: "static", visibility: "visible" }, { x: -600 + i * 200, y: 0, width: 200, height: 100 }, [], false);
|
||||
const real = (i: number): RawNode =>
|
||||
rawS("li", { display: "block", position: "static", visibility: "visible" }, { x: i * 200, y: 0, width: 200, height: 100 }, [{ text: `real${i}` }], true);
|
||||
const track = rawS(
|
||||
"ul",
|
||||
{ display: "flex", position: "relative", visibility: "visible", transform: "matrix(1, 0, 0, 1, -600, 0)" },
|
||||
{ x: 0, y: 0, width: 1000, height: 100 },
|
||||
[clone(0), clone(1), clone(2), real(0), real(1)],
|
||||
true,
|
||||
);
|
||||
const body = raw("body", {}, [track]);
|
||||
const root = buildFixtureIR(body);
|
||||
const ul = findByTag(root, "ul")!;
|
||||
// The clones are pruned (invisible everywhere), leaving the two real slides.
|
||||
const slides = ul.children.filter((c) => !isTextChild(c));
|
||||
assert.equal(slides.length, 2, "only the real slides survive");
|
||||
// The baked -600 translate is re-anchored to 0 (−600 + 3×200).
|
||||
assert.equal(ul.computedByVp[1280]!.transform, "matrix(1, 0, 0, 1, 0, 0)", "track translateX re-anchored to origin");
|
||||
assert.equal(ul.computedByVp[375]!.transform, "matrix(1, 0, 0, 1, 0, 0)", "re-anchored at every viewport");
|
||||
});
|
||||
|
||||
it("leaves a track untouched when no leading children are dropped", () => {
|
||||
const real = (i: number): RawNode =>
|
||||
rawS("li", { display: "block", position: "static", visibility: "visible" }, { x: i * 200, y: 0, width: 200, height: 100 }, [{ text: `real${i}` }], true);
|
||||
const track = rawS(
|
||||
"ul",
|
||||
{ display: "flex", position: "relative", visibility: "visible", transform: "matrix(1, 0, 0, 1, -120, 0)" },
|
||||
{ x: 0, y: 0, width: 400, height: 100 },
|
||||
[real(0), real(1)],
|
||||
true,
|
||||
);
|
||||
const body = raw("body", {}, [track]);
|
||||
const root = buildFixtureIR(body);
|
||||
const ul = findByTag(root, "ul")!;
|
||||
assert.equal(ul.computedByVp[1280]!.transform, "matrix(1, 0, 0, 1, -120, 0)", "a track with no dropped leading children keeps its offset");
|
||||
});
|
||||
|
||||
it("does not re-anchor when the dropped leading siblings are out of flow", () => {
|
||||
const absClone = (i: number): RawNode =>
|
||||
rawS("li", { display: "block", position: "absolute", visibility: "hidden" }, { x: -400 + i * 200, y: 0, width: 200, height: 100 }, [], false);
|
||||
const real = (i: number): RawNode =>
|
||||
rawS("li", { display: "block", position: "static", visibility: "visible" }, { x: i * 200, y: 0, width: 200, height: 100 }, [{ text: `real${i}` }], true);
|
||||
const track = rawS(
|
||||
"ul",
|
||||
{ display: "flex", position: "relative", visibility: "visible", transform: "matrix(1, 0, 0, 1, -80, 0)" },
|
||||
{ x: 0, y: 0, width: 400, height: 100 },
|
||||
[absClone(0), absClone(1), real(0), real(1)],
|
||||
true,
|
||||
);
|
||||
const body = raw("body", {}, [track]);
|
||||
const root = buildFixtureIR(body);
|
||||
const ul = findByTag(root, "ul")!;
|
||||
assert.equal(ul.computedByVp[1280]!.transform, "matrix(1, 0, 0, 1, -80, 0)", "out-of-flow clones don't advance the track, so no re-anchor");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -349,3 +349,89 @@ describe("walker text-wrap capture", () => {
|
||||
assert.equal(p.computed.textWrap, "pretty", "text-wrap:pretty is captured");
|
||||
});
|
||||
});
|
||||
|
||||
describe("walker shadow-DOM composed-tree serialization (FIX 1)", () => {
|
||||
let browser: Browser;
|
||||
let page: Page;
|
||||
before(async () => {
|
||||
browser = await chromium.launch();
|
||||
page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
|
||||
});
|
||||
after(async () => {
|
||||
await browser.close();
|
||||
});
|
||||
|
||||
// Attach an OPEN shadow root to any host carrying data-shadow, injecting its data-shadow value as
|
||||
// the shadow tree HTML. Runs in-page before collectPage so getComputedStyle/bbox see the composed tree.
|
||||
const capture = async (html: string) => {
|
||||
await page.setContent(html);
|
||||
await page.evaluate(() => {
|
||||
for (const host of Array.from(document.querySelectorAll("[data-shadow]"))) {
|
||||
const markup = host.getAttribute("data-shadow") || "";
|
||||
const sr = (host as HTMLElement).attachShadow({ mode: "open" });
|
||||
sr.innerHTML = markup;
|
||||
}
|
||||
});
|
||||
await page.evaluate("globalThis.__name = globalThis.__name || ((fn) => fn);");
|
||||
return page.evaluate(collectPage);
|
||||
};
|
||||
|
||||
it("serializes an open custom element's shadow tree as the host's children", async () => {
|
||||
// A `<product-info>` web component renders its swatches/title/price INSIDE its shadow root; a
|
||||
// childNodes-only walk captured it empty. The composed walk must surface the shadow content.
|
||||
const snap = await capture(`
|
||||
<product-info data-shadow="
|
||||
<div class='pi-title'>Magnolia Shirt</div>
|
||||
<span class='pi-price'>$78.00</span>
|
||||
"></product-info>`);
|
||||
const host = findByTag(snap.root, "product-info")!;
|
||||
assert.ok(host, "the custom element host is present");
|
||||
assert.equal(host.shadowHost, true, "the host is tagged as a shadow host");
|
||||
assert.equal(textRun(host).replace(/\s+/g, " ").trim(), "Magnolia Shirt $78.00", "the shadow tree text is captured");
|
||||
const title = findByClass(host, "pi-title")!;
|
||||
assert.ok(title, "a shadow descendant node is serialized");
|
||||
assert.equal(title.inShadow, true, "shadow descendants are tagged inShadow");
|
||||
assert.ok(title.bbox.width > 0, "shadow nodes get real bboxes via getBoundingClientRect");
|
||||
});
|
||||
|
||||
it("renders the FLATTENED tree: a <slot> is replaced by its assigned light-DOM nodes", async () => {
|
||||
// The host's light child (the assigned node) renders at the slot position — once, and NOT tagged
|
||||
// inShadow (it is the author's real content). Shadow chrome around the slot still appears.
|
||||
const snap = await capture(`
|
||||
<my-card data-shadow="
|
||||
<div class='card-frame'><slot></slot></div>
|
||||
"><h2 class='slotted'>Vintage Sunset T-Shirt</h2></my-card>`);
|
||||
const host = findByTag(snap.root, "my-card")!;
|
||||
const frame = findByClass(host, "card-frame")!;
|
||||
assert.ok(frame, "the shadow frame around the slot is serialized");
|
||||
assert.equal(frame.inShadow, true, "the shadow frame is tagged inShadow");
|
||||
const slotted = findByClass(host, "slotted")!;
|
||||
assert.ok(slotted, "the slotted light child renders at the slot position");
|
||||
assert.equal(textRun(slotted).trim(), "Vintage Sunset T-Shirt");
|
||||
assert.ok(!slotted.inShadow, "the slotted light child is NOT tagged inShadow (author content)");
|
||||
// No double-serialization: exactly one node carries the slotted text.
|
||||
let count = 0;
|
||||
const countText = (n: RawNode): void => {
|
||||
if (n.children.some((c) => isText(c) && c.text.includes("Vintage Sunset T-Shirt"))) count++;
|
||||
for (const c of n.children) if (!isText(c)) countText(c as RawNode);
|
||||
};
|
||||
countText(host);
|
||||
assert.equal(count, 1, "the slotted child is serialized exactly once");
|
||||
});
|
||||
|
||||
it("uses <slot> fallback content when nothing is assigned", async () => {
|
||||
const snap = await capture(`
|
||||
<my-badge data-shadow="
|
||||
<span class='badge'><slot>Default Label</slot></span>
|
||||
"></my-badge>`);
|
||||
const host = findByTag(snap.root, "my-badge")!;
|
||||
assert.equal(textRun(host).replace(/\s+/g, " ").trim(), "Default Label", "unfilled slot falls back to its own content");
|
||||
});
|
||||
|
||||
it("does not tag ordinary light-DOM nodes as shadow", async () => {
|
||||
const snap = await capture(`<div class="plain"><p>light</p></div>`);
|
||||
const plain = findByClass(snap.root, "plain")!;
|
||||
assert.ok(!plain.shadowHost, "a plain div is not a shadow host");
|
||||
assert.ok(!plain.inShadow, "plain light DOM is not tagged inShadow");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user