From 5b367486ced944472e7e8dd3f54479934953ddc7 Mon Sep 17 00:00:00 2001 From: Yanzhen Yu Date: Wed, 1 Apr 2026 12:00:00 +0800 Subject: [PATCH 001/345] init the repo --- .gitignore | 4 ++++ .prettierrc | 4 ++++ README.md | 3 +++ package.json | 33 +++++++++++++++++++++++++++++++++ src/index.ts | 0 tsconfig.json | 12 ++++++++++++ tslint.json | 14 ++++++++++++++ 7 files changed, 70 insertions(+) create mode 100644 .gitignore create mode 100644 .prettierrc create mode 100644 README.md create mode 100644 package.json create mode 100644 src/index.ts create mode 100644 tsconfig.json create mode 100644 tslint.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..6ccc9d84 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.vscode +node_modules +package-lock.json +build diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 00000000..1502887d --- /dev/null +++ b/.prettierrc @@ -0,0 +1,4 @@ +{ + "singleQuote": true, + "trailingComma": "es5" +} \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 00000000..98c66522 --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# rrweb-snapshot + +Not ready yet diff --git a/package.json b/package.json new file mode 100644 index 00000000..c8c0d7d1 --- /dev/null +++ b/package.json @@ -0,0 +1,33 @@ +{ + "name": "rrweb-snapshot", + "version": "0.1.0", + "description": "rrweb's component to take a snapshot of DOM, aka DOM serializer", + "main": "index.js", + "scripts": { + "test": "mocha -r ts-node/register src/**/*.test.ts" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/rrweb-io/rrweb-snapshot.git" + }, + "keywords": [ + "rrweb", + "snapshot", + "DOM" + ], + "author": "yanzhen@smartx.com", + "license": "MIT", + "bugs": { + "url": "https://github.com/rrweb-io/rrweb-snapshot/issues" + }, + "homepage": "https://github.com/rrweb-io/rrweb-snapshot#readme", + "devDependencies": { + "@types/chai": "^4.1.4", + "@types/mocha": "^5.2.5", + "chai": "^4.1.2", + "mocha": "^5.2.0", + "ts-node": "^7.0.1", + "tslint": "^4.5.1", + "typescript": "^3.0.3" + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 00000000..e69de29b diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 00000000..1aec14ff --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "module": "commonjs", + "noImplicitAny": true, + "removeComments": true, + "preserveConstEnums": true, + "sourceMap": true, + "rootDir": "src", + "outDir": "build" + }, + "compileOnSave": true +} \ No newline at end of file diff --git a/tslint.json b/tslint.json new file mode 100644 index 00000000..c82ebf60 --- /dev/null +++ b/tslint.json @@ -0,0 +1,14 @@ +{ + "defaultSeverity": "error", + "extends": ["tslint:recommended"], + "jsRules": {}, + "rules": { + "no-any": true, + "quotemark": [true, "single"], + "ordered-imports": false, + "object-literal-sort-keys": false, + "no-unused-variable": true, + "object-literal-key-quotes": false + }, + "rulesDirectory": [] +} \ No newline at end of file From 97c4b4f6e1519fc2e4b155c0b98cf3bbb5988f69 Mon Sep 17 00:00:00 2001 From: Yanzhen Yu Date: Wed, 1 Apr 2026 12:00:00 +0800 Subject: [PATCH 002/345] basic snapshot implementation --- src/index.ts | 138 ++++++++++++++++++++++++++++++++++++++++++++++++++ tsconfig.json | 5 +- tslint.json | 3 +- 3 files changed, 143 insertions(+), 3 deletions(-) diff --git a/src/index.ts b/src/index.ts index e69de29b..4a003d2b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -0,0 +1,138 @@ +let _id = 1; + +function genId(): number { + return _id++; +} + +enum NodeType { + Document, + DocumentType, + Element, + Text, + CDATA, + Comment, +} + +type serializedNode = + | documentNode + | documentTypeNode + | elementNode + | textNode + | cdataNode + | commentNode; + +type documentNode = { + type: NodeType.Document; + childNodes: serializedNode[]; +}; + +type documentTypeNode = { + type: NodeType.DocumentType; + name: string; + publicId: string; + systemId: string; +}; + +type attributes = { + [key: string]: string; +}; +type elementNode = { + type: NodeType.Element; + tagName: string; + attributes: attributes; + childNodes: serializedNode[]; +}; + +type textNode = { + type: NodeType.Text; + textContent: string; +}; + +type cdataNode = { + type: NodeType.CDATA; + textContent: ''; +}; + +type commentNode = { + type: NodeType.Comment; + textContent: string; +}; + +function serializeNode(n: Node): serializedNode | false { + switch (n.nodeType) { + case n.DOCUMENT_NODE: + return { + type: NodeType.Document, + childNodes: [], + }; + case n.DOCUMENT_TYPE_NODE: + return { + type: NodeType.DocumentType, + name: (n as DocumentType).name, + publicId: (n as DocumentType).publicId, + systemId: (n as DocumentType).systemId, + }; + case n.ELEMENT_NODE: + const tagName = (n as HTMLElement).tagName.toLowerCase(); + const attributes: attributes = {}; + for (const { name, value } of Array.from((n as HTMLElement).attributes)) { + attributes[name] = value; + } + return { + type: NodeType.Element, + tagName, + attributes, + childNodes: [], + }; + case n.TEXT_NODE: + // 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 as Text).textContent; + if (parentTagName === 'SCRIPT') { + textContent = ''; + } + return { + type: NodeType.Text, + textContent, + }; + case n.CDATA_SECTION_NODE: + return { + type: NodeType.CDATA, + textContent: '', + }; + case n.COMMENT_NODE: + return { + type: NodeType.Comment, + textContent: (n as Comment).textContent, + }; + default: + return false; + } +} + +type serializedNodeWithId = serializedNode & { id: number }; + +function snapshot(n: Node): serializedNodeWithId | null { + const _serializedNode = serializeNode(n); + if (!_serializedNode) { + // TODO: dev only + console.warn(n, 'not serialized'); + return null; + } + const serializedNode: serializedNodeWithId = Object.assign(_serializedNode, { + id: genId(), + }); + if ( + serializedNode.type === NodeType.Document || + serializedNode.type === NodeType.Element + ) { + for (const childN of Array.from(n.childNodes)) { + serializedNode.childNodes.push(snapshot(childN)); + } + } + return serializedNode; +} + +export default snapshot; diff --git a/tsconfig.json b/tsconfig.json index 1aec14ff..ce1b7e3c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,7 +6,8 @@ "preserveConstEnums": true, "sourceMap": true, "rootDir": "src", - "outDir": "build" + "outDir": "build", + "lib": ["es6", "dom"] }, "compileOnSave": true -} \ No newline at end of file +} diff --git a/tslint.json b/tslint.json index c82ebf60..3a0c0801 100644 --- a/tslint.json +++ b/tslint.json @@ -8,7 +8,8 @@ "ordered-imports": false, "object-literal-sort-keys": false, "no-unused-variable": true, - "object-literal-key-quotes": false + "object-literal-key-quotes": false, + "variable-name": [true, "ban-keywords", "check-format", "allow-leading-underscore"] }, "rulesDirectory": [] } \ No newline at end of file From a71fb73aaf2e981cbd40912b5108288a2eac26d5 Mon Sep 17 00:00:00 2001 From: Yanzhen Yu Date: Wed, 1 Apr 2026 12:00:00 +0800 Subject: [PATCH 003/345] basic rebuild implementation --- src/index.ts | 140 ++---------------------------------------------- src/rebuild.ts | 46 ++++++++++++++++ src/snapshot.ts | 89 ++++++++++++++++++++++++++++++ src/types.ts | 55 +++++++++++++++++++ 4 files changed, 193 insertions(+), 137 deletions(-) create mode 100644 src/rebuild.ts create mode 100644 src/snapshot.ts create mode 100644 src/types.ts diff --git a/src/index.ts b/src/index.ts index 4a003d2b..758f2df9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,138 +1,4 @@ -let _id = 1; +import snapshot from './snapshot'; +import rebuild from './rebuild'; -function genId(): number { - return _id++; -} - -enum NodeType { - Document, - DocumentType, - Element, - Text, - CDATA, - Comment, -} - -type serializedNode = - | documentNode - | documentTypeNode - | elementNode - | textNode - | cdataNode - | commentNode; - -type documentNode = { - type: NodeType.Document; - childNodes: serializedNode[]; -}; - -type documentTypeNode = { - type: NodeType.DocumentType; - name: string; - publicId: string; - systemId: string; -}; - -type attributes = { - [key: string]: string; -}; -type elementNode = { - type: NodeType.Element; - tagName: string; - attributes: attributes; - childNodes: serializedNode[]; -}; - -type textNode = { - type: NodeType.Text; - textContent: string; -}; - -type cdataNode = { - type: NodeType.CDATA; - textContent: ''; -}; - -type commentNode = { - type: NodeType.Comment; - textContent: string; -}; - -function serializeNode(n: Node): serializedNode | false { - switch (n.nodeType) { - case n.DOCUMENT_NODE: - return { - type: NodeType.Document, - childNodes: [], - }; - case n.DOCUMENT_TYPE_NODE: - return { - type: NodeType.DocumentType, - name: (n as DocumentType).name, - publicId: (n as DocumentType).publicId, - systemId: (n as DocumentType).systemId, - }; - case n.ELEMENT_NODE: - const tagName = (n as HTMLElement).tagName.toLowerCase(); - const attributes: attributes = {}; - for (const { name, value } of Array.from((n as HTMLElement).attributes)) { - attributes[name] = value; - } - return { - type: NodeType.Element, - tagName, - attributes, - childNodes: [], - }; - case n.TEXT_NODE: - // 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 as Text).textContent; - if (parentTagName === 'SCRIPT') { - textContent = ''; - } - return { - type: NodeType.Text, - textContent, - }; - case n.CDATA_SECTION_NODE: - return { - type: NodeType.CDATA, - textContent: '', - }; - case n.COMMENT_NODE: - return { - type: NodeType.Comment, - textContent: (n as Comment).textContent, - }; - default: - return false; - } -} - -type serializedNodeWithId = serializedNode & { id: number }; - -function snapshot(n: Node): serializedNodeWithId | null { - const _serializedNode = serializeNode(n); - if (!_serializedNode) { - // TODO: dev only - console.warn(n, 'not serialized'); - return null; - } - const serializedNode: serializedNodeWithId = Object.assign(_serializedNode, { - id: genId(), - }); - if ( - serializedNode.type === NodeType.Document || - serializedNode.type === NodeType.Element - ) { - for (const childN of Array.from(n.childNodes)) { - serializedNode.childNodes.push(snapshot(childN)); - } - } - return serializedNode; -} - -export default snapshot; +export { snapshot, rebuild }; diff --git a/src/rebuild.ts b/src/rebuild.ts new file mode 100644 index 00000000..1d581e45 --- /dev/null +++ b/src/rebuild.ts @@ -0,0 +1,46 @@ +import { serializedNodeWithId, NodeType } from './types'; + +function buildNode(n: serializedNodeWithId): Node | null { + switch (n.type) { + case NodeType.Document: + return document.implementation.createDocument(null, '', null); + case NodeType.DocumentType: + return document.implementation.createDocumentType( + n.name, + n.publicId, + n.systemId, + ); + case NodeType.Element: + const node = document.createElement(n.tagName); + for (const name in n.attributes) { + if (n.attributes.hasOwnProperty(name)) { + node.setAttribute(name, n.attributes[name]); + } + } + return node; + case NodeType.Text: + return document.createTextNode(n.textContent); + case NodeType.CDATA: + return document.createCDATASection(n.textContent); + case NodeType.Comment: + return document.createComment(n.textContent); + default: + return null; + } +} + +function rebuild(n: serializedNodeWithId): Node | null { + const root = buildNode(n); + if (!root) { + return null; + } + if (n.type === NodeType.Document || n.type === NodeType.Element) { + for (const childN of n.childNodes) { + const childNode = rebuild(childN); + root.appendChild(childNode); + } + } + return root; +} + +export default rebuild; diff --git a/src/snapshot.ts b/src/snapshot.ts new file mode 100644 index 00000000..ce805da5 --- /dev/null +++ b/src/snapshot.ts @@ -0,0 +1,89 @@ +import { + serializedNode, + serializedNodeWithId, + NodeType, + attributes, +} from './types'; + +let _id = 1; + +function genId(): number { + return _id++; +} + +function serializeNode(n: Node): serializedNode | false { + switch (n.nodeType) { + case n.DOCUMENT_NODE: + return { + type: NodeType.Document, + childNodes: [], + }; + case n.DOCUMENT_TYPE_NODE: + return { + type: NodeType.DocumentType, + name: (n as DocumentType).name, + publicId: (n as DocumentType).publicId, + systemId: (n as DocumentType).systemId, + }; + case n.ELEMENT_NODE: + const tagName = (n as HTMLElement).tagName.toLowerCase(); + const attributes: attributes = {}; + for (const { name, value } of Array.from((n as HTMLElement).attributes)) { + attributes[name] = value; + } + return { + type: NodeType.Element, + tagName, + attributes, + childNodes: [], + }; + case n.TEXT_NODE: + // 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 as Text).textContent; + if (parentTagName === 'SCRIPT') { + textContent = ''; + } + return { + type: NodeType.Text, + textContent, + }; + case n.CDATA_SECTION_NODE: + return { + type: NodeType.CDATA, + textContent: '', + }; + case n.COMMENT_NODE: + return { + type: NodeType.Comment, + textContent: (n as Comment).textContent, + }; + default: + return false; + } +} + +function snapshot(n: Node): serializedNodeWithId | null { + const _serializedNode = serializeNode(n); + if (!_serializedNode) { + // TODO: dev only + console.warn(n, 'not serialized'); + return null; + } + const serializedNode: serializedNodeWithId = Object.assign(_serializedNode, { + id: genId(), + }); + if ( + serializedNode.type === NodeType.Document || + serializedNode.type === NodeType.Element + ) { + for (const childN of Array.from(n.childNodes)) { + serializedNode.childNodes.push(snapshot(childN)); + } + } + return serializedNode; +} + +export default snapshot; diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 00000000..25fc9962 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,55 @@ +export enum NodeType { + Document, + DocumentType, + Element, + Text, + CDATA, + Comment, +} + +export type documentNode = { + type: NodeType.Document; + childNodes: serializedNodeWithId[]; +}; + +export type documentTypeNode = { + type: NodeType.DocumentType; + name: string; + publicId: string; + systemId: string; +}; + +export type attributes = { + [key: string]: string; +}; +export type elementNode = { + type: NodeType.Element; + tagName: string; + attributes: attributes; + childNodes: serializedNodeWithId[]; +}; + +export type textNode = { + type: NodeType.Text; + textContent: string; +}; + +export type cdataNode = { + type: NodeType.CDATA; + textContent: ''; +}; + +export type commentNode = { + type: NodeType.Comment; + textContent: string; +}; + +export type serializedNode = + | documentNode + | documentTypeNode + | elementNode + | textNode + | cdataNode + | commentNode; + +export type serializedNodeWithId = serializedNode & { id: number }; From ed2bc918e0d73600fcf7da4e7a9447c873660475 Mon Sep 17 00:00:00 2001 From: Yanzhen Yu Date: Wed, 1 Apr 2026 12:00:00 +0800 Subject: [PATCH 004/345] setup tests --- .prettierrc | 4 +-- index.d.ts | 4 +++ package.json | 6 +++- src/rebuild.ts | 6 +++- test/html/about-mozilla.html | 57 +++++++++++++++++++++++++++++ test/html/basic.html | 15 ++++++++ test/index.ts | 70 ++++++++++++++++++++++++++++++++++++ tsconfig.json | 4 ++- tslint.json | 10 ++++-- 9 files changed, 169 insertions(+), 7 deletions(-) create mode 100644 index.d.ts create mode 100644 test/html/about-mozilla.html create mode 100644 test/html/basic.html create mode 100644 test/index.ts diff --git a/.prettierrc b/.prettierrc index 1502887d..a20502b7 100644 --- a/.prettierrc +++ b/.prettierrc @@ -1,4 +1,4 @@ { "singleQuote": true, - "trailingComma": "es5" -} \ No newline at end of file + "trailingComma": "all" +} diff --git a/index.d.ts b/index.d.ts new file mode 100644 index 00000000..f7f2dbfa --- /dev/null +++ b/index.d.ts @@ -0,0 +1,4 @@ +declare module 'mocha-jsdom' { + function mochaDom(options: any): void; + export = mochaDom; +} diff --git a/package.json b/package.json index c8c0d7d1..2c62a4c9 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "description": "rrweb's component to take a snapshot of DOM, aka DOM serializer", "main": "index.js", "scripts": { - "test": "mocha -r ts-node/register src/**/*.test.ts" + "test": "TS_NODE_FILES=true mocha -r ts-node/register test/**/*.ts" }, "repository": { "type": "git", @@ -23,9 +23,13 @@ "homepage": "https://github.com/rrweb-io/rrweb-snapshot#readme", "devDependencies": { "@types/chai": "^4.1.4", + "@types/jsdom": "^11.12.0", "@types/mocha": "^5.2.5", + "@types/node": "^10.11.3", "chai": "^4.1.2", + "jsdom": "^12.1.0", "mocha": "^5.2.0", + "mocha-jsdom": "^2.0.0", "ts-node": "^7.0.1", "tslint": "^4.5.1", "typescript": "^3.0.3" diff --git a/src/rebuild.ts b/src/rebuild.ts index 1d581e45..3dee6026 100644 --- a/src/rebuild.ts +++ b/src/rebuild.ts @@ -14,7 +14,11 @@ function buildNode(n: serializedNodeWithId): Node | null { const node = document.createElement(n.tagName); for (const name in n.attributes) { if (n.attributes.hasOwnProperty(name)) { - node.setAttribute(name, n.attributes[name]); + try { + node.setAttribute(name, n.attributes[name]); + } catch (error) { + // skip invalid attribute + } } } return node; diff --git a/test/html/about-mozilla.html b/test/html/about-mozilla.html new file mode 100644 index 00000000..f353c482 --- /dev/null +++ b/test/html/about-mozilla.html @@ -0,0 +1,57 @@ + + + + + The Book of Mozilla, 11:9 + + + + + +

