Skip to content

Commit e6b6c07

Browse files
committed
Fixes
1 parent 273758a commit e6b6c07

6 files changed

Lines changed: 363 additions & 601 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,7 @@
1212

1313
- Multi-instance `<TimeToInitialDisplay>` / `<TimeToFullDisplay>` coordination ([#6090](https://github.com/getsentry/sentry-react-native/pull/6090))
1414
- New `ready` prop. When a screen has multiple async data sources, mount one `<TimeToFullDisplay ready={...} />` per source — TTID/TTFD is recorded only when every instance reports `ready === true`.
15-
```tsx
16-
<TimeToFullDisplay ready={feedLoaded} />
17-
<TimeToFullDisplay ready={sidebarLoaded} />
18-
// TTFD fires when both are ready.
19-
```
20-
- The existing `record` prop is **unchanged** and continues to behave exactly as before — instances using `record` are independent and do not gate or get gated by `ready` peers. Existing apps require no migration.
21-
- `record` is now deprecated in favor of `ready`. Migrating to `ready` is a one-line rename that opts the instance into multi-instance coordination. `record` will be removed in the next major version.
22-
- Mounting a not-yet-ready peer on the next render commit (typical `useEffect` → `setState` → child mount waves) cancels any pending fire from an earlier-ready peer, preventing premature TTFD recording. Up-flips of the aggregate are deferred by one macrotask to absorb same-task peer mounts; down-flips are immediate.
15+
- The existing `record` prop is unchanged BUT it is now deprecated in favor of `ready`.
2316

2417
### Fixes
2518

packages/core/src/js/tracing/integrations/timeToDisplayIntegration.ts

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -87,10 +87,6 @@ export const timeToDisplayIntegration = (): Integration => {
8787
event.timestamp = newTransactionEndTimestampSeconds;
8888
}
8989

90-
// Drop the per-span coordinator state now that we've read the native
91-
// ttid/ttfd values. Prevents the module-level registries from
92-
// accumulating entries for screens that outlive their span (keep-alive,
93-
// idle-timeout discarded transactions, etc.).
9490
clearTimeToDisplayCoordinatorSpan(rootSpanId);
9591

9692
return event;

packages/core/src/js/tracing/timeToDisplayCoordinator.ts

Lines changed: 26 additions & 120 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,6 @@
11
/**
22
* Coordinator for multi-instance `<TimeToInitialDisplay>` / `<TimeToFullDisplay>`
33
* components on a single screen (active span).
4-
*
5-
* The aggregate "ready" state exposed via `isAllReady` is *deferred* on its
6-
* way up: when the raw aggregate flips false→true, we schedule the public
7-
* value to flip on the next macrotask. Down-flips (true→false) are
8-
* immediate and cancel any pending up-flip.
9-
*
10-
* Why: in React 18, a typical "parent renders → parent useEffect setState
11-
* → child mounts on next commit" wave executes synchronously inside one
12-
* event-loop task. A `setTimeout(0)` reliably runs after that whole wave,
13-
* so cross-commit-but-same-task peer registrations are absorbed before the
14-
* coordinator declares itself ready. Without the defer, a header that
15-
* registers and is alone-and-ready would emit `fullDisplay=true`
16-
* immediately, the native reporter would fire on the next draw, and a
17-
* sibling sidebar mounting on the next commit could only un-ready the
18-
* aggregate after the (now stuck) native timestamp has already been
19-
* recorded.
20-
*
21-
* The defer does NOT cover arbitrary-async deferred mounting (e.g. mount a
22-
* checkpoint after a fetch resolves). That class of usage is documented
23-
* against — the recommended pattern is to mount all checkpoints at screen
24-
* mount with `ready=false` and flip them as data arrives.
254
*/
265

276
type Checkpoint = { ready: boolean };
@@ -30,36 +9,18 @@ type Listener = () => void;
309
interface SpanRegistry {
3110
checkpoints: Map<string, Checkpoint>;
3211
listeners: Set<Listener>;
33-
/**
34-
* Stable, deferred view of the aggregate exposed via `isAllReady`. Lags
35-
* raw `computeAggregate` on up-flips by `READY_DEFER_MS`, immediate on
36-
* down-flips. Used to avoid waking subscribers when a checkpoint change
37-
* does not flip the aggregate — the dominant lifecycle pattern is "all
38-
* checkpoints register as not-ready, then flip to ready over time", and
39-
* only the final flip needs to notify.
40-
*/
12+
// this value answers the question "are all checkpoints on this span ready?"
13+
// when the raw value goes from false to true, aggregateReady does NOT flip immediately, it gets
14+
// scheduled with setTimeout(0) in `reevaluate` function
15+
//
4116
aggregateReady: boolean;
42-
/**
43-
* Pending up-flip timer. When non-null, an up-flip is scheduled but has
44-
* not yet been applied to `aggregateReady`. Cleared either when the timer
45-
* fires or when an intervening change cancels the pending up-flip.
46-
*/
17+
// when non-null, an up-flip is scheduled but has not yet been applied to `aggregateReady`
4718
pendingUpFlip: ReturnType<typeof setTimeout> | null;
48-
/**
49-
* Checkpoint ids whose components have unmounted but are kept in the
50-
* registry to prevent a premature aggregate flip (sole-blocker safeguard).
51-
* They participate in `computeAggregate` but are excluded from the
52-
* "live work" count in `performCleanup`.
53-
*/
19+
// `sticky` is used indicate checkpints that gets cleared when the screen fully unmounts
20+
// it's useful
5421
sticky: Set<string>;
5522
}
5623

57-
/**
58-
* Defer applied to up-flips. Zero macrotask is enough to absorb a same-task
59-
* cascade of useEffect-driven child mounts in React 18.
60-
*/
61-
const READY_DEFER_MS = 0;
62-
6324
const TTID = 'ttid';
6425
const TTFD = 'ttfd';
6526

@@ -105,17 +66,7 @@ function computeAggregate(entry: SpanRegistry): boolean {
10566
return true;
10667
}
10768

108-
/**
109-
* Recompute the raw aggregate and reconcile it with the cached
110-
* `aggregateReady`. Up-flips are deferred to absorb same-task peer mounts;
111-
* down-flips are immediate and cancel any pending up-flip.
112-
*
113-
* Transition matrix (raw, stable) → action:
114-
* (false, false): no-op; cancel pending up-flip if any (it became stale).
115-
* (true, true): no-op; cancel pending up-flip if any.
116-
* (false, true): immediate down-flip + notify; cancel pending up-flip.
117-
* (true, false): schedule up-flip if not already pending.
118-
*/
69+
// Recompute the raw aggregate and reconcile it with the cached `aggregateReady`
11970
function reevaluate(entry: SpanRegistry): void {
12071
const raw = computeAggregate(entry);
12172

@@ -131,10 +82,11 @@ function reevaluate(entry: SpanRegistry): void {
13182
return;
13283
}
13384

134-
// raw=true, stable=false: schedule deferred up-flip.
13585
if (entry.pendingUpFlip !== null) {
13686
return;
13787
}
88+
// the delay here is set to 0 because in React 18 that
89+
// will schedule the callback to be run asynchronously after the shortest possible delay
13890
entry.pendingUpFlip = setTimeout(() => {
13991
entry.pendingUpFlip = null;
14092
// Re-check on fire — a peer may have un-readied between schedule and now.
@@ -143,7 +95,7 @@ function reevaluate(entry: SpanRegistry): void {
14395
}
14496
entry.aggregateReady = true;
14597
notifyListeners(entry);
146-
}, READY_DEFER_MS);
98+
}, 0);
14799
}
148100

