Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
2eced48
Improve devtools inspection and connection diagnostics
KidkArolis Aug 15, 2026
5bbc744
Add causal tracing and cache inspection
KidkArolis Aug 15, 2026
d082a8b
Compact complete cache indicator
KidkArolis Aug 15, 2026
f994449
Group devtools activity and expose inactive queries
KidkArolis Aug 15, 2026
84da409
Add interactive JSON inspection
KidkArolis Aug 15, 2026
ccd514e
Tighten devtools causal architecture
KidkArolis Aug 15, 2026
3c8b629
Redesign devtools timeline as activity table
KidkArolis Aug 15, 2026
37082d4
Remove query row chevrons
KidkArolis Aug 15, 2026
7a8ff39
Keep query parameters expanded
KidkArolis Aug 15, 2026
0a445f4
Make devtools columns resizable across tabs
KidkArolis Aug 15, 2026
516a018
Compact devtools timeline rows
KidkArolis Aug 15, 2026
3eca895
Keep sticky table header divider visible
KidkArolis Aug 15, 2026
2d48e33
Let timeline waterfall fill available width
KidkArolis Aug 15, 2026
d570f97
Consolidate writes into timeline
KidkArolis Aug 15, 2026
aa7beab
Collapse wrapper actions in timeline
KidkArolis Aug 15, 2026
0dbe21a
Compact devtools status rows
KidkArolis Aug 16, 2026
3ae37ed
Hide missing legacy query shape
KidkArolis Aug 16, 2026
e971d16
Separate timeline operations from activity
KidkArolis Aug 16, 2026
09b052b
Show timeline activity payloads
KidkArolis Aug 16, 2026
9e1a360
Improve query and cache inspectors
KidkArolis Aug 16, 2026
a6b5aaf
Keep live timeline pinned to latest activity
KidkArolis Aug 16, 2026
94ef562
Let timeline overview use the full width
KidkArolis Aug 16, 2026
d38ad47
Simplify query table headers
KidkArolis Aug 16, 2026
e8976fd
Simplify devtools inspector layout
KidkArolis Aug 16, 2026
9b61c35
Use inset column resize grips
KidkArolis Aug 16, 2026
9a95b6a
Rebalance query performance details
KidkArolis Aug 16, 2026
7e6157c
Clarify query detail sections
KidkArolis Aug 16, 2026
d0be791
Simplify pagination query details
KidkArolis Aug 16, 2026
32d483a
Integrate query labels into JSON viewers
KidkArolis Aug 16, 2026
d9f80b3
Standardize JSON viewer titles
KidkArolis Aug 16, 2026
d613b9d
Align event detail attributes
KidkArolis Aug 16, 2026
e08797a
Link cache memberships to queries
KidkArolis Aug 16, 2026
4f54dcd
Normalize toolbar select controls
KidkArolis Aug 16, 2026
840557e
Standardize devtools column labels
KidkArolis Aug 16, 2026
25d2589
Use title case for table headers
KidkArolis Aug 16, 2026
2055f4b
Normalize query plan summaries
KidkArolis Aug 16, 2026
9581d20
Improve devtools traceability and coherence
KidkArolis Aug 16, 2026
e137500
Polish devtools table presentation
KidkArolis Aug 16, 2026
6af00c4
Improve devtools runtime performance
KidkArolis Aug 16, 2026
3fc882f
Polish and harden devtools observability
KidkArolis Aug 16, 2026
9f63336
Decouple query telemetry from cache semantics
KidkArolis Aug 16, 2026
4a7bccb
refactor devtools collection and retention
KidkArolis Aug 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 67 additions & 7 deletions DEVTOOLS_SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,21 +14,81 @@ connection:

- selects the newest live Figbird instance;
- subscribes to `figbird.events`;
- exposes current `inspect()`, `inspectRelational()`, and `mutating` snapshots;
- buffers at most 1,000 events between panel polls; and
- exposes current `inspect()`, `inspectCache()`, and `inspectRelational()` snapshots;
- buffers at most 5,000 events between panel polls; and
- expires after five seconds without a poll, removing every event subscription.

The bridge serializes values before they cross the browser DevTools evaluation boundary.
Errors retain their name and message, bigint values become strings, and circular values
are marked instead of breaking the panel.
are marked instead of breaking the panel. Event payloads and current query, cache, and
relational values are depth-, item-, string-, and node-bounded before serialization.

