feat(frontend): persist empty-path drafts across reloads

Empty paths used to be in-memory only (via the `isLocalOnly` short-circuit)
because we worried about collisions between concurrent /add tabs. The user
asked for the trade-off to flip: a /flows/add or /scripts/add reload should
restore the user's work, while explicitly clicking "+ Flow / + Script / …"
should always open a clean editor.

- Drop `isLocalOnly` from UserDraft so empty-path entries persist under
  `userdraft/w/{ws}/{kind}/` like any other path. The existing per-kind
  refcounting and saveInitialValue=false behavior already handle them
  correctly — the change is just lifting the bypass.
- Each /add page now calls `UserDraft.remove(kind, '')` synchronously
  when `?nodraft=true` is present in the URL, before the handle is
  created.
- The two "+" entry points that lacked the `?nodraft=true` flag
  (CreateActionsScript's plain `<a href>` and CreateActionsFlow's
  YAML/JSON import paths) now include it, so every fresh-start path goes
  through the wipe.
- Tests updated: the "empty path (in-memory only)" block becomes
  "empty path (persists across reloads)" and asserts the new behavior.
This commit is contained in:
Diego Imbert
2026-05-13 17:31:54 +02:00
parent 42e97bb05a
commit e7e586997c
8 changed files with 60 additions and 71 deletions
@@ -33,7 +33,7 @@
async function importRaw() {
$importFlowStore =
importType === 'yaml' ? YAML.parse(pendingRaw ?? '') : JSON.parse(pendingRaw ?? '')
await goto('/flows/add')
await goto('/flows/add?nodraft=true')
drawer?.closeDrawer?.()
}
@@ -41,7 +41,7 @@
const parsed =
wacImportType === 'yaml' ? YAML.parse(pendingWacRaw ?? '') : JSON.parse(pendingWacRaw ?? '')
$importScriptStore = parsed
await goto(`${base}/scripts/add?import=true`)
await goto(`${base}/scripts/add?import=true&nodraft=true`)
wacDrawer?.closeDrawer?.()
}
@@ -14,7 +14,7 @@
unifiedSize="lg"
variant="accent"
startIcon={{ icon: Plus }}
href="{base}/scripts/add"
href="{base}/scripts/add?nodraft=true"
endIcon={{ icon: Code2 }}
>
Script
+15 -45
View File
@@ -91,16 +91,6 @@ function resolveWorkspace(opts?: UserDraftOptions): string {
return ws
}
/**
* Returns true when this (workspace, itemKind, path) should never touch
* localStorage. An empty path means "new item, not yet on disk"; we keep the
* draft in-memory so multiple components on the same /add page still share
* state, but we don't persist it to avoid colliding new-item drafts.
*/
function isLocalOnly(path: string): boolean {
return path === ''
}
function wrap<V>(value: V | undefined, meta?: UserDraftMeta): StoredDraft<V> | undefined {
if (value === undefined) return undefined
const out: StoredDraft<V> = { value }
@@ -186,18 +176,6 @@ function readPersisted<V>(key: string): StoredDraft<V> | undefined {
}
}
function createInMemoryState<V>(defaultValue: StoredDraft<V> | undefined): DraftState<V> {
let s = $state<StoredDraft<V> | undefined>(defaultValue)
return {
get val(): StoredDraft<V> | undefined {
return s
},
set val(newVal: StoredDraft<V> | undefined) {
s = newVal
}
}
}
function mapKey(workspace: string, itemKind: UserDraftItemKind, path: string): string {
return `${workspace}/${itemKind}/${path}`
}
@@ -253,14 +231,12 @@ export const UserDraft = {
const entry = entries.get(mk)
if (entry) {
// Update the shared reactive state so all observers are notified.
// For non-empty paths the underlying useLocalStorageValue setter
// persists the wrapped value; for empty paths it stays in-memory.
// Preserve any existing rev metadata on the entry.
// The underlying useLocalStorageValue setter persists the wrapped
// value. Preserve any existing rev metadata on the entry.
const current = entry.state.val as StoredDraft<unknown> | undefined
entry.state.val = wrap(value, extractMeta(current))
return
}
if (isLocalOnly(path)) return
// External save without a live handle: preserve any persisted meta
// so the staleness signal isn't lost just because the editor wasn't
// open while we wrote.
@@ -286,7 +262,6 @@ export const UserDraft = {
if (entry) {
return unwrap(entry.state.val as StoredDraft<V> | undefined)
}
if (isLocalOnly(path)) return undefined
return unwrap(readPersisted<V>(localStorageKey(ws, itemKind, path)))
},
@@ -312,7 +287,6 @@ export const UserDraft = {
if (current === undefined) return
entry.state.val = wrap(current.value, meta)
}
if (isLocalOnly(path)) return
const existing = readPersisted<unknown>(localStorageKey(ws, itemKind, path))
if (existing === undefined) return
persistDirect(localStorageKey(ws, itemKind, path), existing.value, meta)
@@ -327,22 +301,20 @@ export const UserDraft = {
const mk = mapKey(ws, itemKind, path)
const entry = entries.get(mk)
if (entry) return extractMeta(entry.state.val as StoredDraft<unknown> | undefined)
if (isLocalOnly(path)) return {}
return extractMeta(readPersisted<unknown>(localStorageKey(ws, itemKind, path)))
},
/**
* Whether a draft currently exists for (workspace, itemKind, path).
* For non-empty paths this checks localStorage; for empty paths it
* checks the in-memory entry. Useful for distinguishing "first visit"
* from "returning visit with unsaved local changes".
* Falls back to the persisted localStorage entry when no live handle is
* registered. Useful for distinguishing "first visit" from "returning
* visit with unsaved local changes".
*/
has(itemKind: UserDraftItemKind, path: string, opts?: UserDraftOptions): boolean {
const ws = resolveWorkspace(opts)
const mk = mapKey(ws, itemKind, path)
const entry = entries.get(mk)
if (entry) return entry.state.val !== undefined
if (isLocalOnly(path)) return false
return readPersisted(localStorageKey(ws, itemKind, path)) !== undefined
},
@@ -366,17 +338,15 @@ export const UserDraft = {
let entry = entries.get(mk)
if (!entry) {
const state: DraftState<unknown> = isLocalOnly(path)
? createInMemoryState<unknown>(wrappedDefault)
: useLocalStorageValue<StoredDraft<unknown> | undefined>(
localStorageKey(ws, itemKind, path),
wrappedDefault,
undefined,
// The first value to flow into the handle (e.g. a backend load
// in the editor route) is the baseline — only persist when the
// user actually changes it afterwards.
{ saveInitialValue: false }
)
const state = useLocalStorageValue<StoredDraft<unknown> | undefined>(
localStorageKey(ws, itemKind, path),
wrappedDefault,
undefined,
// The first value to flow into the handle (e.g. a backend load
// in the editor route) is the baseline — only persist when the
// user actually changes it afterwards.
{ saveInitialValue: false }
)
entry = { count: 1, state }
entries.set(mk, entry)
} else {
@@ -423,7 +393,7 @@ export const UserDraft = {
const current = sharedEntry.state.val as StoredDraft<V> | undefined
if (current === undefined) return
sharedEntry.state.val = wrap(current.value, meta)
if (opts?.force && !isLocalOnly(path)) {
if (opts?.force) {
persistDirect(localStorageKey(ws, itemKind, path), current.value, meta)
}
},
+16 -23
View File
@@ -227,18 +227,22 @@ describe('UserDraft.use() — defaultValue', () => {
})
})
describe('UserDraft — empty path (new-item, in-memory only)', () => {
it('use() with empty path uses defaultValue and does not persist', () => {
describe('UserDraft — empty path (new-item drafts persist across reloads)', () => {
it('use() with empty path persists subsequent edits to localStorage', () => {
const handle = UserDraft.use<number>('flow', '', { defaultValue: 0 })
// First write under saveInitialValue=false counts as the baseline and
// is skipped — only the user's subsequent edits persist.
handle.draft = 99
expect(handle.draft).toBe(99)
// No localStorage key with empty path should ever be written.
expect(localStorage.getItem('userdraft/w/test_ws/flow/')).toBeNull()
handle.draft = 100
// The "+ Flow / + Script / …" buttons are expected to call
// `UserDraft.remove(kind, '')` to wipe before navigating; an
// unguarded /add reload therefore restores the previous session.
expect(localStorage.getItem('userdraft/w/test_ws/flow/')).toBe(wrapped(100))
})
it('two handles with empty path share in-memory state per workspace', () => {
it('two handles with empty path share state per workspace', () => {
const a = UserDraft.use<number>('flow', '')
const b = UserDraft.use<number>('flow', '')
@@ -249,30 +253,19 @@ describe('UserDraft — empty path (new-item, in-memory only)', () => {
expect(a.draft).toBe(2)
})
it('save() with empty path is a no-op against localStorage but updates live handles', () => {
const handle = UserDraft.use<number>('flow', '')
it('save() with empty path writes to localStorage when no handle is live', () => {
UserDraft.save('flow', '', 5)
expect(handle.draft).toBe(5)
expect(localStorage.getItem('userdraft/w/test_ws/flow/')).toBeNull()
expect(localStorage.getItem('userdraft/w/test_ws/flow/')).toBe(wrapped(5))
})
it('get() with empty path returns the in-memory value when a handle is live, else undefined', () => {
expect(UserDraft.get('flow', '')).toBeUndefined()
const handle = UserDraft.use<number>('flow', '')
handle.draft = 11
it('get() with empty path falls back to localStorage when no handle is live', () => {
localStorage.setItem('userdraft/w/test_ws/flow/', wrapped(11))
expect(UserDraft.get('flow', '')).toBe(11)
})
it('remove() with empty path is a no-op (no localStorage to clear, in-memory untouched)', () => {
const handle = UserDraft.use<number>('flow', '')
handle.draft = 1
it('remove() with empty path clears localStorage', () => {
localStorage.setItem('userdraft/w/test_ws/flow/', wrapped(1))
UserDraft.remove('flow', '')
// Empty-path entries never touched localStorage and remove() no longer
// resets the in-memory state, so the handle keeps its value.
expect(handle.draft).toBe(1)
expect(localStorage.getItem('userdraft/w/test_ws/flow/')).toBeNull()
})
})
@@ -14,8 +14,15 @@
import { DEFAULT_THEME } from '$lib/components/apps/editor/componentsPanel/themeUtils'
import { emptyApp } from '$lib/components/apps/editor/appUtils'
import { tick } from 'svelte'
import { UserDraft } from '$lib/userDraft.svelte'
let nodraft = page.url.searchParams.get('nodraft')
// "+ App" buttons navigate with ?nodraft=true to signal "start fresh".
// Wipe the persisted empty-path autosave so the child AppEditor's handle
// opens on a clean slate. A plain reload of /apps/add (no nodraft)
// instead restores the previous session via AppEditor's `UserDraft.use`.
if (nodraft) UserDraft.remove('app', '')
let appEditor: AppEditor | undefined = $state(undefined)
const hubId = page.url.searchParams.get('hub')
const templatePath = page.url.searchParams.get('template')
@@ -46,6 +46,12 @@
const templateId = page.url.searchParams.get('template_id')
const hubId = page.url.searchParams.get('hub')
// "+ Raw App" / "+ App > Full code" buttons navigate with ?nodraft=true to
// signal "start fresh". Wipe the persisted empty-path autosave before the
// handle is created so the editor opens on the default template. A plain
// reload of /apps_raw/add (no nodraft) instead restores the previous session.
if (nodraft) UserDraft.remove('raw_app', '')
// Check in-memory store first, then sessionStorage (used when full page reload occurs)
let importRaw = $importStore
if ($importStore) {
@@ -18,6 +18,12 @@
let nodraft = page.url.searchParams.get('nodraft')
// "+ Flow" buttons navigate with ?nodraft=true to signal "start fresh".
// Wipe the persisted empty-path autosave before the handle is created so
// the editor opens on an empty flow. A plain reload of /flows/add (no
// nodraft param) instead restores whatever the user was last working on.
if (nodraft) UserDraft.remove('flow', '')
afterNavigate(() => {
if (nodraft) {
let url = new URL(page.url.href)
@@ -34,6 +34,13 @@
const collabLang = page.url.searchParams.get('lang') as ScriptLang | null
const wacParam = page.url.searchParams.get('wac')
const importParam = page.url.searchParams.get('import')
const nodraft = page.url.searchParams.get('nodraft')
// "+ Script" buttons navigate with ?nodraft=true to signal "start fresh".
// Wipe the persisted empty-path autosave before the handle is created so
// the editor opens on a blank script. A plain reload of /scripts/add (no
// nodraft param) instead restores the previous session.
if (nodraft) UserDraft.remove('script', '')
let initialArgs = urlArgs ? decodeState(urlArgs) : (get(initialArgsStore) ?? {})
if (get(initialArgsStore)) $initialArgsStore = undefined