Navigation

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 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:

  1. A per-service dialog store holds only the dialog targets — plain string refs like deletingId or editingColumnName that default to "" (the empty-string default convention, never undefined). 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.
  2. 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="deletingId = item.id" — no .stop, since the row it sits in leaves a click on a control alone (nested interactions). There are no activator slots and no @update:delete-mode emit chains plumbed up the component tree.
  3. 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)) — so the dialog always shows live data rather than an item captured at open time — guards rendering with v-if="item", and derives its open state from the target via the useSingletonDialog composable — a writable computed whose getter is Boolean(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 a schema form, 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));

The reconciling runs from the lookup's first read, so a target already set when it mounts over a list without its item is dropped as well. The component that passes the resolver also clears the target when it unmounts: targets live in stores that outlive the page, so a dialog left open by a navigation would otherwise re-open over its row on the way back. A dialog that passes nothing keeps the target on unmount, because a :key remount unmounts the old instance after the next target is already set.

When a selection's delete confirms several rows at once, the dialog takes their ids as its target and resolves the rows still present. An empty set counts as no target and no item, so a read that removes every selected row drops the target the way a single row's removal does, and the confirm holds the rows it showed through its leave.

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 than v-show — an always-mounted v-show toolbar per item is the same O(N) mount problem in menu form. See the message list's rendering for its 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

FileRole
app/composables/useSingletonDialog.tsWritable v-model computed over a target ref — open while set, close resets to "" or []
app/store/message/dialog.tsMessage dialog targets (deletingRowKey, pinningRowKey)
app/store/message/room/dialog.tsRoom dialog state (settingsRoomId, isEditRoomDialogOpen)
app/store/message/roomCategoryDialog.ts, app/store/message/room/webhookDialog.tsCategory / webhook delete targets
app/store/post/dialog.ts, app/store/post/comment/dialog.tsPost / comment delete targets
app/store/resource/sheet/columnDialog.ts, app/store/resource/sheet/rowDialog.tsSheet table editor chart/edit/delete targets
app/components/Post/ConfirmDeleteDialog.vueCanonical stateless singleton (resolve → v-if → useSingletonDialog)
app/components/Resource/Sheet/Row/EditDialog.vueCanonical stateful singleton (v-if + :key mount for a fresh edit draft)
app/components/Message/Model/Room/Settings/Dialog.vueFullscreen settings dialog driven by settingsRoomId

Details

Command palette

Keyboard shortcuts