## 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.

## Causal traces

Runtime events carry stable, session-local identifiers where one operation causes
another. Realtime items and reconnections are trace roots. Cache updates retain the
root identifier, reconciliation decisions record whether work started, coalesced,
deferred while hidden, or became pending while inactive, and fetch attempts carry
their reason, retry attempt, and causes. Fetch end/error events share a fetch ID with
their start event.

The event details pane assembles those events into one causal chain. Timeline fetch,
realtime, and connection marks link into the same chain. This metadata is emitted only
while something subscribes to `figbird.events`, and the extension continues to bound
all retained history.

The Events tab defaults to an **Activity** projection: one summary row per causal trace,
mutation, or action. Supporting cache, reconciliation, retry, and sweep events remain in
the row's details. **All events** exposes the complete raw instrumentation stream when a
forensic view is needed.

The Queries tab separates **Inactive queries** from the live view. These are still
present in Figbird's cache but currently have no subscribers, so they remain inspectable
until normal garbage collection removes them. **All queries** also includes bounded
DevTools history for queries that are no longer present in Figbird.

Read-only JSON values use a shared syntax-highlighted browser. The root is expanded so
top-level properties are immediately visible, nested containers are individually
collapsible, and **Expand all** opens the complete value. **Raw** switches to highlighted
formatted JSON. The cache editor remains a JSON textarea because it accepts replacement
input rather than only inspecting a value.

## Cache inspection and editing

The page bridge exposes a serialized projection of each service's normalized entities,
their current query memberships, and complete-set materialization marker. The collector
adds session-local provenance from cache-update events (fetch, realtime, mutation,
optimistic projection, or devtools edit).

An attached extension session may replace one existing entity in memory. The edited
JSON must retain the same entity ID. Figbird reapplies locally decidable query results
and replaces the value in query results that already contain the entity; it never sends
a service mutation or server request. The panel labels this behavior explicitly and
offers a one-step undo. Later fetches or realtime events may overwrite the edit.

## Extension architecture

