frontend-v2-patterns
GitHub定义 RomM v2 前端架构规范,涵盖错误提示、加载状态、Socket.IO 实时通信及 UI 状态持久化等核心行为模式。指导开发者在实现数据流、表单和权限控制时遵循统一机制,避免重复造轮子。
Trigger Scenarios
Install
npx skills add rommapp/romm --skill frontend-v2-patterns -g -y
SKILL.md
Frontmatter
{
"name": "frontend-v2-patterns",
"description": "Cross-cutting feature patterns for the RomM v2 frontend — error\/snackbar handling, loading & skeleton states, real-time Socket.IO updates, UI state persistence (URL vs localStorage vs ephemeral), pagination\/infinite scroll, forms & validation, permissions (useCan), and destructive confirmations. Use when wiring up a v2 feature's behavior (not just its markup). Trigger when implementing data flows, dialogs, forms, toggles, or permission gating under frontend\/src\/v2\/."
}
RomM v2 — Architecture Patterns
How v2 features behave. Each pattern has one canonical mechanism — don't invent a parallel one.
A. Errors & snackbars
- Single channel:
useSnackbar()(src/v2/composables/useSnackbar/) withsuccess | error | warning | infomethods. It emitssnackbarShow;NotificationHoststacks toasts. - The call site decides what's significant — no global "wrap-every-promise" magic.
- Field validation errors render in-place, never as a snackbar.
- Auth (401/403) is handled by the axios interceptor; no per-call-site checks.
- Successful critical actions →
successsnackbar. Routine optimistic toggles → silent on success,erroron failure. - Don't snackbar every rejected promise.
B. Loading states
- Skeleton (
RSkeletonBlock) for first load of a view with known layout — mimic the real shape so the layout doesn't jump. - Inline
:loadingon the control itself for in-flight actions (RBtn,RTextField,RSelect). Never put an externalRSpinnernext to a button that has its ownloading. RSpinnerinline when what's loading isn't a control with nativeloading.- Determinate progress (%): use
RProgressLinear— no rawv-progress-linear. - Empty state ≠ loading state. Zero items is its own UX (message, illustration, optional CTA).
- Optimistic toggles show no spinner: flip immediately; on failure, revert + snackbar.
RBtnshipsloadingDebounce={200}— actions resolving under 200ms never paint a spinner; loading→not-loading is immediate.
C. Real-time updates (Socket.IO)
- One instance:
src/services/socket.ts. Nevernew io(). - New consumers go through (or build) a
useSocketEvent(event, handler)composable for typed subscriptions with automatic mount/unmount cleanup (this composable is still debt — today consumers wiresocket.on/offby hand). - Ownership rule: state living only while a view is open → subscribe in the view; state that must outlive a view (e.g. scan badge in navbar) → a Pinia store subscribes globally and views just read.
- Reconnection is socket.io's job — don't roll your own.
D. UI state persistence — three layers
- Persistent preferences (theme, language, gallery defaults like
groupRoms/boxartStyle, Home panels) →useUISettings(localStorage + backenduser.ui_settingstwo-way sync). Add a key toUI_SETTINGS_KEYS. - Bookmarkable session state (active filters, search query, sort, current tab in detail views) → URL query params. Anyone copying the link reproduces what they see. Active gallery filter must be in URL.
- Ephemeral session state (open dialog, hover, expansion) →
refif local, Pinia store if cross-component within the session.
Don't push state into useUISettings "so it persists", follow the rule above. Layer 3 never touches localStorage: if a value has to survive a reload, it is layer 1 or the per-entity variant below, not ephemeral state.
Per-entity device preferences (a bezel hidden for one game, the core picked for one game) are a narrow variant of layer 1: they persist per device but stay out of useUISettings, because they are keyed by entity rather than global and must not sync to user.ui_settings. Use useLocalStorage from VueUse with writeDefaults: false and a serializer, not a ref plus a watch plus localStorage.setItem. Key it off the route param so it binds before the entity resolves, and make the read fail safe to the default so a stale value can't wedge the view.
D2. Async and reactive lifecycle
Three mistakes that keep reaching review:
- Snapshot before the first
await. Any reactive value a decision depends on can move while requests are in flight. Read it into a local before the call, not between calls:const wasAllFavorited = allFavorited.valuegoes aboveawait ensureFavoriteCollection(), because the response replaces the veryrom_idsthatallFavoritedderives from. - Watch the narrowest source.
watch(() => authStore.user, ...)refires on every unrelated profile update, which then needs a manual "already ran for this id" flag. Watch a derived primitive instead so the watch is self-guarding:() => user?.oauth_scopes.includes("tasks.run") ? user.id : null. - Guard late resolutions with
useIsAlive()(src/v2/composables/useIsAlive/), not a localunmountedflag plusonBeforeUnmount. It usesonScopeDispose, so it also works inside another composable. VueUse'suseMountedis not a substitute.
Name a helper for what it touches: syncCachedRom, not syncRom, when it updates the cache and does not fetch.
E. Pagination & infinite scroll
LoadMore(RBtn+RSpinner+ IntersectionObserver) is the canonical fallback when virtualization stalls.RVirtualScroller(src/v2/lib/structural/, wrappingv-virtual-scroll) is the substrate for large lists/grids.- Page size lives in the store (
fetchLimit); not user-configurable for now. - Scroll restoration on back-nav: Vue Router
scrollBehavior+ Pinia in-session offset. URL holds filters/sort/search but not scroll offset.
F. Forms & validation
- Use the
RFormprimitive (wrapsv-form: Enter-to-submit when valid, scroll-to-first-error after a failedvalidate()). Never usev-formdirectly. - Native Vuetify rules — no Zod/Yup. Rules are arrays of
(v) => true | string. - Reusable rules in
src/v2/utils/validation.ts(required(msg?),email,asciiOnly,lengthBetween,usernameLength/Chars,passwordLength). Utility code may calli18n.global.t(...)(the no-i18n rule covers lib primitives, not utils). - Submit pattern:
await formRef.value?.validate()before the API call; submit button uses:loading="submitting"; errors → snackbar; field errors stay in-place via:error-messages.
G. Permissions
- Action vocabulary
domain.action(rom.upload,rom.delete,library.scan,user.create,app.admin) insrc/v2/composables/useCan/actions.ts. - Scope vocabulary:
type PermissionScope = | { kind: "global" } | { kind: "platform"; id: number } | { kind: "collection"; id: number } | { kind: "rom"; id: number }; useCan(action, scope?)returnsComputedRef<boolean>, reactive topermissionsStore.grants. Without scope: "can do this anywhere."stores/permissions.tsholds normalised grants, hydrated fromauthStore.user.rolevia the role-map (installPermissionsHydration()inAppLayout); a future/permissions/mewill replace it.v-ifto hide options a user shouldn't see;:disabledwith tooltip when the option must be visible but blocked.- Backend is source of truth — frontend is a UX hint. Never bypass with inline
user.role === "...". All grants are pre-loaded (nouseCanAsync).
H. Destructive confirmations
Three friction levels:
- Low / High → shared composite
ConfirmDialog(components/shared/) opened viauseConfirm({ title, body, confirmText, tone, requireTyped }) => Promise<boolean>(mounted once inGlobalDialogs). - Medium → a feature composite when the flow needs extra options (e.g.
DeleteRomDialogwith per-item filesystem checkboxes).
Common rules:
- All destruction goes through a dialog — no silent destructive action.
- Confirm button is danger-toned; focus starts on Cancel; Enter cancels.
- Success → success snackbar or navigate away, dialog closes. Error → error snackbar, dialog stays open. During action → confirm shows
:loading, cancel disabled. - The destructive control respects
useCan(action, scope). - No "don't ask again." Type-to-confirm (
requireTyped) is required when the action affects the filesystem.
Version History
-
5b82462
Current 2026-08-29 04:26
修复了关于第3层状态持久化的矛盾说明,明确临时状态不应写入 localStorage;新增实体设备偏好设置章节;调整 review-polish 技能的执行顺序。
- 42e8043 2026-08-20 16:50