149101
function notifyListeners(entry: SpanRegistry): void {
@@ -152,16 +104,6 @@ function notifyListeners(entry: SpanRegistry): void {
152104
}
153105
}
154106

155-
/**
156-
* Delete the registry entry once there is no live work attached to it.
157-
*
158-
* "Live work" means either subscribed listeners (registry-mode components
159-
* still mounted) or non-sticky checkpoints (still-mounted registrations).
160-
* Sticky checkpoints (kept after a not-ready unmount; see `unregister`)
161-
* exist only to prevent premature aggregate flips while the screen is still
162-
* mounted; once every live counterpart is gone, they are orphaned and safe
163-
* to drop along with the entry.
164-
*/
165107
function performCleanup(kind: DisplayKind, parentSpanId: string, entry: SpanRegistry): void {
166108
const liveCheckpoints = entry.checkpoints.size - entry.sticky.size;
167109
if (liveCheckpoints === 0 && entry.listeners.size === 0) {
@@ -170,22 +112,16 @@ function performCleanup(kind: DisplayKind, parentSpanId: string, entry: SpanRegi
170112
}
171113
}
172114

173-
/**
174-
* True iff removing `checkpointId` from `entry` would flip the aggregate
175-
* from false to true — i.e., the checkpoint is the sole blocker.
176-
*
177-
* Used to detect the premature-fire scenario where a not-ready checkpoint
178-
* unmounts while every other checkpoint is ready: deleting it would let the
179-
* aggregate flip to true and immediately record TTFD/TTID, even though the
180-
* unmounting source never actually became ready.
181-
*/
115+
// A bit of a hack but this is used to detect the premature-fire scenario
116+
// where a not-ready checkpoint unmounts while every other checkpoint is ready:
117+
// deleting it would let the aggregate flip to true and immediately record TTFD/TTID,
118+
// even though the unmounting source never actually became ready.
182119
function isSoleBlocker(entry: SpanRegistry, checkpointId: string): boolean {
183120
if (entry.aggregateReady) {
184121
return false;
185122
}
186123
if (entry.checkpoints.size <= 1) {
187-
// Removing the only checkpoint leaves the registry empty, which yields
188-
// aggregate=false (per `computeAggregate`). No flip.
124+
// because removing the only checkpoint leaves the registry empty
189125
return false;
190126
}
191127
const cp = entry.checkpoints.get(checkpointId);
@@ -203,9 +139,6 @@ function isSoleBlocker(entry: SpanRegistry, checkpointId: string): boolean {
203139
return true;
204140
}
205141

206-
/**
207-
* Register a checkpoint under (kind, parentSpanId). Returns an unregister fn.
208-
*/
209142
export function registerCheckpoint(
210143
kind: DisplayKind,
211144
parentSpanId: string,
@@ -221,10 +154,9 @@ export function registerCheckpoint(
221154
if (!e) {
222155
return;
223156
}
224-
// If this checkpoint is the sole blocker, removing it would flip the
225-
// aggregate to true and prematurely fire TTFD/TTID even though the
226-
// unmounting source never became ready. Keep the checkpoint sticky;
227-
// it gets cleared when the screen fully unmounts.
157+
// if the checkpoint is the only blocker then removing it would flip the
158+
// aggregate to true and fire TTFD/TTID even though the unmounting source never became ready.
159+
// that's why we use `sticky` here to indicate that it gets cleared when the screen fully unmounts
228160
if (isSoleBlocker(e, checkpointId)) {
229161
e.sticky.add(checkpointId);
230162
performCleanup(kind, parentSpanId, e);
@@ -238,9 +170,6 @@ export function registerCheckpoint(
238170
};
239171
}
240172

241-
/**
242-
* Update an existing checkpoint's ready state.
243-
*/
244173
export function updateCheckpoint(
245174
kind: DisplayKind,
246175
parentSpanId: string,
@@ -256,28 +185,19 @@ export function updateCheckpoint(
256185
reevaluate(entry);
257186
}
258187

259-
/**
260-
* True if at least one checkpoint is registered AND all checkpoints are ready.
261-
* Reads the cached aggregate — O(1).
262-
*/
188+
// Returns true if at least one checkpoint is registered AND all checkpoints are ready
263189
export function isAllReady(kind: DisplayKind, parentSpanId: string): boolean {
264190
const entry = registries[kind].get(parentSpanId);
265191
return !!entry && entry.aggregateReady;
266192
}
267193

268-
/**
269-
* Returns true if there is at least one registered checkpoint on this span.
270-
*/
194+
// Returns true if there is at least one registered checkpoint on this span
271195
export function hasAnyCheckpoints(kind: DisplayKind, parentSpanId: string): boolean {
272196
const entry = registries[kind].get(parentSpanId);
273197
return !!entry && entry.checkpoints.size > 0;
274198
}
275199

276-
/**
277-
* Subscribe to aggregate-ready transitions for a given span. The listener is
278-
* called only when the aggregate flips, not on every individual checkpoint
279-
* change.
280-
*/
200+
// Subscribe to aggregate-ready transitions for a given span
281201
export function subscribe(kind: DisplayKind, parentSpanId: string, listener: Listener): () => void {
282202
const entry = getOrCreate(kind, parentSpanId);
283203
entry.listeners.add(listener);
@@ -291,21 +211,10 @@ export function subscribe(kind: DisplayKind, parentSpanId: string, listener: Lis
291211
};
292212
}
293213

294-
/**
295-
* Drop coordinator state for `parentSpanId` across both kinds.
296-
*
297-
* Called by the time-to-display integration once a transaction has been
298-
* processed, since the per-span coordinator state is no longer relevant
299-
* after the native draw timestamps have been read. Without this hook,
300-
* entries for screens that stay mounted past the end of their span
301-
* (React Navigation keep-alive, idle-timeout discarded transactions,
302-
* etc.) would accumulate in the module-level registries.
303-
*
304-
* Components that are still subscribed when their span is cleared remain
305-
* functional: their next interaction recreates the entry under the same
306-
* (now stale) parentSpanId. Since the integration has already read the
307-
* native ttid/ttfd values for that span, any subsequent fires are inert.
308-
*/
214+
// Drop coordinator state for `parentSpanId` across both kinds.
215+
// Called by the time-to-display integration once a transaction has been
216+
// processed, since the per-span coordinator state is no longer relevant
217+
// after the native draw timestamps have been read.
309218
export function clearSpan(parentSpanId: string): void {
310219
for (const kind of [TTID, TTFD] as const) {
311220
const entry = registries[kind].get(parentSpanId);
@@ -316,9 +225,6 @@ export function clearSpan(parentSpanId: string): void {
316225
}
317226
}
318227

319-
/**
320-
* Test-only. Clears all coordinator state.
321-
*/
322228
export function _resetTimeToDisplayCoordinator(): void {
323229
for (const kind of [TTID, TTFD] as const) {
324230
for (const entry of registries[kind].values()) {

0 commit comments

Comments
 (0)