perf(twenty-front): stop Sentry Replay from re-serializing record-table mutations on navigation (#21381)

## Problem

Navigating between record-index pages (e.g. People ↔ Companies) blocks
the main thread for seconds, on every navigation, for ~every user.
Profiling pointed at **Sentry Session Replay(rrweb)**, not app code.

 ## Root cause

Swapping one record table for another produces a large DOM mutation
batch. rrweb serializes that batch **synchronously on the main thread**
(`_isParentRemoved` / mutation processing).
The built-in `mutationLimit` safety valve doesn't help: it's a *count*
threshold (default 10000), but our cost is *per-mutation serialization*
on a wide/deep table DOM — the batch is expensive, not numerous, so it
slips under the limit.

  ## Fix

```ts
replayIntegration({
  _experiments: {
    ignoreMutations: ['[id^="row-virtual-index-"]'],
  },
}),
```

- ignoreMutations tells rrweb to drop mutation batches originating from
the virtualized row containers (StyledVirtualizedRowContainer, ids
row-virtual-index-N) — the source of the
table-swap churn. The table still appears in replays (initial snapshot;
text is already masked by default), its live row updates just aren't
re-serialized.

## Test

Measured locally
  ```

┌────────────────────────────────────────────────────────────┬───────────┬───────────────┐
│ │ Baseline │ With fix │

├────────────────────────────────────────────────────────────┼───────────┼───────────────┤
│ Replay/rrweb total │ 4,112 ms │ 188 ms (−95%) │

├────────────────────────────────────────────────────────────┼───────────┼───────────────┤
│ _isParentRemoved │ 2,195 ms │ 6 ms │

├────────────────────────────────────────────────────────────┼───────────┼───────────────┤

  ```

## Tradeoff

ignoreMutations tells rrweb to skip mutation batches coming from the
virtualized record-table rows, so session replays won't reflect live
changes inside the table — rows scrolling, cells updating, inline edits
will appear "frozen" at the last full snapshot. The table still shows in
the replay (initial render), and **its text is masked by default anyway,
so in practice we lose little**: the surrounding UI, navigation, clicks,
and interactions are all still recorded. The cost we're removing
(multi-second main-thread freeze on every navigation, for ~all users)
**far outweighs not seeing table row churn in replays** imho.
(@FelixMalfait @charlesBochet)

Two caveats worth noting: _experiments.ignoreMutations is an
experimental Sentry API, and it's batch-coarse, if a mutation batch
contains any matching element, the whole batch is dropped, so an
unrelated change occasionally batched with table mutations could be
missed. During navigation these batches are almost entirely table
mutations, so collateral is minimal.
If it ever proves insufficient, the reliable fallback is
`data-sentry-block` on the record-table body (which turns the table into
a placeholder box in replays).

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
This commit is contained in:
Weiko
2026-06-09 22:05:04 +02:00
committed by GitHub
parent d2f30e0513
commit 6c65ae8257
7 changed files with 18 additions and 4 deletions
@@ -2,6 +2,7 @@ import { currentUserState } from '@/auth/states/currentUserState';
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { sentryConfigState } from '@/client-config/states/sentryConfigState';
import { SENTRY_REPLAY_IGNORE_MUTATIONS_ATTRIBUTE } from '@/error-handler/constants/SentryReplayIgnoreMutationsAttribute';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useEffect, useState } from 'react';
import { isNonEmptyString } from '@sniptt/guards';
@@ -42,7 +43,13 @@ export const SentryInitEffect = () => {
dsn: sentryConfig?.dsn,
integrations: [
browserTracingIntegration({}),
replayIntegration(),
replayIntegration({
_experiments: {
ignoreMutations: [
`[${SENTRY_REPLAY_IGNORE_MUTATIONS_ATTRIBUTE}]`,
],
},
}),
globalHandlersIntegration({
onunhandledrejection: false, // handled in PromiseRejectionEffect
}),