Skip to content

Commit

Permalink
Prep PR for async <style> serialization via assets: refactor stringif…
Browse files Browse the repository at this point in the history
…yStylesheet to happen in a single place during initial snapshot.
  • Loading branch information
eoghanmurray committed Apr 5, 2024
1 parent d50ee83 commit 1ec0ab7
Show file tree
Hide file tree
Showing 6 changed files with 66 additions and 43 deletions.
14 changes: 9 additions & 5 deletions packages/rrweb-snapshot/src/rebuild.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,10 @@ function buildNode(
value = adaptCssForReplay(value, cache);
}
if ((isTextarea || isRemoteOrDynamicCss) && typeof value === 'string') {
if (isRemoteOrDynamicCss && n.childNodes.length && n.childNodes[0].type === NodeType.Text) {
n.childNodes[0].textContent = value;
continue
}
node.appendChild(doc.createTextNode(value));
// https://github.com/rrweb-io/rrweb/issues/112
n.childNodes = []; // value overrides childNodes
Expand Down Expand Up @@ -367,11 +371,11 @@ function buildNode(
return node;
}
case NodeType.Text:
return doc.createTextNode(
n.isStyle && hackCss
? adaptCssForReplay(n.textContent, cache)
: n.textContent,
);
if (n.isStyle && hackCss) {
// support legacy style
return doc.createTextNode(adaptCssForReplay(n.textContent, cache));
}
return doc.createTextNode(n.textContent);
case NodeType.CDATA:
return doc.createCDATASection(n.textContent);
case NodeType.Comment:
Expand Down
59 changes: 35 additions & 24 deletions packages/rrweb-snapshot/src/snapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -446,6 +446,7 @@ function serializeNode(
* `newlyAddedElement: true` skips scrollTop and scrollLeft check
*/
newlyAddedElement?: boolean;
blankTextNodes?: boolean;
},
): serializedNode | false {
const {
Expand All @@ -463,6 +464,7 @@ function serializeNode(
recordCanvas,
keepIframeSrcFn,
newlyAddedElement = false,
blankTextNodes = false,
} = options;
// Only record root id when document object is not the base document
const rootId = getRootId(doc, mirror);
Expand Down Expand Up @@ -508,6 +510,7 @@ function serializeNode(
needsMask,
maskTextFn,
rootId,
blankTextNodes,
});
case n.CDATA_SECTION_NODE:
return {
Expand Down Expand Up @@ -538,40 +541,43 @@ function serializeTextNode(
needsMask: boolean | undefined;
maskTextFn: MaskTextFn | undefined;
rootId: number | undefined;
blankTextNodes? : boolean;
},
): serializedNode {
const { needsMask, maskTextFn, rootId } = options;
const { needsMask, maskTextFn, rootId, blankTextNodes } = options;
// The parent node may not be a html element which has a tagName attribute.
// So just let it be undefined which is ok in this use case.
const parentTagName = n.parentNode && (n.parentNode as HTMLElement).tagName;
let textContent = n.textContent;
let textContent: string | null = '';
const isStyle = parentTagName === 'STYLE' ? true : undefined;
const isScript = parentTagName === 'SCRIPT' ? true : undefined;
if (isStyle && textContent) {
try {
// try to read style sheet
if (isScript) {
textContent = 'SCRIPT_PLACEHOLDER';
} else if (!blankTextNodes) {
textContent = n.textContent;
if (isStyle && textContent) {
// This branch is solely for the use of mutation
if (n.nextSibling || n.previousSibling) {
// This is not the only child of the stylesheet.
// We can't read all of the sheet's .cssRules and expect them
// to _only_ include the current rule(s) added by the text node.
// So we'll be conservative and keep textContent as-is.
} else if ((n.parentNode as HTMLStyleElement).sheet?.cssRules) {
textContent = stringifyStylesheet(
(n.parentNode as HTMLStyleElement).sheet!,
);
try {
textContent = stringifyStylesheet(
(n.parentNode as HTMLStyleElement).sheet!,
);
} catch (err) {
console.warn(
`Cannot get CSS styles from text's parentNode. Error: ${err as string}`,
n,
);
}
}
} catch (err) {
console.warn(
`Cannot get CSS styles from text's parentNode. Error: ${err as string}`,
n,
);
textContent = absoluteToStylesheet(textContent, getHref());
}
textContent = absoluteToStylesheet(textContent, getHref());
}
if (isScript) {
textContent = 'SCRIPT_PLACEHOLDER';
}
if (!isStyle && !isScript && textContent && needsMask) {
if (!isScript && !isStyle && textContent && needsMask) {
textContent = maskTextFn
? maskTextFn(textContent, n.parentElement)
: textContent.replace(/[\S]/g, '*');
Expand All @@ -580,7 +586,6 @@ function serializeTextNode(
return {
type: NodeType.Text,
textContent: textContent || '',
isStyle,
rootId,
};
}
Expand Down Expand Up @@ -649,12 +654,9 @@ function serializeElementNode(
attributes._cssText = absoluteToStylesheet(cssText, stylesheet!.href!);
}
}
// dynamic stylesheet
if (
tagName === 'style' &&
(n as HTMLStyleElement).sheet &&
// TODO: Currently we only try to get dynamic stylesheet when it is an empty style element
!(n.innerText || n.textContent || '').trim().length
(n as HTMLStyleElement).sheet
) {
const cssText = stringifyStylesheet(
(n as HTMLStyleElement).sheet as CSSStyleSheet,
Expand Down Expand Up @@ -952,6 +954,7 @@ export function serializeNodeWithId(
node: serializedElementNodeWithId,
) => unknown;
stylesheetLoadTimeout?: number;
blankTextNodes? : boolean;
},
): serializedNodeWithId | null {
const {
Expand All @@ -977,6 +980,7 @@ export function serializeNodeWithId(
stylesheetLoadTimeout = 5000,
keepIframeSrcFn = () => false,
newlyAddedElement = false,
blankTextNodes = false,
} = options;
let { needsMask } = options;
let { preserveWhiteSpace = true } = options;
Expand Down Expand Up @@ -1010,6 +1014,7 @@ export function serializeNodeWithId(
recordCanvas,
keepIframeSrcFn,
newlyAddedElement,
blankTextNodes,
});
if (!_serializedNode) {
// TODO: dev only
Expand All @@ -1025,7 +1030,6 @@ export function serializeNodeWithId(
slimDOMExcluded(_serializedNode, slimDOMOptions) ||
(!preserveWhiteSpace &&
_serializedNode.type === NodeType.Text &&
!_serializedNode.isStyle &&
!_serializedNode.textContent.replace(/^\s+|\s+$/gm, '').length)
) {
id = IGNORED_NODE;
Expand Down Expand Up @@ -1090,6 +1094,7 @@ export function serializeNodeWithId(
onStylesheetLoad,
stylesheetLoadTimeout,
keepIframeSrcFn,
blankTextNodes: false,
};

if (
Expand All @@ -1099,6 +1104,12 @@ export function serializeNodeWithId(
) {
// value parameter in DOM reflects the correct value, so ignore childNode
} else {
if (
serializedNode.type === NodeType.Element &&
(serializedNode as elementNode).attributes._cssText !== undefined
) {
bypassOptions.blankTextNodes = true;
}
for (const childN of Array.from(n.childNodes)) {
const serializedChildNode = serializeNodeWithId(childN, bypassOptions);
if (serializedChildNode) {
Expand Down
4 changes: 4 additions & 0 deletions packages/rrweb-snapshot/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ export type elementNode = {
export type textNode = {
type: NodeType.Text;
textContent: string;
/**
* @deprecated styles are now always snapshotted against parent <style> element
* style mutations can still happen via an added textNode, but they don't need this attribute for correct replay
*/
isStyle?: true;
};

Expand Down
22 changes: 13 additions & 9 deletions packages/rrweb-snapshot/test/snapshot.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,22 +161,26 @@ describe('style elements', () => {
it('should serialize all rules of stylesheet when the sheet has a single child node', () => {
const styleEl = render(`<style>body { color: red; }</style>`);
styleEl.sheet?.insertRule('section { color: blue; }');
expect(serializeNode(styleEl.childNodes[0])).toMatchObject({
isStyle: true,
expect(serializeNode(styleEl)).toMatchObject({
rootId: undefined,
textContent: 'section {color: blue;}body {color: red;}',
type: 3,
attributes: {
_cssText: 'section {color: blue;}body {color: red;}',
},
type: 2,
});
});

it('should serialize individual text nodes on stylesheets with multiple child nodes', () => {
it('should serialize all rules on stylesheets with mix of insertion type', () => {
const styleEl = render(`<style>body { color: red; }</style>`);
styleEl.sheet?.insertRule('section.lost { color: unseeable; }'); // browser throws this away after append
styleEl.append(document.createTextNode('section { color: blue; }'));
expect(serializeNode(styleEl.childNodes[1])).toMatchObject({
isStyle: true,
styleEl.sheet?.insertRule('section.working { color: pink; }');
expect(serializeNode(styleEl)).toMatchObject({
rootId: undefined,
textContent: 'section { color: blue; }',
type: 3,
attributes: {
_cssText: 'section.working {color: pink;}body {color: red;}section {color: blue;}',
},
type: 2,
});
});
});
Expand Down
9 changes: 4 additions & 5 deletions packages/rrweb/test/__snapshots__/integration.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -3923,12 +3923,13 @@ exports[`record integration tests can record style text mutations 1`] = `
{
\\"type\\": 2,
\\"tagName\\": \\"style\\",
\\"attributes\\": {},
\\"attributes\\": {
\\"_cssText\\": \\"body { background-color: black; }\\"
},
\\"childNodes\\": [
{
\\"type\\": 3,
\\"textContent\\": \\"body { background-color: black; }\\",
\\"isStyle\\": true,
\\"textContent\\": \\"\\",
\\"id\\": 14
}
],
Expand Down Expand Up @@ -4004,7 +4005,6 @@ exports[`record integration tests can record style text mutations 1`] = `
\\"node\\": {
\\"type\\": 3,
\\"textContent\\": \\".absolutify { background-image: url(\\\\\\"http://localhost:3030/rel\\\\\\"); }\\",
\\"isStyle\\": true,
\\"id\\": 22
}
},
Expand All @@ -4014,7 +4014,6 @@ exports[`record integration tests can record style text mutations 1`] = `
\\"node\\": {
\\"type\\": 3,
\\"textContent\\": \\"body { background-color: darkgreen; }\\",
\\"isStyle\\": true,
\\"id\\": 23
}
}
Expand Down
1 change: 1 addition & 0 deletions packages/rrweb/test/integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,7 @@ describe('record integration tests', function (this: ISuite) {
});

it('can record style text mutations', async () => {
// This test shows that the `isStyle` attribute on textContent is not needed in a mutation
// TODO: we could get a lot more elaborate here with mixed textContent and insertRule mutations
const page: puppeteer.Page = await browser.newPage();
await page.goto(`${serverURL}/html`);
Expand Down

0 comments on commit 1ec0ab7

Please sign in to comment.