The last few weeks in devset CE were about tidying up foundations: a refreshed landing page with a walkthrough, ESLint 10, security bumps. This commit is different — 2,133 new lines that bring editor-style request tabs to Message Dispatch — the kind every serious API client has.
It's the biggest UX change in that module since it was created, so instead of a dry changelog, here are the architectural patterns behind it — and where I deliberately compromised.
The problem
Message Dispatch was a single form — roughly 40 pieces of state: broker (Kafka/RabbitMQ), topic/exchange, headers, proto schema, payload studio, wire format. Switching to a different request meant losing unsaved edits.
Debugging a flow that needs several messages — order-created, a quick payment-failed test, back to the first one — meant juggling one shared state and a lot of patience. The solution has been known for years: tabs. Each scenario in its own tab, state surviving even a browser close.
Pattern #1: Thin tab — a pointer, not a copy
The fundamental design decision: a tab does not copy request parameters. It points at a saved SingleRequest — the single source of truth — and holds a local form snapshot:
export type DispatchTab = {
id: string
savedRequestRef: SavedRequestRef | null // null = scratch tab
formSnapshot: PerTabFields | null // local, NEVER sent to the server
activeHistoryRef: string | null
title: string
order: number
}
The alternative — a tab as a full copy of the request — would force syncing N copies on every collection edit: "verify & refetch" loops, race conditions, permanent staleness. Clients that went the copy route pay for it in exactly this way — "the tab says one thing, the saved request says another" is a well-known class of bug.
In the pointer model the only possible staleness is a dangling ref — someone deleted the request a tab points to — and we catch that lazily, at hydration time. Zero active synchronization.
The boundary between "belongs to the tab" and "shared" is drawn by the PerTabFields type — a Pick over 29 state fields. The snapshot extractor lists those fields explicitly, so any drift between the type and the extractor is stopped by the compiler, not by code review.
Pattern #2: Stash + hydrate — one live form
The key optimization: only the active tab is live. The reducer doesn't keep N copies of a forty-field state — on tab switch it stashes the current form into the outgoing tab's snapshot and pours in the incoming one:
case 'tabSwitched': {
const target = state.tabs.find((tab) => tab.id === action.id)
if (!target) return state
return {
...applyPerTabFields(state, target.formSnapshot ?? defaultPerTabFields()),
tabs: stashActiveTab(state),
activeTabId: action.id,
}
}
The biggest win here is invisible in the diff: the existing form logic doesn't know tabs exist. Effects, selectors and components work exactly as before — on "that one" state. Tabs are a layer on top of the reducer, not a rewrite of it. The whole mechanism lives in a new MessageDispatch.tabs.ts — 170 lines of pure functions, tested without rendering anything.
Edge cases are closed too: closing the active tab activates a neighbour, closing the last one leaves a fresh scratch tab, and opening a saved request from the list always creates a new tab at the front of the bar — no dedup, because every click is a separate mirror. Want to compare two variants of the same request side by side? Click twice.
Pattern #3: Persistence behind a repository seam
All persistence hides behind a single interface — load() and save(workspace). The reducer and the components see that contract and never learn where the data physically lives.
The localStorage implementation gets Storage injected, so it's tested with an in-memory map, no DOM required; a throwing or full storage degrades to "no persistence" instead of blowing up the app. Writes are debounced (400 ms) so we don't hammer the disk on every keystroke.
Pattern #4: Hybrid — composing two repositories
The most interesting piece. There are two persistence layers, with different roles:
- localStorage holds the full content of all tabs — scratch tabs and edited mirrors of saved requests. This is what rescues unsaved edits across a browser reload.
-
backend (
GET/PUT /dispatch-workspace) receives only pointers to saved tabs: id, collection, request name, title, order. The server knows what is open — never what's inside it.
A third implementation of the same interface glues them together — a classic Composite over a shared port:
async save(workspace) {
await Promise.all([
local.save(workspace), // full content — rescues edits
api.save({ activeTabId, tabs: savedOnly }), // pointers — cross-device
])
}
On load, a merge happens with one iron rule: local content wins per id, while tabs known only to the server — opened on another device — are appended and hydrated fresh from their saved requests. In-flight local edits and the active tab stay untouched. When the API is down, everything degrades to plain localStorage and the user notices nothing.
That rule is the result of a painful lesson. The first iteration split things as "scratch tabs to localStorage, saved tabs to the server" — sounded logical, and lost mirror edits on reload, because an edited mirror is no longer what sits on the server. Hence the current rule: local holds everything, the server holds only the open set.
Pattern #5: Backend — workspace blob, last-write-wins
The new io.devset.ce.be.dispatchworkspace module is a full hexagon in the style of the neighbouring singlerequest: a logic-free controller, a facade, a pure domain on records, MapStruct at layer boundaries.
The endpoint treats the workspace as a singleton per instance with last-write-wins semantics — devset CE is single-user and local, so per-tab diffs or CRDTs would be engineering for show. And if it's ever needed: migrating up from a blob is easy; the other direction is much harder.
The tab list lands in SQLite as a single JSON column via a null-safe JPA converter — deliberately without a separate table and relations. Tabs are always read and written as a whole, so normalization would only buy us JOINs and migrations, with no upside.
UX details
A few decisions that define how the feature feels:
- Update saves to the request's source collection — the tab remembers where it came from — and asks explicitly in a dedicated modal before overwriting.
- Saving without a collection lands in an auto-created "Uncategorized", because forcing a collection name during a quick "I'll save this for later" was pure friction.
- Tab colors went through several iterations, because the first version was "too neon" — the active tab ended up white and lifted by a shadow, with the broker marked by a neutral chip with a dot.
Test grid: pure tab functions and the reducer, both repositories plus the hybrid, components, and on the backend side the domain, the converter and a controller integration test. Plus 250 lines of E2E — including surviving a browser reload.
Known trade-offs — because honesty only gets cheaper after the fact
No architecture is free, so let's write down what we're paying:
- The stash invariant is not enforced by the compiler: every future action that changes the active tab has to remember
stashActiveTab(). Plan: a guard test enumerating actions that touchactiveTabId. - A new form field has to be added to
PerTabFieldsby hand — the compiler won't hint that it should be per-tab. Forget it → the field leaks between tabs. - The append-only merge doesn't propagate closes: a tab closed on device B can come back from device A's localStorage. For a single user — acceptable, but it's a conscious anomaly, not an oversight.
- A dirty guard on tab close — deferred, because there's no reliable "dirty" signal yet. Today it's the only path to real edit loss, so it's high on the list, not "someday".
The result
For the user: no lost work, parallel scenarios without juggling, continuity across devices. Architecturally: tabs landed without rewriting the existing form — and that's the best proof that stash + hydrate was the right call.
As always — devset CE is source available, feedback welcome.
Top comments (0)