Skip to content
Open
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
28 changes: 27 additions & 1 deletion packages/dom/src/clone-dom.js
Original file line number Diff line number Diff line change
Expand Up @@ -155,13 +155,39 @@ export function cloneNodeAndShadow(ctx) {
/**
* Use `getInnerHTML()` to serialize shadow dom as <template> tags. `innerHTML` and `outerHTML` don't do this. Buzzword: "declarative shadow dom"
*/
export function getOuterHTML(docElement, { shadowRootElements, forceShadowAsLightDOM }) {
export function getOuterHTML(docElement, { shadowRootElements, forceShadowAsLightDOM, cloneMayHaveUncountedShadowRoots }) {
// chromium gives us declarative shadow DOM serialization API
let innerHTML = '';
// When forceShadowAsLightDOM is true, treat shadow DOM as normal HTML
if (forceShadowAsLightDOM) {
return docElement.outerHTML;
}
// With no shadow roots to embed, the getHTML()+textContent=''+outerHTML
// .replace() reassembly below just reproduces `docElement.outerHTML` while
// allocating a full-size intermediate string + replace copy. Skip it to cut
// this step's transient footprint (GC pressure) on heavy pages.
//
// The guard is deliberately two-part. An empty shadowRootElements is NOT by
// itself proof that the clone has no shadow roots to serialize: getHTML's
// `serializableShadowRoots: true` also picks up any root attached with
// `serializable: true` (which is how cloneNodeAndShadow attaches them) even
// when it is absent from the explicit `shadowRoots` array. What makes the
// array authoritative is that serializeElements() pushes every clone shadow
// root into it (serialize-dom.js) before serializeHTML runs. A caller-supplied
// domTransformation breaks that: it runs AFTER serializeElements and can
// attach a serializable root to the clone, so cloneMayHaveUncountedShadowRoots
// sends those snapshots down the full reassembly path instead of dropping the
// shadow content on the floor.
if (!cloneMayHaveUncountedShadowRoots && !shadowRootElements?.length) {
// Release the clone's node graph before the caller builds the doctype
// concatenation (and, with stringifyResponse, a full JSON copy) on top of
// this string — the reassembly path below gets that for free via its own
// `textContent = ''`, and on a heavy page the retained tree dwarfs the one
// string copy this fast path saves.
let html = docElement.outerHTML;
docElement.textContent = '';
return html;
}
/* istanbul ignore else if: Only triggered in chrome <= 128 and tests runs on latest */
if (docElement.getHTML) {
// All major browsers in latest versions supports getHTML API to get serialized DOM
Expand Down
36 changes: 31 additions & 5 deletions packages/dom/src/serialize-dom.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,36 @@ function doctype(dom) {
return `<!DOCTYPE ${name}${deprecated}>`;
}

// Un-mangle both serialization markers in one pass instead of two:
// <data-percy-custom-element-x> -> <x>
// ` data-percy-serialized-attribute-y=` -> ` y=`
// Serialized HTML can reach tens of MB. A non-matching global .replace() is
// free (V8 hands back the same string), so this saves nothing on a page with
// no markers — but pages that inline resources hit the attribute marker on
// every rewritten stylesheet/image, and there one pass copies the large string
// once instead of twice. Groups: g1 = `<`/`</` (tag); g2/g3 = space + attr.
//
// The `i` flag is inherited from the old attribute-marker regex and now also
// covers the tag branch, which used to be case-sensitive. That widening is
// intentional: markers are always emitted lowercase in HTML documents (both
// createElement and setAttribute lowercase there), and in XHTML — where
// createElement does NOT lowercase — the tag branch previously failed to
// un-mangle its own output. Practical exposure of the widening is customer
// content that literally contains an uppercase marker inside <script>/<style>
// or a comment, where the serializer emits it verbatim.
const PERCY_MARKER_RE = /(<\/?)data-percy-custom-element-|( )data-percy-serialized-attribute-(\w+?)=/gi;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Low] Merged regex silently widens the tag branch to case-insensitive

The old pair was asymmetric: the custom-element pass was case-sensitive (/(<\/?)data-percy-custom-element-/g) and only the attribute pass had i. Folding them under a single /gi extends i to the tag branch.

I ran a differential fuzz (70k generated inputs plus hand-written edge cases — $&/$1 in attribute values, both markers adjacent, </ closers, empty/hyphen/colon attribute names) against the old two-pass implementation. This is the only divergence found: with lowercase markers the two are byte-identical across 50k cases, but <DATA-PERCY-CUSTOM-ELEMENT-X> is now rewritten to <X> where it previously passed through untouched.

Not reachable from Percy's own output — clone-dom.js:36 builds the tag through document.createElement, which ASCII-lowercases in an HTML document. The exposure is customer page content that literally contains <DATA-PERCY-CUSTOM-ELEMENT-… in a text node, <pre>, <script>, or attribute value, which would now be corrupted in the snapshot.

Suggestion: both markers are always emitted lowercase, so i buys nothing and only widens the blast radius — drop it:

Suggested change
const PERCY_MARKER_RE = /(<\/?)data-percy-custom-element-|( )data-percy-serialized-attribute-(\w+?)=/gi;
const PERCY_MARKER_RE = /(<\/?)data-percy-custom-element-|( )data-percy-serialized-attribute-(\w+?)=/g;

If you'd rather keep the old attribute-branch tolerance exactly, keep i but note it in the comment so the widening is a recorded decision rather than a side effect of merging.

Reviewer: stack:pr-review (orchestrator verification)


// Serializes and returns the cloned DOM as an HTML string
function serializeHTML(ctx) {
let html = getOuterHTML(ctx.clone.documentElement, { shadowRootElements: ctx.shadowRootElements, forceShadowAsLightDOM: ctx.forceShadowAsLightDOM });
// this is replacing serialized data tag with real tag
html = html.replace(/(<\/?)data-percy-custom-element-/g, '$1');
// replace serialized data attributes with real attributes
html = html.replace(/ data-percy-serialized-attribute-(\w+?)=/ig, ' $1=');
let html = getOuterHTML(ctx.clone.documentElement, {
shadowRootElements: ctx.shadowRootElements,
forceShadowAsLightDOM: ctx.forceShadowAsLightDOM,
// A domTransformation can attach shadow roots to the clone after
// serializeElements() finished collecting them — see getOuterHTML.
cloneMayHaveUncountedShadowRoots: !!ctx.domTransformation
});
html = html.replace(PERCY_MARKER_RE, (_match, tagPrefix, attrSpace, attrName) =>
tagPrefix !== undefined ? tagPrefix : `${attrSpace}${attrName}=`);
// include the doctype with the html string
return doctype(ctx.dom) + html;
}
Expand Down Expand Up @@ -110,6 +133,9 @@ export function serializeDOM(options) {
hints: new Set(),
cache: new Map(),
shadowRootElements: [],
// Recorded only so serializeHTML knows a caller-supplied transformation may
// have mutated the clone after shadowRootElements was collected.
domTransformation,
enableJavaScript,
disableShadowDOM,
ignoreCanvasSerializationErrors,
Expand Down
57 changes: 57 additions & 0 deletions packages/dom/test/clone-dom.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { getOuterHTML } from '../src/clone-dom';

describe('getOuterHTML', () => {
// A detached <html> element standing in for a clone's documentElement. Built
// fresh per call so the fast path's `textContent = ''` can't leak between
// assertions.
function makeTree() {
let doc = document.implementation.createHTMLDocument('');
doc.body.innerHTML = '<div id="host"><p>hello</p><span title="$& $1">chars</span></div>';
return doc.documentElement;
}

// The reassembly the fast path replaces, kept verbatim so the equality test
// below is a real comparison and not a restatement of the new code.
function reassemble(docElement, shadowRootElements) {
let innerHTML = docElement.getHTML({ serializableShadowRoots: true, shadowRoots: shadowRootElements });
docElement.textContent = '';
return docElement.outerHTML.replace('</html>', () => `${innerHTML}</html>`);
}

it('produces byte-identical output to the reassembled form with no shadow roots', () => {
expect(getOuterHTML(makeTree(), { shadowRootElements: [] }))
.toBe(reassemble(makeTree(), []));
});

it('releases the clone node graph on the fast path', () => {
// The reassembly path empties docElement as a side effect; the fast path
// has to do the same or the whole clone stays reachable while the caller
// builds the doctype concatenation and any stringified copy on top of it.
let docElement = makeTree();
getOuterHTML(docElement, { shadowRootElements: [] });
expect(docElement.childNodes.length).toBe(0);
});

it('serializes shadow roots absent from shadowRootElements when told the clone may hold them', () => {
// getHTML's `serializableShadowRoots: true` picks up roots attached with
// `serializable: true` even when they are not in the explicit array, so an
// empty array alone must not gate the fast path.
let docElement = makeTree();
let shadow = docElement.querySelector('#host').attachShadow({ mode: 'open', serializable: true });
shadow.innerHTML = '<b>inside shadow</b>';

let html = getOuterHTML(docElement, {
shadowRootElements: [],
cloneMayHaveUncountedShadowRoots: true
});

expect(html).toContain('inside shadow');
expect(html).toContain('<template shadowrootmode="open"');
});

it('returns outerHTML directly when forceShadowAsLightDOM is set', () => {
let docElement = makeTree();
expect(getOuterHTML(docElement, { shadowRootElements: [], forceShadowAsLightDOM: true }))
.toBe(docElement.outerHTML);
});
});
64 changes: 64 additions & 0 deletions packages/dom/test/serialize-dom.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -596,6 +596,70 @@ describe('serializeDOM', () => {

expect(warnings).toEqual(['Could not transform the dom: test error']);
});

// A domTransformation runs after serializeElements() has already collected
// ctx.shadowRootElements, so a shadow root attached here is invisible to
// that array. getOuterHTML must not take its no-shadow-roots fast path for
// these snapshots, or the shadow content is silently dropped.
it('serializes a shadow root attached during the transformation', () => {
let { html } = serializeDOM({
domTransformation(dom) {
let host = dom.querySelector('.delete-me');
let shadow = host.attachShadow({ mode: 'open', serializable: true });
shadow.innerHTML = '<p>shadow added by transformation</p>';
}
});

expect(html).toContain('shadow added by transformation');
expect(html).toContain('<template shadowrootmode="open"');
});
});

describe('marker un-mangling', () => {
it('restores both the tag and attribute markers in one pass', () => {
if (getTestBrowser() !== chromeBrowser) {
return;
}

// Both markers have to land in the SAME tag for this to exercise the
// single-pass replacer's branch discrimination. That needs a custom
// element that cloneElementWithoutLifecycle proxies — i.e. one with an
// attributeChangedCallback — carrying a `src`, which is the attribute it
// rewrites to data-percy-serialized-attribute-src. A plain undefined
// element takes the cloneNode() branch and emits no markers at all.
class MarkerWidget extends window.HTMLElement {
static get observedAttributes() {
return ['src'];
}

attributeChangedCallback() {}
}

if (!window.customElements.get('marker-widget')) {
window.customElements.define('marker-widget', MarkerWidget);
}

withExample('<marker-widget src="/img.png"></marker-widget>', { withShadow: false });

let { html } = serializeDOM();

expect(html).toContain('<marker-widget src="/img.png"');
expect(html).not.toContain('data-percy-custom-element-');
expect(html).not.toContain('data-percy-serialized-attribute-');
});

it('restores the attribute marker written by canvas serialization', () => {
// serialize-canvas sets data-percy-serialized-attribute-src; if the
// attribute branch of the merged regex regressed, the marker would ship.
withExample('<canvas id="canvas" width="10" height="10"></canvas>', { withShadow: false });
let ctx = document.getElementById('canvas').getContext('2d');
ctx.fillRect(0, 0, 10, 10);

let { html } = serializeDOM();

expect(html).not.toContain('data-percy-serialized-attribute-');
expect(html).toMatch(/<img[^>]+src="[^"]+"/);
});
});

describe('with `reshuffleInvalidTags`', () => {
Expand Down
Loading