diff --git a/CHANGELOG.md b/CHANGELOG.md index 5837019405..6ac49f2859 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,18 @@ ## 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`. + ```tsx + + + // TTFD fires when both are ready. + ``` + - 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. + - `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. + ### Fixes - Fix the issue with uploading iOS Debug Symbols in EAS Build when using pnpm ([#6076](https://github.com/getsentry/sentry-react-native/issues/6076)) diff --git a/packages/core/src/js/tracing/timeToDisplayCoordinator.ts b/packages/core/src/js/tracing/timeToDisplayCoordinator.ts new file mode 100644 index 0000000000..0be223d542 --- /dev/null +++ b/packages/core/src/js/tracing/timeToDisplayCoordinator.ts @@ -0,0 +1,164 @@ +/** + * Coordinator for multi-instance `` / `` + * components on a single screen (active span). + */ + +type Checkpoint = { ready: boolean }; +type Listener = () => void; + +interface SpanRegistry { + checkpoints: Map; + listeners: Set; + /** + * Last-observed aggregate ready state. Used to avoid waking subscribers when + * a checkpoint change does not flip the aggregate — the dominant lifecycle + * pattern is "all checkpoints register as not-ready, then flip to ready over + * time", and only the final flip needs to notify. + */ + aggregateReady: boolean; +} + +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, + }; + map.set(parentSpanId, entry); + } + return entry; +} + +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 aggregate; if it flipped, update the cached value and notify. + * No-op when the aggregate is unchanged — this is what avoids the O(N²) + * notify-storm when many checkpoints register/update without crossing the + * aggregate boundary. + */ +function reevaluate(entry: SpanRegistry): void { + const next = computeAggregate(entry); + if (next === entry.aggregateReady) { + return; + } + entry.aggregateReady = next; + for (const listener of entry.listeners) { + listener(); + } +} + +function performCleanup(kind: DisplayKind, parentSpanId: string, entry: SpanRegistry): void { + if (entry.checkpoints.size === 0 && entry.listeners.size === 0) { + registries[kind].delete(parentSpanId); + } +} + +/** + * 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 }); + reevaluate(entry); + + return () => { + const e = registries[kind].get(parentSpanId); + if (!e) { + return; + } + if (e.checkpoints.delete(checkpointId)) { + reevaluate(e); + } + performCleanup(kind, parentSpanId, e); + }; +} + +/** + * 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; + reevaluate(entry); +} + +/** + * True if at least one checkpoint is registered AND all checkpoints are ready. + * Reads the cached aggregate — O(1). + */ +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. The listener is + * called only when the aggregate flips, not on every individual checkpoint + * change. + */ +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); + }; +} + +/** + * Test-only. Clears all coordinator state. + */ +export function _resetTimeToDisplayCoordinator(): void { + registries.ttid.clear(); + registries.ttfd.clear(); +} diff --git a/packages/core/src/js/tracing/timetodisplay.tsx b/packages/core/src/js/tracing/timetodisplay.tsx index 7bb74445b5..bc9a330298 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,31 @@ const spanFrameDataMap = new Map(); export type TimeToDisplayProps = { 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 + * `` / `` mounted under the + * currently active span reports `ready === true`. + * + * + * + */ + ready?: boolean; }; /** * Component to measure time to initial display. * - * The initial display is recorded when the component prop `record` is true. + * Single instance: + * * - * + * Multiple instances coordinating on one screen: + * + * */ export function TimeToInitialDisplay(props: TimeToDisplayProps): React.ReactElement { const activeSpan = getActiveSpan(); @@ -76,8 +94,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 +106,121 @@ 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. + * Single instance: + * * - * + * Multiple instances coordinating on one screen: + * + * */ 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} ); } +/** + * Resolves the boolean passed to the underlying native draw reporter. + * + * Two semantically-distinct modes preserve backward compatibility: + * + * 1. **Legacy (`record`)** — the component is independent. The reporter + * receives `!!props.record` directly. Multiple `record`-only peers don't + * coordinate; the native side resolves them via last-write-wins, exactly + * as before this change. + * + * 2. **Registry (`ready`)** — the component is a checkpoint. It registers + * under the active span and the reporter receives the per-span aggregate. + * Multiple `ready` peers coordinate: every one of them must be ready + * before any of their reporters emits true. + * + * Mode is selected per-instance: `ready !== undefined` opts into registry + * mode. A bare `` (no props) is legacy mode with + * `record=false` — a no-op, same as today. + * + * `ready` and `record` will be unified into one prop in the next major when + * `record` is removed. + */ +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(null); + if (checkpointIdRef.current === null) { + checkpointIdRef.current = `cp-${nextCheckpointId++}`; + } + const checkpointId = checkpointIdRef.current; + const [, force] = useReducer((x: number) => x + 1, 0); + + const useRegistry = props.ready !== undefined; + const localReady = useRegistry ? !!props.ready : !!props.record; + + // Emit deprecation / conflict warnings once per component instance. + 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 + }, []); + + // Subscribe FIRST so this component receives its own registration notify + // (and any peer notifications) on mount. Only registry-mode components + // need peer notifications. + useEffect(() => { + if (!parentSpanId || !useRegistry) { + return undefined; + } + return subscribe(kind, parentSpanId, force); + }, [kind, parentSpanId, useRegistry]); + + // Register on mount / when the active span changes; unregister on unmount. + // Legacy-mode components do not register — they are independent and don't + // gate or get gated by peers. + useEffect(() => { + if (!parentSpanId || !useRegistry) { + return undefined; + } + return registerCheckpoint(kind, parentSpanId, checkpointId, localReady); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [kind, parentSpanId, useRegistry, checkpointId]); + + // Propagate ready transitions to the registry. Legacy-mode components + // skip this — they propagate their value directly via the returned boolean. + useEffect(() => { + if (!parentSpanId || !useRegistry) { + return; + } + updateCheckpoint(kind, parentSpanId, checkpointId, localReady); + }, [kind, parentSpanId, useRegistry, checkpointId, localReady]); + + if (!parentSpanId) { + return false; + } + // Legacy: propagate the local `record` value directly. Native last-wins + // resolves multi-instance ordering exactly as before. + if (!useRegistry) { + return localReady; + } + // Registry: gated on the per-span aggregate. + return isAllReady(kind, parentSpanId); +} + function TimeToDisplay(props: { children?: React.ReactNode; initialDisplay?: boolean; diff --git a/packages/core/test/tracing/timeToDisplayCoordinator.test.ts b/packages/core/test/tracing/timeToDisplayCoordinator.test.ts new file mode 100644 index 0000000000..2c17c73868 --- /dev/null +++ b/packages/core/test/tracing/timeToDisplayCoordinator.test.ts @@ -0,0 +1,143 @@ +import { + _resetTimeToDisplayCoordinator, + hasAnyCheckpoints, + isAllReady, + registerCheckpoint, + subscribe, + updateCheckpoint, +} from '../../src/js/tracing/timeToDisplayCoordinator'; + +const SPAN_FIRST = 'span-first'; +const SPAN_SECOND = 'span-second'; + +describe('timeToDisplayCoordinator', () => { + beforeEach(() => { + _resetTimeToDisplayCoordinator(); + }); + + 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); + 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); + expect(isAllReady('ttfd', SPAN_FIRST)).toBe(true); + }); + + test('late-registering not-ready checkpoint un-readies the aggregate', () => { + registerCheckpoint('ttfd', SPAN_FIRST, 'a', true); + registerCheckpoint('ttfd', SPAN_FIRST, 'b', true); + expect(isAllReady('ttfd', SPAN_FIRST)).toBe(true); + + registerCheckpoint('ttfd', SPAN_FIRST, 'c', false); + expect(isAllReady('ttfd', SPAN_FIRST)).toBe(false); + }); + + test('unregistering the only blocking checkpoint resolves the aggregate', () => { + registerCheckpoint('ttfd', SPAN_FIRST, 'a', true); + const unregisterB = registerCheckpoint('ttfd', SPAN_FIRST, 'b', false); + expect(isAllReady('ttfd', SPAN_FIRST)).toBe(false); + + unregisterB(); + expect(isAllReady('ttfd', SPAN_FIRST)).toBe(true); + }); + + test('unregistering the last checkpoint leaves aggregate not-ready', () => { + const unregister = registerCheckpoint('ttfd', SPAN_FIRST, 'a', true); + expect(isAllReady('ttfd', SPAN_FIRST)).toBe(true); + + unregister(); + expect(isAllReady('ttfd', SPAN_FIRST)).toBe(false); + expect(hasAnyCheckpoints('ttfd', SPAN_FIRST)).toBe(false); + }); + + test('different spans are independent', () => { + registerCheckpoint('ttfd', SPAN_FIRST, 'a', true); + registerCheckpoint('ttfd', SPAN_SECOND, 'a', false); + expect(isAllReady('ttfd', SPAN_FIRST)).toBe(true); + expect(isAllReady('ttfd', SPAN_SECOND)).toBe(false); + }); + + test('different kinds are independent', () => { + registerCheckpoint('ttfd', SPAN_FIRST, 'a', true); + registerCheckpoint('ttid', SPAN_FIRST, 'a', false); + expect(isAllReady('ttfd', SPAN_FIRST)).toBe(true); + expect(isAllReady('ttid', SPAN_FIRST)).toBe(false); + }); + + test('updateCheckpoint is a no-op for unknown id', () => { + const listener = jest.fn(); + subscribe('ttfd', SPAN_FIRST, listener); + updateCheckpoint('ttfd', SPAN_FIRST, 'nope', true); + expect(listener).not.toHaveBeenCalled(); + }); + + test('updateCheckpoint with same ready value does not notify', () => { + registerCheckpoint('ttfd', SPAN_FIRST, 'a', true); + const listener = jest.fn(); + subscribe('ttfd', SPAN_FIRST, listener); + updateCheckpoint('ttfd', SPAN_FIRST, 'a', true); + expect(listener).not.toHaveBeenCalled(); + }); + + 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); + expect(listener).toHaveBeenCalledTimes(1); + unregister(); + expect(listener).toHaveBeenCalledTimes(2); + }); + + test('non-flipping checkpoint changes do not wake subscribers (storm avoidance)', () => { + const listener = jest.fn(); + subscribe('ttfd', SPAN_FIRST, listener); + + for (let i = 0; i < 10; i++) { + registerCheckpoint('ttfd', SPAN_FIRST, `cp-${i}`, false); + } + expect(listener).toHaveBeenCalledTimes(0); + + for (let i = 0; i < 9; i++) { + updateCheckpoint('ttfd', SPAN_FIRST, `cp-${i}`, true); + } + expect(listener).toHaveBeenCalledTimes(0); + + updateCheckpoint('ttfd', SPAN_FIRST, 'cp-9', true); + expect(listener).toHaveBeenCalledTimes(1); + }); + + 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(); + }); +}); diff --git a/packages/core/test/tracing/timetodisplay.multiinstance.test.tsx b/packages/core/test/tracing/timetodisplay.multiinstance.test.tsx new file mode 100644 index 0000000000..b9e8f43933 --- /dev/null +++ b/packages/core/test/tracing/timetodisplay.multiinstance.test.tsx @@ -0,0 +1,267 @@ +import type { Span } from '@sentry/core'; + +import { + getCurrentScope, + getGlobalScope, + getIsolationScope, + setCurrentClient, + spanToJSON, + startSpanManual, +} from '@sentry/core'; + +import * as mockWrapper from '../mockWrapper'; + +jest.mock('../../src/js/wrapper', () => mockWrapper); + +import * as mockedtimetodisplaynative from './mockedtimetodisplaynative'; + +jest.mock('../../src/js/tracing/timetodisplaynative', () => mockedtimetodisplaynative); + +import { act, render } from '@testing-library/react-native'; +import * as React from 'react'; + +import { _resetTimeToDisplayCoordinator } from '../../src/js/tracing/timeToDisplayCoordinator'; +import { TimeToFullDisplay, TimeToInitialDisplay } from '../../src/js/tracing/timetodisplay'; +import { getDefaultTestClientOptions, TestClient } from '../mocks/client'; +import { secondAgoTimestampMs } from '../testutils'; + +jest.mock('../../src/js/utils/environment', () => ({ + isWeb: jest.fn().mockReturnValue(false), + isTurboModuleEnabled: jest.fn().mockReturnValue(false), +})); + +const { getMockedOnDrawReportedProps, clearMockedOnDrawReportedProps } = mockedtimetodisplaynative; + +function tailHasFullDisplay(parentSpanId: string, mountedReporterCount: number): boolean { + const props = getMockedOnDrawReportedProps().filter(p => p.parentSpanId === parentSpanId); + const tail = props.slice(-mountedReporterCount); + return tail.some(p => p.fullDisplay === true); +} + +function tailHasInitialDisplay(parentSpanId: string, mountedReporterCount: number): boolean { + const props = getMockedOnDrawReportedProps().filter(p => p.parentSpanId === parentSpanId); + const tail = props.slice(-mountedReporterCount); + return tail.some(p => p.initialDisplay === true); +} + +jest.useFakeTimers({ advanceTimers: true, doNotFake: ['performance'] }); + +describe('TimeToDisplay multi-instance (`ready` prop)', () => { + beforeEach(() => { + clearMockedOnDrawReportedProps(); + _resetTimeToDisplayCoordinator(); + getCurrentScope().clear(); + getIsolationScope().clear(); + getGlobalScope().clear(); + const options = getDefaultTestClientOptions({ tracesSampleRate: 1.0 }); + const client = new TestClient(options); + setCurrentClient(client); + client.init(); + }); + + afterEach(() => { + jest.clearAllMocks(); + mockWrapper.NATIVE.enableNative = true; + }); + + test('legacy: single `record` instance behaves identically to today', () => { + startSpanManual({ name: 'Screen', startTime: secondAgoTimestampMs() }, (activeSpan: Span | undefined) => { + const spanId = spanToJSON(activeSpan!).span_id; + render(); + 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( + <> + + + , + ); + 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(); + expect(tailHasFullDisplay(spanId, 2)).toBe(false); + + act(() => tree.rerender()); + expect(tailHasFullDisplay(spanId, 2)).toBe(false); + + act(() => tree.rerender()); + expect(tailHasFullDisplay(spanId, 2)).toBe(true); + + activeSpan?.end(); + }); + }); + + test('late-mounting `ready={false}` un-readies an already-ready aggregate', () => { + 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(); + expect(tailHasFullDisplay(spanId, 1)).toBe(true); + + act(() => tree.rerender()); + expect(tailHasFullDisplay(spanId, 2)).toBe(false); + + act(() => tree.rerender()); + expect(tailHasFullDisplay(spanId, 2)).toBe(true); + + activeSpan?.end(); + }); + }); + + test('unmounting the only blocking checkpoint emits 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(); + expect(tailHasFullDisplay(spanId, 2)).toBe(false); + + act(() => tree.rerender()); + expect(tailHasFullDisplay(spanId, 1)).toBe(true); + + activeSpan?.end(); + }); + }); + + test('mixed `record` + `ready`: legacy `record` is independent, `ready` peers coordinate', () => { + // Backward compat: `record`-only instances do not register as checkpoints + // and are not gated by `ready` peers. They emit `fullDisplay` directly + // from their own prop, exactly as before this change. `ready` peers gate + // each other via the registry. + startSpanManual({ name: 'Screen', startTime: secondAgoTimestampMs() }, (activeSpan: Span | undefined) => { + const spanId = spanToJSON(activeSpan!).span_id; + + const Screen = ({ rec, rdy }: { rec: boolean; rdy: boolean }) => ( + <> + + + + ); + + // record=true fires independently; ready=false blocks the ready reporter. + // The tail reflects: [record:true, ready:false] → fullDisplay=true present. + const tree = render(); + expect(tailHasFullDisplay(spanId, 2)).toBe(true); + + // record=false stops emitting; ready=true now fires. + act(() => tree.rerender()); + expect(tailHasFullDisplay(spanId, 2)).toBe(true); + + // Both fire. + act(() => tree.rerender()); + expect(tailHasFullDisplay(spanId, 2)).toBe(true); + + activeSpan?.end(); + }); + }); + + test('legacy: bare does not block `ready` peers', () => { + // Backward compat for layout-placeholder usage. A bare component with + // neither prop is a no-op (legacy `record=false`). + startSpanManual({ name: 'Screen', startTime: secondAgoTimestampMs() }, (activeSpan: Span | undefined) => { + const spanId = spanToJSON(activeSpan!).span_id; + render( + <> + + + , + ); + expect(tailHasFullDisplay(spanId, 2)).toBe(true); + activeSpan?.end(); + }); + }); + + test('legacy: two `record` peers fire independently (no coordination)', () => { + // Backward compat: pre-change behavior was last-write-wins on the native + // side. record-only peers must continue to fire independently. + startSpanManual({ name: 'Screen', startTime: secondAgoTimestampMs() }, (activeSpan: Span | undefined) => { + const spanId = spanToJSON(activeSpan!).span_id; + render( + <> + + + , + ); + // The record=true reporter fires; record=false does not. fullDisplay=true + // present in the tail. + expect(tailHasFullDisplay(spanId, 2)).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(); + expect(tailHasFullDisplay(secondSpanId, 1)).toBe(false); + activeSpan?.end(); + }); + + expect(firstSpanId).not.toEqual(secondSpanId); + }); + + test('TTID `ready` aggregates symmetrically', () => { + 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(); + expect(tailHasInitialDisplay(spanId, 2)).toBe(false); + + act(() => tree.rerender()); + expect(tailHasInitialDisplay(spanId, 2)).toBe(true); + + activeSpan?.end(); + }); + }); +});