diff --git a/frontend/src/lib/components/flows/CreateActionsFlow.svelte b/frontend/src/lib/components/flows/CreateActionsFlow.svelte index 73f8cfedba..009718e3c1 100644 --- a/frontend/src/lib/components/flows/CreateActionsFlow.svelte +++ b/frontend/src/lib/components/flows/CreateActionsFlow.svelte @@ -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?.() } diff --git a/frontend/src/lib/components/scripts/CreateActionsScript.svelte b/frontend/src/lib/components/scripts/CreateActionsScript.svelte index 48480918e0..5abd8235f2 100644 --- a/frontend/src/lib/components/scripts/CreateActionsScript.svelte +++ b/frontend/src/lib/components/scripts/CreateActionsScript.svelte @@ -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 diff --git a/frontend/src/lib/userDraft.svelte.ts b/frontend/src/lib/userDraft.svelte.ts index 5d2147cd2c..d78b7280b8 100644 --- a/frontend/src/lib/userDraft.svelte.ts +++ b/frontend/src/lib/userDraft.svelte.ts @@ -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(value: V | undefined, meta?: UserDraftMeta): StoredDraft | undefined { if (value === undefined) return undefined const out: StoredDraft = { value } @@ -186,18 +176,6 @@ function readPersisted(key: string): StoredDraft | undefined { } } -function createInMemoryState(defaultValue: StoredDraft | undefined): DraftState { - let s = $state | undefined>(defaultValue) - return { - get val(): StoredDraft | undefined { - return s - }, - set val(newVal: StoredDraft | 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 | 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 | undefined) } - if (isLocalOnly(path)) return undefined return unwrap(readPersisted(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(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 | undefined) - if (isLocalOnly(path)) return {} return extractMeta(readPersisted(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 = isLocalOnly(path) - ? createInMemoryState(wrappedDefault) - : useLocalStorageValue | 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 | 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 | 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) } }, diff --git a/frontend/src/lib/userDraft.test.ts b/frontend/src/lib/userDraft.test.ts index 574954bf23..26ebf7b3f6 100644 --- a/frontend/src/lib/userDraft.test.ts +++ b/frontend/src/lib/userDraft.test.ts @@ -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('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('flow', '') const b = UserDraft.use('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('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('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('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() }) }) diff --git a/frontend/src/routes/(root)/(logged)/apps/add/+page.svelte b/frontend/src/routes/(root)/(logged)/apps/add/+page.svelte index 2605e0d181..4e571330be 100644 --- a/frontend/src/routes/(root)/(logged)/apps/add/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/apps/add/+page.svelte @@ -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') diff --git a/frontend/src/routes/(root)/(logged)/apps_raw/add/+page.svelte b/frontend/src/routes/(root)/(logged)/apps_raw/add/+page.svelte index 4dd39ba488..d15b75a077 100644 --- a/frontend/src/routes/(root)/(logged)/apps_raw/add/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/apps_raw/add/+page.svelte @@ -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) { diff --git a/frontend/src/routes/(root)/(logged)/flows/add/+page.svelte b/frontend/src/routes/(root)/(logged)/flows/add/+page.svelte index 93533509d0..521b5af9d7 100644 --- a/frontend/src/routes/(root)/(logged)/flows/add/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/flows/add/+page.svelte @@ -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) diff --git a/frontend/src/routes/(root)/(logged)/scripts/add/+page.svelte b/frontend/src/routes/(root)/(logged)/scripts/add/+page.svelte index 43f401d8e9..38fe3e72c9 100644 --- a/frontend/src/routes/(root)/(logged)/scripts/add/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/scripts/add/+page.svelte @@ -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