mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-10 18:56:53 +00:00
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:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user