From 8b198b338e2764c860155a4b60ed982dc0f9d34b Mon Sep 17 00:00:00 2001 From: Yanzhen Yu Date: Wed, 1 Apr 2026 12:00:00 +0800 Subject: [PATCH] impl #309 observe font face set changes --- package.json | 1 + scripts/repl.ts | 6 ++-- src/record/index.ts | 12 ++++++++ src/record/observer.ts | 62 ++++++++++++++++++++++++++++++++++++++++++ src/replay/index.ts | 16 +++++++++++ src/types.ts | 22 ++++++++++++++- tsconfig.json | 6 +++- 7 files changed, 121 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index c54cb71e..c028a0a6 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,7 @@ "typescript": "^3.9.5" }, "dependencies": { + "@types/css-font-loading-module": "0.0.4", "@xstate/fsm": "^1.4.0", "mitt": "^1.1.3", "pako": "^1.0.11", diff --git a/scripts/repl.ts b/scripts/repl.ts index 637c8828..41755254 100644 --- a/scripts/repl.ts +++ b/scripts/repl.ts @@ -95,7 +95,8 @@ function getCode(): string { window.__IS_RECORDING__ = true rrweb.record({ emit: event => window._replLog(event), - recordCanvas: true + recordCanvas: true, + collectFonts: true }); `); page.on('framenavigated', async () => { @@ -105,7 +106,8 @@ function getCode(): string { window.__IS_RECORDING__ = true rrweb.record({ emit: event => window._replLog(event), - recordCanvas: true + recordCanvas: true, + collectFonts: true }); `); } diff --git a/src/record/index.ts b/src/record/index.ts index c09d4701..b5c50842 100644 --- a/src/record/index.ts +++ b/src/record/index.ts @@ -42,6 +42,7 @@ function record( sampling = {}, mousemoveWait, recordCanvas = false, + collectFonts = false, } = options; // runtime checks for user options if (!emit) { @@ -256,12 +257,23 @@ function record( }, }), ), + fontCb: (p) => + wrappedEmit( + wrapEvent({ + type: EventType.IncrementalSnapshot, + data: { + source: IncrementalSource.Font, + ...p, + }, + }), + ), blockClass, ignoreClass, maskInputOptions, inlineStylesheet, sampling, recordCanvas, + collectFonts, }, hooks, ), diff --git a/src/record/observer.ts b/src/record/observer.ts index 8c364d35..792cd7ff 100644 --- a/src/record/observer.ts +++ b/src/record/observer.ts @@ -1,4 +1,5 @@ import { INode, MaskInputOptions } from 'rrweb-snapshot'; +import { FontFaceDescriptors, FontFaceSet } from 'css-font-loading-module'; import { mirror, throttle, @@ -32,6 +33,8 @@ import { MediaInteractions, SamplingStrategy, canvasMutationCallback, + fontCallback, + fontParam, } from '../types'; import MutationBuffer from './mutation'; @@ -432,6 +435,56 @@ function initCanvasMutationObserver( }; } +function initFontObserver(cb: fontCallback): listenerHandler { + const handlers: listenerHandler[] = []; + + const fontMap = new WeakMap(); + + const originalFontFace = FontFace; + // tslint:disable-next-line: no-any + (window as any).FontFace = function FontFace( + family: string, + source: string | ArrayBufferView, + descriptors?: FontFaceDescriptors, + ) { + const fontFace = new originalFontFace(family, source, descriptors); + fontMap.set(fontFace, { + family, + buffer: typeof source !== 'string', + descriptors, + fontSource: + typeof source === 'string' + ? source + : // tslint:disable-next-line: no-any + JSON.stringify(Array.from(new Uint8Array(source as any))), + }); + return fontFace; + }; + + const restoreHandler = patch(document.fonts, 'add', function (original) { + return function (this: FontFaceSet, fontFace: FontFace) { + setTimeout(() => { + const p = fontMap.get(fontFace); + if (p) { + cb(p); + fontMap.delete(fontFace); + } + }, 0); + return original.apply(this, [fontFace]); + }; + }); + + handlers.push(() => { + // tslint:disable-next-line: no-any + (window as any).FonFace = originalFontFace; + }); + handlers.push(restoreHandler); + + return () => { + handlers.forEach((h) => h()); + }; +} + function mergeHooks(o: observerParam, hooks: hooksParam) { const { mutationCb, @@ -443,6 +496,7 @@ function mergeHooks(o: observerParam, hooks: hooksParam) { mediaInteractionCb, styleSheetRuleCb, canvasMutationCb, + fontCb, } = o; o.mutationCb = (...p: Arguments) => { if (hooks.mutation) { @@ -498,6 +552,12 @@ function mergeHooks(o: observerParam, hooks: hooksParam) { } canvasMutationCb(...p); }; + o.fontCb = (...p: Arguments) => { + if (hooks.font) { + hooks.font(...p); + } + fontCb(...p); + }; } export default function initObservers( @@ -539,6 +599,7 @@ export default function initObservers( const canvasMutationObserver = o.recordCanvas ? initCanvasMutationObserver(o.canvasMutationCb, o.blockClass) : () => {}; + const fontObserver = o.collectFonts ? initFontObserver(o.fontCb) : () => {}; return () => { mutationObserver.disconnect(); @@ -550,5 +611,6 @@ export default function initObservers( mediaInteractionHandler(); styleSheetObserver(); canvasMutationObserver(); + fontObserver(); }; } diff --git a/src/replay/index.ts b/src/replay/index.ts index fb5135de..6a691faf 100644 --- a/src/replay/index.ts +++ b/src/replay/index.ts @@ -813,6 +813,22 @@ export class Replayer { } catch (error) { this.warnCanvasMutationFailed(d, d.id, error); } + break; + } + case IncrementalSource.Font: { + try { + const fontFace = new FontFace( + d.family, + d.buffer ? new Uint8Array(JSON.parse(d.fontSource)) : d.fontSource, + d.descriptors, + ); + this.iframe.contentDocument?.fonts.add(fontFace); + } catch (error) { + if (this.config.showWarning) { + console.warn(error); + } + } + break; } default: } diff --git a/src/types.ts b/src/types.ts index 26c47c6c..c68069fa 100644 --- a/src/types.ts +++ b/src/types.ts @@ -5,6 +5,7 @@ import { MaskInputOptions, } from 'rrweb-snapshot'; import { PackFn, UnpackFn } from './packer/base'; +import { FontFaceDescriptors } from 'css-font-loading-module'; export enum EventType { DomContentLoaded, @@ -71,6 +72,7 @@ export enum IncrementalSource { MediaInteraction, StyleSheetRule, CanvasMutation, + Font, } export type mutationData = { @@ -111,6 +113,10 @@ export type canvasMutationData = { source: IncrementalSource.CanvasMutation; } & canvasMutationParam; +export type fontData = { + source: IncrementalSource.Font; +} & fontParam; + export type incrementalData = | mutationData | mousemoveData @@ -120,7 +126,8 @@ export type incrementalData = | inputData | mediaInteractionData | styleSheetRuleData - | canvasMutationData; + | canvasMutationData + | fontData; export type event = | domContentLoadedEvent @@ -172,6 +179,7 @@ export type recordOptions = { packFn?: PackFn; sampling?: SamplingStrategy; recordCanvas?: boolean; + collectFonts?: boolean; // departed, please use sampling options mousemoveWait?: number; }; @@ -190,8 +198,10 @@ export type observerParam = { inlineStylesheet: boolean; styleSheetRuleCb: styleSheetRuleCallback; canvasMutationCb: canvasMutationCallback; + fontCb: fontCallback; sampling: SamplingStrategy; recordCanvas: boolean; + collectFonts: boolean; }; export type hooksParam = { @@ -204,6 +214,7 @@ export type hooksParam = { mediaInteaction?: mediaInteractionCallback; styleSheetRule?: styleSheetRuleCallback; canvasMutation?: canvasMutationCallback; + font?: fontCallback; }; // https://dom.spec.whatwg.org/#interface-mutationrecord @@ -328,6 +339,15 @@ export type canvasMutationParam = { setter?: true; }; +export type fontParam = { + family: string; + fontSource: string; + buffer: boolean; + descriptors?: FontFaceDescriptors; +}; + +export type fontCallback = (p: fontParam) => void; + export type viewportResizeDimention = { width: number; height: number; diff --git a/tsconfig.json b/tsconfig.json index 17d29926..cdbc63f0 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -14,5 +14,9 @@ }, "compileOnSave": true, "exclude": ["test"], - "include": ["src", "test.d.ts"] + "include": [ + "src", + "test.d.ts", + "node_modules/@types/css-font-loading-module/index.d.ts" + ] }