```
lib/core/devtoolsBridge.ts weak instance registry and inspected-page session
lib/devtools/collector.ts bounded query, event, timeline, and write history
lib/devtools/timelineTraceStore.ts Timeline-owned causal summaries independent of Events
lib/devtools/historicalValue.ts retained-or-evicted diagnostic value contract
lib/devtools/Devtools.tsx shared React panel used only by the extension
extensions/src/remote.ts polling transport exposed as a collector-compatible source
extensions/src/remote.ts polling transport that publishes decoded collector frames
extensions/src/protocol.ts versioned snapshot envelope and wire-to-panel decoding
extensions/src/inspection.ts extension-side picker lifecycle and state
extensions/src/inspectionPage.ts injected element picker and React query-area scanner
Expand Down Expand Up @@ -61,6 +121,6 @@ to expire.

## Scope

The panel is read-only apart from clearing its local history and starting or stopping the
element picker. It does not persist history across reloads, edit application state, or
provide time travel.
The panel is read-only apart from clearing local history, controlling the element picker,
and explicitly replacing an existing cached entity in memory. It does not persist history
across reloads, write cache edits to the server, or provide time travel.
22 changes: 22 additions & 0 deletions demo/src/components/DemoControls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,28 @@ export function DemoControls() {
}
}, [open])

useEffect(() => {
if (!demoState?.chaosArmed) return
let cancelled = false
let timer: ReturnType<typeof setTimeout> | null = null
const refresh = async () => {
try {
const state = await demoControl.getState()
if (cancelled) return
setDemoState(state)
if (!state.chaosArmed) return
} catch {
if (cancelled) return
}
timer = setTimeout(() => void refresh(), 500)
}
timer = setTimeout(() => void refresh(), 500)
return () => {
cancelled = true
if (timer) clearTimeout(timer)
}
}, [demoState?.chaosArmed])

const applyDemoPatch = async (patch: Partial<DemoState>) => {
if (!demoState) return
const previous = demoState
Expand Down
10 changes: 5 additions & 5 deletions demo/src/pages/IssueDetail/screen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -190,23 +190,23 @@ queries: ({ params }) => [
<TasksPanel key={issue.id} issueId={issue.id} users={users} />

<div className='action-toolbar'>
<button className='link' onClick={reassign.run} disabled={busy}>
<button className='link' onClick={() => void reassign.run()} disabled={busy}>
{reassign.pending ? 'Reassigning…' : 'Reassign'}
</button>
<Sep />
<button className='link' onClick={moveTeam.run} disabled={busy}>
<button className='link' onClick={() => void moveTeam.run()} disabled={busy}>
{moveTeam.pending ? 'Moving…' : 'Move team'}
</button>
<Sep />
<button className='link' onClick={boost.run} disabled={busy}>
<button className='link' onClick={() => void boost.run()} disabled={busy}>
{boost.pending ? 'Boosting…' : 'Boost'}
</button>
<Sep />
<button className='link' onClick={toggleStatus.run} disabled={busy}>
<button className='link' onClick={() => void toggleStatus.run()} disabled={busy}>
{toggleStatus.pending ? 'Updating…' : issue.status === 'open' ? 'Close' : 'Reopen'}
</button>
<Sep />
<button className='link' onClick={addLabel.run} disabled={busy}>
<button className='link' onClick={() => void addLabel.run()} disabled={busy}>
{addLabel.pending ? 'Adding…' : 'Add label'}
</button>
<Sep />
Expand Down
7 changes: 6 additions & 1 deletion extensions/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,12 @@ The shared browser extension version lives in `extensions/version.json`. See
## QA checklist

- The Figbird panel reports **Connected** after the page creates a Figbird instance.
- Queries, relational details, events, the fetch timeline, and writes update live.
- Queries, relational details, causal events, the fetch timeline, normalized cache, and writes update live.
- The Queries visibility menu separates live, inactive cached, skipped, and historical queries.
- Events opens in grouped Activity mode; All events exposes the bounded raw stream.
- JSON details open as a collapsible highlighted tree, with Expand all and Raw views.
- Selecting a linked timeline or cache-provenance mark opens its causal event chain.
- Cache JSON edits rerender affected subscribers without issuing a server request and can be undone.
- **Inspect** highlights the page and filters to queries owned by the selected area.
- Reloading or navigating the inspected tab reconnects the panel.
- Closing the panel for five seconds ends the page-side debug session.
Expand Down
56 changes: 47 additions & 9 deletions extensions/src/panel.tsx
Original file line number Diff line number Diff line change
@@ -1,34 +1,72 @@
import { useEffect, useMemo, useSyncExternalStore } from 'react'
import { createRoot } from 'react-dom/client'
import { FigbirdDevtoolsPanel } from '../../lib/devtools/Devtools.js'
import { createCollector } from '../../lib/devtools/collector.js'
import { createRemoteCollector } from '../../lib/devtools/collector.js'
import { PANEL_VISIBILITY_CALLBACK, type DevtoolsPanelWindow } from './panelVisibility.js'
import { ExtensionSession } from './remote.js'

interface DevtoolsNavigationEvent {
addListener(listener: (url: string) => void): void
removeListener(listener: (url: string) => void): void
}

declare const chrome: { devtools: { network: { onNavigated: DevtoolsNavigationEvent } } }

function Panel() {
const session = useMemo(() => new ExtensionSession(), [])
const collector = useMemo(() => createCollector(session.figbird, { heartbeatMs: 0 }), [session])
const collector = useMemo(() => createRemoteCollector(), [])
const cacheEditor = useMemo(() => ({ update: session.editCacheEntity }), [session])
const status = useSyncExternalStore(session.subscribeStatus, session.getStatus, session.getStatus)

useEffect(() => {
const panelWindow = window as DevtoolsPanelWindow
const setVisible = (visible: boolean) => {
if (visible) session.start()
else session.stop()
let documentVisible = document.visibilityState !== 'hidden'
let hostVisible = true
let running = false
const applyVisibility = () => {
const visible = documentVisible && hostVisible
if (visible === running) return
running = visible
if (visible) {
session.start()
} else {
session.stop()
}
}
const updateFromDocument = () => {
documentVisible = document.visibilityState !== 'hidden'
applyVisibility()
}
const updateFromDocument = () => setVisible(document.visibilityState !== 'hidden')
panelWindow[PANEL_VISIBILITY_CALLBACK] = setVisible
updateFromDocument()
panelWindow[PANEL_VISIBILITY_CALLBACK] = visible => {
hostVisible = visible
applyVisibility()
}
applyVisibility()
document.addEventListener('visibilitychange', updateFromDocument)
return () => {
document.removeEventListener('visibilitychange', updateFromDocument)
delete panelWindow[PANEL_VISIBILITY_CALLBACK]
session.stop()
collector.reset()
}
}, [collector, session])

useEffect(() => {
const resetForNavigation = () => session.resetForNavigation()
chrome.devtools.network.onNavigated.addListener(resetForNavigation)
return () => chrome.devtools.network.onNavigated.removeListener(resetForNavigation)
}, [session])

useEffect(() => session.subscribeReset(() => collector.reset()), [collector, session])
useEffect(() => session.subscribeRead(frame => collector.ingest(frame)), [collector, session])

return (
<FigbirdDevtoolsPanel collector={collector} inspection={session.inspection} status={status} />
<FigbirdDevtoolsPanel
collector={collector}
inspection={session.inspection}
cacheEditor={cacheEditor}
status={status}
/>
)
}

Expand Down
42 changes: 28 additions & 14 deletions extensions/src/protocol.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,20 @@
import type { FigbirdEvent } from '../../lib/core/events.js'
import { errorFromDetails } from '../../lib/core/errors.js'
import type {
DevtoolsBridgeConnection,
DevtoolsWireEvent,
DevtoolsWireRead,
} from '../../lib/core/devtoolsBridge.js'

interface WireEnvelopeShape {
protocol: 2
protocol: 2 | 3
version: number
read: {
cache?: unknown[]
events: unknown[]
inFlightMutations: unknown[]
queries: unknown[]
relational: unknown[]
inFlightMutations?: unknown[]
queries?: unknown[]
relational?: unknown[]
} | null
}

Expand All @@ -25,7 +27,7 @@ export function parseConnection(value: unknown): DevtoolsBridgeConnection | null
if (value === null || value === undefined) return null
if (
!isRecord(value) ||
value.protocol !== 2 ||
(value.protocol !== 2 && value.protocol !== 3) ||
typeof value.instanceCount !== 'number' ||
typeof value.instanceId !== 'number' ||
typeof value.sessionId !== 'string'
Expand All @@ -47,10 +49,10 @@ export function parseWireRead(value: unknown): ParsedWireRead | null {
const envelope: unknown = JSON.parse(value)
if (!isWireEnvelope(envelope)) throw new Error('Figbird returned an invalid devtools snapshot')

// Protocol 2 defines the collection item shapes. The envelope check guards the
// The bridge protocol defines the collection item shapes. The envelope check guards the
// transport boundary without duplicating every domain type in the extension.
return {
read: envelope.read as unknown as DevtoolsWireRead | null,
read: envelope.read ? (envelope.read as unknown as DevtoolsWireRead) : null,
version: envelope.version,
}
}
Expand All @@ -59,9 +61,14 @@ export function decodeEvent(event: DevtoolsWireEvent): FigbirdEvent {
switch (event.kind) {
case 'fetch:error':
case 'mutate:error':
case 'action:error': {
const error = new Error(event.error.message)
error.name = event.error.name
case 'action:error':
case 'connection:error': {
const error = errorFromDetails(event.error.details, event.error)
return { ...event, error }
}
case 'connection:reconnect-failed': {
if (!event.error) return event
const error = errorFromDetails(event.error.details, event.error)
return { ...event, error }
}
default:
Expand All @@ -70,15 +77,22 @@ export function decodeEvent(event: DevtoolsWireEvent): FigbirdEvent {
}

function isWireEnvelope(value: unknown): value is WireEnvelopeShape {
if (!isRecord(value) || value.protocol !== 2 || typeof value.version !== 'number') return false
if (
!isRecord(value) ||
(value.protocol !== 2 && value.protocol !== 3) ||
typeof value.version !== 'number'
) {
return false
}
if (value.read === null) return true
if (!isRecord(value.read)) return false
const read = value.read
return (
Array.isArray(read.events) &&
Array.isArray(read.inFlightMutations) &&
Array.isArray(read.queries) &&
Array.isArray(read.relational)
(read.cache === undefined || Array.isArray(read.cache)) &&
(read.inFlightMutations === undefined || Array.isArray(read.inFlightMutations)) &&
(read.queries === undefined || Array.isArray(read.queries)) &&
(read.relational === undefined || Array.isArray(read.relational))
)
}

Expand Down
Loading