-
Notifications
You must be signed in to change notification settings - Fork 60
fix(dom): reduce transient allocation during serialize (PER-10135) #2349
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from 1 commit
9eb405c
fc7b9a0
6ddd033
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -162,6 +162,13 @@ export function getOuterHTML(docElement, { shadowRootElements, forceShadowAsLigh | |||||||||
| 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. | ||||||||||
| if (!shadowRootElements || shadowRootElements.length === 0) { | ||||||||||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [High] Wrong predicate — silently drops shadow DOM attached during Upgraded from the Low note posted earlier on this line: this was verified as reachable, with a reproduction, rather than hypothetical.
The reachable path: a serializeDOM({ domTransformation: (docEl) => {
const sr = docEl.querySelector('#content').attachShadow({ mode: 'open', serializable: true });
sr.innerHTML = '<p>PROBETRANSFORM</p>';
}})master → html contains Ruled out as divergent (no issue there): live-DOM shadow roots — Suggestion: gate on both conditions — thread a flag from Reviewer: stack-code-reviewer |
||||||||||
| return docElement.outerHTML; | ||||||||||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Medium] Skipping The reassembly path clears the clone at line 177 before returning. That was not incidental: it released the entire cloned node tree before the caller built With this early return the clone stays fully reachable across all of that, so peak retention rises by roughly the size of the clone. The PR's own investigation identifies that clone as the dominant contributor (~315 MB for the 45k-node repro, against a ~30 MB html string), so on a tight memory boundary this plausibly costs more than the one saved string copy gains. Nothing depends on the emptying: Suggestion: keep the release in the fast path:
Suggested change
The pre-existing Reviewer: stack-code-reviewer |
||||||||||
| } | ||||||||||
| /* 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 | ||||||||||
|
|
||||||||||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -24,13 +24,19 @@ 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 and each .replace() copies the whole | ||||||
| // string, so folding two passes into one halves the large-string churn (GC | ||||||
| // pressure) per snapshot. Groups: g1 = `<`/`</` (tag); g2/g3 = space + attr. | ||||||
| const PERCY_MARKER_RE = /(<\/?)data-percy-custom-element-|( )data-percy-serialized-attribute-(\w+?)=/gi; | ||||||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 ( I ran a differential fuzz (70k generated inputs plus hand-written edge cases — Not reachable from Percy's own output — Suggestion: both markers are always emitted lowercase, so
Suggested change
If you'd rather keep the old attribute-branch tolerance exactly, keep 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='); | ||||||
| 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; | ||||||
| } | ||||||
|
|
||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[Low] Early return couples shadow-DOM fidelity to an invariant held in another file
The path being skipped did not need this invariant:
getHTML({ serializableShadowRoots: true, … })serializes any shadow root attached withserializable: true— which is exactly how line 126 attaches them — even when theshadowRootsarray is empty. Gating onshadowRootElements.length === 0makes correctness depend on that array being an exact mirror of the clone's shadow roots, and it is populated over inserialize-dom.js:50-66.I traced it and the invariant holds today: line 126 is the only
attachShadowcall site insrc/, it is always paired with thedata-percy-shadow-hoststamp under identicaldisableShadowDOM/forceShadowAsLightDOMconditions (prepare-dom.js:22-33),getShadowRoot()is the same condition clone-dom uses, andserializeElementsruns beforeserializeHTML. So this is a robustness note, not a live bug.One residual gap worth knowing about: a customer-supplied
domTransformationruns atserialize-dom.js:145, after the array is finalized, and can mutate the clone freely — a serializable shadow root attached there would have been picked up by the old path and will now be dropped.Suggestion: record the coupling where a future reader will see it, e.g. append to the new comment:
// Invariant: serializeElements() pushes every clone shadow root into ctx.shadowRootElements (serialize-dom.js:50-66) before serializeHTML runs, so an empty array means the clone has no shadow roots to embed.A test that serializes a shadow-DOM page and asserts the<template shadowrootmode>output survives would pin it against future reordering ofserializeDOM.Reviewer: stack:pr-review (orchestrator verification)