+ Mammon slept. And the beast reborn spread over the earth and its numbers + grew legion. And they proclaimed the times and sacrificed crops unto the + fire, with the cunning of foxes. And they built a new world in their own + image as promised by the + sacred words, and spoke + of the beast with their children. Mammon awoke, and lo! it was + naught but a follower. +

+ +

+ from The Book of Mozilla, 11:9
(10th Edition) +

+ + + + \ No newline at end of file diff --git a/test/html/basic.html b/test/html/basic.html new file mode 100644 index 00000000..95fac2be --- /dev/null +++ b/test/html/basic.html @@ -0,0 +1,15 @@ + + + + + + + + Document + + + + + + + \ No newline at end of file diff --git a/test/index.ts b/test/index.ts new file mode 100644 index 00000000..a9fba369 --- /dev/null +++ b/test/index.ts @@ -0,0 +1,70 @@ +import 'mocha'; +import mochaDom = require('mocha-jsdom'); +import { expect } from 'chai'; +import * as fs from 'fs'; +import * as path from 'path'; +import { JSDOM } from 'jsdom'; +import { snapshot, rebuild } from '../src'; + +const htmlFolder = path.join(__dirname, 'html'); +const htmls = fs.readdirSync(htmlFolder).map(filePath => { + return { + filePath, + content: fs.readFileSync(path.resolve(htmlFolder, filePath), 'utf-8'), + }; +}); + +describe('integration tests', () => { + mochaDom({ url: 'http://localhost' }); + + it('will snapshot document type', () => { + const raw = ''; + const dom = new JSDOM(raw); + const snap = snapshot(dom.window.document); + expect(snap).to.deep.equal({ + type: 0, + childNodes: [ + { + type: 2, + tagName: 'html', + attributes: {}, + childNodes: [ + { + type: 2, + tagName: 'head', + attributes: {}, + childNodes: [], + id: 3, + }, + { + type: 2, + tagName: 'body', + attributes: {}, + childNodes: [], + id: 4, + }, + ], + id: 2, + }, + ], + id: 1, + }); + }); + + it('will not throw error with invalid attribute', () => { + const raw = ``; + const dom = new JSDOM(raw); + expect(() => rebuild(snapshot(dom.window.document))).not.to.throw(); + }); + + for (const html of htmls) { + it('[html file]:' + html.filePath, () => { + const dom = new JSDOM(html.content); + const snap = snapshot(dom.window.document); + const rebuildDom = rebuild(snap); + expect((rebuildDom as Document).documentElement.outerHTML).to.equal( + dom.window.document.documentElement.outerHTML, + ); + }); + } +}); diff --git a/tsconfig.json b/tsconfig.json index ce1b7e3c..06b323aa 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,5 +9,7 @@ "outDir": "build", "lib": ["es6", "dom"] }, - "compileOnSave": true + "compileOnSave": true, + "exclude": ["test"], + "include": ["index.d.ts"] } diff --git a/tslint.json b/tslint.json index 3a0c0801..a153081c 100644 --- a/tslint.json +++ b/tslint.json @@ -9,7 +9,13 @@ "object-literal-sort-keys": false, "no-unused-variable": true, "object-literal-key-quotes": false, - "variable-name": [true, "ban-keywords", "check-format", "allow-leading-underscore"] + "variable-name": [ + true, + "ban-keywords", + "check-format", + "allow-leading-underscore" + ], + "arrow-parens": false }, "rulesDirectory": [] -} \ No newline at end of file +} From e9cf631934bd6db4971619e3c1aa0860fa8fc4c0 Mon Sep 17 00:00:00 2001 From: Yanzhen Yu Date: Wed, 1 Apr 2026 12:00:00 +0800 Subject: [PATCH 005/345] replace script tag with noscript and inline the states of form field components --- src/rebuild.ts | 13 +++- src/snapshot.ts | 35 +++++++++- src/types.ts | 2 +- test/html/with-script.html | 18 +++++ test/index.ts | 70 -------------------- test/integration.ts | 132 +++++++++++++++++++++++++++++++++++++ 6 files changed, 195 insertions(+), 75 deletions(-) create mode 100644 test/html/with-script.html delete mode 100644 test/index.ts create mode 100644 test/integration.ts diff --git a/src/rebuild.ts b/src/rebuild.ts index 3dee6026..f6be61ca 100644 --- a/src/rebuild.ts +++ b/src/rebuild.ts @@ -11,11 +11,20 @@ function buildNode(n: serializedNodeWithId): Node | null { n.systemId, ); case NodeType.Element: - const node = document.createElement(n.tagName); + const tagName = n.tagName === 'script' ? 'noscript' : n.tagName; + const node = document.createElement(tagName); for (const name in n.attributes) { if (n.attributes.hasOwnProperty(name)) { + let value = n.attributes[name]; + value = typeof value === 'boolean' ? '' : value; + // textarea hack + if (n.tagName === 'textarea' && name === 'value') { + const child = document.createTextNode(value); + node.appendChild(child); + continue; + } try { - node.setAttribute(name, n.attributes[name]); + node.setAttribute(name, value); } catch (error) { // skip invalid attribute } diff --git a/src/snapshot.ts b/src/snapshot.ts index ce805da5..c6f4eb60 100644 --- a/src/snapshot.ts +++ b/src/snapshot.ts @@ -11,6 +11,10 @@ function genId(): number { return _id++; } +function resetId() { + _id = 1; +} + function serializeNode(n: Node): serializedNode | false { switch (n.nodeType) { case n.DOCUMENT_NODE: @@ -31,6 +35,28 @@ function serializeNode(n: Node): serializedNode | false { for (const { name, value } of Array.from((n as HTMLElement).attributes)) { attributes[name] = value; } + if ( + tagName === 'input' || + tagName === 'textarea' || + tagName === 'select' + ) { + const value = (n as HTMLInputElement | HTMLTextAreaElement).value; + if ( + attributes.type !== 'radio' && + attributes.type !== 'checkbox' && + value + ) { + attributes.value = value; + } else if ((n as HTMLInputElement).checked) { + attributes.checked = (n as HTMLInputElement).checked; + } + } + if (tagName === 'option') { + const selectValue = (n as HTMLOptionElement).parentElement; + if (attributes.value === (selectValue as HTMLSelectElement).value) { + attributes.selected = (n as HTMLOptionElement).selected; + } + } return { type: NodeType.Element, tagName, @@ -65,7 +91,7 @@ function serializeNode(n: Node): serializedNode | false { } } -function snapshot(n: Node): serializedNodeWithId | null { +function _snapshot(n: Node): serializedNodeWithId | null { const _serializedNode = serializeNode(n); if (!_serializedNode) { // TODO: dev only @@ -80,10 +106,15 @@ function snapshot(n: Node): serializedNodeWithId | null { serializedNode.type === NodeType.Element ) { for (const childN of Array.from(n.childNodes)) { - serializedNode.childNodes.push(snapshot(childN)); + serializedNode.childNodes.push(_snapshot(childN)); } } return serializedNode; } +function snapshot(n: Node): serializedNodeWithId | null { + resetId(); + return _snapshot(n); +} + export default snapshot; diff --git a/src/types.ts b/src/types.ts index 25fc9962..17fc0e42 100644 --- a/src/types.ts +++ b/src/types.ts @@ -20,7 +20,7 @@ export type documentTypeNode = { }; export type attributes = { - [key: string]: string; + [key: string]: string | boolean; }; export type elementNode = { type: NodeType.Element; diff --git a/test/html/with-script.html b/test/html/with-script.html new file mode 100644 index 00000000..e3598090 --- /dev/null +++ b/test/html/with-script.html @@ -0,0 +1,18 @@ + + + + + + + + with script + + + + + + + + \ No newline at end of file diff --git a/test/index.ts b/test/index.ts deleted file mode 100644 index a9fba369..00000000 --- a/test/index.ts +++ /dev/null @@ -1,70 +0,0 @@ -import 'mocha'; -import mochaDom = require('mocha-jsdom'); -import { expect } from 'chai'; -import * as fs from 'fs'; -import * as path from 'path'; -import { JSDOM } from 'jsdom'; -import { snapshot, rebuild } from '../src'; - -const htmlFolder = path.join(__dirname, 'html'); -const htmls = fs.readdirSync(htmlFolder).map(filePath => { - return { - filePath, - content: fs.readFileSync(path.resolve(htmlFolder, filePath), 'utf-8'), - }; -}); - -describe('integration tests', () => { - mochaDom({ url: 'http://localhost' }); - - it('will snapshot document type', () => { - const raw = ''; - const dom = new JSDOM(raw); - const snap = snapshot(dom.window.document); - expect(snap).to.deep.equal({ - type: 0, - childNodes: [ - { - type: 2, - tagName: 'html', - attributes: {}, - childNodes: [ - { - type: 2, - tagName: 'head', - attributes: {}, - childNodes: [], - id: 3, - }, - { - type: 2, - tagName: 'body', - attributes: {}, - childNodes: [], - id: 4, - }, - ], - id: 2, - }, - ], - id: 1, - }); - }); - - it('will not throw error with invalid attribute', () => { - const raw = ``; - const dom = new JSDOM(raw); - expect(() => rebuild(snapshot(dom.window.document))).not.to.throw(); - }); - - for (const html of htmls) { - it('[html file]:' + html.filePath, () => { - const dom = new JSDOM(html.content); - const snap = snapshot(dom.window.document); - const rebuildDom = rebuild(snap); - expect((rebuildDom as Document).documentElement.outerHTML).to.equal( - dom.window.document.documentElement.outerHTML, - ); - }); - } -}); diff --git a/test/integration.ts b/test/integration.ts new file mode 100644 index 00000000..00375d0d --- /dev/null +++ b/test/integration.ts @@ -0,0 +1,132 @@ +import 'mocha'; +import mochaDom = require('mocha-jsdom'); +import { expect } from 'chai'; +import * as fs from 'fs'; +import * as path from 'path'; +import { JSDOM } from 'jsdom'; +import { snapshot, rebuild } from '../src'; + +const htmlFolder = path.join(__dirname, 'html'); +const htmls = fs.readdirSync(htmlFolder).map(filePath => { + return { + filePath, + content: fs.readFileSync(path.resolve(htmlFolder, filePath), 'utf-8'), + }; +}); + +describe('integration tests', () => { + mochaDom({ url: 'http://localhost' }); + + for (const html of htmls) { + it('[html file]:' + html.filePath, () => { + const dom = new JSDOM(html.content); + const snap = snapshot(dom.window.document); + const rebuildDom = rebuild(snap); + const htmlStr = dom.window.document.documentElement.outerHTML + .replace(/') + .replace(//g, '') + .replace(/<\/script>/g, ''); + expect((rebuildDom as Document).documentElement.outerHTML).to.equal( + htmlStr, + ); + }); + } + + it('will snapshot document type', () => { + const raw = ''; + const dom = new JSDOM(raw); + const snap = snapshot(dom.window.document); + expect(snap).to.deep.equal({ + type: 0, + childNodes: [ + { + type: 2, + tagName: 'html', + attributes: {}, + childNodes: [ + { + type: 2, + tagName: 'head', + attributes: {}, + childNodes: [], + id: 3, + }, + { + type: 2, + tagName: 'body', + attributes: {}, + childNodes: [], + id: 4, + }, + ], + id: 2, + }, + ], + id: 1, + }); + }); + + it('will not throw error with invalid attribute', () => { + const raw = ``; + const dom = new JSDOM(raw); + expect(() => rebuild(snapshot(dom.window.document))).not.to.throw(); + }); + + it('will inline text input value', () => { + const raw = ''; + const dom = new JSDOM(raw); + dom.window.document.querySelector('input').value = '1'; + const rebuildDom = rebuild(snapshot(dom.window.document)); + expect((rebuildDom as Document).querySelector('input').outerHTML).to.equal( + '', + ); + }); + + it('will inline radio input value', () => { + const raw = ''; + const dom = new JSDOM(raw); + dom.window.document.querySelector('input').checked = true; + const rebuildDom = rebuild(snapshot(dom.window.document)); + expect((rebuildDom as Document).querySelector('input').outerHTML).to.equal( + '', + ); + }); + + it('will inline checkbox input value', () => { + const raw = ''; + const dom = new JSDOM(raw); + dom.window.document.querySelector('input').checked = true; + const rebuildDom = rebuild(snapshot(dom.window.document)); + expect((rebuildDom as Document).querySelector('input').outerHTML).to.equal( + '', + ); + }); + + it('will inline textarea value into text node', () => { + const raw = ''; + const dom = new JSDOM(raw); + dom.window.document.querySelector('textarea').value = '1234'; + const rebuildDom = rebuild(snapshot(dom.window.document)); + expect( + (rebuildDom as Document).querySelector('textarea').outerHTML, + ).to.equal(''); + }); + + it('will inline options state', () => { + const raw = ` + + `; + const dom = new JSDOM(raw); + dom.window.document.querySelector('select').value = '2'; + const rebuildDom = rebuild(snapshot(dom.window.document)); + expect((rebuildDom as Document).querySelector('select').outerHTML).to.equal( + ``, + ); + }); +}); From 0e35b86d877b593977acf8606cc3d144bf7ddb31 Mon Sep 17 00:00:00 2001 From: Yanzhen Yu Date: Wed, 1 Apr 2026 12:00:00 +0800 Subject: [PATCH 006/345] refactor test infra so most test cases could be implemented by pure HTML --- test/css/style.css | 6 ++ test/html/form-fields.html | 79 ++++++++++++++++++++++ test/html/invalid-attribute.html | 9 +++ test/html/with-script.html | 21 +++++- test/integration.ts | 108 +++++++++---------------------- 5 files changed, 146 insertions(+), 77 deletions(-) create mode 100644 test/css/style.css create mode 100644 test/html/form-fields.html create mode 100644 test/html/invalid-attribute.html diff --git a/test/css/style.css b/test/css/style.css new file mode 100644 index 00000000..70b063e6 --- /dev/null +++ b/test/css/style.css @@ -0,0 +1,6 @@ +body { + margin: 0; +} +p { + color: red; +} diff --git a/test/html/form-fields.html b/test/html/form-fields.html new file mode 100644 index 00000000..6eef3544 --- /dev/null +++ b/test/html/form-fields.html @@ -0,0 +1,79 @@ + + + + + + + + form fields + + + +
+ + + + + +
+ + + + + + + + + + + + + + + form fields + + + +
+ + + + + +
+ + + + \ No newline at end of file diff --git a/test/html/invalid-attribute.html b/test/html/invalid-attribute.html new file mode 100644 index 00000000..6fa84ad4 --- /dev/null +++ b/test/html/invalid-attribute.html @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/test/html/with-script.html b/test/html/with-script.html index e3598090..3dd8ca93 100644 --- a/test/html/with-script.html +++ b/test/html/with-script.html @@ -11,8 +11,27 @@ + + + + + + + + + + + + with script + + + + + + + \ No newline at end of file diff --git a/test/integration.ts b/test/integration.ts index 00375d0d..4f9917c8 100644 --- a/test/integration.ts +++ b/test/integration.ts @@ -8,9 +8,19 @@ import { snapshot, rebuild } from '../src'; const htmlFolder = path.join(__dirname, 'html'); const htmls = fs.readdirSync(htmlFolder).map(filePath => { + const raw = fs.readFileSync(path.resolve(htmlFolder, filePath), 'utf-8'); + if (//.test(raw)) { + const [src, dest] = raw.split(''); + return { + filePath, + src, + dest, + }; + } return { filePath, - content: fs.readFileSync(path.resolve(htmlFolder, filePath), 'utf-8'), + src: raw, + dest: raw, }; }); @@ -18,17 +28,27 @@ describe('integration tests', () => { mochaDom({ url: 'http://localhost' }); for (const html of htmls) { - it('[html file]:' + html.filePath, () => { - const dom = new JSDOM(html.content); - const snap = snapshot(dom.window.document); - const rebuildDom = rebuild(snap); - const htmlStr = dom.window.document.documentElement.outerHTML - .replace(/') - .replace(//g, '') - .replace(/<\/script>/g, ''); - expect((rebuildDom as Document).documentElement.outerHTML).to.equal( - htmlStr, - ); + it('[html file]: ' + html.filePath, done => { + const srcDom = new JSDOM(html.src, { runScripts: 'dangerously' }); + const destDom = new JSDOM(html.dest); + srcDom.window.document.addEventListener('DOMContentLoaded', () => { + const snap = snapshot(srcDom.window.document); + const rebuildDom = rebuild(snap); + const htmlStr = destDom.window.document.documentElement.outerHTML.replace( + /\n\n/g, + '', + ); + const rebuildStr = (rebuildDom as Document).documentElement.outerHTML.replace( + /\n\n/g, + '', + ); + try { + expect(rebuildStr).to.equal(htmlStr); + done(); + } catch (error) { + done(error); + } + }); }); } @@ -65,68 +85,4 @@ describe('integration tests', () => { id: 1, }); }); - - it('will not throw error with invalid attribute', () => { - const raw = ``; - const dom = new JSDOM(raw); - expect(() => rebuild(snapshot(dom.window.document))).not.to.throw(); - }); - - it('will inline text input value', () => { - const raw = ''; - const dom = new JSDOM(raw); - dom.window.document.querySelector('input').value = '1'; - const rebuildDom = rebuild(snapshot(dom.window.document)); - expect((rebuildDom as Document).querySelector('input').outerHTML).to.equal( - '', - ); - }); - - it('will inline radio input value', () => { - const raw = ''; - const dom = new JSDOM(raw); - dom.window.document.querySelector('input').checked = true; - const rebuildDom = rebuild(snapshot(dom.window.document)); - expect((rebuildDom as Document).querySelector('input').outerHTML).to.equal( - '', - ); - }); - - it('will inline checkbox input value', () => { - const raw = ''; - const dom = new JSDOM(raw); - dom.window.document.querySelector('input').checked = true; - const rebuildDom = rebuild(snapshot(dom.window.document)); - expect((rebuildDom as Document).querySelector('input').outerHTML).to.equal( - '', - ); - }); - - it('will inline textarea value into text node', () => { - const raw = ''; - const dom = new JSDOM(raw); - dom.window.document.querySelector('textarea').value = '1234'; - const rebuildDom = rebuild(snapshot(dom.window.document)); - expect( - (rebuildDom as Document).querySelector('textarea').outerHTML, - ).to.equal(''); - }); - - it('will inline options state', () => { - const raw = ` - - `; - const dom = new JSDOM(raw); - dom.window.document.querySelector('select').value = '2'; - const rebuildDom = rebuild(snapshot(dom.window.document)); - expect((rebuildDom as Document).querySelector('select').outerHTML).to.equal( - ``, - ); - }); }); From 7fadc986ecdac1175c6baec54fce9d89bbd4f882 Mon Sep 17 00:00:00 2001 From: Yanzhen Yu Date: Wed, 1 Apr 2026 12:00:00 +0800 Subject: [PATCH 007/345] refactor the test infra: use puppeteer instead of jsdom to get rid of some hack implementations --- index.d.ts | 6 +- package.json | 11 ++- rollup.config.js | 10 +++ test/html/with-script.html | 4 +- test/html/with-style-sheet.html | 16 ++++ test/integration.ts | 147 ++++++++++++++++++-------------- test/js/a.js | 1 + test/server.ts | 0 tsconfig.json | 2 +- 9 files changed, 125 insertions(+), 72 deletions(-) create mode 100644 rollup.config.js create mode 100644 test/html/with-style-sheet.html create mode 100644 test/js/a.js create mode 100644 test/server.ts diff --git a/index.d.ts b/index.d.ts index f7f2dbfa..cbe3deb6 100644 --- a/index.d.ts +++ b/index.d.ts @@ -1,4 +1,4 @@ -declare module 'mocha-jsdom' { - function mochaDom(options: any): void; - export = mochaDom; +declare module 'rollup-plugin-typescript' { + function typescript(): any; + export = typescript; } diff --git a/package.json b/package.json index 2c62a4c9..5944a8df 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,8 @@ "description": "rrweb's component to take a snapshot of DOM, aka DOM serializer", "main": "index.js", "scripts": { - "test": "TS_NODE_FILES=true mocha -r ts-node/register test/**/*.ts" + "test": "TS_NODE_FILES=true mocha -r ts-node/register test/**/*.ts", + "compile": "rollup --config" }, "repository": { "type": "git", @@ -23,14 +24,16 @@ "homepage": "https://github.com/rrweb-io/rrweb-snapshot#readme", "devDependencies": { "@types/chai": "^4.1.4", - "@types/jsdom": "^11.12.0", "@types/mocha": "^5.2.5", "@types/node": "^10.11.3", + "@types/puppeteer": "^1.8.0", "chai": "^4.1.2", - "jsdom": "^12.1.0", "mocha": "^5.2.0", - "mocha-jsdom": "^2.0.0", + "puppeteer": "^1.9.0", + "rollup": "^0.66.4", + "rollup-plugin-typescript": "^1.0.0", "ts-node": "^7.0.1", + "tslib": "^1.9.3", "tslint": "^4.5.1", "typescript": "^3.0.3" } diff --git a/rollup.config.js b/rollup.config.js new file mode 100644 index 00000000..d5080466 --- /dev/null +++ b/rollup.config.js @@ -0,0 +1,10 @@ +import typescript from 'rollup-plugin-typescript'; + +export default { + input: './src/index.ts', + plugins: [typescript()], + output: { + name: 'rrweb', + format: 'iife', + }, +}; diff --git a/test/html/with-script.html b/test/html/with-script.html index 3dd8ca93..5ef140a7 100644 --- a/test/html/with-script.html +++ b/test/html/with-script.html @@ -9,7 +9,7 @@ - + @@ -30,7 +30,7 @@ - + diff --git a/test/html/with-style-sheet.html b/test/html/with-style-sheet.html new file mode 100644 index 00000000..43e0cb01 --- /dev/null +++ b/test/html/with-style-sheet.html @@ -0,0 +1,16 @@ + + + + + + + + with style sheet + + + + + + + + \ No newline at end of file diff --git a/test/integration.ts b/test/integration.ts index 4f9917c8..cdd4139f 100644 --- a/test/integration.ts +++ b/test/integration.ts @@ -1,10 +1,12 @@ -import 'mocha'; -import mochaDom = require('mocha-jsdom'); -import { expect } from 'chai'; import * as fs from 'fs'; import * as path from 'path'; -import { JSDOM } from 'jsdom'; -import { snapshot, rebuild } from '../src'; +import * as http from 'http'; +import * as url from 'url'; +import 'mocha'; +import * as puppeteer from 'puppeteer'; +import * as rollup from 'rollup'; +import typescript = require('rollup-plugin-typescript'); +import { expect } from 'chai'; const htmlFolder = path.join(__dirname, 'html'); const htmls = fs.readdirSync(htmlFolder).map(filePath => { @@ -24,65 +26,86 @@ const htmls = fs.readdirSync(htmlFolder).map(filePath => { }; }); -describe('integration tests', () => { - mochaDom({ url: 'http://localhost' }); +interface IMimeType { + [key: string]: string; +} - for (const html of htmls) { - it('[html file]: ' + html.filePath, done => { - const srcDom = new JSDOM(html.src, { runScripts: 'dangerously' }); - const destDom = new JSDOM(html.dest); - srcDom.window.document.addEventListener('DOMContentLoaded', () => { - const snap = snapshot(srcDom.window.document); - const rebuildDom = rebuild(snap); - const htmlStr = destDom.window.document.documentElement.outerHTML.replace( - /\n\n/g, - '', - ); - const rebuildStr = (rebuildDom as Document).documentElement.outerHTML.replace( - /\n\n/g, - '', - ); - try { - expect(rebuildStr).to.equal(htmlStr); - done(); - } catch (error) { - done(error); - } - }); +const server = () => + new Promise(resolve => { + const mimeType: IMimeType = { + '.html': 'text/html', + '.js': 'text/javascript', + '.css': 'text/css', + }; + const s = http.createServer((req, res) => { + const parsedUrl = url.parse(req.url); + const sanitizePath = path + .normalize(parsedUrl.pathname) + .replace(/^(\.\.[\/\\])+/, ''); + let pathname = path.join(__dirname, sanitizePath); + try { + const data = fs.readFileSync(pathname); + const ext = path.parse(pathname).ext; + res.setHeader('Content-type', mimeType[ext] || 'text/plain'); + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Methods', 'GET'); + res.setHeader('Access-Control-Allow-Headers', 'Content-type'); + res.end(data); + } catch (error) { + res.end(); + } }); - } - - it('will snapshot document type', () => { - const raw = ''; - const dom = new JSDOM(raw); - const snap = snapshot(dom.window.document); - expect(snap).to.deep.equal({ - type: 0, - childNodes: [ - { - type: 2, - tagName: 'html', - attributes: {}, - childNodes: [ - { - type: 2, - tagName: 'head', - attributes: {}, - childNodes: [], - id: 3, - }, - { - type: 2, - tagName: 'body', - attributes: {}, - childNodes: [], - id: 4, - }, - ], - id: 2, - }, - ], - id: 1, + s.listen(3030).on('listening', () => { + resolve(s); }); }); + +describe('integration tests', () => { + before(async () => { + this.server = await server(); + this.browser = await puppeteer.launch({ + headless: false, + executablePath: '/home/yanzhen/Desktop/chrome-linux/chrome', + }); + + const bundle = await rollup.rollup({ + input: path.resolve(__dirname, '../src/index.ts'), + plugins: [typescript()], + }); + const { code } = await bundle.generate({ + name: 'rrweb', + format: 'iife', + }); + this.code = code; + }); + + after(() => { + this.browser.close(); + this.server.close(); + }); + + for (const html of htmls) { + it('[html file]: ' + html.filePath, async () => { + const page: puppeteer.Page = await this.browser.newPage(); + await page.goto(`http://localhost:3030/html/${html.filePath}`); + await page.setContent(html.src); + page.once('load', async () => { + await page.evaluate(() => { + const x = new XMLSerializer(); + return x.serializeToString(document); + }); + const rebuildHtml = (await page.evaluate(`${this.code} + const x = new XMLSerializer(); + const snap = rrweb.snapshot(document); + x.serializeToString(rrweb.rebuild(snap)); + `)).replace(/\n\n/g, ''); + await page.goto(`data:text/html,${html.dest}`); + const destHtml = (await page.evaluate(() => { + const x = new XMLSerializer(); + return x.serializeToString(document); + })).replace(/\n\n/g, ''); + expect(rebuildHtml).to.equal(destHtml); + }); + }).timeout(5000); + } }); diff --git a/test/js/a.js b/test/js/a.js new file mode 100644 index 00000000..7a776f91 --- /dev/null +++ b/test/js/a.js @@ -0,0 +1 @@ +var a = 1 + 1; diff --git a/test/server.ts b/test/server.ts new file mode 100644 index 00000000..e69de29b diff --git a/tsconfig.json b/tsconfig.json index 06b323aa..6378ca69 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -11,5 +11,5 @@ }, "compileOnSave": true, "exclude": ["test"], - "include": ["index.d.ts"] + "include": ["src", "index.d.ts"] } From 51737d9b53ad4ef7191c9585722acfbfe7d6079b Mon Sep 17 00:00:00 2001 From: Yanzhen Yu Date: Wed, 1 Apr 2026 12:00:00 +0800 Subject: [PATCH 008/345] try to inline linked stylesheet when in same origin --- src/rebuild.ts | 20 +++++++++++--- src/snapshot.ts | 38 ++++++++++++++++++++++----- src/types.ts | 4 +++ test/html/cors-style-sheet.html | 16 ++++++++++++ test/html/with-style-sheet.html | 19 ++++++++++++++ test/integration.ts | 46 +++++++++++++++++---------------- 6 files changed, 110 insertions(+), 33 deletions(-) create mode 100644 test/html/cors-style-sheet.html diff --git a/src/rebuild.ts b/src/rebuild.ts index f6be61ca..bcf1f681 100644 --- a/src/rebuild.ts +++ b/src/rebuild.ts @@ -1,4 +1,15 @@ -import { serializedNodeWithId, NodeType } from './types'; +import { serializedNodeWithId, NodeType, tagMap, elementNode } from './types'; + +const tagMap: tagMap = { + script: 'noscript', +}; +function getTagName(n: elementNode): string { + let tagName = tagMap[n.tagName] ? tagMap[n.tagName] : n.tagName; + if (tagName === 'link' && n.attributes._cssText) { + tagName = 'style'; + } + return tagName; +} function buildNode(n: serializedNodeWithId): Node | null { switch (n.type) { @@ -11,14 +22,15 @@ function buildNode(n: serializedNodeWithId): Node | null { n.systemId, ); case NodeType.Element: - const tagName = n.tagName === 'script' ? 'noscript' : n.tagName; + const tagName = getTagName(n); const node = document.createElement(tagName); for (const name in n.attributes) { if (n.attributes.hasOwnProperty(name)) { let value = n.attributes[name]; value = typeof value === 'boolean' ? '' : value; - // textarea hack - if (n.tagName === 'textarea' && name === 'value') { + const isTextarea = tagName === 'textarea' && name === 'value'; + const isRemoteCss = tagName === 'style' && name === '_cssText'; + if (isTextarea || isRemoteCss) { const child = document.createTextNode(value); node.appendChild(child); continue; diff --git a/src/snapshot.ts b/src/snapshot.ts index c6f4eb60..c5af9f58 100644 --- a/src/snapshot.ts +++ b/src/snapshot.ts @@ -15,7 +15,18 @@ function resetId() { _id = 1; } -function serializeNode(n: Node): serializedNode | false { +function getCssRulesString(s: CSSStyleSheet): string | null { + try { + const rules = s.rules || s.cssRules; + return rules + ? Array.from(rules).reduce((prev, cur) => (prev += cur.cssText), '') + : null; + } catch (error) { + return null; + } +} + +function serializeNode(n: Node, doc: Document): serializedNode | false { switch (n.nodeType) { case n.DOCUMENT_NODE: return { @@ -31,10 +42,23 @@ function serializeNode(n: Node): serializedNode | false { }; case n.ELEMENT_NODE: const tagName = (n as HTMLElement).tagName.toLowerCase(); - const attributes: attributes = {}; + let attributes: attributes = {}; for (const { name, value } of Array.from((n as HTMLElement).attributes)) { attributes[name] = value; } + // remote css + if (tagName === 'link' && attributes.hasOwnProperty('href')) { + const stylesheet = Array.from(doc.styleSheets).find( + s => s.href === attributes.href, + ); + const cssText = getCssRulesString(stylesheet as CSSStyleSheet); + if (cssText) { + attributes = { + _cssText: cssText, + }; + } + } + // form fields if ( tagName === 'input' || tagName === 'textarea' || @@ -91,8 +115,8 @@ function serializeNode(n: Node): serializedNode | false { } } -function _snapshot(n: Node): serializedNodeWithId | null { - const _serializedNode = serializeNode(n); +function _snapshot(n: Node, doc: Document): serializedNodeWithId | null { + const _serializedNode = serializeNode(n, doc); if (!_serializedNode) { // TODO: dev only console.warn(n, 'not serialized'); @@ -106,15 +130,15 @@ function _snapshot(n: Node): serializedNodeWithId | null { serializedNode.type === NodeType.Element ) { for (const childN of Array.from(n.childNodes)) { - serializedNode.childNodes.push(_snapshot(childN)); + serializedNode.childNodes.push(_snapshot(childN, doc)); } } return serializedNode; } -function snapshot(n: Node): serializedNodeWithId | null { +function snapshot(n: Document): serializedNodeWithId | null { resetId(); - return _snapshot(n); + return _snapshot(n, n); } export default snapshot; diff --git a/src/types.ts b/src/types.ts index 17fc0e42..ea19a198 100644 --- a/src/types.ts +++ b/src/types.ts @@ -53,3 +53,7 @@ export type serializedNode = | commentNode; export type serializedNodeWithId = serializedNode & { id: number }; + +export type tagMap = { + [key: string]: string; +}; diff --git a/test/html/cors-style-sheet.html b/test/html/cors-style-sheet.html new file mode 100644 index 00000000..06dddaa2 --- /dev/null +++ b/test/html/cors-style-sheet.html @@ -0,0 +1,16 @@ + + + + + + + + with style sheet + + + + + + + + \ No newline at end of file diff --git a/test/html/with-style-sheet.html b/test/html/with-style-sheet.html index 43e0cb01..14f09d38 100644 --- a/test/html/with-style-sheet.html +++ b/test/html/with-style-sheet.html @@ -13,4 +13,23 @@ + + + + + + + + + + + + with style sheet + + + + + + + \ No newline at end of file diff --git a/test/integration.ts b/test/integration.ts index cdd4139f..40f9cc03 100644 --- a/test/integration.ts +++ b/test/integration.ts @@ -64,7 +64,7 @@ describe('integration tests', () => { before(async () => { this.server = await server(); this.browser = await puppeteer.launch({ - headless: false, + // headless: false, executablePath: '/home/yanzhen/Desktop/chrome-linux/chrome', }); @@ -79,33 +79,35 @@ describe('integration tests', () => { this.code = code; }); - after(() => { - this.browser.close(); - this.server.close(); + after(async () => { + await this.browser.close(); + await this.server.close(); }); - for (const html of htmls) { + for (const html of htmls.slice(0, 10)) { it('[html file]: ' + html.filePath, async () => { const page: puppeteer.Page = await this.browser.newPage(); - await page.goto(`http://localhost:3030/html/${html.filePath}`); + // 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); - page.once('load', async () => { - await page.evaluate(() => { - const x = new XMLSerializer(); - return x.serializeToString(document); - }); - const rebuildHtml = (await page.evaluate(`${this.code} - const x = new XMLSerializer(); - const snap = rrweb.snapshot(document); - x.serializeToString(rrweb.rebuild(snap)); - `)).replace(/\n\n/g, ''); - await page.goto(`data:text/html,${html.dest}`); - const destHtml = (await page.evaluate(() => { - const x = new XMLSerializer(); - return x.serializeToString(document); - })).replace(/\n\n/g, ''); - expect(rebuildHtml).to.equal(destHtml); + await page.evaluate(() => { + const x = new XMLSerializer(); + return x.serializeToString(document); }); + const rebuildHtml = (await page.evaluate(`${this.code} + const x = new XMLSerializer(); + const snap = rrweb.snapshot(document); + x.serializeToString(rrweb.rebuild(snap)); + `)).replace(/\n\n/g, ''); + await page.goto(`http://localhost:3030/html`); + await page.setContent(html.dest); + const destHtml = (await page.evaluate(() => { + const x = new XMLSerializer(); + return x.serializeToString(document); + })).replace(/\n\n/g, ''); + expect(rebuildHtml).to.equal(destHtml); }).timeout(5000); } }); From 9e3e5909357d24d2082d7503d1098bd5d1090e59 Mon Sep 17 00:00:00 2001 From: Yanzhen Yu Date: Wed, 1 Apr 2026 12:00:00 +0800 Subject: [PATCH 009/345] add iframe tests and update urls in test file --- src/snapshot.ts | 8 ++++---- test/html/iframe-inner.html | 1 + test/html/iframe.html | 12 ++++++++++++ test/html/with-script.html | 4 ++-- test/html/with-style-sheet.html | 2 +- 5 files changed, 20 insertions(+), 7 deletions(-) create mode 100644 test/html/iframe-inner.html create mode 100644 test/html/iframe.html diff --git a/src/snapshot.ts b/src/snapshot.ts index c5af9f58..0501f499 100644 --- a/src/snapshot.ts +++ b/src/snapshot.ts @@ -47,10 +47,10 @@ function serializeNode(n: Node, doc: Document): serializedNode | false { attributes[name] = value; } // remote css - if (tagName === 'link' && attributes.hasOwnProperty('href')) { - const stylesheet = Array.from(doc.styleSheets).find( - s => s.href === attributes.href, - ); + if (tagName === 'link') { + const stylesheet = Array.from(doc.styleSheets).find(s => { + return s.href === (n as HTMLLinkElement).href; + }); const cssText = getCssRulesString(stylesheet as CSSStyleSheet); if (cssText) { attributes = { diff --git a/test/html/iframe-inner.html b/test/html/iframe-inner.html new file mode 100644 index 00000000..2ef778d9 --- /dev/null +++ b/test/html/iframe-inner.html @@ -0,0 +1 @@ + diff --git a/test/html/iframe.html b/test/html/iframe.html new file mode 100644 index 00000000..8b45139e --- /dev/null +++ b/test/html/iframe.html @@ -0,0 +1,12 @@ + + + + + + + iframe + + + + + diff --git a/test/html/with-script.html b/test/html/with-script.html index 5ef140a7..d7271338 100644 --- a/test/html/with-script.html +++ b/test/html/with-script.html @@ -9,7 +9,7 @@ - + @@ -30,7 +30,7 @@ - + diff --git a/test/html/with-style-sheet.html b/test/html/with-style-sheet.html index 14f09d38..9fc5bdad 100644 --- a/test/html/with-style-sheet.html +++ b/test/html/with-style-sheet.html @@ -6,7 +6,7 @@ with style sheet - + From 546743004b6fb3445eebe4aa42e76cf190a4c340 Mon Sep 17 00:00:00 2001 From: Yanzhen Yu Date: Wed, 1 Apr 2026 12:00:00 +0800 Subject: [PATCH 010/345] update declaration file --- index.d.ts | 8 ++++---- package.json | 2 +- test.d.ts | 4 ++++ test/server.ts | 0 tsconfig.json | 2 +- 5 files changed, 10 insertions(+), 6 deletions(-) create mode 100644 test.d.ts delete mode 100644 test/server.ts diff --git a/index.d.ts b/index.d.ts index cbe3deb6..e1d96cb4 100644 --- a/index.d.ts +++ b/index.d.ts @@ -1,4 +1,4 @@ -declare module 'rollup-plugin-typescript' { - function typescript(): any; - export = typescript; -} +import { serializedNodeWithId } from './src/types'; + +export function snapshot(n: Document): serializedNodeWithId | null; +export function rebuild(n: serializedNodeWithId): Node | null; diff --git a/package.json b/package.json index 5944a8df..a4d87dcf 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "description": "rrweb's component to take a snapshot of DOM, aka DOM serializer", "main": "index.js", "scripts": { - "test": "TS_NODE_FILES=true mocha -r ts-node/register test/**/*.ts", + "test": "TS_NODE_CACHE=false TS_NODE_FILES=true mocha -r ts-node/register test/**/*.ts", "compile": "rollup --config" }, "repository": { diff --git a/test.d.ts b/test.d.ts new file mode 100644 index 00000000..cbe3deb6 --- /dev/null +++ b/test.d.ts @@ -0,0 +1,4 @@ +declare module 'rollup-plugin-typescript' { + function typescript(): any; + export = typescript; +} diff --git a/test/server.ts b/test/server.ts deleted file mode 100644 index e69de29b..00000000 diff --git a/tsconfig.json b/tsconfig.json index 6378ca69..f82e02cd 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -11,5 +11,5 @@ }, "compileOnSave": true, "exclude": ["test"], - "include": ["src", "index.d.ts"] + "include": ["src", "test.d.ts"] } From c496e3edea096752ac826422b45cc7bc0e9fa77f Mon Sep 17 00:00:00 2001 From: Yanzhen Yu Date: Wed, 1 Apr 2026 12:00:00 +0800 Subject: [PATCH 011/345] bump 0.2.0 --- README.md | 8 +++++++- package.json | 2 +- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 98c66522..d61b9f71 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,9 @@ # rrweb-snapshot -Not ready yet +Snapshot the DOM into a stateful and serializable data structure. +Also provide the ability to rebuild the DOM via snapshot. + +## TODO + +- [ ] Replace any url in css rules into absolute path. +- [ ] Select a suitable build strategy. diff --git a/package.json b/package.json index a4d87dcf..73c0b112 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "rrweb-snapshot", - "version": "0.1.0", + "version": "0.2.0", "description": "rrweb's component to take a snapshot of DOM, aka DOM serializer", "main": "index.js", "scripts": { From ac5293f162ac369e8818a23ac2839c6183cf96a4 Mon Sep 17 00:00:00 2001 From: Yanzhen Yu Date: Wed, 1 Apr 2026 12:00:00 +0800 Subject: [PATCH 012/345] add strict null check and fix codes --- index.d.ts | 1 + src/index.ts | 1 + src/rebuild.ts | 6 +++++- src/snapshot.ts | 9 ++++++--- tsconfig.json | 1 + 5 files changed, 14 insertions(+), 4 deletions(-) diff --git a/index.d.ts b/index.d.ts index e1d96cb4..bb15b0be 100644 --- a/index.d.ts +++ b/index.d.ts @@ -1,4 +1,5 @@ import { serializedNodeWithId } from './src/types'; +export * from './src/types'; export function snapshot(n: Document): serializedNodeWithId | null; export function rebuild(n: serializedNodeWithId): Node | null; diff --git a/src/index.ts b/src/index.ts index 758f2df9..6b8e1450 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,5 @@ import snapshot from './snapshot'; import rebuild from './rebuild'; +export * from './types'; export { snapshot, rebuild }; diff --git a/src/rebuild.ts b/src/rebuild.ts index bcf1f681..fce1fb36 100644 --- a/src/rebuild.ts +++ b/src/rebuild.ts @@ -62,7 +62,11 @@ function rebuild(n: serializedNodeWithId): Node | null { if (n.type === NodeType.Document || n.type === NodeType.Element) { for (const childN of n.childNodes) { const childNode = rebuild(childN); - root.appendChild(childNode); + if (!childNode) { + console.warn('Failed to rebuild', childN); + } else { + root.appendChild(childNode); + } } } return root; diff --git a/src/snapshot.ts b/src/snapshot.ts index 0501f499..913210a4 100644 --- a/src/snapshot.ts +++ b/src/snapshot.ts @@ -98,7 +98,7 @@ function serializeNode(n: Node, doc: Document): serializedNode | false { } return { type: NodeType.Text, - textContent, + textContent: textContent || '', }; case n.CDATA_SECTION_NODE: return { @@ -108,7 +108,7 @@ function serializeNode(n: Node, doc: Document): serializedNode | false { case n.COMMENT_NODE: return { type: NodeType.Comment, - textContent: (n as Comment).textContent, + textContent: (n as Comment).textContent || '', }; default: return false; @@ -130,7 +130,10 @@ function _snapshot(n: Node, doc: Document): serializedNodeWithId | null { serializedNode.type === NodeType.Element ) { for (const childN of Array.from(n.childNodes)) { - serializedNode.childNodes.push(_snapshot(childN, doc)); + const serializedChildNode = _snapshot(childN, doc); + if (serializedChildNode) { + serializedNode.childNodes.push(serializedChildNode); + } } } return serializedNode; diff --git a/tsconfig.json b/tsconfig.json index f82e02cd..d58ee546 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,6 +2,7 @@ "compilerOptions": { "module": "commonjs", "noImplicitAny": true, + "strictNullChecks": true, "removeComments": true, "preserveConstEnums": true, "sourceMap": true, From cfc8798b53c4293324e9692a40a2bdd7811465f9 Mon Sep 17 00:00:00 2001 From: Yanzhen Yu Date: Wed, 1 Apr 2026 12:00:00 +0800 Subject: [PATCH 013/345] return id node map when snapshot --- index.d.ts | 4 ++-- src/snapshot.ts | 29 ++++++++++++++++++++++++----- src/types.ts | 8 ++++++++ test/integration.ts | 6 +++--- 4 files changed, 37 insertions(+), 10 deletions(-) diff --git a/index.d.ts b/index.d.ts index bb15b0be..fa9eae71 100644 --- a/index.d.ts +++ b/index.d.ts @@ -1,5 +1,5 @@ -import { serializedNodeWithId } from './src/types'; +import { serializedNodeWithId, idNodeMap } from './src/types'; export * from './src/types'; -export function snapshot(n: Document): serializedNodeWithId | null; +export function snapshot(n: Document): [serializedNodeWithId | null, idNodeMap]; export function rebuild(n: serializedNodeWithId): Node | null; diff --git a/src/snapshot.ts b/src/snapshot.ts index 913210a4..15642b7f 100644 --- a/src/snapshot.ts +++ b/src/snapshot.ts @@ -3,6 +3,8 @@ import { serializedNodeWithId, NodeType, attributes, + INode, + idNodeMap, } from './types'; let _id = 1; @@ -115,22 +117,38 @@ function serializeNode(n: Node, doc: Document): serializedNode | false { } } -function _snapshot(n: Node, doc: Document): serializedNodeWithId | null { +function serializeNodeWithId( + n: Node, + doc: Document, +): serializedNodeWithId | null { const _serializedNode = serializeNode(n, doc); if (!_serializedNode) { // TODO: dev only console.warn(n, 'not serialized'); return null; } - const serializedNode: serializedNodeWithId = Object.assign(_serializedNode, { + return Object.assign(_serializedNode, { id: genId(), }); +} + +function _snapshot( + n: Node, + doc: Document, + map: idNodeMap, +): serializedNodeWithId | null { + const serializedNode = serializeNodeWithId(n, doc); + if (!serializedNode) { + return null; + } + (n as INode).__sn = serializedNode; + map[serializedNode.id] = n as INode; if ( serializedNode.type === NodeType.Document || serializedNode.type === NodeType.Element ) { for (const childN of Array.from(n.childNodes)) { - const serializedChildNode = _snapshot(childN, doc); + const serializedChildNode = _snapshot(childN, doc, map); if (serializedChildNode) { serializedNode.childNodes.push(serializedChildNode); } @@ -139,9 +157,10 @@ function _snapshot(n: Node, doc: Document): serializedNodeWithId | null { return serializedNode; } -function snapshot(n: Document): serializedNodeWithId | null { +function snapshot(n: Document): [serializedNodeWithId | null, idNodeMap] { resetId(); - return _snapshot(n, n); + const idNodeMap: idNodeMap = {}; + return [_snapshot(n, n, idNodeMap), idNodeMap]; } export default snapshot; diff --git a/src/types.ts b/src/types.ts index ea19a198..6fbe58e2 100644 --- a/src/types.ts +++ b/src/types.ts @@ -57,3 +57,11 @@ export type serializedNodeWithId = serializedNode & { id: number }; export type tagMap = { [key: string]: string; }; + +export interface INode extends Node { + __sn: serializedNodeWithId; +} + +export type idNodeMap = { + [key: number]: INode; +}; diff --git a/test/integration.ts b/test/integration.ts index 40f9cc03..a6ed7da5 100644 --- a/test/integration.ts +++ b/test/integration.ts @@ -38,9 +38,9 @@ const server = () => '.css': 'text/css', }; const s = http.createServer((req, res) => { - const parsedUrl = url.parse(req.url); + const parsedUrl = url.parse(req.url!); const sanitizePath = path - .normalize(parsedUrl.pathname) + .normalize(parsedUrl.pathname!) .replace(/^(\.\.[\/\\])+/, ''); let pathname = path.join(__dirname, sanitizePath); try { @@ -98,7 +98,7 @@ describe('integration tests', () => { }); const rebuildHtml = (await page.evaluate(`${this.code} const x = new XMLSerializer(); - const snap = rrweb.snapshot(document); + const [snap] = rrweb.snapshot(document); x.serializeToString(rrweb.rebuild(snap)); `)).replace(/\n\n/g, ''); await page.goto(`http://localhost:3030/html`); From 9cf2c4c7edb9e92305f1fe184af53007674639cc Mon Sep 17 00:00:00 2001 From: Yanzhen Yu Date: Wed, 1 Apr 2026 12:00:00 +0800 Subject: [PATCH 014/345] export serializeNodeWithId so rrweb could serialize newly added nodes --- index.d.ts | 5 +++++ src/index.ts | 4 ++-- src/snapshot.ts | 12 +++++++----- 3 files changed, 14 insertions(+), 7 deletions(-) diff --git a/index.d.ts b/index.d.ts index fa9eae71..85dc9653 100644 --- a/index.d.ts +++ b/index.d.ts @@ -3,3 +3,8 @@ export * from './src/types'; export function snapshot(n: Document): [serializedNodeWithId | null, idNodeMap]; export function rebuild(n: serializedNodeWithId): Node | null; +export function serializeNodeWithId( + n: Node, + doc: Document, + map: idNodeMap, +): serializedNodeWithId | null; diff --git a/src/index.ts b/src/index.ts index 6b8e1450..fd969465 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,5 @@ -import snapshot from './snapshot'; +import snapshot, { serializeNodeWithId } from './snapshot'; import rebuild from './rebuild'; export * from './types'; -export { snapshot, rebuild }; +export { snapshot, serializeNodeWithId, rebuild }; diff --git a/src/snapshot.ts b/src/snapshot.ts index 15642b7f..04eddcf3 100644 --- a/src/snapshot.ts +++ b/src/snapshot.ts @@ -117,9 +117,10 @@ function serializeNode(n: Node, doc: Document): serializedNode | false { } } -function serializeNodeWithId( +export function serializeNodeWithId( n: Node, doc: Document, + map: idNodeMap, ): serializedNodeWithId | null { const _serializedNode = serializeNode(n, doc); if (!_serializedNode) { @@ -127,9 +128,12 @@ function serializeNodeWithId( console.warn(n, 'not serialized'); return null; } - return Object.assign(_serializedNode, { + const serializedNode = Object.assign(_serializedNode, { id: genId(), }); + (n as INode).__sn = serializedNode; + map[serializedNode.id] = n as INode; + return serializedNode; } function _snapshot( @@ -137,12 +141,10 @@ function _snapshot( doc: Document, map: idNodeMap, ): serializedNodeWithId | null { - const serializedNode = serializeNodeWithId(n, doc); + const serializedNode = serializeNodeWithId(n, doc, map); if (!serializedNode) { return null; } - (n as INode).__sn = serializedNode; - map[serializedNode.id] = n as INode; if ( serializedNode.type === NodeType.Document || serializedNode.type === NodeType.Element From b170c3de59f067095b6b316eb8a62889a28fd646 Mon Sep 17 00:00:00 2001 From: Yanzhen Yu Date: Wed, 1 Apr 2026 12:00:00 +0800 Subject: [PATCH 015/345] update the bundle config --- .gitignore | 1 + package.json | 10 +++++++--- rollup.config.js | 19 +++++++++++++++---- 3 files changed, 23 insertions(+), 7 deletions(-) diff --git a/.gitignore b/.gitignore index 6ccc9d84..f35f48b8 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ node_modules package-lock.json build +dist diff --git a/package.json b/package.json index 73c0b112..0c399ca9 100644 --- a/package.json +++ b/package.json @@ -1,11 +1,12 @@ { "name": "rrweb-snapshot", - "version": "0.2.0", + "version": "0.3.0", "description": "rrweb's component to take a snapshot of DOM, aka DOM serializer", - "main": "index.js", + "main": "dist/index.js", + "module": "dist/module.js", "scripts": { "test": "TS_NODE_CACHE=false TS_NODE_FILES=true mocha -r ts-node/register test/**/*.ts", - "compile": "rollup --config" + "bundle": "rollup --config" }, "repository": { "type": "git", @@ -16,6 +17,9 @@ "snapshot", "DOM" ], + "files": [ + "dist" + ], "author": "yanzhen@smartx.com", "license": "MIT", "bugs": { diff --git a/rollup.config.js b/rollup.config.js index d5080466..dc2cd261 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -3,8 +3,19 @@ import typescript from 'rollup-plugin-typescript'; export default { input: './src/index.ts', plugins: [typescript()], - output: { - name: 'rrweb', - format: 'iife', - }, + output: [ + { + format: 'cjs', + file: './dist/index.js', + }, + { + format: 'esm', + file: './dist/module.js', + }, + { + name: 'rrwebSnapshot', + format: 'iife', + file: './dist/browser.js', + }, + ], }; From 0434129b00f14d7a1b9154f5c452905b83dc5f7f Mon Sep 17 00:00:00 2001 From: Yanzhen Yu Date: Wed, 1 Apr 2026 12:00:00 +0800 Subject: [PATCH 016/345] return id node map when rebuild --- index.d.ts | 2 +- src/rebuild.ts | 20 +++++++++++++++++--- test/css/style.css | 3 +++ test/html/with-style-sheet.html | 2 +- test/integration.ts | 2 +- 5 files changed, 23 insertions(+), 6 deletions(-) diff --git a/index.d.ts b/index.d.ts index 85dc9653..0bc43523 100644 --- a/index.d.ts +++ b/index.d.ts @@ -2,7 +2,7 @@ import { serializedNodeWithId, idNodeMap } from './src/types'; export * from './src/types'; export function snapshot(n: Document): [serializedNodeWithId | null, idNodeMap]; -export function rebuild(n: serializedNodeWithId): Node | null; +export function rebuild(n: serializedNodeWithId): [Node | null, idNodeMap]; export function serializeNodeWithId( n: Node, doc: Document, diff --git a/src/rebuild.ts b/src/rebuild.ts index fce1fb36..13029262 100644 --- a/src/rebuild.ts +++ b/src/rebuild.ts @@ -1,4 +1,11 @@ -import { serializedNodeWithId, NodeType, tagMap, elementNode } from './types'; +import { + serializedNodeWithId, + NodeType, + tagMap, + elementNode, + idNodeMap, + INode, +} from './types'; const tagMap: tagMap = { script: 'noscript', @@ -54,14 +61,16 @@ function buildNode(n: serializedNodeWithId): Node | null { } } -function rebuild(n: serializedNodeWithId): Node | null { +function _rebuild(n: serializedNodeWithId, map: idNodeMap): Node | null { const root = buildNode(n); if (!root) { return null; } + (root as INode).__sn = n; + map[n.id] = root as INode; if (n.type === NodeType.Document || n.type === NodeType.Element) { for (const childN of n.childNodes) { - const childNode = rebuild(childN); + const childNode = _rebuild(childN, map); if (!childNode) { console.warn('Failed to rebuild', childN); } else { @@ -72,4 +81,9 @@ function rebuild(n: serializedNodeWithId): Node | null { return root; } +function rebuild(n: serializedNodeWithId): [Node | null, idNodeMap] { + const idNodeMap: idNodeMap = {}; + return [_rebuild(n, idNodeMap), idNodeMap]; +} + export default rebuild; diff --git a/test/css/style.css b/test/css/style.css index 70b063e6..f1be9954 100644 --- a/test/css/style.css +++ b/test/css/style.css @@ -4,3 +4,6 @@ body { p { color: red; } +body > p { + color: yellow; +} diff --git a/test/html/with-style-sheet.html b/test/html/with-style-sheet.html index 9fc5bdad..9ba3c5e4 100644 --- a/test/html/with-style-sheet.html +++ b/test/html/with-style-sheet.html @@ -25,7 +25,7 @@ with style sheet - + diff --git a/test/integration.ts b/test/integration.ts index a6ed7da5..13647cc8 100644 --- a/test/integration.ts +++ b/test/integration.ts @@ -99,7 +99,7 @@ describe('integration tests', () => { const rebuildHtml = (await page.evaluate(`${this.code} const x = new XMLSerializer(); const [snap] = rrweb.snapshot(document); - x.serializeToString(rrweb.rebuild(snap)); + x.serializeToString(rrweb.rebuild(snap)[0]); `)).replace(/\n\n/g, ''); await page.goto(`http://localhost:3030/html`); await page.setContent(html.dest); From cb3efd427f6823ea7cab51328ceec75a1d1685fa Mon Sep 17 00:00:00 2001 From: Yanzhen Yu Date: Wed, 1 Apr 2026 12:00:00 +0800 Subject: [PATCH 017/345] add data attribute to element when rebuild --- index.d.ts | 2 +- src/rebuild.ts | 23 ++++++----------------- 2 files changed, 7 insertions(+), 18 deletions(-) diff --git a/index.d.ts b/index.d.ts index 0bc43523..85dc9653 100644 --- a/index.d.ts +++ b/index.d.ts @@ -2,7 +2,7 @@ import { serializedNodeWithId, idNodeMap } from './src/types'; export * from './src/types'; export function snapshot(n: Document): [serializedNodeWithId | null, idNodeMap]; -export function rebuild(n: serializedNodeWithId): [Node | null, idNodeMap]; +export function rebuild(n: serializedNodeWithId): Node | null; export function serializeNodeWithId( n: Node, doc: Document, diff --git a/src/rebuild.ts b/src/rebuild.ts index 13029262..223e3be8 100644 --- a/src/rebuild.ts +++ b/src/rebuild.ts @@ -1,11 +1,4 @@ -import { - serializedNodeWithId, - NodeType, - tagMap, - elementNode, - idNodeMap, - INode, -} from './types'; +import { serializedNodeWithId, NodeType, tagMap, elementNode } from './types'; const tagMap: tagMap = { script: 'noscript', @@ -61,16 +54,17 @@ function buildNode(n: serializedNodeWithId): Node | null { } } -function _rebuild(n: serializedNodeWithId, map: idNodeMap): Node | null { +function rebuild(n: serializedNodeWithId): Node | null { const root = buildNode(n); if (!root) { return null; } - (root as INode).__sn = n; - map[n.id] = root as INode; + if (n.type === NodeType.Element) { + (root as HTMLElement).setAttribute('data-rrid', String(n.id)); + } if (n.type === NodeType.Document || n.type === NodeType.Element) { for (const childN of n.childNodes) { - const childNode = _rebuild(childN, map); + const childNode = rebuild(childN); if (!childNode) { console.warn('Failed to rebuild', childN); } else { @@ -81,9 +75,4 @@ function _rebuild(n: serializedNodeWithId, map: idNodeMap): Node | null { return root; } -function rebuild(n: serializedNodeWithId): [Node | null, idNodeMap] { - const idNodeMap: idNodeMap = {}; - return [_rebuild(n, idNodeMap), idNodeMap]; -} - export default rebuild; From 87ff591cd1ba3a1c855facb0f9eab98996e101d6 Mon Sep 17 00:00:00 2001 From: Yanzhen Yu Date: Wed, 1 Apr 2026 12:00:00 +0800 Subject: [PATCH 018/345] use jest-snapshot to apply the snapshot testing --- package.json | 1 + test.d.ts | 17 ++++ test/__snapshots__/integration.ts.snap | 133 +++++++++++++++++++++++++ test/html/form-fields.html | 38 ------- test/html/invalid-attribute.html | 6 -- test/html/with-script.html | 19 ---- test/html/with-style-sheet.html | 19 ---- test/integration.ts | 44 ++++---- 8 files changed, 172 insertions(+), 105 deletions(-) create mode 100644 test/__snapshots__/integration.ts.snap diff --git a/package.json b/package.json index 0c399ca9..119a44c0 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "@types/node": "^10.11.3", "@types/puppeteer": "^1.8.0", "chai": "^4.1.2", + "jest-snapshot": "^23.6.0", "mocha": "^5.2.0", "puppeteer": "^1.9.0", "rollup": "^0.66.4", diff --git a/test.d.ts b/test.d.ts index cbe3deb6..a3b614ee 100644 --- a/test.d.ts +++ b/test.d.ts @@ -2,3 +2,20 @@ declare module 'rollup-plugin-typescript' { function typescript(): any; export = typescript; } + +declare module 'jest-snapshot' { + export class SnapshotState { + constructor(testFile: string, options: any); + + save(): any; + } + type matchResult = { + pass: boolean; + report(): string; + }; + export function toMatchSnapshot( + received: any, + propertyMatchers?: any, + testName?: string, + ): matchResult; +} diff --git a/test/__snapshots__/integration.ts.snap b/test/__snapshots__/integration.ts.snap new file mode 100644 index 00000000..6aba1b5e --- /dev/null +++ b/test/__snapshots__/integration.ts.snap @@ -0,0 +1,133 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`[html file]: about-mozilla.html 1`] = ` +" + The Book of Mozilla, 11:9 + +

+ Mammon slept. And the beast reborn spread over the earth and its numbers + grew legion. And they proclaimed the times and sacrificed crops unto the + fire, with the cunning of foxes. And they built a new world in their own + image as promised by the + sacred words, and spoke + of the beast with their children. Mammon awoke, and lo! it was + naught but a follower. +

+ from The Book of Mozilla, 11:9
(10th Edition) +

" +`; + +exports[`[html file]: basic.html 1`] = ` +" + + + + Document +" +`; + +exports[`[html file]: cors-style-sheet.html 1`] = ` +" + + + + with style sheet + +" +`; + +exports[`[html file]: form-fields.html 1`] = ` +" + + + + form fields + +
+ + + + + +
+" +`; + +exports[`[html file]: iframe.html 1`] = ` +" + + + + iframe + + + +" +`; + +exports[`[html file]: iframe-inner.html 1`] = ` +" +" +`; + +exports[`[html file]: invalid-attribute.html 1`] = ` +" +" +`; + +exports[`[html file]: with-script.html 1`] = ` +" + + + + with script + + + " +`; + +exports[`[html file]: with-style-sheet.html 1`] = ` +" + + + + with style sheet + + +" +`; diff --git a/test/html/form-fields.html b/test/html/form-fields.html index 6eef3544..50778d60 100644 --- a/test/html/form-fields.html +++ b/test/html/form-fields.html @@ -39,41 +39,3 @@ - - - - - - - - - - - form fields - - - -
- - - - - -
- - - - \ No newline at end of file diff --git a/test/html/invalid-attribute.html b/test/html/invalid-attribute.html index 6fa84ad4..e2428e28 100644 --- a/test/html/invalid-attribute.html +++ b/test/html/invalid-attribute.html @@ -1,9 +1,3 @@ - - - - - - \ No newline at end of file diff --git a/test/html/with-script.html b/test/html/with-script.html index d7271338..b4812e96 100644 --- a/test/html/with-script.html +++ b/test/html/with-script.html @@ -16,22 +16,3 @@ - - - - - - - - - - - with script - - - - - - - - \ No newline at end of file diff --git a/test/html/with-style-sheet.html b/test/html/with-style-sheet.html index 9ba3c5e4..2083dae9 100644 --- a/test/html/with-style-sheet.html +++ b/test/html/with-style-sheet.html @@ -14,22 +14,3 @@ - - - - - - - - - - - with style sheet - - - - - - - - \ No newline at end of file diff --git a/test/integration.ts b/test/integration.ts index 13647cc8..861c6580 100644 --- a/test/integration.ts +++ b/test/integration.ts @@ -6,23 +6,15 @@ import 'mocha'; import * as puppeteer from 'puppeteer'; import * as rollup from 'rollup'; import typescript = require('rollup-plugin-typescript'); -import { expect } from 'chai'; +import { assert } from 'chai'; +import { SnapshotState, toMatchSnapshot } from 'jest-snapshot'; const htmlFolder = path.join(__dirname, 'html'); const htmls = fs.readdirSync(htmlFolder).map(filePath => { const raw = fs.readFileSync(path.resolve(htmlFolder, filePath), 'utf-8'); - if (//.test(raw)) { - const [src, dest] = raw.split(''); - return { - filePath, - src, - dest, - }; - } return { filePath, src: raw, - dest: raw, }; }); @@ -60,6 +52,20 @@ const server = () => }); }); +function matchSnapshot(actual: string, testFile: string, testTitle: string) { + const snapshotState = new SnapshotState(testFile, { + updateSnapshot: process.env.SNAPSHOT_UPDATE ? 'all' : 'new', + }); + + const matcher = toMatchSnapshot.bind({ + snapshotState, + currentTestName: testTitle, + }); + const result = matcher(actual); + snapshotState.save(); + return result; +} + describe('integration tests', () => { before(async () => { this.server = await server(); @@ -85,29 +91,21 @@ describe('integration tests', () => { }); for (const html of htmls.slice(0, 10)) { - it('[html file]: ' + html.filePath, async () => { + 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); - await page.evaluate(() => { - const x = new XMLSerializer(); - return x.serializeToString(document); - }); const rebuildHtml = (await page.evaluate(`${this.code} const x = new XMLSerializer(); const [snap] = rrweb.snapshot(document); - x.serializeToString(rrweb.rebuild(snap)[0]); + x.serializeToString(rrweb.rebuild(snap)); `)).replace(/\n\n/g, ''); - await page.goto(`http://localhost:3030/html`); - await page.setContent(html.dest); - const destHtml = (await page.evaluate(() => { - const x = new XMLSerializer(); - return x.serializeToString(document); - })).replace(/\n\n/g, ''); - expect(rebuildHtml).to.equal(destHtml); + const result = matchSnapshot(rebuildHtml, __filename, title); + assert(result.pass, result.pass ? '' : result.report()); }).timeout(5000); } }); From e7753e1c24ab62b2ddbee1fe6d7e5fe3e8012d87 Mon Sep 17 00:00:00 2001 From: Yanzhen Yu Date: Wed, 1 Apr 2026 12:00:00 +0800 Subject: [PATCH 019/345] change relative path into absolute path --- README.md | 5 ---- src/snapshot.ts | 38 ++++++++++++++++++++++++-- test/__snapshots__/integration.ts.snap | 18 ++++++++++-- test/css/style.css | 2 ++ test/html/with-relative-res.html | 13 +++++++++ 5 files changed, 66 insertions(+), 10 deletions(-) create mode 100644 test/html/with-relative-res.html diff --git a/README.md b/README.md index d61b9f71..854d85b9 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,3 @@ Snapshot the DOM into a stateful and serializable data structure. Also provide the ability to rebuild the DOM via snapshot. - -## TODO - -- [ ] Replace any url in css rules into absolute path. -- [ ] Select a suitable build strategy. diff --git a/src/snapshot.ts b/src/snapshot.ts index 04eddcf3..e4527e92 100644 --- a/src/snapshot.ts +++ b/src/snapshot.ts @@ -28,6 +28,35 @@ function getCssRulesString(s: CSSStyleSheet): string | null { } } +const URL_IN_CSS_REF = /url\((['"])([^'"]*)\1\)/gm; +function absoluteToStylesheet(cssText: string, href: string): string { + return cssText.replace(URL_IN_CSS_REF, (_1, _2, filePath) => { + const stack = href.split('/'); + const parts = filePath.split('/'); + stack.pop(); + for (const part of parts) { + if (part === '.') { + continue; + } else if (part === '..') { + stack.pop(); + } else { + stack.push(part); + } + } + return `url('${stack.join('/')}')`; + }); +} + +const RELATIVE_PATH = /^(\.\.|\.|)\//; +function absoluteToDoc(doc: Document, attributeValue: string): string { + if (!RELATIVE_PATH.test(attributeValue)) { + return attributeValue; + } + const a: HTMLAnchorElement = document.createElement('a'); + a.href = attributeValue; + return a.href; +} + function serializeNode(n: Node, doc: Document): serializedNode | false { switch (n.nodeType) { case n.DOCUMENT_NODE: @@ -46,7 +75,12 @@ function serializeNode(n: Node, doc: Document): serializedNode | false { const tagName = (n as HTMLElement).tagName.toLowerCase(); let attributes: attributes = {}; for (const { name, value } of Array.from((n as HTMLElement).attributes)) { - attributes[name] = value; + // relative path in attribute + if (name === 'src' || name === 'href') { + attributes[name] = absoluteToDoc(doc, value); + } else { + attributes[name] = value; + } } // remote css if (tagName === 'link') { @@ -56,7 +90,7 @@ function serializeNode(n: Node, doc: Document): serializedNode | false { const cssText = getCssRulesString(stylesheet as CSSStyleSheet); if (cssText) { attributes = { - _cssText: cssText, + _cssText: absoluteToStylesheet(cssText, stylesheet!.href!), }; } } diff --git a/test/__snapshots__/integration.ts.snap b/test/__snapshots__/integration.ts.snap index 6aba1b5e..45ae353e 100644 --- a/test/__snapshots__/integration.ts.snap +++ b/test/__snapshots__/integration.ts.snap @@ -96,7 +96,7 @@ exports[`[html file]: iframe.html 1`] = ` iframe - + " `; @@ -110,6 +110,18 @@ exports[`[html file]: invalid-attribute.html 1`] = ` " `; +exports[`[html file]: with-relative-res.html 1`] = ` +" + + + + Document + + + + \\"\\"" +`; + exports[`[html file]: with-script.html 1`] = ` " @@ -117,7 +129,7 @@ exports[`[html file]: with-script.html 1`] = ` with script - + " `; @@ -127,7 +139,7 @@ exports[`[html file]: with-style-sheet.html 1`] = ` with style sheet - + " `; diff --git a/test/css/style.css b/test/css/style.css index f1be9954..fe10f55d 100644 --- a/test/css/style.css +++ b/test/css/style.css @@ -1,8 +1,10 @@ body { margin: 0; + background: url('../a.jpg'); } p { color: red; + background: url('./b.jpg'); } body > p { color: yellow; diff --git a/test/html/with-relative-res.html b/test/html/with-relative-res.html new file mode 100644 index 00000000..9e75f035 --- /dev/null +++ b/test/html/with-relative-res.html @@ -0,0 +1,13 @@ + + + + + + + Document + + + + + + \ No newline at end of file From 99fa24f71185312d144e9cfd4fc2b4cdbee51e82 Mon Sep 17 00:00:00 2001 From: Yanzhen Yu Date: Wed, 1 Apr 2026 12:00:00 +0800 Subject: [PATCH 020/345] bump 0.3.1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 119a44c0..da1d93dc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "rrweb-snapshot", - "version": "0.3.0", + "version": "0.3.1", "description": "rrweb's component to take a snapshot of DOM, aka DOM serializer", "main": "dist/index.js", "module": "dist/module.js", From 875385552d4bf6e7db02666f710ab0981525f829 Mon Sep 17 00:00:00 2001 From: Yanzhen Yu Date: Wed, 1 Apr 2026 12:00:00 +0800 Subject: [PATCH 021/345] impl the extra child data attribute to align id map --- src/rebuild.ts | 9 +++++++++ src/snapshot.ts | 4 ++-- test/__snapshots__/integration.ts.snap | 8 ++++---- 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/src/rebuild.ts b/src/rebuild.ts index 223e3be8..4e61283e 100644 --- a/src/rebuild.ts +++ b/src/rebuild.ts @@ -24,6 +24,7 @@ function buildNode(n: serializedNodeWithId): Node | null { case NodeType.Element: const tagName = getTagName(n); const node = document.createElement(tagName); + const extraChildIndexes: number[] = []; for (const name in n.attributes) { if (n.attributes.hasOwnProperty(name)) { let value = n.attributes[name]; @@ -32,6 +33,8 @@ function buildNode(n: serializedNodeWithId): Node | null { const isRemoteCss = tagName === 'style' && name === '_cssText'; if (isTextarea || isRemoteCss) { const child = document.createTextNode(value); + // identify the extra child DOM we added when rebuild + extraChildIndexes.push(node.childNodes.length); node.appendChild(child); continue; } @@ -42,6 +45,12 @@ function buildNode(n: serializedNodeWithId): Node | null { } } } + if (extraChildIndexes.length) { + node.setAttribute( + 'data-extra-child-index', + JSON.stringify(extraChildIndexes), + ); + } return node; case NodeType.Text: return document.createTextNode(n.textContent); diff --git a/src/snapshot.ts b/src/snapshot.ts index e4527e92..fa3aea8c 100644 --- a/src/snapshot.ts +++ b/src/snapshot.ts @@ -52,7 +52,7 @@ function absoluteToDoc(doc: Document, attributeValue: string): string { if (!RELATIVE_PATH.test(attributeValue)) { return attributeValue; } - const a: HTMLAnchorElement = document.createElement('a'); + const a: HTMLAnchorElement = doc.createElement('a'); a.href = attributeValue; return a.href; } @@ -130,7 +130,7 @@ function serializeNode(n: Node, doc: Document): serializedNode | false { n.parentNode && (n.parentNode as HTMLElement).tagName; let textContent = (n as Text).textContent; if (parentTagName === 'SCRIPT') { - textContent = ''; + textContent = 'SCRIPT_PLACEHOLDER'; } return { type: NodeType.Text, diff --git a/test/__snapshots__/integration.ts.snap b/test/__snapshots__/integration.ts.snap index 45ae353e..87f1446e 100644 --- a/test/__snapshots__/integration.ts.snap +++ b/test/__snapshots__/integration.ts.snap @@ -76,7 +76,7 @@ exports[`[html file]: form-fields.html 1`] = ` - + " `; @@ -130,7 +130,7 @@ exports[`[html file]: with-script.html 1`] = ` with script - " + " `; exports[`[html file]: with-style-sheet.html 1`] = ` @@ -139,7 +139,7 @@ exports[`[html file]: with-style-sheet.html 1`] = ` with style sheet - + " `; From f3b456270b9df6c236a0e949cf67ae9ad335339d Mon Sep 17 00:00:00 2001 From: Yanzhen Yu Date: Wed, 1 Apr 2026 12:00:00 +0800 Subject: [PATCH 022/345] update README and add travis --- .travis.yml | 14 ++++++++++++++ README.md | 32 ++++++++++++++++++++++++++++++++ test/integration.ts | 1 - 3 files changed, 46 insertions(+), 1 deletion(-) create mode 100644 .travis.yml diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 00000000..55653dd1 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,14 @@ +language: node_js + +node_js: + - 10 + +cache: + directories: + - 'node_modules' + +install: + - npm install + +script: + - npm test diff --git a/README.md b/README.md index 854d85b9..1dcd2ada 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,36 @@ # rrweb-snapshot +[![Build Status](https://travis-ci.org/rrweb-io/rrweb-snapshot.svg?branch=master)](https://travis-ci.org/rrweb-io/rrweb-snapshot) + Snapshot the DOM into a stateful and serializable data structure. Also provide the ability to rebuild the DOM via snapshot. + +## API + +This module export 3 methods: + +### snapshot + +`snapshot` will traverse the DOM and return a stateful and serializable data structure which can represent the current DOM **view**. + +There are serveral things will be done during snapshot: + +1. Inline some DOM states into HTML attributes, e.g, HTMLInputElement's value. +2. Turn script tags into noscript tags to avoid scripts being executed. +3. Try to inline stylesheets to make sure local stylesheets can be used. +4. Make relative paths in href, src, css to be absolute paths. +5. Give a id to each Node, and return the id node map when snapshot finished. + +#### rebuild + +`rebuild` will build the DOM according to the taken snapshot. + +There are serveral things will be done during rebuild: + +1. Add data-rrid attribute if Node is an Element. +2. Create some extra DOM node like text node to place inline css and some states. +3. Add data-extra-child-index attribute if Node has some extra child DOM. + +#### serializeNodeWithId + +`serializeNodeWithId` can serialize a node into snapshot format with id. diff --git a/test/integration.ts b/test/integration.ts index 861c6580..700469f2 100644 --- a/test/integration.ts +++ b/test/integration.ts @@ -71,7 +71,6 @@ describe('integration tests', () => { this.server = await server(); this.browser = await puppeteer.launch({ // headless: false, - executablePath: '/home/yanzhen/Desktop/chrome-linux/chrome', }); const bundle = await rollup.rollup({ From fe174248e1d1da7d7641a0f59e2cec07ea3428f7 Mon Sep 17 00:00:00 2001 From: Yanzhen Yu Date: Wed, 1 Apr 2026 12:00:00 +0800 Subject: [PATCH 023/345] bump 0.4.0 --- .travis.yml | 4 ---- package.json | 2 +- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/.travis.yml b/.travis.yml index 55653dd1..26cef21f 100644 --- a/.travis.yml +++ b/.travis.yml @@ -3,10 +3,6 @@ language: node_js node_js: - 10 -cache: - directories: - - 'node_modules' - install: - npm install diff --git a/package.json b/package.json index da1d93dc..57db3613 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "rrweb-snapshot", - "version": "0.3.1", + "version": "0.4.0", "description": "rrweb's component to take a snapshot of DOM, aka DOM serializer", "main": "dist/index.js", "module": "dist/module.js", From 14dac7c0cc155c70e6adf08bcf835cc191c21b03 Mon Sep 17 00:00:00 2001 From: Yanzhen Yu Date: Wed, 1 Apr 2026 12:00:00 +0800 Subject: [PATCH 024/345] fix npm package files and bump version --- package.json | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 57db3613..6f32f4e4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "rrweb-snapshot", - "version": "0.4.0", + "version": "0.4.2", "description": "rrweb's component to take a snapshot of DOM, aka DOM serializer", "main": "dist/index.js", "module": "dist/module.js", @@ -18,7 +18,9 @@ "DOM" ], "files": [ - "dist" + "dist", + "index.d.ts", + "src/types.ts" ], "author": "yanzhen@smartx.com", "license": "MIT", From bbf23157c52e2f177d8580383c31287ccc2bd955 Mon Sep 17 00:00:00 2001 From: Yanzhen Yu Date: Wed, 1 Apr 2026 12:00:00 +0800 Subject: [PATCH 025/345] export reset id function --- index.d.ts | 1 + package.json | 2 +- src/index.ts | 4 ++-- src/snapshot.ts | 2 +- 4 files changed, 5 insertions(+), 4 deletions(-) diff --git a/index.d.ts b/index.d.ts index 85dc9653..bfd03d96 100644 --- a/index.d.ts +++ b/index.d.ts @@ -8,3 +8,4 @@ export function serializeNodeWithId( doc: Document, map: idNodeMap, ): serializedNodeWithId | null; +export function resetId(): void; diff --git a/package.json b/package.json index 6f32f4e4..6f56531c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "rrweb-snapshot", - "version": "0.4.2", + "version": "0.4.3", "description": "rrweb's component to take a snapshot of DOM, aka DOM serializer", "main": "dist/index.js", "module": "dist/module.js", diff --git a/src/index.ts b/src/index.ts index fd969465..cba7b499 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,5 @@ -import snapshot, { serializeNodeWithId } from './snapshot'; +import snapshot, { serializeNodeWithId, resetId } from './snapshot'; import rebuild from './rebuild'; export * from './types'; -export { snapshot, serializeNodeWithId, rebuild }; +export { snapshot, serializeNodeWithId, resetId, rebuild }; diff --git a/src/snapshot.ts b/src/snapshot.ts index fa3aea8c..c948fa14 100644 --- a/src/snapshot.ts +++ b/src/snapshot.ts @@ -13,7 +13,7 @@ function genId(): number { return _id++; } -function resetId() { +export function resetId() { _id = 1; } From 349d78e02b01dd0e720eb5cd65ca21b8dbb18881 Mon Sep 17 00:00:00 2001 From: Yanzhen Yu Date: Wed, 1 Apr 2026 12:00:00 +0800 Subject: [PATCH 026/345] fix style content url normalizer and add some tests --- package.json | 2 +- src/snapshot.ts | 22 +++++++++++++++++++++- test/snapshot.test.ts | 31 +++++++++++++++++++++++++++++++ 3 files changed, 53 insertions(+), 2 deletions(-) create mode 100644 test/snapshot.test.ts diff --git a/package.json b/package.json index 6f56531c..04fde37f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "rrweb-snapshot", - "version": "0.4.3", + "version": "0.4.4", "description": "rrweb's component to take a snapshot of DOM, aka DOM serializer", "main": "dist/index.js", "module": "dist/module.js", diff --git a/src/snapshot.ts b/src/snapshot.ts index c948fa14..5674e86d 100644 --- a/src/snapshot.ts +++ b/src/snapshot.ts @@ -28,9 +28,29 @@ function getCssRulesString(s: CSSStyleSheet): string | null { } } +function extractOrigin(url: string): string { + let origin; + if (url.indexOf('//') > -1) { + origin = url + .split('/') + .slice(0, 3) + .join('/'); + } else { + origin = url.split('/')[0]; + } + origin = origin.split('?')[0]; + return origin; +} + const URL_IN_CSS_REF = /url\((['"])([^'"]*)\1\)/gm; -function absoluteToStylesheet(cssText: string, href: string): string { +export function absoluteToStylesheet(cssText: string, href: string): string { return cssText.replace(URL_IN_CSS_REF, (_1, _2, filePath) => { + if (!/^[./]/.test(filePath)) { + return `url('${filePath}')`; + } + if (filePath[0] === '/') { + return `url('${extractOrigin(href) + filePath}')`; + } const stack = href.split('/'); const parts = filePath.split('/'); stack.pop(); diff --git a/test/snapshot.test.ts b/test/snapshot.test.ts new file mode 100644 index 00000000..0720f120 --- /dev/null +++ b/test/snapshot.test.ts @@ -0,0 +1,31 @@ +import 'mocha'; +import { expect } from 'chai'; +import { absoluteToStylesheet } from '../src/snapshot'; + +describe('absolute url to stylesheet', () => { + const href = 'http://localhost/css/style.css'; + + it('can handle same level path', () => { + expect(absoluteToStylesheet('url("./a.jpg")', href)).to.equal( + `url('http://localhost/css/a.jpg')`, + ); + }); + + it('can handle parent level path', () => { + expect(absoluteToStylesheet('url("../a.jpg")', href)).to.equal( + `url('http://localhost/a.jpg')`, + ); + }); + + it('can handle absolute path', () => { + expect(absoluteToStylesheet('url("/a.jpg")', href)).to.equal( + `url('http://localhost/a.jpg')`, + ); + }); + + it('can handle external path', () => { + expect( + absoluteToStylesheet('url("http://localhost/a.jpg")', href), + ).to.equal(`url('http://localhost/a.jpg')`); + }); +}); From 19eca4da6da82b4455f0e0226fc0b8370abe2fc8 Mon Sep 17 00:00:00 2001 From: Yanzhen Yu Date: Wed, 1 Apr 2026 12:00:00 +0800 Subject: [PATCH 027/345] use document object from params instead of the one in the current scope --- index.d.ts | 2 +- src/rebuild.ts | 22 +++++++++++----------- test/integration.ts | 2 +- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/index.d.ts b/index.d.ts index bfd03d96..9f2eda37 100644 --- a/index.d.ts +++ b/index.d.ts @@ -2,7 +2,7 @@ import { serializedNodeWithId, idNodeMap } from './src/types'; export * from './src/types'; export function snapshot(n: Document): [serializedNodeWithId | null, idNodeMap]; -export function rebuild(n: serializedNodeWithId): Node | null; +export function rebuild(n: serializedNodeWithId, doc: Document): Node | null; export function serializeNodeWithId( n: Node, doc: Document, diff --git a/src/rebuild.ts b/src/rebuild.ts index 4e61283e..c53cf72b 100644 --- a/src/rebuild.ts +++ b/src/rebuild.ts @@ -11,19 +11,19 @@ function getTagName(n: elementNode): string { return tagName; } -function buildNode(n: serializedNodeWithId): Node | null { +function buildNode(n: serializedNodeWithId, doc: Document): Node | null { switch (n.type) { case NodeType.Document: - return document.implementation.createDocument(null, '', null); + return doc.implementation.createDocument(null, '', null); case NodeType.DocumentType: - return document.implementation.createDocumentType( + return doc.implementation.createDocumentType( n.name, n.publicId, n.systemId, ); case NodeType.Element: const tagName = getTagName(n); - const node = document.createElement(tagName); + const node = doc.createElement(tagName); const extraChildIndexes: number[] = []; for (const name in n.attributes) { if (n.attributes.hasOwnProperty(name)) { @@ -32,7 +32,7 @@ function buildNode(n: serializedNodeWithId): Node | null { const isTextarea = tagName === 'textarea' && name === 'value'; const isRemoteCss = tagName === 'style' && name === '_cssText'; if (isTextarea || isRemoteCss) { - const child = document.createTextNode(value); + const child = doc.createTextNode(value); // identify the extra child DOM we added when rebuild extraChildIndexes.push(node.childNodes.length); node.appendChild(child); @@ -53,18 +53,18 @@ function buildNode(n: serializedNodeWithId): Node | null { } return node; case NodeType.Text: - return document.createTextNode(n.textContent); + return doc.createTextNode(n.textContent); case NodeType.CDATA: - return document.createCDATASection(n.textContent); + return doc.createCDATASection(n.textContent); case NodeType.Comment: - return document.createComment(n.textContent); + return doc.createComment(n.textContent); default: return null; } } -function rebuild(n: serializedNodeWithId): Node | null { - const root = buildNode(n); +function rebuild(n: serializedNodeWithId, doc: Document): Node | null { + const root = buildNode(n, doc); if (!root) { return null; } @@ -73,7 +73,7 @@ function rebuild(n: serializedNodeWithId): Node | null { } if (n.type === NodeType.Document || n.type === NodeType.Element) { for (const childN of n.childNodes) { - const childNode = rebuild(childN); + const childNode = rebuild(childN, doc); if (!childNode) { console.warn('Failed to rebuild', childN); } else { diff --git a/test/integration.ts b/test/integration.ts index 700469f2..d2a74aca 100644 --- a/test/integration.ts +++ b/test/integration.ts @@ -101,7 +101,7 @@ describe('integration tests', () => { const rebuildHtml = (await page.evaluate(`${this.code} const x = new XMLSerializer(); const [snap] = rrweb.snapshot(document); - x.serializeToString(rrweb.rebuild(snap)); + x.serializeToString(rrweb.rebuild(snap, document)); `)).replace(/\n\n/g, ''); const result = matchSnapshot(rebuildHtml, __filename, title); assert(result.pass, result.pass ? '' : result.report()); From 9a4c21c30fb828570b6ca66a03ec16fdfed02975 Mon Sep 17 00:00:00 2001 From: Yanzhen Yu Date: Wed, 1 Apr 2026 12:00:00 +0800 Subject: [PATCH 028/345] refactor rebuild implementation which mount DOM onto the target document object --- index.d.ts | 12 +- package.json | 2 +- src/index.ts | 4 +- src/rebuild.ts | 156 +++++++++++++++++++++---- src/snapshot.ts | 16 +-- test/__snapshots__/integration.ts.snap | 154 ++++++++++++------------ test/integration.ts | 3 +- 7 files changed, 230 insertions(+), 117 deletions(-) diff --git a/index.d.ts b/index.d.ts index 9f2eda37..21a07000 100644 --- a/index.d.ts +++ b/index.d.ts @@ -1,11 +1,19 @@ -import { serializedNodeWithId, idNodeMap } from './src/types'; +import { serializedNodeWithId, idNodeMap, INode } from './src/types'; export * from './src/types'; export function snapshot(n: Document): [serializedNodeWithId | null, idNodeMap]; -export function rebuild(n: serializedNodeWithId, doc: Document): Node | null; +export function rebuild( + n: serializedNodeWithId, + doc: Document, +): [Node | null, idNodeMap]; export function serializeNodeWithId( n: Node, doc: Document, map: idNodeMap, ): serializedNodeWithId | null; export function resetId(): void; +export function buildNodeWithSN( + n: serializedNodeWithId, + doc: Document, + map: idNodeMap, +): INode | null; diff --git a/package.json b/package.json index 04fde37f..e0eceea0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "rrweb-snapshot", - "version": "0.4.4", + "version": "0.5.1", "description": "rrweb's component to take a snapshot of DOM, aka DOM serializer", "main": "dist/index.js", "module": "dist/module.js", diff --git a/src/index.ts b/src/index.ts index cba7b499..f3238ea2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,5 @@ import snapshot, { serializeNodeWithId, resetId } from './snapshot'; -import rebuild from './rebuild'; +import rebuild, { buildNodeWithSN } from './rebuild'; export * from './types'; -export { snapshot, serializeNodeWithId, resetId, rebuild }; +export { snapshot, serializeNodeWithId, resetId, rebuild, buildNodeWithSN }; diff --git a/src/rebuild.ts b/src/rebuild.ts index c53cf72b..c995b6cc 100644 --- a/src/rebuild.ts +++ b/src/rebuild.ts @@ -1,4 +1,108 @@ -import { serializedNodeWithId, NodeType, tagMap, elementNode } from './types'; +import { + serializedNodeWithId, + NodeType, + tagMap, + elementNode, + idNodeMap, + INode, +} from './types'; + +// TODO: need a more accurate list +const svgTags = [ + 'altGlyph', + 'altGlyphDef', + 'altGlyphItem', + 'animate', + 'animateColor', + 'animateMotion', + 'animateTransform', + 'animation', + 'circle', + 'clipPath', + 'color-profile', + 'cursor', + 'defs', + 'desc', + 'discard', + 'ellipse', + 'feBlend', + 'feColorMatrix', + 'feComponentTransfer', + 'feComposite', + 'feConvolveMatrix', + 'feDiffuseLighting', + 'feDisplacementMap', + 'feDistantLight', + 'feDropShadow', + 'feFlood', + 'feFuncA', + 'feFuncB', + 'feFuncG', + 'feFuncR', + 'feGaussianBlur', + 'feImage', + 'feMerge', + 'feMergeNode', + 'feMorphology', + 'feOffset', + 'fePointLight', + 'feSpecularLighting', + 'feSpotLight', + 'feTile', + 'feTurbulence', + 'filter', + 'font', + 'font-face', + 'font-face-format', + 'font-face-name', + 'font-face-src', + 'font-face-uri', + 'foreignObject', + 'g', + 'glyph', + 'glyphRef', + 'handler', + 'hatch', + 'hatchpath', + 'hkern', + 'image', + 'line', + 'linearGradient', + 'listener', + 'marker', + 'mask', + 'mesh', + 'meshgradient', + 'meshpatch', + 'meshrow', + 'metadata', + 'missing-glyph', + 'mpath', + 'path', + 'pattern', + 'polygon', + 'polyline', + 'prefetch', + 'radialGradient', + 'rect', + 'set', + 'solidColor', + 'solidcolor', + 'stop', + 'svg', + 'switch', + 'symbol', + 'tbreak', + 'text', + 'textArea', + 'textPath', + 'tref', + 'tspan', + 'unknown', + 'use', + 'view', + 'vkern', +]; const tagMap: tagMap = { script: 'noscript', @@ -23,8 +127,12 @@ function buildNode(n: serializedNodeWithId, doc: Document): Node | null { ); case NodeType.Element: const tagName = getTagName(n); - const node = doc.createElement(tagName); - const extraChildIndexes: number[] = []; + let node: Element; + if (svgTags.indexOf(tagName) < 0) { + node = doc.createElement(tagName); + } else { + node = doc.createElementNS('http://www.w3.org/2000/svg', tagName); + } for (const name in n.attributes) { if (n.attributes.hasOwnProperty(name)) { let value = n.attributes[name]; @@ -33,10 +141,7 @@ function buildNode(n: serializedNodeWithId, doc: Document): Node | null { const isRemoteCss = tagName === 'style' && name === '_cssText'; if (isTextarea || isRemoteCss) { const child = doc.createTextNode(value); - // identify the extra child DOM we added when rebuild - extraChildIndexes.push(node.childNodes.length); node.appendChild(child); - continue; } try { node.setAttribute(name, value); @@ -45,12 +150,6 @@ function buildNode(n: serializedNodeWithId, doc: Document): Node | null { } } } - if (extraChildIndexes.length) { - node.setAttribute( - 'data-extra-child-index', - JSON.stringify(extraChildIndexes), - ); - } return node; case NodeType.Text: return doc.createTextNode(n.textContent); @@ -63,25 +162,42 @@ function buildNode(n: serializedNodeWithId, doc: Document): Node | null { } } -function rebuild(n: serializedNodeWithId, doc: Document): Node | null { - const root = buildNode(n, doc); - if (!root) { +export function buildNodeWithSN( + n: serializedNodeWithId, + doc: Document, + map: idNodeMap, +): INode | null { + let node = buildNode(n, doc); + if (!node) { return null; } - if (n.type === NodeType.Element) { - (root as HTMLElement).setAttribute('data-rrid', String(n.id)); + // use target document as root document + if (n.type === NodeType.Document) { + doc.open(); + node = doc; } + + (node as INode).__sn = n; + map[n.id] = node as INode; if (n.type === NodeType.Document || n.type === NodeType.Element) { for (const childN of n.childNodes) { - const childNode = rebuild(childN, doc); + const childNode = buildNodeWithSN(childN, doc, map); if (!childNode) { console.warn('Failed to rebuild', childN); } else { - root.appendChild(childNode); + node.appendChild(childNode); } } } - return root; + return node as INode; +} + +function rebuild( + n: serializedNodeWithId, + doc: Document, +): [Node | null, idNodeMap] { + const idNodeMap: idNodeMap = {}; + return [buildNodeWithSN(n, doc, idNodeMap), idNodeMap]; } export default rebuild; diff --git a/src/snapshot.ts b/src/snapshot.ts index 5674e86d..d141b35b 100644 --- a/src/snapshot.ts +++ b/src/snapshot.ts @@ -187,24 +187,12 @@ export function serializeNodeWithId( }); (n as INode).__sn = serializedNode; map[serializedNode.id] = n as INode; - return serializedNode; -} - -function _snapshot( - n: Node, - doc: Document, - map: idNodeMap, -): serializedNodeWithId | null { - const serializedNode = serializeNodeWithId(n, doc, map); - if (!serializedNode) { - return null; - } if ( serializedNode.type === NodeType.Document || serializedNode.type === NodeType.Element ) { for (const childN of Array.from(n.childNodes)) { - const serializedChildNode = _snapshot(childN, doc, map); + const serializedChildNode = serializeNodeWithId(childN, doc, map); if (serializedChildNode) { serializedNode.childNodes.push(serializedChildNode); } @@ -216,7 +204,7 @@ function _snapshot( function snapshot(n: Document): [serializedNodeWithId | null, idNodeMap] { resetId(); const idNodeMap: idNodeMap = {}; - return [_snapshot(n, n, idNodeMap), idNodeMap]; + return [serializeNodeWithId(n, n, idNodeMap), idNodeMap]; } export default snapshot; diff --git a/test/__snapshots__/integration.ts.snap b/test/__snapshots__/integration.ts.snap index 87f1446e..f2213003 100644 --- a/test/__snapshots__/integration.ts.snap +++ b/test/__snapshots__/integration.ts.snap @@ -1,9 +1,9 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`[html file]: about-mozilla.html 1`] = ` -" - The Book of Mozilla, 11:9 - -

- Mammon slept. And the beast reborn spread over the earth and its numbers - grew legion. And they proclaimed the times and sacrificed crops unto the - fire, with the cunning of foxes. And they built a new world in their own - image as promised by the - sacred words, and spoke +

+ Mammon slept. And the beast reborn spread over the earth and its numbers + grew legion. And they proclaimed the times and sacrificed crops unto the + fire, with the cunning of foxes. And they built a new world in their own + image as promised by the + sacred words, and spoke of the beast with their children. Mammon awoke, and lo! it was - naught but a follower. -

- from The Book of Mozilla, 11:9
(10th Edition) + naught but a follower. +

+ from The Book of Mozilla, 11:9
(10th Edition)

" `; exports[`[html file]: basic.html 1`] = ` -" - - - - Document -" +" + + + + Document +" `; exports[`[html file]: cors-style-sheet.html 1`] = ` -" - - - - with style sheet - -" +" + + + + with style sheet + +" `; exports[`[html file]: form-fields.html 1`] = ` -" - - - - form fields - -
-