From 2eced48b0684cae51fc813cd3eacbea806577a8b Mon Sep 17 00:00:00 2001 From: Karolis Narkevicius Date: Sat, 15 Aug 2026 17:04:18 +0100 Subject: [PATCH 01/42] Improve devtools inspection and connection diagnostics --- DEVTOOLS_SPEC.md | 14 ++ extensions/src/panel.tsx | 2 + extensions/src/protocol.ts | 9 +- extensions/src/remote.ts | 30 +++- lib/adapters/adapter.ts | 20 +++ lib/adapters/feathers.ts | 144 ++++++++++++++++ lib/core/devtoolsBridge.ts | 14 +- lib/core/events.ts | 30 +++- lib/core/figbird.ts | 14 +- lib/core/queryStore.ts | 46 ++++- lib/core/relationalQuery.ts | 4 + lib/devtools/Devtools.tsx | 43 +++-- lib/devtools/EventsTab.tsx | 260 ++++++++++++++++++++++++----- lib/devtools/QueriesTab.tsx | 155 ++++++++++++----- lib/devtools/QueryDetails.tsx | 21 +++ lib/devtools/QueryPresentation.tsx | 15 +- lib/devtools/TimelineCanvas.tsx | 149 ++++++++++++++--- lib/devtools/TimelineOverview.tsx | 15 +- lib/devtools/TimelineTab.tsx | 128 +++++++++++++- lib/devtools/collector.ts | 69 +++++++- lib/devtools/model.ts | 22 ++- test/devtools.test.tsx | 52 +++++- test/relational-query.test.tsx | 4 + 23 files changed, 1100 insertions(+), 160 deletions(-) diff --git a/DEVTOOLS_SPEC.md b/DEVTOOLS_SPEC.md index ee4ab004..523e2016 100644 --- a/DEVTOOLS_SPEC.md +++ b/DEVTOOLS_SPEC.md @@ -22,6 +22,20 @@ The bridge serializes values before they cross the browser DevTools evaluation b Errors retain their name and message, bigint values become strings, and circular values are marked instead of breaking the panel. +## Connection diagnostics + +Adapters may expose transport lifecycle events through Figbird's adapter-neutral +connection observer. The Feathers adapter maps Socket.IO `connect`, `disconnect`, +connection failure, and Manager reconnection state into this observer. A successful +reconnection remains the single trigger for Figbird's active-query sweep. + +The panel retains these lifecycle events in the bounded event log and renders a +**Connection** timeline lane. Red spans show detected offline intervals; the reconnect +marker carries the final attempt count and transport so the refetch activity immediately +after it can be correlated visually. Individual retry attempts are deliberately +coalesced instead of filling the event buffer. Authentication payloads and tokens are +never collected. + ## Extension architecture ``` diff --git a/extensions/src/panel.tsx b/extensions/src/panel.tsx index 26f26eaa..67385255 100644 --- a/extensions/src/panel.tsx +++ b/extensions/src/panel.tsx @@ -27,6 +27,8 @@ function Panel() { } }, [session]) + useEffect(() => session.subscribeReset(() => collector.reset()), [collector, session]) + return ( ) diff --git a/extensions/src/protocol.ts b/extensions/src/protocol.ts index fb14196d..c049a36a 100644 --- a/extensions/src/protocol.ts +++ b/extensions/src/protocol.ts @@ -59,7 +59,14 @@ export function decodeEvent(event: DevtoolsWireEvent): FigbirdEvent { switch (event.kind) { case 'fetch:error': case 'mutate:error': - case 'action:error': { + case 'action:error': + case 'connection:error': { + const error = new Error(event.error.message) + error.name = event.error.name + return { ...event, error } + } + case 'connection:reconnect-failed': { + if (!event.error) return event const error = new Error(event.error.message) error.name = event.error.name return { ...event, error } diff --git a/extensions/src/remote.ts b/extensions/src/remote.ts index 4c003112..90866b4b 100644 --- a/extensions/src/remote.ts +++ b/extensions/src/remote.ts @@ -90,6 +90,15 @@ class RemoteFigbird implements FigbirdLikeForDevtools { this.#renderFrame = null } + reset(): void { + this.cancelPending() + this.#queries = [] + this.#relational = [] + this.#mutations = [] + for (const listener of this.#stateListeners) listener(undefined) + for (const listener of this.#mutatingListeners) listener() + } + #flush(): void { const read = this.#pending this.#pending = null @@ -122,6 +131,7 @@ export class ExtensionSession { #statusListeners = new Set<() => void>() #timer: ReturnType | null = null #version: number | null = null + #resetListeners = new Set<() => void>() constructor(evaluate: Evaluate = evaluateInspectedWindow) { this.#evaluate = evaluate @@ -135,6 +145,11 @@ export class ExtensionSession { return () => this.#statusListeners.delete(listener) } + subscribeReset = (listener: () => void): (() => void) => { + this.#resetListeners.add(listener) + return () => this.#resetListeners.delete(listener) + } + start(): void { if (this.#timer) return const generation = ++this.#generation @@ -182,8 +197,7 @@ export class ExtensionSession { ) if (generation !== this.#generation) return if (!poll) { - this.#connection = null - this.#version = null + this.#resetConnection() this.inspection.reset() this.#setStatus('Reconnecting') return @@ -193,8 +207,7 @@ export class ExtensionSession { await this.inspection.refresh() } catch { if (generation !== this.#generation) return - this.#connection = null - this.#version = null + this.#resetConnection() this.inspection.reset() this.#setStatus('Cannot inspect this page') } finally { @@ -202,6 +215,15 @@ export class ExtensionSession { } } + #resetConnection(): void { + const hadConnection = this.#connection !== null || this.#version !== null + this.#connection = null + this.#version = null + if (!hadConnection) return + this.figbird.reset() + for (const listener of this.#resetListeners) listener() + } + async #disconnect(sessionId: string): Promise { await this.#evaluate(`${BRIDGE_EXPRESSION}?.disconnect(${JSON.stringify(sessionId)})`).catch( () => {}, diff --git a/lib/adapters/adapter.ts b/lib/adapters/adapter.ts index b84e3548..9a92c1f1 100644 --- a/lib/adapters/adapter.ts +++ b/lib/adapters/adapter.ts @@ -61,6 +61,23 @@ export interface EventHandlers { removed: (item: unknown) => void } +/** Transport lifecycle facts an adapter can expose for observability. */ +export type AdapterConnectionEvent = + | { type: 'connected'; transport?: string; connectionId?: string } + | { + type: 'disconnected' + reason?: string + reconnecting: boolean + } + | { + type: 'reconnected' + attempt?: number + transport?: string + connectionId?: string + } + | { type: 'error'; phase: 'connect' | 'reconnect'; error: Error } + | { type: 'reconnect-failed'; error?: Error } + /** Service context supplied when the adapter evaluates a query locally. */ export interface MatcherContext { serviceName: string @@ -102,6 +119,9 @@ export interface Adapter< // reconnects after a period where realtime events may have been missed. subscribeToReconnect?(handler: () => void): () => void + /** Optional detailed transport lifecycle for devtools and diagnostics. */ + subscribeToConnectionEvents?(handler: (event: AdapterConnectionEvent) => void): () => void + /** * Read an item's id, or `undefined` when absent. Pure extraction — whether a * missing id is noteworthy is the store's call (it warns on event/fetch paths diff --git a/lib/adapters/feathers.ts b/lib/adapters/feathers.ts index bc7b6045..8f865f37 100644 --- a/lib/adapters/feathers.ts +++ b/lib/adapters/feathers.ts @@ -1,5 +1,6 @@ import type { Adapter, + AdapterConnectionEvent, EventHandlers, MatcherContext, PageCursor, @@ -228,6 +229,23 @@ interface ReconnectEventSource { removeListener?: (event: string, listener: () => void) => void } +type ConnectionEventListener = (...args: unknown[]) => void + +interface ConnectionEventSource { + on(event: string, listener: ConnectionEventListener): void + off?: (event: string, listener: ConnectionEventListener) => void + removeListener?: (event: string, listener: ConnectionEventListener) => void +} + +interface SocketIoConnectionSource extends ConnectionEventSource { + active?: boolean + connected?: boolean + id?: string + io?: ConnectionEventSource & { + engine?: { transport?: { name?: string } } + } +} + /** * Typed Feathers service for a specific service in the schema. * Provides full type safety for CRUD methods and custom methods. @@ -633,6 +651,124 @@ export class FeathersAdapter> implements Adapte } } + subscribeToConnectionEvents(handler: (event: AdapterConnectionEvent) => void): () => void { + const socket = this.#getSocketIoConnectionSource() + if (!socket) { + const source = this.#getReconnectEventSource() + if (!source) return () => {} + const onReconnect = () => handler({ type: 'reconnected' }) + source.on('reconnect', onReconnect) + return () => { + if (source.off) source.off('reconnect', onReconnect) + else source.removeListener?.('reconnect', onReconnect) + } + } + + const manager = socket.io + let disconnected = false + let reconnectAttempt: number | undefined + let lastReconnectError: Error | undefined + const listeners: Array<{ + source: ConnectionEventSource + event: string + listener: ConnectionEventListener + }> = [] + const listen = ( + source: ConnectionEventSource | undefined, + event: string, + listener: ConnectionEventListener, + ) => { + if (!source) return + source.on(event, listener) + listeners.push({ source, event, listener }) + } + const connectionDetails = () => ({ + ...(manager?.engine?.transport?.name ? { transport: manager.engine.transport.name } : {}), + ...(socket.id ? { connectionId: socket.id } : {}), + }) + + listen(socket, 'connect', () => { + if (disconnected || reconnectAttempt !== undefined) { + handler({ + type: 'reconnected', + ...(reconnectAttempt === undefined ? {} : { attempt: reconnectAttempt }), + ...connectionDetails(), + }) + } else { + handler({ type: 'connected', ...connectionDetails() }) + } + disconnected = false + reconnectAttempt = undefined + lastReconnectError = undefined + }) + listen(socket, 'disconnect', (reason: unknown) => { + disconnected = true + handler({ + type: 'disconnected', + ...(typeof reason === 'string' ? { reason } : {}), + reconnecting: socket.active === true, + }) + }) + listen(socket, 'connect_error', (error: unknown) => { + const captured = connectionError(error) + if (disconnected || reconnectAttempt !== undefined || socket.active === true) { + lastReconnectError = captured + } else { + handler({ type: 'error', phase: 'connect', error: captured }) + } + }) + listen(manager, 'reconnect_attempt', (attempt: unknown) => { + if (typeof attempt !== 'number') return + reconnectAttempt = attempt + }) + listen(manager, 'reconnect_error', (error: unknown) => { + lastReconnectError = connectionError(error) + }) + listen(manager, 'reconnect_failed', () => + handler({ + type: 'reconnect-failed', + ...(lastReconnectError ? { error: lastReconnectError } : {}), + }), + ) + if (!manager) { + listen(socket, 'reconnect', (attempt: unknown) => { + handler({ + type: 'reconnected', + ...(typeof attempt === 'number' ? { attempt } : {}), + ...connectionDetails(), + }) + disconnected = false + reconnectAttempt = undefined + lastReconnectError = undefined + }) + } + + return () => { + for (const { source, event, listener } of listeners) { + if (source.off) source.off(event, listener) + else source.removeListener?.(event, listener) + } + } + } + + #getSocketIoConnectionSource(): SocketIoConnectionSource | null { + const candidates = [ + (this.feathers as { io?: unknown }).io, + (this.feathers as { socket?: unknown }).socket, + ] + for (const candidate of candidates) { + if ( + candidate && + typeof candidate === 'object' && + 'on' in candidate && + typeof candidate.on === 'function' + ) { + return candidate as SocketIoConnectionSource + } + } + return null + } + #getReconnectEventSource(): ReconnectEventSource | null { const io = (this.feathers as { io?: { io?: unknown } }).io const candidates = [ @@ -751,3 +887,11 @@ export class FeathersAdapter> implements Adapte return window } } + +function connectionError(value: unknown): Error { + if (value instanceof Error) return value + if (typeof value === 'object' && value !== null && 'message' in value) { + return new Error(String(value.message)) + } + return new Error(String(value)) +} diff --git a/lib/core/devtoolsBridge.ts b/lib/core/devtoolsBridge.ts index 4ffd99dd..80500707 100644 --- a/lib/core/devtoolsBridge.ts +++ b/lib/core/devtoolsBridge.ts @@ -21,9 +21,12 @@ export interface DevtoolsWireError { name: string } -type ToWireEvent = E extends { error: Error } - ? Omit & { error: DevtoolsWireError } - : E +type ToWireEvent = E extends unknown + ? 'error' extends keyof E + ? Omit & + (E extends { error: Error } ? { error: DevtoolsWireError } : { error?: DevtoolsWireError }) + : E + : never export type DevtoolsWireEvent = ToWireEvent @@ -222,10 +225,15 @@ function toWireEvent(event: FigbirdEvent): DevtoolsWireEvent { case 'fetch:error': case 'mutate:error': case 'action:error': + case 'connection:error': return { ...event, error: { message: event.error.message, name: event.error.name }, } + case 'connection:reconnect-failed': + return event.error + ? { ...event, error: { message: event.error.message, name: event.error.name } } + : event default: return event } diff --git a/lib/core/events.ts b/lib/core/events.ts index 0055160b..8ce0e317 100644 --- a/lib/core/events.ts +++ b/lib/core/events.ts @@ -17,9 +17,10 @@ export type MutationEventMethod = MutationMethod | (string & {}) * Observability events emitted by a Figbird instance — the same signal a dev tool * panel or trace logger would want to subscribe to. * - * Events are intentionally lightweight. Mutation and action starts carry their - * original arguments so an attached devtool can inspect the write; no result or - * cache diffs are emitted, and emit() drops everything when nothing is listening. + * Events are intentionally bounded by consumers. Realtime events and mutation/action + * starts carry their original payloads so an attached devtool can inspect what + * happened; no result or cache diffs are emitted, and emit() drops everything when + * nothing is listening. */ export type FigbirdEvent = | { @@ -59,7 +60,30 @@ export type FigbirdEvent = serviceName: string type: EventType itemId: string | number | undefined + item?: unknown } + | { + kind: 'connection:connected' + transport?: string + connectionId?: string + } + | { + kind: 'connection:disconnected' + reason?: string + reconnecting: boolean + } + | { + kind: 'connection:reconnected' + attempt?: number + transport?: string + connectionId?: string + } + | { + kind: 'connection:error' + phase: 'connect' | 'reconnect' + error: Error + } + | { kind: 'connection:reconnect-failed'; error?: Error } | { kind: 'mutate:start' /** Correlates the start/end/error/rollback events of one mutation. */ diff --git a/lib/core/figbird.ts b/lib/core/figbird.ts index d595f7e2..047e9a97 100644 --- a/lib/core/figbird.ts +++ b/lib/core/figbird.ts @@ -287,10 +287,10 @@ export class Figbird< } /** - * Returns the entire internal state map keyed by service name — including the - * cached entities themselves, which `inspect()` deliberately omits. Debug-grade: - * internal shapes may change between versions; prefer `inspect()` for anything - * built to last. + * Returns the entire internal state map keyed by service name — including cached + * entities that are not part of a current query result. Debug-grade: internal + * shapes may change between versions; prefer `inspect()` for anything built to + * last. */ getState(): Map>> { return this.queryStore.getState() @@ -943,8 +943,10 @@ export class Figbird< } : {}), classification: query.classification, + skipped: query.config.skip === true, status: query.state.status, isFetching: query.state.isFetching, + data: query.state.data, itemCount: Array.isArray(query.state.data) ? query.state.data.length : query.state.data @@ -990,8 +992,12 @@ export interface InspectedQuery { /** Native adapter page details. Offset pages remain visible in `query` as `$skip`/`$limit`. */ page?: { request: PageRequest; info?: PageInfo } classification: QueryNodeClass | 'get' + /** True when this entry was materialized with `skip: true`. */ + skipped?: boolean status: 'loading' | 'success' | 'error' isFetching: boolean + /** Current result for this query. Debug-only and safe to inspect, not mutate. */ + data?: unknown itemCount: number fetchedAt: number | undefined subscriberCount: number diff --git a/lib/core/queryStore.ts b/lib/core/queryStore.ts index 724b7e9e..27fedb63 100644 --- a/lib/core/queryStore.ts +++ b/lib/core/queryStore.ts @@ -259,7 +259,50 @@ export class QueryStore< this.#reconnectJitter = this.#normalizeReconnectJitter(reconnectJitter) this.#visibility = visibility ?? documentVisibility() this.#visibility.onChange(() => this.#drainDeferredReconciles()) - this.#adapter.subscribeToReconnect?.(() => this.#scheduleReconnectSweep()) + if (this.#adapter.subscribeToConnectionEvents) { + this.#adapter.subscribeToConnectionEvents(event => { + switch (event.type) { + case 'connected': + this.#events.emit({ + kind: 'connection:connected', + ...(event.transport ? { transport: event.transport } : {}), + ...(event.connectionId ? { connectionId: event.connectionId } : {}), + }) + break + case 'disconnected': + this.#events.emit({ + kind: 'connection:disconnected', + ...(event.reason ? { reason: event.reason } : {}), + reconnecting: event.reconnecting, + }) + break + case 'reconnected': + this.#events.emit({ + kind: 'connection:reconnected', + ...(event.attempt === undefined ? {} : { attempt: event.attempt }), + ...(event.transport ? { transport: event.transport } : {}), + ...(event.connectionId ? { connectionId: event.connectionId } : {}), + }) + this.#scheduleReconnectSweep() + break + case 'error': + this.#events.emit({ + kind: 'connection:error', + phase: event.phase, + error: event.error, + }) + break + case 'reconnect-failed': + this.#events.emit({ + kind: 'connection:reconnect-failed', + ...(event.error ? { error: event.error } : {}), + }) + break + } + }) + } else { + this.#adapter.subscribeToReconnect?.(() => this.#scheduleReconnectSweep()) + } } // Public store API @@ -1581,6 +1624,7 @@ export class QueryStore< serviceName, type, itemId: this.#getIdWarn(serviceName, item), + item, }) } } diff --git a/lib/core/relationalQuery.ts b/lib/core/relationalQuery.ts index 89894ff6..5ce14d30 100644 --- a/lib/core/relationalQuery.ts +++ b/lib/core/relationalQuery.ts @@ -167,6 +167,8 @@ export interface InspectedRelationalQuery { service: string ast: QueryAST pagination?: InspectedPagination + /** Current assembled result when the relational query has settled successfully. */ + data?: unknown nodes: Array<{ path: string role?: 'junction' @@ -317,12 +319,14 @@ export class RelationalQueryRef< break } } + const snapshot = this.getSnapshot() return { key: this.#queryId, ...(this.#name ? { name: this.#name } : {}), service: this.#ast.service, ast: this.#ast, ...(this.#pagedRoot ? { pagination: this.#pagedRoot.inspectPagination() } : {}), + ...(snapshot.status === 'success' ? { data: snapshot.data } : {}), nodes, } } diff --git a/lib/devtools/Devtools.tsx b/lib/devtools/Devtools.tsx index 75527393..566e3c19 100644 --- a/lib/devtools/Devtools.tsx +++ b/lib/devtools/Devtools.tsx @@ -17,6 +17,7 @@ import { } from './ui.js' type Tab = 'queries' | 'timeline' | 'events' | 'writes' +export type QueryVisibility = 'active' | 'all' | 'skipped' export type DevtoolsInspectionSnapshot = | { kind: 'idle'; version: number } @@ -61,7 +62,7 @@ export function FigbirdDevtoolsPanel({ const themeValue = useMemo(() => ({ colors, styles }), [colors, styles]) const [tab, setTab] = useState('queries') const [queryFilter, setQueryFilter] = useState('') - const [queryActiveOnly, setQueryActiveOnly] = useState(true) + const [queryVisibility, setQueryVisibility] = useState('active') const [eventFilter, setEventFilter] = useState('') const [timelineFollow, setTimelineFollow] = useState(true) @@ -80,8 +81,10 @@ export function FigbirdDevtoolsPanel({ ) const model = useMemo(() => buildDevtoolsModel(snapshot), [snapshot]) + const skippedQueryCount = model.operations.filter(operation => operation.summary.skipped).length const timelineEmpty = snapshot.timeline.realtime.length === 0 && + snapshot.timeline.connection.length === 0 && snapshot.queries.every(query => query.spans.length === 0) const clearTimeline = useCallback(() => { collector.clearTimeline() @@ -133,22 +136,24 @@ export function FigbirdDevtoolsPanel({ onChange={event => setQueryFilter(event.currentTarget.value)} placeholder='Filter service or query' /> - + + + + {inspection ? ( + ) : null} + + + {message ? ( +
+ {message.text} + {undoValue !== null && editor ? ( + + ) : null} +
+ ) : null} + + {editing ? ( + +