Skip to content

Commit f40f0cf

Browse files
authored
PR fix: Add test for null attribute (#66)
This adds on top of #65 and adds a test for this, as well as streamlining this check in the `transformAttribute` method.
1 parent 3d533c1 commit f40f0cf

File tree

7 files changed

+249
-16
lines changed

7 files changed

+249
-16
lines changed

packages/rrweb-snapshot/src/rebuild.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,7 @@ function buildNode(
154154
continue;
155155
}
156156
value =
157-
typeof value === 'boolean' || typeof value === 'number' ? '' : value;
157+
typeof value === 'boolean' || typeof value === 'number' || value === null ? '' : value;
158158
// attribute names start with rr_ are internal attributes added by rrweb
159159
if (!name.startsWith('rr_')) {
160160
const isTextarea = tagName === 'textarea' && name === 'value';

packages/rrweb-snapshot/src/snapshot.ts

+15-12
Original file line numberDiff line numberDiff line change
@@ -238,36 +238,39 @@ export function transformAttribute(
238238
doc: Document,
239239
tagName: string,
240240
name: string,
241-
value: string,
241+
value: string | null,
242242
maskAllText: boolean,
243243
maskTextFn: MaskTextFn | undefined,
244-
): string {
244+
): string | null {
245+
if (!value) {
246+
return value;
247+
}
248+
245249
// relative path in attribute
246-
if (name === 'src' || (name === 'href' && value)) {
250+
if (name === 'src' || name === 'href') {
247251
return absoluteToDoc(doc, value);
248-
} else if (name === 'xlink:href' && value && value[0] !== '#') {
252+
} else if (name === 'xlink:href' && value[0] !== '#') {
249253
// xlink:href starts with # is an id pointer
250254
return absoluteToDoc(doc, value);
251255
} else if (
252256
name === 'background' &&
253-
value &&
254257
(tagName === 'table' || tagName === 'td' || tagName === 'th')
255258
) {
256259
return absoluteToDoc(doc, value);
257-
} else if (name === 'srcset' && value) {
260+
} else if (name === 'srcset') {
258261
return getAbsoluteSrcsetString(doc, value);
259-
} else if (name === 'style' && value) {
262+
} else if (name === 'style') {
260263
return absoluteToStylesheet(value, getHref());
261-
} else if (tagName === 'object' && name === 'data' && value) {
264+
} else if (tagName === 'object' && name === 'data') {
262265
return absoluteToDoc(doc, value);
263266
} else if (
264267
maskAllText &&
265268
['placeholder', 'title', 'aria-label'].indexOf(name) > -1
266269
) {
267270
return maskTextFn ? maskTextFn(value) : defaultMaskFn(value);
268-
} else {
269-
return value;
270271
}
272+
273+
return value;
271274
}
272275

273276
export function _isBlockedElement(
@@ -770,8 +773,8 @@ function serializeNode(
770773
}
771774
}
772775

773-
function lowerIfExists(maybeAttr: string | number | boolean): string {
774-
if (maybeAttr === undefined) {
776+
function lowerIfExists(maybeAttr: string | number | boolean | null | undefined): string {
777+
if (maybeAttr === undefined || maybeAttr === null) {
775778
return '';
776779
} else {
777780
return (maybeAttr as string).toLowerCase();

packages/rrweb-snapshot/src/types.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ export type documentTypeNode = {
2121
};
2222

2323
export type attributes = {
24-
[key: string]: string | number | boolean;
24+
[key: string]: string | number | boolean | null;
2525
};
2626
export type elementNode = {
2727
type: NodeType.Element;

packages/rrweb-snapshot/typings/snapshot.d.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { serializedNodeWithId, INode, idNodeMap, MaskInputOptions, SlimDOMOption
22
export declare const IGNORED_NODE = -2;
33
export declare function absoluteToStylesheet(cssText: string | null, href: string): string;
44
export declare function absoluteToDoc(doc: Document, attributeValue: string): string;
5-
export declare function transformAttribute(doc: Document, tagName: string, name: string, value: string, maskAllText: boolean, maskTextFn: MaskTextFn | undefined): string;
5+
export declare function transformAttribute(doc: Document, tagName: string, name: string, value: string | null, maskAllText: boolean, maskTextFn: MaskTextFn | undefined): string | null;
66
export declare function _isBlockedElement(element: HTMLElement, blockClass: string | RegExp, blockSelector: string | null, unblockSelector: string | null): boolean;
77
export declare function needMaskingText(node: Node | null, maskTextClass: string | RegExp, maskTextSelector: string | null, unmaskTextSelector: string | null, maskAllText: boolean): boolean;
88
export declare function serializeNodeWithId(n: Node | INode, options: {

packages/rrweb-snapshot/typings/types.d.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ export type documentTypeNode = {
1818
systemId: string;
1919
};
2020
export type attributes = {
21-
[key: string]: string | number | boolean;
21+
[key: string]: string | number | boolean | null;
2222
};
2323
export type elementNode = {
2424
type: NodeType.Element;

packages/rrweb/test/__snapshots__/integration.test.ts.snap

+199
Original file line numberDiff line numberDiff line change
@@ -3684,6 +3684,205 @@ exports[`record integration tests can use maskInputOptions to configure which ty
36843684
]"
36853685
`;
36863686

3687+
exports[`record integration tests handles null attribute values 1`] = `
3688+
"[
3689+
{
3690+
"type": 0,
3691+
"data": {}
3692+
},
3693+
{
3694+
"type": 1,
3695+
"data": {}
3696+
},
3697+
{
3698+
"type": 4,
3699+
"data": {
3700+
"href": "about:blank",
3701+
"width": 1920,
3702+
"height": 1080
3703+
}
3704+
},
3705+
{
3706+
"type": 2,
3707+
"data": {
3708+
"node": {
3709+
"type": 0,
3710+
"childNodes": [
3711+
{
3712+
"type": 1,
3713+
"name": "html",
3714+
"publicId": "",
3715+
"systemId": "",
3716+
"id": 2
3717+
},
3718+
{
3719+
"type": 2,
3720+
"tagName": "html",
3721+
"attributes": {},
3722+
"childNodes": [
3723+
{
3724+
"type": 2,
3725+
"tagName": "head",
3726+
"attributes": {},
3727+
"childNodes": [],
3728+
"id": 4
3729+
},
3730+
{
3731+
"type": 2,
3732+
"tagName": "body",
3733+
"attributes": {},
3734+
"childNodes": [
3735+
{
3736+
"type": 3,
3737+
"textContent": "\\n ",
3738+
"id": 6
3739+
},
3740+
{
3741+
"type": 2,
3742+
"tagName": "p",
3743+
"attributes": {},
3744+
"childNodes": [
3745+
{
3746+
"type": 3,
3747+
"textContent": "******** ********",
3748+
"id": 8
3749+
}
3750+
],
3751+
"id": 7
3752+
},
3753+
{
3754+
"type": 3,
3755+
"textContent": "\\n ",
3756+
"id": 9
3757+
},
3758+
{
3759+
"type": 2,
3760+
"tagName": "ul",
3761+
"attributes": {},
3762+
"childNodes": [
3763+
{
3764+
"type": 3,
3765+
"textContent": "\\n ",
3766+
"id": 11
3767+
},
3768+
{
3769+
"type": 2,
3770+
"tagName": "li",
3771+
"attributes": {},
3772+
"childNodes": [],
3773+
"id": 12
3774+
},
3775+
{
3776+
"type": 3,
3777+
"textContent": "\\n ",
3778+
"id": 13
3779+
}
3780+
],
3781+
"id": 10
3782+
},
3783+
{
3784+
"type": 3,
3785+
"textContent": "\\n ",
3786+
"id": 14
3787+
},
3788+
{
3789+
"type": 2,
3790+
"tagName": "canvas",
3791+
"attributes": {},
3792+
"childNodes": [],
3793+
"id": 15
3794+
},
3795+
{
3796+
"type": 3,
3797+
"textContent": "\\n\\n ",
3798+
"id": 16
3799+
},
3800+
{
3801+
"type": 2,
3802+
"tagName": "script",
3803+
"attributes": {},
3804+
"childNodes": [
3805+
{
3806+
"type": 3,
3807+
"textContent": "SCRIPT_PLACEHOLDER",
3808+
"id": 18
3809+
}
3810+
],
3811+
"id": 17
3812+
},
3813+
{
3814+
"type": 3,
3815+
"textContent": "\\n \\n \\n",
3816+
"id": 19
3817+
}
3818+
],
3819+
"id": 5
3820+
}
3821+
],
3822+
"id": 3
3823+
}
3824+
],
3825+
"id": 1
3826+
},
3827+
"initialOffset": {
3828+
"left": 0,
3829+
"top": 0
3830+
}
3831+
}
3832+
},
3833+
{
3834+
"type": 3,
3835+
"data": {
3836+
"source": 0,
3837+
"texts": [],
3838+
"attributes": [
3839+
{
3840+
"id": 20,
3841+
"attributes": {
3842+
"aria-label": "*****",
3843+
"id": "test-li"
3844+
}
3845+
}
3846+
],
3847+
"removes": [],
3848+
"adds": [
3849+
{
3850+
"parentId": 10,
3851+
"nextId": null,
3852+
"node": {
3853+
"type": 2,
3854+
"tagName": "li",
3855+
"attributes": {
3856+
"aria-label": "*****",
3857+
"id": "test-li"
3858+
},
3859+
"childNodes": [],
3860+
"id": 20
3861+
}
3862+
}
3863+
]
3864+
}
3865+
},
3866+
{
3867+
"type": 3,
3868+
"data": {
3869+
"source": 0,
3870+
"texts": [],
3871+
"attributes": [
3872+
{
3873+
"id": 20,
3874+
"attributes": {
3875+
"aria-label": null
3876+
}
3877+
}
3878+
],
3879+
"removes": [],
3880+
"adds": []
3881+
}
3882+
}
3883+
]"
3884+
`;
3885+
36873886
exports[`record integration tests should mask all text (except unmaskTextSelector), using maskAllText 1`] = `
36883887
"[
36893888
{

packages/rrweb/test/integration.test.ts

+31
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,37 @@ describe('record integration tests', function (this: ISuite) {
130130
assertSnapshot(snapshots);
131131
});
132132

133+
it('handles null attribute values', async () => {
134+
const page: puppeteer.Page = await browser.newPage();
135+
await page.goto('about:blank');
136+
await page.setContent(
137+
getHtml.call(this, 'mutation-observer.html', {
138+
maskAllInputs: true,
139+
maskAllText: true,
140+
}),
141+
);
142+
143+
await page.evaluate(() => {
144+
const li = document.createElement('li');
145+
const ul = document.querySelector('ul') as HTMLUListElement;
146+
ul.appendChild(li);
147+
148+
li.setAttribute('aria-label', 'label');
149+
li.setAttribute('id', 'test-li');
150+
});
151+
152+
await new Promise((resolve) => setTimeout(resolve, 100));
153+
154+
await page.evaluate(() => {
155+
const li = document.querySelector('#test-li') as HTMLLIElement;
156+
// This triggers the mutation observer with a `null` attribute value
157+
li.removeAttribute('aria-label');
158+
});
159+
160+
const snapshots = await page.evaluate('window.snapshots');
161+
assertSnapshot(snapshots);
162+
});
163+
133164
it('can record character data muatations', async () => {
134165
const page: puppeteer.Page = await browser.newPage();
135166
await page.goto('about:blank');

0 commit comments

Comments
 (0)