|
| 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