From d65db2378708149c4cdcaa95085283be5335fbd0 Mon Sep 17 00:00:00 2001 From: Diego Imbert Date: Fri, 15 May 2026 10:39:03 +0200 Subject: [PATCH] refactor(frontend): replace UserDraft.release() with useMany() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Public surface change: - New `UserDraft.useMany(getSpecs: () => UserDraftSpec[])` returns a reactive array of handles. The reconcile loop acquires entries for added specs, releases entries for removed specs, and re-uses cached handles for unchanged keys so caller-captured references stay stable. - `UserDraft.use(kind, path, opts?)` becomes a 1-len wrapper around `useMany`. The spec getter is `untrack`ed so reactive opts (`$workspaceStore` etc.) are still captured-once — current `use()` semantics unchanged. - `UserDraftHandle.release()` and the `manualRelease` option are gone. Component teardown is handled by a single internal `onDestroy` that releases every entry `useMany` acquired. ResourceEditor + VariableEditor migrated: - Replaced `Record` + manual `ensureHandle`/`release` with a `workspaceSpecs: $state>` plus a derived `Record` that pairs each ws with its parallel handle from `useMany`. `ensureHandle(ws)` is now just a push to the specs array; `VariableEditor.reset()` clears it. The reconcile loop handles acquisition/release end-to-end. Tests: - Dropped the `manualRelease`/`release` test; the option no longer exists. - Added a `useMany` test asserting per-spec entries, isolated workspace-scoped localStorage keys, and a single onDestroy registration covering every acquired entry. Implementation note: I tried wrapping `useLocalStorageValue` in `$effect.root` to give the entry's `$state`/`$effect` an independent scope (in case `useMany`'s reconcile effect tore down nested effects across cycles). But `$effect.root`'s callback wasn't running synchronously in the test runtime (vitest + svelte-vite plugin), and the original `use()` implementation called `useLocalStorageValue` directly without issue. Reverted to the direct call; the nested-scope concern stays theoretical. --- .../src/lib/components/ResourceEditor.svelte | 49 ++-- .../src/lib/components/VariableEditor.svelte | 54 ++-- frontend/src/lib/userDraft.svelte.ts | 253 ++++++++++++------ frontend/src/lib/userDraft.test.ts | 39 +-- 4 files changed, 254 insertions(+), 141 deletions(-) diff --git a/frontend/src/lib/components/ResourceEditor.svelte b/frontend/src/lib/components/ResourceEditor.svelte index 19e55138c2..6827eb74f5 100644 --- a/frontend/src/lib/components/ResourceEditor.svelte +++ b/frontend/src/lib/components/ResourceEditor.svelte @@ -2,7 +2,7 @@ import type { Schema } from '$lib/common' import { ResourceService, WorkspaceService, type Resource, type ResourceType } from '$lib/gen' import { canWrite } from '$lib/utils' - import { createEventDispatcher, onDestroy, untrack } from 'svelte' + import { createEventDispatcher, untrack } from 'svelte' import { userStore, workspaceStore } from '$lib/stores' import { sendUserToast } from '$lib/toast' import { clearJsonSchemaResourceCache } from './schema/jsonSchemaResource.svelte' @@ -49,32 +49,41 @@ let effectiveWorkspace = $derived(workspace ?? $workspaceStore!) let initialPath = path - // Per-workspace handles. Each workspace's autosave lives at its own - // localStorage key (`userdraft/w/{ws}/resource/{initialPath}`) so editing - // the same path across two workspaces stays cleanly separated. - let states: Record> = $state({}) + // Per-workspace handles are driven by `useMany`. We track the workspace + // IDs (and their seeded defaults) in a parallel `$state` array; on every + // mutation `useMany` reconciles, acquiring entries for new workspaces and + // releasing them on component teardown. `states` indexes the resulting + // handles by workspace ID for ergonomic lookup downstream. + let workspaceSpecs = $state>([]) let initialStates: Record = $state({}) let existedInitially: Record = $state({}) let fetchedResources: Record = $state({}) let perWsUser: Record = $state({}) - onDestroy(() => { - for (const h of Object.values(states)) h.release() + const handlesArray = UserDraft.useMany(() => + workspaceSpecs.map((s) => ({ + itemKind: 'resource' as const, + path: initialPath ?? '', + workspace: s.ws, + defaultValue: s.defaultValue + })) + ) + const states = $derived.by(() => { + const out: Record> = {} + for (let i = 0; i < workspaceSpecs.length; i++) { + const handle = handlesArray[i] + if (handle) out[workspaceSpecs[i].ws] = handle + } + return out }) - /** Create (or reuse) a per-workspace handle. `defaultValue` is what the - * handle reports when no autosave is persisted; an existing autosave - * always wins. The default itself never round-trips to localStorage — only - * the user's first real edit triggers a write. */ - function ensureHandle(ws: string, defaultValue: ResourceState): UserDraftHandle { - if (states[ws]) return states[ws] - const h = UserDraft.use('resource', initialPath ?? '', { - workspace: ws, - defaultValue, - manualRelease: true - }) - states[ws] = h - return h + /** Register a workspace so `useMany` acquires (or reuses) its handle. + * `defaultValue` is what the handle reports when no autosave is persisted; + * an existing autosave always wins. The default itself never round-trips + * to localStorage — only the user's first real edit triggers a write. */ + function ensureHandle(ws: string, defaultValue: ResourceState): void { + if (workspaceSpecs.some((s) => s.ws === ws)) return + workspaceSpecs.push({ ws, defaultValue }) } let isValid = $state(true) diff --git a/frontend/src/lib/components/VariableEditor.svelte b/frontend/src/lib/components/VariableEditor.svelte index 5d3a9b3f06..3ab1d71fc9 100644 --- a/frontend/src/lib/components/VariableEditor.svelte +++ b/frontend/src/lib/components/VariableEditor.svelte @@ -1,6 +1,6 @@