-
-
Notifications
You must be signed in to change notification settings - Fork 365
Multi-instance <TimeToInitialDisplay> / <TimeToFullDisplay> coordination; a multi-signal TTID/TTFD system
#6090
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weโll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 5 commits
9b9178c
f156953
cb268c8
aa62b62
0598e1b
a9937e9
a420720
9ef82b8
273758a
e6b6c07
a54077b
324a6ee
57d1b67
f274498
1582d1d
71eeb91
558a01a
4cee212
82cca10
1c2fb3b
2b724b9
c6f8584
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,139 @@ | ||
| /** | ||
| * Coordinator for multi-instance `<TimeToInitialDisplay>` / `<TimeToFullDisplay>` | ||
| * components on a single screen (active span). | ||
| */ | ||
|
|
||
| type Checkpoint = { ready: boolean }; | ||
| type Listener = () => void; | ||
|
|
||
| interface SpanRegistry { | ||
| checkpoints: Map<string, Checkpoint>; | ||
| listeners: Set<Listener>; | ||
| } | ||
|
|
||
| const TTID = 'ttid'; | ||
| const TTFD = 'ttfd'; | ||
|
|
||
| export type DisplayKind = typeof TTID | typeof TTFD; | ||
|
|
||
| const registries: Record<DisplayKind, Map<string, SpanRegistry>> = { | ||
| ttid: new Map(), | ||
| ttfd: new Map(), | ||
| }; | ||
|
|
||
| function getOrCreate(kind: DisplayKind, parentSpanId: string): SpanRegistry { | ||
| const map = registries[kind]; | ||
| let entry = map.get(parentSpanId); | ||
| if (!entry) { | ||
| entry = { | ||
| checkpoints: new Map(), | ||
| listeners: new Set() | ||
| }; | ||
| map.set(parentSpanId, entry); | ||
| } | ||
| return entry; | ||
| } | ||
|
|
||
| function performCleanup(kind: DisplayKind, parentSpanId: string, entry: SpanRegistry): void { | ||
| if (entry.checkpoints.size === 0 && entry.listeners.size === 0) { | ||
| registries[kind].delete(parentSpanId); | ||
| } | ||
| } | ||
|
Check warning on line 41 in packages/core/src/js/tracing/timeToDisplayCoordinator.ts
|
||
|
sentry-warden[bot] marked this conversation as resolved.
|
||
|
|
||
| /** | ||
| * Register a checkpoint under (kind, parentSpanId). Returns an unregister fn. | ||
| */ | ||
| export function registerCheckpoint( | ||
| kind: DisplayKind, | ||
| parentSpanId: string, | ||
| checkpointId: string, | ||
| ready: boolean, | ||
| ): () => void { | ||
| const entry = getOrCreate(kind, parentSpanId); | ||
| entry.checkpoints.set(checkpointId, { ready }); | ||
| notify(entry); | ||
|
|
||
| return () => { | ||
| const e = registries[kind].get(parentSpanId); | ||
| if (!e) { | ||
| return; | ||
| } | ||
| if (e.checkpoints.delete(checkpointId)) { | ||
| notify(e); | ||
| } | ||
| performCleanup(kind, parentSpanId, e); | ||
| }; | ||
|
cursor[bot] marked this conversation as resolved.
|
||
| } | ||
|
|
||
| /** | ||
| * Update an existing checkpoint's ready state. | ||
| */ | ||
| export function updateCheckpoint( | ||
| kind: DisplayKind, | ||
| parentSpanId: string, | ||
| checkpointId: string, | ||
| ready: boolean, | ||
| ): void { | ||
| const entry = registries[kind].get(parentSpanId); | ||
| const cp = entry?.checkpoints.get(checkpointId); | ||
| if (!entry || !cp || cp.ready === ready) { | ||
| return; | ||
| } | ||
| cp.ready = ready; | ||
| notify(entry); | ||
| } | ||
|
|
||
| /** | ||
| * True if at least one checkpoint is registered AND all checkpoints are ready. | ||
| */ | ||
| export function isAllReady(kind: DisplayKind, parentSpanId: string): boolean { | ||
| const entry = registries[kind].get(parentSpanId); | ||
| if (!entry || entry.checkpoints.size === 0) { | ||
| return false; | ||
| } | ||
| for (const cp of entry.checkpoints.values()) { | ||
| if (!cp.ready) { | ||
| return false; | ||
| } | ||
| } | ||
| return true; | ||
| } | ||
|
|
||
| /** | ||
| * Returns true if there is at least one registered checkpoint on this span. | ||
| */ | ||
| export function hasAnyCheckpoints(kind: DisplayKind, parentSpanId: string): boolean { | ||
| const entry = registries[kind].get(parentSpanId); | ||
| return !!entry && entry.checkpoints.size > 0; | ||
| } | ||
|
|
||
| /** | ||
| * Subscribe to any checkpoint state change for a given span. The listener is | ||
| * called synchronously after each register/update/unregister event. | ||
| */ | ||
| export function subscribe(kind: DisplayKind, parentSpanId: string, listener: Listener): () => void { | ||
| const entry = getOrCreate(kind, parentSpanId); | ||
| entry.listeners.add(listener); | ||
| return () => { | ||
| const e = registries[kind].get(parentSpanId); | ||
| if (!e) { | ||
| return; | ||
| } | ||
| e.listeners.delete(listener); | ||
| performCleanup(kind, parentSpanId, e); | ||
| }; | ||
| } | ||
|
|
||
| function notify(entry: SpanRegistry): void { | ||
| for (const listener of entry.listeners) { | ||
| listener(); | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Test-only. Clears all coordinator state. | ||
| */ | ||
| export function _resetTimeToDisplayCoordinator(): void { | ||
| registries.ttid.clear(); | ||
| registries.ttfd.clear(); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -13,12 +13,14 @@ | |
| startInactiveSpan, | ||
| } from '@sentry/core'; | ||
| import * as React from 'react'; | ||
| import { useState } from 'react'; | ||
| import { useEffect, useReducer, useRef, useState } from 'react'; | ||
|
|
||
| import type { NativeFramesResponse } from '../NativeRNSentry'; | ||
|
|
||
| import { NATIVE } from '../wrapper'; | ||
| import { SPAN_ORIGIN_AUTO_UI_TIME_TO_DISPLAY, SPAN_ORIGIN_MANUAL_UI_TIME_TO_DISPLAY } from './origin'; | ||
| import type { DisplayKind } from './timeToDisplayCoordinator'; | ||
| import { isAllReady, registerCheckpoint, subscribe, updateCheckpoint } from './timeToDisplayCoordinator'; | ||
| import { getRNSentryOnDrawReporter } from './timetodisplaynative'; | ||
| import { setSpanDurationAsMeasurement, setSpanDurationAsMeasurementOnSpan } from './utils'; | ||
|
|
||
|
|
@@ -59,15 +61,31 @@ | |
|
|
||
| export type TimeToDisplayProps = { | ||
|
alwx marked this conversation as resolved.
|
||
| children?: React.ReactNode; | ||
| /** | ||
| * @deprecated Use `ready` instead. `record` and `ready` are equivalent; | ||
| * `record` will be removed in the next major version. | ||
| */ | ||
| record?: boolean; | ||
| /** | ||
| * Marks this checkpoint as ready. The display is recorded only when every | ||
| * `<TimeToFullDisplay>` / `<TimeToInitialDisplay>` mounted under the | ||
| * currently active span reports `ready === true`. | ||
| * | ||
| * <TimeToFullDisplay ready={feedReady} /> | ||
| * <TimeToFullDisplay ready={sidebarReady} /> | ||
| */ | ||
| ready?: boolean; | ||
|
Check warning on line 77 in packages/core/src/js/tracing/timetodisplay.tsx
|
||
|
sentry-warden[bot] marked this conversation as resolved.
|
||
| }; | ||
|
|
||
| /** | ||
| * Component to measure time to initial display. | ||
| * | ||
| * The initial display is recorded when the component prop `record` is true. | ||
| * Single instance: | ||
| * <TimeToInitialDisplay ready={isLoaded} /> | ||
| * | ||
| * <TimeToInitialDisplay record /> | ||
| * Multiple instances coordinating on one screen: | ||
| * <TimeToInitialDisplay ready={headerLoaded} /> | ||
| * <TimeToInitialDisplay ready={contentLoaded} /> | ||
| */ | ||
| export function TimeToInitialDisplay(props: TimeToDisplayProps): React.ReactElement { | ||
| const activeSpan = getActiveSpan(); | ||
|
|
@@ -76,8 +94,10 @@ | |
| } | ||
|
|
||
| const parentSpanId = activeSpan && spanToJSON(activeSpan).span_id; | ||
| const initialDisplay = useCoordinatedDisplay('ttid', parentSpanId, props); | ||
|
|
||
| return ( | ||
| <TimeToDisplay initialDisplay={props.record} parentSpanId={parentSpanId}> | ||
| <TimeToDisplay initialDisplay={initialDisplay} parentSpanId={parentSpanId}> | ||
| {props.children} | ||
| </TimeToDisplay> | ||
| ); | ||
|
|
@@ -86,20 +106,93 @@ | |
| /** | ||
| * Component to measure time to full display. | ||
| * | ||
| * The initial display is recorded when the component prop `record` is true. | ||
| * Single instance: | ||
| * <TimeToFullDisplay ready={isLoaded} /> | ||
| * | ||
| * <TimeToInitialDisplay record /> | ||
| * Multiple instances coordinating on one screen: | ||
| * <TimeToFullDisplay ready={feedReady} /> | ||
| * <TimeToFullDisplay ready={sidebarReady} /> | ||
| */ | ||
| export function TimeToFullDisplay(props: TimeToDisplayProps): React.ReactElement { | ||
| const activeSpan = getActiveSpan(); | ||
| const parentSpanId = activeSpan && spanToJSON(activeSpan).span_id; | ||
| const fullDisplay = useCoordinatedDisplay('ttfd', parentSpanId, props); | ||
|
|
||
| return ( | ||
| <TimeToDisplay fullDisplay={props.record} parentSpanId={parentSpanId}> | ||
| <TimeToDisplay fullDisplay={fullDisplay} parentSpanId={parentSpanId}> | ||
| {props.children} | ||
| </TimeToDisplay> | ||
| ); | ||
| } | ||
|
sentry-warden[bot] marked this conversation as resolved.
|
||
|
|
||
| /** | ||
| * Every `<TimeToInitialDisplay>` / `<TimeToFullDisplay>` instance registers as | ||
| * a checkpoint under the active span. The aggregate is ready if every | ||
| * checkpoint reports ready. | ||
| */ | ||
| /** | ||
| * Module-local counter used to mint stable, unique checkpoint ids per | ||
| * component instance without requiring React 18's `useId`. | ||
| */ | ||
| let nextCheckpointId = 0; | ||
|
|
||
| function useCoordinatedDisplay( | ||
| kind: DisplayKind, | ||
| parentSpanId: string | undefined, | ||
| props: TimeToDisplayProps, | ||
| ): boolean { | ||
| // Stable per-instance id. `useRef` is available since React 16.8. | ||
| const checkpointIdRef = useRef<string | null>(null); | ||
| if (checkpointIdRef.current === null) { | ||
| checkpointIdRef.current = `cp-${nextCheckpointId++}`; | ||
| } | ||
| const checkpointId = checkpointIdRef.current; | ||
| const [, force] = useReducer((x: number) => x + 1, 0); | ||
|
|
||
| // `ready` takes precedence when both are provided. | ||
| const localReady = props.ready !== undefined ? !!props.ready : !!props.record; | ||
|
|
||
| if (__DEV__) { | ||
| if (props.ready !== undefined && props.record !== undefined) { | ||
| debug.warn('[TimeToDisplay] Both `ready` and `record` were provided โ ignoring `record`.'); | ||
| } | ||
| if (props.record !== undefined) { | ||
| debug.warn('[TimeToDisplay] The `record` prop is deprecated. Use `ready` instead.'); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Let's create an issue on v9 to remove the deprecation
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| } | ||
| } | ||
|
sentry-warden[bot] marked this conversation as resolved.
Outdated
|
||
|
|
||
| // Subscribe FIRST so this component receives its own registration notify | ||
| // (and any peer notifications) on mount. | ||
| useEffect(() => { | ||
| if (!parentSpanId) { | ||
| return undefined; | ||
| } | ||
| return subscribe(kind, parentSpanId, force); | ||
| }, [kind, parentSpanId]); | ||
|
|
||
| // Register on mount / when the active span changes; unregister on unmount. | ||
| useEffect(() => { | ||
| if (!parentSpanId) { | ||
| return undefined; | ||
| } | ||
| return registerCheckpoint(kind, parentSpanId, checkpointId, localReady); | ||
| // eslint-disable-next-line react-hooks/exhaustive-deps | ||
| }, [kind, parentSpanId, checkpointId]); | ||
|
|
||
| // Propagate ready transitions to the registry. | ||
| useEffect(() => { | ||
| if (!parentSpanId) { | ||
| return; | ||
| } | ||
| updateCheckpoint(kind, parentSpanId, checkpointId, localReady); | ||
| }, [kind, parentSpanId, checkpointId, localReady]); | ||
|
|
||
| if (!parentSpanId) { | ||
| return false; | ||
| } | ||
| return isAllReady(kind, parentSpanId); | ||
|
Check warning on line 193 in packages/core/src/js/tracing/timetodisplay.tsx
|
||
|
sentry-warden[bot] marked this conversation as resolved.
Outdated
sentry-warden[bot] marked this conversation as resolved.
cursor[bot] marked this conversation as resolved.
|
||
| } | ||
|
sentry-warden[bot] marked this conversation as resolved.
|
||
|
|
||
|
cursor[bot] marked this conversation as resolved.
|
||
| function TimeToDisplay(props: { | ||
| children?: React.ReactNode; | ||
| initialDisplay?: boolean; | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.