Skip to content

Commit f88a7d1

Browse files
authored
Multi-instance <TimeToInitialDisplay> / <TimeToFullDisplay> coordination; a multi-signal TTID/TTFD system (#6090)
* Coordinated TTID/TTFD * Changelog entry * Added changelog PR number * Updates the order of entries in CHANGELOG.md * useRef instead of useId for React 17 compatibility * Use refs to only throw warnings once * An attempt to fix things * Fixes * Fixes * Fixes * Fixes to tests * Changelog fix * Fix * Fix * fix * Changlog entry fix * fixes for AI stuff * cleanup * fix
1 parent f3215d3 commit f88a7d1

7 files changed

Lines changed: 663 additions & 11 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@
1010

1111
### Features
1212

13+
- Multi-instance `<TimeToInitialDisplay>` / `<TimeToFullDisplay>` coordination ([#6090](https://github.com/getsentry/sentry-react-native/pull/6090))
14+
- 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+
- The existing `record` prop is unchanged BUT it is now deprecated in favor of `ready`.
1316
- Extract text content from children of touched components as a label fallback for touch breadcrumbs ([#6106](https://github.com/getsentry/sentry-react-native/pull/6106))
1417

1518
### Dependencies

packages/core/etc/sentry-react-native.api.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -669,6 +669,7 @@ export const timeToDisplayIntegration: () => Integration;
669669
export type TimeToDisplayProps = {
670670
children?: React_2.ReactNode;
671671
record?: boolean;
672+
ready?: boolean;
672673
};
673674

674675
// @public

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { SPAN_ORIGIN_AUTO_UI_TIME_TO_DISPLAY, SPAN_ORIGIN_MANUAL_UI_TIME_TO_DISP
88
import { getReactNavigationIntegration } from '../reactnavigation';
99
import { SEMANTIC_ATTRIBUTE_ROUTE_HAS_BEEN_SEEN } from '../semanticAttributes';
1010
import { SPAN_THREAD_NAME, SPAN_THREAD_NAME_JAVASCRIPT } from '../span';
11+
import { clearSpan as clearTimeToDisplayCoordinatorSpan } from '../timeToDisplayCoordinator';
1112
import { getTimeToInitialDisplayFallback } from '../timeToDisplayFallback';
1213
import { createSpanJSON } from '../utils';
1314

@@ -86,6 +87,8 @@ export const timeToDisplayIntegration = (): Integration => {
8687
event.timestamp = newTransactionEndTimestampSeconds;
8788
}
8889

90+
clearTimeToDisplayCoordinatorSpan(rootSpanId);
91+
8992
return event;
9093
},
9194
};
Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
1+
/**
2+
* Coordinator for multi-instance `<TimeToInitialDisplay>` / `<TimeToFullDisplay>`
3+
* components on a single screen (active span).
4+
*/
5+
6+
type Checkpoint = { ready: boolean };
7+
type Listener = () => void;
8+
9+
interface SpanRegistry {
10+
checkpoints: Map<string, Checkpoint>;
11+
listeners: Set<Listener>;
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+
//
16+
aggregateReady: boolean;
17+
// when non-null, an up-flip is scheduled but has not yet been applied to `aggregateReady`
18+
pendingUpFlip: ReturnType<typeof setTimeout> | null;
19+
// `sticky` is used indicate checkpints that gets cleared when the screen fully unmounts
20+
// it's useful
21+
sticky: Set<string>;
22+
}
23+
24+
const TTID = 'ttid';
25+
const TTFD = 'ttfd';
26+
27+
export type DisplayKind = typeof TTID | typeof TTFD;
28+
29+
const registries: Record<DisplayKind, Map<string, SpanRegistry>> = {
30+
ttid: new Map(),
31+
ttfd: new Map(),
32+
};
33+
34+
function getOrCreate(kind: DisplayKind, parentSpanId: string): SpanRegistry {
35+
const map = registries[kind];
36+
let entry = map.get(parentSpanId);
37+
if (!entry) {
38+
entry = {
39+
checkpoints: new Map(),
40+
listeners: new Set(),
41+
aggregateReady: false,
42+
pendingUpFlip: null,
43+
sticky: new Set(),
44+
};
45+
map.set(parentSpanId, entry);
46+
}
47+
return entry;
48+
}
49+
50+
function cancelPendingUpFlip(entry: SpanRegistry): void {
51+
if (entry.pendingUpFlip !== null) {
52+
clearTimeout(entry.pendingUpFlip);
53+
entry.pendingUpFlip = null;
54+
}
55+
}
56+
57+
function computeAggregate(entry: SpanRegistry): boolean {
58+
if (entry.checkpoints.size === 0) {
59+
return false;
60+
}
61+
for (const cp of entry.checkpoints.values()) {
62+
if (!cp.ready) {
63+
return false;
64+
}
65+
}
66+
return true;
67+
}
68+
69+
// Recompute the raw aggregate and reconcile it with the cached `aggregateReady`
70+
function reevaluate(entry: SpanRegistry): void {
71+
const raw = computeAggregate(entry);
72+
73+
if (raw === entry.aggregateReady) {
74+
cancelPendingUpFlip(entry);
75+
return;
76+
}
77+
78+
if (!raw) {
79+
cancelPendingUpFlip(entry);
80+
entry.aggregateReady = false;
81+
notifyListeners(entry);
82+
return;
83+
}
84+
85+
if (entry.pendingUpFlip !== null) {
86+
return;
87+
}
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
90+
entry.pendingUpFlip = setTimeout(() => {
91+
entry.pendingUpFlip = null;
92+
// Re-check on fire — a peer may have un-readied between schedule and now.
93+
if (!computeAggregate(entry) || entry.aggregateReady) {
94+
return;
95+
}
96+
entry.aggregateReady = true;
97+
notifyListeners(entry);
98+
}, 0);
99+
}
100+
101+
function notifyListeners(entry: SpanRegistry): void {
102+
for (const listener of entry.listeners) {
103+
listener();
104+
}
105+
}
106+
107+
function performCleanup(kind: DisplayKind, parentSpanId: string, entry: SpanRegistry): void {
108+
const liveCheckpoints = entry.checkpoints.size - entry.sticky.size;
109+
if (liveCheckpoints === 0 && entry.listeners.size === 0) {
110+
cancelPendingUpFlip(entry);
111+
registries[kind].delete(parentSpanId);
112+
}
113+
}
114+
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.
119+
function isSoleBlocker(entry: SpanRegistry, checkpointId: string): boolean {
120+
if (entry.aggregateReady) {
121+
return false;
122+
}
123+
if (entry.checkpoints.size <= 1) {
124+
// because removing the only checkpoint leaves the registry empty
125+
return false;
126+
}
127+
const cp = entry.checkpoints.get(checkpointId);
128+
if (!cp || cp.ready) {
129+
return false;
130+
}
131+
for (const [id, other] of entry.checkpoints) {
132+
if (id === checkpointId) {
133+
continue;
134+
}
135+
if (!other.ready) {
136+
return false;
137+
}
138+
}
139+
return true;
140+
}
141+
142+
export function registerCheckpoint(
143+
kind: DisplayKind,
144+
parentSpanId: string,
145+
checkpointId: string,
146+
ready: boolean,
147+
): () => void {
148+
const entry = getOrCreate(kind, parentSpanId);
149+
150+
// Any new registration means the screen's component graph is changing.
151+
// Drop leftover sticky entries from previous unmount cycles -- otherwise
152+
// a remounted checkpoint would be permanently blocked
153+
if (entry.sticky.size > 0) {
154+
for (const id of entry.sticky) {
155+
entry.checkpoints.delete(id);
156+
}
157+
entry.sticky.clear();
158+
}
159+
160+
entry.checkpoints.set(checkpointId, { ready });
161+
reevaluate(entry);
162+
163+
return () => {
164+
const e = registries[kind].get(parentSpanId);
165+
if (!e) {
166+
return;
167+
}
168+
// if the checkpoint is the only blocker then removing it would flip the
169+
// aggregate to true and fire TTFD/TTID even though the unmounting source never became ready.
170+
// that's why we use `sticky` here to indicate that it gets cleared when the screen fully unmounts
171+
if (isSoleBlocker(e, checkpointId)) {
172+
e.sticky.add(checkpointId);
173+
performCleanup(kind, parentSpanId, e);
174+
return;
175+
}
176+
if (e.checkpoints.delete(checkpointId)) {
177+
e.sticky.delete(checkpointId);
178+
reevaluate(e);
179+
}
180+
performCleanup(kind, parentSpanId, e);
181+
};
182+
}
183+
184+
export function updateCheckpoint(kind: DisplayKind, parentSpanId: string, checkpointId: string, ready: boolean): void {
185+
const entry = registries[kind].get(parentSpanId);
186+
const cp = entry?.checkpoints.get(checkpointId);
187+
if (!entry || !cp || cp.ready === ready) {
188+
return;
189+
}
190+
cp.ready = ready;
191+
reevaluate(entry);
192+
}
193+
194+
// Returns true if at least one checkpoint is registered AND all checkpoints are ready
195+
export function isAllReady(kind: DisplayKind, parentSpanId: string): boolean {
196+
const entry = registries[kind].get(parentSpanId);
197+
return !!entry && entry.aggregateReady;
198+
}
199+
200+
// Returns true if there is at least one registered checkpoint on this span
201+
export function hasAnyCheckpoints(kind: DisplayKind, parentSpanId: string): boolean {
202+
const entry = registries[kind].get(parentSpanId);
203+
return !!entry && entry.checkpoints.size > 0;
204+
}
205+
206+
// Subscribe to aggregate-ready transitions for a given span
207+
export function subscribe(kind: DisplayKind, parentSpanId: string, listener: Listener): () => void {
208+
const entry = getOrCreate(kind, parentSpanId);
209+
entry.listeners.add(listener);
210+
return () => {
211+
const e = registries[kind].get(parentSpanId);
212+
if (!e) {
213+
return;
214+
}
215+
e.listeners.delete(listener);
216+
performCleanup(kind, parentSpanId, e);
217+
};
218+
}
219+
220+
// Drop coordinator state for `parentSpanId` across both kinds.
221+
// Called by the time-to-display integration once a transaction has been
222+
// processed, since the per-span coordinator state is no longer relevant
223+
// after the native draw timestamps have been read.
224+
export function clearSpan(parentSpanId: string): void {
225+
for (const kind of [TTID, TTFD] as const) {
226+
const entry = registries[kind].get(parentSpanId);
227+
if (entry) {
228+
cancelPendingUpFlip(entry);
229+
registries[kind].delete(parentSpanId);
230+
}
231+
}
232+
}
233+
234+
export function _resetTimeToDisplayCoordinator(): void {
235+
for (const kind of [TTID, TTFD] as const) {
236+
for (const entry of registries[kind].values()) {
237+
cancelPendingUpFlip(entry);
238+
}
239+
registries[kind].clear();
240+
}
241+
}

0 commit comments

Comments
 (0)