View state that survives
This is a guide, not the contract. What the platform guarantees is specified under
openspec/specs/. For this page:persistence-ports·surfaces·surface-retention. Where this page and a specification disagree, the specification is right, and that is a defect in this page: change the behaviour there, then explain it here.
This page keeps what a surface shows across a hide and a reload: filters, sort, scroll position, the
active sub-tab. The VIEW_STATE handle stores it for a docked surface; the address stores it for a
routable one; retain keeps a live instance alive where neither fits.
The VIEW_STATE handle
A docked surface persists its own serialisable state (filters, sort, scroll position, expanded
nodes, the active sub-tab) through the host-provided VIEW_STATE handle, so it survives both a hide
and a reload. Inject it and type it to your own state shape; the host auto-saves every set
(debounced) and hands the saved blob back on the next mount. You never touch storage, and the platform
stays domain-pure: it stores an opaque blob, only your view reads it.
import { VIEW_STATE, ViewState } from '@loomweaver/plugin-sdk';
interface OutlineState { sort: 'natural' | 'alpha'; }
@Component({ /* ... */ })export class OutlineView { private readonly viewState = inject(VIEW_STATE) as ViewState<OutlineState>; // value() is Signal-shaped; undefined for a fresh view → apply your own default. protected readonly sort = computed(() => this.viewState.value()?.sort ?? 'natural');
toggleSort(): void { this.viewState.set({ sort: this.sort() === 'alpha' ? 'natural' : 'alpha' }); // host saves it }}Two things to know before you scale that up. set replaces the whole blob, so spread the current
value when you change one field. Keep one state shape rather than five separate signals and there is
nothing to merge. And call set as often as you like: the value is live immediately and the write is
debounced, so even a per-keystroke set costs one save once typing stops. A write never waits longer
than two seconds, and a reload sends what was still waiting; completing it during the unload is the
store’s part where the product backs it with its server.
Recipe 7 in Samples is the full treatment: form
value, scroll position, expanded nodes, active sub-tab and filter in a single shape, next to the
counter-example that loses all of it. (Persistence is backed by the distribution’s
working-state store; default localStorage.)
A routable surface has no VIEW_STATE handle. It owns a
URL, and the URL is the better store for everything shareable: put the filter and the active sub-tab in
route params or subRoutes and they survive a reload and a deep link, take part in browser history and
can be sent to a colleague. What the URL should not carry, unsaved edits, is what DirtySurface and
retain are for. A sandboxed surface has no handle either, because nothing of the VIEW_STATE
shape crosses the RPC boundary; it declares retain: 'always' instead
(below).
State travels with the tab. The user can drag your view’s tab from a sidebar into the centre,
into a split, and back again. The same VIEW_STATE stays bound to it, so filters and sort survive
the move. Deliberately independent copies (stacking the same view, split-born panes) each get their own
state; your component code is identical in both cases.
The user can reset it. The view tab’s context menu offers Reset view: the host clears
that instance’s blob and value() flips back to undefined. That happens live, without a remount.
Your ?? default fallback (as above) is all you need for this to work.
Named saved instances. Set instanceable: true on the view and the host adds a switcher to the
view header: the user can save, name, rename and delete several configurations of your view, each with its
own auto-saved VIEW_STATE blob. Your component code does not change. It just reads/writes
VIEW_STATE; the host binds it to whichever instance is active and manages the list (the
non-deletable default instance carries the baseline state). The switcher travels with the view:
it is rendered wherever the host mounts it, in a sidebar, a content pane, a split or a pop-out window,
so instance management never disappears when the user moves your view. A pane that was deliberately
born as its own instance (stacked below another, or split off) keeps its independent state until
the user picks an instance from the switcher. That pick re-binds the pane to the named instance.
ctx.registerSurface({ id: 'library', title: 'library.title', docks: ['left-panel'], instanceable: true, component: LibraryView });It also works in a pop-out window. The user can open any view or content tab in its own browser
window (for a second monitor) from its context menu. Your surface is mounted there exactly as it is
in a pane: nothing to declare, nothing to change. Both windows share one VIEW_STATE instance,
so they mirror each other live.
Keeping a hidden surface alive
A hidden surface is destroyed as soon as it is clean, and anything that must survive a reload belongs
in VIEW_STATE; Retention and unsaved work is the rule
and the reason. What is left for you to declare is the exception. A surface that genuinely needs its
live instance kept while hidden (an expensive rebuild, a live connection) declares retain: 'always'
on its registration; retain: 'never' opts back into destruction when the distribution flipped the
app-wide default.
One thing to check before you declare it: where the thing you want to keep is unsaved work,
DirtySurface is the guard, not retain.
Surfaces live off the router
says what the route a kept surface receives carries.
Showing a surface somewhere else
Sometimes the one live instance has to go on running somewhere the workbench does not draw, for
example a chat moved into a floating window during a call. Your product does that itself: it opens
the window, moves your surface’s element into it and takes it back. The workbench offers one thing
for it, the SURFACE_HOLD handle of your docked instance.
private readonly hold = inject(SURFACE_HOLD);private readonly element = inject(ElementRef<HTMLElement>).nativeElement;
async float(): Promise<void> { this.hold.hold(); // before the element leaves its place const floating = await openYourWindow(); floating.document.body.append(this.element); floating.addEventListener('pagehide', () => this.hold.release(), { once: true });}While it is held, the workbench leaves the element where you put it. Collapsing the panel, switching the view or the workspace neither takes it out of the document, hides it nor puts it back, and the instance is not destroyed for being hidden. Moving the view to the other sidebar or into another pane keeps that same instance where you put it, and releasing it places it where the view now is. Release on every way back, including the window closing by itself. You do not need to put the element back yourself. On release the workbench treats the instance as if it had never been held: back in its place if that place is visible, otherwise hidden or released as usual.
Closing still closes. Closing the view or turning your plugin off ends the instance wherever its
element is, and it ends the hold with it. When the view is opened again, the new instance starts
unheld and is placed as usual until it holds itself. Resetting the workspace ends the hold first, and
the reset then treats your instance as one that was never held. Watch held(): when it reads false
without your release, the element is no longer yours to show, so close your window. The workbench notices nothing on its own and offers no window,
gesture or styling, so mirroring styles and theme into your window is yours. Only a docked surface
running in the page has the handle. A routable surface has none, and a sandboxed one would reload if
its document were moved.
A sandboxed surface and the atomic move
A sandboxed (iframe) surface retains too, at a URL and at a dock alike. The host hides it in
place instead of destroying it, so your document keeps running and the Penpal handshake is not paid
again. Moving it is where the browser decides. An <iframe> that is removed and re-inserted the
ordinary way reloads, so the host uses the browser’s atomic move where it exists (Chromium and Firefox
today). Before the pane around your surface goes away, the host moves the frame into a hidden holding
area, and it moves it back when the pane returns. Your surface then also survives a collapsed sidebar, a
minimised pane and a workspace switch. Where the browser has no atomic move (WebKit today) the surface
is rebuilt in those cases. A split and a drag into another pane rebuild everywhere, because the
instance is keyed to the pane it sits in. So write your surface so that a rebuild is survivable either
way. container surfaces are always rebuilt.
Where next
- Unsaved changes: the guard for work the user has not saved yet.
- Your plugin’s own store: state that belongs to the plugin, not to one view.
- Retention and unsaved work: why hiding is not closing.
- Samples: recipe 7, everything a view must persist.