Inline stylesheets on load (#909)

* inline stylesheets when loaded

* set empty link elements to loaded by default

* Clean up stylesheet manager

* Remove attribute mutation code

* Update packages/rrweb/test/record.test.ts

* Update packages/rrweb/test/record.test.ts

* Update packages/rrweb/test/record.test.ts

* Update packages/rrweb/scripts/repl.js

* Update packages/rrweb/test/record.test.ts

* Update packages/rrweb/src/record/index.ts

* Add todo

* Move require out of time sensitive assert

* Add waitForRAF, its more reliable than waitForTimeout

* Remove flaky tests

* Add recording stylesheets in iframes

* Remove variability from flaky test

* Make test more robust

* Fix naming
This commit is contained in:
Justin Halsall
2026-04-01 12:00:00 +08:00
committed by GitHub
parent c52368fd91
commit 77e86d1f20
16 changed files with 985 additions and 25 deletions

View File

@@ -94,6 +94,109 @@ exports[`record can add custom event 1`] = `
]"
`;
exports[`record captures CORS stylesheets that are still loading 1`] = `
"[
{
\\"type\\": 4,
\\"data\\": {
\\"href\\": \\"about:blank\\",
\\"width\\": 1920,
\\"height\\": 1080
}
},
{
\\"type\\": 2,
\\"data\\": {
\\"node\\": {
\\"type\\": 0,
\\"childNodes\\": [
{
\\"type\\": 1,
\\"name\\": \\"html\\",
\\"publicId\\": \\"\\",
\\"systemId\\": \\"\\",
\\"id\\": 2
},
{
\\"type\\": 2,
\\"tagName\\": \\"html\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 2,
\\"tagName\\": \\"head\\",
\\"attributes\\": {},
\\"childNodes\\": [],
\\"id\\": 4
},
{
\\"type\\": 2,
\\"tagName\\": \\"body\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 6
},
{
\\"type\\": 2,
\\"tagName\\": \\"input\\",
\\"attributes\\": {
\\"type\\": \\"text\\",
\\"size\\": \\"40\\"
},
\\"childNodes\\": [],
\\"id\\": 7
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\\\n \\\\n \\",
\\"id\\": 8
}
],
\\"id\\": 5
}
],
\\"id\\": 3
}
],
\\"id\\": 1
},
\\"initialOffset\\": {
\\"left\\": 0,
\\"top\\": 0
}
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 0,
\\"adds\\": [
{
\\"parentId\\": 9,
\\"nextId\\": null,
\\"node\\": {
\\"type\\": 2,
\\"tagName\\": \\"link\\",
\\"attributes\\": {
\\"rel\\": \\"stylesheet\\",
\\"href\\": \\"https://cdn.jsdelivr.net/npm/pure@2.85.0/index.css\\"
},
\\"childNodes\\": [],
\\"id\\": 9
}
}
],
\\"removes\\": [],
\\"texts\\": [],
\\"attributes\\": []
}
}
]"
`;
exports[`record captures inserted style text nodes correctly 1`] = `
"[
{
@@ -640,6 +743,498 @@ exports[`record captures stylesheet rules 1`] = `
]"
`;
exports[`record captures stylesheets in iframes that are still loading 1`] = `
"[
{
\\"type\\": 4,
\\"data\\": {
\\"href\\": \\"about:blank\\",
\\"width\\": 1920,
\\"height\\": 1080
}
},
{
\\"type\\": 2,
\\"data\\": {
\\"node\\": {
\\"type\\": 0,
\\"childNodes\\": [
{
\\"type\\": 1,
\\"name\\": \\"html\\",
\\"publicId\\": \\"\\",
\\"systemId\\": \\"\\",
\\"id\\": 2
},
{
\\"type\\": 2,
\\"tagName\\": \\"html\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 2,
\\"tagName\\": \\"head\\",
\\"attributes\\": {},
\\"childNodes\\": [],
\\"id\\": 4
},
{
\\"type\\": 2,
\\"tagName\\": \\"body\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 6
},
{
\\"type\\": 2,
\\"tagName\\": \\"input\\",
\\"attributes\\": {
\\"type\\": \\"text\\",
\\"size\\": \\"40\\"
},
\\"childNodes\\": [],
\\"id\\": 7
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\\\n \\\\n \\",
\\"id\\": 8
},
{
\\"type\\": 2,
\\"tagName\\": \\"iframe\\",
\\"attributes\\": {},
\\"childNodes\\": [],
\\"id\\": 9
}
],
\\"id\\": 5
}
],
\\"id\\": 3
}
],
\\"id\\": 1
},
\\"initialOffset\\": {
\\"left\\": 0,
\\"top\\": 0
}
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 0,
\\"adds\\": [
{
\\"parentId\\": 9,
\\"nextId\\": null,
\\"node\\": {
\\"type\\": 0,
\\"childNodes\\": [
{
\\"type\\": 2,
\\"tagName\\": \\"html\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 2,
\\"tagName\\": \\"head\\",
\\"attributes\\": {},
\\"childNodes\\": [],
\\"rootId\\": 10,
\\"id\\": 12
},
{
\\"type\\": 2,
\\"tagName\\": \\"body\\",
\\"attributes\\": {},
\\"childNodes\\": [],
\\"rootId\\": 10,
\\"id\\": 14
}
],
\\"rootId\\": 10,
\\"id\\": 11
}
],
\\"compatMode\\": \\"BackCompat\\",
\\"id\\": 10
}
}
],
\\"removes\\": [],
\\"texts\\": [],
\\"attributes\\": [],
\\"isAttachIframe\\": true
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 0,
\\"adds\\": [
{
\\"parentId\\": 13,
\\"nextId\\": null,
\\"node\\": {
\\"type\\": 2,
\\"tagName\\": \\"link\\",
\\"attributes\\": {
\\"_cssText\\": \\"body { color: pink; }\\"
},
\\"childNodes\\": [],
\\"rootId\\": 10,
\\"id\\": 13
}
}
],
\\"removes\\": [],
\\"texts\\": [],
\\"attributes\\": []
}
}
]"
`;
exports[`record captures stylesheets in iframes with \`blob:\` url 1`] = `
"[
{
\\"type\\": 4,
\\"data\\": {
\\"href\\": \\"about:blank\\",
\\"width\\": 1920,
\\"height\\": 1080
}
},
{
\\"type\\": 2,
\\"data\\": {
\\"node\\": {
\\"type\\": 0,
\\"childNodes\\": [
{
\\"type\\": 1,
\\"name\\": \\"html\\",
\\"publicId\\": \\"\\",
\\"systemId\\": \\"\\",
\\"id\\": 2
},
{
\\"type\\": 2,
\\"tagName\\": \\"html\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 2,
\\"tagName\\": \\"head\\",
\\"attributes\\": {},
\\"childNodes\\": [],
\\"id\\": 4
},
{
\\"type\\": 2,
\\"tagName\\": \\"body\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 6
},
{
\\"type\\": 2,
\\"tagName\\": \\"input\\",
\\"attributes\\": {
\\"type\\": \\"text\\",
\\"size\\": \\"40\\"
},
\\"childNodes\\": [],
\\"id\\": 7
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\\\n \\\\n \\",
\\"id\\": 8
},
{
\\"type\\": 2,
\\"tagName\\": \\"iframe\\",
\\"attributes\\": {},
\\"childNodes\\": [],
\\"id\\": 9
}
],
\\"id\\": 5
}
],
\\"id\\": 3
}
],
\\"id\\": 1
},
\\"initialOffset\\": {
\\"left\\": 0,
\\"top\\": 0
}
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 0,
\\"adds\\": [
{
\\"parentId\\": 9,
\\"nextId\\": null,
\\"node\\": {
\\"type\\": 0,
\\"childNodes\\": [
{
\\"type\\": 2,
\\"tagName\\": \\"html\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 2,
\\"tagName\\": \\"head\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 2,
\\"tagName\\": \\"link\\",
\\"attributes\\": {
\\"_cssText\\": \\"body { color: pink; }\\"
},
\\"childNodes\\": [],
\\"rootId\\": 10,
\\"id\\": 13
}
],
\\"rootId\\": 10,
\\"id\\": 12
},
{
\\"type\\": 2,
\\"tagName\\": \\"body\\",
\\"attributes\\": {},
\\"childNodes\\": [],
\\"rootId\\": 10,
\\"id\\": 14
}
],
\\"rootId\\": 10,
\\"id\\": 11
}
],
\\"compatMode\\": \\"BackCompat\\",
\\"id\\": 10
}
}
],
\\"removes\\": [],
\\"texts\\": [],
\\"attributes\\": [],
\\"isAttachIframe\\": true
}
}
]"
`;
exports[`record captures stylesheets that are still loading 1`] = `
"[
{
\\"type\\": 4,
\\"data\\": {
\\"href\\": \\"about:blank\\",
\\"width\\": 1920,
\\"height\\": 1080
}
},
{
\\"type\\": 2,
\\"data\\": {
\\"node\\": {
\\"type\\": 0,
\\"childNodes\\": [
{
\\"type\\": 1,
\\"name\\": \\"html\\",
\\"publicId\\": \\"\\",
\\"systemId\\": \\"\\",
\\"id\\": 2
},
{
\\"type\\": 2,
\\"tagName\\": \\"html\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 2,
\\"tagName\\": \\"head\\",
\\"attributes\\": {},
\\"childNodes\\": [],
\\"id\\": 4
},
{
\\"type\\": 2,
\\"tagName\\": \\"body\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 6
},
{
\\"type\\": 2,
\\"tagName\\": \\"input\\",
\\"attributes\\": {
\\"type\\": \\"text\\",
\\"size\\": \\"40\\"
},
\\"childNodes\\": [],
\\"id\\": 7
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\\\n \\\\n \\",
\\"id\\": 8
}
],
\\"id\\": 5
}
],
\\"id\\": 3
}
],
\\"id\\": 1
},
\\"initialOffset\\": {
\\"left\\": 0,
\\"top\\": 0
}
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 0,
\\"adds\\": [
{
\\"parentId\\": 9,
\\"nextId\\": null,
\\"node\\": {
\\"type\\": 2,
\\"tagName\\": \\"link\\",
\\"attributes\\": {
\\"_cssText\\": \\"body { color: pink; }\\"
},
\\"childNodes\\": [],
\\"id\\": 9
}
}
],
\\"removes\\": [],
\\"texts\\": [],
\\"attributes\\": []
}
}
]"
`;
exports[`record captures stylesheets with \`blob:\` url 1`] = `
"[
{
\\"type\\": 4,
\\"data\\": {
\\"href\\": \\"about:blank\\",
\\"width\\": 1920,
\\"height\\": 1080
}
},
{
\\"type\\": 2,
\\"data\\": {
\\"node\\": {
\\"type\\": 0,
\\"childNodes\\": [
{
\\"type\\": 1,
\\"name\\": \\"html\\",
\\"publicId\\": \\"\\",
\\"systemId\\": \\"\\",
\\"id\\": 2
},
{
\\"type\\": 2,
\\"tagName\\": \\"html\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 2,
\\"tagName\\": \\"head\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 2,
\\"tagName\\": \\"link\\",
\\"attributes\\": {
\\"_cssText\\": \\"body { color: pink; }\\"
},
\\"childNodes\\": [],
\\"id\\": 5
}
],
\\"id\\": 4
},
{
\\"type\\": 2,
\\"tagName\\": \\"body\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 7
},
{
\\"type\\": 2,
\\"tagName\\": \\"input\\",
\\"attributes\\": {
\\"type\\": \\"text\\",
\\"size\\": \\"40\\"
},
\\"childNodes\\": [],
\\"id\\": 8
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\\\n \\\\n \\",
\\"id\\": 9
}
],
\\"id\\": 6
}
],
\\"id\\": 3
}
],
\\"id\\": 1
},
\\"initialOffset\\": {
\\"left\\": 0,
\\"top\\": 0
}
}
}
]"
`;
exports[`record iframes captures stylesheet mutations in iframes 1`] = `
"[
{

View File

@@ -34,7 +34,9 @@ const setup = function (this: ISuite, content: string): ISuite {
const ctx = {} as ISuite;
beforeAll(async () => {
ctx.browser = await launchPuppeteer();
ctx.browser = await launchPuppeteer({
devtools: true,
});
const bundlePath = path.resolve(__dirname, '../dist/rrweb.min.js');
ctx.code = fs.readFileSync(bundlePath, 'utf8');
@@ -143,16 +145,20 @@ describe('record', function (this: ISuite) {
checkoutEveryNms: 500,
});
});
let count = 30;
while (count--) {
await ctx.page.type('input', 'a');
}
await ctx.page.type('input', 'a');
await ctx.page.waitForTimeout(300);
expect(ctx.events.length).toEqual(33); // before first automatic snapshot
await ctx.page.waitForTimeout(200); // could be 33 or 35 events by now depending on speed of test env
expect(
ctx.events.filter((event: eventWithTime) => event.type === EventType.Meta)
.length,
).toEqual(1); // before first automatic snapshot
expect(
ctx.events.filter(
(event: eventWithTime) => event.type === EventType.FullSnapshot,
).length,
).toEqual(1); // before first automatic snapshot
await ctx.page.waitForTimeout(200);
await ctx.page.type('input', 'a');
await ctx.page.waitForTimeout(10);
expect(ctx.events.length).toEqual(36); // additionally includes the 2 checkout events
expect(
ctx.events.filter((event: eventWithTime) => event.type === EventType.Meta)
.length,
@@ -162,8 +168,6 @@ describe('record', function (this: ISuite) {
(event: eventWithTime) => event.type === EventType.FullSnapshot,
).length,
).toEqual(2);
expect(ctx.events[1].type).toEqual(EventType.FullSnapshot);
expect(ctx.events[35].type).toEqual(EventType.FullSnapshot);
});
it('is safe to checkout during async callbacks', async () => {
@@ -381,6 +385,151 @@ describe('record', function (this: ISuite) {
await waitForRAF(ctx.page);
assertSnapshot(ctx.events);
});
it('captures stylesheets with `blob:` url', async () => {
await ctx.page.evaluate(() => {
const link1 = document.createElement('link');
link1.setAttribute('rel', 'stylesheet');
link1.setAttribute(
'href',
URL.createObjectURL(
new Blob(['body { color: pink; }'], {
type: 'text/css',
}),
),
);
document.head.appendChild(link1);
});
await waitForRAF(ctx.page);
await ctx.page.evaluate(() => {
const { record } = ((window as unknown) as IWindow).rrweb;
record({
inlineStylesheet: true,
emit: ((window as unknown) as IWindow).emit,
});
});
await waitForRAF(ctx.page);
assertSnapshot(ctx.events);
});
it('captures stylesheets in iframes with `blob:` url', async () => {
await ctx.page.evaluate(() => {
const iframe = document.createElement('iframe');
iframe.setAttribute('src', 'about:blank');
document.body.appendChild(iframe);
const linkEl = document.createElement('link');
linkEl.setAttribute('rel', 'stylesheet');
linkEl.setAttribute(
'href',
URL.createObjectURL(
new Blob(['body { color: pink; }'], {
type: 'text/css',
}),
),
);
const iframeDoc = iframe.contentDocument!;
iframeDoc.head.appendChild(linkEl);
});
await waitForRAF(ctx.page);
await ctx.page.evaluate(() => {
const { record } = ((window as unknown) as IWindow).rrweb;
record({
inlineStylesheet: true,
emit: ((window as unknown) as IWindow).emit,
});
});
await waitForRAF(ctx.page);
assertSnapshot(ctx.events);
});
it('captures stylesheets that are still loading', async () => {
await ctx.page.evaluate(() => {
const { record } = ((window as unknown) as IWindow).rrweb;
record({
inlineStylesheet: true,
emit: ((window as unknown) as IWindow).emit,
});
const link1 = document.createElement('link');
link1.setAttribute('rel', 'stylesheet');
link1.setAttribute(
'href',
URL.createObjectURL(
new Blob(['body { color: pink; }'], {
type: 'text/css',
}),
),
);
document.head.appendChild(link1);
});
// `blob:` URLs are not available immediately, so we need to wait for the browser to load them
await waitForRAF(ctx.page);
assertSnapshot(ctx.events);
});
it('captures stylesheets in iframes that are still loading', async () => {
await ctx.page.evaluate(() => {
const iframe = document.createElement('iframe');
iframe.setAttribute('src', 'about:blank');
document.body.appendChild(iframe);
const iframeDoc = iframe.contentDocument!;
const { record } = ((window as unknown) as IWindow).rrweb;
record({
inlineStylesheet: true,
emit: ((window as unknown) as IWindow).emit,
});
const linkEl = document.createElement('link');
linkEl.setAttribute('rel', 'stylesheet');
linkEl.setAttribute(
'href',
URL.createObjectURL(
new Blob(['body { color: pink; }'], {
type: 'text/css',
}),
),
);
iframeDoc.head.appendChild(linkEl);
});
// `blob:` URLs are not available immediately, so we need to wait for the browser to load them
await waitForRAF(ctx.page);
assertSnapshot(ctx.events);
});
it('captures CORS stylesheets that are still loading', async () => {
const corsStylesheetURL =
'https://cdn.jsdelivr.net/npm/pure@2.85.0/index.css';
// do not `await` the following function, otherwise `waitForResponse` _might_ not be called
void ctx.page.evaluate((corsStylesheetURL) => {
const { record } = ((window as unknown) as IWindow).rrweb;
record({
inlineStylesheet: true,
emit: ((window as unknown) as IWindow).emit,
});
const link1 = document.createElement('link');
link1.setAttribute('rel', 'stylesheet');
link1.setAttribute('href', corsStylesheetURL);
document.head.appendChild(link1);
}, corsStylesheetURL);
await ctx.page.waitForResponse(corsStylesheetURL); // wait for stylesheet to be loaded
await waitForRAF(ctx.page); // wait for rrweb to emit events
assertSnapshot(ctx.events);
});
});
describe('record iframes', function (this: ISuite) {
@@ -463,7 +612,8 @@ describe('record iframes', function (this: ISuite) {
}, 10);
}, 10);
});
await ctx.page.waitForTimeout(50);
await ctx.page.waitForTimeout(50); // wait till setTimeout is called
await waitForRAF(ctx.page); // wait till events get sent
const styleRelatedEvents = ctx.events.filter(
(e) =>
e.type === EventType.IncrementalSnapshot &&