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.
| key | default | description |
| ---------------- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| -------------------- | ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 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 |
| 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 |
| 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) |
| 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

View File

@@ -132,7 +132,7 @@ setInterval(save, 10 * 1000);
`rrweb.record(config)` 的 config 部分接受以下参数
| key | 默认值 | 功能 |
| ---------------- | ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| -------------------- | ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| emit | 必填 | 获取当前录制的数据 |
| checkoutEveryNth | - | 每 N 次事件重新制作一次全量快照<br />详见[“重新制作快照”](#重新制作快照)章节 |
| checkoutEveryNms | - | 每 N 毫秒重新制作一次全量快照<br />详见[“重新制作快照”](#重新制作快照)章节 |
@@ -153,6 +153,7 @@ setInterval(save, 10 * 1000);
| recordCanvas | false | 是否记录 canvas 内容 |
| collectFonts | false | 是否记录页面中的字体文件 |
| 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 = {},
mousemoveWait,
recordCanvas = false,
userTriggeredOnInput = false,
collectFonts = false,
plugins,
keepIframeSrcFn = () => false,
@@ -379,6 +380,7 @@ function record<T = eventWithTime>(
inlineStylesheet,
sampling,
recordCanvas,
userTriggeredOnInput,
collectFonts,
doc,
maskInputFn,

View File

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

View File

@@ -115,6 +115,7 @@ export class Replayer {
triggerFocus: true,
UNSAFE_replayCanvas: false,
pauseAnimation: true,
userTriggeredOnInput: true,
mouseTail: defaultMouseTailConfig,
};
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
let last_index = this.service.state.context.events.length - 1;
if (
event ===
this.service.state.context.events[last_index]
) {
if (event === this.service.state.context.events[last_index]) {
const finish = () => {
if (last_index < this.service.state.context.events.length - 1) {
// more events have been added since the setTimeout

View File

@@ -218,6 +218,7 @@ export type recordOptions<T> = {
packFn?: PackFn;
sampling?: SamplingStrategy;
recordCanvas?: boolean;
userTriggeredOnInput?: boolean;
collectFonts?: boolean;
plugins?: RecordPlugin[];
// departed, please use sampling options
@@ -247,6 +248,7 @@ export type observerParam = {
fontCb: fontCallback;
sampling: SamplingStrategy;
recordCanvas: boolean;
userTriggeredOnInput: boolean;
collectFonts: boolean;
slimDOMOptions: SlimDOMOptions;
doc: Document;
@@ -419,6 +421,12 @@ export type viewportResizeCallback = (d: viewportResizeDimension) => void;
export type inputValue = {
text: string;
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;
@@ -484,6 +492,7 @@ export type playerConfig = {
triggerFocus: boolean;
UNSAFE_replayCanvas: boolean;
pauseAnimation?: boolean;
userTriggeredOnInput: boolean;
mouseTail:
| boolean
| {

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -75,6 +75,7 @@ describe('record integration tests', function (this: ISuite) {
maskTextSelector: ${JSON.stringify(options.maskTextSelector)},
maskAllInputs: ${options.maskAllInputs},
maskInputOptions: ${JSON.stringify(options.maskAllInputs)},
userTriggeredOnInput: ${options.userTriggeredOnInput},
maskTextFn: ${options.maskTextFn},
recordCanvas: ${options.recordCanvas},
plugins: ${options.plugins}
@@ -284,6 +285,24 @@ describe('record integration tests', function (this: ISuite) {
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 () => {
const page: puppeteer.Page = await this.browser.newPage();
await page.goto('about:blank');