Add userTriggered (#495)

* add `userTriggered`

* update snapshots to add userTriggered

* add `userTriggered`

* update snapshots to add userTriggered

* update snapshot to include userTrigger

* only set userTriggered on `userTriggeredOnInput: true`

* What is user triggered?

* correct snapshot

* add second radio to demonstrate userTriggered
This commit is contained in:
Justin Halsall
2026-04-01 12:00:00 +08:00
committed by GitHub
parent a224fdd903
commit 9df2aa8e0e
9 changed files with 1541 additions and 470 deletions

View File

@@ -136,7 +136,7 @@ setInterval(save, 10 * 1000);
The parameter of `rrweb.record` accepts the following options. The parameter of `rrweb.record` accepts the following options.
| key | default | description | | key | default | description |
| ---------------- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | -------------------- | ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| emit | required | the callback function to get emitted events | | emit | required | the callback function to get emitted events |
| checkoutEveryNth | - | take a full snapshot after every N events<br />refer to the [checkout](#checkout) chapter | | checkoutEveryNth | - | take a full snapshot after every N events<br />refer to the [checkout](#checkout) chapter |
| checkoutEveryNms | - | take a full snapshot after every N ms<br />refer to the [checkout](#checkout) chapter | | checkoutEveryNms | - | take a full snapshot after every N ms<br />refer to the [checkout](#checkout) chapter |
@@ -157,6 +157,7 @@ The parameter of `rrweb.record` accepts the following options.
| recordCanvas | false | whether to record the canvas element | | recordCanvas | false | whether to record the canvas element |
| collectFonts | false | whether to collect fonts in the website | | collectFonts | false | whether to collect fonts in the website |
| recordLog | false | whether to record console output, refer to the [console recipe](./docs/recipes/console.md) | | recordLog | false | whether to record console output, refer to the [console recipe](./docs/recipes/console.md) |
| userTriggeredOnInput | false | whether to add `userTriggered` on input events that indicates if this event was triggered directly by the user or not. [What is `userTriggered`?](https://github.com/rrweb-io/rrweb/pull/495) |
#### Privacy #### Privacy

View File

@@ -132,7 +132,7 @@ setInterval(save, 10 * 1000);
`rrweb.record(config)` 的 config 部分接受以下参数 `rrweb.record(config)` 的 config 部分接受以下参数
| key | 默认值 | 功能 | | key | 默认值 | 功能 |
| ---------------- | ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | -------------------- | ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| emit | 必填 | 获取当前录制的数据 | | emit | 必填 | 获取当前录制的数据 |
| checkoutEveryNth | - | 每 N 次事件重新制作一次全量快照<br />详见[“重新制作快照”](#重新制作快照)章节 | | checkoutEveryNth | - | 每 N 次事件重新制作一次全量快照<br />详见[“重新制作快照”](#重新制作快照)章节 |
| checkoutEveryNms | - | 每 N 毫秒重新制作一次全量快照<br />详见[“重新制作快照”](#重新制作快照)章节 | | checkoutEveryNms | - | 每 N 毫秒重新制作一次全量快照<br />详见[“重新制作快照”](#重新制作快照)章节 |
@@ -153,6 +153,7 @@ setInterval(save, 10 * 1000);
| recordCanvas | false | 是否记录 canvas 内容 | | recordCanvas | false | 是否记录 canvas 内容 |
| collectFonts | false | 是否记录页面中的字体文件 | | collectFonts | false | 是否记录页面中的字体文件 |
| recordLog | false | 是否记录 console 输出,详见[console 录制和播放](./docs/recipes/console.zh_CN.md) | | recordLog | false | 是否记录 console 输出,详见[console 录制和播放](./docs/recipes/console.zh_CN.md) |
| userTriggeredOnInput | false | [什么是 `userTriggered`](https://github.com/rrweb-io/rrweb/pull/495) |
#### 隐私 #### 隐私

View File

@@ -57,6 +57,7 @@ function record<T = eventWithTime>(
sampling = {}, sampling = {},
mousemoveWait, mousemoveWait,
recordCanvas = false, recordCanvas = false,
userTriggeredOnInput = false,
collectFonts = false, collectFonts = false,
plugins, plugins,
keepIframeSrcFn = () => false, keepIframeSrcFn = () => false,
@@ -379,6 +380,7 @@ function record<T = eventWithTime>(
inlineStylesheet, inlineStylesheet,
sampling, sampling,
recordCanvas, recordCanvas,
userTriggeredOnInput,
collectFonts, collectFonts,
doc, doc,
maskInputFn, maskInputFn,

View File

@@ -337,6 +337,15 @@ function initViewportResizeObserver(
return on('resize', updateDimension, window); return on('resize', updateDimension, window);
} }
function wrapEventWithUserTriggeredFlag(
v: inputValue,
enable: boolean,
): inputValue {
const value = { ...v };
if (!enable) delete value.userTriggered;
return value;
}
export const INPUT_TAGS = ['INPUT', 'TEXTAREA', 'SELECT']; export const INPUT_TAGS = ['INPUT', 'TEXTAREA', 'SELECT'];
const lastInputValueMap: WeakMap<EventTarget, inputValue> = new WeakMap(); const lastInputValueMap: WeakMap<EventTarget, inputValue> = new WeakMap();
function initInputObserver( function initInputObserver(
@@ -348,9 +357,11 @@ function initInputObserver(
maskInputOptions: MaskInputOptions, maskInputOptions: MaskInputOptions,
maskInputFn: MaskInputFn | undefined, maskInputFn: MaskInputFn | undefined,
sampling: SamplingStrategy, sampling: SamplingStrategy,
userTriggeredOnInput: boolean,
): listenerHandler { ): listenerHandler {
function eventHandler(event: Event) { function eventHandler(event: Event) {
const target = getEventTarget(event); const target = getEventTarget(event);
const userTriggered = event.isTrusted;
if ( if (
!target || !target ||
!(target as Element).tagName || !(target as Element).tagName ||
@@ -381,7 +392,13 @@ function initInputObserver(
maskInputFn, maskInputFn,
}); });
} }
cbWithDedup(target, { text, isChecked }); cbWithDedup(
target,
wrapEventWithUserTriggeredFlag(
{ text, isChecked, userTriggered },
userTriggeredOnInput,
),
);
// if a radio was checked // if a radio was checked
// the other radios with the same name attribute will be unchecked. // the other radios with the same name attribute will be unchecked.
const name: string | undefined = (target as HTMLInputElement).name; const name: string | undefined = (target as HTMLInputElement).name;
@@ -390,10 +407,17 @@ function initInputObserver(
.querySelectorAll(`input[type="radio"][name="${name}"]`) .querySelectorAll(`input[type="radio"][name="${name}"]`)
.forEach((el) => { .forEach((el) => {
if (el !== target) { if (el !== target) {
cbWithDedup(el, { cbWithDedup(
el,
wrapEventWithUserTriggeredFlag(
{
text: (el as HTMLInputElement).value, text: (el as HTMLInputElement).value,
isChecked: !isChecked, isChecked: !isChecked,
}); userTriggered: false,
},
userTriggeredOnInput,
),
);
} }
}); });
} }
@@ -763,6 +787,7 @@ export function initObservers(
o.maskInputOptions, o.maskInputOptions,
o.maskInputFn, o.maskInputFn,
o.sampling, o.sampling,
o.userTriggeredOnInput,
); );
const mediaInteractionHandler = initMediaInteractionObserver( const mediaInteractionHandler = initMediaInteractionObserver(
o.mediaInteractionCb, o.mediaInteractionCb,

View File

@@ -115,6 +115,7 @@ export class Replayer {
triggerFocus: true, triggerFocus: true,
UNSAFE_replayCanvas: false, UNSAFE_replayCanvas: false,
pauseAnimation: true, pauseAnimation: true,
userTriggeredOnInput: true,
mouseTail: defaultMouseTailConfig, mouseTail: defaultMouseTailConfig,
}; };
this.config = Object.assign({}, defaultConfig, config); this.config = Object.assign({}, defaultConfig, config);
@@ -501,10 +502,7 @@ export class Replayer {
// events are kept sorted by timestamp, check if this is the last event // events are kept sorted by timestamp, check if this is the last event
let last_index = this.service.state.context.events.length - 1; let last_index = this.service.state.context.events.length - 1;
if ( if (event === this.service.state.context.events[last_index]) {
event ===
this.service.state.context.events[last_index]
) {
const finish = () => { const finish = () => {
if (last_index < this.service.state.context.events.length - 1) { if (last_index < this.service.state.context.events.length - 1) {
// more events have been added since the setTimeout // more events have been added since the setTimeout

View File

@@ -218,6 +218,7 @@ export type recordOptions<T> = {
packFn?: PackFn; packFn?: PackFn;
sampling?: SamplingStrategy; sampling?: SamplingStrategy;
recordCanvas?: boolean; recordCanvas?: boolean;
userTriggeredOnInput?: boolean;
collectFonts?: boolean; collectFonts?: boolean;
plugins?: RecordPlugin[]; plugins?: RecordPlugin[];
// departed, please use sampling options // departed, please use sampling options
@@ -247,6 +248,7 @@ export type observerParam = {
fontCb: fontCallback; fontCb: fontCallback;
sampling: SamplingStrategy; sampling: SamplingStrategy;
recordCanvas: boolean; recordCanvas: boolean;
userTriggeredOnInput: boolean;
collectFonts: boolean; collectFonts: boolean;
slimDOMOptions: SlimDOMOptions; slimDOMOptions: SlimDOMOptions;
doc: Document; doc: Document;
@@ -419,6 +421,12 @@ export type viewportResizeCallback = (d: viewportResizeDimension) => void;
export type inputValue = { export type inputValue = {
text: string; text: string;
isChecked: boolean; isChecked: boolean;
// `userTriggered` indicates if this event was triggered directly by user (userTriggered: true)
// or was triggered indirectly (userTriggered: false)
// Example of `userTriggered` in action:
// User clicks on radio element (userTriggered: true) which triggers the other radio element to change (userTriggered: false)
userTriggered?: boolean;
}; };
export type inputCallback = (v: inputValue & { id: number }) => void; export type inputCallback = (v: inputValue & { id: number }) => void;
@@ -484,6 +492,7 @@ export type playerConfig = {
triggerFocus: boolean; triggerFocus: boolean;
UNSAFE_replayCanvas: boolean; UNSAFE_replayCanvas: boolean;
pauseAnimation?: boolean; pauseAnimation?: boolean;
userTriggeredOnInput: boolean;
mouseTail: mouseTail:
| boolean | boolean
| { | {

File diff suppressed because it is too large Load Diff

View File

@@ -1,23 +1,25 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head>
<head> <meta charset="UTF-8" />
<meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta http-equiv="X-UA-Compatible" content="ie=edge" />
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>form fields</title> <title>form fields</title>
</head> </head>
<body> <body>
<form> <form>
<label for="text"> <label for="text">
<input type="text"> <input type="text" />
</label> </label>
<label for="radio"> <label>
<input type="radio"> <input type="radio" name="toggle" value="on" />
</label>
<label>
<input type="radio" name="toggle" value="off" checked />
</label> </label>
<label for="checkbox"> <label for="checkbox">
<input type="checkbox"> <input type="checkbox" />
</label> </label>
<label for="textarea"> <label for="textarea">
<textarea name="" id="" cols="30" rows="10"></textarea> <textarea name="" id="" cols="30" rows="10"></textarea>
@@ -32,6 +34,5 @@
<input type="password" /> <input type="password" />
</label> </label>
</form> </form>
</body> </body>
</html> </html>

View File

@@ -75,6 +75,7 @@ describe('record integration tests', function (this: ISuite) {
maskTextSelector: ${JSON.stringify(options.maskTextSelector)}, maskTextSelector: ${JSON.stringify(options.maskTextSelector)},
maskAllInputs: ${options.maskAllInputs}, maskAllInputs: ${options.maskAllInputs},
maskInputOptions: ${JSON.stringify(options.maskAllInputs)}, maskInputOptions: ${JSON.stringify(options.maskAllInputs)},
userTriggeredOnInput: ${options.userTriggeredOnInput},
maskTextFn: ${options.maskTextFn}, maskTextFn: ${options.maskTextFn},
recordCanvas: ${options.recordCanvas}, recordCanvas: ${options.recordCanvas},
plugins: ${options.plugins} plugins: ${options.plugins}
@@ -284,6 +285,24 @@ describe('record integration tests', function (this: ISuite) {
assertSnapshot(snapshots, __filename, 'maskPassword'); assertSnapshot(snapshots, __filename, 'maskPassword');
}); });
it('should record input userTriggered values if userTriggeredOnInput is enabled', async () => {
const page: puppeteer.Page = await this.browser.newPage();
await page.goto('about:blank');
await page.setContent(
getHtml.call(this, 'form.html', { userTriggeredOnInput: true }),
);
await page.type('input[type="text"]', 'test');
await page.click('input[type="radio"]');
await page.click('input[type="checkbox"]');
await page.type('input[type="password"]', 'password');
await page.type('textarea', 'textarea test');
await page.select('select', '1');
const snapshots = await page.evaluate('window.snapshots');
assertSnapshot(snapshots, __filename, 'userTriggered');
});
it('should not record blocked elements and its child nodes', async () => { it('should not record blocked elements and its child nodes', async () => {
const page: puppeteer.Page = await this.browser.newPage(); const page: puppeteer.Page = await this.browser.newPage();
await page.goto('about:blank'); await page.goto('about:blank');