From 3c2e1f0f46e8746027dee2d6b2770321b905e340 Mon Sep 17 00:00:00 2001 From: Shaoyu Meng Date: Mon, 13 Jul 2026 13:42:20 -0700 Subject: [PATCH 01/13] docs: add design spec for dialog record/replay plugin Design for a plugin pair that captures native window.alert/confirm/prompt dialogs (message, prompt default, user response) and surfaces them on replay via a callback, mirroring the console/network plugins. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../specs/2026-07-13-dialog-plugin-design.md | 179 ++++++++++++++++++ 1 file changed, 179 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-13-dialog-plugin-design.md diff --git a/docs/superpowers/specs/2026-07-13-dialog-plugin-design.md b/docs/superpowers/specs/2026-07-13-dialog-plugin-design.md new file mode 100644 index 0000000000..ab0cc615d2 --- /dev/null +++ b/docs/superpowers/specs/2026-07-13-dialog-plugin-design.md @@ -0,0 +1,179 @@ +# Dialog Plugin Design — recording `alert` / `confirm` / `prompt` + +Date: 2026-07-13 + +## Problem + +rrweb records the DOM (initial snapshot + `MutationObserver` + event listeners), so +DOM-based popups (``, custom modal `
`s) are captured and replayed. But the +native, blocking browser dialogs — `window.alert`, `window.confirm`, `window.prompt` — are +rendered as browser chrome outside the DOM. rrweb has no hook for them today, so neither the +fact that a dialog appeared nor the user's response is recorded. + +## Goal + +A record/replay plugin **pair** (mirroring the console and network plugins) that captures +native `alert` / `confirm` / `prompt` calls — including the message, `prompt`'s default +value, and the user's response — and surfaces them during replay via a callback. + +Because these functions are **synchronous and blocking**, a monkey-patch can call through to +the real dialog, capture its return value after the user responds, and emit an rrweb Plugin +event. No async plumbing is required. + +## Non-goals + +- Re-showing a real blocking native dialog during replay (would freeze the replayer UI). +- A built-in visual overlay renderer for replay. Replay is **callback-only**; the consumer + decides how to surface the data. (An overlay could be added later as an opt-in without + breaking this design.) +- Capturing dialogs from other sources (e.g. `beforeunload` confirmation, `print`). + +## Packages + +Two new packages under `packages/plugins/`, lockstep-versioned at `2.1.0`, following the +existing plugin conventions (Vite library build, `@rrweb/types` + `@rrweb/utils` as +peer + dev deps, turbo `prepublish`): + +- `@rrweb/rrweb-plugin-dialog-record` +- `@rrweb/rrweb-plugin-dialog-replay` — dev-depends on the record twin to import + `PLUGIN_NAME` and the shared `DialogData` type. + +Shared identifier, exported from the record package: + +```ts +export const PLUGIN_NAME = 'rrweb/dialog@1'; +``` + +Factory naming follows convention: `getRecordDialogPlugin(options?)` and +`getReplayDialogPlugin(options)`. + +## Data model + +```ts +export type DialogKind = 'alert' | 'confirm' | 'prompt'; + +export type DialogData = { + kind: DialogKind; + message: string; // 1st arg to alert/confirm/prompt (coerced to string) + defaultValue?: string; // prompt's 2nd arg; present only for kind === 'prompt' when supplied + returnValue?: boolean | string | null; // confirm→boolean, prompt→string|null; omitted for alert or when disabled +}; +``` + +rrweb wraps whatever the observer passes to `cb()` into: + +```ts +{ type: EventType.Plugin, data: { plugin: 'rrweb/dialog@1', payload: DialogData } } +``` + +with rrweb's own event timestamp, so the payload carries **no** custom timing field. + +## Record plugin (`rrweb-plugin-dialog-record`) + +### Options + +```ts +export type DialogRecordOptions = { + level?: DialogKind[]; // which dialogs to hook; default ['alert', 'confirm', 'prompt'] + recordReturnValue?: boolean; // capture confirm/prompt result; default true + maskDialog?: (data: DialogData) => DialogData; // redaction hook applied just before emit +}; +``` + +### Observer behavior + +For each kind in `level`, patch `win[kind]` using `patch()` from `@rrweb/utils` (stores the +original under a non-enumerable `__rrweb_original__` and returns a restore function). + +The wrapper, on each call: + +1. Calls the **original** dialog first (`original.apply(this, args)`). This blocks until the + user responds, which is exactly how we obtain the user's answer for `confirm`/`prompt`. +2. Builds `DialogData`: + - `kind` = the patched kind. + - `message` = `String(args[0] ?? '')`. + - `defaultValue` = for `prompt` only, `String(args[1])` when `args[1] != null`. + - `returnValue` = the value from step 1, included only when + `recordReturnValue !== false` **and** `kind !== 'alert'`. +3. If `maskDialog` is provided, replaces `data` with `maskDialog(data)`. +4. Emits via `cb(data)`. +5. Returns the original return value to the calling page (transparent passthrough). + +The observer returns a teardown that restores every patched method. + +Runs per-window via rrweb's existing plugin-per-window wiring, so same-origin iframes and the +top window are each patched. + +### Correctness points + +- `alert` → `returnValue` omitted (native returns `undefined`). +- `confirm` → `returnValue` is a `boolean`. +- `prompt` → `returnValue` is a `string`, or `null` when the user cancels. +- `recordReturnValue: false` → omit `returnValue` for `confirm`/`prompt`. +- `level` subset → only listed kinds are patched; others are untouched. +- Passthrough return preserves page behavior exactly. +- Idempotent teardown restores the exact original references. + +### Type note + +Like network-record, the typed observer signature (`(cb: (data: DialogData) => void, win, +options) => listenerHandler`) is narrower than `RecordPlugin['observer']`, so the factory +casts `observer: initDialogObserver as RecordPlugin['observer']`. + +## Replay plugin (`rrweb-plugin-dialog-replay`) + +### Options + +```ts +export type DialogReplayOptions = { + onDialog: (data: DialogData) => void; +}; +``` + +### Handler behavior + +```ts +export const getReplayDialogPlugin: (options: DialogReplayOptions) => ReplayPlugin = + (options) => ({ + handler(event) { + if (event.type === EventType.Plugin && event.data.plugin === PLUGIN_NAME) { + options.onDialog(event.data.payload as DialogData); + } + }, + }); +``` + +Mirrors network-replay exactly — filter by plugin name, hand the payload to the consumer. + +## Testing + +Primary pattern: Vitest unit tests with a mock window (network-record's approach), which is a +clean fit since native dialogs are trivially stubbed. + +For record: +- Stub `win.alert` / `win.confirm` / `win.prompt` on a mock window (with controllable return + values). +- Call `plugin.observer(captureCb, mockWin, plugin.options)`, invoke the dialogs, assert the + captured `DialogData[]`, then call teardown and assert the originals are restored. +- Cases: `alert` (no `returnValue`); `confirm` true/false; `prompt` returns typed string; + `prompt` cancel → `null`; `prompt` `defaultValue`; `recordReturnValue: false` omits + `returnValue`; `maskDialog` transforms the payload; `level` subset patches only the listed + kinds; passthrough returns the original value to the caller. + +For replay: +- Feed a synthetic `EventType.Plugin` event with `plugin === PLUGIN_NAME` and assert + `onDialog` receives the payload; feed an unrelated event and assert it is ignored. + +## Tooling / files per package + +Copied from the network plugin templates: + +- `rrweb-plugin-dialog-record/`: `package.json`, `tsconfig.json`, `vite.config.ts`, + `vitest.config.ts`, `src/index.ts`, `test/index.test.ts`, `README.md`. +- `rrweb-plugin-dialog-replay/`: `package.json`, `tsconfig.json`, `vite.config.ts`, + `src/index.ts`, `README.md`. + +`vite.config.ts` uses the shared `vite.config.default` factory with entry `src/index.ts` and +UMD names `rrwebPluginDialogRecord` / `rrwebPluginDialogReplay`. `package.json` names, +exports, scripts, and peer/dev deps mirror the network plugin at version `2.1.0`. A changeset +should be added for the two new packages. From 0e5531e4a3d216e1eb672c63c787f2b2b3a18999 Mon Sep 17 00:00:00 2001 From: Shaoyu Meng Date: Mon, 13 Jul 2026 13:52:41 -0700 Subject: [PATCH 02/13] feat(plugins): add dialog record/replay plugin for alert/confirm/prompt Native window.alert/confirm/prompt dialogs are browser chrome outside the DOM, so rrweb never captured them. This adds a plugin pair mirroring the console/network plugins: - @rrweb/rrweb-plugin-dialog-record patches alert/confirm/prompt via @rrweb/utils' patch(), calling through to the real (blocking) dialog first so it can capture the user's response, then emitting a 'rrweb/dialog@1' plugin event with { kind, message, defaultValue?, returnValue? }. Options: level, recordReturnValue, maskDialog. - @rrweb/rrweb-plugin-dialog-replay filters those events and surfaces each dialog to an onDialog callback (callback-only, since re-showing a blocking native dialog would freeze the replayer). Covered by unit tests (mock-window observer pattern); both packages type-check and build. Co-Authored-By: Claude Opus 4.8 (1M context) --- .changeset/dialog-plugin.md | 6 + .../rrweb-plugin-dialog-record/README.md | 67 ++++++++ .../rrweb-plugin-dialog-record/package.json | 63 +++++++ .../rrweb-plugin-dialog-record/src/index.ts | 89 ++++++++++ .../test/index.test.ts | 159 ++++++++++++++++++ .../rrweb-plugin-dialog-record/tsconfig.json | 17 ++ .../rrweb-plugin-dialog-record/vite.config.ts | 3 + .../vitest.config.ts | 15 ++ .../rrweb-plugin-dialog-replay/README.md | 41 +++++ .../rrweb-plugin-dialog-replay/package.json | 62 +++++++ .../rrweb-plugin-dialog-replay/src/index.ts | 33 ++++ .../test/index.test.ts | 57 +++++++ .../rrweb-plugin-dialog-replay/tsconfig.json | 17 ++ .../rrweb-plugin-dialog-replay/vite.config.ts | 3 + .../vitest.config.ts | 20 +++ 15 files changed, 652 insertions(+) create mode 100644 .changeset/dialog-plugin.md create mode 100644 packages/plugins/rrweb-plugin-dialog-record/README.md create mode 100644 packages/plugins/rrweb-plugin-dialog-record/package.json create mode 100644 packages/plugins/rrweb-plugin-dialog-record/src/index.ts create mode 100644 packages/plugins/rrweb-plugin-dialog-record/test/index.test.ts create mode 100644 packages/plugins/rrweb-plugin-dialog-record/tsconfig.json create mode 100644 packages/plugins/rrweb-plugin-dialog-record/vite.config.ts create mode 100644 packages/plugins/rrweb-plugin-dialog-record/vitest.config.ts create mode 100644 packages/plugins/rrweb-plugin-dialog-replay/README.md create mode 100644 packages/plugins/rrweb-plugin-dialog-replay/package.json create mode 100644 packages/plugins/rrweb-plugin-dialog-replay/src/index.ts create mode 100644 packages/plugins/rrweb-plugin-dialog-replay/test/index.test.ts create mode 100644 packages/plugins/rrweb-plugin-dialog-replay/tsconfig.json create mode 100644 packages/plugins/rrweb-plugin-dialog-replay/vite.config.ts create mode 100644 packages/plugins/rrweb-plugin-dialog-replay/vitest.config.ts diff --git a/.changeset/dialog-plugin.md b/.changeset/dialog-plugin.md new file mode 100644 index 0000000000..ad00edd9de --- /dev/null +++ b/.changeset/dialog-plugin.md @@ -0,0 +1,6 @@ +--- +'@rrweb/rrweb-plugin-dialog-record': minor +'@rrweb/rrweb-plugin-dialog-replay': minor +--- + +Add dialog plugin pair that records native `window.alert` / `window.confirm` / `window.prompt` dialogs (message, prompt default value, and user response) and surfaces them during replay via an `onDialog` callback. diff --git a/packages/plugins/rrweb-plugin-dialog-record/README.md b/packages/plugins/rrweb-plugin-dialog-record/README.md new file mode 100644 index 0000000000..7984ced0cb --- /dev/null +++ b/packages/plugins/rrweb-plugin-dialog-record/README.md @@ -0,0 +1,67 @@ +# @rrweb/rrweb-plugin-dialog-record + +Records native browser dialogs — `window.alert`, `window.confirm`, and `window.prompt` — +that are otherwise invisible to rrweb because they are rendered as browser chrome outside the +DOM. The plugin captures the dialog message, `prompt`'s default value, and the user's +response (the boolean from `confirm`, the string from `prompt`). + +Pair it with [`@rrweb/rrweb-plugin-dialog-replay`](../rrweb-plugin-dialog-replay) to surface +the captured dialogs during replay. + +See the [guide](../../../guide.md) for more info on rrweb. + +## Installation + +```sh +npm install @rrweb/rrweb-plugin-dialog-record +``` + +## Usage + +```ts +import { record } from 'rrweb'; +import { getRecordDialogPlugin } from '@rrweb/rrweb-plugin-dialog-record'; + +record({ + emit(event) { + // store the event + }, + plugins: [getRecordDialogPlugin()], +}); +``` + +Each captured dialog is emitted as an rrweb Plugin event with this payload shape: + +```ts +type DialogData = { + kind: 'alert' | 'confirm' | 'prompt'; + message: string; + defaultValue?: string; // prompt's second argument, when provided + returnValue?: boolean | string | null; // confirm → boolean, prompt → string | null; omitted for alert +}; +``` + +Note: because native dialogs are synchronous and blocking, the plugin calls through to the +real dialog first (so the page behaves exactly as before) and records the user's response +once they dismiss it. + +## Options + +```ts +getRecordDialogPlugin({ + // Which dialogs to hook. Defaults to all three. + level: ['alert', 'confirm', 'prompt'], + + // Whether to record the user's response for confirm / prompt. Defaults to true. + recordReturnValue: true, + + // Redact sensitive content just before it is recorded. + maskDialog: (data) => ({ ...data, message: '***', returnValue: '***' }), +}); +``` + +| Option | Type | Default | Description | +| --- | --- | --- | --- | +| `level` | `('alert' \| 'confirm' \| 'prompt')[]` | all three | Which native dialogs to hook. | +| `recordReturnValue` | `boolean` | `true` | Record the `confirm`/`prompt` response. `alert` never has a return value. | +| `maskDialog` | `(data: DialogData) => DialogData` | — | Transform the payload before it is emitted, e.g. to redact PII. | diff --git a/packages/plugins/rrweb-plugin-dialog-record/package.json b/packages/plugins/rrweb-plugin-dialog-record/package.json new file mode 100644 index 0000000000..4989d28731 --- /dev/null +++ b/packages/plugins/rrweb-plugin-dialog-record/package.json @@ -0,0 +1,63 @@ +{ + "name": "@rrweb/rrweb-plugin-dialog-record", + "version": "2.1.0", + "description": "", + "type": "module", + "main": "./dist/rrweb-plugin-dialog-record.umd.cjs", + "module": "./dist/rrweb-plugin-dialog-record.js", + "unpkg": "./dist/rrweb-plugin-dialog-record.umd.cjs", + "jsdelivr": "./umd/rrweb-plugin-dialog-record.js", + "typings": "dist/index.d.ts", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.ts", + "default": "./dist/rrweb-plugin-dialog-record.js" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/rrweb-plugin-dialog-record.umd.cjs" + } + } + }, + "files": [ + "umd", + "dist", + "package.json" + ], + "scripts": { + "dev": "vite build --watch", + "test": "vitest run", + "test:watch": "vitest watch", + "build": "yarn turbo run prepublish", + "check-types": "tsc -noEmit", + "prepublish": "tsc -noEmit && vite build" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/rrweb-io/rrweb.git" + }, + "keywords": [ + "rrweb" + ], + "author": "rrweb Core Team ", + "license": "MIT", + "bugs": { + "url": "https://github.com/rrweb-io/rrweb/issues" + }, + "homepage": "https://github.com/rrweb-io/rrweb#readme", + "devDependencies": { + "@rrweb/types": "^2.1.0", + "@rrweb/utils": "^2.1.0", + "rrweb": "^2.1.0", + "typescript": "^5.4.5", + "vite": "^6.0.1", + "vite-plugin-dts": "^3.9.1", + "vitest": "^1.4.0" + }, + "peerDependencies": { + "rrweb": "^2.1.0", + "@rrweb/types": "^2.1.0", + "@rrweb/utils": "^2.1.0" + } +} diff --git a/packages/plugins/rrweb-plugin-dialog-record/src/index.ts b/packages/plugins/rrweb-plugin-dialog-record/src/index.ts new file mode 100644 index 0000000000..a6c2ded1b8 --- /dev/null +++ b/packages/plugins/rrweb-plugin-dialog-record/src/index.ts @@ -0,0 +1,89 @@ +import type { listenerHandler, RecordPlugin, IWindow } from '@rrweb/types'; +import { patch } from '@rrweb/utils'; + +export const PLUGIN_NAME = 'rrweb/dialog@1'; + +export type DialogKind = 'alert' | 'confirm' | 'prompt'; + +export type DialogData = { + kind: DialogKind; + message: string; + defaultValue?: string; + returnValue?: boolean | string | null; +}; + +export type DialogRecordOptions = { + /** + * Which native dialogs to hook. Defaults to all three. + */ + level?: DialogKind[]; + /** + * Whether to record the user's response for `confirm` / `prompt`. + * `alert` never has a return value. Defaults to `true`. + */ + recordReturnValue?: boolean; + /** + * Redaction hook applied to the payload immediately before it is emitted. + */ + maskDialog?: (data: DialogData) => DialogData; +}; + +type DialogCallback = (data: DialogData) => void; + +const ALL_KINDS: DialogKind[] = ['alert', 'confirm', 'prompt']; + +function initDialogObserver( + cb: DialogCallback, + win: IWindow, + options: DialogRecordOptions, +): listenerHandler { + const kinds = options.level ?? ALL_KINDS; + const recordReturnValue = options.recordReturnValue !== false; + const handlers: listenerHandler[] = []; + + for (const kind of kinds) { + handlers.push( + patch( + win as unknown as Record, + kind, + (original) => { + const originalFn = original as (...args: unknown[]) => unknown; + return function (this: unknown, ...args: unknown[]) { + // Call through to the real (blocking) dialog first so we can + // capture the user's response for confirm / prompt. + const returnValue = originalFn.apply(this, args); + + let data: DialogData = { + kind, + message: String(args[0] ?? ''), + }; + if (kind === 'prompt' && args[1] != null) { + data.defaultValue = String(args[1]); + } + if (recordReturnValue && kind !== 'alert') { + data.returnValue = returnValue as boolean | string | null; + } + if (options.maskDialog) { + data = options.maskDialog(data); + } + cb(data); + + return returnValue; + }; + }, + ), + ); + } + + return () => { + handlers.forEach((h) => h()); + }; +} + +export const getRecordDialogPlugin: ( + options?: DialogRecordOptions, +) => RecordPlugin = (options) => ({ + name: PLUGIN_NAME, + observer: initDialogObserver as RecordPlugin['observer'], + options: options ?? {}, +}); diff --git a/packages/plugins/rrweb-plugin-dialog-record/test/index.test.ts b/packages/plugins/rrweb-plugin-dialog-record/test/index.test.ts new file mode 100644 index 0000000000..8e03a696df --- /dev/null +++ b/packages/plugins/rrweb-plugin-dialog-record/test/index.test.ts @@ -0,0 +1,159 @@ +import { describe, expect, it, vi } from 'vitest'; +import { getRecordDialogPlugin, PLUGIN_NAME } from '../src'; +import type { DialogData } from '../src'; + +type MockWindow = { + alert: (message?: unknown) => void; + confirm: (message?: unknown) => boolean; + prompt: (message?: unknown, defaultValue?: unknown) => string | null; +}; + +function createMockWindow(overrides: Partial = {}): MockWindow { + return { + alert: () => undefined, + confirm: () => true, + prompt: () => null, + ...overrides, + }; +} + +function run( + win: MockWindow, + options: Parameters[0] = undefined, +) { + const events: DialogData[] = []; + const plugin = getRecordDialogPlugin(options); + const cleanup = plugin.observer!( + (data: DialogData) => events.push(data), + win as never, + plugin.options, + ); + return { events, cleanup }; +} + +describe('rrweb-plugin-dialog-record', () => { + it('exposes the expected plugin name', () => { + expect(PLUGIN_NAME).toBe('rrweb/dialog@1'); + expect(getRecordDialogPlugin().name).toBe('rrweb/dialog@1'); + }); + + it('records an alert with its message and no returnValue', () => { + const win = createMockWindow(); + const { events, cleanup } = run(win); + + win.alert('hello'); + cleanup(); + + expect(events).toEqual([{ kind: 'alert', message: 'hello' }]); + }); + + it('records a confirm with its boolean returnValue', () => { + const win = createMockWindow({ confirm: () => false }); + const { events, cleanup } = run(win); + + const result = win.confirm('are you sure?'); + cleanup(); + + expect(result).toBe(false); + expect(events).toEqual([ + { kind: 'confirm', message: 'are you sure?', returnValue: false }, + ]); + }); + + it('records a prompt with its default value and typed returnValue', () => { + const win = createMockWindow({ prompt: () => 'Neo' }); + const { events, cleanup } = run(win); + + const result = win.prompt('your name?', 'anon'); + cleanup(); + + expect(result).toBe('Neo'); + expect(events).toEqual([ + { + kind: 'prompt', + message: 'your name?', + defaultValue: 'anon', + returnValue: 'Neo', + }, + ]); + }); + + it('records a null returnValue when the user cancels a prompt', () => { + const win = createMockWindow({ prompt: () => null }); + const { events, cleanup } = run(win); + + win.prompt('your name?'); + cleanup(); + + expect(events).toEqual([ + { kind: 'prompt', message: 'your name?', returnValue: null }, + ]); + }); + + it('omits returnValue when recordReturnValue is false', () => { + const win = createMockWindow({ confirm: () => true, prompt: () => 'x' }); + const { events, cleanup } = run(win, { recordReturnValue: false }); + + win.confirm('ok?'); + win.prompt('name?'); + cleanup(); + + expect(events).toEqual([ + { kind: 'confirm', message: 'ok?' }, + { kind: 'prompt', message: 'name?' }, + ]); + }); + + it('applies maskDialog before emitting', () => { + const win = createMockWindow({ prompt: () => 'secret' }); + const { events, cleanup } = run(win, { + maskDialog: (data) => ({ ...data, message: '***', returnValue: '***' }), + }); + + win.prompt('ssn?', '000'); + cleanup(); + + expect(events).toEqual([ + { + kind: 'prompt', + message: '***', + defaultValue: '000', + returnValue: '***', + }, + ]); + }); + + it('only patches the kinds listed in the level option', () => { + const win = createMockWindow({ confirm: () => true }); + const { events, cleanup } = run(win, { level: ['confirm'] }); + + win.alert('ignored'); + win.confirm('kept'); + win.prompt('ignored'); + cleanup(); + + expect(events).toEqual([ + { kind: 'confirm', message: 'kept', returnValue: true }, + ]); + }); + + it('restores the original dialog functions on teardown', () => { + const originalAlert = vi.fn(); + const win = createMockWindow({ alert: originalAlert }); + const { cleanup } = run(win); + + expect(win.alert).not.toBe(originalAlert); + cleanup(); + expect(win.alert).toBe(originalAlert); + }); + + it('coerces a non-string message to a string', () => { + const win = createMockWindow(); + const { events, cleanup } = run(win); + + win.alert(42); + cleanup(); + + expect(events).toEqual([{ kind: 'alert', message: '42' }]); + }); +}); diff --git a/packages/plugins/rrweb-plugin-dialog-record/tsconfig.json b/packages/plugins/rrweb-plugin-dialog-record/tsconfig.json new file mode 100644 index 0000000000..b70866e1ab --- /dev/null +++ b/packages/plugins/rrweb-plugin-dialog-record/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "../../../tsconfig.base.json", + "include": ["src"], + "exclude": ["vite.config.ts", "vitest.config.ts", "test"], + "compilerOptions": { + "rootDir": "src", + "tsBuildInfoFile": "./tsconfig.tsbuildinfo" + }, + "references": [ + { + "path": "../../types" + }, + { + "path": "../../utils" + } + ] +} diff --git a/packages/plugins/rrweb-plugin-dialog-record/vite.config.ts b/packages/plugins/rrweb-plugin-dialog-record/vite.config.ts new file mode 100644 index 0000000000..5786e0e562 --- /dev/null +++ b/packages/plugins/rrweb-plugin-dialog-record/vite.config.ts @@ -0,0 +1,3 @@ +import config from '../../../vite.config.default'; + +export default config('src/index.ts', 'rrwebPluginDialogRecord'); diff --git a/packages/plugins/rrweb-plugin-dialog-record/vitest.config.ts b/packages/plugins/rrweb-plugin-dialog-record/vitest.config.ts new file mode 100644 index 0000000000..0ea1013a23 --- /dev/null +++ b/packages/plugins/rrweb-plugin-dialog-record/vitest.config.ts @@ -0,0 +1,15 @@ +/// +import { defineProject, mergeConfig } from 'vitest/config'; +import { resolve } from 'node:path'; +import configShared from '../../../vitest.config.ts'; + +export default mergeConfig( + configShared, + defineProject({ + resolve: { + alias: { + '@rrweb/utils': resolve(__dirname, '../../utils/src'), + }, + }, + }), +); diff --git a/packages/plugins/rrweb-plugin-dialog-replay/README.md b/packages/plugins/rrweb-plugin-dialog-replay/README.md new file mode 100644 index 0000000000..fe9da79031 --- /dev/null +++ b/packages/plugins/rrweb-plugin-dialog-replay/README.md @@ -0,0 +1,41 @@ +# @rrweb/rrweb-plugin-dialog-replay + +Consumes the native-dialog events captured by +[`@rrweb/rrweb-plugin-dialog-record`](../rrweb-plugin-dialog-record) during replay. + +Native `alert` / `confirm` / `prompt` boxes are blocking browser chrome, so re-showing a real +one during replay would freeze the player. Instead, this plugin surfaces each recorded dialog +to a callback you provide, so you can render it however you like (a custom overlay, a log +line, a timeline marker, …). + +See the [guide](../../../guide.md) for more info on rrweb. + +## Installation + +```sh +npm install @rrweb/rrweb-plugin-dialog-replay +``` + +## Usage + +```ts +import { Replayer } from 'rrweb'; +import { getReplayDialogPlugin } from '@rrweb/rrweb-plugin-dialog-replay'; + +const replayer = new Replayer(events, { + plugins: [ + getReplayDialogPlugin({ + onDialog(data) { + // data = { kind, message, defaultValue?, returnValue? } + console.log('dialog replayed', data); + }, + }), + ], +}); +``` + +## Options + +| Option | Type | Description | +| --- | --- | --- | +| `onDialog` | `(data: DialogData) => void` | Called for every recorded dialog as it is replayed. | diff --git a/packages/plugins/rrweb-plugin-dialog-replay/package.json b/packages/plugins/rrweb-plugin-dialog-replay/package.json new file mode 100644 index 0000000000..11207a7304 --- /dev/null +++ b/packages/plugins/rrweb-plugin-dialog-replay/package.json @@ -0,0 +1,62 @@ +{ + "name": "@rrweb/rrweb-plugin-dialog-replay", + "version": "2.1.0", + "description": "", + "type": "module", + "main": "./dist/rrweb-plugin-dialog-replay.umd.cjs", + "module": "./dist/rrweb-plugin-dialog-replay.js", + "unpkg": "./dist/rrweb-plugin-dialog-replay.umd.cjs", + "jsdelivr": "./umd/rrweb-plugin-dialog-replay.js", + "typings": "dist/index.d.ts", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.ts", + "default": "./dist/rrweb-plugin-dialog-replay.js" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/rrweb-plugin-dialog-replay.umd.cjs" + } + } + }, + "files": [ + "umd", + "dist", + "package.json" + ], + "scripts": { + "dev": "vite build --watch", + "test": "vitest run", + "test:watch": "vitest watch", + "build": "yarn turbo run prepublish", + "check-types": "tsc -noEmit", + "prepublish": "tsc -noEmit && vite build" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/rrweb-io/rrweb.git" + }, + "keywords": [ + "rrweb" + ], + "author": "rrweb Core Team ", + "license": "MIT", + "bugs": { + "url": "https://github.com/rrweb-io/rrweb/issues" + }, + "homepage": "https://github.com/rrweb-io/rrweb#readme", + "devDependencies": { + "@rrweb/rrweb-plugin-dialog-record": "^2.1.0", + "@rrweb/types": "^2.1.0", + "rrweb": "^2.1.0", + "typescript": "^5.4.5", + "vite": "^6.0.1", + "vite-plugin-dts": "^3.9.1", + "vitest": "^1.4.0" + }, + "peerDependencies": { + "rrweb": "^2.1.0", + "@rrweb/types": "^2.1.0" + } +} diff --git a/packages/plugins/rrweb-plugin-dialog-replay/src/index.ts b/packages/plugins/rrweb-plugin-dialog-replay/src/index.ts new file mode 100644 index 0000000000..9d14578cbe --- /dev/null +++ b/packages/plugins/rrweb-plugin-dialog-replay/src/index.ts @@ -0,0 +1,33 @@ +import type { eventWithTime } from '@rrweb/types'; +import { EventType } from '@rrweb/types'; +import { PLUGIN_NAME } from '@rrweb/rrweb-plugin-dialog-record'; +import type { DialogData } from '@rrweb/rrweb-plugin-dialog-record'; + +export type { DialogData, DialogKind } from '@rrweb/rrweb-plugin-dialog-record'; + +type ReplayPlugin = { + handler?: (event: eventWithTime, isSync: boolean, context: unknown) => void; +}; + +export type OnDialog = (data: DialogData) => void; + +export type DialogReplayOptions = { + onDialog: OnDialog; +}; + +export const getReplayDialogPlugin: ( + options: DialogReplayOptions, +) => ReplayPlugin = (options) => { + return { + handler(event: eventWithTime) { + if ( + event.type === EventType.Plugin && + event.data.plugin === PLUGIN_NAME + ) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const dialogData = event.data.payload as DialogData; + options.onDialog(dialogData); + } + }, + }; +}; diff --git a/packages/plugins/rrweb-plugin-dialog-replay/test/index.test.ts b/packages/plugins/rrweb-plugin-dialog-replay/test/index.test.ts new file mode 100644 index 0000000000..13e139f854 --- /dev/null +++ b/packages/plugins/rrweb-plugin-dialog-replay/test/index.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it, vi } from 'vitest'; +import { EventType } from '@rrweb/types'; +import type { eventWithTime } from '@rrweb/types'; +import { PLUGIN_NAME } from '@rrweb/rrweb-plugin-dialog-record'; +import type { DialogData } from '@rrweb/rrweb-plugin-dialog-record'; +import { getReplayDialogPlugin } from '../src'; + +function pluginEvent(plugin: string, payload: unknown): eventWithTime { + return { + type: EventType.Plugin, + data: { plugin, payload }, + timestamp: 0, + } as eventWithTime; +} + +describe('rrweb-plugin-dialog-replay', () => { + it('forwards a matching dialog plugin event to onDialog', () => { + const onDialog = vi.fn(); + const plugin = getReplayDialogPlugin({ onDialog }); + const payload: DialogData = { + kind: 'confirm', + message: 'sure?', + returnValue: true, + }; + + plugin.handler!(pluginEvent(PLUGIN_NAME, payload), false, {} as never); + + expect(onDialog).toHaveBeenCalledTimes(1); + expect(onDialog).toHaveBeenCalledWith(payload); + }); + + it('ignores plugin events from other plugins', () => { + const onDialog = vi.fn(); + const plugin = getReplayDialogPlugin({ onDialog }); + + plugin.handler!( + pluginEvent('rrweb/network@1', { some: 'data' }), + false, + {} as never, + ); + + expect(onDialog).not.toHaveBeenCalled(); + }); + + it('ignores non-plugin events', () => { + const onDialog = vi.fn(); + const plugin = getReplayDialogPlugin({ onDialog }); + + plugin.handler!( + { type: EventType.Meta, data: {}, timestamp: 0 } as eventWithTime, + false, + {} as never, + ); + + expect(onDialog).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/plugins/rrweb-plugin-dialog-replay/tsconfig.json b/packages/plugins/rrweb-plugin-dialog-replay/tsconfig.json new file mode 100644 index 0000000000..f6cb2502b5 --- /dev/null +++ b/packages/plugins/rrweb-plugin-dialog-replay/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "../../../tsconfig.base.json", + "include": ["src"], + "exclude": ["vite.config.ts", "vitest.config.ts", "test"], + "compilerOptions": { + "rootDir": "src", + "tsBuildInfoFile": "./tsconfig.tsbuildinfo" + }, + "references": [ + { + "path": "../../types" + }, + { + "path": "../rrweb-plugin-dialog-record" + } + ] +} diff --git a/packages/plugins/rrweb-plugin-dialog-replay/vite.config.ts b/packages/plugins/rrweb-plugin-dialog-replay/vite.config.ts new file mode 100644 index 0000000000..b38a3af6c6 --- /dev/null +++ b/packages/plugins/rrweb-plugin-dialog-replay/vite.config.ts @@ -0,0 +1,3 @@ +import config from '../../../vite.config.default'; + +export default config('src/index.ts', 'rrwebPluginDialogReplay'); diff --git a/packages/plugins/rrweb-plugin-dialog-replay/vitest.config.ts b/packages/plugins/rrweb-plugin-dialog-replay/vitest.config.ts new file mode 100644 index 0000000000..54bd621739 --- /dev/null +++ b/packages/plugins/rrweb-plugin-dialog-replay/vitest.config.ts @@ -0,0 +1,20 @@ +/// +import { defineProject, mergeConfig } from 'vitest/config'; +import { resolve } from 'node:path'; +import configShared from '../../../vitest.config.ts'; + +export default mergeConfig( + configShared, + defineProject({ + resolve: { + alias: { + '@rrweb/rrweb-plugin-dialog-record': resolve( + __dirname, + '../rrweb-plugin-dialog-record/src', + ), + '@rrweb/types': resolve(__dirname, '../../types/src'), + '@rrweb/utils': resolve(__dirname, '../../utils/src'), + }, + }, + }), +); From 906bde8fb06611e9701fb617f887475be925ebd7 Mon Sep 17 00:00:00 2001 From: Shaoyu Meng Date: Mon, 13 Jul 2026 15:42:42 -0700 Subject: [PATCH 03/13] refactor(plugins): rename dialog plugin pair to popup Rename @rrweb/rrweb-plugin-dialog-record/replay to @rrweb/rrweb-plugin-popup-record/replay, including the shared plugin identifier ('rrweb/dialog@1' -> 'rrweb/popup@1') and all exported symbols (getRecordPopupPlugin, getReplayPopupPlugin, PopupData, PopupKind, PopupRecordOptions, PopupReplayOptions, onPopup, maskPopup). Co-Authored-By: Claude Opus 4.8 (1M context) --- .changeset/dialog-plugin.md | 6 --- .changeset/popup-plugin.md | 6 +++ .../rrweb-plugin-dialog-record/vite.config.ts | 3 -- .../rrweb-plugin-dialog-replay/src/index.ts | 33 --------------- .../rrweb-plugin-dialog-replay/vite.config.ts | 3 -- .../README.md | 34 +++++++-------- .../package.json | 14 +++---- .../src/index.ts | 42 +++++++++---------- .../test/index.test.ts | 24 +++++------ .../tsconfig.json | 0 .../rrweb-plugin-popup-record/vite.config.ts | 3 ++ .../vitest.config.ts | 0 .../README.md | 20 ++++----- .../package.json | 16 +++---- .../rrweb-plugin-popup-replay/src/index.ts | 33 +++++++++++++++ .../test/index.test.ts | 32 +++++++------- .../tsconfig.json | 2 +- .../rrweb-plugin-popup-replay/vite.config.ts | 3 ++ .../vitest.config.ts | 4 +- 19 files changed, 139 insertions(+), 139 deletions(-) delete mode 100644 .changeset/dialog-plugin.md create mode 100644 .changeset/popup-plugin.md delete mode 100644 packages/plugins/rrweb-plugin-dialog-record/vite.config.ts delete mode 100644 packages/plugins/rrweb-plugin-dialog-replay/src/index.ts delete mode 100644 packages/plugins/rrweb-plugin-dialog-replay/vite.config.ts rename packages/plugins/{rrweb-plugin-dialog-record => rrweb-plugin-popup-record}/README.md (50%) rename packages/plugins/{rrweb-plugin-dialog-record => rrweb-plugin-popup-record}/package.json (76%) rename packages/plugins/{rrweb-plugin-dialog-record => rrweb-plugin-popup-record}/src/index.ts (64%) rename packages/plugins/{rrweb-plugin-dialog-record => rrweb-plugin-popup-record}/test/index.test.ts (84%) rename packages/plugins/{rrweb-plugin-dialog-record => rrweb-plugin-popup-record}/tsconfig.json (100%) create mode 100644 packages/plugins/rrweb-plugin-popup-record/vite.config.ts rename packages/plugins/{rrweb-plugin-dialog-record => rrweb-plugin-popup-record}/vitest.config.ts (100%) rename packages/plugins/{rrweb-plugin-dialog-replay => rrweb-plugin-popup-replay}/README.md (55%) rename packages/plugins/{rrweb-plugin-dialog-replay => rrweb-plugin-popup-replay}/package.json (72%) create mode 100644 packages/plugins/rrweb-plugin-popup-replay/src/index.ts rename packages/plugins/{rrweb-plugin-dialog-replay => rrweb-plugin-popup-replay}/test/index.test.ts (52%) rename packages/plugins/{rrweb-plugin-dialog-replay => rrweb-plugin-popup-replay}/tsconfig.json (86%) create mode 100644 packages/plugins/rrweb-plugin-popup-replay/vite.config.ts rename packages/plugins/{rrweb-plugin-dialog-replay => rrweb-plugin-popup-replay}/vitest.config.ts (81%) diff --git a/.changeset/dialog-plugin.md b/.changeset/dialog-plugin.md deleted file mode 100644 index ad00edd9de..0000000000 --- a/.changeset/dialog-plugin.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -'@rrweb/rrweb-plugin-dialog-record': minor -'@rrweb/rrweb-plugin-dialog-replay': minor ---- - -Add dialog plugin pair that records native `window.alert` / `window.confirm` / `window.prompt` dialogs (message, prompt default value, and user response) and surfaces them during replay via an `onDialog` callback. diff --git a/.changeset/popup-plugin.md b/.changeset/popup-plugin.md new file mode 100644 index 0000000000..8c45e96847 --- /dev/null +++ b/.changeset/popup-plugin.md @@ -0,0 +1,6 @@ +--- +'@rrweb/rrweb-plugin-popup-record': minor +'@rrweb/rrweb-plugin-popup-replay': minor +--- + +Add popup plugin pair that records native `window.alert` / `window.confirm` / `window.prompt` popups (message, prompt default value, and user response) and surfaces them during replay via an `onPopup` callback. diff --git a/packages/plugins/rrweb-plugin-dialog-record/vite.config.ts b/packages/plugins/rrweb-plugin-dialog-record/vite.config.ts deleted file mode 100644 index 5786e0e562..0000000000 --- a/packages/plugins/rrweb-plugin-dialog-record/vite.config.ts +++ /dev/null @@ -1,3 +0,0 @@ -import config from '../../../vite.config.default'; - -export default config('src/index.ts', 'rrwebPluginDialogRecord'); diff --git a/packages/plugins/rrweb-plugin-dialog-replay/src/index.ts b/packages/plugins/rrweb-plugin-dialog-replay/src/index.ts deleted file mode 100644 index 9d14578cbe..0000000000 --- a/packages/plugins/rrweb-plugin-dialog-replay/src/index.ts +++ /dev/null @@ -1,33 +0,0 @@ -import type { eventWithTime } from '@rrweb/types'; -import { EventType } from '@rrweb/types'; -import { PLUGIN_NAME } from '@rrweb/rrweb-plugin-dialog-record'; -import type { DialogData } from '@rrweb/rrweb-plugin-dialog-record'; - -export type { DialogData, DialogKind } from '@rrweb/rrweb-plugin-dialog-record'; - -type ReplayPlugin = { - handler?: (event: eventWithTime, isSync: boolean, context: unknown) => void; -}; - -export type OnDialog = (data: DialogData) => void; - -export type DialogReplayOptions = { - onDialog: OnDialog; -}; - -export const getReplayDialogPlugin: ( - options: DialogReplayOptions, -) => ReplayPlugin = (options) => { - return { - handler(event: eventWithTime) { - if ( - event.type === EventType.Plugin && - event.data.plugin === PLUGIN_NAME - ) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - const dialogData = event.data.payload as DialogData; - options.onDialog(dialogData); - } - }, - }; -}; diff --git a/packages/plugins/rrweb-plugin-dialog-replay/vite.config.ts b/packages/plugins/rrweb-plugin-dialog-replay/vite.config.ts deleted file mode 100644 index b38a3af6c6..0000000000 --- a/packages/plugins/rrweb-plugin-dialog-replay/vite.config.ts +++ /dev/null @@ -1,3 +0,0 @@ -import config from '../../../vite.config.default'; - -export default config('src/index.ts', 'rrwebPluginDialogReplay'); diff --git a/packages/plugins/rrweb-plugin-dialog-record/README.md b/packages/plugins/rrweb-plugin-popup-record/README.md similarity index 50% rename from packages/plugins/rrweb-plugin-dialog-record/README.md rename to packages/plugins/rrweb-plugin-popup-record/README.md index 7984ced0cb..8d11e5faea 100644 --- a/packages/plugins/rrweb-plugin-dialog-record/README.md +++ b/packages/plugins/rrweb-plugin-popup-record/README.md @@ -1,39 +1,39 @@ -# @rrweb/rrweb-plugin-dialog-record +# @rrweb/rrweb-plugin-popup-record -Records native browser dialogs — `window.alert`, `window.confirm`, and `window.prompt` — +Records native browser popups — `window.alert`, `window.confirm`, and `window.prompt` — that are otherwise invisible to rrweb because they are rendered as browser chrome outside the -DOM. The plugin captures the dialog message, `prompt`'s default value, and the user's +DOM. The plugin captures the popup message, `prompt`'s default value, and the user's response (the boolean from `confirm`, the string from `prompt`). -Pair it with [`@rrweb/rrweb-plugin-dialog-replay`](../rrweb-plugin-dialog-replay) to surface -the captured dialogs during replay. +Pair it with [`@rrweb/rrweb-plugin-popup-replay`](../rrweb-plugin-popup-replay) to surface +the captured popups during replay. See the [guide](../../../guide.md) for more info on rrweb. ## Installation ```sh -npm install @rrweb/rrweb-plugin-dialog-record +npm install @rrweb/rrweb-plugin-popup-record ``` ## Usage ```ts import { record } from 'rrweb'; -import { getRecordDialogPlugin } from '@rrweb/rrweb-plugin-dialog-record'; +import { getRecordPopupPlugin } from '@rrweb/rrweb-plugin-popup-record'; record({ emit(event) { // store the event }, - plugins: [getRecordDialogPlugin()], + plugins: [getRecordPopupPlugin()], }); ``` -Each captured dialog is emitted as an rrweb Plugin event with this payload shape: +Each captured popup is emitted as an rrweb Plugin event with this payload shape: ```ts -type DialogData = { +type PopupData = { kind: 'alert' | 'confirm' | 'prompt'; message: string; defaultValue?: string; // prompt's second argument, when provided @@ -41,27 +41,27 @@ type DialogData = { }; ``` -Note: because native dialogs are synchronous and blocking, the plugin calls through to the -real dialog first (so the page behaves exactly as before) and records the user's response +Note: because native popups are synchronous and blocking, the plugin calls through to the +real popup first (so the page behaves exactly as before) and records the user's response once they dismiss it. ## Options ```ts -getRecordDialogPlugin({ - // Which dialogs to hook. Defaults to all three. +getRecordPopupPlugin({ + // Which popups to hook. Defaults to all three. level: ['alert', 'confirm', 'prompt'], // Whether to record the user's response for confirm / prompt. Defaults to true. recordReturnValue: true, // Redact sensitive content just before it is recorded. - maskDialog: (data) => ({ ...data, message: '***', returnValue: '***' }), + maskPopup: (data) => ({ ...data, message: '***', returnValue: '***' }), }); ``` | Option | Type | Default | Description | | --- | --- | --- | --- | -| `level` | `('alert' \| 'confirm' \| 'prompt')[]` | all three | Which native dialogs to hook. | +| `level` | `('alert' \| 'confirm' \| 'prompt')[]` | all three | Which native popups to hook. | | `recordReturnValue` | `boolean` | `true` | Record the `confirm`/`prompt` response. `alert` never has a return value. | -| `maskDialog` | `(data: DialogData) => DialogData` | — | Transform the payload before it is emitted, e.g. to redact PII. | +| `maskPopup` | `(data: PopupData) => PopupData` | — | Transform the payload before it is emitted, e.g. to redact PII. | diff --git a/packages/plugins/rrweb-plugin-dialog-record/package.json b/packages/plugins/rrweb-plugin-popup-record/package.json similarity index 76% rename from packages/plugins/rrweb-plugin-dialog-record/package.json rename to packages/plugins/rrweb-plugin-popup-record/package.json index 4989d28731..d10f64127a 100644 --- a/packages/plugins/rrweb-plugin-dialog-record/package.json +++ b/packages/plugins/rrweb-plugin-popup-record/package.json @@ -1,22 +1,22 @@ { - "name": "@rrweb/rrweb-plugin-dialog-record", + "name": "@rrweb/rrweb-plugin-popup-record", "version": "2.1.0", "description": "", "type": "module", - "main": "./dist/rrweb-plugin-dialog-record.umd.cjs", - "module": "./dist/rrweb-plugin-dialog-record.js", - "unpkg": "./dist/rrweb-plugin-dialog-record.umd.cjs", - "jsdelivr": "./umd/rrweb-plugin-dialog-record.js", + "main": "./dist/rrweb-plugin-popup-record.umd.cjs", + "module": "./dist/rrweb-plugin-popup-record.js", + "unpkg": "./dist/rrweb-plugin-popup-record.umd.cjs", + "jsdelivr": "./umd/rrweb-plugin-popup-record.js", "typings": "dist/index.d.ts", "exports": { ".": { "import": { "types": "./dist/index.d.ts", - "default": "./dist/rrweb-plugin-dialog-record.js" + "default": "./dist/rrweb-plugin-popup-record.js" }, "require": { "types": "./dist/index.d.cts", - "default": "./dist/rrweb-plugin-dialog-record.umd.cjs" + "default": "./dist/rrweb-plugin-popup-record.umd.cjs" } } }, diff --git a/packages/plugins/rrweb-plugin-dialog-record/src/index.ts b/packages/plugins/rrweb-plugin-popup-record/src/index.ts similarity index 64% rename from packages/plugins/rrweb-plugin-dialog-record/src/index.ts rename to packages/plugins/rrweb-plugin-popup-record/src/index.ts index a6c2ded1b8..bc574cca98 100644 --- a/packages/plugins/rrweb-plugin-dialog-record/src/index.ts +++ b/packages/plugins/rrweb-plugin-popup-record/src/index.ts @@ -1,22 +1,22 @@ import type { listenerHandler, RecordPlugin, IWindow } from '@rrweb/types'; import { patch } from '@rrweb/utils'; -export const PLUGIN_NAME = 'rrweb/dialog@1'; +export const PLUGIN_NAME = 'rrweb/popup@1'; -export type DialogKind = 'alert' | 'confirm' | 'prompt'; +export type PopupKind = 'alert' | 'confirm' | 'prompt'; -export type DialogData = { - kind: DialogKind; +export type PopupData = { + kind: PopupKind; message: string; defaultValue?: string; returnValue?: boolean | string | null; }; -export type DialogRecordOptions = { +export type PopupRecordOptions = { /** - * Which native dialogs to hook. Defaults to all three. + * Which native popups to hook. Defaults to all three. */ - level?: DialogKind[]; + level?: PopupKind[]; /** * Whether to record the user's response for `confirm` / `prompt`. * `alert` never has a return value. Defaults to `true`. @@ -25,17 +25,17 @@ export type DialogRecordOptions = { /** * Redaction hook applied to the payload immediately before it is emitted. */ - maskDialog?: (data: DialogData) => DialogData; + maskPopup?: (data: PopupData) => PopupData; }; -type DialogCallback = (data: DialogData) => void; +type PopupCallback = (data: PopupData) => void; -const ALL_KINDS: DialogKind[] = ['alert', 'confirm', 'prompt']; +const ALL_KINDS: PopupKind[] = ['alert', 'confirm', 'prompt']; -function initDialogObserver( - cb: DialogCallback, +function initPopupObserver( + cb: PopupCallback, win: IWindow, - options: DialogRecordOptions, + options: PopupRecordOptions, ): listenerHandler { const kinds = options.level ?? ALL_KINDS; const recordReturnValue = options.recordReturnValue !== false; @@ -49,11 +49,11 @@ function initDialogObserver( (original) => { const originalFn = original as (...args: unknown[]) => unknown; return function (this: unknown, ...args: unknown[]) { - // Call through to the real (blocking) dialog first so we can + // Call through to the real (blocking) popup first so we can // capture the user's response for confirm / prompt. const returnValue = originalFn.apply(this, args); - let data: DialogData = { + let data: PopupData = { kind, message: String(args[0] ?? ''), }; @@ -63,8 +63,8 @@ function initDialogObserver( if (recordReturnValue && kind !== 'alert') { data.returnValue = returnValue as boolean | string | null; } - if (options.maskDialog) { - data = options.maskDialog(data); + if (options.maskPopup) { + data = options.maskPopup(data); } cb(data); @@ -80,10 +80,10 @@ function initDialogObserver( }; } -export const getRecordDialogPlugin: ( - options?: DialogRecordOptions, -) => RecordPlugin = (options) => ({ +export const getRecordPopupPlugin: ( + options?: PopupRecordOptions, +) => RecordPlugin = (options) => ({ name: PLUGIN_NAME, - observer: initDialogObserver as RecordPlugin['observer'], + observer: initPopupObserver as RecordPlugin['observer'], options: options ?? {}, }); diff --git a/packages/plugins/rrweb-plugin-dialog-record/test/index.test.ts b/packages/plugins/rrweb-plugin-popup-record/test/index.test.ts similarity index 84% rename from packages/plugins/rrweb-plugin-dialog-record/test/index.test.ts rename to packages/plugins/rrweb-plugin-popup-record/test/index.test.ts index 8e03a696df..29b13c7733 100644 --- a/packages/plugins/rrweb-plugin-dialog-record/test/index.test.ts +++ b/packages/plugins/rrweb-plugin-popup-record/test/index.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; -import { getRecordDialogPlugin, PLUGIN_NAME } from '../src'; -import type { DialogData } from '../src'; +import { getRecordPopupPlugin, PLUGIN_NAME } from '../src'; +import type { PopupData } from '../src'; type MockWindow = { alert: (message?: unknown) => void; @@ -19,22 +19,22 @@ function createMockWindow(overrides: Partial = {}): MockWindow { function run( win: MockWindow, - options: Parameters[0] = undefined, + options: Parameters[0] = undefined, ) { - const events: DialogData[] = []; - const plugin = getRecordDialogPlugin(options); + const events: PopupData[] = []; + const plugin = getRecordPopupPlugin(options); const cleanup = plugin.observer!( - (data: DialogData) => events.push(data), + (data: PopupData) => events.push(data), win as never, plugin.options, ); return { events, cleanup }; } -describe('rrweb-plugin-dialog-record', () => { +describe('rrweb-plugin-popup-record', () => { it('exposes the expected plugin name', () => { - expect(PLUGIN_NAME).toBe('rrweb/dialog@1'); - expect(getRecordDialogPlugin().name).toBe('rrweb/dialog@1'); + expect(PLUGIN_NAME).toBe('rrweb/popup@1'); + expect(getRecordPopupPlugin().name).toBe('rrweb/popup@1'); }); it('records an alert with its message and no returnValue', () => { @@ -104,10 +104,10 @@ describe('rrweb-plugin-dialog-record', () => { ]); }); - it('applies maskDialog before emitting', () => { + it('applies maskPopup before emitting', () => { const win = createMockWindow({ prompt: () => 'secret' }); const { events, cleanup } = run(win, { - maskDialog: (data) => ({ ...data, message: '***', returnValue: '***' }), + maskPopup: (data) => ({ ...data, message: '***', returnValue: '***' }), }); win.prompt('ssn?', '000'); @@ -137,7 +137,7 @@ describe('rrweb-plugin-dialog-record', () => { ]); }); - it('restores the original dialog functions on teardown', () => { + it('restores the original popup functions on teardown', () => { const originalAlert = vi.fn(); const win = createMockWindow({ alert: originalAlert }); const { cleanup } = run(win); diff --git a/packages/plugins/rrweb-plugin-dialog-record/tsconfig.json b/packages/plugins/rrweb-plugin-popup-record/tsconfig.json similarity index 100% rename from packages/plugins/rrweb-plugin-dialog-record/tsconfig.json rename to packages/plugins/rrweb-plugin-popup-record/tsconfig.json diff --git a/packages/plugins/rrweb-plugin-popup-record/vite.config.ts b/packages/plugins/rrweb-plugin-popup-record/vite.config.ts new file mode 100644 index 0000000000..28f38d4855 --- /dev/null +++ b/packages/plugins/rrweb-plugin-popup-record/vite.config.ts @@ -0,0 +1,3 @@ +import config from '../../../vite.config.default'; + +export default config('src/index.ts', 'rrwebPluginPopupRecord'); diff --git a/packages/plugins/rrweb-plugin-dialog-record/vitest.config.ts b/packages/plugins/rrweb-plugin-popup-record/vitest.config.ts similarity index 100% rename from packages/plugins/rrweb-plugin-dialog-record/vitest.config.ts rename to packages/plugins/rrweb-plugin-popup-record/vitest.config.ts diff --git a/packages/plugins/rrweb-plugin-dialog-replay/README.md b/packages/plugins/rrweb-plugin-popup-replay/README.md similarity index 55% rename from packages/plugins/rrweb-plugin-dialog-replay/README.md rename to packages/plugins/rrweb-plugin-popup-replay/README.md index fe9da79031..524e6fd035 100644 --- a/packages/plugins/rrweb-plugin-dialog-replay/README.md +++ b/packages/plugins/rrweb-plugin-popup-replay/README.md @@ -1,10 +1,10 @@ -# @rrweb/rrweb-plugin-dialog-replay +# @rrweb/rrweb-plugin-popup-replay -Consumes the native-dialog events captured by -[`@rrweb/rrweb-plugin-dialog-record`](../rrweb-plugin-dialog-record) during replay. +Consumes the native-popup events captured by +[`@rrweb/rrweb-plugin-popup-record`](../rrweb-plugin-popup-record) during replay. Native `alert` / `confirm` / `prompt` boxes are blocking browser chrome, so re-showing a real -one during replay would freeze the player. Instead, this plugin surfaces each recorded dialog +one during replay would freeze the player. Instead, this plugin surfaces each recorded popup to a callback you provide, so you can render it however you like (a custom overlay, a log line, a timeline marker, …). @@ -13,21 +13,21 @@ See the [guide](../../../guide.md) for more info on rrweb. ## Installation ```sh -npm install @rrweb/rrweb-plugin-dialog-replay +npm install @rrweb/rrweb-plugin-popup-replay ``` ## Usage ```ts import { Replayer } from 'rrweb'; -import { getReplayDialogPlugin } from '@rrweb/rrweb-plugin-dialog-replay'; +import { getReplayPopupPlugin } from '@rrweb/rrweb-plugin-popup-replay'; const replayer = new Replayer(events, { plugins: [ - getReplayDialogPlugin({ - onDialog(data) { + getReplayPopupPlugin({ + onPopup(data) { // data = { kind, message, defaultValue?, returnValue? } - console.log('dialog replayed', data); + console.log('popup replayed', data); }, }), ], @@ -38,4 +38,4 @@ const replayer = new Replayer(events, { | Option | Type | Description | | --- | --- | --- | -| `onDialog` | `(data: DialogData) => void` | Called for every recorded dialog as it is replayed. | +| `onPopup` | `(data: PopupData) => void` | Called for every recorded popup as it is replayed. | diff --git a/packages/plugins/rrweb-plugin-dialog-replay/package.json b/packages/plugins/rrweb-plugin-popup-replay/package.json similarity index 72% rename from packages/plugins/rrweb-plugin-dialog-replay/package.json rename to packages/plugins/rrweb-plugin-popup-replay/package.json index 11207a7304..4891befbb3 100644 --- a/packages/plugins/rrweb-plugin-dialog-replay/package.json +++ b/packages/plugins/rrweb-plugin-popup-replay/package.json @@ -1,22 +1,22 @@ { - "name": "@rrweb/rrweb-plugin-dialog-replay", + "name": "@rrweb/rrweb-plugin-popup-replay", "version": "2.1.0", "description": "", "type": "module", - "main": "./dist/rrweb-plugin-dialog-replay.umd.cjs", - "module": "./dist/rrweb-plugin-dialog-replay.js", - "unpkg": "./dist/rrweb-plugin-dialog-replay.umd.cjs", - "jsdelivr": "./umd/rrweb-plugin-dialog-replay.js", + "main": "./dist/rrweb-plugin-popup-replay.umd.cjs", + "module": "./dist/rrweb-plugin-popup-replay.js", + "unpkg": "./dist/rrweb-plugin-popup-replay.umd.cjs", + "jsdelivr": "./umd/rrweb-plugin-popup-replay.js", "typings": "dist/index.d.ts", "exports": { ".": { "import": { "types": "./dist/index.d.ts", - "default": "./dist/rrweb-plugin-dialog-replay.js" + "default": "./dist/rrweb-plugin-popup-replay.js" }, "require": { "types": "./dist/index.d.cts", - "default": "./dist/rrweb-plugin-dialog-replay.umd.cjs" + "default": "./dist/rrweb-plugin-popup-replay.umd.cjs" } } }, @@ -47,7 +47,7 @@ }, "homepage": "https://github.com/rrweb-io/rrweb#readme", "devDependencies": { - "@rrweb/rrweb-plugin-dialog-record": "^2.1.0", + "@rrweb/rrweb-plugin-popup-record": "^2.1.0", "@rrweb/types": "^2.1.0", "rrweb": "^2.1.0", "typescript": "^5.4.5", diff --git a/packages/plugins/rrweb-plugin-popup-replay/src/index.ts b/packages/plugins/rrweb-plugin-popup-replay/src/index.ts new file mode 100644 index 0000000000..fb9522fe80 --- /dev/null +++ b/packages/plugins/rrweb-plugin-popup-replay/src/index.ts @@ -0,0 +1,33 @@ +import type { eventWithTime } from '@rrweb/types'; +import { EventType } from '@rrweb/types'; +import { PLUGIN_NAME } from '@rrweb/rrweb-plugin-popup-record'; +import type { PopupData } from '@rrweb/rrweb-plugin-popup-record'; + +export type { PopupData, PopupKind } from '@rrweb/rrweb-plugin-popup-record'; + +type ReplayPlugin = { + handler?: (event: eventWithTime, isSync: boolean, context: unknown) => void; +}; + +export type OnPopup = (data: PopupData) => void; + +export type PopupReplayOptions = { + onPopup: OnPopup; +}; + +export const getReplayPopupPlugin: ( + options: PopupReplayOptions, +) => ReplayPlugin = (options) => { + return { + handler(event: eventWithTime) { + if ( + event.type === EventType.Plugin && + event.data.plugin === PLUGIN_NAME + ) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const popupData = event.data.payload as PopupData; + options.onPopup(popupData); + } + }, + }; +}; diff --git a/packages/plugins/rrweb-plugin-dialog-replay/test/index.test.ts b/packages/plugins/rrweb-plugin-popup-replay/test/index.test.ts similarity index 52% rename from packages/plugins/rrweb-plugin-dialog-replay/test/index.test.ts rename to packages/plugins/rrweb-plugin-popup-replay/test/index.test.ts index 13e139f854..f5e491c4ac 100644 --- a/packages/plugins/rrweb-plugin-dialog-replay/test/index.test.ts +++ b/packages/plugins/rrweb-plugin-popup-replay/test/index.test.ts @@ -1,9 +1,9 @@ import { describe, expect, it, vi } from 'vitest'; import { EventType } from '@rrweb/types'; import type { eventWithTime } from '@rrweb/types'; -import { PLUGIN_NAME } from '@rrweb/rrweb-plugin-dialog-record'; -import type { DialogData } from '@rrweb/rrweb-plugin-dialog-record'; -import { getReplayDialogPlugin } from '../src'; +import { PLUGIN_NAME } from '@rrweb/rrweb-plugin-popup-record'; +import type { PopupData } from '@rrweb/rrweb-plugin-popup-record'; +import { getReplayPopupPlugin } from '../src'; function pluginEvent(plugin: string, payload: unknown): eventWithTime { return { @@ -13,11 +13,11 @@ function pluginEvent(plugin: string, payload: unknown): eventWithTime { } as eventWithTime; } -describe('rrweb-plugin-dialog-replay', () => { - it('forwards a matching dialog plugin event to onDialog', () => { - const onDialog = vi.fn(); - const plugin = getReplayDialogPlugin({ onDialog }); - const payload: DialogData = { +describe('rrweb-plugin-popup-replay', () => { + it('forwards a matching popup plugin event to onPopup', () => { + const onPopup = vi.fn(); + const plugin = getReplayPopupPlugin({ onPopup }); + const payload: PopupData = { kind: 'confirm', message: 'sure?', returnValue: true, @@ -25,13 +25,13 @@ describe('rrweb-plugin-dialog-replay', () => { plugin.handler!(pluginEvent(PLUGIN_NAME, payload), false, {} as never); - expect(onDialog).toHaveBeenCalledTimes(1); - expect(onDialog).toHaveBeenCalledWith(payload); + expect(onPopup).toHaveBeenCalledTimes(1); + expect(onPopup).toHaveBeenCalledWith(payload); }); it('ignores plugin events from other plugins', () => { - const onDialog = vi.fn(); - const plugin = getReplayDialogPlugin({ onDialog }); + const onPopup = vi.fn(); + const plugin = getReplayPopupPlugin({ onPopup }); plugin.handler!( pluginEvent('rrweb/network@1', { some: 'data' }), @@ -39,12 +39,12 @@ describe('rrweb-plugin-dialog-replay', () => { {} as never, ); - expect(onDialog).not.toHaveBeenCalled(); + expect(onPopup).not.toHaveBeenCalled(); }); it('ignores non-plugin events', () => { - const onDialog = vi.fn(); - const plugin = getReplayDialogPlugin({ onDialog }); + const onPopup = vi.fn(); + const plugin = getReplayPopupPlugin({ onPopup }); plugin.handler!( { type: EventType.Meta, data: {}, timestamp: 0 } as eventWithTime, @@ -52,6 +52,6 @@ describe('rrweb-plugin-dialog-replay', () => { {} as never, ); - expect(onDialog).not.toHaveBeenCalled(); + expect(onPopup).not.toHaveBeenCalled(); }); }); diff --git a/packages/plugins/rrweb-plugin-dialog-replay/tsconfig.json b/packages/plugins/rrweb-plugin-popup-replay/tsconfig.json similarity index 86% rename from packages/plugins/rrweb-plugin-dialog-replay/tsconfig.json rename to packages/plugins/rrweb-plugin-popup-replay/tsconfig.json index f6cb2502b5..294aa7b2f1 100644 --- a/packages/plugins/rrweb-plugin-dialog-replay/tsconfig.json +++ b/packages/plugins/rrweb-plugin-popup-replay/tsconfig.json @@ -11,7 +11,7 @@ "path": "../../types" }, { - "path": "../rrweb-plugin-dialog-record" + "path": "../rrweb-plugin-popup-record" } ] } diff --git a/packages/plugins/rrweb-plugin-popup-replay/vite.config.ts b/packages/plugins/rrweb-plugin-popup-replay/vite.config.ts new file mode 100644 index 0000000000..8f9e4a1860 --- /dev/null +++ b/packages/plugins/rrweb-plugin-popup-replay/vite.config.ts @@ -0,0 +1,3 @@ +import config from '../../../vite.config.default'; + +export default config('src/index.ts', 'rrwebPluginPopupReplay'); diff --git a/packages/plugins/rrweb-plugin-dialog-replay/vitest.config.ts b/packages/plugins/rrweb-plugin-popup-replay/vitest.config.ts similarity index 81% rename from packages/plugins/rrweb-plugin-dialog-replay/vitest.config.ts rename to packages/plugins/rrweb-plugin-popup-replay/vitest.config.ts index 54bd621739..143a557ea0 100644 --- a/packages/plugins/rrweb-plugin-dialog-replay/vitest.config.ts +++ b/packages/plugins/rrweb-plugin-popup-replay/vitest.config.ts @@ -8,9 +8,9 @@ export default mergeConfig( defineProject({ resolve: { alias: { - '@rrweb/rrweb-plugin-dialog-record': resolve( + '@rrweb/rrweb-plugin-popup-record': resolve( __dirname, - '../rrweb-plugin-dialog-record/src', + '../rrweb-plugin-popup-record/src', ), '@rrweb/types': resolve(__dirname, '../../types/src'), '@rrweb/utils': resolve(__dirname, '../../utils/src'), From 697f1753bdcb39e2058f5caefa9c64fcf260d145 Mon Sep 17 00:00:00 2001 From: Shaoyu Meng Date: Mon, 13 Jul 2026 16:42:15 -0700 Subject: [PATCH 04/13] style(popup-record): use options: options, matching network plugin Drop `?? {}` from the factory to match the network plugin's style; normalize undefined options inside the observer instead, and type the factory return as RecordPlugin (as network does) so options may be undefined. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../plugins/rrweb-plugin-popup-record/src/index.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/plugins/rrweb-plugin-popup-record/src/index.ts b/packages/plugins/rrweb-plugin-popup-record/src/index.ts index bc574cca98..6eb6327140 100644 --- a/packages/plugins/rrweb-plugin-popup-record/src/index.ts +++ b/packages/plugins/rrweb-plugin-popup-record/src/index.ts @@ -37,8 +37,9 @@ function initPopupObserver( win: IWindow, options: PopupRecordOptions, ): listenerHandler { - const kinds = options.level ?? ALL_KINDS; - const recordReturnValue = options.recordReturnValue !== false; + const popupOptions = options || {}; + const kinds = popupOptions.level ?? ALL_KINDS; + const recordReturnValue = popupOptions.recordReturnValue !== false; const handlers: listenerHandler[] = []; for (const kind of kinds) { @@ -63,8 +64,8 @@ function initPopupObserver( if (recordReturnValue && kind !== 'alert') { data.returnValue = returnValue as boolean | string | null; } - if (options.maskPopup) { - data = options.maskPopup(data); + if (popupOptions.maskPopup) { + data = popupOptions.maskPopup(data); } cb(data); @@ -82,8 +83,8 @@ function initPopupObserver( export const getRecordPopupPlugin: ( options?: PopupRecordOptions, -) => RecordPlugin = (options) => ({ +) => RecordPlugin = (options) => ({ name: PLUGIN_NAME, observer: initPopupObserver as RecordPlugin['observer'], - options: options ?? {}, + options: options, }); From 92d89d2541fc31ca4b2066f09bbdea75055970e9 Mon Sep 17 00:00:00 2001 From: Shaoyu Meng Date: Mon, 13 Jul 2026 16:52:15 -0700 Subject: [PATCH 05/13] refactor(popup-record): rename `level` option to `kinds` `level` was a meaningless carry-over from the console plugin (where it denotes log severity). Here the option holds a PopupKind[] (alert / confirm / prompt), so `kinds` names it accurately. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/plugins/rrweb-plugin-popup-record/README.md | 4 ++-- packages/plugins/rrweb-plugin-popup-record/src/index.ts | 4 ++-- packages/plugins/rrweb-plugin-popup-record/test/index.test.ts | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/plugins/rrweb-plugin-popup-record/README.md b/packages/plugins/rrweb-plugin-popup-record/README.md index 8d11e5faea..e6c71286ab 100644 --- a/packages/plugins/rrweb-plugin-popup-record/README.md +++ b/packages/plugins/rrweb-plugin-popup-record/README.md @@ -50,7 +50,7 @@ once they dismiss it. ```ts getRecordPopupPlugin({ // Which popups to hook. Defaults to all three. - level: ['alert', 'confirm', 'prompt'], + kinds: ['alert', 'confirm', 'prompt'], // Whether to record the user's response for confirm / prompt. Defaults to true. recordReturnValue: true, @@ -62,6 +62,6 @@ getRecordPopupPlugin({ | Option | Type | Default | Description | | --- | --- | --- | --- | -| `level` | `('alert' \| 'confirm' \| 'prompt')[]` | all three | Which native popups to hook. | +| `kinds` | `('alert' \| 'confirm' \| 'prompt')[]` | all three | Which native popups to hook. | | `recordReturnValue` | `boolean` | `true` | Record the `confirm`/`prompt` response. `alert` never has a return value. | | `maskPopup` | `(data: PopupData) => PopupData` | — | Transform the payload before it is emitted, e.g. to redact PII. | diff --git a/packages/plugins/rrweb-plugin-popup-record/src/index.ts b/packages/plugins/rrweb-plugin-popup-record/src/index.ts index 6eb6327140..117c145223 100644 --- a/packages/plugins/rrweb-plugin-popup-record/src/index.ts +++ b/packages/plugins/rrweb-plugin-popup-record/src/index.ts @@ -16,7 +16,7 @@ export type PopupRecordOptions = { /** * Which native popups to hook. Defaults to all three. */ - level?: PopupKind[]; + kinds?: PopupKind[]; /** * Whether to record the user's response for `confirm` / `prompt`. * `alert` never has a return value. Defaults to `true`. @@ -38,7 +38,7 @@ function initPopupObserver( options: PopupRecordOptions, ): listenerHandler { const popupOptions = options || {}; - const kinds = popupOptions.level ?? ALL_KINDS; + const kinds = popupOptions.kinds ?? ALL_KINDS; const recordReturnValue = popupOptions.recordReturnValue !== false; const handlers: listenerHandler[] = []; diff --git a/packages/plugins/rrweb-plugin-popup-record/test/index.test.ts b/packages/plugins/rrweb-plugin-popup-record/test/index.test.ts index 29b13c7733..3c1db0da71 100644 --- a/packages/plugins/rrweb-plugin-popup-record/test/index.test.ts +++ b/packages/plugins/rrweb-plugin-popup-record/test/index.test.ts @@ -123,9 +123,9 @@ describe('rrweb-plugin-popup-record', () => { ]); }); - it('only patches the kinds listed in the level option', () => { + it('only patches the kinds listed in the kinds option', () => { const win = createMockWindow({ confirm: () => true }); - const { events, cleanup } = run(win, { level: ['confirm'] }); + const { events, cleanup } = run(win, { kinds: ['confirm'] }); win.alert('ignored'); win.confirm('kept'); From dc08b7623898d12e0c7b3ddb65ff600e767e7524 Mon Sep 17 00:00:00 2001 From: Shaoyu Meng Date: Mon, 13 Jul 2026 16:52:54 -0700 Subject: [PATCH 06/13] refactor(popup-record): rename `kinds` option to `popupKinds` Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/plugins/rrweb-plugin-popup-record/README.md | 4 ++-- packages/plugins/rrweb-plugin-popup-record/src/index.ts | 4 ++-- packages/plugins/rrweb-plugin-popup-record/test/index.test.ts | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/plugins/rrweb-plugin-popup-record/README.md b/packages/plugins/rrweb-plugin-popup-record/README.md index e6c71286ab..766c4f1d67 100644 --- a/packages/plugins/rrweb-plugin-popup-record/README.md +++ b/packages/plugins/rrweb-plugin-popup-record/README.md @@ -50,7 +50,7 @@ once they dismiss it. ```ts getRecordPopupPlugin({ // Which popups to hook. Defaults to all three. - kinds: ['alert', 'confirm', 'prompt'], + popupKinds: ['alert', 'confirm', 'prompt'], // Whether to record the user's response for confirm / prompt. Defaults to true. recordReturnValue: true, @@ -62,6 +62,6 @@ getRecordPopupPlugin({ | Option | Type | Default | Description | | --- | --- | --- | --- | -| `kinds` | `('alert' \| 'confirm' \| 'prompt')[]` | all three | Which native popups to hook. | +| `popupKinds` | `('alert' \| 'confirm' \| 'prompt')[]` | all three | Which native popups to hook. | | `recordReturnValue` | `boolean` | `true` | Record the `confirm`/`prompt` response. `alert` never has a return value. | | `maskPopup` | `(data: PopupData) => PopupData` | — | Transform the payload before it is emitted, e.g. to redact PII. | diff --git a/packages/plugins/rrweb-plugin-popup-record/src/index.ts b/packages/plugins/rrweb-plugin-popup-record/src/index.ts index 117c145223..86b25ad4e5 100644 --- a/packages/plugins/rrweb-plugin-popup-record/src/index.ts +++ b/packages/plugins/rrweb-plugin-popup-record/src/index.ts @@ -16,7 +16,7 @@ export type PopupRecordOptions = { /** * Which native popups to hook. Defaults to all three. */ - kinds?: PopupKind[]; + popupKinds?: PopupKind[]; /** * Whether to record the user's response for `confirm` / `prompt`. * `alert` never has a return value. Defaults to `true`. @@ -38,7 +38,7 @@ function initPopupObserver( options: PopupRecordOptions, ): listenerHandler { const popupOptions = options || {}; - const kinds = popupOptions.kinds ?? ALL_KINDS; + const kinds = popupOptions.popupKinds ?? ALL_KINDS; const recordReturnValue = popupOptions.recordReturnValue !== false; const handlers: listenerHandler[] = []; diff --git a/packages/plugins/rrweb-plugin-popup-record/test/index.test.ts b/packages/plugins/rrweb-plugin-popup-record/test/index.test.ts index 3c1db0da71..ce389918cc 100644 --- a/packages/plugins/rrweb-plugin-popup-record/test/index.test.ts +++ b/packages/plugins/rrweb-plugin-popup-record/test/index.test.ts @@ -123,9 +123,9 @@ describe('rrweb-plugin-popup-record', () => { ]); }); - it('only patches the kinds listed in the kinds option', () => { + it('only patches the kinds listed in the popupKinds option', () => { const win = createMockWindow({ confirm: () => true }); - const { events, cleanup } = run(win, { kinds: ['confirm'] }); + const { events, cleanup } = run(win, { popupKinds: ['confirm'] }); win.alert('ignored'); win.confirm('kept'); From 9cda9a0e8273eb886e26ee2f1191af2a4166a1a0 Mon Sep 17 00:00:00 2001 From: Shaoyu Meng Date: Mon, 13 Jul 2026 17:01:15 -0700 Subject: [PATCH 07/13] refactor(popup-record): fold recordReturnValue into maskPopupData recordReturnValue was redundant: dropping the response is just a special case of masking. Remove it, always capture returnValue, and rename maskPopup -> maskPopupData. Users redact or drop the return value via the mask hook; documented with an example. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../rrweb-plugin-popup-record/README.md | 19 ++++++++---- .../rrweb-plugin-popup-record/src/index.ts | 17 +++++------ .../test/index.test.ts | 30 +++++++++---------- 3 files changed, 34 insertions(+), 32 deletions(-) diff --git a/packages/plugins/rrweb-plugin-popup-record/README.md b/packages/plugins/rrweb-plugin-popup-record/README.md index 766c4f1d67..40fe7e9d0c 100644 --- a/packages/plugins/rrweb-plugin-popup-record/README.md +++ b/packages/plugins/rrweb-plugin-popup-record/README.md @@ -52,16 +52,23 @@ getRecordPopupPlugin({ // Which popups to hook. Defaults to all three. popupKinds: ['alert', 'confirm', 'prompt'], - // Whether to record the user's response for confirm / prompt. Defaults to true. - recordReturnValue: true, - // Redact sensitive content just before it is recorded. - maskPopup: (data) => ({ ...data, message: '***', returnValue: '***' }), + maskPopupData: (data) => ({ ...data, message: '***', returnValue: '***' }), }); ``` | Option | Type | Default | Description | | --- | --- | --- | --- | | `popupKinds` | `('alert' \| 'confirm' \| 'prompt')[]` | all three | Which native popups to hook. | -| `recordReturnValue` | `boolean` | `true` | Record the `confirm`/`prompt` response. `alert` never has a return value. | -| `maskPopup` | `(data: PopupData) => PopupData` | — | Transform the payload before it is emitted, e.g. to redact PII. | +| `maskPopupData` | `(data: PopupData) => PopupData` | — | Transform the payload before it is emitted, e.g. to redact PII. | + +Use `maskPopupData` to redact anything sensitive, including the user's response. +Return a modified copy to mask the `message`, or drop `returnValue` entirely to +avoid recording what the user typed into a `prompt` or answered in a `confirm`: + +```ts +getRecordPopupPlugin({ + // Record that a popup happened, but never store the user's response. + maskPopupData: ({ returnValue, ...rest }) => rest, +}); +``` diff --git a/packages/plugins/rrweb-plugin-popup-record/src/index.ts b/packages/plugins/rrweb-plugin-popup-record/src/index.ts index 86b25ad4e5..9cb9e06f80 100644 --- a/packages/plugins/rrweb-plugin-popup-record/src/index.ts +++ b/packages/plugins/rrweb-plugin-popup-record/src/index.ts @@ -17,15 +17,13 @@ export type PopupRecordOptions = { * Which native popups to hook. Defaults to all three. */ popupKinds?: PopupKind[]; - /** - * Whether to record the user's response for `confirm` / `prompt`. - * `alert` never has a return value. Defaults to `true`. - */ - recordReturnValue?: boolean; /** * Redaction hook applied to the payload immediately before it is emitted. + * Return a modified copy to redact sensitive content — e.g. mask the + * `message`, or drop `returnValue` to avoid recording what the user typed + * into a `prompt` or answered in a `confirm`. */ - maskPopup?: (data: PopupData) => PopupData; + maskPopupData?: (data: PopupData) => PopupData; }; type PopupCallback = (data: PopupData) => void; @@ -39,7 +37,6 @@ function initPopupObserver( ): listenerHandler { const popupOptions = options || {}; const kinds = popupOptions.popupKinds ?? ALL_KINDS; - const recordReturnValue = popupOptions.recordReturnValue !== false; const handlers: listenerHandler[] = []; for (const kind of kinds) { @@ -61,11 +58,11 @@ function initPopupObserver( if (kind === 'prompt' && args[1] != null) { data.defaultValue = String(args[1]); } - if (recordReturnValue && kind !== 'alert') { + if (kind !== 'alert') { data.returnValue = returnValue as boolean | string | null; } - if (popupOptions.maskPopup) { - data = popupOptions.maskPopup(data); + if (popupOptions.maskPopupData) { + data = popupOptions.maskPopupData(data); } cb(data); diff --git a/packages/plugins/rrweb-plugin-popup-record/test/index.test.ts b/packages/plugins/rrweb-plugin-popup-record/test/index.test.ts index ce389918cc..0188dff1f4 100644 --- a/packages/plugins/rrweb-plugin-popup-record/test/index.test.ts +++ b/packages/plugins/rrweb-plugin-popup-record/test/index.test.ts @@ -90,24 +90,10 @@ describe('rrweb-plugin-popup-record', () => { ]); }); - it('omits returnValue when recordReturnValue is false', () => { - const win = createMockWindow({ confirm: () => true, prompt: () => 'x' }); - const { events, cleanup } = run(win, { recordReturnValue: false }); - - win.confirm('ok?'); - win.prompt('name?'); - cleanup(); - - expect(events).toEqual([ - { kind: 'confirm', message: 'ok?' }, - { kind: 'prompt', message: 'name?' }, - ]); - }); - - it('applies maskPopup before emitting', () => { + it('applies maskPopupData before emitting', () => { const win = createMockWindow({ prompt: () => 'secret' }); const { events, cleanup } = run(win, { - maskPopup: (data) => ({ ...data, message: '***', returnValue: '***' }), + maskPopupData: (data) => ({ ...data, message: '***', returnValue: '***' }), }); win.prompt('ssn?', '000'); @@ -123,6 +109,18 @@ describe('rrweb-plugin-popup-record', () => { ]); }); + it('lets maskPopupData drop the return value', () => { + const win = createMockWindow({ prompt: () => 'secret' }); + const { events, cleanup } = run(win, { + maskPopupData: ({ returnValue, ...rest }) => rest, + }); + + win.prompt('name?'); + cleanup(); + + expect(events).toEqual([{ kind: 'prompt', message: 'name?' }]); + }); + it('only patches the kinds listed in the popupKinds option', () => { const win = createMockWindow({ confirm: () => true }); const { events, cleanup } = run(win, { popupKinds: ['confirm'] }); From beeea47efa58e86ce7b8f2da0655253f3e7899ec Mon Sep 17 00:00:00 2001 From: Shaoyu Meng Date: Mon, 13 Jul 2026 17:22:30 -0700 Subject: [PATCH 08/13] docs(popup): match plugin README format and add popup recipe Rewrite both popup plugin READMEs to the standard rrweb plugin format (pointer to the recipe + guide, followed by the shared sponsors/team block), matching rrweb-plugin-console-record and rrweb-plugin-network-record. Move the usage/options docs into docs/recipes/popup.md. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/recipes/popup.md | 104 +++++++ .../rrweb-plugin-popup-record/README.md | 278 +++++++++++++----- .../rrweb-plugin-popup-replay/README.md | 231 +++++++++++++-- 3 files changed, 513 insertions(+), 100 deletions(-) create mode 100644 docs/recipes/popup.md diff --git a/docs/recipes/popup.md b/docs/recipes/popup.md new file mode 100644 index 0000000000..7fb64bcd26 --- /dev/null +++ b/docs/recipes/popup.md @@ -0,0 +1,104 @@ +# Popup Recorder and Replayer + +Native browser popups — `window.alert`, `window.confirm`, and `window.prompt` — are rendered as browser chrome outside the DOM, so rrweb does not capture them by default. This plugin records them (the message, a `prompt`'s default value, and the user's response) and lets you surface them during replay. + +### Enable Recording Popups + +You can enable using the default options like this: + +```js +import { record } from '@rrweb/record'; +import { getRecordPopupPlugin } from '@rrweb/rrweb-plugin-popup-record'; + +record({ + emit: function emit(event) { + events.push(event); + }, + // to use default record options + plugins: [getRecordPopupPlugin()], +}); +``` + +Each captured popup is emitted as a Plugin event with the following payload: + +```ts +type PopupData = { + kind: 'alert' | 'confirm' | 'prompt'; + message: string; + defaultValue?: string; // prompt's second argument, when provided + returnValue?: boolean | string | null; // confirm → boolean, prompt → string | null; omitted for alert +}; +``` + +Because native popups are synchronous and blocking, the plugin calls through to the real popup first (so the page behaves exactly as before) and records the user's response once they dismiss it. + +You can also customize the behavior like this: + +```js +import { record } from '@rrweb/record'; +import { getRecordPopupPlugin } from '@rrweb/rrweb-plugin-popup-record'; + +record({ + emit: function emit(event) { + events.push(event); + }, + // customized record options + plugins: [ + getRecordPopupPlugin({ + // only hook confirm and prompt, ignore alert + popupKinds: ['confirm', 'prompt'], + // redact sensitive content before it is recorded + maskPopupData: (data) => { + return { ...data, message: maskTextFn(data.message) }; + }, + }), + ], +}); +``` + +Use `maskPopupData` to redact anything sensitive, **including the user's response**. Return a modified copy to mask the `message`, or drop `returnValue` entirely to avoid recording what the user typed into a `prompt` or answered in a `confirm`: + +```js +getRecordPopupPlugin({ + // record that a popup happened, but never store the user's response + maskPopupData: ({ returnValue, ...rest }) => rest, +}); +``` + +All options are described below: + +| key | default | description | +| ------------- | -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | +| popupKinds | `['alert', 'confirm', 'prompt']` | Which native popups to hook. Override it to record only a subset. | +| maskPopupData | `(data) => data` | Transform the payload before it is emitted, e.g. to mask the `message` or drop the `returnValue` to hide the user's response. | + +## Replay Popups + +A native popup is a blocking browser dialog, so re-showing a real one during replay would freeze the player. Instead it is up to you to decide how to best replay your popup events using the `onPopup` callback (e.g. render a custom overlay, log a line, or add a timeline marker). + +```js +import rrweb from 'rrweb'; +import { getReplayPopupPlugin } from '@rrweb/rrweb-plugin-popup-replay'; + +const replayer = new rrweb.Replayer(events, { + plugins: [ + getReplayPopupPlugin({ + onPopup: (data) => { + const { kind, message, returnValue } = data; + console.log(`${kind}: ${message} -> ${returnValue}`); + }, + }), + ], +}); +replayer.play(); +``` + +Description of the replay option is as follows: + +| key | default | description | +| ------- | ----------- | ------------------------------------------------------------------ | +| onPopup | `undefined` | Called for every recorded popup as it is replayed, with `PopupData`. | + +## Technical Implementation + +This implementation records native popups by patching [`window.alert`](https://developer.mozilla.org/en-US/docs/Web/API/Window/alert), [`window.confirm`](https://developer.mozilla.org/en-US/docs/Web/API/Window/confirm), and [`window.prompt`](https://developer.mozilla.org/en-US/docs/Web/API/Window/prompt). Since these functions are synchronous and blocking, the patched wrapper calls through to the original popup first and captures its return value (the user's response) before emitting the event. On teardown the original functions are restored. diff --git a/packages/plugins/rrweb-plugin-popup-record/README.md b/packages/plugins/rrweb-plugin-popup-record/README.md index 40fe7e9d0c..331f397803 100644 --- a/packages/plugins/rrweb-plugin-popup-record/README.md +++ b/packages/plugins/rrweb-plugin-popup-record/README.md @@ -1,74 +1,212 @@ # @rrweb/rrweb-plugin-popup-record -Records native browser popups — `window.alert`, `window.confirm`, and `window.prompt` — -that are otherwise invisible to rrweb because they are rendered as browser chrome outside the -DOM. The plugin captures the popup message, `prompt`'s default value, and the user's -response (the boolean from `confirm`, the string from `prompt`). - -Pair it with [`@rrweb/rrweb-plugin-popup-replay`](../rrweb-plugin-popup-replay) to surface -the captured popups during replay. - +Please refer to the [popup recipe](../../../docs/recipes/popup.md) on how to use this plugin. See the [guide](../../../guide.md) for more info on rrweb. -## Installation - -```sh -npm install @rrweb/rrweb-plugin-popup-record -``` - -## Usage - -```ts -import { record } from 'rrweb'; -import { getRecordPopupPlugin } from '@rrweb/rrweb-plugin-popup-record'; - -record({ - emit(event) { - // store the event - }, - plugins: [getRecordPopupPlugin()], -}); -``` - -Each captured popup is emitted as an rrweb Plugin event with this payload shape: - -```ts -type PopupData = { - kind: 'alert' | 'confirm' | 'prompt'; - message: string; - defaultValue?: string; // prompt's second argument, when provided - returnValue?: boolean | string | null; // confirm → boolean, prompt → string | null; omitted for alert -}; -``` - -Note: because native popups are synchronous and blocking, the plugin calls through to the -real popup first (so the page behaves exactly as before) and records the user's response -once they dismiss it. - -## Options - -```ts -getRecordPopupPlugin({ - // Which popups to hook. Defaults to all three. - popupKinds: ['alert', 'confirm', 'prompt'], - - // Redact sensitive content just before it is recorded. - maskPopupData: (data) => ({ ...data, message: '***', returnValue: '***' }), -}); -``` - -| Option | Type | Default | Description | -| --- | --- | --- | --- | -| `popupKinds` | `('alert' \| 'confirm' \| 'prompt')[]` | all three | Which native popups to hook. | -| `maskPopupData` | `(data: PopupData) => PopupData` | — | Transform the payload before it is emitted, e.g. to redact PII. | - -Use `maskPopupData` to redact anything sensitive, including the user's response. -Return a modified copy to mask the `message`, or drop `returnValue` entirely to -avoid recording what the user typed into a `prompt` or answered in a `confirm`: - -```ts -getRecordPopupPlugin({ - // Record that a popup happened, but never store the user's response. - maskPopupData: ({ returnValue, ...rest }) => rest, -}); -``` +## Sponsors + +[Become a sponsor](https://opencollective.com/rrweb#sponsor) and get your logo on our README on Github with a link to your site. + +### Gold Sponsors 🥇 + +
+ +sponsor +sponsor +sponsor +sponsor +sponsor +sponsor +sponsor + +
+ +### Silver Sponsors 🥈 + +
+ +sponsor +sponsor +sponsor +sponsor +sponsor +sponsor +sponsor + +
+ +### Bronze Sponsors 🥉 + +
+ +sponsor +sponsor +sponsor +sponsor +sponsor +sponsor +sponsor +sponsor +sponsor + +
+ +### Backers + + + +## Core Team Members + + + + + + + + +
+ + +
Yuyz0112 +

+
+
+ + +
Yun Feng +

+
+
+ + +
eoghanmurray +

+
+
+ + +
Juice10 +
open for rrweb consulting +
+
+ +## Who's using rrweb? + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + SmartX + + + + PostHog + + + + + + + + Smart screen recording for SaaS + +
+ + Sentry + + + + Pendo + + + + Mixpanel + + + + Datadog + +
+ + Amplitude + + + + New Relic + + + + The first ever UX automation tool + + + + Remote Access & Co-Browsing + +
+ + The open source, fullstack Monitoring Platform. + + + + Comprehensive data analytics platform that empowers businesses to gain valuable insights and make data-driven decisions. + + + + Intercept, Modify, Record & Replay HTTP Requests. + + + + In-app bug reporting & customer feedback platform. + +
+ + Self-hosted website analytics with heatmaps and session recordings. + + + + Interactive product demos for small marketing teams + +
diff --git a/packages/plugins/rrweb-plugin-popup-replay/README.md b/packages/plugins/rrweb-plugin-popup-replay/README.md index 524e6fd035..9989dac76b 100644 --- a/packages/plugins/rrweb-plugin-popup-replay/README.md +++ b/packages/plugins/rrweb-plugin-popup-replay/README.md @@ -1,41 +1,212 @@ # @rrweb/rrweb-plugin-popup-replay -Consumes the native-popup events captured by -[`@rrweb/rrweb-plugin-popup-record`](../rrweb-plugin-popup-record) during replay. +Please refer to the [popup recipe](../../../docs/recipes/popup.md) on how to use this plugin. +See the [guide](../../../guide.md) for more info on rrweb. -Native `alert` / `confirm` / `prompt` boxes are blocking browser chrome, so re-showing a real -one during replay would freeze the player. Instead, this plugin surfaces each recorded popup -to a callback you provide, so you can render it however you like (a custom overlay, a log -line, a timeline marker, …). +## Sponsors -See the [guide](../../../guide.md) for more info on rrweb. +[Become a sponsor](https://opencollective.com/rrweb#sponsor) and get your logo on our README on Github with a link to your site. + +### Gold Sponsors 🥇 + +
+ +sponsor +sponsor +sponsor +sponsor +sponsor +sponsor +sponsor + +
+ +### Silver Sponsors 🥈 + +
+ +sponsor +sponsor +sponsor +sponsor +sponsor +sponsor +sponsor + +
+ +### Bronze Sponsors 🥉 + +
+ +sponsor +sponsor +sponsor +sponsor +sponsor +sponsor +sponsor +sponsor +sponsor -## Installation +
-```sh -npm install @rrweb/rrweb-plugin-popup-replay -``` +### Backers -## Usage + -```ts -import { Replayer } from 'rrweb'; -import { getReplayPopupPlugin } from '@rrweb/rrweb-plugin-popup-replay'; +## Core Team Members -const replayer = new Replayer(events, { - plugins: [ - getReplayPopupPlugin({ - onPopup(data) { - // data = { kind, message, defaultValue?, returnValue? } - console.log('popup replayed', data); - }, - }), - ], -}); -``` + + + + + + + +
+ + +
Yuyz0112 +

+
+
+ + +
Yun Feng +

+
+
+ + +
eoghanmurray +

+
+
+ + +
Juice10 +
open for rrweb consulting +
+
-## Options +## Who's using rrweb? -| Option | Type | Description | -| --- | --- | --- | -| `onPopup` | `(data: PopupData) => void` | Called for every recorded popup as it is replayed. | + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + SmartX + + + + PostHog + + + + + + + + Smart screen recording for SaaS + +
+ + Sentry + + + + Pendo + + + + Mixpanel + + + + Datadog + +
+ + Amplitude + + + + New Relic + + + + The first ever UX automation tool + + + + Remote Access & Co-Browsing + +
+ + The open source, fullstack Monitoring Platform. + + + + Comprehensive data analytics platform that empowers businesses to gain valuable insights and make data-driven decisions. + + + + Intercept, Modify, Record & Replay HTTP Requests. + + + + In-app bug reporting & customer feedback platform. + +
+ + Self-hosted website analytics with heatmaps and session recordings. + + + + Interactive product demos for small marketing teams + +
From 49588666d671778946b4b9124b955cb0c8bbae1c Mon Sep 17 00:00:00 2001 From: Shaoyu Meng Date: Mon, 13 Jul 2026 18:13:31 -0700 Subject: [PATCH 09/13] refactor(popup-record): bind popup call to win, drop this param The patched popup's receiver is always the recorded window, so apply to `win` directly and use an arrow function instead of capturing the caller's `this`. Removes the `this: unknown` annotation and is slightly more robust (correct Window receiver even inside iframes). Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/plugins/rrweb-plugin-popup-record/src/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/plugins/rrweb-plugin-popup-record/src/index.ts b/packages/plugins/rrweb-plugin-popup-record/src/index.ts index 9cb9e06f80..bc8a38a33b 100644 --- a/packages/plugins/rrweb-plugin-popup-record/src/index.ts +++ b/packages/plugins/rrweb-plugin-popup-record/src/index.ts @@ -46,10 +46,10 @@ function initPopupObserver( kind, (original) => { const originalFn = original as (...args: unknown[]) => unknown; - return function (this: unknown, ...args: unknown[]) { + return (...args: unknown[]) => { // Call through to the real (blocking) popup first so we can // capture the user's response for confirm / prompt. - const returnValue = originalFn.apply(this, args); + const returnValue = originalFn.apply(win, args); let data: PopupData = { kind, From d23d96075f19f23e108517e2d1b6567cede09f2c Mon Sep 17 00:00:00 2001 From: Shaoyu Meng Date: Mon, 13 Jul 2026 18:15:38 -0700 Subject: [PATCH 10/13] refactor(popup-record): drop unnecessary win cast in patch call IWindow (= Window & typeof globalThis) already satisfies patch()'s { [key: string]: any } source parameter, as network-record does with patch(win, 'fetch', ...). Pass win directly. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/plugins/rrweb-plugin-popup-record/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/plugins/rrweb-plugin-popup-record/src/index.ts b/packages/plugins/rrweb-plugin-popup-record/src/index.ts index bc8a38a33b..16f785e0ee 100644 --- a/packages/plugins/rrweb-plugin-popup-record/src/index.ts +++ b/packages/plugins/rrweb-plugin-popup-record/src/index.ts @@ -42,7 +42,7 @@ function initPopupObserver( for (const kind of kinds) { handlers.push( patch( - win as unknown as Record, + win, kind, (original) => { const originalFn = original as (...args: unknown[]) => unknown; From 67253581cb937f29b33dc53ed3eb0501cd9ae21a Mon Sep 17 00:00:00 2001 From: Shaoyu Meng Date: Mon, 13 Jul 2026 18:23:01 -0700 Subject: [PATCH 11/13] refactor(popup-record): mark observer options optional, drop intermediate options is undefined at runtime when the plugin is created with no args (getRecordPopupPlugin() -> factory passes options: options). Type the observer param as optional and read via optional chaining instead of the `options || {}` intermediate, so the signature is honest and the direct access is safe. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/plugins/rrweb-plugin-popup-record/src/index.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/plugins/rrweb-plugin-popup-record/src/index.ts b/packages/plugins/rrweb-plugin-popup-record/src/index.ts index 16f785e0ee..0cdd992732 100644 --- a/packages/plugins/rrweb-plugin-popup-record/src/index.ts +++ b/packages/plugins/rrweb-plugin-popup-record/src/index.ts @@ -33,10 +33,9 @@ const ALL_KINDS: PopupKind[] = ['alert', 'confirm', 'prompt']; function initPopupObserver( cb: PopupCallback, win: IWindow, - options: PopupRecordOptions, + options?: PopupRecordOptions, ): listenerHandler { - const popupOptions = options || {}; - const kinds = popupOptions.popupKinds ?? ALL_KINDS; + const kinds = options?.popupKinds ?? ALL_KINDS; const handlers: listenerHandler[] = []; for (const kind of kinds) { @@ -61,8 +60,8 @@ function initPopupObserver( if (kind !== 'alert') { data.returnValue = returnValue as boolean | string | null; } - if (popupOptions.maskPopupData) { - data = popupOptions.maskPopupData(data); + if (options?.maskPopupData) { + data = options.maskPopupData(data); } cb(data); From d6d4c77a382c770a0b2acae02cef30bb78f40abf Mon Sep 17 00:00:00 2001 From: Shaoyu Meng Date: Mon, 13 Jul 2026 18:57:50 -0700 Subject: [PATCH 12/13] refactor(popup-replay): rename OnPopup type to OnPopupData Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/plugins/rrweb-plugin-popup-replay/src/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/plugins/rrweb-plugin-popup-replay/src/index.ts b/packages/plugins/rrweb-plugin-popup-replay/src/index.ts index fb9522fe80..2422a05737 100644 --- a/packages/plugins/rrweb-plugin-popup-replay/src/index.ts +++ b/packages/plugins/rrweb-plugin-popup-replay/src/index.ts @@ -9,10 +9,10 @@ type ReplayPlugin = { handler?: (event: eventWithTime, isSync: boolean, context: unknown) => void; }; -export type OnPopup = (data: PopupData) => void; +export type OnPopupData = (data: PopupData) => void; export type PopupReplayOptions = { - onPopup: OnPopup; + onPopup: OnPopupData; }; export const getReplayPopupPlugin: ( From 189d2a6b0cf7e2cfafc8ea09204e74eedd7279e3 Mon Sep 17 00:00:00 2001 From: Shaoyu Meng Date: Mon, 13 Jul 2026 18:59:27 -0700 Subject: [PATCH 13/13] docs: remove dialog-plugin design spec Design is finalized and implemented; the point-in-time spec (with its pre-rename dialog naming) is no longer needed. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../specs/2026-07-13-dialog-plugin-design.md | 179 ------------------ 1 file changed, 179 deletions(-) delete mode 100644 docs/superpowers/specs/2026-07-13-dialog-plugin-design.md diff --git a/docs/superpowers/specs/2026-07-13-dialog-plugin-design.md b/docs/superpowers/specs/2026-07-13-dialog-plugin-design.md deleted file mode 100644 index ab0cc615d2..0000000000 --- a/docs/superpowers/specs/2026-07-13-dialog-plugin-design.md +++ /dev/null @@ -1,179 +0,0 @@ -# Dialog Plugin Design — recording `alert` / `confirm` / `prompt` - -Date: 2026-07-13 - -## Problem - -rrweb records the DOM (initial snapshot + `MutationObserver` + event listeners), so -DOM-based popups (``, custom modal `
`s) are captured and replayed. But the -native, blocking browser dialogs — `window.alert`, `window.confirm`, `window.prompt` — are -rendered as browser chrome outside the DOM. rrweb has no hook for them today, so neither the -fact that a dialog appeared nor the user's response is recorded. - -## Goal - -A record/replay plugin **pair** (mirroring the console and network plugins) that captures -native `alert` / `confirm` / `prompt` calls — including the message, `prompt`'s default -value, and the user's response — and surfaces them during replay via a callback. - -Because these functions are **synchronous and blocking**, a monkey-patch can call through to -the real dialog, capture its return value after the user responds, and emit an rrweb Plugin -event. No async plumbing is required. - -## Non-goals - -- Re-showing a real blocking native dialog during replay (would freeze the replayer UI). -- A built-in visual overlay renderer for replay. Replay is **callback-only**; the consumer - decides how to surface the data. (An overlay could be added later as an opt-in without - breaking this design.) -- Capturing dialogs from other sources (e.g. `beforeunload` confirmation, `print`). - -## Packages - -Two new packages under `packages/plugins/`, lockstep-versioned at `2.1.0`, following the -existing plugin conventions (Vite library build, `@rrweb/types` + `@rrweb/utils` as -peer + dev deps, turbo `prepublish`): - -- `@rrweb/rrweb-plugin-dialog-record` -- `@rrweb/rrweb-plugin-dialog-replay` — dev-depends on the record twin to import - `PLUGIN_NAME` and the shared `DialogData` type. - -Shared identifier, exported from the record package: - -```ts -export const PLUGIN_NAME = 'rrweb/dialog@1'; -``` - -Factory naming follows convention: `getRecordDialogPlugin(options?)` and -`getReplayDialogPlugin(options)`. - -## Data model - -```ts -export type DialogKind = 'alert' | 'confirm' | 'prompt'; - -export type DialogData = { - kind: DialogKind; - message: string; // 1st arg to alert/confirm/prompt (coerced to string) - defaultValue?: string; // prompt's 2nd arg; present only for kind === 'prompt' when supplied - returnValue?: boolean | string | null; // confirm→boolean, prompt→string|null; omitted for alert or when disabled -}; -``` - -rrweb wraps whatever the observer passes to `cb()` into: - -```ts -{ type: EventType.Plugin, data: { plugin: 'rrweb/dialog@1', payload: DialogData } } -``` - -with rrweb's own event timestamp, so the payload carries **no** custom timing field. - -## Record plugin (`rrweb-plugin-dialog-record`) - -### Options - -```ts -export type DialogRecordOptions = { - level?: DialogKind[]; // which dialogs to hook; default ['alert', 'confirm', 'prompt'] - recordReturnValue?: boolean; // capture confirm/prompt result; default true - maskDialog?: (data: DialogData) => DialogData; // redaction hook applied just before emit -}; -``` - -### Observer behavior - -For each kind in `level`, patch `win[kind]` using `patch()` from `@rrweb/utils` (stores the -original under a non-enumerable `__rrweb_original__` and returns a restore function). - -The wrapper, on each call: - -1. Calls the **original** dialog first (`original.apply(this, args)`). This blocks until the - user responds, which is exactly how we obtain the user's answer for `confirm`/`prompt`. -2. Builds `DialogData`: - - `kind` = the patched kind. - - `message` = `String(args[0] ?? '')`. - - `defaultValue` = for `prompt` only, `String(args[1])` when `args[1] != null`. - - `returnValue` = the value from step 1, included only when - `recordReturnValue !== false` **and** `kind !== 'alert'`. -3. If `maskDialog` is provided, replaces `data` with `maskDialog(data)`. -4. Emits via `cb(data)`. -5. Returns the original return value to the calling page (transparent passthrough). - -The observer returns a teardown that restores every patched method. - -Runs per-window via rrweb's existing plugin-per-window wiring, so same-origin iframes and the -top window are each patched. - -### Correctness points - -- `alert` → `returnValue` omitted (native returns `undefined`). -- `confirm` → `returnValue` is a `boolean`. -- `prompt` → `returnValue` is a `string`, or `null` when the user cancels. -- `recordReturnValue: false` → omit `returnValue` for `confirm`/`prompt`. -- `level` subset → only listed kinds are patched; others are untouched. -- Passthrough return preserves page behavior exactly. -- Idempotent teardown restores the exact original references. - -### Type note - -Like network-record, the typed observer signature (`(cb: (data: DialogData) => void, win, -options) => listenerHandler`) is narrower than `RecordPlugin['observer']`, so the factory -casts `observer: initDialogObserver as RecordPlugin['observer']`. - -## Replay plugin (`rrweb-plugin-dialog-replay`) - -### Options - -```ts -export type DialogReplayOptions = { - onDialog: (data: DialogData) => void; -}; -``` - -### Handler behavior - -```ts -export const getReplayDialogPlugin: (options: DialogReplayOptions) => ReplayPlugin = - (options) => ({ - handler(event) { - if (event.type === EventType.Plugin && event.data.plugin === PLUGIN_NAME) { - options.onDialog(event.data.payload as DialogData); - } - }, - }); -``` - -Mirrors network-replay exactly — filter by plugin name, hand the payload to the consumer. - -## Testing - -Primary pattern: Vitest unit tests with a mock window (network-record's approach), which is a -clean fit since native dialogs are trivially stubbed. - -For record: -- Stub `win.alert` / `win.confirm` / `win.prompt` on a mock window (with controllable return - values). -- Call `plugin.observer(captureCb, mockWin, plugin.options)`, invoke the dialogs, assert the - captured `DialogData[]`, then call teardown and assert the originals are restored. -- Cases: `alert` (no `returnValue`); `confirm` true/false; `prompt` returns typed string; - `prompt` cancel → `null`; `prompt` `defaultValue`; `recordReturnValue: false` omits - `returnValue`; `maskDialog` transforms the payload; `level` subset patches only the listed - kinds; passthrough returns the original value to the caller. - -For replay: -- Feed a synthetic `EventType.Plugin` event with `plugin === PLUGIN_NAME` and assert - `onDialog` receives the payload; feed an unrelated event and assert it is ignored. - -## Tooling / files per package - -Copied from the network plugin templates: - -- `rrweb-plugin-dialog-record/`: `package.json`, `tsconfig.json`, `vite.config.ts`, - `vitest.config.ts`, `src/index.ts`, `test/index.test.ts`, `README.md`. -- `rrweb-plugin-dialog-replay/`: `package.json`, `tsconfig.json`, `vite.config.ts`, - `src/index.ts`, `README.md`. - -`vite.config.ts` uses the shared `vite.config.default` factory with entry `src/index.ts` and -UMD names `rrwebPluginDialogRecord` / `rrwebPluginDialogReplay`. `package.json` names, -exports, scripts, and peer/dev deps mirror the network plugin at version `2.1.0`. A changeset -should be added for the two new packages.