feat: add developer-mode startup and stack hydration timing (#1619)

* feat: add developer-mode startup and stack hydration timing

Instrument boot-to-list and detail hydration with commit-aligned milestones, truthful request stages, and destination/gateway debug duration logs so performance work is guided by measurements.

* fix: redact stack names and complete hydration request stages

Stop logging stack identifiers in containers debug timing, and record state_dispatch (plus detail fetch spans) so copied reports match the advertised stage breakdown.
This commit is contained in:
Anso
2026-07-14 17:24:25 -04:00
committed by GitHub
parent 4079cb9198
commit b70a529656
26 changed files with 2449 additions and 32 deletions
+13
View File
@@ -58,6 +58,9 @@ import { deriveMobileSurface, type MobileView } from './EditorLayout/mobile-surf
import { BESPOKE_MOBILE_VIEWS } from './EditorLayout/mobile-treatments';
import { CapabilityGate } from './CapabilityGate';
import { HubOnlyGate } from './HubOnlyGate';
import { HydrationTimingPanel } from './HydrationTimingPanel';
import { useDeveloperMode } from '@/hooks/useDeveloperMode';
import { markMilestone } from '@/lib/hydrationTiming';
import type { SectionId } from './settings/types';
import type { NotificationItem } from './dashboard/types';
@@ -151,6 +154,14 @@ export default function EditorLayout() {
const canOfferVolumeRemoval =
activeNodeMeta?.capabilities.includes(STACK_DOWN_REMOVE_VOLUMES_CAPABILITY) === true;
// One-shot boot milestone: the app shell has mounted. Developer mode gates the
// hydration-timing overlay for the active node; it follows node switches.
useEffect(() => {
markMilestone('shell_committed');
}, []);
const developerMode = useDeveloperMode(activeNode?.id);
const hydrationOverlay = developerMode ? <HydrationTimingPanel /> : null;
// Mirror activeNode.id in a ref so async handlers (e.g. CreateStackDialog's
// post-create handoff) can detect a node switch that happened mid-flight.
// Closure capture of activeNode would always match the value at handler-creation
@@ -1120,6 +1131,7 @@ export default function EditorLayout() {
/>
{adoptDialogEl}
{shellOverlaysEl}
{hydrationOverlay}
</div>
);
}
@@ -1137,6 +1149,7 @@ export default function EditorLayout() {
</div>
{adoptDialogEl}
{shellOverlaysEl}
{hydrationOverlay}
</div>
);
})()}
@@ -1,5 +1,6 @@
import { useEffect, useRef, useState } from 'react';
import { apiFetch, fetchForNode } from '@/lib/api';
import { beginSpan, endSpan, markMilestone } from '@/lib/hydrationTiming';
import { toast } from '@/components/ui/toast-store';
import type { Node } from '@/context/NodeContext';
import type { NotificationItem } from '../../dashboard/types';
@@ -23,6 +24,10 @@ export function useNotifications({ nodes, onStateInvalidate, onImageUpdatesChang
onStateInvalidateRef.current = onStateInvalidate;
const onImageUpdatesChangeRef = useRef(onImageUpdatesChange);
onImageUpdatesChangeRef.current = onImageUpdatesChange;
// One-shot: the notifications_ready milestone reflects the first local settle.
// Its spans instrument only that first fetch so later polls do not pollute the
// report.
const notificationsReadyRef = useRef(false);
const fetchNotifications = async () => {
try {
@@ -31,18 +36,41 @@ export function useNotifications({ nodes, onStateInvalidate, onImageUpdatesChang
// Skip offline nodes: polling a removed/unreachable node only yields 502s.
const remoteNodes = currentNodes.filter(n => n.type === 'remote' && n.status !== 'offline');
const [localResult, ...remoteNodeResults] = await Promise.allSettled([
apiFetch('/notifications', { localOnly: true } as Parameters<typeof apiFetch>[1]),
...remoteNodes.map(n => fetchForNode('/notifications', n.id)),
]);
// Launch every request concurrently, but settle the local one first so the
// notifications_ready milestone is not held back by the slowest remote.
const localPromise = apiFetch('/notifications', { localOnly: true } as Parameters<typeof apiFetch>[1]);
const remotePromises = remoteNodes.map(n => fetchForNode('/notifications', n.id));
const all: NotificationItem[] = [];
if (localResult.status === 'fulfilled' && localResult.value.ok) {
const data = await localResult.value.json() as Omit<NotificationItem, 'nodeId' | 'nodeName'>[];
data.forEach(n => all.push({ ...n, nodeId: localNode?.id ?? -1, nodeName: localNode?.name ?? 'Local' }));
const instrument = !notificationsReadyRef.current;
const headersSpan = instrument ? beginSpan('fetch_headers', { background: true }) : null;
let bodySpan: ReturnType<typeof beginSpan> | null = null;
try {
const localRes = await localPromise;
if (headersSpan !== null) endSpan(headersSpan, { detail: { status: localRes.status } });
if (localRes.ok) {
bodySpan = instrument ? beginSpan('body_decode', { background: true }) : null;
const data = await localRes.json() as Omit<NotificationItem, 'nodeId' | 'nodeName'>[];
if (bodySpan !== null) endSpan(bodySpan);
bodySpan = null;
const dispatchSpan = instrument ? beginSpan('state_dispatch', { background: true }) : null;
data.forEach(n => all.push({ ...n, nodeId: localNode?.id ?? -1, nodeName: localNode?.name ?? 'Local' }));
if (dispatchSpan !== null) endSpan(dispatchSpan);
}
} catch (e) {
if (headersSpan !== null) endSpan(headersSpan, { outcome: 'error' });
if (bodySpan !== null) endSpan(bodySpan, { outcome: 'error' });
console.error('[Notifications] local fetch error:', e);
} finally {
if (!notificationsReadyRef.current) {
notificationsReadyRef.current = true;
markMilestone('notifications_ready');
}
}
// Remotes settle in the background; they never gate the milestone above.
const remoteNodeResults = await Promise.allSettled(remotePromises);
for (let i = 0; i < remoteNodes.length; i++) {
const result = remoteNodeResults[i];
if (result?.status === 'fulfilled' && result.value.ok) {
@@ -1,5 +1,14 @@
import { useRef, useCallback, useEffect } from 'react';
import { useRef, useCallback, useEffect, useState } from 'react';
import { apiFetch, withDeploySession } from '@/lib/api';
import {
newAttemptId,
abortAttempt,
beginSpan,
endSpan,
flushPendingCommit,
type PendingCommit,
type SpanHandle,
} from '@/lib/hydrationTiming';
import { toast } from '@/components/ui/toast-store';
import { buildServiceUrl, openServiceUrl } from '@/lib/serviceUrl';
import type { useEditorViewState } from './useEditorViewState';
@@ -268,6 +277,17 @@ export function useStackActions(options: UseStackActionsOptions) {
// late responses never overwrite freshly-loaded state.
const loadFileAbortRef = useRef<AbortController | null>(null);
// Hydration-timing: the current detail (loadFileCore) attempt, the file it is
// loading, and the commits waiting for React to observe committed state.
const detailAttemptRef = useRef<string | null>(null);
const detailFileRef = useRef<string | null>(null);
const detailVisiblePendingRef = useRef<PendingCommit | null>(null);
const detailContainersPendingRef = useRef<PendingCommit | null>(null);
const detailHydratedPendingRef = useRef<PendingCommit | null>(null);
// Bumped when arming detail_visible so same-file reloads (selectedFile and
// activeView unchanged) still re-run the commit effect.
const [detailVisibleEpoch, setDetailVisibleEpoch] = useState(0);
useEffect(() => {
return () => {
if (checkUpdatesIntervalRef.current !== null) {
@@ -277,6 +297,28 @@ export function useStackActions(options: UseStackActionsOptions) {
};
}, []);
// Commit-aligned detail milestones. Each fires once React has committed the
// observed state for the owning attempt; commitMilestone no-ops for a
// superseded (node switch) or aborted (re-load) attempt so an interrupted
// load never records a success milestone.
useEffect(() => {
if (stackListState.selectedFile !== detailFileRef.current) return;
if (navState.activeView !== 'editor') return;
flushPendingCommit(detailVisiblePendingRef, 'detail_visible');
}, [stackListState.selectedFile, navState.activeView, detailVisibleEpoch]);
useEffect(() => {
if (stackListState.selectedFile !== detailFileRef.current) return;
flushPendingCommit(detailContainersPendingRef, 'detail_containers_ready');
}, [editorState.containers, stackListState.selectedFile]);
useEffect(() => {
// Wait for the load to settle so this reflects the fully hydrated detail.
if (editorState.isFileLoading) return;
if (stackListState.selectedFile !== detailFileRef.current) return;
flushPendingCommit(detailHydratedPendingRef, 'detail_hydrated');
}, [editorState.isFileLoading, stackListState.selectedFile, editorState.containers]);
const isAbortError = (err: unknown): boolean =>
err instanceof Error && err.name === 'AbortError';
@@ -476,16 +518,47 @@ export function useStackActions(options: UseStackActionsOptions) {
}
};
const loadContainerState = async (filename: string, signal?: AbortSignal) => {
const loadContainerState = async (
filename: string,
signal?: AbortSignal,
attemptId?: string,
proxied?: boolean,
): Promise<number> => {
let headersSpan: SpanHandle | null = null;
let bodySpan: SpanHandle | null = null;
try {
headersSpan = attemptId
? beginSpan('fetch_headers', { attemptId, proxied })
: null;
const containersRes = await apiFetch(`/stacks/${filename}/containers`, { signal });
if (signal?.aborted) return;
const hopProxied = containersRes.headers.get('x-sencho-proxy') === '1' || proxied === true;
if (headersSpan !== null) {
endSpan(headersSpan, { proxied: hopProxied, detail: { status: containersRes.status } });
headersSpan = null;
}
if (signal?.aborted) return 0;
bodySpan = attemptId
? beginSpan('body_decode', { attemptId, proxied: hopProxied })
: null;
const conts = await containersRes.json();
editorState.setContainers(Array.isArray(conts) ? conts : []);
if (bodySpan !== null) {
endSpan(bodySpan);
bodySpan = null;
}
const list = Array.isArray(conts) ? conts : [];
const dispatchSpan = attemptId
? beginSpan('state_dispatch', { attemptId, proxied: hopProxied })
: null;
editorState.setContainers(list);
if (dispatchSpan !== null) endSpan(dispatchSpan);
return list.length;
} catch (error) {
if (isAbortError(error)) return;
if (headersSpan !== null) endSpan(headersSpan, { outcome: 'error' });
if (bodySpan !== null) endSpan(bodySpan, { outcome: 'error' });
if (isAbortError(error)) return 0;
console.error('Failed to load containers:', error);
editorState.setContainers([]);
return 0;
}
};
@@ -520,29 +593,58 @@ export function useStackActions(options: UseStackActionsOptions) {
return { ok: false };
}
loadFileAbortRef.current?.abort();
// Supersede the previous detail attempt so a late commit from an interrupted
// load can never record a success milestone.
if (detailAttemptRef.current) abortAttempt(detailAttemptRef.current);
detailVisiblePendingRef.current = null;
detailContainersPendingRef.current = null;
detailHydratedPendingRef.current = null;
const controller = new AbortController();
loadFileAbortRef.current = controller;
const { signal } = controller;
const attemptId = newAttemptId();
detailAttemptRef.current = attemptId;
detailFileRef.current = filename;
editorState.setIsFileLoading(true);
editorState.setIsEditing(false);
editorState.setEditingCompose(false);
editorState.setActiveTab('compose');
let headersSpan: SpanHandle | null = null;
let bodySpan: SpanHandle | null = null;
try {
headersSpan = beginSpan('fetch_headers', { attemptId });
const res = await apiFetch(`/stacks/${filename}`, { signal });
if (signal.aborted) return { ok: false };
const proxied = res.headers.get('x-sencho-proxy') === '1';
endSpan(headersSpan, { proxied, detail: { status: res.status } });
headersSpan = null;
if (signal.aborted) { abortAttempt(attemptId); return { ok: false }; }
bodySpan = beginSpan('body_decode', { attemptId, proxied });
const text = await res.text();
if (signal.aborted) return { ok: false };
endSpan(bodySpan);
bodySpan = null;
if (signal.aborted) { abortAttempt(attemptId); return { ok: false }; }
if (!res.ok) {
throw new Error(`Failed to load stack: ${res.status}`);
}
const dispatchSpan = beginSpan('state_dispatch', { attemptId, proxied });
stackListState.setSelectedFile(filename);
navState.setActiveView('editor');
editorState.setContent(text || '');
editorState.setOriginalContent(text || '');
editorState.setComposeEtag(res.headers.get('etag'));
endSpan(dispatchSpan);
detailVisiblePendingRef.current = { attemptId, token: filename, proxied };
setDetailVisibleEpoch((n) => n + 1);
const envFiles = await loadEnvState(filename, signal);
await loadContainerState(filename, signal);
const containerCount = await loadContainerState(filename, signal, attemptId, proxied);
if (!signal.aborted) {
detailContainersPendingRef.current = {
attemptId,
token: `${filename}:${containerCount}`,
proxied,
};
}
await loadBackupState(filename, signal);
// Post-load auto-edit evaluates permission for the loaded target, not
// the previously selected stack (selectedFile was just updated above).
@@ -551,9 +653,14 @@ export function useStackActions(options: UseStackActionsOptions) {
editorState.setActiveTab('compose');
editorState.setIsEditing(true);
}
if (!signal.aborted) {
detailHydratedPendingRef.current = { attemptId, token: filename, proxied };
}
return { ok: true, envFiles };
} catch (error) {
if (isAbortError(error) || signal.aborted) return { ok: false };
if (headersSpan !== null) endSpan(headersSpan, { outcome: 'error' });
if (bodySpan !== null) endSpan(bodySpan, { outcome: 'error' });
if (isAbortError(error) || signal.aborted) { abortAttempt(attemptId); return { ok: false }; }
console.error('Failed to load file:', error);
toast.error(`Could not open "${filename.replace(/\.(ya?ml)$/, '')}". Check your connection and try again.`);
stackListState.setSelectedFile(null);
@@ -1,5 +1,15 @@
import { useState, useEffect, useRef, useMemo, useCallback } from 'react';
import { apiFetch } from '@/lib/api';
import {
newAttemptId,
abortAttempt,
beginSpan,
endSpan,
flushPendingCommit,
markMilestone,
type SpanHandle,
type PendingCommit,
} from '@/lib/hydrationTiming';
import { toast } from '@/components/ui/toast-store';
import { useNodes } from '@/context/NodeContext';
import { useImageUpdates } from '@/hooks/useImageUpdates';
@@ -85,6 +95,13 @@ export function useStackListState() {
// state writes so a rapid node switch cannot leave a stale files/filesNodeId.
const fetchSeqRef = useRef(0);
// Hydration-timing: the current foreground list attempt and the commits it is
// waiting for React to observe. Only foreground loads arm these; background
// refreshes still record diagnostic spans but do not re-commit the milestones.
const listAttemptRef = useRef<string | null>(null);
const listVisiblePendingRef = useRef<PendingCommit | null>(null);
const listHydratedPendingRef = useRef<PendingCommit | null>(null);
// Per-stack terminal failure records driving the in-detail recovery panel.
// In-memory only. Node scoping is enforced by the caller, which clears these
// on active-node change (see EditorLayout's node-switch effect) so a repeated
@@ -196,6 +213,20 @@ export function useStackListState() {
const mySeq = ++fetchSeqRef.current;
const stale = () => fetchSeqRef.current !== mySeq;
// Supersede any in-flight list attempt so a late commit from an interrupted
// load cannot record list_visible / list_hydrated for a stale fetch.
if (listAttemptRef.current) abortAttempt(listAttemptRef.current);
listVisiblePendingRef.current = null;
listHydratedPendingRef.current = null;
const attemptId = newAttemptId();
listAttemptRef.current = attemptId;
// True once the list itself is committed, so the shared catch below can tell
// a list-fetch failure (nothing visible) from a status-path failure (list is
// visible, hydration errored).
let listSucceeded = false;
let proxied = false;
if (!background) setIsLoading(true);
setStacksLoadNodeId(fetchNodeId);
if (!background || !hadSuccessfulListRef.current) {
@@ -203,9 +234,13 @@ export function useStackListState() {
setStacksLoadError(null);
}
const headersSpan = beginSpan('fetch_headers', { attemptId, background });
let bodySpan: SpanHandle | null = null;
try {
const res = await apiFetch('/stacks');
if (stale()) return [];
proxied = res.headers.get('x-sencho-proxy') === '1';
endSpan(headersSpan, { proxied, detail: { status: res.status } });
if (stale()) { abortAttempt(attemptId); return []; }
if (!res.ok) {
const message = `Could not load stacks (${res.status})`;
if (background && hadSuccessfulListRef.current) {
@@ -218,26 +253,51 @@ export function useStackListState() {
setStacksLoadError(message);
return [];
}
bodySpan = beginSpan('body_decode', { attemptId, background, proxied });
const data = await res.json();
endSpan(bodySpan);
bodySpan = null;
const fileList: string[] = Array.isArray(data) ? data : [];
const listDispatch = beginSpan('state_dispatch', { attemptId, background, proxied });
setFiles(fileList);
setFilesNodeId(fetchNodeId);
hadSuccessfulListRef.current = true;
setStacksLoadStatus('success');
setStacksLoadError(null);
endSpan(listDispatch);
listSucceeded = true;
// Token folds node + count so an empty->empty commit still fires once per
// attempt even when the committed `files` is referentially equal.
const listToken = `${fetchNodeId}:${fileList.length}`;
if (!background) {
listVisiblePendingRef.current = { attemptId, token: listToken, proxied };
}
// Fetch all stack statuses in a single bulk call. Only the current object
// format can express `partial`; a node lacking the endpoint or returning
// the legacy plain-string format is re-derived from per-stack containers
// so a crashed container is not hidden behind a healthy sibling.
const statusHeaders = beginSpan('fetch_headers', { attemptId, background, proxied });
const statusRes = await apiFetch('/stacks/statuses');
if (stale()) return fileList;
const statusProxied = statusRes.headers.get('x-sencho-proxy') === '1' || proxied;
endSpan(statusHeaders, { proxied: statusProxied, detail: { status: statusRes.status } });
if (stale()) { abortAttempt(attemptId); return fileList; }
let bulkStatuses: Record<string, StackRowStatus> = {};
const bulkPorts: Record<string, number | undefined> = {};
const bulkSelf: Record<string, boolean> = {};
const bulkCounts: StackCounts = {};
const raw: unknown = statusRes.ok ? await statusRes.json() : null;
let raw: unknown = null;
if (statusRes.ok) {
const statusBodySpan = beginSpan('body_decode', { attemptId, background, proxied: statusProxied });
try {
raw = await statusRes.json();
endSpan(statusBodySpan);
} catch (decodeErr) {
endSpan(statusBodySpan, { outcome: 'error' });
throw decodeErr;
}
}
if (isBulkStatusObjectFormat(raw)) {
for (const [key, val] of Object.entries(raw as Record<string, StackStatusInfo>)) {
bulkStatuses[key] = val.status;
@@ -250,6 +310,7 @@ export function useStackListState() {
} else {
bulkStatuses = await deriveStatusesFromContainers(fileList);
}
const statusDispatch = beginSpan('state_dispatch', { attemptId, background, proxied: statusProxied });
setStackStatuses(prev => {
const next: StackStatus = {};
for (const file of fileList) {
@@ -265,12 +326,24 @@ export function useStackListState() {
});
setStackSelfFlags(bulkSelf);
setStackCounts(bulkCounts);
endSpan(statusDispatch);
refreshLabels();
if (!background) {
listHydratedPendingRef.current = { attemptId, token: listToken, proxied: statusProxied };
}
return fileList;
} catch (error) {
if (stale()) return [];
// endSpan is a no-op when the span was already closed (or never opened).
endSpan(headersSpan, { outcome: 'error' });
if (bodySpan !== null) endSpan(bodySpan, { outcome: 'error' });
if (stale()) { abortAttempt(attemptId); return []; }
console.error('Failed to refresh stacks:', error);
const message = error instanceof Error ? error.message : 'Failed to load stacks';
// The list committed but hydrating its statuses threw: record the list
// path as hydrated-with-error rather than leaving it hanging.
if (listSucceeded && !background) {
markMilestone('list_hydrated', { attemptId, outcome: 'error', proxied });
}
if (background && hadSuccessfulListRef.current) {
setStacksLoadError(message);
return files;
@@ -290,6 +363,20 @@ export function useStackListState() {
const refreshStacksRef = useRef(refreshStacks);
useEffect(() => { refreshStacksRef.current = refreshStacks; });
// Commit-aligned list milestones: fire once React has actually committed the
// file list (list_visible) and the statuses (list_hydrated) for the owning
// attempt. commitMilestone no-ops for a superseded/aborted attempt, so a stale
// load can never complete a session it no longer owns. Empty lists still fire
// via the completion token.
useEffect(() => {
if (stacksLoadStatus !== 'success') return;
flushPendingCommit(listVisiblePendingRef, 'list_visible');
}, [files, filesNodeId, stacksLoadStatus]);
useEffect(() => {
flushPendingCommit(listHydratedPendingRef, 'list_hydrated');
}, [stackStatuses, filesNodeId]);
const handleScanStacks = async () => {
if (isScanning) return;
setIsScanning(true);
@@ -0,0 +1,184 @@
import { useCallback, useEffect, useState } from 'react';
import { Copy, Timer, Trash2, X } from 'lucide-react';
import { cn } from '@/lib/utils';
import { copyToClipboard } from '@/lib/clipboard';
import { toast } from '@/components/ui/toast-store';
import { useHydrationTiming } from '@/hooks/useHydrationTiming';
import { clearReport, getHydrationReport } from '@/lib/hydrationTiming';
import type { HydrationOutcome } from '@/lib/hydrationTiming';
/** Format an elapsed duration compactly: seconds with one decimal at or above
* 1s, whole milliseconds below. */
function formatMs(ms: number): string {
return ms >= 1000 ? `${(ms / 1000).toFixed(1)}s` : `${Math.round(ms)}ms`;
}
function formatOffset(ms: number | null): string {
return ms == null ? '-' : formatMs(ms);
}
function outcomeDotClass(outcome: HydrationOutcome | undefined, critical: boolean): string {
if (outcome === 'error') return 'bg-destructive';
if (outcome === 'aborted' || outcome === 'superseded') return 'bg-muted-foreground';
return critical ? 'bg-brand' : 'bg-success';
}
const POSITION_CLASS =
'fixed left-4 bottom-6 z-[100] max-md:left-3 max-md:right-3 max-md:bottom-[calc(var(--sn-mobile-tabbar-h)_+_env(safe-area-inset-bottom)_+_0.75rem)]';
/**
* 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.
*/
export function HydrationTimingPanel() {
const { listVisibleMs } = useHydrationTiming();
const [expanded, setExpanded] = useState(false);
useEffect(() => {
if (!expanded) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setExpanded(false);
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [expanded]);
const chipLabel = listVisibleMs == null ? 'list …' : `list ${formatMs(listVisibleMs)}`;
const handleCopy = useCallback(async () => {
try {
await copyToClipboard(JSON.stringify(getHydrationReport(), null, 2));
toast.success('Hydration report copied.');
} catch (e) {
console.error('[HydrationTiming] copy failed:', e);
toast.error('Could not copy the hydration report.');
}
}, []);
if (!expanded) {
return (
<button
type="button"
aria-label={`Hydration timing, ${chipLabel}`}
data-testid="hydration-chip"
onClick={() => setExpanded(true)}
className={cn(
POSITION_CLASS,
'flex items-center gap-1.5 rounded-full border border-glass-border bg-popover/95 px-3 py-1.5 text-xs text-foreground shadow-lg backdrop-blur-[10px] backdrop-saturate-[1.15] max-md:right-auto',
)}
>
<Timer className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className="font-mono tabular-nums">{chipLabel}</span>
</button>
);
}
// Only build the (potentially large) report while the panel is open.
const report = getHydrationReport();
return (
<div
role="dialog"
aria-label="Hydration timing"
data-testid="hydration-panel"
className={cn(
POSITION_CLASS,
'flex w-[360px] max-w-[calc(100vw-2rem)] flex-col overflow-hidden rounded-xl border border-glass-border bg-popover/95 text-xs text-foreground shadow-lg backdrop-blur-[10px] backdrop-saturate-[1.15] max-md:w-auto',
)}
>
<div className="flex items-center justify-between gap-2 border-b border-glass-border px-3 py-2">
<span className="flex items-center gap-1.5 font-medium">
<Timer className="h-3.5 w-3.5 text-muted-foreground" />
Hydration timing
</span>
<span className="font-mono tabular-nums text-muted-foreground">{chipLabel}</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]">
<tr>
<th className="px-3 py-1.5 font-medium">Phase</th>
<th className="px-3 py-1.5 text-right font-medium">At</th>
<th className="px-3 py-1.5 text-right font-medium">Dur</th>
</tr>
</thead>
<tbody>
{report.phases.length === 0 ? (
<tr>
<td className="px-3 py-2 text-muted-foreground" colSpan={3}>
No events recorded yet.
</td>
</tr>
) : (
report.phases.map((p, i) => (
<tr key={`${p.phase}-${p.attemptId ?? ''}-${i}`} className="border-t border-glass-border/50">
<td className="px-3 py-1.5">
<span className="flex items-center gap-1.5">
<span
className={cn(
'h-1.5 w-1.5 shrink-0 rounded-full',
outcomeDotClass(p.outcome, p.critical),
)}
/>
<span className="font-mono">{p.phase}</span>
{p.proxied && <span className="text-[0.6rem] text-muted-foreground">proxy</span>}
</span>
</td>
<td className="px-3 py-1.5 text-right font-mono tabular-nums text-muted-foreground">
{formatOffset(p.offsetMs)}
</td>
<td className="px-3 py-1.5 text-right font-mono tabular-nums text-muted-foreground">
{p.durationMs == null ? '-' : formatMs(p.durationMs)}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{report.anyProxied && (
<p className="border-t border-glass-border px-3 py-2 text-[0.65rem] leading-snug text-muted-foreground">
Some requests were proxied to a remote node. Nodes and proxy debug logs
need developer mode enabled on the gateway to appear.
</p>
)}
<div className="flex items-center justify-end gap-1.5 border-t border-glass-border px-3 py-2">
<button
type="button"
onClick={handleCopy}
data-testid="hydration-copy"
className="flex items-center gap-1 rounded-md border border-glass-border px-2 py-1 text-xs hover:bg-accent"
>
<Copy className="h-3 w-3" />
Copy report
</button>
<button
type="button"
onClick={clearReport}
data-testid="hydration-clear"
className="flex items-center gap-1 rounded-md border border-glass-border px-2 py-1 text-xs hover:bg-accent"
>
<Trash2 className="h-3 w-3" />
Clear
</button>
<button
type="button"
onClick={() => setExpanded(false)}
aria-label="Collapse hydration timing"
data-testid="hydration-collapse"
className="flex items-center gap-1 rounded-md border border-glass-border px-2 py-1 text-xs hover:bg-accent"
>
<X className="h-3 w-3" />
Collapse
</button>
</div>
</div>
);
}
@@ -0,0 +1,117 @@
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { HydrationReport, HydrationSnapshot } from '@/lib/hydrationTiming';
let mockSnapshot: HydrationSnapshot;
let mockListVisibleMs: number | null;
let mockReport: HydrationReport;
vi.mock('@/hooks/useHydrationTiming', () => ({
useHydrationTiming: () => ({ snapshot: mockSnapshot, listVisibleMs: mockListVisibleMs }),
}));
const clearReportMock = vi.fn();
vi.mock('@/lib/hydrationTiming', () => ({
getHydrationReport: () => mockReport,
clearReport: () => clearReportMock(),
}));
const copyMock = vi.fn().mockResolvedValue(undefined);
vi.mock('@/lib/clipboard', () => ({ copyToClipboard: (text: string) => copyMock(text) }));
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,
events,
};
}
function report(over: Partial<HydrationReport> = {}): HydrationReport {
return {
schemaVersion: 1,
capturedAt: 0,
clock: 'performance.now',
bootSessionId: 'boot-1',
nodeSessionId: 'node-2',
nodeId: 1,
listVisibleMs: 1200,
anyProxied: false,
phases: [
{ phase: 'boot_start', kind: 'milestone', offsetMs: 0, critical: true, outcome: 'ok' },
{ phase: 'list_visible', kind: 'milestone', offsetMs: 1200, uiCommitMs: 1200, critical: true, outcome: 'ok' },
],
...over,
};
}
beforeEach(() => {
clearReportMock.mockClear();
copyMock.mockClear();
mockSnapshot = snapshot();
mockListVisibleMs = 1200;
mockReport = report();
});
describe('HydrationTimingPanel', () => {
it('shows the list_visible elapsed time on the collapsed chip', () => {
render(<HydrationTimingPanel />);
expect(screen.getByTestId('hydration-chip')).toHaveTextContent('list 1.2s');
});
it('shows an ellipsis before list_visible commits', () => {
mockListVisibleMs = null;
render(<HydrationTimingPanel />);
expect(screen.getByTestId('hydration-chip')).toHaveTextContent('list …');
});
it('expands into the phase table and collapses again', () => {
render(<HydrationTimingPanel />);
fireEvent.click(screen.getByTestId('hydration-chip'));
expect(screen.getByTestId('hydration-panel')).toBeInTheDocument();
expect(screen.getByText('list_visible')).toBeInTheDocument();
fireEvent.click(screen.getByTestId('hydration-collapse'));
expect(screen.queryByTestId('hydration-panel')).toBeNull();
expect(screen.getByTestId('hydration-chip')).toBeInTheDocument();
});
it('collapses on Escape', () => {
render(<HydrationTimingPanel />);
fireEvent.click(screen.getByTestId('hydration-chip'));
act(() => {
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
});
expect(screen.queryByTestId('hydration-panel')).toBeNull();
});
it('copies the hydration report as pretty JSON', async () => {
render(<HydrationTimingPanel />);
fireEvent.click(screen.getByTestId('hydration-chip'));
fireEvent.click(screen.getByTestId('hydration-copy'));
await waitFor(() => expect(copyMock).toHaveBeenCalledTimes(1));
expect(copyMock).toHaveBeenCalledWith(JSON.stringify(mockReport, null, 2));
});
it('clears the report', () => {
render(<HydrationTimingPanel />);
fireEvent.click(screen.getByTestId('hydration-chip'));
fireEvent.click(screen.getByTestId('hydration-clear'));
expect(clearReportMock).toHaveBeenCalledTimes(1);
});
it('notes gateway developer mode when any event was proxied', () => {
mockReport = report({ anyProxied: true });
render(<HydrationTimingPanel />);
fireEvent.click(screen.getByTestId('hydration-chip'));
expect(screen.getByText(/gateway/i)).toBeInTheDocument();
});
});
+8
View File
@@ -1,4 +1,5 @@
import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from 'react';
import { markMilestone } from '@/lib/hydrationTiming';
type AppStatus = 'loading' | 'needsSetup' | 'notAuthenticated' | 'mfaChallenge' | 'authenticated';
@@ -109,6 +110,13 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}
};
// One-shot boot milestone: the auth gate has resolved to a terminal status
// (setup, login, MFA, or authenticated), so the app can leave the splash.
useEffect(() => {
if (appStatus === 'loading') return;
markMilestone('auth_resolved');
}, [appStatus]);
useEffect(() => {
checkAuth();
const handleUnauthorized = () => {
+20
View File
@@ -1,5 +1,6 @@
import React, { createContext, useContext, useState, useEffect, useCallback, useRef, useMemo } from 'react';
import { apiFetch } from '@/lib/api';
import { beginNodeSession, markMilestone } from '@/lib/hydrationTiming';
import type { Capability } from '@/lib/capabilities';
export type NodeMode = 'proxy' | 'pilot_agent';
@@ -56,6 +57,18 @@ export function NodeProvider({ children }: { children: React.ReactNode }) {
const nodeMetaRef = useRef<Map<number, NodeMeta>>(nodeMeta);
nodeMetaRef.current = nodeMeta;
// Open a fresh hydration-timing session the moment the active node id changes,
// during render (guarded by a ref to fire once per id). This must precede the
// shell's stack-list refresh so its attempt binds to the new node session:
// child effects (EditorLayout's node-switch effect that calls refreshStacks)
// run before this provider's effects, so an effect here would begin the
// session too late and the list milestone would never commit.
const hydrationNodeRef = useRef<number | null>(null);
if (activeNode && hydrationNodeRef.current !== activeNode.id) {
hydrationNodeRef.current = activeNode.id;
beginNodeSession(activeNode.id);
}
const fetchNodeMeta = useCallback(async (nodeId: number, force = false) => {
const cached = nodeMetaRef.current.get(nodeId);
if (cached && !force) {
@@ -157,6 +170,13 @@ export function NodeProvider({ children }: { children: React.ReactNode }) {
return () => window.removeEventListener('node-not-found', handleNodeNotFound);
}, [refreshNodes]);
// One-shot boot milestone: nodes have finished loading and an active node is
// resolved. Deduped by the store, so a later re-run is a no-op.
useEffect(() => {
if (isLoading || !activeNode) return;
markMilestone('nodes_resolved');
}, [isLoading, activeNode]);
const activeNodeMeta = useMemo(() => {
if (!activeNode) return null;
return nodeMeta.get(activeNode.id) ?? null;
@@ -0,0 +1,96 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { renderHook, waitFor, act } from '@testing-library/react';
vi.mock('@/lib/api', () => ({ apiFetch: vi.fn() }));
import { apiFetch } from '@/lib/api';
import { SENCHO_SETTINGS_CHANGED } from '@/lib/events';
import { useDeveloperMode } from '../useDeveloperMode';
const mockedFetch = apiFetch as unknown as ReturnType<typeof vi.fn>;
function settingsResponse(developerMode: string) {
return { ok: true, status: 200, json: async () => ({ developer_mode: developerMode }) };
}
beforeEach(() => {
mockedFetch.mockReset();
// Failures are logged, not thrown; silence the expected console noise.
vi.spyOn(console, 'error').mockImplementation(() => {});
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('useDeveloperMode', () => {
it('enables when the active node has developer_mode on', async () => {
mockedFetch.mockResolvedValue(settingsResponse('1'));
const { result } = renderHook(() => useDeveloperMode(1));
await waitFor(() => expect(result.current).toBe(true));
});
it('discards a delayed node A response after switching to node B', async () => {
mockedFetch.mockImplementation((_url: string, opts?: { nodeId?: number | null }) => {
if (opts?.nodeId === 1) {
// Node A: developer mode on, but its response is slow.
return new Promise((resolve) => setTimeout(() => resolve(settingsResponse('1')), 50));
}
// Node B: developer mode off, fast.
return Promise.resolve(settingsResponse('0'));
});
const { result, rerender } = renderHook(({ id }) => useDeveloperMode(id), {
initialProps: { id: 1 as number | undefined },
});
rerender({ id: 2 });
await waitFor(() => expect(result.current).toBe(false));
// Node A's late response must not flip node B to enabled.
await new Promise((r) => setTimeout(r, 80));
expect(result.current).toBe(false);
});
it('returns false when the settings fetch rejects', async () => {
mockedFetch.mockRejectedValue(new Error('network down'));
const { result } = renderHook(() => useDeveloperMode(1));
await waitFor(() => expect(mockedFetch).toHaveBeenCalled());
expect(result.current).toBe(false);
});
it('returns false on a non-ok settings response', async () => {
mockedFetch.mockResolvedValue({ ok: false, status: 500, json: async () => ({}) });
const { result } = renderHook(() => useDeveloperMode(1));
await waitFor(() => expect(mockedFetch).toHaveBeenCalled());
expect(result.current).toBe(false);
});
it('refetches when a developer_mode settings change is broadcast', async () => {
let dev = '0';
mockedFetch.mockImplementation(() => Promise.resolve(settingsResponse(dev)));
const { result } = renderHook(() => useDeveloperMode(1));
await waitFor(() => expect(result.current).toBe(false));
dev = '1';
act(() => {
window.dispatchEvent(
new CustomEvent(SENCHO_SETTINGS_CHANGED, { detail: { changedKeys: ['developer_mode'] } }),
);
});
await waitFor(() => expect(result.current).toBe(true));
});
it('ignores a settings change that does not touch developer_mode', async () => {
mockedFetch.mockResolvedValue(settingsResponse('0'));
const { result } = renderHook(() => useDeveloperMode(1));
await waitFor(() => expect(result.current).toBe(false));
const callsBefore = mockedFetch.mock.calls.length;
act(() => {
window.dispatchEvent(
new CustomEvent(SENCHO_SETTINGS_CHANGED, { detail: { changedKeys: ['log_retention_days'] } }),
);
});
expect(mockedFetch.mock.calls.length).toBe(callsBefore);
});
});
+82
View File
@@ -0,0 +1,82 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { apiFetch } from '@/lib/api';
import { SENCHO_SETTINGS_CHANGED } from '@/lib/events';
import type { SenchoSettingsChangedDetail } from '@/lib/events';
/**
* Reads the active node's `developer_mode` setting, race-safe against node
* switches (mirrors the ownership pattern in `useImageUpdates`).
*
* The setting is node-scoped: `/settings` is proxied to whichever node is
* active, so a mid-flight switch must never let node A's response flip the
* result for node B. A generation counter discards stale responses, and an
* owner check returns `false` on the render before the reset effect fires.
*
* Any failure (network, non-ok, parse) resolves to `false` so the developer
* overlay stays hidden rather than flickering on a transient error.
*/
export function useDeveloperMode(activeNodeId: number | undefined): boolean {
const [enabled, setEnabled] = useState(false);
// Which node owns the current `enabled` value. When `activeNodeId` changes,
// React renders once with the old owner before the reset effect clears it;
// returning false on a mismatch avoids a one-frame flash of the wrong node's
// developer state.
const [ownerNodeId, setOwnerNodeId] = useState<number | undefined>(activeNodeId);
// Every node change increments this, and every await is gated against it so a
// slow response from a previous node is dropped.
const genRef = useRef(0);
const refresh = useCallback(async () => {
const gen = ++genRef.current;
const targetNodeId = activeNodeId ?? null;
try {
const res = await apiFetch('/settings', { nodeId: targetNodeId });
if (genRef.current !== gen) return;
if (!res.ok) {
console.error('[DeveloperMode] settings fetch returned', res.status);
setEnabled(false);
return;
}
const data = (await res.json()) as Record<string, string>;
if (genRef.current !== gen) return;
setEnabled(data.developer_mode === '1');
} catch (e) {
if (genRef.current !== gen) return;
console.error('[DeveloperMode] settings fetch failed:', e);
setEnabled(false);
}
}, [activeNodeId]);
// Pin the settings-event handler to the latest closure without retriggering
// the listener effect on every render.
const refreshRef = useRef(refresh);
refreshRef.current = refresh;
// Reset and refetch on mount and on node change. Capture the owning node and
// clear the flag BEFORE fetching so the guard returns false until the new
// node's response arrives.
useEffect(() => {
genRef.current += 1;
setEnabled(false); // eslint-disable-line react-hooks/set-state-in-effect
setOwnerNodeId(activeNodeId); // eslint-disable-line react-hooks/set-state-in-effect
void refreshRef.current();
}, [activeNodeId]);
// Propagate a developer-mode toggle immediately. Refetch when the change set
// names developer_mode, or when the detail is missing (unknown change set).
useEffect(() => {
const handler = (e: Event) => {
const detail = (e as CustomEvent<Partial<SenchoSettingsChangedDetail>>).detail;
if (!detail?.changedKeys || detail.changedKeys.includes('developer_mode')) {
void refreshRef.current();
}
};
window.addEventListener(SENCHO_SETTINGS_CHANGED, handler);
return () => window.removeEventListener(SENCHO_SETTINGS_CHANGED, handler);
}, []);
const isOwner = activeNodeId !== undefined && activeNodeId === ownerNodeId;
return isOwner ? enabled : false;
}
+21
View File
@@ -0,0 +1,21 @@
import { useSyncExternalStore } from 'react';
import { subscribe, getSnapshot, listVisibleMsFrom } 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;
}
/** 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. */
export function useHydrationTiming(): UseHydrationTiming {
const snapshot = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
return {
snapshot,
listVisibleMs: listVisibleMsFrom(snapshot.events, snapshot.bootStartAt),
};
}
+13
View File
@@ -1,5 +1,6 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { apiFetch } from '@/lib/api';
import { markMilestone } from '@/lib/hydrationTiming';
import { SENCHO_SETTINGS_CHANGED } from '@/lib/events';
import type { ImageUpdateStatus, StackUpdateInfo } from '@/types/imageUpdates';
@@ -31,6 +32,10 @@ export function useImageUpdates(activeNodeId: number | undefined) {
// discarded.
const genRef = useRef(0);
// Node the image_updates_ready milestone last fired for, so it records once
// per node session (re-firing after a node switch) rather than every poll.
const imageUpdatesReadyNodeRef = useRef<number | null | undefined>(undefined);
const refresh = useCallback(async () => {
const gen = ++genRef.current;
const targetNodeId = activeNodeId ?? null;
@@ -92,6 +97,14 @@ export function useImageUpdates(activeNodeId: number | undefined) {
};
await Promise.allSettled([fetchStatus(), fetchDetail()]);
// Background milestone: both image-update requests have settled for the
// active node. Fire once per node session, and only if this refresh still
// owns the generation (a node switch mid-flight defers to the new node).
if (genRef.current === gen && imageUpdatesReadyNodeRef.current !== targetNodeId) {
imageUpdatesReadyNodeRef.current = targetNodeId;
markMilestone('image_updates_ready');
}
}, [activeNodeId]);
// Pin the interval to the latest closure without retriggering it on
@@ -0,0 +1,185 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { waitFor } from '@testing-library/react';
// Each test gets a fresh module instance so the module-scoped boot session and
// event buffer start clean. Resetting modules re-runs the boot_start init.
type Store = typeof import('../hydrationTiming');
async function loadStore(setup?: () => void): Promise<Store> {
vi.resetModules();
setup?.();
return import('../hydrationTiming');
}
/** Stub `performance` with a caller-controlled clock so durations are exact. */
function stubClock(getT: () => number): void {
vi.stubGlobal('performance', {
now: () => getT(),
mark: vi.fn(),
measure: vi.fn(),
clearMarks: vi.fn(),
clearMeasures: vi.fn(),
});
}
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
describe('hydrationTiming store', () => {
it('records boot_start once and dedupes one-shot milestones (StrictMode)', async () => {
const store = await loadStore();
// Simulate a StrictMode double invocation plus an explicit re-mark.
store.markMilestone('boot_start');
store.markMilestone('boot_start', { oneShot: true });
store.markMilestone('auth_resolved');
store.markMilestone('auth_resolved');
const phases = store.getHydrationReport().phases.map((p) => p.phase);
expect(phases.filter((p) => p === 'boot_start')).toHaveLength(1);
expect(phases.filter((p) => p === 'auth_resolved')).toHaveLength(1);
});
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);
}
const report = store.getHydrationReport();
expect(report.phases).toHaveLength(200);
// boot_start was the very first event, so it has been evicted.
expect(report.phases.some((p) => p.phase === 'boot_start')).toBe(false);
});
it('never completes a superseded or aborted attempt', async () => {
const store = await loadStore();
store.beginNodeSession(1);
const superseded = store.newAttemptId();
store.beginNodeSession(2); // supersedes node session 1's attempts
store.commitMilestone('list_visible', superseded);
expect(store.getHydrationReport().phases.some((p) => p.phase === 'list_visible')).toBe(false);
const aborted = store.newAttemptId();
store.abortAttempt(aborted);
store.commitMilestone('detail_hydrated', aborted);
expect(store.getHydrationReport().phases.some((p) => p.phase === 'detail_hydrated')).toBe(false);
// A live attempt for the current node session still commits.
const live = store.newAttemptId();
store.commitMilestone('list_visible', live);
expect(store.getHydrationReport().phases.some((p) => p.phase === 'list_visible')).toBe(true);
});
it('marks unknown attempts as no-ops for commit milestones', async () => {
const store = await loadStore();
store.beginNodeSession(1);
store.commitMilestone('list_visible', 'attempt-does-not-exist');
expect(store.getHydrationReport().phases.some((p) => p.phase === 'list_visible')).toBe(false);
});
it('falls back to Date.now when performance is unavailable', async () => {
const store = await loadStore(() => vi.stubGlobal('performance', undefined));
expect(() => store.markMilestone('auth_resolved')).not.toThrow();
expect(store.getHydrationReport().clock).toBe('date.now-fallback');
});
it('tolerates a performance object missing mark and measure', async () => {
const store = await loadStore(() => vi.stubGlobal('performance', { now: () => 5 }));
expect(() => {
store.beginNodeSession(1);
const a = store.newAttemptId();
store.commitMilestone('list_visible', a);
const handle = store.beginSpan('body_decode', { attemptId: a });
store.endSpan(handle);
}).not.toThrow();
expect(store.getHydrationReport().clock).toBe('performance.now');
});
it('clears node session events but keeps boot markers', async () => {
const store = await loadStore();
store.markMilestone('auth_resolved');
store.beginNodeSession(1);
const a = store.newAttemptId();
store.commitMilestone('list_visible', a);
store.clearReport();
const phases = store.getHydrationReport().phases.map((p) => p.phase);
expect(phases).toContain('boot_start');
expect(phases).toContain('auth_resolved');
expect(phases).not.toContain('list_visible');
});
it('reports list_visible elapsed from boot_start', async () => {
let t = 0;
const store = await loadStore(() => stubClock(() => t));
t = 1200;
store.beginNodeSession(1);
const a = store.newAttemptId();
store.commitMilestone('list_visible', a);
expect(store.getListVisibleMs()).toBe(1200);
expect(store.getHydrationReport().listVisibleMs).toBe(1200);
});
it('returns null list_visible timing before it commits', async () => {
const store = await loadStore();
expect(store.getListVisibleMs()).toBeNull();
});
it('records span duration between begin and end', async () => {
let t = 0;
const store = await loadStore(() => stubClock(() => t));
store.beginNodeSession(1);
const a = store.newAttemptId();
t = 10;
const handle = store.beginSpan('fetch_headers', { attemptId: a });
t = 35;
store.endSpan(handle);
const span = store.getHydrationReport().phases.find((p) => p.phase === 'fetch_headers');
expect(span?.durationMs).toBe(25);
});
it('fires an empty->empty commit once per attempt via completion token', async () => {
const store = await loadStore();
store.beginNodeSession(1);
const a = store.newAttemptId();
store.commitMilestone('list_visible', a, { completionToken: 'empty' });
store.commitMilestone('list_visible', a, { completionToken: 'empty' });
const listVisible = store.getHydrationReport().phases.filter((p) => p.phase === 'list_visible');
expect(listVisible).toHaveLength(1);
expect(listVisible[0].detail?.completionToken).toBe('empty');
// Without a token, a repeat commit for the same attempt still dedupes.
const b = store.newAttemptId();
store.commitMilestone('list_hydrated', b);
store.commitMilestone('list_hydrated', b);
expect(store.getHydrationReport().phases.filter((p) => p.phase === 'list_hydrated')).toHaveLength(1);
});
it('classifies background phases as non-critical', async () => {
const store = await loadStore();
expect(store.classifyCritical('list_visible')).toBe(true);
expect(store.classifyCritical('notifications_ready')).toBe(false);
expect(store.classifyCritical('image_updates_ready')).toBe(false);
});
it('keeps snapshots referentially stable until an emit fires', async () => {
const store = await loadStore();
const before = store.getSnapshot();
expect(store.getSnapshot()).toBe(before);
const listener = vi.fn();
const unsubscribe = store.subscribe(listener);
store.markMilestone('auth_resolved');
await waitFor(() => expect(listener).toHaveBeenCalled());
const after = store.getSnapshot();
expect(after).not.toBe(before);
expect(after.events.some((e) => e.phase === 'auth_resolved')).toBe(true);
unsubscribe();
});
});
+639
View File
@@ -0,0 +1,639 @@
/**
* Session-scoped startup and stack-hydration timing store.
*
* The store lives for the lifetime of the page (one boot session) and tracks
* the latest node session on top of that. It is consumed through
* `useSyncExternalStore` (see `useHydrationTiming`) and copied out as a
* versioned JSON report from the developer-mode overlay.
*
* Design constraints:
* - Instrumentation only. Recording an event must never throw into a caller
* and must never perturb the timing it measures, so emits are coalesced.
* - Truthful ownership. A milestone committed from a React effect carries the
* owning attempt id; a late commit for a superseded or aborted attempt is a
* no-op so a stale render can never complete a session it no longer owns.
* - Degrade safely. `performance` and the User Timing API are optional; a
* `Date.now()` fallback keeps durations flowing when they are absent.
*/
/** Commit-aligned lifecycle phases surfaced in the chip, panel, and report. */
export type HydrationPhase =
| 'boot_start'
| 'auth_resolved'
| 'nodes_resolved'
| 'shell_committed'
| 'list_visible'
| 'list_hydrated'
| 'detail_visible'
| 'detail_containers_ready'
| 'detail_hydrated'
| 'notifications_ready'
| 'image_updates_ready';
/** Immediate post-setter debug marks, kept distinct from commit-aligned phases. */
export type StateDispatchedMark = `${string}_state_dispatched`;
/** Anything `markMilestone` accepts: a known phase or a debug dispatch mark. */
export type HydrationMark = HydrationPhase | StateDispatchedMark;
/** The subset of phases that are committed via `commitMilestone` (effect-observed). */
export type UiCommitPhase =
| 'list_visible'
| 'list_hydrated'
| 'detail_visible'
| 'detail_containers_ready'
| 'detail_hydrated';
/** Instrumented request stages at each `apiFetch` call site. */
export type HydrationStage = 'fetch_headers' | 'body_decode' | 'state_dispatch';
export type HydrationEventKind = 'milestone' | 'span' | 'background';
export type HydrationOutcome = 'ok' | 'error' | 'aborted' | 'superseded';
export type HydrationClock = 'performance.now' | 'date.now-fallback';
export type HydrationDetail = Record<string, unknown>;
export interface HydrationEvent {
/** Monotonic sequence id, stable for the lifetime of the event. */
id: number;
/** The boot or node session that owns this event. */
sessionId: string;
attemptId?: string;
/** Phase name (milestone) or stage name (span). */
phase: string;
kind: HydrationEventKind;
/** Clock start (milestone mark time, or span begin). */
t0: number;
/** Clock end for spans; absent for point milestones. */
t1?: number;
outcome?: HydrationOutcome;
detail?: HydrationDetail;
/** True when the underlying request crossed the remote-node proxy. */
proxied?: boolean;
/** True for milestones committed from an effect that observed committed state. */
commit?: boolean;
nodeId?: number | null;
}
export interface HydrationSnapshot {
schemaVersion: 1;
clock: HydrationClock;
bootSessionId: string;
bootStartAt: number | null;
nodeSessionId: string | null;
nodeId: number | null;
events: readonly HydrationEvent[];
}
export interface HydrationReportPhase {
phase: string;
kind: HydrationEventKind;
outcome?: HydrationOutcome;
/** Elapsed ms from `boot_start` to this event, or null if boot is unknown. */
offsetMs: number | null;
/** Span duration in ms (t1 - t0). */
durationMs?: number;
/** Elapsed ms from `boot_start` for commit-aligned milestones. */
uiCommitMs?: number;
critical: boolean;
proxied?: boolean;
attemptId?: string;
detail?: HydrationDetail;
}
export interface HydrationReport {
schemaVersion: 1;
/** Wall-clock capture time (Date.now), only for human reference. */
capturedAt: number;
clock: HydrationClock;
appVersion?: string;
bootSessionId: string;
nodeSessionId: string | null;
nodeId: number | null;
listVisibleMs: number | null;
anyProxied: boolean;
phases: HydrationReportPhase[];
}
export interface MilestoneOptions {
attemptId?: string;
outcome?: HydrationOutcome;
oneShot?: boolean;
detail?: HydrationDetail;
proxied?: boolean;
}
export interface CommitOptions {
outcome?: HydrationOutcome;
detail?: HydrationDetail;
proxied?: boolean;
/** Lets an empty->empty commit still fire once per attempt when committed
* state may be referentially equal to the previous render. */
completionToken?: string;
}
/** Armed by a fetch, flushed from a React effect once committed state is observed. */
export interface PendingCommit {
attemptId: string;
token: string;
proxied: boolean;
}
export interface SpanOptions {
attemptId?: string;
detail?: HydrationDetail;
proxied?: boolean;
/** Marks the span as belonging to a background refresh rather than the
* critical hydration path. */
background?: boolean;
}
export interface EndSpanOptions {
outcome?: HydrationOutcome;
detail?: HydrationDetail;
proxied?: boolean;
}
/** Opaque handle returned by `beginSpan` and passed back to `endSpan`. */
export type SpanHandle = number;
const MAX_EVENTS = 200;
const MAX_ATTEMPTS = 200;
/** Coalesce emits to at most 4 Hz so the store never perturbs the timings. */
const MIN_EMIT_INTERVAL_MS = 250;
const USER_TIMING_PREFIX = 'sn-hyd';
/** Phases recorded exactly once per boot session (deduped under StrictMode). */
const ONE_SHOT_PHASES: ReadonlySet<string> = new Set([
'boot_start',
'auth_resolved',
'nodes_resolved',
'shell_committed',
]);
/** Phases that hydrate off the critical path (reported as non-critical). */
const BACKGROUND_PHASES: ReadonlySet<string> = new Set([
'notifications_ready',
'image_updates_ready',
]);
const appVersion: string | undefined =
typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : undefined;
const clockKind: HydrationClock =
typeof performance !== 'undefined' && typeof performance.now === 'function'
? 'performance.now'
: 'date.now-fallback';
function now(): number {
return clockKind === 'performance.now' ? performance.now() : Date.now();
}
function round(n: number): number {
return Math.round(n * 100) / 100;
}
interface AttemptRecord {
sessionId: string;
aborted: boolean;
superseded: boolean;
}
interface OpenSpan {
stage: HydrationStage;
sessionId: string;
attemptId?: string;
t0: number;
kind: HydrationEventKind;
detail?: HydrationDetail;
proxied?: boolean;
}
let seq = 0;
function nextId(): number {
return ++seq;
}
function newSessionId(prefix: string): string {
return `${prefix}-${nextId()}`;
}
const bootSessionId = newSessionId('boot');
let bootStartAt: number | null = null;
let nodeSessionId: string | null = null;
let currentNodeId: number | null = null;
let events: HydrationEvent[] = [];
const attempts = new Map<string, AttemptRecord>();
const openSpans = new Map<SpanHandle, OpenSpan>();
/** Boot one-shot dedupe keys (`phase::bootSessionId`). */
const oneShotKeys = new Set<string>();
/** Commit dedupe keys (`phase::attemptId::completionToken`). */
const committedKeys = new Set<string>();
// User Timing helpers. Every entry point tolerates a missing API surface so a
// browser or test environment without `performance`, `mark`, or `measure`
// records durations off the clock fallback without throwing.
function hasPerformance(): boolean {
return typeof performance !== 'undefined';
}
const userTiming = {
mark(name: string): void {
if (!hasPerformance() || typeof performance.mark !== 'function') return;
try {
performance.mark(name);
} catch {
// User Timing is best-effort diagnostics; never surface a failure.
}
},
measure(name: string, startMark: string, endMark: string): void {
if (!hasPerformance() || typeof performance.measure !== 'function') return;
try {
performance.measure(name, startMark, endMark);
} catch {
// A missing start/end mark must not break timing capture.
}
},
clear(name: string): void {
if (!hasPerformance()) return;
try {
performance.clearMarks?.(name);
performance.clearMarks?.(`${name}:start`);
performance.clearMarks?.(`${name}:end`);
performance.clearMeasures?.(name);
} catch {
// Clearing stale entries is best-effort.
}
},
};
function markName(sessionId: string, attemptId: string | undefined, phase: string): string {
return `${USER_TIMING_PREFIX}:${sessionId}:${attemptId ?? '-'}:${phase}`;
}
function milestoneKind(phase: string): HydrationEventKind {
return BACKGROUND_PHASES.has(phase) ? 'background' : 'milestone';
}
/** Boot-scoped one-shots belong to the boot session; everything else to the
* active node session (falling back to boot before the first node resolves). */
function phaseSessionId(phase: string): string {
return ONE_SHOT_PHASES.has(phase) ? bootSessionId : nodeSessionId ?? bootSessionId;
}
function sessionNodeId(sessionId: string): number | null {
return sessionId === bootSessionId ? null : currentNodeId;
}
function currentSessionId(): string {
return nodeSessionId ?? bootSessionId;
}
function mergeDetail(a?: HydrationDetail, b?: HydrationDetail): HydrationDetail | undefined {
if (!a && !b) return undefined;
return { ...a, ...b };
}
// ---------------------------------------------------------------------------
// Snapshot + emit scheduling (useSyncExternalStore contract)
// ---------------------------------------------------------------------------
let snapshot: HydrationSnapshot;
const listeners = new Set<() => void>();
let flushScheduled = false;
let lastEmitAt = 0;
function rebuildSnapshot(): void {
snapshot = {
schemaVersion: 1,
clock: clockKind,
bootSessionId,
bootStartAt,
nodeSessionId,
nodeId: currentNodeId,
events: events.slice(),
};
}
function doEmit(): void {
flushScheduled = false;
lastEmitAt = now();
rebuildSnapshot();
for (const listener of listeners) listener();
}
function scheduleEmit(): void {
if (flushScheduled) return;
flushScheduled = true;
const run = (): void => {
const elapsed = now() - lastEmitAt;
if (elapsed >= MIN_EMIT_INTERVAL_MS) {
doEmit();
} else {
setTimeout(doEmit, MIN_EMIT_INTERVAL_MS - elapsed);
}
};
if (typeof requestAnimationFrame === 'function') {
requestAnimationFrame(run);
} else {
setTimeout(run, 16);
}
}
function pushEvent(event: HydrationEvent): void {
events.push(event);
if (events.length > MAX_EVENTS) {
events.splice(0, events.length - MAX_EVENTS);
}
scheduleEmit();
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
export function subscribe(listener: () => void): () => void {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}
export function getSnapshot(): HydrationSnapshot {
return snapshot;
}
/** Start a fresh node session, superseding the prior one and its attempts. */
export function beginNodeSession(nodeId: number): string {
if (nodeSessionId) {
const prior = nodeSessionId;
for (const attempt of attempts.values()) {
if (attempt.sessionId === prior && !attempt.aborted) attempt.superseded = true;
}
for (const [handle, span] of openSpans) {
if (span.sessionId === prior) openSpans.delete(handle);
}
// Retain only boot markers plus the incoming node session's events.
events = events.filter((e) => e.sessionId === bootSessionId);
committedKeys.clear();
}
nodeSessionId = newSessionId('node');
currentNodeId = nodeId;
scheduleEmit();
return nodeSessionId;
}
export function newAttemptId(): string {
const id = newSessionId('attempt');
attempts.set(id, { sessionId: currentSessionId(), aborted: false, superseded: false });
if (attempts.size > MAX_ATTEMPTS) {
const oldest = attempts.keys().next().value;
if (oldest !== undefined) attempts.delete(oldest);
}
return id;
}
/** Mark an attempt aborted; its in-flight spans finalize as aborted and later
* commit milestones for it become no-ops. */
export function abortAttempt(attemptId: string): void {
const attempt = attempts.get(attemptId);
if (!attempt) return;
attempt.aborted = true;
for (const [handle, span] of openSpans) {
if (span.attemptId !== attemptId) continue;
openSpans.delete(handle);
pushEvent({
id: handle,
sessionId: span.sessionId,
attemptId,
phase: span.stage,
kind: span.kind,
t0: span.t0,
t1: now(),
outcome: 'aborted',
detail: span.detail,
proxied: span.proxied,
nodeId: sessionNodeId(span.sessionId),
});
}
}
export function markMilestone(phase: HydrationMark, opts: MilestoneOptions = {}): void {
const oneShot = opts.oneShot ?? ONE_SHOT_PHASES.has(phase);
if (oneShot) {
const key = `${phase}::${bootSessionId}`;
if (oneShotKeys.has(key)) return;
oneShotKeys.add(key);
}
const sessionId = phaseSessionId(phase);
const t = now();
pushEvent({
id: nextId(),
sessionId,
attemptId: opts.attemptId,
phase,
kind: milestoneKind(phase),
t0: t,
outcome: opts.outcome ?? 'ok',
detail: opts.detail,
proxied: opts.proxied,
nodeId: sessionNodeId(sessionId),
});
userTiming.mark(markName(sessionId, opts.attemptId, phase));
}
/** Record a commit-aligned UI milestone. No-op unless the attempt is still the
* current, non-superseded, non-aborted attempt for the active node session. */
export function commitMilestone(
phase: UiCommitPhase,
attemptId: string,
opts: CommitOptions = {},
): void {
const attempt = attempts.get(attemptId);
if (!attempt) return;
if (attempt.aborted || attempt.superseded) return;
if (attempt.sessionId !== nodeSessionId) return;
const key = `${phase}::${attemptId}::${opts.completionToken ?? ''}`;
if (committedKeys.has(key)) return;
committedKeys.add(key);
const t = now();
const detail = opts.completionToken
? { ...(opts.detail ?? {}), completionToken: opts.completionToken }
: opts.detail;
pushEvent({
id: nextId(),
sessionId: attempt.sessionId,
attemptId,
phase,
kind: milestoneKind(phase),
t0: t,
outcome: opts.outcome ?? 'ok',
detail,
proxied: opts.proxied,
commit: true,
nodeId: currentNodeId,
});
userTiming.mark(markName(attempt.sessionId, attemptId, phase));
}
/** Commit and clear a pending UI milestone ref. No-op when the ref is empty;
* callers still own the readiness guards (selected file, load status, etc.). */
export function flushPendingCommit(
pendingRef: { current: PendingCommit | null },
phase: UiCommitPhase,
): void {
const pending = pendingRef.current;
if (!pending) return;
commitMilestone(phase, pending.attemptId, {
completionToken: pending.token,
proxied: pending.proxied,
});
pendingRef.current = null;
}
export function beginSpan(stage: HydrationStage, opts: SpanOptions = {}): SpanHandle {
const handle = nextId();
const sessionId = currentSessionId();
openSpans.set(handle, {
stage,
sessionId,
attemptId: opts.attemptId,
t0: now(),
kind: opts.background ? 'background' : 'span',
detail: opts.detail,
proxied: opts.proxied,
});
const name = markName(sessionId, opts.attemptId, stage);
userTiming.clear(name);
userTiming.mark(`${name}:start`);
return handle;
}
export function endSpan(handle: SpanHandle, opts: EndSpanOptions = {}): void {
const span = openSpans.get(handle);
if (!span) return;
openSpans.delete(handle);
const t1 = now();
const outcome = resolveSpanOutcome(span.attemptId, opts.outcome);
const name = markName(span.sessionId, span.attemptId, span.stage);
userTiming.mark(`${name}:end`);
userTiming.measure(name, `${name}:start`, `${name}:end`);
pushEvent({
id: handle,
sessionId: span.sessionId,
attemptId: span.attemptId,
phase: span.stage,
kind: span.kind,
t0: span.t0,
t1,
outcome,
detail: mergeDetail(span.detail, opts.detail),
proxied: opts.proxied ?? span.proxied,
nodeId: sessionNodeId(span.sessionId),
});
}
function resolveSpanOutcome(
attemptId: string | undefined,
requested: HydrationOutcome | undefined,
): HydrationOutcome {
if (attemptId) {
const attempt = attempts.get(attemptId);
if (attempt?.aborted) return 'aborted';
if (attempt?.superseded) return 'superseded';
}
return requested ?? 'ok';
}
/** True when a phase is on the critical hydration path (not a background fill). */
export function classifyCritical(phase: string): boolean {
return !BACKGROUND_PHASES.has(phase);
}
/** Elapsed ms from `boot_start` to the most recent `list_visible` in `events`. */
export function listVisibleMsFrom(
eventList: readonly HydrationEvent[],
bootAt: number | null,
): number | null {
if (bootAt == null) return null;
for (let i = eventList.length - 1; i >= 0; i--) {
if (eventList[i].phase === 'list_visible') {
return round(eventList[i].t0 - bootAt);
}
}
return null;
}
/** Elapsed ms from `boot_start` to the most recent `list_visible`, or null. */
export function getListVisibleMs(): number | null {
return listVisibleMsFrom(events, bootStartAt);
}
function toReportPhase(event: HydrationEvent): HydrationReportPhase {
const offsetMs = bootStartAt == null ? null : round(event.t0 - bootStartAt);
const durationMs = event.t1 != null ? round(event.t1 - event.t0) : undefined;
const uiCommitMs = event.commit && offsetMs != null ? offsetMs : undefined;
return {
phase: event.phase,
kind: event.kind,
outcome: event.outcome,
offsetMs,
durationMs,
uiCommitMs,
// Background fills already use kind 'background' (see milestoneKind / beginSpan).
critical: event.kind !== 'background',
proxied: event.proxied,
attemptId: event.attemptId,
detail: event.detail,
};
}
export function getHydrationReport(): HydrationReport {
return {
schemaVersion: 1,
capturedAt: Date.now(),
clock: clockKind,
...(appVersion ? { appVersion } : {}),
bootSessionId,
nodeSessionId,
nodeId: currentNodeId,
listVisibleMs: getListVisibleMs(),
anyProxied: events.some((e) => e.proxied === true),
phases: events.map(toReportPhase),
};
}
/** Clear the current node session's events (and commit dedupe), keeping boot
* markers so the boot timeline survives a manual clear. */
export function clearReport(): void {
events = events.filter((e) => e.sessionId === bootSessionId);
committedKeys.clear();
for (const [handle, span] of openSpans) {
if (span.sessionId !== bootSessionId) openSpans.delete(handle);
}
scheduleEmit();
}
// ---------------------------------------------------------------------------
// Boot session init: record boot_start once and seed the initial snapshot
// synchronously so the first `getSnapshot()` read already carries it.
// ---------------------------------------------------------------------------
bootStartAt = now();
oneShotKeys.add(`boot_start::${bootSessionId}`);
events.push({
id: nextId(),
sessionId: bootSessionId,
phase: 'boot_start',
kind: 'milestone',
t0: bootStartAt,
outcome: 'ok',
nodeId: null,
});
userTiming.mark(markName(bootSessionId, undefined, 'boot_start'));
rebuildSnapshot();
+3
View File
@@ -1,3 +1,6 @@
// Side-effect import first so the timing store records boot_start at the
// earliest possible point, before React mounts.
import '@/lib/hydrationTiming'
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'