fix(browser): cold-start the timeline source when no projection value is pushed
A session can sit with no pushed contextTimeline value at all — the enable recomposition raced its open baseline (the projection store clears a key the baseline omits), or its checkpoint row is missing or version-stale on a cold-observed session. The old code returned a loading surface in that case with no request and no failure path, so the Context tab and /context modal stalled on "Reading the session log…" until a refresh. The source now opens the detail channel itself (rev 0): the detail route folds the durable log on demand and serves the slim head beside the collections, the next pushed head takes the channel back per its own revision, and a read that settles without data surfaces the retryable failure note instead of an eternal spinner.
This commit is contained in:
1 parent
9ec53289ba
commit
864aa2603c
3 files changed
+73
-24
No files matched your search
@@ -16,6 +16,7 @@ import { useTimelineSource } from '../timelineSource'
|
||||
import type { ViewKit } from '../viewkit'
|
||||
import { makeContextBrowser } from './browser'
|
||||
import { makeCurrentComposition } from './currentComposition'
|
||||
import { makeDetailNote } from './detailNote'
|
||||
import { makeErrorBoundary } from './errorBoundary'
|
||||
import { useEscapeClose } from './escapeClose'
|
||||
import { makeLegend, makeStackedBar } from './stackedBar'
|
||||
@@ -35,6 +36,7 @@ export function makeContextModal(
|
||||
const Legend = makeLegend(kit)
|
||||
const CurrentComposition = makeCurrentComposition(kit, StackedBar, Legend)
|
||||
const ContextBrowser = makeContextBrowser(kit, StackedBar, settings)
|
||||
const DetailNote = makeDetailNote(kit)
|
||||
const ErrorBoundary = makeErrorBoundary(t)
|
||||
|
||||
function ContextModalBody(props: ContextModalProps): ReactElement | null {
|
||||
@@ -125,7 +127,11 @@ export function makeContextModal(
|
||||
</div>
|
||||
|
||||
{data === null || head === null ? (
|
||||
<div className="lc-empty">{t('loading')}</div>
|
||||
source.detailState === 'failed' ? (
|
||||
<DetailNote state="failed" onRetry={source.retryDetail} />
|
||||
) : (
|
||||
<div className="lc-empty">{t('loading')}</div>
|
||||
)
|
||||
) : (
|
||||
<div>
|
||||
<CurrentComposition
|
||||
|
||||
@@ -362,7 +362,16 @@ export function makeContextView(
|
||||
}, [activeReq, activeIdx, displayRequests])
|
||||
|
||||
if (!data) {
|
||||
return <div className="lc-root" ref={rootRef}><div className="lc-empty">{t('loading')}</div></div>
|
||||
// No renderable value: the cold read is still in flight (the loading
|
||||
// screen) or settled without one — the retryable failure note, never a
|
||||
// spinner that never resolves.
|
||||
return (
|
||||
<div className="lc-root" ref={rootRef}>
|
||||
{source.detailState === 'failed'
|
||||
? <DetailNote state="failed" onRetry={source.retryDetail} />
|
||||
: <div className="lc-empty">{t('loading')}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const markerOf = (req: RequestRecord): ContextEventRecord | undefined => {
|
||||
|
||||
@@ -24,6 +24,15 @@
|
||||
* stops the trailing until the head moves again. With no detail at all,
|
||||
* failure surfaces as a retryable note on the detail cards instead of an
|
||||
* empty chart.
|
||||
*
|
||||
* The cold start: a session can sit with NO pushed `contextTimeline` value
|
||||
* at all — the enable recomposition raced its open baseline (and the
|
||||
* projection store clears a key its baseline omits), or its checkpoint row
|
||||
* is missing or version-stale on a cold-observed session. Waiting for a
|
||||
* push that never comes would stall the view on "loading" forever, so the
|
||||
* source opens the detail channel on its own (rev 0): the route folds the
|
||||
* durable log on demand and serves the slim head beside the collections,
|
||||
* and the next pushed head takes the channel back per its own revision.
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useSyncExternalStore } from 'react'
|
||||
@@ -283,38 +292,63 @@ export function useTimelineSource(ctx: ClientCtx, props: SessionStandardProps):
|
||||
// inline generation — `headRev` null means no detail channel work at all.
|
||||
const headRev = head !== null && typeof head.detailRev === 'number' ? head.detailRev : null
|
||||
const slim = headRev !== null
|
||||
// The cold start arms whenever no value has been pushed: the channel is
|
||||
// opened by the source itself (rev 0) instead of waiting on a push that
|
||||
// may never come (the enable recomposition raced the baseline, a cleared
|
||||
// or stale checkpoint row). The store is per session, so the cold read's
|
||||
// result carries straight into the pushed generation's later refetches.
|
||||
const cold = head === null
|
||||
const store = useMemo(
|
||||
() => (slim ? detailStoreOf(ctx, sessionId) : null),
|
||||
[ctx, sessionId, slim],
|
||||
() => (slim || cold ? detailStoreOf(ctx, sessionId) : null),
|
||||
[ctx, sessionId, slim, cold],
|
||||
)
|
||||
const snap = useSyncExternalStore(
|
||||
store !== null ? store.subscribe : noopSubscribe,
|
||||
store !== null ? store.getSnapshot : () => EMPTY_SNAP,
|
||||
)
|
||||
useEffect(() => {
|
||||
if (store !== null && headRev !== null) store.request(headRev)
|
||||
// The pushed head's revision drives the trailing refetch; the cold start
|
||||
// requests rev 0 — acceptance is rev-safe, and a head that arrives later
|
||||
// trails per its own revision.
|
||||
if (store !== null) store.request(headRev ?? 0)
|
||||
}, [store, headRev])
|
||||
|
||||
return useMemo<TimelineSource>(() => {
|
||||
if (head === null) return { data: null, detailState: 'loading', retryDetail: noopRetry }
|
||||
if (!slim || store === null) return { data: head, detailState: 'legacy', retryDetail: noopRetry }
|
||||
const detail = snap.detail
|
||||
const data: ContextTimeline = detail === null
|
||||
? head
|
||||
: {
|
||||
...head,
|
||||
requests: detail.requests,
|
||||
events: detail.events,
|
||||
nodes: detail.nodes,
|
||||
droppedNodes: detail.droppedNodes,
|
||||
archive: detail.archive,
|
||||
// Slim heads serve no floors; the detail's pair lands whole here.
|
||||
...(detail.surfaceFloor !== undefined ? { surfaceFloor: detail.surfaceFloor } : {}),
|
||||
...(detail.archiveFloor !== undefined ? { archiveFloor: detail.archiveFloor } : {}),
|
||||
...(detail.fileOps !== undefined ? { fileOps: detail.fileOps } : {}),
|
||||
...(detail.fileOpsFloor !== undefined ? { fileOpsFloor: detail.fileOpsFloor } : {}),
|
||||
}
|
||||
const detailState: DetailState = detail !== null ? 'ready' : snap.failed ? 'failed' : 'loading'
|
||||
return { data, detailState, retryDetail: store.retry }
|
||||
// A head-with-detail can only exist through a live channel store — the
|
||||
// fallback covers TS's narrowing loss, never a real state.
|
||||
const retry = store !== null ? store.retry : noopRetry
|
||||
// The inline generation passes through untouched — no store exists, the
|
||||
// collections never ride the channel.
|
||||
if (head !== null && !slim) return { data: head, detailState: 'legacy', retryDetail: noopRetry }
|
||||
// The render head: the pushed value, or — while nothing has been pushed —
|
||||
// the detail read's own slim head.
|
||||
const base = head !== null ? head : detail !== null ? detail.head ?? null : null
|
||||
if (base === null) {
|
||||
// Nothing renderable: the cold read still pending, or settled without
|
||||
// a usable value (transport failure, absence, a headless payload) —
|
||||
// the retryable failure, never a spinner that never resolves.
|
||||
const failed = store !== null && (snap.failed || detail !== null)
|
||||
return { data: null, detailState: failed ? 'failed' : 'loading', retryDetail: retry }
|
||||
}
|
||||
if (detail === null) {
|
||||
// The head's own counters render while the first read is in flight (or
|
||||
// once it settled without data — the failure note rides beside).
|
||||
return { data: base, detailState: snap.failed ? 'failed' : 'loading', retryDetail: retry }
|
||||
}
|
||||
const data: ContextTimeline = {
|
||||
...base,
|
||||
requests: detail.requests,
|
||||
events: detail.events,
|
||||
nodes: detail.nodes,
|
||||
droppedNodes: detail.droppedNodes,
|
||||
archive: detail.archive,
|
||||
// Slim heads serve no floors; the detail's pair lands whole here.
|
||||
...(detail.surfaceFloor !== undefined ? { surfaceFloor: detail.surfaceFloor } : {}),
|
||||
...(detail.archiveFloor !== undefined ? { archiveFloor: detail.archiveFloor } : {}),
|
||||
...(detail.fileOps !== undefined ? { fileOps: detail.fileOps } : {}),
|
||||
...(detail.fileOpsFloor !== undefined ? { fileOpsFloor: detail.fileOpsFloor } : {}),
|
||||
}
|
||||
return { data, detailState: 'ready', retryDetail: retry }
|
||||
}, [head, slim, store, snap])
|
||||
}
|
||||
Reference in new issue
Block a user