State Management¶
The Vayu app uses a dual-state management approach: Zustand for UI state and TanStack Query for server state. Cross-cutting stores (tabs, layout, session, engine, save, response, dashboard, client settings, toasts, import modal) live in app/src/stores/ and are exported via the barrel app/src/stores/index.ts. Module-local UI stores co-locate in app/src/modules/<feature>/<feature>-store.ts (collections, history, variables, settings) to keep feature-specific UI state decoupled from global app state.
Architecture Overview¶
┌─────────────────────────────────────────────────┐
│ State Management Layers │
├─────────────────────────────────────────────────┤
│ UI State (Zustand) │
│ - Cross-cutting: tabs, layout, session │
│ - Domain: engine, save, response, dashboard │
│ - Module-local: collections, history, vars │
└─────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────┐
│ Server State (TanStack Query) │
│ - Collections, Requests, Environments │
│ - Runs, Metrics, Global Variables │
│ - Automatic caching & synchronization │
└─────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────┐
│ Services Layer │
│ - HTTP Client (api.ts) │
│ - SSE Client (sse-client.ts) │
└─────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────┐
│ Vayu Engine (C++ Daemon) │
│ - SQLite Database │
│ - HTTP API (localhost:9876) │
└─────────────────────────────────────────────────┘
Zustand Stores¶
Cross-Cutting Stores (app/src/stores/)¶
tabs-store.ts - Open Tabs & Navigation¶
Manages all open tabs (welcome, request, collection, dashboard, run, variables, settings) and active tab focus. Enforces a maximum of 12 open tabs with LRU eviction for non-exempt types.
State:
{
openTabs: Tab[] // Each tab has unique id, type, and optional entityId
activeTabId: string | null
tabFocusedAt: Record<string, number> // Tab id -> when it was last focused (epoch ms)
specTabTarget: string | null // Collection whose Spec tab something pointed at
dataRowTarget: { requestId, rowIndex } | null // Request a repro pointed at, and the row
navHistory: TabLocation[] // Visited locations this session, oldest first
navIndex: number // Cursor into navHistory; -1 before anything is visited
}
Key Features:
- Deduplication: Singleton types (welcome, variables, settings, inbox) only allow one tab at a
time. Opening one that is already open with a different entityId retargets it - same tab
id, new address - rather than focusing whatever it was showing. The inbox tab is the case that
needs it: every Services drawer row opens that one tab pointed at its own inbox (issue #554).
The other three are always opened with a null entityId, for which this is a no-op
- LRU eviction: Oldest non-active, non-exempt, clean tabs are closed when over limit
- Integration with save-store: dirty tabs are never selected for eviction. The
guard resolves each tab's save-context key by tab type (isTabDirty),
because the registry is keyed by editor and the two do not line up: settings
and variables are singletons with no entityId, so a request-${entityId}
lookup read them as clean and the 13th tab could close a dirty Settings tab.
A variables tab counts any dirty variable-editor context as its own, since
a Tab does not record which editor the sidebar has selected - over-matching
keeps a tab that could have closed, under-matching loses work. Nothing is
flushed during eviction; the predicate already refused the tab
- Bulk close (issue #1360): closeOtherTabs, closeTabsToRight and
closeSavedTabs - the tab strip's right-click menu - go through the same
closeTabs core as closeTab and closeTabsForEntities, so "which tab
takes the place of the one that was showing" stays one rule. A bulk close is
one set and at most one recorded navigation visit, never a loop of single
closes: closing tabs one at a time would focus and record a visit for every
intermediate tab on the way to the last, leaving Back to walk the user
through places they never went.
- Response eviction: closeTabsForEntities clears each id's entry in
response-store. Both callers reach it after a delete - the map's own LRU
bound would get there eventually, but a response nothing can reach again
should go at the delete, not twenty-four sends later. MAX_OPEN_TABS is
exported for that bound: the cache retains twice it
- Focus recency: tabFocusedAt stamps openTab and focusTab, and is read by
the command palette, which lists open tabs most-recently-used first. It cannot
come from openTabs, which is insertion order - the tab you were just in sits
wherever it was opened. Session-scoped on purpose (absent from partialize):
tabs are restored across launches, so a persisted copy would rank a restored
list by yesterday's attention, and an empty map falls back to strip order.
Entries for closed tabs are dropped on the next focus rather than in every
close path - nothing reads a stamp for a tab that is not open
- Pointing into a collection: openTab can only name a collection, and a
collection tab picks its own sub-tab, so specTabTarget is where the one
navigation that means a section says which. The import dialog sets it when a
re-import is answered with Sync (issue #680); CollectionDetail opens on Spec
when the named collection is on screen and clears it, so it survives the tab
opening for the first time and never fires twice. Session-scoped like
tabFocusedAt - a persisted copy would jump the user to Spec on the next
launch for a choice they made yesterday
- Pointing into a request the same way: dataRowTarget carries the data row a
navigation meant, because openTab can only name a request and which row
Send-with-row binds is state the UrlBar holds. A failed step of a collection
run sets it through openRequestWithDataRow (issue #730) - "reproduce this
step" means its request and the row that iteration bound - and the UrlBar
selects the row, opens the picker on it and clears the target, whether or not
the request can bind rows at all: a target left standing would fire on the
next request that can. Session-scoped like specTabTarget, and doubly so -
it points into a data file whose rows are deliberately never persisted, so a
remembered index would name a different row in a file that has since changed.
A non-integer or negative index throws rather than being stored
- Navigation history (issue #1245): navHistory is a TabLocation per visit
(a Tab without its instance id, so a closed tab's place survives it), with
navIndex as the cursor - -1 before anything is visited. openTab and
focusTab record each visit themselves, coalescing a repeat of the current
location and truncating the forward half on a new one; capped at 50 entries
(MAX_NAV_HISTORY), oldest dropped first. goBack() / goForward() move the
cursor and show what it lands on - focusing the open tab if there is one,
reopening it through openTab otherwise - without recording a step of their
own. Closing the active tab now activates the most recent history entry
whose tab is still open, falling back to the old left-neighbour rule, and
leaves the closed location ahead of the cursor so Forward can reopen it;
closeTabsForEntities prunes history entries for deleted entities, so Back
never lands on one. Session-scoped like tabFocusedAt and specTabTarget
(absent from partialize), and NOT persisted: tabs are restored across
launches, and a persisted history would offer a Back that walks yesterday's
route through today's window
- Persistence: vayu.tabs (v1), with a pass-through migrate. zustand discards
a payload whose stamped version differs from the store's when no migrate
is supplied, so the stub is where the next bump goes; it also refuses a
payload of the wrong shape rather than handing a non-array to every reader
Key Methods:
const { openTab, closeTab, focusTab, closeTabsForEntities } = useTabsStore();
openTab({ type: "request", entityId: "req-123" });
closeTabsForEntities(["req-123"]); // after a delete: closes tabs, drops responses
const { closeOtherTabs, closeTabsToRight, closeSavedTabs } = useTabsStore();
closeOtherTabs("tab-1"); // keeps tab-1, one set-and-visit for the rest
closeTabsToRight("tab-1");
closeSavedTabs(); // keeps every dirty tab
openCollectionSpecTab("col-123"); // opens the collection, on its Spec tab
openRequestWithDataRow("req-123", 500); // opens the request, on row 501 of its data file
const { goBack, goForward } = useTabsStore();
goBack(); // step back through navHistory; reopens the tab if it was closed
goForward(); // never records a visit - only openTab/focusTab do that
// canGoBack / canGoForward are exported selectors, not store methods - read
// by the title-bar buttons and the palette's go-back / go-forward commands
canGoBack(useTabsStore.getState());
canGoForward(useTabsStore.getState());
layout-store.ts - Drawer, Context Bar, & Response Position¶
Manages the left drawer (collections/history/variables/settings), the right context bar, and where the response pane sits - beside the request or below it - with one split ratio per arrangement.
State:
{
drawerOpen: boolean // Is the left drawer visible?
drawerView: DrawerView // "collections" | "history" | "variables" | "services" | "settings"
drawerWidth: number // One width for every view
contextBarOpen: boolean // Is the right context bar visible?
contextBarWidth: number
contextBarCollapsedSections: string[] // Section ids the user collapsed (`code` by default)
responsePosition: ResponsePosition // "beside" | "below" | "auto" (default beside, #1711)
autoResponseArrangement: "beside" | "below" // What auto resolves to right now - written by the builder's measurement, not persisted
requestSplitRatioBeside: number // 0-1; the request pane's share while the response is beside it
requestSplitRatioBelow: number // 0-1; the request pane's share while the response is below it
scriptSnippetsCollapsed: boolean // Default a fresh script row's snippets list starts from (collapsed); each row keeps its own state after that
scriptEditorHeights: Record<string, number> // Height (px) of a script.pre/script.post element's editor box, keyed by the element's own id - capped at 200 entries, oldest set evicted first
scriptEditorHeightDefault: number // Height a script row with no entry above starts at - the last height set on any row
recentElementKinds: string[] // Last 5 kinds added through the Add-element picker, most recent first
paletteOpen: boolean // Is the ⌘K command palette showing?
}
Key Methods:
const {
drawerOpen, setDrawerOpen, toggleDrawer,
drawerView, setDrawerView, activateDrawerView,
drawerWidth, setDrawerWidth,
contextBarOpen, setContextBarOpen, toggleContextBar,
contextBarWidth, setContextBarWidth,
contextBarCollapsedSections, toggleContextBarSection,
responsePosition, setResponsePosition, toggleResponsePosition,
setRequestSplitRatio
} = useLayoutStore();
activateDrawerView("variables"); // Open drawer to variables, or toggle closed if already there
setDrawerWidth(300); // Clamped to [PANEL_MIN_WIDTH, PANEL_MAX_WIDTH] (constants/layout.ts)
toggleResponsePosition(); // beside <-> below; from auto, the opposite of what auto currently shows
setRequestSplitRatio("below", 0.6); // Into the ratio for the arrangement that was dragged, clamped 0.2..0.8
resolveResponseArrangement(useLayoutStore.getState()); // The arrangement on screen: the setting, or auto's pick
One drawer width, not one per view. v2 stored a width per view, so switching
from Collections to History resized the main content under the user; the v3
migration collapses them onto a single drawerWidth, keeping whatever
Collections (the default view) had. Re-introducing a per-view width re-introduces
that bug. setRequestSplitRatio clamps to [0.2, 0.8]; both panel widths clamp to
PANEL_MIN_WIDTH / PANEL_MAX_WIDTH.
setDrawerWidth / setContextBarWidth are not called per pointermove.
PanelResizeHandle (app/src/components/layout/PanelResizeHandle.tsx) holds a
drag's live width outside the store, painting it onto the panel's own inline
style.width once per animation frame, and calls the setter - the one write
partialize persists - exactly once, on pointerup. A store write on every
pointermove was a synchronous JSON.stringify + localStorage.setItem of the
whole persisted slice 120-240 times a second (#1715).
paletteOpen is here, and is not persisted. The palette lives in Shell
while the things that open it - the welcome Launcher's Search tile, the title
bar's search bar - are in other subtrees, so the flag has to be shared state
rather than the dialog's own. It is deliberately absent from partialize: a
dialog that reopened itself on every launch is not a layout preference.
Context-bar sections are collapsed by exception. The store holds the ids the
user closed (contextBarCollapsedSections); anything not listed is expanded. Two
consequences worth keeping: a section added in a later release ships expanded
for existing users, because a blob written before it existed cannot name it; and
it is an array rather than a Set because persist serializes with JSON, which
writes a Set as {} - a collapse would survive exactly until the next launch.
code is the one section allowed to break the ships-expanded rule
(CONTEXT_BAR_DEFAULT_COLLAPSED, constants/layout.ts, currently ["code"]):
an expanded Code section issues a POST /compose on mount, so with the bar open
the app composed a snippet on every request tab opened, whether or not anyone
looked at it. That default needs both the initial state and the v3 -> v4
migration to actually reach an existing user, and neither alone is enough:
persist merges a missing key onto the initial state, never a missing
element into an array that is already there, so seeding
contextBarCollapsedSections: ["code"] in the initial state only reaches a
fresh install - a user who has ever collapsed anything already has the key, with
an array persist treats as complete. The migration writes code into that
array directly, and at the same time prunes any id in
RETIRED_CONTEXT_BAR_SECTIONS (environment, retired the same release), so a
persisted collapse list never keeps naming a section that no longer exists. It
is a default, not a policy: once migrated, a user's own toggle on code
overrides it exactly like any other section.
Persistence: vayu.layout (v6, with a real migration for every bump -
the one store in the app doing persistence versioning end to end; v6 carries
the old single requestSplitRatio forward as the Beside ratio, seeds Below
even and keeps the position at Beside, so an upgrade re-arranges nothing)
session-store.ts - Active Environment¶
Tracks the active environment (for variable resolution) and the collection the user last worked in (a new-request target only), persisted across sessions.
State:
Key Methods:
const { activeEnvironmentId, setActiveEnvironmentId } = useSessionStore();
setLastCollectionId(collectionId);
Persistence: vayu.session (v2)
A persisted id must not outlive what it names. activeEnvironmentId rides
on every composed payload, so a dangling one is not cosmetic - the switcher
renders "No Environment" through a defensive find() while the wire still
carries a deleted id. It is cleared at both ends:
useDeleteEnvironmentMutation clears it when the active environment is the one
deleted (in the mutation, so both delete flows are covered), and
useActiveEnvironmentGuard() - mounted once in App.tsx - clears an id the
engine's environment list does not contain. That guard keys on the query's
isSuccess, never on the list being empty: an unreachable engine produces an
empty list too, and clearing on that would discard a good selection whenever
the app started before the engine did.
activeCollectionId was removed in v2. It had a reader (the resolver's
fallback scope) and no writer, so it was permanently null on a fresh install
and, on an older one, rehydrated a collection the user had long left and
silently scoped {{var}} previews to it. The persist migrate drops the
stored key. lastCollectionId is the field that looks similar and is not: it
has a real writer and feeds only the welcome screen's new-request target - it
must never feed the resolver.
The migration rebuilds the payload from the fields v2 knows rather than deleting
that one key, which is also the shape check: a hand-edited or half-written entry
degrades to defaults instead of handing a non-string id to the switcher. That
whitelist is where the next bump goes - zustand discards a payload whose
stamped version does not match when no migrate is supplied, which is the
same reason tabs-store carries one.
activeEnvironmentId is a cache of engine state, not the source of truth.
The engine owns which environment is active (environments.is_active, at most
one row, enforced in its DB layer). The store holds the same id so the
switcher, the resolver and every composed payload can read it synchronously,
and two pieces of wiring keep the two in step:
useSetActiveEnvironmentMutation(queries/environments.ts) is the only writer. It PUTsisActiveand updates the store optimistically, rolling the store back if the engine refuses - a selection the engine did not accept must not survive in the UI, or the next launch silently disagrees with this one. It invalidates the environments list rather than patching the response into it, because the deactivated row changed server-side without appearing in any response body.useActiveEnvironmentRestore(hooks/, mounted inApp.tsx) reconciles on launch: adopt the engine's active environment if it has one, otherwise push a persisted id that names an environment that still exists - once per session, so a write the engine keeps rejecting is a failed write and not a request loop. Both directions wait onisSuccess; an unreachable engine returns an empty list that is indistinguishable from "no environments exist", and adopting from that would clear a good selection on every cold start. A third direction was added with the MCP state tools (#758): once this session has seen the engine hold a selection, an engine that reports none is adopted as a clear rather than pushed back at. Otherwise anactivate_environmentwith"none"- or any other client's deactivate - would be undone on the very next refetch by this window's memory of the id it used to hold. The upgrade push stays for the case it was written for: an engine that has never held a selection at all.
It composes with useActiveEnvironmentGuard above rather than replacing it, and
is mounted after it: the guard answers "does this id still exist", this hook
answers "which id does the engine hold". Order matters only in the one case
where both would act - a dangling id should be dropped, not pushed back at the
engine as a selection.
Editing an environment's variables deliberately sends no isActive
(absent means "keep" on a PUT): echoing a cached value back would let a
variable edit re-activate an environment from a stale read, deactivating
whichever one the engine actually holds.
data-file-store.ts - Where Each Collection's Data File Lives¶
Remembers where a collection's data file is on this machine, so the Run dialog can pre-fill it instead of asking for it every run (issue #599).
State:
Key Methods:
const { setDataFile, clearDataFile } = useDataFileStore();
setDataFile(collectionId, { path, fileName });
Persistence: vayu.data-files (v1)
Two things this store deliberately is not. It is not the contract - the
declared columns are the same on every machine, so they live on the engine's
collection row as dataSchema and travel through import; a path is true of one
filesystem only and stays here, never reaching the engine, an export or MCP. And
it never holds rows: a data file's contents are user data of unknown
sensitivity and are persisted nowhere in Vayu, which data-file-store.test.ts
asserts against the persisted payload rather than against the store's surface.
The path is written when the user declares a contract in the Data tab
(CollectionDetail/DataTab.tsx), obtained through the preload's existing
getFilePath bridge, and dropped when the collection is deleted or its contract
cleared. Reading it back needs the gated dataFile:read IPC
(electron/data-file.ts), because the renderer otherwise cannot name a path.
The persisted payload is normalized through both migrate and merge:
migrate runs only on a version mismatch, and the common case is a
same-version payload, so an entry that is not a {path, fileName} pair of
strings would otherwise reach the read IPC as a path. A bad entry is dropped
rather than repaired - there is nothing to repair a path to, and the picker is
one click away.
spec-file-store.ts - Where Each Collection's Spec File Lives¶
Remembers where a collection's bound OpenAPI document is on this machine, for the specs that were picked as files rather than fetched from a URL (issue #638).
State:
Key Methods:
const { setSpecFile, clearSpecFile } = useSpecFileStore();
setSpecFile(collectionId, { path, fileName });
Persistence: vayu.spec-files (v1)
The same two-halves law as data-file-store above, one contract over: the
document is engine state (spec_documents.content, hashed there, bound by
collection.openapi.specId) because it is the same on every machine and travels
through import; a path is true of one filesystem only and stays here. A
URL-sourced spec has no entry at all - its origin is spec_documents.source_url,
which is portable and is what a re-fetch will use. And it never holds spec
content: a second copy in localStorage could not be hashed and could not be
told apart from the bound one, which spec-file-store.test.ts asserts against
the persisted payload rather than against the store's surface.
The path is written when an OpenAPI file is imported (queries/import.ts, after
the apply, because the collection has no id until then) or when the Spec tab
binds a picked file, obtained through the preload's existing getFilePath
bridge, and dropped on unbind. Reading the files a picked document references
needs the gated specFile:read IPC (electron/spec-file.ts), which takes the
document's path plus the reference and resolves one against the other in the
main process - the renderer names no directory of its own, the same posture
dataFile:read holds. It is normalized through both migrate and
merge, for the reason spelled out for data-file-store above.
bound-row-store.ts - The Row The Open Builder Is Bound To¶
One slot: the data row a Send-with-row has picked, and the id of the request it was picked for (issue #1074).
State:
Key Methods:
const setBoundRow = useBoundRowStore((s) => s.setBoundRow);
setBoundRow({ requestId, row }); // or null for "bound to none"
Persistence: none, deliberately - see below.
Why a store at all. RequestBuilderProvider already gives the picked row to
its own resolver, which covers every preview inside the builder (issue #1062).
The tab strip is the one that is not below it: useTabDescriptors labels every
open tab from a single list-wide resolver, so it cannot take the row off the
builder's context and, left alone, labelled a tab from the environment while the
bar one row beneath it showed the file's value.
One slot, not a map, and it names its request. The builder binds a row for
the request it is showing, so that is the only request an on-screen preview can
be bound for; a row per remembered index would be a row out of a file that is no
longer the one loaded. Carrying the request id is what lets a reader check
rather than assume, so a slot left standing cannot relabel the next tab -
boundRowFor(bound, requestId) is that check, named once rather than repeated
at each call site.
Never persisted, and cleared with the builder. Rows are user data of unknown
sensitivity and are persisted nowhere in Vayu, the same law data-file-store
states above; the builder also clears the slot when it unmounts, so a row cannot
outlive the send that justified it.
recovery-notice-store.ts - Which Data-Loss Notice Was Already Seen¶
One timestamp: the at of the startup-recovery record the user has already been
told about (issue #922).
State:
Key Methods:
Persistence: vayu.recovery-notice (v1)
The record is the engine's - a marker file beside the database, reported on
GET /health for as long as it stands, because a record that cleared itself as
soon as something read it would be lost whenever the engine restarted before the
app polled. Showing a notice about it exactly once is this side's job, and one
timestamp is the whole of it: a later recovery carries a later at and is a
different event, so it surfaces again with no per-event bookkeeping. Persisted
rather than session state because "does not repeat on the next launch" is the
point - a window reopened against an already-running engine would otherwise
re-announce a wipe the user had dismissed. A stored value that is not a finite
number reads as "nothing acknowledged", the safe direction: a notice shown twice
is an annoyance, one suppressed by a garbage value is silent data loss again. It
is normalized through both migrate and merge, for the reason spelled out
for data-file-store above.
engine-store.ts - Engine Connection & Restart State¶
Merged store managing engine connection status and restart-required notifications (for config changes that need an engine restart).
State:
{
engineStatus: EngineStatus // "starting" | "connected" | "unreachable"
engineError: string | null
recovery: EngineRecovery | null // What the engine's startup did to the database
workers: number | null // The `workers` setting's effective value, or null before the first poll
engineStartWindow: number | null // When the engine now coming up began, or null
pendingRestart: boolean
restartRequiredKeys: string[] // Config keys requiring restart
}
Key Methods:
const {
engineStatus, setEngineStatus,
engineError, setEngineError,
recovery, setEngineRecovery,
workers, setWorkers,
engineStartWindow, openEngineStartWindow, closeEngineStartWindow,
pendingRestart, addRestartRequiredKey, clearRestartRequired,
} = useEngineStore();
recovery is the optional recovery node from GET /health (issue #922),
written by the same poll that writes engineError and read by RecoveryBanner.
null is a clean start; a value means the engine restored the database from its
backup or deleted it as unrecoverable.
workers is the workers field from the same poll (issue #1508) - the
workers setting's effective value, not the machine's raw core count - read by
the Dock's version-string tooltip. null before the first poll answers; it is
not cleared on a later disconnect, so a hover during a brief drop still shows
the last known count.
engineStatus is starting while no poll has succeeded since the engine now
coming up began coming up and it is still inside its grace window, connected
once a poll has answered ok, and unreachable once a poll fails past that
window or an engine that had answered stops answering with nothing starting.
engineError is what the failed health poll recorded (queries/health.ts), and
it is null in every state but unreachable: an engine still inside its grace
window has no failure to report. The Dock's connection indicator renders it in a
tooltip, for unreachable alone - "Disconnected" on its own read the same for a
refused connection, a timeout and a TLS failure.
engineStartWindow is the evidence that decision is made against: the moment
the engine now coming up began coming up, or null when none is. It is a
window rather than a "has ever connected" flag because a cold start is not only
the app's first (#1227) - useEngineRestart kills the running engine and spawns
a fresh one that repeats the whole startup housekeeping, with the port down for
all of it, and a flag called that silence a failure on the evidence of an engine
that no longer existed. Two paths open it - useHealthQuery on mount and the
restart path before it invokes the IPC - and it is deliberately evidence rather
than a status, so useHealthQuery stays the only writer of engineStatus. It
is closed by the poll an engine answers, and by a restart the main process
reports as failed, after which the next failed poll owes the user its reason
again. A window nobody closes is spent rather than cleared - an engine that
never arrives leaves its opening time in place, expired - so this is not an "is
something starting" flag and must not be read as one: only
engineStatusAfterFailedPoll interprets it, and to that an expired timestamp
and a null mean the same thing.
Non-persisted (cleared on app restart).
save-store.ts - Centralized Auto-Save¶
Orchestrates auto-save across the app with a registry of saveable contexts (e.g., request tabs, environment editors). Provides Ctrl/Cmd+S integration and unified save status.
State:
{
status: "idle" | "pending" | "saving" | "saved" | "error"
lastErrorMessage: string | null
activeContextId: string | null
contexts: Map<string, SaveContext> // Saveable entities
}
failSave is the app's single failure seam. It sets status: "error" and
raises an error toast carrying the reason. Its call sites are the collection
tree's create / delete / duplicate / rename, useSaveManager, SettingsMain,
VariableTableEditor, useDraftSaveContext, the context bar's
VariablesSection and the inbox's CannedResponseControls - and doing the
reporting here rather than at each of them means a new caller cannot forget to
report. The last two were added because both could fail in complete silence:
the variables section fired three mutations with no onError at all, and the
manual-draft editors rendered an inline callout that a quit flush has no screen
to show. CannedResponseControls (#1450) is a direct writer like
SettingsMain: pressing Apply calls startSaving/completeSaveThenIdle/
failSave itself around the PUT, rather than only through the
useDraftSaveContext-registered save those two functions also serve to
Ctrl/Cmd+S and the quit flush - the registration alone would report nothing
for a direct click, since only triggerSave/flushAll route a registered
context's save through the store's own status wrapper.
VariablesSection also registers a context (context-bar-variables),
because failSave alone only covers the failure. A variable commit is a plain
mutation rather than a draft, so it registers one entry for the whole section
whose
hasPendingChanges tracks whether any commit is outstanding and whose save()
resolves when the last one settles - without it flushAll had nothing to wait
for, and the renderer could be torn down mid-PUT with the input already showing
the value as committed.
Only the context that set a status clears it. SettingsMain used to run an
unconditional setStatus("idle") whenever it had nothing pending - which
included mount - so merely opening Settings wiped an error another context had
just published to the Dock. It now tracks whether the pending on screen is its
own and leaves anything else alone.
The same rule is why completeSaveThenIdle is the only way to report a
success: it sets saved and arms the return to idle behind two checks - that
the status is still the saved it set, and that no later save has re-armed the
reset since. A bare completeSave() followed by
setTimeout(() => setStatus("idle"), …) fires regardless of what happened in the
meantime, so a rename that succeeded two seconds ago cleared the failure a delete
had just published; and two saves a second apart had the first one's timer end
the second one's indicator early. triggerSave has always guarded the first way,
useSaveManager hand-rolled the second as a clearTimeout of its own timer.
Both live in the store now, and the six call sites (useSaveManager,
SettingsMain, VariableTableEditor, VariablesSection, both collection-tree
renames) share that one implementation rather than five copies of the timer.
completeSave is gone with them. It set saved and armed nothing, so every
caller either paired it with a hand-rolled timer or - VariablesSection, the one
non-draft commit path - left "Saved" in the Dock until something else happened to
change it. The indicator's lifetime is TIMING.SAVED_STATUS_DURATION_MS, in one
place, for every surface that saves.
lastErrorMessage is the reason behind an "error" status, for the Dock's
persistent line - see below. It went through the same fate as errorMessage
once: the field existed for the Dock's error line, that line was removed in
favour of the toast, and the field went with it as one nothing read any more.
It is back because the toast turned out not to be the whole answer (next
paragraph), and this time it has a second reader.
There is no lastSavedAt or pendingSaveId, for the same reason that field
almost went a second time - every match on either name was a write inside
save-store.ts. status is still the store's main public surface, and all
five of its values have a reader: the Dock renders pending as "Unsaved
changes", which is the only place in the app that says so. That matters
because auto-save is a setting the user can turn off, and with it off nothing
was written back and nothing said as much (the tab strip carries no
unsaved-dot on purpose).
"error" has two readers now, not one. The toast failSave raises expires
after TIMING.TOAST_DURATION_MS.error (10s), and a failed save can leave a
draft unsaved for as long as the engine stays down - far longer than that. The
Dock's ambient status line (Dock.tsx) renders an "error" status as "Not
saved", coloured with --destructive-text, with lastErrorMessage in a
tooltip on hover - the same shape EngineStatus uses for its own error, rather
than a second way of saying "hover for the reason." It stays until the next
save transition, so it outlives the toast for exactly as long as the draft
stays unsaved.
A failed auto-save now retries itself. useSaveManager's debounced timer
is the only caller that does: on a failure it re-arms with the auto-save
delay, doubling on each further failure and capped at
TIMING.SAVE_RETRY_MAX_DELAY_MS (one minute), cancelled on unmount, on an
entity switch, and by the next ordinary edit. Cmd/Ctrl+S and the
entity-switch/quit goodbye flush still call performSave directly and report
their own outcome once - retrying either would save into a pane the user has
already left, or double a save they just asked for by hand.
A reconnect flushes every dirty context, not just the one that failed.
useHealthQuery's automatic-reconnect branch and useEngineRestart's manual
one both call flushAll() right after invalidateQueries() - the same move,
for the same reason: a dirty editor's own retry can be backed off up to a
minute behind an engine that just came back, and nothing else pokes it sooner.
Every registered context is safe to flush here, unlike the quit flush's
urgency: a context with nothing pending is a no-op, and one that has never
failed loses nothing by saving now instead of on its own schedule.
triggerSave will not report a success the context did not have. Registered
contexts report their own failures through failSave and then resolve rather
than rejecting - useSaveManager, SettingsMain and VariableTableEditor all
do - so runSave checks for status === "error" after awaiting instead of
setting "saved" unconditionally. Without that check a failed Cmd/Ctrl+S showed
"Saved" beside its own failure toast.
It refuses "pending" on the same grounds (#1381). A context that saw an edit
land while its write was in flight publishes pending, because the payload that
went out does not hold that edit - and runSave is the path Cmd/Ctrl+S and the
quit flush take, so overwriting it put "Saved" on the Dock over an edit nobody
had persisted. One rule covers both: a status the context published for itself
is the truthful one, and runSave only fills in the silence.
A writer that registers no context inherits that rule from
completeSaveThenIdle. The collection tree's two renames call startSaving
and completeSaveThenIdle by hand, so runSave never saw them: a rename
published "Saved" onto the one status the Dock renders while the open request
held an unsaved script - true of the rename, false of everything else on screen
(#1385). The success reporter now asks the registry, and publishes pending
instead while any other registered context still has hasPendingChanges; the
context holding that edit clears it when it writes. A registered context names
itself as it reports - completeSaveThenIdle(contextId) - because its own entry
is refreshed by an effect and so still reads dirty at the moment its write
lands. A direct writer names nobody and is measured against all of them, which
is the point of guarding in the store rather than at the call sites: the writer
added next inherits it without knowing the rule exists.
That same effect lag is why the pending this guard publishes is re-derived
once, after TIMING.SAVED_STATUS_DURATION_MS. Two surfaces finishing within a
render of each other each read the other's not-yet-refreshed entry as dirty, so
both report pending over an editor holding nothing - and nothing would revisit
it until the next save. The re-check clears it only while no context is dirty
and no later save has re-armed the reset, so a pending a context published for
its own unsaved edit is untouched.
SaveContext:
Key Methods:
const {
registerContext, unregisterContext, updateContext,
setActiveContext, getActiveContext,
triggerSave, // Ctrl/Cmd+S - saves active or first dirty context
flushAll // Save all dirty contexts (used before app quit); resolves
// to { saved, failed, pending } - see below
} = useSaveStore();
Non-persisted. See useSaveManager (debounced-autosave editors) and
useDraftSaveContext (draft editors, whether they commit on blur or on a
button) for registration details.
Between them every dirty editor in the app is in this registry, which is what
flushAll walks on quit - a surface that is not registered is not merely
unsaved on quit, it is invisible.
The registry has a second reader: the collection tree refuses to drag a
request whose open tab still has unsaved edits (contexts.get("request-<id>")
?.hasPendingChanges, keyed the way useSaveManager registers it). Two writers
on one row - a save carrying the row's contents while a reorder rewrites its
owner and order - is the clobber family #237 belongs to, and the drag is the
half that can simply wait.
collection-auth-draft-store.ts - A Collection's Live Auth-Tab Draft¶
A Map<collectionId, CollectionAuth> mirroring AuthTab's in-progress draft
outside the component (#1483).
Key Methods:
Non-persisted. AuthTab's draft is component-local useEntityDraft state,
invisible to anything that is not AuthTab - including the Inheritance Chain
card it renders as a child, and the context bar's CollectionAuthSection,
mounted in a different part of the tree and backed by useCollectionsQuery
directly. Both used to describe the last-saved mode while the picker sat on an
unsaved one. AuthTab writes its current draft here on every render and clears
its entry on unmount; withDraftAuth (modules/request-builder/utils/auth-resolution.ts)
substitutes it into an ancestor chain before either reader calls
resolveAuthSource, so a dirty pick and its own inheritance chain agree.
response-store.ts - Response Cache¶
In-memory storage of responses per request ID, persisted across view/tab switches but not to disk.
State:
{
responses: Map<string, StoredResponse>,
lru: string[] // request ids, least-recently-written first
}
StoredResponse: Includes status, headers, body, execution time, script results, and console logs.
Key Methods:
Eviction by identity: clearResponse runs from useDeleteRequestMutation
(the delete is what makes the response unreachable) and from tabs-store's
closeTabsForEntities (the collection cascade, which knows every descendant id).
Eviction by count: RESPONSE_CACHE_MAX_ENTRIES (24) bounds the map (#1156).
Those two delete seams were once the only ones, so the map grew with every
distinct request a session sent - each entry a body plus its raw copy - until
the app quit. What the map holds is the 24 most recently sent requests, not
the open tabs: this store cannot ask tabs-store what is open, since that
store imports this one. The cap is twice MAX_OPEN_TABS so that recency covers
tab reach in practice - a full set of tabs can each be re-sent and every one of
them still re-opens on its full-fidelity body. The gap is a tab held open
without being re-sent while 24 other requests are, which tab eviction allows
only for a dirty tab; it falls back like any older request. Order comes from
lru, not from executedAt, which a restored response carries from the run
that produced it and which therefore says nothing about when this session last
touched the entry; setResponse is the only writer, and it moves a re-sent
request back to the recent end, so the entry just stored is never the one
evicted. Past the cap nothing is lost, only unabridged: the request re-opens
against the backend's stored run, whose body the engine truncated at
maxTraceBodyBytes.
Non-persisted (responses are reloadable from backend).
tab-selection-store.ts - Active Sub-Tab Per Entity¶
Three independent maps remembering which sub-tab is showing, per entity id: the request builder's tab, the response pane's tab, and the collection screen's tab.
State:
{
requestTab: Map<string, RequestTab> // keyed by request id
responseTab: Map<string, ResponseTab> // keyed by request id
collectionTab: Map<string, CollectionTab> // keyed by collection id
}
Key Methods:
const { getRequestTab, setRequestTab } = useTabSelectionStore();
const { getResponseTab, setResponseTab } = useTabSelectionStore();
const { getCollectionTab, setCollectionTab } = useTabSelectionStore();
useTabSelectionStore.getState().clearEntity(id); // drops id from all three maps
useTabSelectionStore.getState().clearAll();
Why a store at all. Shell.tsx mounts one workspace tab's surface at a
time, so RequestBuilderProvider, ResponseViewer and CollectionDetail all
unmount whenever the user looks at a different tab and remount when they come
back - and each held its active sub-tab in a bare useState, which is gone
the moment the component is. The same three components also serve every
open tab of their own type without remounting (RequestBuilder is not
recreated per request tab, nor CollectionDetail per collection tab), so a
tab-id change reaching the same component instance needed the same lookup a
mount-time initializer already had to do - both call sites read and write
through this store rather than through two different mechanisms.
In memory only, like response-store.ts above, and for the same reason: a
sub-tab pick is not worth persisting across a relaunch. Unlike that store,
this one carries no LRU cap - an entry is one enum value rather than a
response body, so bounding it buys nothing.
Eviction by identity: clearEntity runs from tabs-store's
closeTabsForEntities, unconditionally rather than gated on type the way
the response clear is - a collection id keys collectionTab exactly as a
request id keys requestTab / responseTab, so a collection cascade has to
evict here too, and clearEntity is a harmless no-op for whichever of the
three maps never held a given id.
appearance-store.ts - Pre-Paint Interface Preferences¶
The UI font, the interface scale, the corner roundedness and the interface
density - the four preferences index.html's pre-paint script applies before
React mounts. Seeded from localStorage at module load and written back one
key per preference (vayu-ui-font, vayu-ui-font-custom, vayu-ui-scale,
vayu-ui-radius, vayu-ui-density), not through zustand's persist: the
pre-paint script reads those exact keys, and SETTINGS_STORAGE_KEYS clears
them on "Reset app settings".
Density (issue #1670) differs from the other three in mechanism: font, scale
and radius each resolve a stored value to something applyX computes and
writes with style.setProperty, while applyDensity only toggles a
data-density attribute on documentElement - the two densities are whole
--spacing values declared directly in index.css
(:root / [data-density="comfortable"]), so there is nothing to compute.
State:
{
font: UiFontChoice // a preset or "custom"
fontCustom: string // used when font === "custom"
scale: number // page-zoom factor, 0.8-2.0 in 0.1 steps
radius: UiRadius
}
Each action both persists and applies (--font-sans, --radius,
webFrame.setZoomFactor), so there is one write path per preference rather than
a state update and a separate effect that has to agree with it.
Why a store and not useState in useAppearance: scale has two inputs. The
Appearance panel's slider and the View menu's Ctrl/Cmd + - 0 move the
same value, and useAppearance is mounted twice (the app shell and the panel).
Per-instance state let the panel keep reading "Default" while the window
rendered 133%. The menu bridge (onZoomCommand → nudgeScale/resetScale)
lives in useMenuActions, which mounts exactly once - subscribing from
useAppearance would move the zoom two steps per keypress.
The scale range, the legacy-preset migration and the clamp/snap live in
constants/appearance.ts (clampScale, parseScale, nudgeScale). The
pre-paint script duplicates them by necessity - it runs before any module - and
appearance.prepaint.test.ts executes the real script to keep the duplicate
honest.
client-settings-store.ts - Renderer Preferences¶
Central home for renderer-only preferences that aren't part of the pre-paint appearance set (theme/color/UI-font/scale/radius live in their own localStorage keys so index.html can apply them before React mounts). Holds editor behavior, the monospace/code font, chart granularity, the capacity SLO threshold, the live refresh rate, auto-save preferences, reduced motion, the notification preferences and the load-test dialog's ceilings. Backs the Settings panels (modules/settings/main/panels/). Non-React consumers (services, the dashboard store) read via getState().
State:
{
editor: EditorPrefs // fontSize, wordWrap, minimap, lineNumbers, tabSize
monoFont: MonoFontChoice // a preset or "custom"
monoFontCustom: string // used when monoFont === "custom"
chartBucketSeconds: number // time-bucket width for the dashboard charts
sloThresholdMs: number // p99 latency the capacity breakpoint triggers at
liveRefreshMs: number // how often live metrics commit into the store
autoSave: AutoSavePrefs // { enabled, delayMs }
reducedMotion: boolean // mirrored onto <html data-reduced-motion>
keepAwakeDuringRuns: boolean // standing answer on the run wake lock; default off
systemNotifications: boolean // opt-in for OS notifications while Vayu is in the background; default off
notifications: NotificationPrefs // position, durationScale, maxVisible, minSeverity
loadTestCeilings: LoadTestCeilings
}
import { SETTINGS_STORAGE_KEYS } from "@/stores"; // localStorage keys reset by "Reset app settings"
monoFont and reducedMotion also touch the DOM (--font-mono,
data-reduced-motion), so their setters and onRehydrateStorage both apply
them - a preference that only lands on the next reload reads as a broken
setting.
keepAwakeDuringRuns is the user's standing answer to whether a run may ask the
OS to stay awake (issue #1357). It is read when a run starts rather than
watched, by LoadTestService and ScenarioRunService, so the answer that
governs a run is the one that was true when it began - flipping the row mid-run
does not reach a stream already going. Off is the shipped default: with it off a
load run of ten minutes or more asks once (KeepAwakePrompt), and that grant
takes the same wake-lock key the service releases, so it ends with the run.
systemNotifications is the opt-in for the OS notifications services/notify.ts
posts (issue #1358). It is read at the moment an event fires, not watched,
by the same reasoning as keepAwakeDuringRuns: every call site is a service or
an event handler with no hook to hang a subscription on. Off is the shipped
default, and it is persisted through partialize like the rest of this store's
top-level preferences.
loadTestCeilings is the one slice with a bound outside the app: each value is clamped to LOAD_TEST_CEILING_BOUNDS (constants/load-test.ts) on write and on rehydrate, because the bounds are the engine's crash guards and a build that tightens one must not keep offering a stored ceiling above it. The load dialog turns them into its field ranges via resolveLoadTestLimits; nothing else reads them.
Persisted to localStorage (via zustand/persist), version: 1 with a
pass-through migrate; workspace/session state (open tabs, layout, active
environment) is deliberately excluded from the reset, as is the live chart
window - that one is engine config, so it resets with the engine's settings.
Nested preferences are completed against their defaults on rehydrate
(mergeWithNestedDefaults). zustand's merge is a shallow top-level spread, so a
stored editor / autoSave / notifications / loadTestCeilings object
replaces its defaults whole and a key added after that payload was written
arrives undefined - notifications.maxVisible feeds slice(-maxVisible) in
toast-store, and slice(-undefined) caps nothing. Adding an object-valued
preference means adding a line to that merge; the store's test enumerates them
rather than trusting the list.
dashboard-store.ts - Load Test Metrics & State¶
Manages live load test run state: streaming metrics, final reports, and running aggregates (peak concurrency, SLO breakpoint). Retention is time-based, not a fixed point count: addMetricsBatch trims ticks older than the user-configurable live window (liveWindowSeconds, default 5m, null = full run), backstopped by a maxRetainedTicks ceiling (default 50,000). Both are kept in sync by the useLiveChartSettings hook and drive what the live charts plot. (app/src/config/metrics.ts now only holds the SSE commit throttle, METRICS_UI_THROTTLE_MS.)
The window is engine config, not a renderer preference. It is stored as the engine's liveReplayWindowMs entry (milliseconds; 0 = full run), so useLiveChartSettings reads it from useConfigQuery and writes it with useUpdateConfigMutation - there is no localStorage key. The engine needs the same number: it sizes the in-memory SSE tick ring that GET /runs/:runId/live replays from offset 0, and that replay is what rebuilds these charts when the dashboard attaches or re-attaches mid-run. Two settings would let the retained span and the displayed span disagree, with the engine replaying less than the chart is configured to show. constants/live-window.ts owns the option list and the liveWindowToMs / liveWindowFromMs mapping. The Dashboard picker is its only editor: the engine settings list rendered a second one until #586, under a different label and a different save model, and ENGINE_SETTINGS_EDITED_IN_APP (modules/settings/engine-settings-edited-in-app.ts) is what keeps that row out of it now.
The tick ceiling is shared the same way, as liveMaxRetainedTicks (default 50,000). It is a memory bound, not a rendering one - bucketColumns collapses ticks into chartBucketSeconds buckets (0.5s by default) before uPlot sees them, and uPlot draws to canvas, so a full window reaches the screen as a few thousand points however many ticks back it. It also costs nothing at stock settings, since the window is what sizes the retained history; it only binds when window / tick-interval exceeds it. DEFAULT_MAX_RETAINED_TICKS here and DEFAULT_MAX_LIVE_TICKS in the engine are the pre-config defaults and must move together.
Because the value arrives asynchronously, the store seeds liveWindowSeconds with the module default rather than reading it synchronously at creation; the hook corrects it once the config query resolves.
State:
{
currentRunId: string | null
mode: "running" | "completed" | "stopped"
isStreaming: boolean
currentMetrics: LoadTestMetrics | null
historicalMetrics: LoadTestMetrics[] // Trimmed to liveWindowSeconds (cap: maxRetainedTicks)
monitorSamples: MonitorSample[] // Server vitals scraped this run (cap: maxRetainedTicks)
liveWindowSeconds: number | null // Live retention window; null = full run
finalReport: RunReport | null
error: string | null
activeView: "metrics" | "request-response"
isStopping: boolean
loadTestConfig: LoadTestRunConfig | null // Config snapshot during run
requestInfo: LoadTestRequestInfo | null // Request snapshot during run
peakConcurrency: number // Running max (monotonic)
breakpoint: Breakpoint // SLO crossing (latched on first breach)
}
Key Methods:
const {
startRun, stopRun, setStreaming,
addMetricsBatch, // Efficiently fold batch into history and update aggregates
addMonitorSamples, // Append scraped server vitals, bounded by maxRetainedTicks
setFinalReport, setError, setActiveView, setStopping,
setLiveWindowSeconds, // Update the live retention window (from useLiveChartSettings)
setMaxRetainedTicks
} = useDashboardStore();
The load-test dialog seeds its scrape cadence and bounds its metric list from
the engine's monitorIntervalMs / monitorMaxSeries settings, through
useMonitorSettings - the same read-the-engine's-copy arrangement
useLiveChartSettings uses above, and for a sharper reason: this dialog always
sends an explicit intervalMs, so a renderer-local default would mean the
setting never applied to a run started here at all.
monitorSamples is kept beside the ticks rather than merged into them: the
two are sampled by different clocks (the engine's tick cadence and the user's
scrape interval), so they are joined onto one x axis at render time by
joinMonitorToTimeline - which is also where a failed scrape becomes a gap in
the line rather than a plateau. The array is empty for a run that configured no
monitor, and that emptiness is what keeps the vitals chart row off the dashboard
entirely. It carries its own copy of the tick ceiling because a scrape can be
configured faster than the tick cadence.
There is deliberately no store-wide reset: startRun already wipes the series,
the report and the aggregates, and a reset on top of it nulls currentRunId -
the dashboard then shows no active test while one streams.
Non-persisted (fresh per session).
host-sleep-store.ts - When The Machine Slept Under A Run¶
The intervals the host spent asleep while a run was streaming (issue #1357), keyed by run id. The app holds a system wake lock for the length of a run, but that lock is a request to the OS: a closed lid or a critical battery overrides it, and the run's series is then missing a stretch that nothing in it explains.
The record lives here rather than in the run report because the engine was
suspended too and knows nothing about it. useHostSleepRecorder writes it -
the main process's power:suspended carries the anchor (where the run had got
to, read off dashboard-store on the way down, since the renderer is frozen in
between) and power:resumed carries the duration.
State:
{
byRun: Record<string, HostSleep[]> // { at, durationMs, startSeconds }, oldest first
runOrder: string[] // first-annotated order, for eviction
}
Two readers, both by run id through the useHostSleeps(runId) selector rather
than one prop drilled to both: MetricsView and PerformanceTab mark the
sleeps on the charts, OverviewTab states them in RunEvents. The selector
returns one frozen empty array for a run with no sleeps, so a dashboard
rendering at 10 Hz is not re-rendered by a stable absence.
Persisted, bounded to the newest MAX_RUNS (20) runs and
MAX_SLEEPS_PER_RUN (20) intervals each: the gap outlives the session that
produced it, and a user who finds the hole tomorrow needs the same answer, but
localStorage is a fixed budget shared with the workspace. migrate and merge
both normalize, as recovery-notice-store does and for the same reason.
inbox-notify-store.ts - Which Inboxes May Notify On A Capture¶
Whether a capture landing on one inbox may raise an OS notification while Vayu
is in the background (issue #1388), keyed by inbox id. Off for an inbox nobody
has decided about, and off even when systemNotifications is on: a capture is
the one relevant event with no natural rate, and a webhook source that retries
would otherwise teach the user to turn the whole feature off, taking the
run-finished notification with it.
It lives here rather than on the engine's inbox record, the way
host-sleep-store keeps a run's annotation beside a report with no field for
it: the engine's inbox is in-memory state that never outlives the process that
opened it, and it does not act on this flag - only the desktop app does.
State:
Four readers. Two at the moment a capture arrives:
modules/inbox/capture-notifier.ts gates on it (the second gate;
services/notify.ts reads the global opt-in), and the header toggle in
modules/inbox/index.tsx writes it. The third is hooks/useInboxWatchers.ts,
which reads the whole map continuously to decide which inboxes
services/inbox-watch-service.ts should hold a live stream for: the toggle
promises a notification while Vayu is in the background, and the inbox tab is
mounted only while it is the active tab (issue #1400). The fourth is the
Services drawer's inbox row (modules/services/ServicesPanel.tsx), which reads
one inbox's entry to decide whether to say the promise is not currently being
kept - a stream that gave up, or one the stream cap left out (issue #1412). It
reads rather than writes, and an inbox whose toggle is off gets no note, because
there is nothing to say about a promise that was never made.
Persisted, and pruned: an inbox id belongs to the engine process that minted
it, so retainInboxes drops every id a successful GET /inbox no longer names,
called from useInboxWatchers on a list read the engine actually answered - a
failed read leaves the map alone, because "no inboxes" and "could not ask" are
not the same list. It was called from InboxView until #1400, which pruned the
map only while that tab happened to be open. migrate and merge both
normalize, as host-sleep-store does.
scenario-run-store.ts - Live Collection-Run Steps¶
The live half of a scenario (collection) run's tab. ScenarioRunService pushes the step SSE events in here and ScenarioRunView reads them, so the stream survives navigating away from the tab and back - the same split, for the same reason, as LoadTestService and dashboard-store.
It holds one run. A scenario is sequential and the app starts one at a time, so startRun replaces the previous run's steps rather than accumulating; a tab for an older run reads an empty list rather than whatever is streaming now. addSteps drops a batch that arrives with no run registered, because the stream is replayable and a step from a replaced run can still land on a socket that has not finished closing.
State:
{
runId: string | null // The run being streamed, or null
steps: ScenarioStepRow[] // Reported so far, in plan order
summary: StepListSummary // The four outcome counts, plus how many steps are
// past the first pass and how many bound a data row
appendEpoch: number // Moves whenever the list changed by anything other
// than growing at its end
isStreaming: boolean
error: string | null // A transport failure on the stream
}
A batch per flush, not a commit per event (issue #1153). ScenarioRunService buffers arriving step events and commits what it collected on the user's liveRefreshMs cadence, the same throttle LoadTestService puts on its ticks. The premise for having none - that a scenario's step rate is bounded by request latency - does not hold: the engine's scenario loop is sequential with no pacing, so a local target returns hundreds of steps per second, and each one used to cost a full copy of the step list, a store notify and a re-render of the run tab. The buffer is committed on the leading edge of a window and again on a trailing timer, and drained when the stream closes or fails; a run replaced mid-flight discards whatever it left buffered, since those rows belong to a list the store has already cleared. That mechanism - buffer, leading edge, trailing timer, and the liveRefreshMs read behind it - is one implementation, services/throttled-batcher.ts (issue #1206), and now has a third caller: useExecutionEvents buffers a design-mode request's streamed events through it the same way (issue #1158). When to flush and when to discard stay each caller's own, because the lifecycles genuinely differ - the design-mode hook flushes on teardown rather than discarding, because a teardown there is as often the builder unmounting mid-stream as it is a replacement, and the store's run guard is what drops the leftovers in the second case.
Steps are folded in by foldStepEvents (modules/history/main/scenario-steps.ts), which keys on (iteration, stepIndex) rather than arrival order: the engine's SSE ring replays from Last-Event-ID, so a reconnect re-delivers events already rendered and an append-only list would double every row it re-saw. A whole batch costs one array copy, and the fold returns the state it was given when every event in the batch was an idempotent replay, so a reconnect's replay does not re-render the list.
summary is maintained by that same fold rather than recounted by the view - the dashboard-store aggregate stance, for the same reason: a scan per commit is a scan over a list that grows for the length of the run. ScenarioRunView is its reader, for the four count chips and for the two whole-list questions its rows answer. It does not displace the report: once report.scenario can give the run's own totals those win, because thinning drops passes and the stored rows undercount them (see docs/app/COMPONENTS.md). summarizeSteps answers for the stored rows, which arrive complete, and is the oracle the incremental summary is tested against.
appendEpoch is the second thing that fold publishes, and the reason it has to (issue #1205). Every commit hands the view a new array - immutability, which zustand's change detection depends on - so from the rows alone an appended row and a replaced one look alike, and a reader that wants to keep anything derived from the list has to compare the two lists to find out. The fold already knows, so it says: foldStepEvents returns appendedOnly beside the fold, and the store turns it into a monotone counter that moves for a replay that replaced a row, a gap-resume that spliced one in, and a startRun that emptied the list. useFilteredSteps (modules/history/main/) is its reader: it counts what the batch that arrived added to a chip's or the search box's match, and produces rows only as far as the growing window it owns has to show them, so a narrowed live list costs one predicate pass over the batch rather than over the whole run per flush - and, once that window is full, no array copy at all (issue #1297). Monotone matters - React may render once for two commits, and a value that could return to one a reader had already seen would let a stale prefix through.
Once a run reaches a terminal status its stored results rows are the complete record and the view reads those instead. ScenarioRunService.handleClose invalidates runs.detail(runId) and refetches runs.report(runId) through the query cache - the same keys the tab reads, so a bare fetch would leave the pane on the stale copy. It also invalidates runs.lists() (the History row still says "running") and runs.lastCollectionRuns() (the context bar's Last run section says it too, from its own family, and is not polled at all).
That report fetch carries staleTime: 0, and the feature rides on it. The tab mounts the moment the run starts and asks for the report immediately, when the engine has written no step rows yet - they go to SQLite in one batch at the end - so the cache holds a report with an empty results[], seconds old. Under the hook's own five-minute RUNS_STALE_TIME_MS that entry reads as fresh and fetchQuery resolves from it without a request, which left the step list on the live rows for the life of the tab and made every step expand into an empty panel, since only a stored row carries the exchange. This is the one fetch in the app that knows the data just changed, so it is the one that must not honour the cache. The engine writes the rows, then the summary, then the terminal status, and publishes complete after all three, so the refetch cannot race the write it exists to pick up.
There is deliberately no stopMonitoring on the service and no clear on the store: the stream ends on its own when the engine sends complete, and nothing in the app stops a collection run mid-flight yet. Both belong with the stop control that would need them.
Non-persisted (fresh per session).
execution-events-store.ts - Live Streaming-Request Events¶
The live half of a streaming design request (issue #574): POST /execute with stream: true answers 202 {runId, eventsUrl} and the upstream's own events arrive over GET /runs/:id/events. modules/request-builder/hooks/useExecutionEvents buffers the relayed frames through services/throttled-batcher.ts and commits a batch per flush window on the user's liveRefreshMs cadence, so the store pays one copy and one notify per window rather than per frame (issue #1158) - the same batcher LoadTestService and ScenarioRunService commit through, now with three callers. The response pane's Events tab reads the store, so the rows survive switching to another request tab and back - the same split, and the same reason, as scenario-run-store above.
It holds one stream: a second Send is what ends the first, so two are not a state the builder can reach. It also holds the request the stream belongs to, not only the run - one RequestBuilderProvider serves every request tab, so "are these rows mine?" is a question about the request on screen and a run id alone cannot answer it. Both the provider and the viewer select against requestId, which is what stops a stream started elsewhere appearing under a request that never streamed.
State:
{
requestId: string | null // Whose Send started this stream
runId: string | null // The run the engine created; what a Stop names
eventsUrl: string | null // Engine-relative, exactly as the answer gave it
open: StreamOpen | null // What the stream connected to (the `open` frame)
events: StreamEvent[] // Received so far, oldest first
totalEvents: number | null // The engine's own count, from the `complete` frame
isStreaming: boolean
endReason: StreamEndReason | null
error: string | null // A transport failure on the relay
}
Every write is addressed to a run, and a write for a run the store is not holding is dropped. Not defensive tidiness: the relay replays its retained ring on connect, so a frame from a stream that has already been replaced can still arrive on a socket that has not finished closing - and those rows landing under the send that replaced it is the worst failure here, because such a timeline looks real.
totalEvents is set only from the complete frame (falling back to what arrived, so it is never left null once a stream has ended). While the stream runs, the arrived-so-far count is events.length; reporting anything else would be a total nothing had counted. It matters because it is not the row count once a list has been capped, and the Events tab's truncation disclosure compares the two.
Once the run reaches a terminal status the stored trace is the complete record: the provider fetches the report, restore-response.ts maps its events node onto ResponseState, and the tab reads that instead - the two-sources-one-list handoff ScenarioRunView makes. What lives here is only ever what arrived on the socket, and it is not bounded by the engine's retained ring: that ring (sseMaxRetainedEvents, 2,000 by default) is only what a reconnect replays, and a client that stays connected is replayed the ring once and then tails the rest of the stream. What actually bounds the list is the per-stream cap instead - sseMaxStreamEvents (100,000 by default, 10,000,000 at the ceiling) or sseMaxStreamDurationMs (600,000 ms / 10 minutes by default), whichever the stream reaches first - which is orders of magnitude bigger than the ring and why the Events tab cannot assume the list stays short. That is also why the tab (ResponseEvents.tsx) renders through useGrowingWindow with memoized rows rather than the whole list at once, and why a live stream passes its runId as the window's listKey - the default reset key is the row count, which would snap the window back to its first slice on every batch a growing-in-place list receives.
Non-persisted (fresh per session).
import-modal-store.ts - Import Modal UI¶
Simple modal state for the collection import dialog.
State:
Key Methods:
toast-store.ts - Transient Notifications¶
The queue behind the toasts. It holds what to show; the Radix primitive in
components/ui/toast.tsx owns when - the dismiss timer, pausing on hover,
focus and window blur, swipe, and the open/closed state the exit animation keys
off. There is no setTimeout in the store.
State:
Key Methods:
const { showToast, dismissToast } = useToastStore();
showToast("Run history cleared", "success"); // string form, still supported
showToast({ // returns the toast id
message: "Couldn't stop the run",
variant: "error",
action: { label: "Try again", onClick: retry },
});
Variants are info | success | warning | error, with base durations of 4s
/ 4s / 6s / 10s from TIMING.TOAST_DURATION_MS. warning is for a refusal ("A
load test is already running") as distinct from a failure.
Three of the policies are user preferences (notifications on
client-settings-store, shapes in constants/toast.ts), applied here on the way
in:
minSeverity- a floor, compared bypassesSeverityFloor. Below itshowToastreturns an id and shows nothing.durationScale- a multiplier over the per-variant duration (scaledDuration), not a replacement for it, so halving keeps the ratio between a confirmation and a failure.neverbecomesNEVER_DISMISS_MSrather thanInfinity, which the primitive'ssetTimeoutwould coerce to 1.maxVisible- the stack cap, defaulting toMAX_TOASTS(4); past it the oldest is dropped.
Two more policies live here because the primitive has no opinion on them: an
identical message and variant already on screen is collapsed rather than
stacked, and an explicit duration from the caller wins over the scale.
Removal is driven by
the primitive's onOpenChange(false), which covers timeout, close button and
swipe alike, so there is one removal path rather than three - but it closes
the toast rather than dropping it, and the entry leaves the queue
TIMING.TOAST_EXIT_MS later so the exit animation has frames to run in.
The values are not in the store. Delays live in config/timing.ts
(TOAST_DURATION_MS, TOAST_EXIT_MS) with every other UI-facing delay in the
app, and the stack cap in constants/toast.ts (MAX_TOASTS). The store holds
behaviour, not configuration.
See docs/design-system.md -> Toasts for the visual tokens.
Module-Local Stores¶
Module-local UI stores co-locate in app/src/modules/<feature>/<feature>-store.ts and manage feature-specific UI state that should not leak into the global store tree.
modules/collections/collections-store.ts - Collections Tree Expansion¶
UI-only: Which collections are expanded/collapsed in the tree.
State:
Key Methods:
const {
expandedCollectionIds,
toggleCollectionExpanded, expandCollection, expandCollections
} = useCollectionsStore();
Every action here has a caller, and expanding is idempotent. expandCollection
(one id) and expandCollections (an ancestor chain) both return the same Set
when nothing changes, because it is passed down the whole tree and listed in
effect dependencies - a fresh Set for a no-op re-renders every row and re-runs
the reveal effect. That is why the tree calls them instead of hand-rolling
if (!expanded.has(id)) toggle(id), which reads the set it writes and so drags
expandedCollectionIds into the dependencies of everything that expands.
collapseCollection and reset used to live here with no callers at all;
collapsing goes through toggleCollectionExpanded.
modules/history/history-store.ts - History Filter & Sort¶
UI-only: Search, filter (type/status/pinned), and sort (newest/oldest) for the history tab.
State:
{
searchQuery: string
filterType: "all" | "load" | "design"
filterStatus: "all" | "pending" | "running" | "completed" | "stopped" | "failed"
pinnedOnly: boolean
sortBy: "newest" | "oldest"
}
Helper: filterRuns(runs, filters) applies type/status filtering and
sorting to the loaded pages. Search is not handled here: searchQuery is
debounced into the server-side q param (see useRunsQuery) so it covers all
runs, not just the pages loaded into the sidebar.
pinnedOnly is server-side for the same reason - it drives
GET /runs?baseline=true, so a pin older than the loaded pages is still
findable - and applied again in filterRuns. That second pass is not
redundant: unpinning patches the loaded pages in place rather than refetching
them (see useSetRunBaselineMutation below), so the row just unpinned would
otherwise sit in the pinned-only list until the next poll. The param decides
what is fetched; the pass decides what is shown. false is never sent: the
engine reads baseline=false as "only unpinned runs", so the off state omits
the param entirely.
Key Methods:
const {
searchQuery, setSearchQuery,
filterType, setFilterType,
filterStatus, setFilterStatus,
pinnedOnly, setPinnedOnly,
sortBy, setSortBy,
resetFilters
} = useHistoryStore();
modules/variables/variables-store.ts - Variables Category Selection¶
UI-only: Which category (globals/collection/environment) is selected in the variables tree.
State:
{
selectedCategory: VariableCategory | null
// VariableCategory = { type: "globals" } | { type: "collection"; collectionId }
// | { type: "environment"; environmentId }
}
Key Methods:
A variable-map write merges onto the freshest map, never onto its own copy¶
PUT /environments/:id, PUT /globals and PUT /collections/:id replace the
whole variables map, so any writer holding less than the freshest map risks
carrying a key it never touched back to its pre-write value. Two surfaces write
these maps and both go through lib/variable-merge.ts's mergeVariableChanges
rather than each re-deriving the read-fresh-merge-write shape (#1439):
- The context bar's
useVariableCommit(components/layout/context-bar/ variable-commit.ts) commits one key.CommitScope.read()re-reads the query cache at commit time, not the value the input was rendered with, andmergeVariableChangesapplies the one changed key onto it beforewrite/mutate. VariableTableEditor(modules/variables/main/) commits every row the user has actually edited.performSavediffs the current rows againstbaselineRef- the map the table last agreed with the server on - to get aVariableChangesof only the touched keys, then merges that ontodataVariables, the query-cache-backed prop, which is why it stays current even while the row-init effect below is refusing to reseed. A key nobody touched - an MCP agent'supdate_environmentlanding while the table has an unrelated unsaved edit - therefore survives the next save regardless of what the editor's own copy of it says.
A key both sides touched is a conflict, not a coin flip. While the table is
dirty, the row-init effect recomputes the same diff against baselineRef on
every dataVariables change purely to call findVariableConflicts, which
reports a key only when the fresh map and the user's edit both moved it away
from the baseline to different values. The table renders one Callout per
conflicting key naming it, with a "Take theirs" action; the user's value is
what saves by default (it is already the value performSave sends for that
key) until they explicitly take the other side's. baselineRef itself only
advances on a full reseed, on this editor's own successful save (to the map
that save just wrote, so a later write is never compared against this save's
own history), and when a conflict is resolved - never merely because the table
was dirty when a fresh write arrived, since a key nobody has touched needs no
conflict bookkeeping at all.
modules/settings/settings-store.ts - Settings Category Selection¶
UI-only: Which settings category (e.g., "ui") is selected in the sidebar.
State:
Key Methods:
lib/graphql/explorer-store.ts - GraphQL Schema Explorer View¶
UI-only, in memory: whether the schema explorer pane is open, and per schema identity the search text, the expanded row ids, the scroll position and whether rows show their full description or one clipped line.
State:
{
open: boolean,
byKey: Record<
string,
{ search: string; expanded: string[]; scrollTop: number; showDescriptions: boolean }
>,
lru: string[]
}
Key Methods:
const { open, setOpen, view, setSearch, toggleExpanded, setScrollTop, toggleDescriptions } =
useExplorerStore();
showDescriptions is per schema like everything else here: how much
documentation a user wants on screen is a property of the schema being read, and
an endpoint that documents nothing has nothing to answer for.
A store rather than component state, because the pane is unmounted for no reason the user did. Radix tears the whole Body tab down on every glance at Headers or Auth - the same unmount the body drafts exist for - and a component-state explorer comes back collapsed to its roots with an empty search box.
Keyed by schema identity, using the schema cache's own schemaCacheKey, so
two requests against one endpoint share the tree they have opened and the same
URL reached with different credentials does not. Deliberately not persisted and
capped at EXPLORER_VIEW_MAX_ENTRIES (8, matching the schema cache): an
expansion set is a description of a schema that may not exist next launch.
lib/graphql/reveal-store.ts - GraphQL Outline Click-to-Scroll¶
UI-only, in memory: one slot holding the operation the context bar's GraphQL outline asked the query editor to scroll to, until something serves it.
State:
Key Methods:
A store because the two ends cannot see each other. The outline lives in the
context bar, outside RequestBuilderProvider; the Monaco instance lives inside
GraphQLBody and stays there, the way the insert machinery applies its edits
in-component rather than handing the editor out. What crosses the boundary is a
request to reveal, not an editor.
Consume-and-clear, for the reason the insertion effect records: a command
left in the slot is replayed on the next render and the next remount, and Radix
remounts the Body tab on every glance at Headers. GraphQLBody clears it once
served and when it cannot serve it (the operation was renamed away, which it
says out loud); the provider clears one naming another request or a request
whose body is no longer GraphQL, since nothing under it can ever serve those.
The provider's other half is bringing the Body tab forward - the editor does not
exist while another tab is on screen.
A command names its request for the reason the body drafts do: only one request builder is mounted at a time, so a mismatch takes a click and a tab switch in the same tick, and the cost of not carrying the id is another request's editor jumping to a line number that means nothing there.
TanStack Query (Server State)¶
TanStack Query manages server state with automatic caching, refetching, and synchronization. It is the source of truth for collections, requests, environments, globals, and runs.
Query Hooks¶
Located in app/src/queries/, one file per resource family, all re-exported
from app/src/queries/index.ts, which is what callers import from. The key
factory lives beside them in app/src/queries/keys.ts. A couple of hooks with a
single consumer are imported from their file directly (useRunTimeSeriesQuery
from @/queries/runs); everything with more than one is in the barrel.
Collections & Requests¶
useCollectionsQuery()- Fetch all collections (there is no single-collection query: a collection is read out of this list)useRequestsQuery(collectionId)- Fetch requests in a collectionuseMultipleCollectionRequests(collectionIds)- The same list for several collections at once, as parallel queries. ItsrequestsByCollectionmap is referentially stable while the underlying results are unchanged (built inuseQueries'combine, which TanStack memoises only for acombineof stable identity - hence theuseCallback). Callers list it in effect dependencies; when it was rebuilt on every call, the collection tree's reveal effect (useRevealActiveSelection) re-ran on every render and re-expanded a collection the user had just collapseduseRequestQuery(requestId)/requestDetailOptions(requestId)- One request by id (GET /requests/:id), used by a restored request tab and by a design-run copy on cold start. Only anApiErrorwithstatusCode === 404becomesRequestNotFoundError(test it withisRequestNotFound); a 5xx or an unreachable engine is rethrown untouched, because a transport failure must not read as "this request was deleted"useCollectionAncestors(collectionId)- The collection's ancestor chain including itself, root first, derived from the collections list rather than fetchedusePrefetchCollectionsAndRequests()- Warm-cache pass over every collection's requests at startup
Sorting: every tree list - roots, a collection's children, a collection's
requests - sorts with the single exported compareTreeOrder (types/domain.ts):
order, then createdAt, then id byte-wise. There were two comparators with
two different tie rules and neither matched the engine's, so "run this folder"
could execute in an order the sidebar had never shown. The rule is pinned to the
engine's SQL by engine/tests/fixtures/tree-order-conformance.json, read by
types/tree-order.conformance.test.ts and by the engine's tree_order_test.cpp
- change one side and the other suite fails.
That rule orders one block. A folder's sub-collections and its requests are
two separately ordered blocks, so their order values can collide, and where the
blocks sit relative to each other is the render's rule: CollectionItem puts
every subfolder above every request, at every depth
(CollectionTree.folders-first.test.tsx). A recursive collection run has to
execute in that same sequence - each subfolder's whole subtree, then the folder's
own requests - which is pinned across the render and the engine's plan by
engine/tests/fixtures/recursive-run-order-conformance.json, read by
modules/collections/CollectionTree.run-order.conformance.test.tsx and by the
engine's scenario_plan_test.cpp (issue #431).
Mutations:
- useCreateCollectionMutation() - Create collection
- useUpdateCollectionMutation() - Update collection (with cache update)
- useDeleteCollectionMutation() - Soft-delete a collection into the trash
rather than removing it (issue #988); invalidates coarsely, see below, and
also invalidates the trash list so the Trash view picks it up
- useCreateRequestMutation() - Create request
- useUpdateRequestMutation() - Update request (invalidates only the lists
that can have changed, see below)
- useDeleteRequestMutation() - Soft-delete a request into the trash (also
clears the response, and invalidates the trash list)
- useReorderMutation() - Reposition collections and requests through
POST /reorder in one transaction, optimistically (see below)
- useImportMutation() (queries/import.ts) - Apply a parsed import through
POST /import/apply in one transaction
Reordering (useReorderMutation) is the one mutation that writes its caches
three times, because a drag is only believable if the row is where the pointer
left it before the write returns:
onMutatesnapshots every key the plan can touch and draws the plan into the caches - the same two steps the engine performs, normalize each named scope to0..n-1in display order, then position each move. A cross-collection move crosses list caches and updatesrequests.detail(which carriesstaleTime: Infinity, so a stalecollectionIdthere would outlive every refetch).onSuccessre-applies the rows the engine actually wrote, which the response carries. They are normally what was drawn, but a normalization is authoritative from the engine, so the tree settles on real positions rather than a guess.onErrorrestores the snapshots wholesale and reports throughuseSaveStore.failSave- the one channel every save failure reaches the Dock and the toast through. The mutation owns this rather than its caller, so the gesture layer cannot forget it.onSettledinvalidates the affected keys once - the collections list only if a collection moved, and only the request lists the plan named.
Every write goes through setQueryData with a fresh array, never a mutation in
place: the map useMultipleCollectionRequests builds is compared by reference by
the reveal effect, so an in-place edit would move a row on screen that the tree
never notices. The plan itself comes from
modules/collections/reorder-math.ts - a pure module, node-tested, that turns
sibling lists and a drop index into the minimal set of rows to rewrite.
Trash¶
The read side of the same soft delete (issue #989): useDeleteCollectionMutation
and useDeleteRequestMutation stamp a row rather than removing it, and these
are how the app sees what got stamped (queries/trash.ts).
useTrashQuery()- Every deleted root, newest first (GET /trash). NostaleTime: the list changes only through the two mutations below, the two delete mutations above, and the startup retention purge, so the default is already the cheapest correct answer.useRestoreTrashMutation()- Put one deleted root back (POST /trash/:id/restore). Invalidatestrash.all,collections.all,requests.allandprefetch.allRequests()- the same coarse invalidationuseDeleteCollectionMutationdoes, and for the same reason (see the cascade delete note below).usePurgeTrashMutation()- Destroy one deleted root for good (DELETE /trash/:id). Invalidates onlytrash.all: every other cache already stopped serving these rows when they were stamped, so purging them changes nothing a live read can see.
Both mutations also invalidate trash.all on failure (#1438). The row
either acted on is one this list said existed, so the likeliest failure is that
it no longer does - an agent's purge_trash_entry or restore_trash_entry
landed between the drawer's last read and the click. Leaving the list alone
there keeps a phantom row on screen that fails the same way on every further
click; refetching either removes it or proves it real.
Environments & Variables¶
useEnvironmentsQuery()- Fetch all environmentsuseGlobalsQuery()- Fetch global variablesuseCookiesQuery()/useClearCookiesMutation()(queries/cookies.ts) - The engine's cookie jars, one per environment, read and cleared byCookiesCardin Settings. The mutation invalidates rather than patching the cache: a clear that raced a request in flight would otherwise leave the panel claiming an empty jar the engine has already refilleduseClientCertificatesQuery()and the create / update / delete mutations (queries/client-certificates.ts) - the engine's host-to-certificate registry for mTLS endpoints (issue #707), read and edited byClientCertificatesCardin Settings > Network & connectivity. The mutations invalidate rather than patching: the engine refuses a second entry for a host+port pair already taken, so the list is the authority on what a new entry may claim
Mutations:
- useCreateEnvironmentMutation()
- useUpdateEnvironmentMutation()
- useSetActiveEnvironmentMutation() - The only writer of the active
environment; PUTs isActive and mirrors it into session-store, rolling the
store back if the engine refuses
- useDeleteEnvironmentMutation()
- useUpdateGlobalsMutation()
Runs & History¶
useRunsQuery(q?)- Run history as an infinite query over the paginatedGET /runs{data, pagination}envelope, newest first. MirrorsuseRunTimeSeriesQuery'sgetNextPageParamon the same envelope shape;fetchNextPagepages older runs in on demand.qis the optional server-side search.- Polling is gated to the unpaged state (
runsPollInterval): 5s while only page 1 is loaded, off once the user has paged older runs in. Refetching an infinite query re-fetches every loaded page in sequence, so a user ten pages deep drove ~10 engine requests per tick. Only page 1 can gain rows (start_time DESC) anyway; a paged list refreshes on the next mutation or invalidation instead of on a timer. - Only a surface that renders runs mounts it. The interval belongs to the
hook, so it runs for as long as any observer is mounted - and the App root
used to be one, which kept the poll alive with History closed, nothing
running and nothing at the root reading the result (#1150). The root now
calls
usePrefetchRuns()instead: oneprefetchInfiniteQueryoverrunsListInfiniteOptions(), which warms the same cache entry without observing it.HistoryList,WelcomeScreenand the palette'suseEntityItemsobserve the key while they are visible and drive the cadence themselves. runsListInfiniteOptions(q?, pinnedOnly?)- the key, fetcher and page params, shared by the hook and the warm-up so the entry one writes is the entry the other reads.prefetchInfiniteQuery, notprefetchQuery: the readers are infinite queries and the cache entry has to be inInfiniteDatashape.flattenRunPages(data)- flatten the pages into a de-dupedRun[](dedupe by id guards the momentary double-row a head insertion can cause across two refetched pages).runsTotal(data)- the server's total from the first page.useAllRunsQuery()- Every run (all pages) as a flat list, for callers that need the whole set rather than a polled page (Settings' count + clear). Not polled.useLastDesignRunQuery(requestId)- The most recent completed design run for a request, in one filtered call (?requestId=&type=design&status=completed&limit=1) - the server sortsstart_time DESC, so its single row is the answer. No client-side download-and-filter. It caches a plainRunListResponse, so it has its own key family (queryKeys.runs.lastDesign(requestId)) and deliberately does not sit underruns.lists():RequestBuilderProvidermounts it for every open request tab, and the delete-run patch walks that prefix asInfiniteData. Keeping the two shapes apart at the root is the rule - a prefix patch must never meet a cache shape it did not write. Under thelastDesigns()prefix, like the three families below it, so a delete, a cleared history or an MCPrunevent reaches it without knowing which request it belonged to (#776): a run id gives no way back to one, and until the prefix existed a deleted design run went on being restored into the tab that had it open.useRecentDesignRunsQuery(requestId)- The lastRECENT_DESIGN_RUN_LIMIT(5) design runs of a request, newest first, behind the context bar's Recent sends section. One filtered call (?requestId=&type=design&limit=5) and no report fetch: each row carries its ownresultSummary(statusCode+latencyMs) from the engine, and the report path would load and JSON-parse every result'strace_data, per row. Deliberately unfiltered by status, unlikeuseLastDesignRunQuery-statustakes one value, so filtering tocompletedwould hide every failed send, which is most of what a trend is read for. Own key family (queryKeys.runs.recentDesign(requestId)) under therecentDesigns()prefix, outsideruns.lists(), for the same shape reason aslastDesign. Not polled: the builder's send path,DesignRunView's replay, the MCPrunevent,useDeleteRunMutationanduseInvalidateRuns(Settings' Clear run history) each invalidate it, and a new run-writing path has to as well or the section keeps showing the sends from before it.useLastCollectionRunQuery(collectionId)- The most recent run of one collection, behind the context bar's Last run section. One filtered call (?collectionId=&limit=1) over the engine'scollectionIdfilter, which matches a scenario snapshot's ownscenario.collectionIdas JSON rather than its text - the lookup that did not exist, which is why the section was deferred twice (#377 → PR #394 → PR #400 → #422). Unfiltered by status for the same reasonuseRecentDesignRunsQueryis: a failed run is the one worth surfacing. Own key family (queryKeys.runs.lastCollectionRun(collectionId)) under thelastCollectionRuns()prefix, outsideruns.lists(), for the third time and the same shape reason. Invalidated byuseStartScenarioRunMutation(the new run is the last run),useDeleteRunMutationanduseInvalidateRuns.useRunQuery(runId)/runDetailOptions(runId)- Fetch a single run (fullconfigSnapshot). Same 404 contract asrequestDetailOptions: only anApiErrorwithstatusCode === 404becomesRunNotFoundError(test it withisRunNotFound, never by message), and that error is never retried - a run tab is persisted and outlives its run, so a deleted run used to retry forever behind a "Try again" that could not work. Everything else - a 5xx, an unreachable engine - is rethrown untouched and keeps the default retry budget.HistoryDetailrenders the two cases differently: "this run no longer exists" with Close tab, versus "couldn't load this run" with Try again.-
useRunReportQuery(runId)- Fetch final report for a run. Also written byLoadTestServiceat stream end viaqueryClient.fetchQuery, so opening the run in History reads the cached copy rather than re-fetching a report that cannot change. -
useRunTimeSeriesQuery(runId)(@/queries/runs) - The stored per-tick series behind the History charts, as an infinite query over the same{data, pagination}envelope. Historical data cannot change, sostaleTime: Infinity.
Mutations:
- Stopping a run is not a mutation hook. It is
useEngine().stopLoadTest(runId), which calls apiService.stopRun and
returns a boolean rather than throwing - the dashboard's Stop button reports
the refusal itself.
- useDeleteRunMutation() - Delete a run. Patches every infinite-list cache
variant in place (drops the row, decrements the mirrored total) plus the
all-runs cache, and evicts the run's report. The list updater shape-guards
(!Array.isArray(old.pages)) as a belt to lastDesign's braces. The
recentDesigns() and lastCollectionRuns() families are invalidated rather
than patched: a deleted run gives no way back to the request or collection
their sections are keyed by, and refetching a few rows beats carrying a
run-to-owner map to patch them.
Deleting a run closes its tabs. Both delete flows - HistoryList's row
delete and Settings' Clear run history - call
closeTabsForEntities(ids, "run") for the runs the engine actually accepted (a
409'd run keeps its tab, because the run is still there). Tabs are persisted, so
a run tab left open after its run is gone is not merely stale: it rehydrates
into a pane that can never load on every restart.
Engine Health, Config & OAuth¶
useHealthQuery()- Health check, polled everyTIMING.HEALTH_CHECK_INTERVAL_MS; it is what setsengineStatus/engineErroronengine-store, and is where the starting-vs-unreachable decision gets made, so the connection indicator follows it. It reads that decision offengineStartWindow, whichuseEngineRestartalso opens - the restart supplies the evidence, the poll still does the classifyinguseConfigQuery()/useUpdateConfigMutation()- Engine configuration (QUERY_CACHE.CONFIG_STALE_TIME_MS); also the home of the live chart window, which is engine config rather than a renderer preferenceuseScriptCompletionsQuery()/useScriptTypeDefinitionsQuery()- thepm.*completion list and the engine-generated.d.tsbehind Monaco's hover text and diagnostics. Both derive from one engine-side table that changes only with the engine binary, so both takeQUERY_CACHE.SCRIPT_COMPLETIONS_STALE_TIME_MSand the same gc time; the type definitions also cap retries atSCRIPT_COMPLETIONS_RETRY, since losing them costs hover text rather than the editoruseOAuth2TokenStatusQuery(cacheKey),useFetchOAuth2TokenMutation(),useClearOAuth2TokenMutation()- the engine-side OAuth 2.0 token cachequeryKeys.compose.forRequest(requestId, environmentId)-POST /composefor a stored request, behind an inlineuseQueryin the context bar's Code section. Keyed by environment as well as request, because the same request composes differently per environment and one key for both would serve the wrong snippet after a switch.staleTime: Infinitywith an explicit recompose: the section is only mounted while expanded, so this is the "compose on expand, not per keystroke" rule. The section also ships collapsed by default (CONTEXT_BAR_DEFAULT_COLLAPSED), so that round trip now happens on first expansion rather than on every request tab opened (#1310).queryKeys.compose.allForRequest(requestId)is the per-request invalidation root, andforRequestis built from it rather than beside it so the prefix relationship holds by construction - that is the key an MCP write to one request drops (#1438), andstaleTime: Infinitydoes not stand in its way: it gates the cost of composing, not the correctness, becauseinvalidateQueriesrefetches a mounted observer whatever itsstaleTime
Query Keys & Cache Invalidation¶
Centralized in app/src/queries/keys.ts, using TanStack Query's hierarchical key
factory pattern. Copy the shapes from that file rather than hand-writing a
key - a key that differs by one segment is not an error, it is a silent cache
miss that presents as a data bug:
// app/src/queries/keys.ts (abridged)
collections: {
all: ["collections"], // a constant, not a call
lists: () => ["collections", "list"],
list: () => ["collections", "list"],
details: () => ["collections", "detail"],
detail: (id) => ["collections", "detail", id],
},
requests: {
all: ["requests"],
lists: () => ["requests", "list"],
listByCollection: (collectionId) => ["requests", "list", { collectionId }],
details: () => ["requests", "detail"],
detail: (id) => ["requests", "detail", id],
examples: (id) => ["requests", "detail", id, "examples"], // saved example responses (#481)
},
trash: {
all: ["trash"],
list: () => ["trash", "list"], // no per-entry detail cache - the list is the only read
},
runs: {
all: ["runs"],
lists: () => ["runs", "list"],
list: (filters = {}) => ["runs", "list", filters], // keyed by its server-side filters (q, baseline)
lastDesigns: () => ["runs", "lastDesign"], // prefix: invalidate every request's last run
lastDesign: (requestId) => ["runs", "lastDesign", requestId],
recentDesigns: () => ["runs", "recentDesign"], // prefix: invalidate every request's list
recentDesign: (requestId) => ["runs", "recentDesign", requestId],
lastCollectionRuns: () => ["runs", "lastCollectionRun"], // prefix: invalidate every collection's row
lastCollectionRun: (collectionId) => ["runs", "lastCollectionRun", collectionId],
baselines: () => ["runs", "baseline"], // prefix: invalidate every request's pin
baseline: (requestId) => ["runs", "baseline", requestId],
allRuns: () => ["runs", "allRuns"],
detail: (id) => ["runs", "detail", id],
report: (id) => ["runs", "report", id],
timeSeries: (id) => ["runs", "timeSeries", id],
monitorSeries: (id) => ["runs", "monitorSeries", id], // scraped server vitals, when the run had a monitor
samples: (id) => ["runs", "samples", id], // captured response bodies, fetched lazily
},
// environments mirrors collections; globals / cookies / health / config /
// scriptCompletions / scriptTypes / oauth / prefetch each own a root.
The lists() / details() levels exist to be invalidated as prefixes; what a
query is keyed on is list() / detail(id). requests.examples(id) sits
under detail(id) rather than beside it, because examples are owned by the
request: invalidating one request's subtree drops its examples with it, which is
what a request delete needs. runs.lastDesign,
runs.recentDesign, runs.lastCollectionRun and runs.baseline sit under
runs.all but deliberately not under runs.lists() - see below.
runs.baseline(requestId) caches the run pinned as that request's baseline
(GET /runs?baseline=true&requestId=&limit=1), which the history report's
vs-baseline strip reads. Pinning, unpinning and deleting a run all invalidate
the baselines() prefix rather than patching it: the pin moves, and a run id
gives no way back to the request whose baseline it was - a run of an unsaved
request has no requestId at all. The pin flag on the loaded list rows is
patched in place from the mutation's response, the same way a delete patches
them, because the sidebar polls only its first page and a refetch would leave a
pin invisible on any page the user had scrolled to.
specs.detail(id) and specs.meta(id) are the same document under two keys
(issue #712): the full read carries content and both extracted indexes, the
meta read describes the row and nothing else. They are kept apart rather than
merged so a cached description can never satisfy a reader that needs the text -
and holding one row twice is safe here precisely because a document is immutable,
which is also why both carry staleTime: Infinity. The Spec tab's card reads
meta; the import dialog's bound-spec match and the Sync section's Check read the
full document, each on the action that needs it. Export reads neither since it
moved engine-side (issue #855) - specs.export(collectionId, format, opened)
holds the finished document instead, keyed by format because the dialog toggles
between two serializations of one answer, and by the moment the dialog mounted
because a collection changes under its id in a way a document never does:
that is what makes it fresh on every open and cached across a toggle, which
neither staleTime alone can say. A key per format also means a switch is a
cache miss, so this is the one read that carries placeholderData:
keepPreviousData (issue #1311): the counts the dialog prints belong to the
collection rather than to the serialization, and are the same either way, so
the previous answer is the honest thing to hold while the next one assembles.
The dialog gates Copy and Download on isFetching for that window - the text
under them is still the previous format's - and shows a placeholder shaped like
its summary card on the first read, when there is nothing to keep.
specs.match(collectionId, fingerprint) is the third of that family and the one
that names no document (issue #761): the pairing of a collection's requests
against a picked document's operations, answered by POST /specs/match since
the matcher moved engine-side. There is no id to key it by - the document has
not been stored, because the tab asks before the user commits to the bind - so
the key carries a fingerprint of both inputs, exactly the fields the matcher
reads off them (a request's id, method and URL; an operation's identity). That
is what makes its staleTime: Infinity honest: an answer cannot go stale while
both inputs are pinned in its key, and either of them changing is a different
key rather than a stale entry under this one.
Automatic Invalidation:
- Mutations automatically invalidate related queries (e.g., creating a request invalidates the collection's request list)
- Some mutations use optimistic updates and cache updates for instant UI feedback
- A cascade delete invalidates coarsely, on purpose. Deleting a collection
deletes its descendant collections and all their requests engine-side, and
which rows those are is engine-side knowledge - a client that re-derives the
subtree to patch caches surgically will drift from the engine's definition of
"descendant". So useDeleteCollectionMutation invalidates
collections.all + requests.all wholesale. requests.detail entries carry
staleTime: Infinity, so without this a deleted request stays fresh forever
and keeps feeding restored tabs. useRestoreTrashMutation invalidates the
same two families for the same reason, in the other direction: which rows a
restore brought back is just as much engine-side knowledge - the cohort is
defined by a timestamp the client never sees - so it takes the same wholesale
invalidation rather than a client guess at which requests came back with a
restored collection.
- A single-request update invalidates narrowly, for the same reason. Which
lists a request write can affect is client-side knowledge: the request's own
collection, plus the one it left if the write was a move. So
useUpdateRequestMutation invalidates requests.listByCollection(id) per
affected collection rather than requests.lists(). It used to refetch every
collection's list on any rename, which a reorder (a run of sibling PUTs) turns
into one full-tree refetch per row. The source collection of a move is read
from the requests.detail cache, which still holds the pre-update row at that
point - the response carries only the new owner.
- The warm-cache prefetch is a query too. usePrefetchCollectionsAndRequests
is keyed as queryKeys.prefetch.allRequests() rather than an inline key, so
creating a collection can invalidate it - it succeeds once at startup and
would otherwise never re-run for a collection created mid-session.
- ...but warming a polled list must not be a query. usePrefetchRuns is a
one-shot prefetchInfiniteQuery on mount, not an observer, because
useRunsQuery carries a refetchInterval and observing it from the root
bought the warm cache at the price of a poll that never stopped (#1150). An
invalidation of runs.lists() then marks the warm entry stale without
refetching it, which is the wanted behaviour: nothing is displaying it.
Writes from outside the renderer: mcp:data-changed. An MCP tool call
mutates the engine from the Electron main process, so no mutation runs here
and no query notices - and with refetchOnWindowFocus: false (below) nothing
ever catches up on its own. A request an agent created stayed invisible in the
collection tree until some unrelated renderer mutation happened to invalidate
the lists. The main process now sends one event per data family a successful
call touched; useMcpDataInvalidation() (registered once, in App.tsx) maps it
to keys through lib/mcp-invalidation.ts:
| Entity | Invalidates | Why that key |
|---|---|---|
collection |
collections.all, requests.all, trash.all, compose.all, prefetch.allRequests() |
A delete_collection cascades through descendants and their requests, and which rows those were is engine-side knowledge - the same reason useDeleteCollectionMutation invalidates coarsely. trash.all because a delete is what adds a row the drawer lists and a restore or purge is what takes one away; compose.all because a collection write moves the chain every descendant inherits auth, headers and scripts from, and this event carries no way to name them; prefetch.allRequests() because the warm-cache pass succeeds once and would stay fresh forever, so a collection created mid-session never gets one - the same invalidation useCreateCollectionMutation and useRestoreTrashMutation do (#1438) |
request |
requests.listByCollection(collectionId), or requests.lists() when the call named no collection, trash.all, plus requests.detail(requestId) and compose.allForRequest(requestId) when the call named one row |
The same narrowing useUpdateRequestMutation does; without a named owner the owner is unknowable here. The detail key is for update_request / delete_request: it is staleTime: Infinity, so a restored tab would otherwise keep serving the copy it read on open. trash.all because delete_request declares this family alone and stamps a row the drawer lists. The compose key is the per-request prefix, not compose.all: every tool that can change a composition either names the row it wrote or declares collection too, create_request composes nothing yet, and invalidateQueries refetches a mounted observer whatever its staleTime - so the wide key would re-compose an open snippet once per write in an agent's loop over rows it never touched. prefetch.allRequests() is deliberately not here for the same reason: it fans out one list fetch per collection, and this is the family an agent loops over row by row (#1438) |
environment |
environments.all, globals.all, compose.all |
Variables are read through the detail cache as well as the list; POST /compose substitutes those same variables, and nothing refetches a composition on its own. globals.all rides along because update_globals (#758) declares this family - same resolution order, same blob shape, and an entity of its own would have had exactly one reader |
run |
runs.lists(), runs.allRuns(), runs.baselines(), runs.recentDesigns(), runs.lastCollectionRuns(), runs.lastDesigns() - and a removal of runs.detail/report/samples/timeSeries/monitorSeries for a named runId |
The history list polls, but Settings' count, the vs-baseline strip, Recent sends, Last run and every open tab's last design run do not. The four prefixes rather than per-row keys because a run id gives no way back to the request or collection it belonged to - the same trade useDeleteRunMutation makes |
cookie |
cookies.all |
One key for every jar - the engine reports them together |
config |
config.all, requestDefaults.all |
requestDefaults is a second endpoint over the same entries rather than a slice of the first: four of them (negotiateCompression, loadNegotiateCompression, correlationIdEnabled, correlationIdHeader) decide what a send adds on its own and the engine resolves the answer, so the declared set is re-read rather than derived here - which is why useUpdateConfigMutation drops both keys. At the root, because design and load are separate cache entries and one write can move both (#1438) |
service |
inbox.list(), mockServer.list(), mockIssuer.list(), plus a removal of inbox.captures(inboxId) for a named inbox and of mockServer.routes(mockId) for a named mock |
The drawer and the Dock's count poll, so the lists are about immediacy; the captures cannot be invalidated, because useInboxCapturesQuery merges its fetched page into the cache and would union back the rows a clear_inbox_captures just destroyed, and a stopped mock's route table has no id left to refetch from |
oauth |
oauth.all |
The whole prefix, not the one key: useOAuth2TokenStatusQuery is keyed per cache key, and the key a fetch_oauth2_token writes under is derived engine-side and appears only in the answer, so the event carries no hint to narrow by. The query polls at 30s on its own, so this is immediacy - an agent that clears a token must not leave the row saying it is valid |
The event carries no engine data, only which family went stale, so a row still
reaches the UI by exactly one path: the query layer. Per-run reports and time
series are still never invalidated wholesale - a new run cannot have changed
an existing run's report, and those are the expensive fetches in the family.
They are dropped only for the one run a call named: stop_run,
set_run_baseline and delete_run each take a runId, and the event carries
it as a third scope hint. Removal rather than invalidation, because samples
and both series are staleTime: Infinity - a deleted run would otherwise go on
rendering under an open History tab until its entry was garbage collected.
runs.detail goes with them so the pane refetches, takes its 404 and shows
HistoryDetail's "This run no longer exists" state instead of a run the sidebar
no longer lists. The hint says which run changed, not how, so a set_run_baseline
costs an open detail pane one refetch of data it already had; a stale answer is
a lie and a refetch is a wait. runs.lastDesigns() is taken at its prefix and
never per request (#776): delete_run and set_run_baseline name a runId and
no request, so the narrow key could not reach the tab whose run went away, and
the cost of the prefix is that a run_request refetches one filtered row per
mounted tab instead of one. The service family (issues #756, #757) takes the
same shape one level down, with two scope hints instead of one. inboxId: a
named inbox has its capture list removed rather than invalidated for a reason
the run family does not have - three writers share that one cache entry (the
fetch, the load-more pages and the live SSE stream), so every write to it is a
union by capture id, and a refetch into a cache still holding cleared rows puts
them straight back. The app's own clear mutation writes an empty page first for
exactly this; from the main process the equivalent is to drop the entry.
mockId: a named mock has its route table removed for the mirror reason - the
table is a start-time snapshot held at staleTime: Infinity, so an invalidation
would not refetch it, and after a stop_mock_server there is nothing to refetch
from, since a mock's record dies with its listener. Both are what
useDeleteInboxMutation and useStopMockServerMutation already do app-side.
All three lists are invalidated on every service event rather than one per
kind, because the entity is deliberately one family: the drawer and the Dock's
count ask "what is listening", not "which kind".
One field on that event is not about the cache at all. startedRun rides
the run event of start_load_run and run_collection and names the run those
tools just created, plus which of the two run services owns its stream
({runId, kind}, issue #1419). useRunWatchers() - registered once in
App.tsx, beside useMcpDataInvalidation() - reads it and calls
loadTestService.startMonitoring or scenarioRunService.startMonitoring, which
is the same path the dashboard and the Run Collection dialog take, so the wake
lock, the OS progress bar and the finished notification all begin when an
agent's run does rather than when someone opens its tab. For a load run it
points dashboard-store at the run first, exactly as those surfaces do:
startMonitoring states that its caller has registered the run, and a store
left unpointed would collect the ticks of a run it does not name. It carries no
config - the agent's arguments are the engine's business, and a run with no
declared duration is one whose bar has no denominator yet. Every other run
event names a run that already exists (a stop, a baseline change, a delete) and
starts no watcher; run_collection_smoke sends its requests one at a time and
has no run to watch at all.
The entity list is duplicated across the process boundary
(MCP_DATA_ENTITIES in electron/mcp/tools.ts, McpDataEntity in
types/domain.ts) because production code under electron/ cannot import from
app/src; data-changed.conformance.test.ts is what keeps the copies equal,
and the map above is a Record over the union so a new family fails to compile
until it names a reader. The emitting side is documented in
docs/engine/mcp.md.
Retry policy: the shared default is shouldRetryQuery (lib/query-client.ts),
not a bare count. A 4xx from the engine is a verdict, not a hiccup - a 404 for a
deleted row answers identically every time, so retrying it only delays the error
the caller is waiting on. 4xx is never retried; everything else (5xx, timeout,
unreachable engine - which http-client.ts throws as a plain Error, not an
ApiError) keeps the DEFAULT_QUERY_RETRY budget.
Cache policy lives in config/cache.ts (QUERY_CACHE), not in the call
sites. Name the constant when you need a duration; restating the number here
is how this section drifted before. The current shape:
| Query | Policy |
|---|---|
| Collections, requests lists, environments, globals, runs list | DEFAULT_STALE_TIME_MS (30s) via the shared client |
Request detail (requestDetailOptions) |
staleTime: Infinity - a restored tab reads it once and mutations invalidate it |
| Run detail, run report | RUNS_STALE_TIME_MS (5m); completed runs are immutable |
| Run time series | staleTime: Infinity, RUNS_GC_TIME_MS (30m) |
| Engine config | CONFIG_STALE_TIME_MS (1m) |
| Script completions | SCRIPT_COMPLETIONS_STALE_TIME_MS (1h), same gc time |
| Health | staleTime: 0, refetched every TIMING.HEALTH_CHECK_INTERVAL_MS (30s) |
Polling intervals are separate from staleness: the runs list polls every 5s
only while unpaged (runsPollInterval), health polls on
TIMING.HEALTH_CHECK_INTERVAL_MS, and the shared client sets
refetchOnWindowFocus: false - a desktop app that refetched everything on focus
would fight the editor the user just came back to.
Custom Hooks¶
useEngine() - Compose, Execute, Stop¶
Wraps the three engine calls that are not queries, with isExecuting / error
for the caller to render.
API:
const {
composeRequest: (params: ComposeRequestRequest) => Promise<ComposedRequest>
executeRequest: (params: ExecuteRequestRequest, environmentId?: string) => Promise<SanityResult | null>
stopLoadTest: (runId: string) => Promise<boolean>
isExecuting: boolean
error: string | null
} = useEngine();
composeRequestisPOST /compose- the engine resolves{{variables}}andinheritauth and hands back the execute-ready payload (issue #226). It throws on failure; the caller surfaces it like an execute failure.executeRequesttakes an already composed payload. It never throws: a failure comes back as aSanityResultwithstatus: 0and anerrorCode, because the response pane renders the failure the same way it renders a response.stopLoadTestreturnsfalserather than throwing when the engine refuses.- Starting a load test is not here. The request builder composes, then calls
apiService.startLoadTest()(POST /runs),useDashboardStore().startRun()andloadTestService.startMonitoring()in that order, at one call site - registering the run before attaching the stream is what keeps the dashboard from showing a stream with no run behind it.
Live Metrics Streaming - loadTestService + sseClient¶
There is no React hook for the metrics stream, on purpose. The stream has to
outlive whichever view is mounted (navigate away from the dashboard mid-run and
the run keeps streaming), so it is a module singleton:
services/load-test-service.ts holds the state machine and
services/sse-client.ts the EventSource.
import { loadTestService } from "@/services/load-test-service";
loadTestService.startMonitoring(runId); // after useDashboardStore().startRun(...)
loadTestService.stopMonitoring();
- Connects to
GET /runs/:runId/live, a replayable tick topic read from offset 0 - so attaching, or re-attaching from History mid-run, rebuilds the charts rather than starting blank. - Every tick is buffered; commits into
useDashboardStore().addMetricsBatch()are throttled toMETRICS_UI_THROTTLE_MSsohistoricalMetricskeeps the full 10 Hz signal while renders stay bounded. The buffer, the leading edge and the trailing timer areservices/throttled-batcher.ts, shared withScenarioRunService(issue #1206) and withuseExecutionEvents(issue #1158) - a monitor scrape has no timer of its own and rides the next tick's flush, which is why that second buffer stays in the service rather than moving into the batcher. - The engine sends an explicit
completeevent, so aCLOSEDreadyState is a genuine failure. There is no custom reconnect:EventSourcecannot setLast-Event-IDon a fresh connection, so a manual reconnect would replay the whole topic and duplicate every tick already plotted. The browser's own intra-connection retry does carry it and is left alone; once the browser gives up, recovery is converging onGET /runs/:id/report, which the service does in its close handler.
useVariableResolver() - Variable Resolution¶
Resolves {{variableName}} patterns in strings and objects using environment, collection, and global variables.
API:
const {
resolveString: (input: string) => string
resolveObject: <T>(obj: T) => T
getVariable: (name: string) => ResolvedVariable | null
getAllVariables: () => Record<string, ResolvedVariable>
getVariableOrigins: (name: string) => VariableOrigin[]
} = useVariableResolver({ collectionId?: string; boundRow?: DataFileRow });
// resolveString takes an optional per-call row (issue #1074), for the one
// caller that resolves for several requests at once - the tab strip.
resolveString(input: string, row?: DataFileRow): string
Resolution Priority (highest to lowest):
1. Bound data row (bare column names) - only while boundRow is passed and a
row is actually picked (issue #1062)
2. Environment variables
3. Collection variables
4. Global variables
Collection scope comes from the collectionId option and nowhere else - a
caller that passes none resolves against globals + environment. See
docs/app/variable-resolution.md for why the session-store fallback was
removed.
boundRow is optional and additive: without it resolveString /
resolveObject resolve exactly as before (composition only, both a bound
column and {{data.column}} deferred). With it, they resolve through
resolveTemplateWithRow instead, so a bare bound column and {{data.column}}
both read the row - the request builder's provider is the one caller that
passes it, deriving it from the picked-row index. See
docs/app/variable-resolution.md for the tier and its rules.
resolveString's second argument is the same row named per call instead of per
hook. It exists for useTabDescriptors, which labels every open tab from a
single resolver and therefore cannot name one row for the whole of it; it reads
the row out of useBoundRowStore for the tab it is labelling. Both spellings
render a row's cells through the same helper, so the two cannot disagree.
The environment is the one scope no option names. It is not passed in: the
hook reads useSessionStore().activeEnvironmentId itself, so every caller
resolves against the environment the user has actually selected and no caller
can scope a preview to a different one. This page documented an
environmentId option for a while; nothing has ever accepted one. The two
scopes are asymmetric on purpose - the active environment is app-wide state,
while the collection is a property of whatever the caller is rendering.
ResolvedVariable carries sourceId / sourceName - the specific environment
or collection the winning value came from (absent for global).
getVariableOrigins returns every definition of a name, lowest precedence
first, including disabled ones that never resolve. Display-only; the variable
popover renders it as "also defined". Since issue #1064 it also carries the
bound row on top when one answers the name, taking winner from whichever
scope it beat. See docs/app/variable-resolution.md for why the losers are
kept and why the MCP copy is not given the same accessor.
RequestBuilderContext - variable members¶
The request builder re-exposes the resolver plus two things only it can derive:
getVariableOrigins: (name: string) => VariableOrigin[]
updateVariable: (name: string, value: string, scope: VariableScope) => void
writableScopes: VariableScope[]
writableScopes lists the scopes updateVariable would actually write to.
Each of its branches opens with a guard (if (!activeEnvironmentId) return), so
a write to a scope with no active target is a silent no-op - the variable
popover uses this list so its "create in" picker cannot offer one.
A write through updateVariable always sets enabled: true. Setting a value
means "make this value apply"; enabling and disabling belongs to the variables
editor. Without it, creating a value for a name that was disabled everywhere
preserved enabled: false and the token stayed unresolved.
Usage:
const { resolveString } = useVariableResolver({ collectionId });
const resolvedUrl = resolveString("https://{{baseUrl}}/api/users");
RequestBuilderContext - body drafts¶
What the body modes you are not looking at were holding. A request stores
one body - the shape is a discriminated union, {"mode":"json","content":
"..."} - so JSON, text and GraphQL share request.body, and switching mode
handed the same string to a different reader. Switching from JSON to GraphQL
therefore read the payload as a raw query string and destroyed it.
Two buckets, not eight: json, text, jsonrpc and xml are one raw string
differing only in highlighting, so text carries between them deliberately;
graphql is an envelope this side parses into two panes and keeps its own; the
two form modes use formData / urlEncoded and never touch body. jsonrpc
and xml sit in the raw bucket because nothing here reads their text as a
structure - JSON-RPC's frame is completed engine-side at wire time and an XML
document is sent byte for byte, so each pane holds one plain document. The rule lives in
modules/request-builder/utils/body-drafts.ts.
Two things about it are deliberate and easy to undo by accident:
- It lives in the provider, not in
BodyPanel. Radix unmounts an inactiveTabsContent, so a panel-local ref is discarded the moment you glance at the Headers tab, taking the stashed body with it. - The provider does not reset it on a request change. The drafts carry
their own
requestIdandswitchBodydrops any belonging to another request, so a second reset would duplicate that rule - and would fire on the request-change effect, which re-runs on more than the id.
Deliberately not persisted: a request has one body, and storing payloads it will never send would put them in exports and in the engine's schema.
RequestBuilderContext - the GraphQL Variables draft¶
The Variables pane's raw text, for the same reason and with the same lifetime.
The pane is a JSON editor over one key of the GraphQL envelope, and the
envelope cannot always hold what it shows: text that is neither JSON nor a
resolvable {{template}} is dropped by serializeGraphQLBody, deliberately, so
that the query pane keeps saving while the variables pane has an unclosed brace.
That makes the pane's own text the only copy - and GraphQLBody's component
state the wrong place for it, since the Radix unmount discards a half-typed
variables object exactly as it once discarded a stashed JSON body.
It is beside the mode drafts rather than inside them because it is not a mode's
body (GraphQL's body is already in the graphql bucket). It carries its own
requestId for the drafts' reason, and ownVariablesDraft - not a second reset
in the provider - is what drops one belonging to another request.
RequestBuilderContext - the headers a setting added¶
Two settings each own a header row: the body mode's Content-Type (written by
BodyPanel) and the Event stream toggle's Accept: text/event-stream (written
by SettingsPanel, issue #574). GraphQL is sent as a JSON envelope and
genuinely needs Content-Type: application/json, so BodyPanel appends one -
but nothing removed it, so a single visit to GraphQL left the header on the
request for good, including after switching back to none, which sends no
body at all.
Ownership of the row used to be a context-level record, { requestId, rowId,
value }, held in a ref the provider owned. That could not survive a reload: a
fresh mount started with an empty ref, so a stale auto-written row was then
indistinguishable from one the user typed, and neither withoutContentType
(nothing to remove) nor a later mode switch (an "existing" row, so nothing to
add) could tell the difference (issue #1481). Ownership now lives on the row
itself - KeyValueEntry.source?: HeaderRowSource, whose "body-mode" | "stream"
half (AutoHeaderSource) is what an app setting writes; the third value,
"legacy-default", is the engine's own header-strip repair pass marking a row
it disabled rather than deleted (issue #1491, docs/engine/db-schema.md's
"header-strip pass" section) and is never written from this side. There is no
context accessor for either app setting any more; switchAutoHeader in
modules/request-builder/utils/auto-header.ts reads and writes the marker
directly on the headers array it is handed, once per change: it removes the
marked row when the new setting does not need that same header, then adds
whatever the new setting does need - both halves in one pass over one array,
because two updateField("headers", …) calls would compute the second against
the array they had before the first. panels/body/content-type.ts is now just
the body-mode half: which Content-Type a mode requires, plus the delegation.
Two things it is deliberate about:
- By marker, not by value. A
Content-Typethe user typed and the row a setting wrote are identical apart from the marker, and only a marked row may be removed.KeyValueEditor'shandleUpdateclears the marker the moment a user retypes a row's key or value - once retyped, it is theirs, whatever the new value happens to be. Merely disabling a marked row does not clear it: the row is not sent while disabled, so there is nothing to have adopted. - A row already declaring the header - even a different value - is never overridden. Silently replacing it would be a worse version of the bug this exists to fix. A disabled declaration does not count: it is not sent, so the header the mode needs is still missing.
The setting that used to be the one slot left, AutoMethod, is not a header
row at all (issue #1228). A new request is a GET, and GraphQL over GET is a
different transport - the document travels as query parameters and a mutation
cannot be sent that way - so picking the GraphQL body mode on a request still
holding that default used to build one the server answered with a bare 400.
The mode now sets POST the same reversible way it sets the Content-Type
header above, and leaving the mode puts the method back. It has the same
reload gap issue #1481 fixed for the two header rows, closed the same way by
issue #1505: ownership lives on RequestState.methodSource?: MethodSource
("graphql", the domain type mirroring HeaderRowSource), a value on the
request itself rather than a ref-held record, so it is exactly as durable as
request.method beside it. switchGraphQLMethod in
panels/body/graphql-method.ts is the rule that reads and writes it, called
from BodyPanel.handleModeChange alongside switchAutoHeader, and it keeps
the same two guarantees stated as bullets above, read against a scalar field
instead of a row: a method the user has since chosen - methodSource no
longer "graphql" - is no longer this side effect's to revert, and
MethodSelector's onValueChange clears the marker in the same call that
sets the hand-picked method, the exact moment KeyValueEditor's handleUpdate
clears a header row's source on a retype. A GET still reaches GraphQL
through the door this side effect does not touch - the user picks GET back, or
an import wrote one - which is when the Query pane header's BadgeText names
the transport that will be used.
Which identity a builder files under is one value, resolved once (issue
1272): memoryKey ?? request.id ?? UNSAVED_AUTO_KEY, read by every¶
per-builder map the provider still holds - now only the Send-with-row picker's
row memory below, since the three reversible-setting records above all moved
onto the request's own state. A request tab is its request and passes
nothing. The editable copy History renders for a stored run passes the run
id, because design-run-seed.ts gives that copy id: null on purpose - a
null id is one of the two gates that stop an edited copy from rewriting the
saved request - so every open run copy used to share the id-less bucket
before issue #1272. That was not a near miss: the rules read a requestId of
null against another null as a match, so the second run tab's picked row
was handed the first one's record and read as its own. A prop rather than a
field on RequestState that only History would set: request.id stays
honestly null, and no request grows a "which run am I" field for one caller.
A run id names a tab, so that memory is bounded by the same sweep rather than
exempt from it.
UNSAVED_AUTO_KEY is what remains for a builder declaring neither - nothing in
the app today - and it is the one key the sweep cannot drop, since a key naming
no tab cannot be bounded by the tabs. It is kept rather than removed because the
alternative for such a builder is sharing whichever key was written last, which
the null === null ownership check hands out as owned rather than refusing.
The open tabs bound the Send-with-row picker's memory (issue #1271), the
provider's one remaining per-request map: which row index each request was
last sent with (rowIndexByRequest, issues #659 and #1062). It survives a tab
switch and a return, because neither of those is a send - and it goes when the
tab does, since the request it was picked for can no longer be switched to.
tabs-store is subscribed to rather than selected from, because a provider
that re-rendered on every tab focus would charge the request the user is
working in for it. MAX_OPEN_TABS caps the tab strip, so it caps the map, and
both request tabs and run tabs are swept, by their entityId - a request id
and a run id respectively, from different tables and so unable to collide. A
run tab holding no builder at all (a load test, a scenario) only ever keeps a
key nothing wrote.
The row memory is useState rather than a ref - lastRowIndex is read while
rendering - so its half of the sweep is a setState, and retainKeys
(context/retain-keys.ts) returns the map it was handed when every key is still
live. Without that identity guard the provider would re-render on every tab
focus, which is exactly the cost the subscription was chosen to avoid.
The two header markers and methodSource need none of this: what is
persisted is the row itself, in request.headers, or the method's own marker,
in request.methodSource - no provider-side map, no sweep, nothing a reload
can lose that a save does not already carry.
useSaveManager() - Auto-Save Manager¶
Orchestrates auto-save for a saveable entity (request, environment, etc.) with debouncing, context registration, and centralized save state tracking. Located in app/src/hooks/useSaveManager.ts.
API:
const {
forceSave: () => Promise<void>
status: "idle" | "pending" | "saving" | "saved" | "error"
isSaving: boolean
} = useSaveManager({
entityId: string | null // Unique ID for this entity
contextName?: string // Display name (e.g., "Request: GET /api")
onSave: () => Promise<void> // Function to persist changes
hasChanges: boolean // Whether unsaved changes exist
changeToken: number // Bumped by the caller on every edit
enabled?: boolean // Disable auto-save (default: true)
});
Features:
- Debounced auto-save: Triggers after the delay the user chose in Settings → General, defaulting to 5000ms (autoSave.delayMs in client-settings-store, options in constants/client-settings.ts)
- Context registration: Automatically registers with useSaveStore() for app-wide Ctrl/Cmd+S integration and tab LRU coordination
- Save status: Updates centralized save store so UI can show "Saving..." or "Saved" indicators
- Entity switching: Flushes pending saves when entity ID changes (in cleanup, before unmounting)
- Saves are queued, never skipped: a caller arriving while another save is in
flight waits behind it and gets its own write, so its promise resolves only
once its edits are persisted. Skipping (the old behaviour) meant Cmd/Ctrl+S,
the quit flush and the entity-switch flush all reported "saved" for edits that
were never in the flying snapshot - and the switch then reset the provider,
making them unrecoverable. performSave binds onSaveRef.current at call
time rather than when the queue reaches it, so a save queued by the
entity-switch cleanup still writes the entity being left
- No debounceMs parameter: the delay is a user preference, not a per-caller
one, so the hook reads it from the store rather than taking it as an option.
Turning auto-save off in Settings leaves the entity marked dirty - Ctrl/Cmd+S
still saves it - but schedules nothing.
- The delay runs from the last edit, and changeToken is what makes it a
debounce (#1381). hasChanges cannot carry that on its own: it is already
true by the second keystroke, so an effect keyed on it does not re-run and
the timer armed by the first keystroke runs to term. A save then went out
five seconds into every burst carrying a half-typed script. The token changes
on every edit, so each one tears the pending timer down and arms a fresh one
- A returning save reports "Saved" only for the edits it carried (#1381).
The saver and the token are bound together when performSave is called;
if the token has moved by the time the write lands, an edit arrived that the
payload does not hold, and the status goes back to pending rather than
flashing "Saved" over it. The caller is what re-arms the save - this only
refuses to mislabel it
app/src/config/timing.ts used to carry an AUTO_SAVE_DELAY_MS: 3000 that
nothing read and that this section documented as the source of truth. It is
deleted; timing-keys-have-readers.test.ts now fails on any TIMING key without
a reader, and useSaveManager.autosave-setting.test.tsx pins the Settings value
to the timer that actually runs.
Usage:
const { forceSave, status, isSaving } = useSaveManager({
entityId: requestId,
contextName: `Request: ${request.method} ${request.url}`,
onSave: () => apiService.updateRequest(requestId, changes),
hasChanges: JSON.stringify(draft) !== JSON.stringify(saved),
enabled: true
});
useEntityDraft() - Manual Draft/Save Model¶
The other save model, and the counterpart to useSaveManager(): an editable draft, a Save button gated on isDirty, and a Reset that discards it. Located in app/src/hooks/useEntityDraft.ts. CollectionDetail's AuthTab and ElementsTab (issue #1512) are its button users.
InfoTab uses the hook without the button: it wants the draft, the resync and the mutation reset, but commits when focus leaves the field, like the request builder. reset therefore has fewer callers than draft has.
Why auth - and now Elements - keep the button (#446, #1512). Not because a credential or an element list outranks a plain field - the request builder autosaves its own auth through useSaveManager, and used to autosave its scripts the same way InfoTab still does. Because a blur is not a completion signal once a tab is more than one focus stop: an OAuth 2.0 config with Advanced open renders 20 focus stops, 9 of them non-value controls, and clicking reveal to check a half-typed password fires focusout while the draft is dirty - so a blur-commit would write half a credential to the record every descendant request inherits from. The old ScriptTab had exactly one focus stop (its Monaco editor), so leaving it meant what leaving a description means - but ElementsTab's ElementList is adding a kind, naming a row, toggling it, reordering it and editing its form, each a separate stop, and a script.* element's own editor inside the list would fire the identical half-typed-value blur. The fields only make sense saved together, which is what the button means on both tabs now. Should collection auth or elements ever persist by themselves, the mechanism is the debounce, not a blur - a different change from this one. Both tabs state their save model above the fields, since they are the ones on that screen that differ.
API:
const {
draft: T // The editable copy
setDraft: Dispatch<SetStateAction<T>> // Standard setState signature
isDirty: boolean // Draft differs from the persisted value
reset: () => void // Discard the draft, adopting the persisted value
baseline: T // The value the draft last agreed with the server on
externalValue: T | null // A persisted change pending while the draft is dirty
} = useEntityDraft<T>({
entityKey: string // Identity of the thing being edited
value: T // The persisted value
mutation: { reset: () => void } // The save mutation this editor reports through
});
Behaviour:
- Seeds and resyncs while clean: a clean draft follows value when it changes - a save landing, a background refetch. In InfoTab this is what clears the post-trim divergence, since the tab persists name.trim(). The request builder needs the same property for the same reason and gets it a different way, since its state is not a draft: see the draft adopts an external write per field.
- A dirty draft is never silently overwritten (#1437). While the draft disagrees with baseline, an incoming change to value is held in externalValue instead of reseeding draft - an MCP update_collection landing mid-edit used to replace the user's unsaved text with whatever the agent wrote. ElementsTab and AuthTab treat externalValue as one value and show a "Changed elsewhere" Callout (CollectionDetail/shared.tsx's ExternalChangeCallout) whose action calls reset() to take it. InfoTab's draft has two independent fields, so it diffs draft and externalValue against baseline per key instead: a key the user has not touched adopts the external value immediately, the same as a clean tab; a key both sides touched surfaces its own conflict, named, and is left at the user's edit until they choose.
- Tracks by JSON value, not identity: value may be a fresh object literal every render (InfoTab builds { name, description } inline); callers do not have to memoize it.
- entityKey is a switch, not an edit: a change reseeds the draft and calls mutation.reset(), discarding any pending externalValue too. These editors render without a React key, so a different entity arrives via props on the same instance, and a TanStack mutation holds isError until the next mutate - without the reset, a failed save is reported against an entity the user never tried to save. ElementsTab passes ${collection.id}:elements, one entity key for the whole element list under one collection id (the retired ScriptTab passed ${collection.id}:${fieldKey}, since pre- and post-request scripts were two separate things to edit).
- Requiring the mutation is the point: the three hand-rolled copies this replaced had drifted, and the one that omitted the reset had exactly that bug.
Usage:
const { draft, setDraft, isDirty, reset } = useEntityDraft({
entityKey: collection.id,
value: collection.auth,
mutation: updateCollection,
});
The draft lives in component state, so its panel must not unmount. Radix
unmounts an inactive TabsContent; CollectionDetail therefore force-mounts the
four draft-holding tabs (Info, Auth, Pre-request, Post-request) from their first
visit onwards, so an intra-collection tab switch stops discarding the draft.
This is the same call, for the same reason, as the request builder's body drafts
living in RequestBuilderProvider rather than in BodyPanel (see
request-builder/utils/body-drafts.ts). The Variables tab is deliberately not
force-mounted: it autosaves and claims the store's active context on mount, so
keeping it alive behind another tab would point Ctrl/Cmd+S at the wrong editor.
A collection switch still reseeds and discards, deliberately - it is the
hook's documented behaviour above, and pinned by useEntityDraft.test.ts.
useDraftSaveContext() - Registering a Manual Draft¶
The counterpart to useSaveManager's registration half, for editors using the
useEntityDraft model. Located in app/src/hooks/useDraftSaveContext.ts. Used
by InfoTab, AuthTab and ElementsTab.
useDraftSaveContext({
id: `collection-${collection.id}-auth`, // Unique per editor
name: `Collection auth: ${collection.name}`,
isDirty, // From useEntityDraft
isActive: active, // Is this the tab on screen?
save: persist, // Rejecting is how failure is reported
});
Behaviour:
- Registration only. It schedules nothing; each editor decides when to call
its own save - AuthTab and ElementsTab on their Save button, InfoTab
when focus leaves the field. The defect it fixes is orthogonal to that
choice: the other ways to save - Ctrl/Cmd+S, the quit flush, tab eviction -
could not reach these editors at all, because none of the three tabs ever
called registerContext.
- isActive decides who owns Ctrl/Cmd+S. triggerSave prefers the active
context, and these editors stay mounted while hidden, so without it the last
sibling to mount would answer for the panel on screen.
- A failure toasts rather than resolving quietly. The editors render an
inline SaveFailed callout for a button press, but a quit flush has no callout
on screen, and runSave reads a resolved promise as success - swallowing here
would report "Saved" for a write that failed.
- The save must carry its own validity guard. A disabled button does not stop
the store-driven paths, which is why InfoTab refuses a blank collection name
inside persist. With its buttons gone that guard is now the only one, and it
is silent by itself - the tab pairs it with reportBlankNameRefused()
(lib/blank-name.ts), which restores the stored name and reports through
failSave.
- isDirty also drives the Dock's "Unsaved changes" line (#1483). The
registry's own hasPendingChanges is not what the Dock reads - only the
store-wide status is - so the hook calls markPendingSave() the moment
isDirty turns true and completeSaveThenIdle(id) the moment it turns false
again, mirroring useSaveManager's autosave editors. Edge-triggered, and
reset rather than fired across an id change, so switching the entity a
mounted editor describes never reports the previous entity's save as this
one's.
The request builder's draft adopts an external write per field¶
RequestBuilderProvider resets its whole draft when the request id
changes, and neither a rename, an MCP agent's update_request, nor a move to
another collection changes the id. Before issue #1436, only name adopted a
change that arrived this way - every other field held a snapshot taken when the
tab opened, so a debounced auto-save could write that snapshot back over
whatever an agent had just changed, a delete under a dirty tab flushed a doomed
PUT into a 404, and a move discarded the whole draft along with the id-based
reset it was routed through.
The fix generalizes the name's adoption into a per-field three-way merge, the
same shape useEntityDraft (below) already uses for the collection tabs:
baselineis the value the draft last agreed with the server on - not necessarilyrequest, which is whatever the user has typed, and not necessarily what a stray refetch just reported either, if that report conflicts with an edit (see below).touchedFieldsis which top-levelRequestStatekeyssetRequest/updateFieldhave written since that baseline. A successful save clears exactly the fields it sent;takeExternalField(below) clears one.- On every
initialRequestchange (the request query's copy, gated on its object identity so the diff runs once per refetch and not once per keystroke),mergeExternalWrite(app/src/lib/field-merge.ts) diffsinitialRequestagainstbaselineover every mergeable field (MergeableRequestField- everyRequestStatekey exceptid,collectionIdanddisabledDefaultHeaders, which have their own rules below): - a field the fetch did not change is left alone;
- a field the fetch changed and the user has not touched adopts the new
value into the draft, via
setRequestState(neversetRequest- adopting someone else's write is not an edit of ours, and marking it dirty would schedule a save that writes back what was just read) andbaselinemoves to match; - a field the fetch changed and the user has touched, to a different
value than the fetch reports, is a conflict: the draft keeps the user's
value,
baselineis deliberately left where it was (so the conflict keeps being detected until it is resolved), and the incoming value is exposed onfieldConflicts[field]; - a touched field the fetch now agrees with (the save's own echo, or a
coincidence) resolves quietly -
baselinemoves, nothing is shown.mergeExternalWritecompares by JSON content, not by reference:headers,paramsandauthare rebuilt into fresh arrays/objects on every fetch regardless of content, and===there would read every refetch as an external change - the same reasonuseEntityDraftcompares byJSON.stringify. fieldConflictsandtakeExternalField(field)are onRequestBuilderContextValue.ExternalChangeNotice(request-builder/components/) renders oneExternalChangeCalloutper logical group - the four fields the editor splits a request's body across (bodyMode/body/formData/urlEncoded) count as one "body" group, so a body conflict does not paint four callouts - and its "Take theirs" resolves every field in the group.collectionIdis not a mergeable field: acollectionId-only change with the same id (themove_itemcase) updates just that field in place, leaving the rest of a dirty draft untouched, in a dedicated branch beside the id-keyed full reset.- A request deleted elsewhere stops
index.tsxhanding the provider aninitialRequestat all, while keepingRequestBuilderProvidermounted (see below) so the draft is not lost. The provider noticesinitialRequestwent missing whilerequest.idis still set and flipsrequestGone, which feedsuseSaveManager'senabled- so a dirty draft's autosave timer, already armed or about to be, never fires a PUT against an id that now 404s. - The generation
handleSavechecks (changeTokenRef, see the save-manager walkthrough below) bumps on a foreign write too, not only on the user's own edits - an effect keyed onbaseline's identity, not a direct write inside the merge itself: a ref write during the render phase is unsafe under React's rules (a render can be discarded or replayed) and is what this repo'sreact-hooks/refslint rejects. Without the bump, a save already in flight when the merge adopts or conflicts a field could still clearhasUnsavedChangesover a value the merge just changed underneath its response. restoreStoredName()is unchanged in spirit but now readsbaseline.namerather than a name-only ref: the Info tab's blank-name refusal needs the stored name back, and the baseline already holds it for every field, not just this one.
The save payload only ever carries what touchedFields names
(buildUpdatePayload in request-builder/index.tsx): onSave receives
(request, changedFields), and an untouched field is omitted from the
PUT /requests/:id body entirely - which the engine's merge-patch already
reads as "leave the stored value alone" - rather than being resent unchanged.
The four body fields collapse into the wire's body/bodyType pair if any one
of them was touched. name keeps its own rule inside that: touched-but-blank
still omits the key (a blank name is refused, not saved), for the reason the
old comment gave - the debounced auto-save can fire while the field is briefly
empty.
useUpdateRequestMutation's cache write also learned to lose a race
(queries/collections.ts): a foreign write's own refetch can land in the
detail cache while this client's save is still in flight, and writing the
save's response into the cache unconditionally could then overwrite the
newer row with a stale one purely because the older write's round trip
finished second. The onSuccess handler now compares updatedAt
(a toISOString() string end to end, so lexical and chronological order
agree) and keeps whichever row is actually newer.
Guarded by RequestBuilderProvider.name-sync.test.tsx (the name-only case that
started this), RequestBuilderProvider.external-write-merge.test.tsx (the
general per-field merge, the move, the delete, and the generation bump),
field-merge.test.ts (mergeExternalWrite itself), save-request-name.test.ts
and request-builder.script-clearing.test.tsx (the partial payload), and
collections.update-request-race.test.ts (the cache race).
State Flow Examples¶
Executing a Single Request¶
- User clicks "Send" button in request builder
useEngine().executeRequest()is called with request and (optionally) environment IDuseVariableResolver()resolves any{{variables}}in the request URL, headers, body- Request is transformed (frontend → backend format) and sent via HTTP
- Response is stored in
useResponseStore()keyed by request ID - Response viewer component reads the response and displays it
- On request tab switch, the response persists in
response-storeand is displayed if the user returns - for the 24 most recently sent requests, past which the store's LRU bound has dropped the entry and the backend's stored run answers instead - If any script ran, the environment / globals / collection query families are invalidated so values the script wrote are visible in the variables editor and the resolver. The gate is
scriptsMayWriteVariables(pre, post)(request-builder/utils/execute-mapping.ts), shared by the builder's send path and the History run view's resend. Both script kinds count:pm.environment.setand friends persist engine-side from a Tests-tab script exactly as from a pre-request one, and withrefetchOnWindowFocus: falsenothing else is coming to correct a stale value
Starting a Load Test Run¶
- User configures load test in the dashboard modal (duration, concurrency, etc.)
- The request half is composed engine-side (
useEngine().composeRequest()), thenapiService.startLoadTest()sends the composed payload plus the load config toPOST /runs - Engine responds with
runId useDashboardStore().startRun(runId, config, requestInfo, requestId)initializes dashboard stateloadTestService.startMonitoring(runId)connects to/runs/:runId/live- As metrics stream in,
addMetricsBatch()folds them into historical metrics (trimmed toliveWindowSeconds, backstopped bymaxRetainedTicks) and updates running aggregates (peak concurrency, SLO breakpoint) - Dashboard view shows live metrics, request/response (from the SSE stream's final response), and aggregates
- When the run completes, the engine sends a
completeevent LoadTestService.handleClose()fetches the final report through the query cache (underqueryKeys.runs.report(runId), so History reuses it) and stores it indashboard-store.finalReport- only if the dashboard is still showing that run. The store is re-read after the await: finishing run A and immediately starting run B otherwise landed A's report on B's dashboard, flipping a running test to "completed" with A's percentiles.- Dashboard switches to "completed" mode showing the final report; the runs lists are invalidated so the terminal status lands without waiting for a poll
Saving a Request with Auto-Save¶
- User opens or creates a request tab via
useTabsStore().openTab() - Component mounts
useSaveManager()with the request ID and save callback - Hook registers the context with
useSaveStore()for Ctrl/Cmd+S integration - User edits the request (URL, headers, body, etc.)
hasChangesis marked true andchangeTokenis bumped, arming the debounce timer -autoSave.delayMsfromclient-settings-store, 5s by default (Settings → General offers 5s / 30s / 1m)- A further change within that window bumps the token again, which tears the pending timer down and arms a new one, so the delay measures from the last edit
- After the delay elapses with no edit,
performSave()is called, which calls theonSavecallback - Save status updates in
useSaveStore(), and the Dock shows "Saving..." then "Saved" forTIMING.SAVED_STATUS_DURATION_MS - A save is only allowed to call the entity clean for the generation it sent (#1381).
onSave(request, changedFields)serialises the state - and the set of touched fields - as they were when the save started, so a keystroke landing during the round trip is not in that payload. The generation also moves on a foreign write the per-field merge adopts or conflicts, not only on the user's own edits (#1436): a save in flight when that happens must not clearhasUnsavedChangesover a value the merge just changed underneath its response. The provider records the generation beside the snapshot and clearshasUnsavedChanges- and the fields the snapshot named - only if the token has not moved when the write lands; the hook applies the same check to the "Saved" indicator. Clearing unconditionally marked that keystroke saved, left nothing dirty for the next timer to fire on, and lost the edit - The request builder's
onSavesends onlychangedFields(#1436, see the draft adopts an external write per field above) - an untouched field is omitted from thePUTrather than resent unchanged - On tab switch or unmount, any pending save is flushed before the context is unregistered - unless the entity is confirmed gone (
useSaveManager'senabled: false), which the request builder sets once its request is deleted elsewhere, so the flush never fires a doomed PUT. If that flush fails, the context is re-registered under the same id rather than left unreachable -flushAll's reconnect trigger (below) and a later quit both walk the registry, not the pane that just unmounted (#1489) - On app quit (Electron
before-quit) and on window close (the X button),useSaveStore().flushAll()saves all dirty contexts and resolves to{ saved, failed, pending }- what actually landed, not just that the round trip finished
Both window-destroying paths flush, through one coordinator. before-quit
always did; close did not, and close is what the X button fires - it
destroys the WebContents and nulls the window handle, so the before-quit that
followed found no renderer to ask and skipped the flush entirely (on macOS,
close does not quit at all, so the edits were simply gone with the app still
running). electron/save-flush.ts owns the once-only flush and the 2s ACK
ceiling for both, which is also why "already flushed" is shared state: a quit
that flushed and then closes the window must not ask a dying renderer twice.
It lives outside main.ts so it can be tested - main.ts creates windows and
starts the engine at import time.
A running service is asked about before either path flushes (#1363). A close or quit that would stop an inbox, mock server or mock issuer names it and waits for an answer first, and Cancel leaves the window, the renderer and every pending save exactly as they were - a flush run ahead of the question would have told the renderer its work was ending. Confirm, and the flush runs as it always did on the way out.
The flush settling is not the same as the flush landing (#1489). Before,
both paths closed the window the moment the round trip finished - the ACK, or
the 2s ceiling if the renderer never answered - whether or not anything had
actually been saved: a refused write, a hung engine, or a save still running
when the ceiling gave up all looked identical to a clean quit. The renderer's
flushAll now carries its outcome back over the before-quit-flushed ACK
(FlushResult, electron/save-flush.ts), and a settle with no ACK at all (the
ceiling won) is treated the same as a failure - nothing is known to have
landed. Either case holds the close behind one native dialog,
confirmDiscardOnFailedFlush: "N edits could not be saved - the engine is not
responding", Quit/Close anyway or Keep working. A clean flush still
closes with no dialog, exactly as before. useTabsStore.closeTab asks the same
question one tab early: closing a single dirty tab while the engine is not
connected keeps the tab and toasts instead of letting the unmount flush fail
into a pane that no longer exists.
Variable Resolution Priority¶
- User activates a request in a tab. The environment comes from
useSessionStore().activeEnvironmentId; the collection comes from the request's owncollectionId- not from the session store, which has held no collection scope since thevayu.sessionv2 migration - Component calls
useVariableResolver({ collectionId })- the environment is not passed, the hook reads it from the session store - Hook fetches globals, collection variables, and environment variables via TanStack Query
- When
resolveString("https://{{baseUrl}}/{{path}}")is called: - First, check environment variables for
baseUrlandpath - If not found, check collection variables
- If still not found, check global variables
- Replace with the first match found, or leave
{{variableName}}unreplaced if no match
Best Practices¶
-
Cross-cutting vs. module-local: Store cross-cutting UI state (tabs, layout, engine, save) in
app/src/stores/; store feature-specific UI state (collections tree, history filters, variables category, settings category) inapp/src/modules/<feature>/<feature>-store.ts. -
Zustand for transient UI state: Use Zustand for UI state that doesn't persist to disk (or is ephemeral per session). Decorate with
persistmiddleware to survive page reloads if needed (e.g., open tabs, drawer state). -
TanStack Query for server state: Use TanStack Query for collections, requests, environments, globals, runs, and reports. It is the single source of truth and ensures consistency across the app.
-
Save manager integration: Use
useSaveManager()in any component that edits a persistable entity (request, environment, etc.) that autosaves. It handles debouncing, context registration, and centralized save state. Do not manually calluseSaveStore()for auto-save. For an editor that holds a draft instead - committing it on blur or on an explicit button - useuseEntityDraft()plususeDraftSaveContext()- the first owns the draft, the second puts it in the registry - and do not hand-roll the draft/resync/isDirty/mutation-reset parts again. -
Centralized save on app quit: On Electron's
before-quitevent, calluseSaveStore().flushAll()to persist any pending changes before the app closes. An editor that is not registered is not merely unsaved here, it is invisible - which is how the collection tabs lost drafts silently for as long as they existed.flushAllreports what happened ({ saved, failed, pending }), andmain.tsasks before discarding anything it did not save (#1489) rather than closing on faith.Corollary: an editing surface must never fail without saying so. There is no global
MutationCache.onErrorinlib/query-client.ts, so a baremutation.mutate(...)reports nothing at all. Route the failure throughfailSave(toast + status) or render the mutation'sisError, and roll an uncontrolled input back to the stored value while you are at it - the context bar's variables section did neither, so a rejected edit sat on screen looking committed. -
Leaving an editor saves or asks; it does not drop. A settings category switch, an unmount, a tab switch - each used to discard dirty state silently in at least one place. Settings flushes its valid edits on the way out (engine config writes are cheap merge-patches); the collection tabs keep their panels mounted so there is nothing to discard.
-
Tab LRU and dirty state: The tab store reads the save registry and refuses to evict a dirty tab (
isTabDirty, matched by tab type - the registry is keyed by editor and the two do not line up). Nothing is flushed during eviction, because the predicate has already declined to take unsaved work; over the cap with every candidate dirty, no tab closes at all. An explicit close (closeTab) is not eviction and used to have no such guard; it now applies the sameisTabDirtycheck but only refuses when the engine is notconnected(#1489) - a dirty tab closes and flushes as usual once the engine can actually take the write. -
Response persistence: Responses are stored in memory (not localStorage) so they survive tab switches but are cleared on page reload. This balances UX (quick switch back) with memory (responses can be large).
-
Live metric retention is a time window, not a point count.
addMetricsBatchtrims ticks older thanliveWindowSeconds(the engine'sliveReplayWindowMs, 5m by default,null= full run), withmaxRetainedTicks(DEFAULT_MAX_RETAINED_TICKS, 50,000) as a memory backstop. Both are engine config so the replayed span and the displayed span cannot disagree. The only knob inconfig/metrics.tsisMETRICS_UI_THROTTLE_MS, the SSE commit throttle; chart cost is bounded by bucketing (chartBucketSeconds), not by dropping ticks. -
Variable resolution priority: Always resolve variables in priority order: environment > collection > global. Use
useVariableResolver({ collectionId })to scope a preview to a collection; the active environment comes from the session store and is not a parameter. -
Lazy loading and prefetch: Use
usePrefetchCollectionsAndRequests()andusePrefetchRuns()on app init to warm up caches. Lazily fetch environments, globals, and run reports only when needed to reduce initial bundle size and API load. Warm a polled list with a prefetch, never by mounting its hook at the root - an observer that lives for the session polls for the session (#1150).