fix(hydration-timing): report list hydration relative to the foreground attempt (#1813)

The headline listVisibleMs was boot-relative, so a navigation or node
switch minutes after boot reported page age as the foreground hydration
duration. The developer-mode report now distinguishes boot-, node-session-,
and attempt-relative durations (schema v2), the collapsed chip shows the
attempt-relative foreground value with its anchor, and superseded, aborted,
or failed attempts can never become the reported success.

The report schema bumps to v2 with explicit anchors: bootAgeMs and the
boot->auth/nodes/shell durations, sessionAgeMs and session-relative
list visible/hydrated, and lastAttempt* fields resolved from the newest
committed list_visible event in the active node session (event-derived, so
a later stack-detail attempt cannot steal the headline). listVisibleMs is
retained as the raw boot-relative compatibility field.

The repeatable performance baseline scenario matrix is documented in the
local internal docs (docs/internal/performance/hydration-timing-baseline.md);
the post-hot-path-fix baseline capture is a follow-up evidence step.
This commit is contained in:
Anso
2026-08-09 19:19:00 -04:00
committed by GitHub
parent 600367e66c
commit 88fc5470e8
6 changed files with 720 additions and 42 deletions
@@ -30,12 +30,13 @@ const POSITION_CLASS =
* Developer-mode-only overlay for startup and stack-hydration timing.
*
* Mount this only when developer mode is on for the active node; it does not
* gate itself. It shows a collapsed chip with the boot-to-`list_visible`
* elapsed time, expanding to a phase table with copy/clear actions. It sits
* below toasts and modals and never covers the mobile tab bar or safe area.
* gate itself. It shows a collapsed chip with the foreground (attempt- or
* session-relative) `list_visible` elapsed time and its anchor, expanding to a
* phase table with copy/clear actions. It sits below toasts and modals and
* never covers the mobile tab bar or safe area.
*/
export function HydrationTimingPanel() {
const { listVisibleMs } = useHydrationTiming();
const { listVisibleMs, listAnchor } = useHydrationTiming();
const [expanded, setExpanded] = useState(false);
useEffect(() => {
@@ -47,7 +48,10 @@ export function HydrationTimingPanel() {
return () => window.removeEventListener('keydown', onKey);
}, [expanded]);
const chipLabel = listVisibleMs == null ? 'list …' : `list ${formatMs(listVisibleMs)}`;
const chipLabel =
listVisibleMs == null
? 'list …'
: `list ${formatMs(listVisibleMs)} · ${listAnchor}`;
const handleCopy = useCallback(async () => {
try {
@@ -98,6 +102,16 @@ export function HydrationTimingPanel() {
<span className="font-mono tabular-nums text-muted-foreground">{chipLabel}</span>
</div>
<div className="flex items-center justify-between gap-2 border-b border-glass-border px-3 py-1.5 text-[0.65rem] text-muted-foreground">
<span>
Boot age <span className="font-mono tabular-nums">{formatOffset(report.bootAgeMs)}</span>
</span>
<span>
Session age{' '}
<span className="font-mono tabular-nums">{formatOffset(report.sessionAgeMs)}</span>
</span>
</div>
<div className="max-h-[50vh] overflow-y-auto max-md:max-h-[40vh]">
<table className="w-full border-collapse text-left">
<thead className="sticky top-0 bg-popover/95 text-[0.65rem] uppercase tracking-wide text-muted-foreground backdrop-blur-[10px]">
@@ -4,10 +4,15 @@ import type { HydrationReport, HydrationSnapshot } from '@/lib/hydrationTiming';
let mockSnapshot: HydrationSnapshot;
let mockListVisibleMs: number | null;
let mockListAnchor: 'attempt' | 'session' | null;
let mockReport: HydrationReport;
vi.mock('@/hooks/useHydrationTiming', () => ({
useHydrationTiming: () => ({ snapshot: mockSnapshot, listVisibleMs: mockListVisibleMs }),
useHydrationTiming: () => ({
snapshot: mockSnapshot,
listVisibleMs: mockListVisibleMs,
listAnchor: mockListAnchor,
}),
}));
const clearReportMock = vi.fn();
@@ -23,25 +28,39 @@ import { HydrationTimingPanel } from '../HydrationTimingPanel';
function snapshot(events: HydrationSnapshot['events'] = []): HydrationSnapshot {
return {
schemaVersion: 1,
clock: 'performance.now',
bootSessionId: 'boot-1',
bootStartAt: 0,
nodeSessionId: 'node-2',
nodeId: 1,
nodeSessionStartAt: 0,
lastAttempt: null,
events,
};
}
function report(over: Partial<HydrationReport> = {}): HydrationReport {
return {
schemaVersion: 1,
schemaVersion: 2,
capturedAt: 0,
clock: 'performance.now',
bootSessionId: 'boot-1',
nodeSessionId: 'node-2',
nodeId: 1,
listVisibleMs: 1200,
bootAgeMs: 620000,
bootAuthResolvedMs: 200,
bootNodesResolvedMs: 400,
bootShellCommittedMs: 500,
sessionAgeMs: 4200,
sessionListVisibleMs: 1200,
sessionListHydratedMs: 1500,
lastAttemptId: 'attempt-1',
lastAttemptListVisibleMs: 420,
lastAttemptListHydratedMs: 700,
lastAttemptHydrationGapMs: 280,
lastAttemptProxied: null,
lastAttemptNodeId: null,
anyProxied: false,
phases: [
{ phase: 'boot_start', kind: 'milestone', offsetMs: 0, critical: true, outcome: 'ok' },
@@ -55,18 +74,27 @@ beforeEach(() => {
clearReportMock.mockClear();
copyMock.mockClear();
mockSnapshot = snapshot();
mockListVisibleMs = 1200;
mockListVisibleMs = 420;
mockListAnchor = 'attempt';
mockReport = report();
});
describe('HydrationTimingPanel', () => {
it('shows the list_visible elapsed time on the collapsed chip', () => {
it('shows the foreground list_visible elapsed time and its anchor on the collapsed chip', () => {
render(<HydrationTimingPanel />);
expect(screen.getByTestId('hydration-chip')).toHaveTextContent('list 1.2s');
expect(screen.getByTestId('hydration-chip')).toHaveTextContent('list 420ms · attempt');
});
it('shows the session anchor when no foreground attempt exists', () => {
mockListAnchor = 'session';
mockListVisibleMs = 100;
render(<HydrationTimingPanel />);
expect(screen.getByTestId('hydration-chip')).toHaveTextContent('list 100ms · session');
});
it('shows an ellipsis before list_visible commits', () => {
mockListVisibleMs = null;
mockListAnchor = null;
render(<HydrationTimingPanel />);
expect(screen.getByTestId('hydration-chip')).toHaveTextContent('list …');
});
@@ -83,6 +111,13 @@ describe('HydrationTimingPanel', () => {
expect(screen.getByTestId('hydration-chip')).toBeInTheDocument();
});
it('shows boot age and session age as context alongside the chip', () => {
render(<HydrationTimingPanel />);
fireEvent.click(screen.getByTestId('hydration-chip'));
expect(screen.getByText(/Boot age/)).toHaveTextContent('620.0s');
expect(screen.getByText(/Session age/)).toHaveTextContent('4.2s');
});
it('collapses on Escape', () => {
render(<HydrationTimingPanel />);
fireEvent.click(screen.getByTestId('hydration-chip'));
@@ -0,0 +1,62 @@
import { describe, it, expect, vi } from 'vitest';
import { renderHook, waitFor } from '@testing-library/react';
type Store = typeof import('../../lib/hydrationTiming');
/** Fresh module instance so the module-scoped store starts clean. */
async function loadStore(): Promise<Store> {
vi.resetModules();
return import('../../lib/hydrationTiming');
}
describe('useHydrationTiming', () => {
it('stays stable without new events (no store emit)', async () => {
await loadStore();
const { useHydrationTiming } = await import('../useHydrationTiming');
const { result } = renderHook(() => useHydrationTiming());
expect(result.current.listVisibleMs).toBeNull();
expect(result.current.listAnchor).toBeNull();
const firstSnapshot = result.current.snapshot;
// No store mutation, so no coalesced emit: the snapshot identity and chip
// value must remain stable over time.
await new Promise((resolve) => setTimeout(resolve, 300));
expect(result.current.snapshot).toBe(firstSnapshot);
expect(result.current.listVisibleMs).toBeNull();
expect(result.current.listAnchor).toBeNull();
});
it('re-renders with the attempt anchor when a list attempt commits while mounted', async () => {
const store = await loadStore();
const { useHydrationTiming } = await import('../useHydrationTiming');
const { result } = renderHook(() => useHydrationTiming());
expect(result.current.listAnchor).toBeNull();
store.beginNodeSession(1);
const a = store.newAttemptId();
store.commitMilestone('list_visible', a);
await waitFor(() => expect(result.current.listAnchor).toBe('attempt'));
expect(result.current.listVisibleMs).not.toBeNull();
});
it('falls back to the session anchor when no foreground attempt exists', async () => {
const store = await loadStore();
store.beginNodeSession(1);
const a = store.newAttemptId();
store.commitMilestone('list_visible', a);
for (let i = 0; i < 200; i++) store.newAttemptId(); // evict `a` from the attempt map
// Wait for a coalesced emit to rebuild the snapshot with the session
// anchor; the initial snapshot already has lastAttempt null.
await waitFor(() => {
expect(store.getSnapshot().nodeSessionStartAt).not.toBeNull();
expect(store.getSnapshot().lastAttempt).toBeNull();
});
const { useHydrationTiming } = await import('../useHydrationTiming');
const { result } = renderHook(() => useHydrationTiming());
expect(result.current.listAnchor).toBe('session');
expect(result.current.listVisibleMs).not.toBeNull();
});
});
+40 -12
View File
@@ -1,21 +1,49 @@
import { useSyncExternalStore } from 'react';
import { subscribe, getSnapshot, listVisibleMsFrom } from '@/lib/hydrationTiming';
import {
subscribe,
getSnapshot,
getAttemptListVisibleMsFrom,
getSessionListVisibleMsFrom,
} from '@/lib/hydrationTiming';
import type { HydrationSnapshot } from '@/lib/hydrationTiming';
export interface UseHydrationTiming {
snapshot: HydrationSnapshot;
/** Elapsed ms from boot to `list_visible`, or null before it commits. */
listVisibleMs: number | null;
/** Chip readout: the foreground list hydration latency plus the anchor it was
* resolved against. The union keeps `listAnchor` non-null exactly when
* `listVisibleMs` is non-null. */
type ListReadout =
| { listVisibleMs: number; listAnchor: 'attempt' | 'session' }
| { listVisibleMs: null; listAnchor: null };
type UseHydrationTiming = { snapshot: HydrationSnapshot } & ListReadout;
/** Derive the chip readout from the snapshot React last read, not live store
* state, so the chip stays consistent with the events on screen. */
function deriveListReadout(snapshot: HydrationSnapshot): ListReadout {
const foreground = snapshot.lastAttempt;
if (foreground != null) {
const attemptMs = getAttemptListVisibleMsFrom(
snapshot.events,
foreground.attemptId,
foreground.createdAt,
);
if (attemptMs != null) return { listVisibleMs: attemptMs, listAnchor: 'attempt' };
}
const sessionMs = getSessionListVisibleMsFrom(
snapshot.events,
snapshot.nodeSessionId,
snapshot.nodeSessionStartAt,
);
return sessionMs == null
? { listVisibleMs: null, listAnchor: null }
: { listVisibleMs: sessionMs, listAnchor: 'session' };
}
/** Subscribe to the hydration timing store and expose the current snapshot
* plus the derived `list_visible` elapsed time for the collapsed chip.
* Derives from the snapshot React last read so the chip stays consistent
* with the events on screen, not a later live store mutation. */
* plus the derived foreground `list_visible` elapsed time for the collapsed
* chip: attempt-relative when a foreground attempt exists, otherwise
* session-relative, never boot-relative, so a node switch minutes after boot
* cannot present page age as hydration time. */
export function useHydrationTiming(): UseHydrationTiming {
const snapshot = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
return {
snapshot,
listVisibleMs: listVisibleMsFrom(snapshot.events, snapshot.bootStartAt),
};
return { snapshot, ...deriveListReadout(snapshot) };
}
@@ -22,6 +22,14 @@ function stubClock(getT: () => number): void {
});
}
/** Push 250 span pairs so the oldest events evict past the 200-event cap. */
function evictOldestEvents(store: Store): void {
for (let i = 0; i < 250; i++) {
const handle = store.beginSpan('fetch_headers');
store.endSpan(handle);
}
}
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
@@ -43,10 +51,7 @@ describe('hydrationTiming store', () => {
it('evicts the oldest events beyond the 200-event cap (FIFO)', async () => {
const store = await loadStore();
for (let i = 0; i < 250; i++) {
const handle = store.beginSpan('fetch_headers');
store.endSpan(handle);
}
evictOldestEvents(store);
const report = store.getHydrationReport();
expect(report.phases).toHaveLength(200);
// boot_start was the very first event, so it has been evicted.
@@ -112,7 +117,7 @@ describe('hydrationTiming store', () => {
expect(phases).not.toContain('list_visible');
});
it('reports list_visible elapsed from boot_start', async () => {
it('reports list_visible elapsed from boot_start (compat) and session/attempt anchors', async () => {
let t = 0;
const store = await loadStore(() => stubClock(() => t));
t = 1200;
@@ -120,7 +125,11 @@ describe('hydrationTiming store', () => {
const a = store.newAttemptId();
store.commitMilestone('list_visible', a);
expect(store.getListVisibleMs()).toBe(1200);
expect(store.getHydrationReport().listVisibleMs).toBe(1200);
const report = store.getHydrationReport();
expect(report.listVisibleMs).toBe(1200);
expect(report.schemaVersion).toBe(2);
expect(report.sessionListVisibleMs).toBe(0);
expect(report.lastAttemptListVisibleMs).toBe(0);
});
it('returns null list_visible timing before it commits', async () => {
@@ -182,4 +191,282 @@ describe('hydrationTiming store', () => {
expect(after.events.some((e) => e.phase === 'auth_resolved')).toBe(true);
unsubscribe();
});
it('reports a late node-session list_visible relative to the session, not boot', async () => {
let t = 0;
const store = await loadStore(() => stubClock(() => t));
t = 600_000; // page has been alive for ten minutes before the node session
store.beginNodeSession(1);
const a = store.newAttemptId();
t = 600_010;
store.commitMilestone('list_visible', a);
const report = store.getHydrationReport();
// The foreground hydration duration is 10 ms, never the 600 s boot age.
expect(report.lastAttemptListVisibleMs).toBe(10);
expect(report.sessionListVisibleMs).toBe(10);
expect(report.bootAgeMs).toBe(600_010);
expect(report.listVisibleMs).toBe(600_010); // raw boot-relative compat field
});
it('keeps a stack-detail attempt from stealing the foreground list attempt', async () => {
let t = 0;
const store = await loadStore(() => stubClock(() => t));
store.beginNodeSession(1);
t = 10;
const listAttempt = store.newAttemptId();
t = 50;
store.commitMilestone('list_visible', listAttempt);
t = 60;
const detailAttempt = store.newAttemptId(); // never commits list_visible
const report = store.getHydrationReport();
expect(report.lastAttemptId).toBe(listAttempt);
expect(report.lastAttemptId).not.toBe(detailAttempt);
expect(report.lastAttemptListVisibleMs).toBe(40);
});
it('leaves lastAttemptListHydratedMs null until the foreground attempt hydrates', async () => {
const store = await loadStore();
store.beginNodeSession(1);
const a = store.newAttemptId();
store.commitMilestone('list_visible', a);
const report = store.getHydrationReport();
expect(report.lastAttemptId).toBe(a);
expect(report.lastAttemptListVisibleMs).not.toBeNull();
expect(report.lastAttemptListHydratedMs).toBeNull();
expect(report.lastAttemptHydrationGapMs).toBeNull();
});
it('never reports a superseded attempt as the foreground attempt', async () => {
const store = await loadStore();
store.beginNodeSession(1);
const a = store.newAttemptId();
store.commitMilestone('list_visible', a);
store.beginNodeSession(2); // supersedes session 1 and prunes its events
const report = store.getHydrationReport();
expect(report.lastAttemptId).toBeNull();
expect(report.lastAttemptListVisibleMs).toBeNull();
});
it('never reports an aborted attempt as the foreground attempt', async () => {
const store = await loadStore();
store.beginNodeSession(1);
const a = store.newAttemptId();
store.commitMilestone('list_visible', a);
store.abortAttempt(a);
const report = store.getHydrationReport();
expect(report.lastAttemptId).toBeNull();
expect(report.lastAttemptListVisibleMs).toBeNull();
});
it('preserves a failed attempt outcome without presenting it as a clean success', async () => {
const store = await loadStore();
store.beginNodeSession(1);
const a = store.newAttemptId();
store.commitMilestone('list_visible', a, { outcome: 'error' });
const report = store.getHydrationReport();
expect(report.lastAttemptId).toBe(a);
expect(report.phases.find((p) => p.phase === 'list_visible')?.outcome).toBe('error');
});
it('degrades attempt-relative fields gracefully when the attempt map evicts the record', async () => {
const store = await loadStore();
store.beginNodeSession(1);
const a = store.newAttemptId();
store.commitMilestone('list_visible', a);
for (let i = 0; i < 200; i++) store.newAttemptId(); // MAX_ATTEMPTS evicts `a`
const report = store.getHydrationReport();
expect(report.lastAttemptId).toBeNull();
expect(report.lastAttemptListVisibleMs).toBeNull();
expect(report.sessionListVisibleMs).not.toBeNull();
});
it('degrades boot-relative fields gracefully when the event cap evicts boot markers', async () => {
const store = await loadStore();
store.markMilestone('auth_resolved');
store.beginNodeSession(1);
evictOldestEvents(store);
const report = store.getHydrationReport();
expect(report.bootAuthResolvedMs).toBeNull();
expect(report.bootAgeMs).not.toBeNull();
expect(report.sessionAgeMs).not.toBeNull();
});
it('clearReport preserves the session anchor and session age', async () => {
const store = await loadStore();
store.beginNodeSession(1);
const a = store.newAttemptId();
store.commitMilestone('list_visible', a);
await waitFor(() => expect(store.getSnapshot().nodeSessionStartAt).not.toBeNull());
store.clearReport();
await waitFor(() =>
expect(store.getSnapshot().events.some((e) => e.phase === 'list_visible')).toBe(false),
);
const report = store.getHydrationReport();
expect(report.sessionAgeMs).not.toBeNull();
expect(report.sessionListVisibleMs).toBeNull();
expect(store.getSnapshot().nodeSessionStartAt).not.toBeNull();
});
it('populates boot-relative auth, nodes, and shell durations from boot-scoped events', async () => {
let t = 0;
const store = await loadStore(() => stubClock(() => t));
t = 100;
store.markMilestone('auth_resolved');
t = 250;
store.markMilestone('nodes_resolved');
t = 400;
store.markMilestone('shell_committed');
const report = store.getHydrationReport();
expect(report.bootAuthResolvedMs).toBe(100);
expect(report.bootNodesResolvedMs).toBe(250);
expect(report.bootShellCommittedMs).toBe(400);
});
it('stamps the foreground attempt proxy flag and node id from its own event', async () => {
const store = await loadStore();
store.beginNodeSession(7);
const a = store.newAttemptId();
store.commitMilestone('list_visible', a, { proxied: true });
const report = store.getHydrationReport();
expect(report.lastAttemptProxied).toBe(true);
expect(report.lastAttemptNodeId).toBe(7);
});
it('exposes every session-correct schema-2 field on the report', async () => {
const store = await loadStore();
store.beginNodeSession(1);
const a = store.newAttemptId();
store.commitMilestone('list_visible', a);
const report = store.getHydrationReport();
expect(report.schemaVersion).toBe(2);
for (const key of [
'bootAgeMs',
'bootAuthResolvedMs',
'bootNodesResolvedMs',
'bootShellCommittedMs',
'sessionAgeMs',
'sessionListVisibleMs',
'sessionListHydratedMs',
'lastAttemptId',
'lastAttemptListVisibleMs',
'lastAttemptListHydratedMs',
'lastAttemptHydrationGapMs',
'lastAttemptProxied',
'lastAttemptNodeId',
]) {
expect(report).toHaveProperty(key);
}
});
it('reports positive same-attempt visible, hydrated, and gap durations', async () => {
let t = 0;
const store = await loadStore(() => stubClock(() => t));
store.beginNodeSession(1);
t = 100;
const a = store.newAttemptId();
t = 250;
store.commitMilestone('list_visible', a);
t = 400;
store.commitMilestone('list_hydrated', a);
const report = store.getHydrationReport();
expect(report.lastAttemptId).toBe(a);
expect(report.lastAttemptListVisibleMs).toBe(150);
expect(report.lastAttemptListHydratedMs).toBe(300);
expect(report.lastAttemptHydrationGapMs).toBe(150);
});
it('never borrows another attempt hydration for the foreground attempt', async () => {
let t = 0;
const store = await loadStore(() => stubClock(() => t));
store.beginNodeSession(1);
t = 100;
const first = store.newAttemptId();
t = 250;
store.commitMilestone('list_visible', first);
t = 400;
store.commitMilestone('list_hydrated', first);
t = 500;
const second = store.newAttemptId();
t = 600;
store.commitMilestone('list_visible', second);
const report = store.getHydrationReport();
expect(report.lastAttemptId).toBe(second);
expect(report.lastAttemptListVisibleMs).toBe(100);
expect(report.lastAttemptListHydratedMs).toBeNull();
expect(report.lastAttemptHydrationGapMs).toBeNull();
});
it('a failed hydration marked via markMilestone never reads as hydrated', async () => {
const store = await loadStore();
store.beginNodeSession(1);
const a = store.newAttemptId();
store.commitMilestone('list_visible', a);
// Real failure path (useStackListState): the statuses fetch threw, so
// list_hydrated is marked uncommitted with an error outcome, never
// committed as a completed hydration.
store.markMilestone('list_hydrated', { attemptId: a, outcome: 'error' });
const report = store.getHydrationReport();
expect(report.lastAttemptId).toBe(a);
expect(report.lastAttemptListVisibleMs).not.toBeNull();
expect(report.lastAttemptListHydratedMs).toBeNull();
expect(report.lastAttemptHydrationGapMs).toBeNull();
expect(report.phases.find((p) => p.phase === 'list_hydrated')?.outcome).toBe('error');
});
it('reports session-correct values across a node switch', async () => {
let t = 0;
const store = await loadStore(() => stubClock(() => t));
store.beginNodeSession(1);
t = 300_000; // first hydration five minutes into the page
const a = store.newAttemptId();
t = 300_010;
store.commitMilestone('list_visible', a);
t = 600_000; // node switch at ten minutes
store.beginNodeSession(2);
const b = store.newAttemptId();
t = 600_010;
store.commitMilestone('list_visible', b);
const report = store.getHydrationReport();
expect(report.lastAttemptId).toBe(b);
expect(report.sessionListVisibleMs).toBe(10);
expect(report.lastAttemptListVisibleMs).toBe(10);
expect(report.listVisibleMs).toBe(600_010); // boot-relative compat stays boot age
});
it('degrades gracefully when the event cap evicts the foreground list_visible', async () => {
const store = await loadStore();
store.beginNodeSession(1);
const a = store.newAttemptId();
store.commitMilestone('list_visible', a);
evictOldestEvents(store);
const report = store.getHydrationReport();
expect(report.lastAttemptId).toBeNull(); // list_visible evicted; record survives
expect(report.lastAttemptListVisibleMs).toBeNull();
expect(report.sessionListVisibleMs).toBeNull();
expect(report.sessionAgeMs).not.toBeNull();
});
it('leaves lastAttemptProxied null for a local attempt', async () => {
const store = await loadStore();
store.beginNodeSession(1);
const a = store.newAttemptId();
store.commitMilestone('list_visible', a);
const report = store.getHydrationReport();
expect(report.lastAttemptProxied).toBeNull();
expect(report.lastAttemptNodeId).toBe(1);
});
it('abortAttempt rebuilds the snapshot so an aborted attempt cannot linger as foreground', async () => {
const store = await loadStore();
store.beginNodeSession(1);
const a = store.newAttemptId();
store.commitMilestone('list_visible', a);
await waitFor(() => expect(store.getSnapshot().lastAttempt?.attemptId).toBe(a));
store.abortAttempt(a); // no open spans: only the scheduled emit can clear it
await waitFor(() => expect(store.getSnapshot().lastAttempt).toBeNull());
expect(store.getHydrationReport().lastAttemptId).toBeNull();
});
});
+265 -13
View File
@@ -78,12 +78,18 @@ export interface HydrationEvent {
}
export interface HydrationSnapshot {
schemaVersion: 1;
clock: HydrationClock;
bootSessionId: string;
bootStartAt: number | null;
nodeSessionId: string | null;
nodeId: number | null;
/** Clock time when the active node session began; null before the first node resolves. */
nodeSessionStartAt: number | null;
/** Resolved foreground list attempt (see `resolveForegroundAttempt`); null
* until a list attempt commits `list_visible`. Unversioned by policy: the
* snapshot never leaves the process; `HydrationReport` is the versioned,
* serialized artifact. */
lastAttempt: ForegroundAttempt | null;
events: readonly HydrationEvent[];
}
@@ -104,7 +110,7 @@ export interface HydrationReportPhase {
}
export interface HydrationReport {
schemaVersion: 1;
schemaVersion: 2;
/** Wall-clock capture time (Date.now), only for human reference. */
capturedAt: number;
clock: HydrationClock;
@@ -112,7 +118,37 @@ export interface HydrationReport {
bootSessionId: string;
nodeSessionId: string | null;
nodeId: number | null;
/** Raw boot-relative elapsed ms from `boot_start` to the most recent
* `list_visible`, kept for diagnostic back-compat. The truthful foreground
* duration is `lastAttemptListVisibleMs` (attempt-relative). */
listVisibleMs: number | null;
/** Page age at capture: `boot_start` to now. */
bootAgeMs: number | null;
/** `boot_start` to `auth_resolved`. */
bootAuthResolvedMs: number | null;
/** `boot_start` to `nodes_resolved`. */
bootNodesResolvedMs: number | null;
/** `boot_start` to `shell_committed`. */
bootShellCommittedMs: number | null;
/** Elapsed ms since the active node session began (node-session age). */
sessionAgeMs: number | null;
/** `node-session start` to the session's most recent committed `list_visible`. */
sessionListVisibleMs: number | null;
/** `node-session start` to the session's most recent committed `list_hydrated`. */
sessionListHydratedMs: number | null;
/** Foreground list attempt: the newest committed `list_visible` attempt in
* the active node session that is still live (not aborted or superseded). */
lastAttemptId: string | null;
/** `attempt start` to its committed `list_visible`. */
lastAttemptListVisibleMs: number | null;
/** `attempt start` to its committed `list_hydrated`; null until the foreground attempt hydrates. */
lastAttemptListHydratedMs: number | null;
/** `list_visible` to `list_hydrated` for the same foreground attempt. */
lastAttemptHydrationGapMs: number | null;
/** Proxy flag from the foreground attempt's own `list_visible` event. */
lastAttemptProxied: boolean | null;
/** Node id from the foreground attempt's own `list_visible` event. */
lastAttemptNodeId: number | null;
anyProxied: boolean;
phases: HydrationReportPhase[];
}
@@ -195,8 +231,10 @@ function round(n: number): number {
return Math.round(n * 100) / 100;
}
interface AttemptRecord {
export interface AttemptRecord {
sessionId: string;
/** Clock time when the attempt was created (`newAttemptId`). */
createdAt: number;
aborted: boolean;
superseded: boolean;
}
@@ -222,6 +260,7 @@ function newSessionId(prefix: string): string {
const bootSessionId = newSessionId('boot');
let bootStartAt: number | null = null;
let nodeSessionId: string | null = null;
let nodeSessionStartAt: number | null = null;
let currentNodeId: number | null = null;
let events: HydrationEvent[] = [];
@@ -306,13 +345,15 @@ let flushScheduled = false;
let lastEmitAt = 0;
function rebuildSnapshot(): void {
const foreground = resolveForegroundAttempt(events, nodeSessionId, attempts);
snapshot = {
schemaVersion: 1,
clock: clockKind,
bootSessionId,
bootStartAt,
nodeSessionId,
nodeId: currentNodeId,
nodeSessionStartAt,
lastAttempt: foreground,
events: events.slice(),
};
}
@@ -380,6 +421,7 @@ export function beginNodeSession(nodeId: number): string {
committedKeys.clear();
}
nodeSessionId = newSessionId('node');
nodeSessionStartAt = now();
currentNodeId = nodeId;
scheduleEmit();
return nodeSessionId;
@@ -387,7 +429,12 @@ export function beginNodeSession(nodeId: number): string {
export function newAttemptId(): string {
const id = newSessionId('attempt');
attempts.set(id, { sessionId: currentSessionId(), aborted: false, superseded: false });
attempts.set(id, {
sessionId: currentSessionId(),
createdAt: now(),
aborted: false,
superseded: false,
});
if (attempts.size > MAX_ATTEMPTS) {
const oldest = attempts.keys().next().value;
if (oldest !== undefined) attempts.delete(oldest);
@@ -418,6 +465,9 @@ export function abortAttempt(attemptId: string): void {
nodeId: sessionNodeId(span.sessionId),
});
}
// Rebuild the snapshot even when no span was open, so the aborted attempt
// cannot linger as the resolved foreground attempt until unrelated activity.
scheduleEmit();
}
export function markMilestone(phase: HydrationMark, opts: MilestoneOptions = {}): void {
@@ -556,23 +606,200 @@ export function classifyCritical(phase: string): boolean {
return !BACKGROUND_PHASES.has(phase);
}
/** Elapsed ms from `boot_start` to the most recent `list_visible` in `events`. */
/** Newest event in `eventList` matching `predicate`, or null. */
function findLatestEvent(
eventList: readonly HydrationEvent[],
predicate: (e: HydrationEvent) => boolean,
): HydrationEvent | null {
for (let i = eventList.length - 1; i >= 0; i--) {
if (predicate(eventList[i])) return eventList[i];
}
return null;
}
/** Raw boot-relative elapsed ms from `boot_start` to the most recent
* `list_visible`, with no session or attempt filtering. Retained as the
* compatibility surface; consumers needing a truthful foreground hydration
* duration should use `getAttemptListVisibleMsFrom` (attempt-relative) or
* `getSessionListVisibleMsFrom` (session-relative). */
export function listVisibleMsFrom(
eventList: readonly HydrationEvent[],
bootAt: number | null,
): number | null {
if (bootAt == null) return null;
const event = findLatestEvent(eventList, (e) => e.phase === 'list_visible');
return event == null ? null : round(event.t0 - bootAt);
}
/** Boot-relative compatibility getter: elapsed ms from `boot_start` to the
* most recent `list_visible` across all sessions, or null. */
export function getListVisibleMs(): number | null {
return listVisibleMsFrom(events, bootStartAt);
}
// ---------------------------------------------------------------------------
// Session-correct derivations. The `*From` functions below are pure scans
// over the event list and explicit anchors, so React consumers can derive
// from the snapshot they last read (see `useHydrationTiming`). The no-arg
// getters wrap them with live module state for the report capture, a
// point-in-time read rather than a reactive derivation.
// ---------------------------------------------------------------------------
export interface ForegroundAttempt {
attemptId: string;
createdAt: number;
/** Proxy flag from the foreground `list_visible` event itself. */
proxied: boolean | null;
/** Node id from the foreground `list_visible` event itself. */
nodeId: number | null;
}
/** The foreground list attempt: the newest committed `list_visible` event in
* the active node session whose attempt record is still live (exists, not
* aborted, not superseded, same session). Selection is anchored on committed
* events, so a later stack-detail attempt can never steal the headline; the
* attempt map only filters for liveness and supplies `createdAt`. */
export function resolveForegroundAttempt(
eventList: readonly HydrationEvent[],
sessionId: string | null,
attemptRecords: ReadonlyMap<string, AttemptRecord>,
): ForegroundAttempt | null {
if (sessionId == null) return null;
for (let i = eventList.length - 1; i >= 0; i--) {
if (eventList[i].phase === 'list_visible') {
return round(eventList[i].t0 - bootAt);
}
const event = eventList[i];
if (event.phase !== 'list_visible' || event.commit !== true) continue;
if (event.sessionId !== sessionId) continue;
const attemptId = event.attemptId;
if (!attemptId) continue;
const record = attemptRecords.get(attemptId);
if (!record || record.aborted || record.superseded) continue;
if (record.sessionId !== sessionId) continue;
return {
attemptId,
createdAt: record.createdAt,
proxied: event.proxied ?? null,
nodeId: event.nodeId ?? null,
};
}
return null;
}
/** Elapsed ms from `boot_start` to the most recent `list_visible`, or null. */
export function getListVisibleMs(): number | null {
return listVisibleMsFrom(events, bootStartAt);
/** Elapsed ms from `boot_start` to the most recent `phase` event belonging to
* the boot session, or null when the phase has not fired (or was evicted). */
export function getBootMilestoneMsFrom(
eventList: readonly HydrationEvent[],
sessionId: string,
phase: string,
bootAt: number | null,
): number | null {
if (bootAt == null) return null;
const event = findLatestEvent(
eventList,
(e) => e.phase === phase && e.sessionId === sessionId,
);
return event == null ? null : round(event.t0 - bootAt);
}
/** Elapsed ms from the active node-session start to its most recent committed
* `list_visible`, or null before one exists. */
export function getSessionListVisibleMsFrom(
eventList: readonly HydrationEvent[],
sessionId: string | null,
sessionStartAt: number | null,
): number | null {
if (sessionStartAt == null) return null;
const event = findLatestEvent(
eventList,
(e) => e.phase === 'list_visible' && e.commit === true && e.sessionId === sessionId,
);
return event == null ? null : round(event.t0 - sessionStartAt);
}
/** Elapsed ms from the active node-session start to its most recent committed
* `list_hydrated`, or null before one exists. */
export function getSessionListHydratedMsFrom(
eventList: readonly HydrationEvent[],
sessionId: string | null,
sessionStartAt: number | null,
): number | null {
if (sessionStartAt == null) return null;
const event = findLatestEvent(
eventList,
(e) => e.phase === 'list_hydrated' && e.commit === true && e.sessionId === sessionId,
);
return event == null ? null : round(event.t0 - sessionStartAt);
}
/** Elapsed ms from the attempt's creation to its committed `list_visible`, or
* null. Only commit-aligned events count, matching `resolveForegroundAttempt`
* and the session getters; an uncommitted error mark never reads as success. */
export function getAttemptListVisibleMsFrom(
eventList: readonly HydrationEvent[],
attemptId: string,
attemptCreatedAt: number,
): number | null {
const event = findLatestEvent(
eventList,
(e) => e.phase === 'list_visible' && e.attemptId === attemptId && e.commit === true,
);
return event == null ? null : round(event.t0 - attemptCreatedAt);
}
/** Elapsed ms from the attempt's creation to its committed `list_hydrated`, or
* null until the attempt actually hydrates. Only commit-aligned events count,
* so a failed hydration marked via `markMilestone` never reads as completed.
* Never borrowed from another attempt. */
export function getAttemptListHydratedMsFrom(
eventList: readonly HydrationEvent[],
attemptId: string,
attemptCreatedAt: number,
): number | null {
const event = findLatestEvent(
eventList,
(e) => e.phase === 'list_hydrated' && e.attemptId === attemptId && e.commit === true,
);
return event == null ? null : round(event.t0 - attemptCreatedAt);
}
/** Elapsed ms from committed `list_visible` to committed `list_hydrated` for
* the same attempt, or null when either phase has not committed for it (or
* when hydration committed before visibility, which is not a valid gap).
* Defined only when visible precedes hydrated. */
export function getAttemptHydrationGapMsFrom(
eventList: readonly HydrationEvent[],
attemptId: string,
): number | null {
const visible = findLatestEvent(
eventList,
(e) => e.attemptId === attemptId && e.commit === true && e.phase === 'list_visible',
);
const hydrated = findLatestEvent(
eventList,
(e) => e.attemptId === attemptId && e.commit === true && e.phase === 'list_hydrated',
);
if (visible == null || hydrated == null || hydrated.t0 < visible.t0) return null;
return round(hydrated.t0 - visible.t0);
}
/** Page age at call time: elapsed ms from `boot_start` to now, or null. */
function getBootAgeMs(): number | null {
return bootStartAt == null ? null : round(now() - bootStartAt);
}
/** Active node-session age at call time: elapsed ms from `beginNodeSession`
* to now, or null before the first node resolves. */
function getSessionAgeMs(): number | null {
return nodeSessionStartAt == null ? null : round(now() - nodeSessionStartAt);
}
/** Session-relative `list_visible` for the active node session, or null. */
function getSessionListVisibleMs(): number | null {
return getSessionListVisibleMsFrom(events, nodeSessionId, nodeSessionStartAt);
}
/** Session-relative `list_hydrated` for the active node session, or null. */
function getSessionListHydratedMs(): number | null {
return getSessionListHydratedMsFrom(events, nodeSessionId, nodeSessionStartAt);
}
function toReportPhase(event: HydrationEvent): HydrationReportPhase {
@@ -594,9 +821,14 @@ function toReportPhase(event: HydrationEvent): HydrationReportPhase {
};
}
function bootMilestoneMs(phase: string): number | null {
return getBootMilestoneMsFrom(events, bootSessionId, phase, bootStartAt);
}
export function getHydrationReport(): HydrationReport {
const foreground = resolveForegroundAttempt(events, nodeSessionId, attempts);
return {
schemaVersion: 1,
schemaVersion: 2,
capturedAt: Date.now(),
clock: clockKind,
...(appVersion ? { appVersion } : {}),
@@ -604,6 +836,26 @@ export function getHydrationReport(): HydrationReport {
nodeSessionId,
nodeId: currentNodeId,
listVisibleMs: getListVisibleMs(),
bootAgeMs: getBootAgeMs(),
bootAuthResolvedMs: bootMilestoneMs('auth_resolved'),
bootNodesResolvedMs: bootMilestoneMs('nodes_resolved'),
bootShellCommittedMs: bootMilestoneMs('shell_committed'),
sessionAgeMs: getSessionAgeMs(),
sessionListVisibleMs: getSessionListVisibleMs(),
sessionListHydratedMs: getSessionListHydratedMs(),
lastAttemptId: foreground?.attemptId ?? null,
lastAttemptListVisibleMs:
foreground != null
? getAttemptListVisibleMsFrom(events, foreground.attemptId, foreground.createdAt)
: null,
lastAttemptListHydratedMs:
foreground != null
? getAttemptListHydratedMsFrom(events, foreground.attemptId, foreground.createdAt)
: null,
lastAttemptHydrationGapMs:
foreground != null ? getAttemptHydrationGapMsFrom(events, foreground.attemptId) : null,
lastAttemptProxied: foreground?.proxied ?? null,
lastAttemptNodeId: foreground?.nodeId ?? null,
anyProxied: events.some((e) => e.proxied === true),
phases: events.map(toReportPhase),
};