Record when a doc is in compatMode and trigger this mode upon replay (#697)

* Hygiene: clean up the xhtml namespace attribute; this is an artefact of the `serializeToString` method which we are using (I think) to be consistent with whitespace and to clean up invalid attributes. I'm removing as was confused as am adding tests related to doctypes

* Record when a document is in `compatMode` and trigger this mode on the iframe upon replay

 https://developer.mozilla.org/en-US/docs/Web/API/Document/compatMode

 the included DOCTYPE was picked up from https://stackoverflow.com/questions/18976213/ - there may be better ways of triggering compatMode

* Don't write an extra DOCTYPE if there's one already present in the snapshot. Rely instead on whatever doctype is there to trigger the BackCompat mode

* Modify to write the correct doctype if we can sniff xhtml - don't have any evidence that this will make a difference

* Dev convenience: Ignore files generated by editors

* Typo fix

* Was getting a 2000ms timeout on the 'before' hook I believe

* Change certain tests to go directly to their localhost page instead of loading the html content programmatically in order to avoid triggering an incorrect BackCompat mode (incorrect in that the html content has a correct doctype)

* Add test based on motivating site that had images lined up in a square which were all different sizes; very old style percentage width/height attributes were doing the right thing in quirksmode, which is what we are testing for here

* Fixup rrweb test html to include a valid doctype and avoid BackCompat to ensure we're not accidentally testing against quirks modes. I didn't find an elegant way of avoiding the `BackCompat` when adding a minimal iframe, so some BackCompat has slipped in here, I don't think there's much harm
This commit is contained in:
Eoghan Murray
2026-04-01 12:00:00 +08:00
committed by GitHub
parent af929f0767
commit 24d686ecfb
17 changed files with 645 additions and 420 deletions

View File

@@ -321,6 +321,20 @@ export function buildNodeWithSN(
// close before open to make sure document was closed
doc.close();
doc.open();
if (n.compatMode === 'BackCompat' &&
(n.childNodes && n.childNodes[0].type !== NodeType.DocumentType) // there isn't one already defined
) {
// Trigger compatMode in the iframe
// this is needed as document.createElement('iframe') otherwise inherits a CSS1Compat mode from the parent replayer environment
if (n.childNodes[0].type === NodeType.Element &&
'xmlns' in n.childNodes[0].attributes &&
n.childNodes[0].attributes.xmlns === 'http://www.w3.org/1999/xhtml') {
// might as well use an xhtml doctype if we've got an xhtml namespace
doc.write('<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "">');
} else {
doc.write('<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.0 Transitional//EN" "">');
}
}
node = doc;
}

View File

@@ -10,6 +10,7 @@ import {
MaskTextFn,
MaskInputFn,
KeepIframeSrcFn,
documentNode,
} from './types';
import { isElement, isShadowRoot, maskInputValue } from './utils';
@@ -379,11 +380,20 @@ function serializeNode(
}
switch (n.nodeType) {
case n.DOCUMENT_NODE:
return {
type: NodeType.Document,
childNodes: [],
rootId,
};
if ((n as HTMLDocument).compatMode !== 'CSS1Compat') {
return {
type: NodeType.Document,
childNodes: [],
compatMode: (n as HTMLDocument).compatMode, // probably "BackCompat"
rootId,
}
} else {
return {
type: NodeType.Document,
childNodes: [],
rootId,
}
}
case n.DOCUMENT_TYPE_NODE:
return {
type: NodeType.DocumentType,

View File

@@ -10,6 +10,7 @@ export enum NodeType {
export type documentNode = {
type: NodeType.Document;
childNodes: serializedNodeWithId[];
compatMode?: string;
};
export type documentTypeNode = {

View File

@@ -1,7 +1,7 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`[html file]: about-mozilla.html 1`] = `
"<!DOCTYPE html><html xmlns=\\"http://www.w3.org/1999/xhtml\\"><head>
"<!DOCTYPE html><html><head>
<title>The Book of Mozilla, 11:9</title>
<style type=\\"text/css\\">
html {
@@ -40,7 +40,7 @@ exports[`[html file]: about-mozilla.html 1`] = `
`;
exports[`[html file]: basic.html 1`] = `
"<!DOCTYPE html><html xmlns=\\"http://www.w3.org/1999/xhtml\\" lang=\\"en\\"><head>
"<!DOCTYPE html><html lang=\\"en\\"><head>
<meta charset=\\"UTF-8\\" />
<meta name=\\"viewport\\" content=\\"width=device-width, initial-scale=1.0\\" />
<meta http-equiv=\\"X-UA-Compatible\\" content=\\"ie=edge\\" />
@@ -50,7 +50,7 @@ exports[`[html file]: basic.html 1`] = `
`;
exports[`[html file]: block-element.html 1`] = `
"<!DOCTYPE html><html xmlns=\\"http://www.w3.org/1999/xhtml\\" lang=\\"en\\"><head>
"<!DOCTYPE html><html lang=\\"en\\"><head>
<meta charset=\\"UTF-8\\" />
<meta name=\\"viewport\\" content=\\"width=device-width, initial-scale=1.0\\" />
<meta http-equiv=\\"X-UA-Compatible\\" content=\\"ie=edge\\" />
@@ -74,8 +74,22 @@ exports[`[html file]: block-element.html 1`] = `
</body></html>"
`;
exports[`[html file]: compat-mode.html 1`] = `
"<!DOCTYPE html PUBLIC \\"-//W3C//DTD HTML 4.0 Transitional//EN\\"><!-- no doctype! --><html><head>
<title>Compat Mode; image resizing</title>
</head>
<body>
<center>
<a href=\\"http://localhost:3030/html#\\" class=\\"should-be-square-shaped\\">
<img width=\\"40%\\" height=\\"35%\\" src=\\"http://localhost:3030/images/compat-top-left.png\\" />
<img width=\\"40%\\" height=\\"35%\\" src=\\"http://localhost:3030/images/compat-top-right.png\\" />
<br /><img width=\\"80%\\" height=\\"20%\\" src=\\"http://localhost:3030/images/compat-bottom.png\\" /></a>
</center>
</body></html>"
`;
exports[`[html file]: cors-style-sheet.html 1`] = `
"<!DOCTYPE html><html xmlns=\\"http://www.w3.org/1999/xhtml\\" lang=\\"en\\"><head>
"<!DOCTYPE html><html lang=\\"en\\"><head>
<meta charset=\\"UTF-8\\" />
<meta name=\\"viewport\\" content=\\"width=device-width, initial-scale=1.0\\" />
<meta http-equiv=\\"X-UA-Compatible\\" content=\\"ie=edge\\" />
@@ -87,7 +101,7 @@ exports[`[html file]: cors-style-sheet.html 1`] = `
`;
exports[`[html file]: dynamic-stylesheet.html 1`] = `
"<!DOCTYPE html><html xmlns=\\"http://www.w3.org/1999/xhtml\\" lang=\\"en\\"><head>
"<!DOCTYPE html><html lang=\\"en\\"><head>
<meta charset=\\"UTF-8\\" />
<meta name=\\"viewport\\" content=\\"width=device-width, initial-scale=1.0\\" />
<meta http-equiv=\\"X-UA-Compatible\\" content=\\"ie=edge\\" />
@@ -101,7 +115,7 @@ exports[`[html file]: dynamic-stylesheet.html 1`] = `
`;
exports[`[html file]: form-fields.html 1`] = `
"<!DOCTYPE html><html xmlns=\\"http://www.w3.org/1999/xhtml\\" lang=\\"en\\"><head>
"<!DOCTYPE html><html lang=\\"en\\"><head>
<meta charset=\\"UTF-8\\" />
<meta name=\\"viewport\\" content=\\"width=device-width, initial-scale=1.0\\" />
<meta http-equiv=\\"X-UA-Compatible\\" content=\\"ie=edge\\" />
@@ -135,7 +149,7 @@ exports[`[html file]: form-fields.html 1`] = `
`;
exports[`[html file]: hover.html 1`] = `
"<!DOCTYPE html><html xmlns=\\"http://www.w3.org/1999/xhtml\\" lang=\\"en\\"><head>
"<!DOCTYPE html><html lang=\\"en\\"><head>
<meta charset=\\"UTF-8\\" />
<meta name=\\"viewport\\" content=\\"width=device-width, initial-scale=1.0\\" />
<meta http-equiv=\\"X-UA-Compatible\\" content=\\"ie=edge\\" />
@@ -158,7 +172,7 @@ exports[`[html file]: hover.html 1`] = `
`;
exports[`[html file]: iframe.html 1`] = `
"<!DOCTYPE html><html xmlns=\\"http://www.w3.org/1999/xhtml\\" lang=\\"en\\"><head>
"<!DOCTYPE html><html lang=\\"en\\"><head>
<meta charset=\\"UTF-8\\" />
<meta name=\\"viewport\\" content=\\"width=device-width, initial-scale=1.0\\" />
<meta http-equiv=\\"X-UA-Compatible\\" content=\\"ie=edge\\" />
@@ -170,17 +184,17 @@ exports[`[html file]: iframe.html 1`] = `
`;
exports[`[html file]: iframe-inner.html 1`] = `
"<html xmlns=\\"http://www.w3.org/1999/xhtml\\"><head></head><body><button>inner iframe button</button>
"<!DOCTYPE html PUBLIC \\"-//W3C//DTD HTML 4.0 Transitional//EN\\"><html><head></head><body><button>inner iframe button</button>
</body></html>"
`;
exports[`[html file]: invalid-attribute.html 1`] = `
"<html xmlns=\\"http://www.w3.org/1999/xhtml\\" foo=\\"bar\\"><head></head><body>
"<!DOCTYPE html PUBLIC \\"-//W3C//DTD HTML 4.0 Transitional//EN\\"><html foo=\\"bar\\"><head></head><body>
</body></html>"
`;
exports[`[html file]: invalid-doctype.html 1`] = `
"<!DOCTYPE html><html xmlns=\\"http://www.w3.org/1999/xhtml\\" lang=\\"en\\"><head>
"<!DOCTYPE html><html lang=\\"en\\"><head>
<meta charset=\\"UTF-8\\" />
<meta name=\\"viewport\\" content=\\"width=device-width, initial-scale=1.0\\" />
<title>Invalid Doctype</title>
@@ -189,7 +203,7 @@ exports[`[html file]: invalid-doctype.html 1`] = `
`;
exports[`[html file]: invalid-tagname.html 1`] = `
"<!DOCTYPE html><html xmlns=\\"http://www.w3.org/1999/xhtml\\" lang=\\"en\\"><head>
"<!DOCTYPE html><html lang=\\"en\\"><head>
<meta charset=\\"UTF-8\\" />
<meta name=\\"viewport\\" content=\\"width=device-width, initial-scale=1.0\\" />
<meta http-equiv=\\"X-UA-Compatible\\" content=\\"ie=edge\\" />
@@ -203,7 +217,7 @@ exports[`[html file]: invalid-tagname.html 1`] = `
`;
exports[`[html file]: mask-text.html 1`] = `
"<!DOCTYPE html><html xmlns=\\"http://www.w3.org/1999/xhtml\\" lang=\\"en\\"><head>
"<!DOCTYPE html><html lang=\\"en\\"><head>
<meta charset=\\"UTF-8\\" />
<meta name=\\"viewport\\" content=\\"width=device-width, initial-scale=1.0\\" />
<meta http-equiv=\\"X-UA-Compatible\\" content=\\"ie=edge\\" />
@@ -218,7 +232,7 @@ exports[`[html file]: mask-text.html 1`] = `
`;
exports[`[html file]: picture.html 1`] = `
"<html xmlns=\\"http://www.w3.org/1999/xhtml\\"><head></head><body>
"<!DOCTYPE html PUBLIC \\"-//W3C//DTD XHTML 1.0 Transitional//EN\\"><html xmlns=\\"http://www.w3.org/1999/xhtml\\"><head></head><body>
<picture>
<source type=\\"image/webp\\" srcset=\\"http://localhost:3030/assets/img/characters/robot.webp\\" />
<img src=\\"http://localhost:3030/assets/img/characters/robot.png\\" />
@@ -227,7 +241,7 @@ exports[`[html file]: picture.html 1`] = `
`;
exports[`[html file]: preload.html 1`] = `
"<!DOCTYPE html><html xmlns=\\"http://www.w3.org/1999/xhtml\\" lang=\\"en\\"><head>
"<!DOCTYPE html><html lang=\\"en\\"><head>
<meta charset=\\"UTF-8\\" />
<meta name=\\"viewport\\" content=\\"width=device-width, initial-scale=1.0\\" />
<title>Document</title>
@@ -238,7 +252,7 @@ exports[`[html file]: preload.html 1`] = `
`;
exports[`[html file]: shadow-dom.html 1`] = `
"<!DOCTYPE html><html xmlns=\\"http://www.w3.org/1999/xhtml\\" lang=\\"en\\"><head>
"<!DOCTYPE html><html lang=\\"en\\"><head>
<meta charset=\\"UTF-8\\" />
<meta name=\\"viewport\\" content=\\"width=device-width, initial-scale=1.0\\" />
<title>shadow DOM</title>
@@ -257,7 +271,7 @@ exports[`[html file]: shadow-dom.html 1`] = `
`;
exports[`[html file]: video.html 1`] = `
"<!DOCTYPE html><html xmlns=\\"http://www.w3.org/1999/xhtml\\" lang=\\"en\\"><head>
"<!DOCTYPE html><html lang=\\"en\\"><head>
<meta charset=\\"UTF-8\\" />
<meta name=\\"viewport\\" content=\\"width=device-width, initial-scale=1.0\\" />
<meta http-equiv=\\"X-UA-Compatible\\" content=\\"ie=edge\\" />
@@ -272,7 +286,7 @@ exports[`[html file]: video.html 1`] = `
`;
exports[`[html file]: with-relative-res.html 1`] = `
"<!DOCTYPE html><html xmlns=\\"http://www.w3.org/1999/xhtml\\" lang=\\"en\\"><head>
"<!DOCTYPE html><html lang=\\"en\\"><head>
<meta charset=\\"UTF-8\\" />
<meta name=\\"viewport\\" content=\\"width=device-width, initial-scale=1.0\\" />
<meta http-equiv=\\"X-UA-Compatible\\" content=\\"ie=edge\\" />
@@ -292,7 +306,7 @@ exports[`[html file]: with-relative-res.html 1`] = `
`;
exports[`[html file]: with-script.html 1`] = `
"<!DOCTYPE html><html xmlns=\\"http://www.w3.org/1999/xhtml\\" lang=\\"en\\"><head>
"<!DOCTYPE html><html lang=\\"en\\"><head>
<meta charset=\\"UTF-8\\" />
<meta name=\\"viewport\\" content=\\"width=device-width, initial-scale=1.0\\" />
<meta http-equiv=\\"X-UA-Compatible\\" content=\\"ie=edge\\" />
@@ -303,7 +317,7 @@ exports[`[html file]: with-script.html 1`] = `
`;
exports[`[html file]: with-style-sheet.html 1`] = `
"<!DOCTYPE html><html xmlns=\\"http://www.w3.org/1999/xhtml\\" lang=\\"en\\"><head>
"<!DOCTYPE html><html lang=\\"en\\"><head>
<meta charset=\\"UTF-8\\" />
<meta name=\\"viewport\\" content=\\"width=device-width, initial-scale=1.0\\" />
<meta http-equiv=\\"X-UA-Compatible\\" content=\\"ie=edge\\" />
@@ -314,7 +328,7 @@ exports[`[html file]: with-style-sheet.html 1`] = `
`;
exports[`[html file]: with-style-sheet-with-import.html 1`] = `
"<!DOCTYPE html><html xmlns=\\"http://www.w3.org/1999/xhtml\\" lang=\\"en\\"><head>
"<!DOCTYPE html><html lang=\\"en\\"><head>
<meta charset=\\"UTF-8\\" />
<meta name=\\"viewport\\" content=\\"width=device-width, initial-scale=1.0\\" />
<meta http-equiv=\\"X-UA-Compatible\\" content=\\"ie=edge\\" />
@@ -456,7 +470,7 @@ exports[`iframe integration tests 1`] = `
}"
`;
exports[`shadown DOM integration tests 1`] = `
exports[`shadow DOM integration tests 1`] = `
"{
\\"type\\": 0,
\\"childNodes\\": [

View File

@@ -0,0 +1,14 @@
<!-- no doctype! -->
<html>
<head>
<title>Compat Mode; image resizing</title>
</head>
<body>
<center>
<a href="#" class="should-be-square-shaped">
<img width="40%" height="35%" src="/images/compat-top-left.png">
<img width="40%" height="35%" src="/images/compat-top-right.png">
<br><img width="80%" height="20%" src="/images/compat-bottom.png"></a>
</center>
</body>
</html>

View File

@@ -1,4 +1,4 @@
<html>
<html xmlns="http://www.w3.org/1999/xhtml">
<body>
<picture>
<source type="image/webp" srcset="assets/img/characters/robot.webp" />

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

@@ -74,6 +74,8 @@ interface ISuite extends Suite {
}
describe('integration tests', function (this: ISuite) {
this.timeout(10_000);
before(async () => {
this.server = await server();
this.browser = await puppeteer.launch({
@@ -97,32 +99,84 @@ describe('integration tests', function (this: ISuite) {
});
for (const html of htmls) {
if (html.filePath.substring(html.filePath.length - 1) === '~') {
continue;
}
const title = '[html file]: ' + html.filePath;
it(title, async () => {
const page: puppeteer.Page = await this.browser.newPage();
// console for debug
// tslint:disable-next-line: no-console
page.on('console', (msg) => console.log(msg.text()));
await page.goto(`http://localhost:3030/html`);
await page.setContent(html.src, {
waitUntil: 'load',
});
if (html.filePath === 'iframe.html') {
// loading directly is needed to ensure we don't trigger compatMode='BackCompat'
// which happens before setContent can be called
await page.goto(`http://localhost:3030/html/${html.filePath}`, {
waitUntil: 'load',
});
const outerCompatMode = await page.evaluate('document.compatMode');
const innerCompatMode = await page.evaluate('document.querySelector("iframe").contentDocument.compatMode');
assert(outerCompatMode === 'CSS1Compat', outerCompatMode + ' for outer iframe.html should be CSS1Compat as it has "<!DOCTYPE html>"');
// inner omits a doctype so gets rendered in backwards compat mode
// although this was originally accidental, we'll add a synthetic doctype to the rebuild to recreate this
assert(innerCompatMode === 'BackCompat', innerCompatMode + ' for iframe-inner.html should be BackCompat as it lacks "<!DOCTYPE html>"');
} else {
// loading indirectly is improtant for relative path testing
await page.goto(`http://localhost:3030/html`);
await page.setContent(html.src, {
waitUntil: 'load',
});
}
const rebuildHtml = (
await page.evaluate(`${this.code}
const x = new XMLSerializer();
const [snap] = rrweb.snapshot(document);
x.serializeToString(rrweb.rebuild(snap, { doc: document })[0]);
let out = x.serializeToString(rrweb.rebuild(snap, { doc: document })[0]);
if (document.querySelector('html').getAttribute('xmlns') !== 'http://www.w3.org/1999/xhtml') {
// this is just an artefact of serializeToString
out = out.replace(' xmlns=\"http://www.w3.org/1999/xhtml\"', '');
}
out; // return
`)
).replace(/\n\n/g, '');
const result = matchSnapshot(rebuildHtml, __filename, title);
assert(result.pass, result.pass ? '' : result.report());
}).timeout(5000);
}
it('correctly triggers backCompat mode and rendering', async () => {
const page: puppeteer.Page = await this.browser.newPage();
// console for debug
// tslint:disable-next-line: no-console
page.on('console', (msg) => console.log(msg.text()));
await page.goto('http://localhost:3030/html/compat-mode.html', {
waitUntil: 'load',
});
const compatMode = await page.evaluate('document.compatMode');
assert(compatMode === 'BackCompat', compatMode + ' for compat-mode.html should be BackCompat as DOCTYPE is deliberately omitted');
const renderedHeight = await page.evaluate('document.querySelector("center").clientHeight');
// can remove following assertion if dimensions of page change
assert(renderedHeight < 400, `pre-check: images will be rendered ~326px high in BackCompat mode, and ~588px in CSS1Compat mode; getting: ${renderedHeight}px`)
const rebuildRenderedHeight = await page.evaluate(`${this.code}
const [snap] = rrweb.snapshot(document);
const iframe = document.createElement('iframe');
iframe.setAttribute('width', document.body.clientWidth)
iframe.setAttribute('height', document.body.clientHeight)
iframe.style.transform = 'scale(0.3)'; // mini-me
document.body.appendChild(iframe);
// magic here! rebuild in a new iframe
const rebuildNode = rrweb.rebuild(snap, { doc: iframe.contentDocument })[0];
iframe.contentDocument.querySelector('center').clientHeight
`);
const rebuildCompatMode = await page.evaluate('document.querySelector("iframe").contentDocument.compatMode');
assert(rebuildCompatMode === 'BackCompat', 'rebuilt compatMode should match source compatMode, but doesn\'t: ' + rebuildCompatMode);
assert(rebuildRenderedHeight === renderedHeight, 'rebuilt height (${rebuildRenderedHeight}) should equal original height (${renderedHeight})')
}).timeout(5000);
});
describe('iframe integration tests', function (this: ISuite) {
const iframeHtml = path.join(__dirname, 'iframe-html/main.html');
const raw = fs.readFileSync(iframeHtml, 'utf-8');
before(async () => {
this.server = await server();
@@ -151,8 +205,7 @@ describe('iframe integration tests', function (this: ISuite) {
// console for debug
// tslint:disable-next-line: no-console
page.on('console', (msg) => console.log(msg.text()));
await page.goto(`http://localhost:3030/html`);
await page.setContent(raw, {
await page.goto(`http://localhost:3030/iframe-html/main.html`, {
waitUntil: 'load',
});
const snapshotResult = JSON.stringify(
@@ -167,9 +220,7 @@ describe('iframe integration tests', function (this: ISuite) {
}).timeout(5000);
});
describe('shadown DOM integration tests', function (this: ISuite) {
const shadowDomHtml = path.join(__dirname, 'html/shadow-dom.html');
const raw = fs.readFileSync(shadowDomHtml, 'utf-8');
describe('shadow DOM integration tests', function (this: ISuite) {
before(async () => {
this.server = await server();
@@ -198,8 +249,7 @@ describe('shadown DOM integration tests', function (this: ISuite) {
// console for debug
// tslint:disable-next-line: no-console
page.on('console', (msg) => console.log(msg.text()));
await page.goto(`http://localhost:3030/html`);
await page.setContent(raw, {
await page.goto(`http://localhost:3030/html/shadow-dom.html`, {
waitUntil: 'load',
});
const snapshotResult = JSON.stringify(

View File

@@ -9,6 +9,7 @@ export declare enum NodeType {
export declare type documentNode = {
type: NodeType.Document;
childNodes: serializedNodeWithId[];
compatMode?: string;
};
export declare type documentTypeNode = {
type: NodeType.DocumentType;