diff --git a/CHANGELOG.md b/CHANGELOG.md index 431ea3d062..3b8022cbd4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,14 @@ > make sure you follow our [migration guide](https://docs.sentry.io/platforms/react-native/migration/) first. +## Unreleased + +### Features + +- Multi-instance `` / `` coordination ([#6090](https://github.com/getsentry/sentry-react-native/pull/6090)) + - New `ready` prop. When a screen has multiple async data sources, mount one `` per source — TTID/TTFD is recorded only when every instance reports `ready === true`. + - The existing `record` prop is unchanged BUT it is now deprecated in favor of `ready`. + ## 8.11.0 ### Features diff --git a/packages/core/src/js/tracing/integrations/timeToDisplayIntegration.ts b/packages/core/src/js/tracing/integrations/timeToDisplayIntegration.ts index b6214c1542..7f32cad1ef 100644 --- a/packages/core/src/js/tracing/integrations/timeToDisplayIntegration.ts +++ b/packages/core/src/js/tracing/integrations/timeToDisplayIntegration.ts @@ -4,6 +4,7 @@ import { debug } from '@sentry/core'; import { NATIVE } from '../../wrapper'; import { UI_LOAD_FULL_DISPLAY, UI_LOAD_INITIAL_DISPLAY } from '../ops'; +import { clearSpan as clearTimeToDisplayCoordinatorSpan } from '../timeToDisplayCoordinator'; import { SPAN_ORIGIN_AUTO_UI_TIME_TO_DISPLAY, SPAN_ORIGIN_MANUAL_UI_TIME_TO_DISPLAY } from '../origin'; import { getReactNavigationIntegration } from '../reactnavigation'; import { SEMANTIC_ATTRIBUTE_ROUTE_HAS_BEEN_SEEN } from '../semanticAttributes'; @@ -86,6 +87,8 @@ export const timeToDisplayIntegration = (): Integration => { event.timestamp = newTransactionEndTimestampSeconds; } + clearTimeToDisplayCoordinatorSpan(rootSpanId); + return event; }, }; diff --git a/packages/core/src/js/tracing/timeToDisplayCoordinator.ts b/packages/core/src/js/tracing/timeToDisplayCoordinator.ts new file mode 100644 index 0000000000..da638834b8 --- /dev/null +++ b/packages/core/src/js/tracing/timeToDisplayCoordinator.ts @@ -0,0 +1,248 @@ +/** + * Coordinator for multi-instance `` / `` + * components on a single screen (active span). + */ + +type Checkpoint = { ready: boolean }; +type Listener = () => void; + +interface SpanRegistry { + checkpoints: Map; + listeners: Set; + // this value answers the question "are all checkpoints on this span ready?" + // when the raw value goes from false to true, aggregateReady does NOT flip immediately, it gets + // scheduled with setTimeout(0) in `reevaluate` function + // + aggregateReady: boolean; + // when non-null, an up-flip is scheduled but has not yet been applied to `aggregateReady` + pendingUpFlip: ReturnType | null; + // `sticky` is used indicate checkpints that gets cleared when the screen fully unmounts + // it's useful + sticky: Set; +} + +const TTID = 'ttid'; +const TTFD = 'ttfd'; + +export type DisplayKind = typeof TTID | typeof TTFD; + +const registries: Record> = { + 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(), + aggregateReady: false, + pendingUpFlip: null, + sticky: new Set(), + }; + map.set(parentSpanId, entry); + } + return entry; +} + +function cancelPendingUpFlip(entry: SpanRegistry): void { + if (entry.pendingUpFlip !== null) { + clearTimeout(entry.pendingUpFlip); + entry.pendingUpFlip = null; + } +} + +function computeAggregate(entry: SpanRegistry): boolean { + if (entry.checkpoints.size === 0) { + return false; + } + for (const cp of entry.checkpoints.values()) { + if (!cp.ready) { + return false; + } + } + return true; +} + +// Recompute the raw aggregate and reconcile it with the cached `aggregateReady` +function reevaluate(entry: SpanRegistry): void { + const raw = computeAggregate(entry); + + if (raw === entry.aggregateReady) { + cancelPendingUpFlip(entry); + return; + } + + if (!raw) { + cancelPendingUpFlip(entry); + entry.aggregateReady = false; + notifyListeners(entry); + return; + } + + if (entry.pendingUpFlip !== null) { + return; + } + // the delay here is set to 0 because in React 18 that + // will schedule the callback to be run asynchronously after the shortest possible delay + entry.pendingUpFlip = setTimeout(() => { + entry.pendingUpFlip = null; + // Re-check on fire — a peer may have un-readied between schedule and now. + if (!computeAggregate(entry) || entry.aggregateReady) { + return; + } + entry.aggregateReady = true; + notifyListeners(entry); + }, 0); +} + +function notifyListeners(entry: SpanRegistry): void { + for (const listener of entry.listeners) { + listener(); + } +} + +function performCleanup(kind: DisplayKind, parentSpanId: string, entry: SpanRegistry): void { + if (entry.sticky.size > 0) { + return; + } + if (entry.checkpoints.size === 0 && entry.listeners.size === 0) { + cancelPendingUpFlip(entry); + registries[kind].delete(parentSpanId); + } +} + +// A bit of a hack but this is used to detect the premature-fire scenario +// where a not-ready checkpoint unmounts while every other checkpoint is ready: +// deleting it would let the aggregate flip to true and immediately record TTFD/TTID, +// even though the unmounting source never actually became ready. +function isSoleBlocker(entry: SpanRegistry, checkpointId: string): boolean { + if (entry.aggregateReady) { + return false; + } + if (entry.checkpoints.size <= 1) { + // because removing the only checkpoint leaves the registry empty + return false; + } + const cp = entry.checkpoints.get(checkpointId); + if (!cp || cp.ready) { + return false; + } + for (const [id, other] of entry.checkpoints) { + if (id === checkpointId) { + continue; + } + if (!other.ready) { + return false; + } + } + return true; +} + +export function registerCheckpoint( + kind: DisplayKind, + parentSpanId: string, + checkpointId: string, + ready: boolean, +): () => void { + const entry = getOrCreate(kind, parentSpanId); + + // Any new registration means the screen's component graph is changing. + // Drop leftover sticky entries from previous unmount cycles -- otherwise + // a remounted checkpoint would be permanently blockedю + if (entry.sticky.size > 0) { + for (const id of entry.sticky) { + entry.checkpoints.delete(id); + } + entry.sticky.clear(); + } + + entry.checkpoints.set(checkpointId, { ready }); + reevaluate(entry); + + return () => { + const e = registries[kind].get(parentSpanId); + if (!e) { + return; + } + // if the checkpoint is the only blocker then removing it would flip the + // aggregate to true and fire TTFD/TTID even though the unmounting source never became ready. + // that's why we use `sticky` here to indicate that it gets cleared when the screen fully unmounts + if (isSoleBlocker(e, checkpointId)) { + e.sticky.add(checkpointId); + performCleanup(kind, parentSpanId, e); + return; + } + if (e.checkpoints.delete(checkpointId)) { + e.sticky.delete(checkpointId); + reevaluate(e); + } + performCleanup(kind, parentSpanId, e); + }; +} + +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; + reevaluate(entry); +} + +// Returns 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); + return !!entry && entry.aggregateReady; +} + +// 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 aggregate-ready transitions for a given span +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); + }; +} + +// Drop coordinator state for `parentSpanId` across both kinds. +// Called by the time-to-display integration once a transaction has been +// processed, since the per-span coordinator state is no longer relevant +// after the native draw timestamps have been read. +export function clearSpan(parentSpanId: string): void { + for (const kind of [TTID, TTFD] as const) { + const entry = registries[kind].get(parentSpanId); + if (entry) { + cancelPendingUpFlip(entry); + registries[kind].delete(parentSpanId); + } + } +} + +export function _resetTimeToDisplayCoordinator(): void { + for (const kind of [TTID, TTFD] as const) { + for (const entry of registries[kind].values()) { + cancelPendingUpFlip(entry); + } + registries[kind].clear(); + } +} diff --git a/packages/core/src/js/tracing/timetodisplay.tsx b/packages/core/src/js/tracing/timetodisplay.tsx index 7bb74445b5..1814e24a9e 100644 --- a/packages/core/src/js/tracing/timetodisplay.tsx +++ b/packages/core/src/js/tracing/timetodisplay.tsx @@ -13,12 +13,14 @@ import { 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,18 @@ const spanFrameDataMap = new Map(); export type TimeToDisplayProps = { children?: React.ReactNode; + /** @deprecated Use `ready` instead. `record` will be removed in the next major version. **/ record?: boolean; + // Marks this checkpoint as ready. + ready?: boolean; }; /** * Component to measure time to initial display. * - * The initial display is recorded when the component prop `record` is true. - * - * + * Usage example: + * + * */ export function TimeToInitialDisplay(props: TimeToDisplayProps): React.ReactElement { const activeSpan = getActiveSpan(); @@ -76,8 +81,10 @@ export function TimeToInitialDisplay(props: TimeToDisplayProps): React.ReactElem } const parentSpanId = activeSpan && spanToJSON(activeSpan).span_id; + const initialDisplay = useCoordinatedDisplay('ttid', parentSpanId, props); + return ( - + {props.children} ); @@ -86,20 +93,99 @@ export function TimeToInitialDisplay(props: TimeToDisplayProps): React.ReactElem /** * Component to measure time to full display. * - * The initial display is recorded when the component prop `record` is true. - * - * + * Usage example: + * + * */ export function TimeToFullDisplay(props: TimeToDisplayProps): React.ReactElement { const activeSpan = getActiveSpan(); const parentSpanId = activeSpan && spanToJSON(activeSpan).span_id; + const fullDisplay = useCoordinatedDisplay('ttfd', parentSpanId, props); + return ( - + {props.children} ); } +let nextCheckpointId = 0; + +function useCoordinatedDisplay( + kind: DisplayKind, + parentSpanId: string | undefined, + props: TimeToDisplayProps, +): boolean { + const checkpointIdRef = useRef(null); + if (checkpointIdRef.current === null) { + checkpointIdRef.current = `cp-${nextCheckpointId++}`; + } + const checkpointId = checkpointIdRef.current; + const [, force] = useReducer((x: number) => x + 1, 0); + + // useRegistry means we might use multiple TTID/TTFD components + const useRegistry = props.ready !== undefined; + const localReady = useRegistry ? !!props.ready : !!props.record; + + // We do it this way because we don't want warnings being thrown on every re-render + const warnedRef = useRef(false); + useEffect(() => { + if (!__DEV__ || warnedRef.current) return; + if (props.ready !== undefined && props.record !== undefined) { + warnedRef.current = true; + debug.warn('[TimeToDisplay] Both `ready` and `record` were provided — ignoring `record`.'); + } else if (props.record !== undefined) { + warnedRef.current = true; + debug.warn('[TimeToDisplay] The `record` prop is deprecated. Use `ready` instead.'); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + if (!parentSpanId || !useRegistry) { + return undefined; + } + return subscribe(kind, parentSpanId, force); + }, [kind, parentSpanId, useRegistry]); + + // Tracks if this component's checkpoint is currently registered with the coordinator + const registeredRef = useRef(false); + + // Register on mount / when the active span changes + useEffect(() => { + if (!parentSpanId || !useRegistry) { + return undefined; + } + const unregister = registerCheckpoint(kind, parentSpanId, checkpointId, localReady); + registeredRef.current = true; + return () => { + registeredRef.current = false; + unregister(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [kind, parentSpanId, useRegistry, checkpointId]); + + // Propagate ready transitions to the registry + useEffect(() => { + if (!parentSpanId || !useRegistry) { + return; + } + updateCheckpoint(kind, parentSpanId, checkpointId, localReady); + }, [kind, parentSpanId, useRegistry, checkpointId, localReady]); + + // Main logic for "readiness" + if (!parentSpanId) { + return false; + } + if (!useRegistry) { + return localReady; + } + if (!registeredRef.current) { + return localReady && isAllReady(kind, parentSpanId); + } + return isAllReady(kind, parentSpanId); +} + function TimeToDisplay(props: { children?: React.ReactNode; initialDisplay?: boolean; @@ -435,7 +521,12 @@ function createTimeToDisplay({ }; }); - return ; + // gate both legacy `record` and the new `ready` checkpoint on focus + // + // the idea here is that wrappers built via createTimeToFullDisplay/createTimeToInitialDisplay + // can only record TTID/TTFD on a focused screen + const gatedReady = props.ready === undefined ? undefined : focused && props.ready; + return ; }; TimeToDisplayWrapper.displayName = 'TimeToDisplayWrapper'; diff --git a/packages/core/test/tracing/timeToDisplayCoordinator.test.ts b/packages/core/test/tracing/timeToDisplayCoordinator.test.ts new file mode 100644 index 0000000000..76b11dfb66 --- /dev/null +++ b/packages/core/test/tracing/timeToDisplayCoordinator.test.ts @@ -0,0 +1,140 @@ +import { + _resetTimeToDisplayCoordinator, + clearSpan, + hasAnyCheckpoints, + isAllReady, + registerCheckpoint, + subscribe, + updateCheckpoint, +} from '../../src/js/tracing/timeToDisplayCoordinator'; + +const SPAN_FIRST = 'span-first'; +const SPAN_SECOND = 'span-second'; + +function flushDefer(): void { + jest.runOnlyPendingTimers(); +} + +describe('timeToDisplayCoordinator', () => { + beforeEach(() => { + jest.useFakeTimers(); + _resetTimeToDisplayCoordinator(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + test('empty registry is not ready', () => { + expect(isAllReady('ttfd', SPAN_FIRST)).toBe(false); + expect(hasAnyCheckpoints('ttfd', SPAN_FIRST)).toBe(false); + }); + + test('single not-ready checkpoint blocks', () => { + registerCheckpoint('ttfd', SPAN_FIRST, 'a', false); + expect(isAllReady('ttfd', SPAN_FIRST)).toBe(false); + }); + + test('single ready checkpoint resolves', () => { + registerCheckpoint('ttfd', SPAN_FIRST, 'a', true); + flushDefer(); + expect(isAllReady('ttfd', SPAN_FIRST)).toBe(true); + }); + + test('all ready resolves; one not-ready blocks', () => { + registerCheckpoint('ttfd', SPAN_FIRST, 'a', true); + registerCheckpoint('ttfd', SPAN_FIRST, 'b', true); + registerCheckpoint('ttfd', SPAN_FIRST, 'c', false); + expect(isAllReady('ttfd', SPAN_FIRST)).toBe(false); + + updateCheckpoint('ttfd', SPAN_FIRST, 'c', true); + flushDefer(); + expect(isAllReady('ttfd', SPAN_FIRST)).toBe(true); + }); + + test('late-registering not-ready checkpoint makes the aggregate "non-ready"', () => { + registerCheckpoint('ttfd', SPAN_FIRST, 'a', true); + registerCheckpoint('ttfd', SPAN_FIRST, 'b', true); + flushDefer(); + expect(isAllReady('ttfd', SPAN_FIRST)).toBe(true); + + registerCheckpoint('ttfd', SPAN_FIRST, 'c', false); + expect(isAllReady('ttfd', SPAN_FIRST)).toBe(false); + }); + + test('different spans are independent', () => { + registerCheckpoint('ttfd', SPAN_FIRST, 'a', true); + registerCheckpoint('ttfd', SPAN_SECOND, 'a', false); + flushDefer(); + expect(isAllReady('ttfd', SPAN_FIRST)).toBe(true); + expect(isAllReady('ttfd', SPAN_SECOND)).toBe(false); + }); + + test('different kinds of spans are independent', () => { + registerCheckpoint('ttfd', SPAN_FIRST, 'a', true); + registerCheckpoint('ttid', SPAN_FIRST, 'a', false); + flushDefer(); + expect(isAllReady('ttfd', SPAN_FIRST)).toBe(true); + expect(isAllReady('ttid', SPAN_FIRST)).toBe(false); + }); + + test('subscribers are notified only on aggregate-ready flips', () => { + const listener = jest.fn(); + subscribe('ttfd', SPAN_FIRST, listener); + + const unregister = registerCheckpoint('ttfd', SPAN_FIRST, 'a', false); + expect(listener).toHaveBeenCalledTimes(0); + updateCheckpoint('ttfd', SPAN_FIRST, 'a', true); + flushDefer(); + expect(listener).toHaveBeenCalledTimes(1); + unregister(); + expect(listener).toHaveBeenCalledTimes(2); + }); + + test('unsubscribe stops further notifications', () => { + const listener = jest.fn(); + const unsubscribe = subscribe('ttfd', SPAN_FIRST, listener); + unsubscribe(); + registerCheckpoint('ttfd', SPAN_FIRST, 'a', true); + expect(listener).not.toHaveBeenCalled(); + }); + + test('subscribers on one span ignore changes on another span', () => { + const listener = jest.fn(); + subscribe('ttfd', SPAN_FIRST, listener); + registerCheckpoint('ttfd', SPAN_SECOND, 'a', true); + expect(listener).not.toHaveBeenCalled(); + }); + + test('up-flip is deferred', () => { + registerCheckpoint('ttfd', SPAN_FIRST, 'header', true); + expect(isAllReady('ttfd', SPAN_FIRST)).toBe(false); + + registerCheckpoint('ttfd', SPAN_FIRST, 'sidebar', false); + + flushDefer(); + expect(isAllReady('ttfd', SPAN_FIRST)).toBe(false); + updateCheckpoint('ttfd', SPAN_FIRST, 'sidebar', true); + flushDefer(); + expect(isAllReady('ttfd', SPAN_FIRST)).toBe(true); + }); + + test('down-flip is immediate', () => { + registerCheckpoint('ttfd', SPAN_FIRST, 'a', true); + flushDefer(); + expect(isAllReady('ttfd', SPAN_FIRST)).toBe(true); + + registerCheckpoint('ttfd', SPAN_FIRST, 'b', false); + expect(isAllReady('ttfd', SPAN_FIRST)).toBe(false); + }); + + test('sole-blocker unmount with no remount keeps the aggregate blocked', () => { + registerCheckpoint('ttfd', SPAN_FIRST, 'header', true); + const unregisterLoader = registerCheckpoint('ttfd', SPAN_FIRST, 'loader', false); + + unregisterLoader(); // sticky + flushDefer(); + expect(isAllReady('ttfd', SPAN_FIRST)).toBe(false); + expect(hasAnyCheckpoints('ttfd', SPAN_FIRST)).toBe(true); + }); +}); diff --git a/packages/core/test/tracing/timetodisplay.test.tsx b/packages/core/test/tracing/timetodisplay.test.tsx index 2542fff3f8..3c59f86afa 100644 --- a/packages/core/test/tracing/timetodisplay.test.tsx +++ b/packages/core/test/tracing/timetodisplay.test.tsx @@ -20,10 +20,11 @@ import * as mockedtimetodisplaynative from './mockedtimetodisplaynative'; jest.mock('../../src/js/tracing/timetodisplaynative', () => mockedtimetodisplaynative); -import { render } from '@testing-library/react-native'; +import { act, render } from '@testing-library/react-native'; import * as React from 'react'; import { timeToDisplayIntegration } from '../../src/js/tracing/integrations/timeToDisplayIntegration'; +import { _resetTimeToDisplayCoordinator } from '../../src/js/tracing/timeToDisplayCoordinator'; import { SPAN_ORIGIN_MANUAL_UI_TIME_TO_DISPLAY } from '../../src/js/tracing/origin'; import { SEMANTIC_ATTRIBUTE_SENTRY_OP, @@ -48,6 +49,28 @@ jest.mock('../../src/js/utils/environment', () => ({ const { mockRecordedTimeToDisplay, getMockedOnDrawReportedProps, clearMockedOnDrawReportedProps } = mockedtimetodisplaynative; +/** Flush the coordinator's deferred up-flip + any consequent React re-renders. */ +function flushReadyDefer(): void { + act(() => { + jest.runOnlyPendingTimers(); + }); +} + +/** + * The mock records every render of every native draw reporter. We slice the + * tail of the prop log to inspect the converged post-effect state of all + * currently-mounted reporters for a given span. + */ +function tailHasFullDisplay(parentSpanId: string, mountedReporterCount: number): boolean { + const props = getMockedOnDrawReportedProps().filter(p => p.parentSpanId === parentSpanId); + return props.slice(-mountedReporterCount).some(p => p.fullDisplay === true); +} + +function tailHasInitialDisplay(parentSpanId: string, mountedReporterCount: number): boolean { + const props = getMockedOnDrawReportedProps().filter(p => p.parentSpanId === parentSpanId); + return props.slice(-mountedReporterCount).some(p => p.initialDisplay === true); +} + jest.useFakeTimers({ advanceTimers: true, doNotFake: ['performance'], // Keep real performance API @@ -58,6 +81,7 @@ describe('TimeToDisplay', () => { beforeEach(() => { clearMockedOnDrawReportedProps(); + _resetTimeToDisplayCoordinator(); getCurrentScope().clear(); getIsolationScope().clear(); getGlobalScope().clear(); @@ -744,4 +768,158 @@ describe('Frame Data', () => { expect(ttidSpan).toBeDefined(); expect(ttidSpan!.data).not.toHaveProperty('frames.delay'); }); + + describe('multi-instance', () => { + test('legacy: single instance behaves identically to today', () => { + startSpanManual({ name: 'Screen', startTime: secondAgoTimestampMs() }, (activeSpan: Span | undefined) => { + const spanId = spanToJSON(activeSpan!).span_id; + render(); + flushReadyDefer(); + expect(tailHasFullDisplay(spanId, 1)).toBe(true); + activeSpan?.end(); + }); + }); + + test('two ready=false instances do not emit', () => { + startSpanManual({ name: 'Screen', startTime: secondAgoTimestampMs() }, (activeSpan: Span | undefined) => { + const spanId = spanToJSON(activeSpan!).span_id; + render( + <> + + + , + ); + flushReadyDefer(); + expect(tailHasFullDisplay(spanId, 2)).toBe(false); + activeSpan?.end(); + }); + }); + + test('two ready instances emit only when both are ready', () => { + startSpanManual({ name: 'Screen', startTime: secondAgoTimestampMs() }, (activeSpan: Span | undefined) => { + const spanId = spanToJSON(activeSpan!).span_id; + + const Screen = ({ a, b }: { a: boolean; b: boolean }) => ( + <> + + + + ); + + const tree = render(); + flushReadyDefer(); + expect(tailHasFullDisplay(spanId, 2)).toBe(false); + + act(() => tree.rerender()); + flushReadyDefer(); + expect(tailHasFullDisplay(spanId, 2)).toBe(false); + + act(() => tree.rerender()); + flushReadyDefer(); + expect(tailHasFullDisplay(spanId, 2)).toBe(true); + + activeSpan?.end(); + }); + }); + + test('late-mounting makes a previously ready aggregate non-ready', () => { + startSpanManual({ name: 'Screen', startTime: secondAgoTimestampMs() }, (activeSpan: Span | undefined) => { + const spanId = spanToJSON(activeSpan!).span_id; + + const Screen = ({ showLate, lateReady }: { showLate: boolean; lateReady: boolean }) => ( + <> + + {showLate ? : null} + + ); + + const tree = render(); + flushReadyDefer(); + expect(tailHasFullDisplay(spanId, 1)).toBe(true); + + act(() => tree.rerender()); + flushReadyDefer(); + expect(tailHasFullDisplay(spanId, 2)).toBe(false); + + act(() => tree.rerender()); + flushReadyDefer(); + expect(tailHasFullDisplay(spanId, 2)).toBe(true); + + activeSpan?.end(); + }); + }); + + test('unmounting the sole blocker does NOT emit ready', () => { + startSpanManual({ name: 'Screen', startTime: secondAgoTimestampMs() }, (activeSpan: Span | undefined) => { + const spanId = spanToJSON(activeSpan!).span_id; + + const Screen = ({ showBlocker }: { showBlocker: boolean }) => ( + <> + + {showBlocker ? : null} + + ); + + const tree = render(); + flushReadyDefer(); + expect(tailHasFullDisplay(spanId, 2)).toBe(false); + + act(() => tree.rerender()); + flushReadyDefer(); + expect(tailHasFullDisplay(spanId, 1)).toBe(false); + + activeSpan?.end(); + }); + }); + + test('unmounting a non-sole-blocker resolves the aggregate when remaining peers are ready', () => { + startSpanManual({ name: 'Screen', startTime: secondAgoTimestampMs() }, (activeSpan: Span | undefined) => { + const spanId = spanToJSON(activeSpan!).span_id; + + const Screen = ({ aReady, showB }: { aReady: boolean; showB: boolean }) => ( + <> + + {showB ? : null} + + ); + + const tree = render(); + flushReadyDefer(); + expect(tailHasFullDisplay(spanId, 2)).toBe(false); + + act(() => tree.rerender()); + flushReadyDefer(); + expect(tailHasFullDisplay(spanId, 1)).toBe(false); + + act(() => tree.rerender()); + flushReadyDefer(); + expect(tailHasFullDisplay(spanId, 1)).toBe(true); + + activeSpan?.end(); + }); + }); + + test('different active spans have independent registries', () => { + let firstSpanId = ''; + let secondSpanId = ''; + + startSpanManual({ name: 'Screen A', startTime: secondAgoTimestampMs() }, (activeSpan: Span | undefined) => { + firstSpanId = spanToJSON(activeSpan!).span_id; + render(); + activeSpan?.end(); + }); + + clearMockedOnDrawReportedProps(); + + startSpanManual({ name: 'Screen B', startTime: secondAgoTimestampMs() }, (activeSpan: Span | undefined) => { + secondSpanId = spanToJSON(activeSpan!).span_id; + render(); + flushReadyDefer(); + expect(tailHasFullDisplay(secondSpanId, 1)).toBe(false); + activeSpan?.end(); + }); + + expect(firstSpanId).not.toEqual(secondSpanId); + }); + }); });