Capture stylesheets designated as rel="preload" (#1374)

* feat(Snapshot): Capture stylesheets designated as `rel="preload"`

* fix(Snapshot): Harden asset file extension matching

* Add changeset

* chore: Lint

* Tweak regex, add try-catch block on URL constructor
This commit is contained in:
Andrew Pomeroy
2026-04-01 12:00:00 +08:00
committed by GitHub
parent 8d555c1b1c
commit 8b90bd8f62
4 changed files with 85 additions and 3 deletions

View File

@@ -0,0 +1,6 @@
---
'rrweb-snapshot': patch
'rrweb': patch
---
Capture stylesheets designated as `rel="preload"`

View File

@@ -23,6 +23,7 @@ import {
stringifyStylesheet, stringifyStylesheet,
getInputType, getInputType,
toLowerCase, toLowerCase,
extractFileExtension,
} from './utils'; } from './utils';
let _id = 1; let _id = 1;
@@ -847,7 +848,7 @@ function slimDOMExcluded(
(sn.tagName === 'link' && (sn.tagName === 'link' &&
sn.attributes.rel === 'prefetch' && sn.attributes.rel === 'prefetch' &&
typeof sn.attributes.href === 'string' && typeof sn.attributes.href === 'string' &&
sn.attributes.href.endsWith('.js'))) extractFileExtension(sn.attributes.href) === 'js'))
) { ) {
return true; return true;
} else if ( } else if (
@@ -1177,7 +1178,11 @@ export function serializeNodeWithId(
if ( if (
serializedNode.type === NodeType.Element && serializedNode.type === NodeType.Element &&
serializedNode.tagName === 'link' && serializedNode.tagName === 'link' &&
serializedNode.attributes.rel === 'stylesheet' typeof serializedNode.attributes.rel === 'string' &&
(serializedNode.attributes.rel === 'stylesheet' ||
(serializedNode.attributes.rel === 'preload' &&
typeof serializedNode.attributes.href === 'string' &&
extractFileExtension(serializedNode.attributes.href) === 'css'))
) { ) {
onceStylesheetLoaded( onceStylesheetLoaded(
n as HTMLLinkElement, n as HTMLLinkElement,

View File

@@ -331,3 +331,23 @@ export function getInputType(element: HTMLElement): Lowercase<string> | null {
toLowerCase(type) toLowerCase(type)
: null; : null;
} }
/**
* Extracts the file extension from an a path, considering search parameters and fragments.
* @param path - Path to file
* @param baseURL - [optional] Base URL of the page, used to resolve relative paths. Defaults to current page URL.
*/
export function extractFileExtension(
path: string,
baseURL?: string,
): string | null {
let url;
try {
url = new URL(path, baseURL ?? window.location.href);
} catch (err) {
return null;
}
const regex = /\.([0-9a-z]+)(?:$)/i;
const match = url.pathname.match(regex);
return match?.[1] ?? null;
}

View File

@@ -2,7 +2,7 @@
* @jest-environment jsdom * @jest-environment jsdom
*/ */
import { NodeType, serializedNode } from '../src/types'; import { NodeType, serializedNode } from '../src/types';
import { isNodeMetaEqual } from '../src/utils'; import { extractFileExtension, isNodeMetaEqual } from '../src/utils';
import { serializedNodeWithId } from 'rrweb-snapshot'; import { serializedNodeWithId } from 'rrweb-snapshot';
describe('utils', () => { describe('utils', () => {
@@ -147,4 +147,55 @@ describe('utils', () => {
expect(isNodeMetaEqual(element2, element3)).toBeFalsy(); expect(isNodeMetaEqual(element2, element3)).toBeFalsy();
}); });
}); });
describe('extractFileExtension', () => {
test('absolute path', () => {
const path = 'https://example.com/styles/main.css';
const extension = extractFileExtension(path);
expect(extension).toBe('css');
});
test('relative path', () => {
const path = 'styles/main.css';
const baseURL = 'https://example.com/';
const extension = extractFileExtension(path, baseURL);
expect(extension).toBe('css');
});
test('path with search parameters', () => {
const path = 'https://example.com/scripts/app.js?version=1.0';
const extension = extractFileExtension(path);
expect(extension).toBe('js');
});
test('path with fragment', () => {
const path = 'https://example.com/styles/main.css#section1';
const extension = extractFileExtension(path);
expect(extension).toBe('css');
});
test('path with search parameters and fragment', () => {
const path = 'https://example.com/scripts/app.js?version=1.0#section1';
const extension = extractFileExtension(path);
expect(extension).toBe('js');
});
test('path without extension', () => {
const path = 'https://example.com/path/to/directory/';
const extension = extractFileExtension(path);
expect(extension).toBeNull();
});
test('invalid URL', () => {
const path = '!@#$%^&*()';
const baseURL = 'invalid';
const extension = extractFileExtension(path, baseURL);
expect(extension).toBeNull();
});
test('path with multiple dots', () => {
const path = 'https://example.com/scripts/app.min.js?version=1.0';
const extension = extractFileExtension(path);
expect(extension).toBe('js');
});
});
}); });