Singleton Dialogs
Every dialog that acts on a list item (delete a message, edit a row, open room settings) is mounted once at the list level and targeted through a store ref, instead of being embedded inside each list item. This is the repo-wide answer to a class of performance bug: a v-for over N items that each mount their own v-dialog (plus its form, preview, and validation subtree) creates N full component trees that all mount, hydrate, and patch together. On the messages page this pattern (dialogs, options toolbars, and emoji pickers per message) pushed Interaction to Next Paint from milliseconds into whole seconds before conversion.
How it works
Three parts cooperate, and each lives in a fixed place:
- A per-service dialog store holds only the dialog targets — plain string refs like
deletingIdoreditingColumnNamethat default to""(the empty-string default convention, neverundefined). Dialog UI state is deliberately kept out of business-logic stores: each service gets a dialog store next to its business store, e.g.store/message/dialog.ts(useMessageDialogStore),store/post/dialog.ts,store/resource/sheet/rowDialog.ts. - Per-item action buttons write the target. The button in the list item is a dumb icon button whose click handler is one assignment:
@click.stop="deletingId = item.id". There are no activator slots and no@update:delete-modeemit chains plumbed up the component tree. - One singleton dialog component is mounted at the list/table/page level. It resolves the full item from the business store by the target (
items.find(({ id }) => id === deletingId)), guards rendering withv-if="item", and derives its open state from the target via theuseSingletonDialogcomposable — a writable computed whose getter isBoolean(target)and whose setter resets the target to""on close.
flowchart LR Button["Per-item action button"] -- "deletingId = item.id" --> DialogStore["Dialog store (per service)"] DialogStore -- "useSingletonDialog target" --> Dialog["Singleton dialog (one per list)"] BusinessStore["Business store items"] -- "find by target" --> Dialog Dialog -- "close resets target to empty string" --> DialogStore Dialog -- "confirm calls mutation" --> BusinessStore
Because the target is a single ref, only two components react when it changes: the singleton dialog and (at most) the one item whose derived state depends on it. The other N-1 items are untouched.
Per-open local state
A confirm dialog is stateless, so a plain v-if="item" guard inside the singleton suffices. An edit dialog that clones its item into a local draft (structuredClone for vjsf, useCloned for row edits) must re-create that draft per target — mount it at the list level with a v-if and a :key so Vue recreates the component when the target changes:
<ResourceSheetRowEditDialog v-if="editingRow" :key="editingRow.id" :row="editingRow" :index="..." />
A dialog the user can hold open while the list re-reads underneath it — a confirm as much as an edit, since a confirmation waits on the user just as long — resolves its item through useSingletonDialog rather than in a computed of its own. The v-if unmounts it the moment its row leaves items (a search, a page turn, a sort change, an optimistic removal) while the target ref stays set, so without that the dialog re-opens by itself over the same row as soon as a later read brings it back:
const { isOpen, item } = useSingletonDialog(detailRowKey, () => items.find(({ rowKey }) => rowKey === detailRowKey));
Where the parent owns the lookup because it passes the item down as a prop (the v-if + :key case above), the two halves land in different components: the parent passes the item and uses item, the dialog passes nothing and uses isOpen.
Scope and non-goals
- Hover toolbars and options menus in list items follow the same principle with
v-if(mount on hover/activation) rather thanv-show— an always-mountedv-showtoolbar per item is the same O(N) mount problem in menu form. See/docs/esbabbler/message-list-renderingfor the message list's full treatment. - Single-instance dialogs are fine as combined button+dialog components. A create button in a toolbar or a page-level settings dialog mounts exactly once, so the rule does not apply — it targets per-item multiplication only.
Key files
| File | Role |
|---|---|
app/composables/useSingletonDialog.ts | Writable v-model computed over a target ref — open while set, close resets to "" |
app/store/message/dialog.ts | Message dialog targets (deletingRowKey, pinningRowKey) |
app/store/message/room/dialog.ts | Room dialog state (settingsRoomId, isEditRoomDialogOpen) |
app/store/message/roomCategoryDialog.ts, app/store/message/room/webhookDialog.ts | Category / webhook delete targets |
app/store/post/dialog.ts, app/store/post/comment/dialog.ts | Post / comment delete targets |
app/store/resource/sheet/columnDialog.ts, app/store/resource/sheet/rowDialog.ts | Sheet table editor chart/edit/delete targets |
app/components/Post/ConfirmDeleteDialog.vue | Canonical stateless singleton (resolve → v-if → useSingletonDialog) |
app/components/Resource/Sheet/Row/EditDialog.vue | Canonical stateful singleton (v-if + :key mount for a fresh edit draft) |
app/components/Message/Model/Room/Settings/Dialog.vue | Fullscreen settings dialog driven by settingsRoomId |
Notes
- The emit-plumbing style this replaces (
@update:delete-modechains + activator slots per item) was the original convention; it was retired in July 2026 after profiling showed per-item dialog trees dominated interaction latency. - Targets are resolved back to full items through the business store, so the dialog always shows live data — no stale item props captured at open time.