From 29781fc694cfd79b5773069faf4edfbbf3358b8a Mon Sep 17 00:00:00 2001 From: Kelvin Amoaba <97001695+AmoabaKelvin@users.noreply.github.com> Date: Sun, 8 Nov 2026 06:45:30 +0000 Subject: [PATCH] feat(mobile): keep automation drafts across kills (#39683) * feat(mobile): keep automation drafts across kills * fix(mobile): drop a created automation's draft on affirm and restore saved fields under typed ones * fix(mobile): stop saving an automation draft after it is dropped * fix(mobile): reopen the automation editor with in-memory edits while the store can't be read * refactor(mobile): one rule for which automation draft fields changed --- mobile/src/automations/AutomationEditor.tsx | 11 +- .../AutomationEditorNavigation.tsx | 21 +-- ...tomationManager.draft-persistence.spec.tsx | 77 ++++++++++ .../automations/AutomationManager.test.tsx | 17 +++ mobile/src/automations/AutomationManager.tsx | 13 +- mobile/src/automations/automation-draft.ts | 16 ++ .../automation-editor-draft-row.ts | 143 ++++++++++++++++++ .../use-automation-create-attempt.ts | 10 +- .../use-automation-editor-draft.ts | 44 ++++++ .../automations/use-automation-reads.test.tsx | 7 +- mobile/src/drafts/durable-draft-rows.ts | 17 ++- 11 files changed, 346 insertions(+), 30 deletions(-) create mode 100644 mobile/src/automations/AutomationManager.draft-persistence.spec.tsx create mode 100644 mobile/src/automations/automation-editor-draft-row.ts create mode 100644 mobile/src/automations/use-automation-editor-draft.ts diff --git a/mobile/src/automations/AutomationEditor.tsx b/mobile/src/automations/AutomationEditor.tsx index 4057c69ca81..2182c203bf0 100644 --- a/mobile/src/automations/AutomationEditor.tsx +++ b/mobile/src/automations/AutomationEditor.tsx @@ -29,6 +29,7 @@ import { validateAutomationDraft, type AutomationDraft } from './automation-draft' +import { useAutomationEditorDraft } from './use-automation-editor-draft' import { automationErrorMessage } from './automation-error' import { automationStyles as styles } from './automation-styles' @@ -36,6 +37,8 @@ export function AutomationEditor(props: { client: RpcClient current?: MobileAutomation initialDraft?: AutomationDraft + /** Where unsaved edits are kept; they come back when this editor opens again, even after a kill. */ + draftKey: string onDraftChange: (draft: AutomationDraft) => void busy: boolean connected: boolean @@ -46,9 +49,9 @@ export function AutomationEditor(props: { }) { const t = useMobileTranslation() useMobileColorScheme() - const [draft, setDraft] = useState(() => props.initialDraft ?? newAutomationDraft(props.current)) - const original = useRef(newAutomationDraft(props.current)) - const leave = useAutomationLeaveGuard(draft, original, props.leaveGuard) + const form = useAutomationEditorDraft(props.draftKey, props.current, props.initialDraft) + const { draft, setDraft, original } = form + const leave = useAutomationLeaveGuard(draft, original, props.leaveGuard, form.discard) const selectTargetDraft = useAutomationTargetDrafts() const initialized = useRef(false) const [targets, setTargets] = useState(null) @@ -96,7 +99,7 @@ export function AutomationEditor(props: { initial.workspaceMode = 'existing' } original.current = { ...initial } - setDraft(props.initialDraft ?? initial) + setDraft(props.initialDraft ?? form.withSavedEdits(initial)) } }) .catch((failure: unknown) => { diff --git a/mobile/src/automations/AutomationEditorNavigation.tsx b/mobile/src/automations/AutomationEditorNavigation.tsx index 40eb908708b..ed6b5f08456 100644 --- a/mobile/src/automations/AutomationEditorNavigation.tsx +++ b/mobile/src/automations/AutomationEditorNavigation.tsx @@ -4,7 +4,7 @@ import { ConfirmModal } from '../components/ConfirmModal' import { useMobileTranslation } from '../i18n/use-mobile-translation' import { useMobileColorScheme } from '../theme/use-mobile-color-scheme' import { AutomationButton } from './AutomationControls' -import type { AutomationDraft } from './automation-draft' +import { automationDraftChanged, type AutomationDraft } from './automation-draft' import { automationStyles as styles } from './automation-styles' type EditorPosition = { step: number; editing: boolean; busy: boolean } @@ -20,25 +20,17 @@ export function leaveThrough(slot: AutomationLeaveSlot | undefined, leave: () => } } -// The *Edited flags record how a field changed, not anything the user would lose. -function draftChanged(original: AutomationDraft, draft: AutomationDraft): boolean { - const before = new Map(Object.entries(original)) - return Object.entries(draft).some( - ([key, value]) => - key !== 'targetEdited' && key !== 'setupDecisionEdited' && value !== before.get(key) - ) -} - /** Asks before a leave drops unsaved edits; fills `slot` while mounted so the tab and host switches ask too. */ export function useAutomationLeaveGuard( draft: AutomationDraft, original: RefObject, - slot?: AutomationLeaveSlot + slot: AutomationLeaveSlot | undefined, + onDiscard: () => void ) { const t = useMobileTranslation() const [pendingLeave, setPendingLeave] = useState<(() => void) | null>(null) const guard: AutomationLeaveGuard = (leave) => { - if (draftChanged(original.current, draft)) { + if (automationDraftChanged(original.current, draft)) { setPendingLeave(() => leave) } else { leave() @@ -64,7 +56,10 @@ export function useAutomationLeaveGuard( confirmLabel={t('automations.mobile.discard', 'Discard')} cancelLabel={t('automations.mobile.keepEditing', 'Keep editing')} destructive - onConfirm={() => pendingLeave?.()} + onConfirm={() => { + onDiscard() + pendingLeave?.() + }} onCancel={() => setPendingLeave(null)} /> ) diff --git a/mobile/src/automations/AutomationManager.draft-persistence.spec.tsx b/mobile/src/automations/AutomationManager.draft-persistence.spec.tsx new file mode 100644 index 00000000000..2f957625a34 --- /dev/null +++ b/mobile/src/automations/AutomationManager.draft-persistence.spec.tsx @@ -0,0 +1,77 @@ +import { act, type ReactTestInstance } from 'react-test-renderer' +import { expect, it, vi } from 'vitest' +import { publishPageDeviceStore } from '../mobile-web-shell/bridge/page-device-store' +import { AutomationField } from './AutomationControls' +import { resetAutomationEditorDraftsForTests } from './automation-editor-draft-row' + +export function registerAutomationDraftPersistenceTests({ + root, + press, + mount, + unmount +}: { + root: () => ReactTestInstance + press: (label: string) => Promise + mount: () => Promise + unmount: () => void +}): void { + it('brings unsaved edits back after the page reloads, and forgets them once discarded', async () => { + // The shell's draft table outlives the page; nothing else does. + const table = new Map() + publishPageDeviceStore({ + available: () => true, + read: async (_table, keys) => ({ + status: 'ok', + values: keys.map((key) => table.get(key) ?? null) + }), + write: async (_table, ops) => { + for (const op of ops) { + if (op.value === null) { + table.delete(op.key) + } else { + table.set(op.key, op.value) + } + } + return { written: true } + } + }) + const field = (label: string) => + root() + .findAllByType(AutomationField) + .find((node) => node.props.label === label)! + async function reload(): Promise { + unmount() + resetAutomationEditorDraftsForTests() + await mount() + await press('New automation') + } + try { + await mount() + await press('New automation') + vi.useFakeTimers() + await act(async () => { + field('Name').props.onChange('Nightly digest') + field('Instructions').props.onChange('Summarize merged PRs') + }) + await act(async () => { + await vi.advanceTimersByTimeAsync(1000) + }) + vi.useRealTimers() + await reload() + expect(field('Name').props.value).toBe('Nightly digest') + expect(field('Instructions').props.value).toBe('Summarize merged PRs') + + await press('Cancel') + const prompt = root().find( + (node) => String(node.type) === 'ConfirmModal' && node.props.visible === true + ) + await act(async () => prompt.props.onConfirm()) + await reload() + expect(field('Name').props.value).toBe('') + expect(table.size).toBe(0) + } finally { + vi.useRealTimers() + publishPageDeviceStore(null) + } + }) +} diff --git a/mobile/src/automations/AutomationManager.test.tsx b/mobile/src/automations/AutomationManager.test.tsx index 298b00fe0c2..f0e2c4493c0 100644 --- a/mobile/src/automations/AutomationManager.test.tsx +++ b/mobile/src/automations/AutomationManager.test.tsx @@ -1,6 +1,7 @@ import { registerAutomationPresentationTests } from './AutomationManager.presentation.spec' import { registerAutomationHostReselectionTests } from './AutomationManager.host-reselection.spec' import { registerAutomationRunHistoryTests } from './AutomationManager.run-history.spec' +import { registerAutomationDraftPersistenceTests } from './AutomationManager.draft-persistence.spec' import { AUTOMATION_CREATE_IDEMPOTENCY_RUNTIME_CAPABILITY } from '../../../src/shared/automation-runtime-capabilities' import { markRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity' import { AutomationEditor } from './AutomationEditor' @@ -8,6 +9,10 @@ import { automationUpdateParams } from './automation-draft' import { createElement } from 'react' import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + automationEditorDraftKey, + resetAutomationEditorDraftsForTests +} from './automation-editor-draft-row' import { AutomationManager } from './AutomationManager' import { AutomationButton, AutomationChoice, AutomationField } from './AutomationControls' import { AutomationRunDetails } from './AutomationRunDetails' @@ -70,6 +75,8 @@ let tree: ReactTestRenderer | null = null afterEach(() => { act(() => tree?.unmount()) tree = null + // Unsaved edits outlive the editor for the page's life; each test is a fresh page. + resetAutomationEditorDraftsForTests() }) function setTree(value: ReactTestRenderer): void { tree = value @@ -204,6 +211,12 @@ async function mount( describe('mobile automation navigation and setup', () => { registerAutomationRunHistoryTests({ root, setTree }) + registerAutomationDraftPersistenceTests({ + root, + press, + mount, + unmount: () => act(() => tree?.unmount()) + }) registerAutomationPresentationTests({ inventory, root, press, setTree, mount }) registerAutomationHostReselectionTests({ inventory, @@ -236,6 +249,7 @@ describe('mobile automation navigation and setup', () => { capabilities: [], onCancel: vi.fn(), onDraftChange: vi.fn(), + draftKey: automationEditorDraftKey(), onSave: save }) ) @@ -337,6 +351,7 @@ describe('mobile automation navigation and setup', () => { capabilities: [], onCancel: vi.fn(), onDraftChange: vi.fn(), + draftKey: automationEditorDraftKey(), onSave: vi.fn() }) ) @@ -414,6 +429,7 @@ describe('mobile automation navigation and setup', () => { capabilities: [], onCancel: vi.fn(), onDraftChange: vi.fn(), + draftKey: automationEditorDraftKey(), onSave: vi.fn() } await act(async () => { @@ -570,6 +586,7 @@ describe('mobile automation navigation and setup', () => { capabilities: [], onCancel: vi.fn(), onDraftChange: vi.fn(), + draftKey: automationEditorDraftKey(), onSave } await act(async () => { diff --git a/mobile/src/automations/AutomationManager.tsx b/mobile/src/automations/AutomationManager.tsx index adc19d9511c..b7abf033afc 100644 --- a/mobile/src/automations/AutomationManager.tsx +++ b/mobile/src/automations/AutomationManager.tsx @@ -28,6 +28,7 @@ import { import { automationUpdateParams, type AutomationDraft } from './automation-draft' import type { AutomationTargets } from './automation-target-inventory' import { useAutomationCatalog } from './use-automation-catalog' +import { automationEditorDraftKey, dropAutomationEditorDraft } from './automation-editor-draft-row' import { automationStyles as styles } from './automation-styles' type AutomationView = @@ -55,7 +56,6 @@ export function AutomationManager(props: { const [view, setView] = useState(startView) const [tab, setTab] = useState<'automations' | 'runs'>('automations') const [filter, setFilter] = useState(null) - const filterId = filter?.automationId ?? '' const [outcome, setOutcome] = useState('all') const [deleting, setDeleting] = useState(null) const createAttempt = useAutomationCreateAttempt( @@ -67,7 +67,8 @@ export function AutomationManager(props: { initialDraft ? { kind: 'editor', row: saved, initialDraft } : { kind: 'detail', row: saved } ) }, - catalog.isCurrentSnapshot + catalog.isCurrentSnapshot, + () => dropAutomationEditorDraft(props.executionHost) ) const setupConsent = useAutomationSetupConsent(props.client, props.executionHost) const disabled = !catalog.connected || catalog.busy @@ -107,9 +108,7 @@ export function AutomationManager(props: { setTab(next) setView({ kind: 'list' }) } - function allAutomations() { - showList('automations') - } + const allAutomations = () => showList('automations') function allRuns(automationId = '') { selectFilter(automationId) showList('runs') @@ -240,6 +239,7 @@ export function AutomationManager(props: { return } const id = saved.automation.id + dropAutomationEditorDraft(props.executionHost, id) catalog.setRows((rows) => [...rows.filter((row) => row.automation.id !== id), saved]) setView({ kind: 'detail', row: saved }) } @@ -286,6 +286,7 @@ export function AutomationManager(props: { client={props.client} current={view.row?.automation} initialDraft={view.initialDraft} + draftKey={automationEditorDraftKey(props.executionHost, view.row?.automation.id)} onDraftChange={createAttempt.observeDraft} leaveGuard={props.leaveGuard} capabilities={catalog.capabilities} @@ -351,7 +352,7 @@ export function AutomationManager(props: { ({ diff --git a/mobile/src/automations/automation-draft.ts b/mobile/src/automations/automation-draft.ts index 2bf7442aeb7..6ca44f81116 100644 --- a/mobile/src/automations/automation-draft.ts +++ b/mobile/src/automations/automation-draft.ts @@ -59,6 +59,22 @@ export function newAutomationDraft(current?: MobileAutomation): AutomationDraft } } +/** The fields that differ from `original`; null when nothing the user would lose did (the *Edited flags only record how a field changed). */ +export function automationDraftEdits( + original: AutomationDraft, + draft: AutomationDraft +): Partial | null { + const before = new Map(Object.entries(original)) + const edits = Object.entries(draft).filter(([key, value]) => value !== before.get(key)) + return edits.some(([key]) => key !== 'targetEdited' && key !== 'setupDecisionEdited') + ? Object.fromEntries(edits) + : null +} + +export function automationDraftChanged(original: AutomationDraft, draft: AutomationDraft): boolean { + return automationDraftEdits(original, draft) !== null +} + // The host checks arguments for an agent or surface this build doesn't know. function assertKnownExtraAgentArgs(draft: AutomationDraft): void { const { agentId, surface } = draft diff --git a/mobile/src/automations/automation-editor-draft-row.ts b/mobile/src/automations/automation-editor-draft-row.ts new file mode 100644 index 00000000000..53e2d15855f --- /dev/null +++ b/mobile/src/automations/automation-editor-draft-row.ts @@ -0,0 +1,143 @@ +import * as z from 'zod' +import { + durableDraftRow, + durableDraftRowKey, + pageDraftRowBackend, + resetDurableDraftRowsForTests, + type DurableDraftRow +} from '../drafts/durable-draft-rows' +import type { AutomationDraft } from './automation-draft' + +// Only the fields the user changed are kept, so a field edited elsewhere since then is not reverted. +export type AutomationDraftEdits = Partial + +const editsSchema = z + .object({ + targetEdited: z.boolean(), + setupDecisionEdited: z.boolean(), + name: z.string(), + prompt: z.string(), + agentId: z.string().nullable(), + surface: z.string(), + setupId: z.string(), + workspaceId: z.string(), + workspaceMode: z.enum(['existing', 'new_per_run']), + rrule: z.string(), + timezone: z.string(), + baseBranch: z.string(), + setupDecision: z.enum(['inherit', 'run', 'skip']), + reuseSession: z.boolean(), + extraAgentArgs: z.string(), + precheckCommand: z.string(), + precheckTimeout: z.string(), + grace: z.string() + } satisfies Record) + .partial() + +type Slot = { + edits: AutomationDraftEdits | null + // A drop before the row was read: the stored copy is what was dropped, so it never comes back. + dropped: boolean + // Dropped since the editor opened: that editor's later typing is not saved (its automation may exist now). + closed: boolean + // The open editor, told whenever a read (the first or a retried one) brings saved edits. + onRestore: ((edits: AutomationDraftEdits) => void) | null + row: DurableDraftRow +} +const slots = new Map() + +/** One row per authority (the paired host, or a server) and automation, `new` for a create. */ +export function automationEditorDraftKey(executionHost?: string, automationId?: string): string { + return durableDraftRowKey( + 'automation', + '', + `${executionHost ?? 'paired'}:${automationId ?? 'new'}` + ) +} + +/** Lays saved edits over the form, except on fields the user has changed since it opened. */ +export function laySavedEdits( + form: AutomationDraft, + original: AutomationDraft, + edits: AutomationDraftEdits +): AutomationDraft { + const before = new Map(Object.entries(original)) + const now = new Map(Object.entries(form)) + const untouched = Object.entries(edits).filter(([key]) => now.get(key) === before.get(key)) + return untouched.length ? { ...form, ...Object.fromEntries(untouched) } : form +} + +function slotFor(key: string): Slot { + let slot = slots.get(key) + if (!slot) { + const created: Slot = { + edits: null, + dropped: false, + closed: false, + onRestore: null, + row: durableDraftRow(key, { + backend: () => pageDraftRowBackend(key), + snapshot: () => created.edits, + restore: (saved) => { + const parsed = editsSchema.safeParse(saved) + if (created.dropped || !parsed.success || Object.keys(parsed.data).length === 0) { + return + } + // Fields typed before the read landed win over the saved copy. + created.edits = { ...parsed.data, ...created.edits } + created.onRestore?.(created.edits) + } + }) + } + slot = created + slots.set(key, slot) + } + return slot +} + +/** The page's copy: what was read, plus typing kept in memory while the store can't be read. */ +export function readAutomationEditorDraft(key: string): AutomationDraftEdits | null { + return slotFor(key).edits +} + +/** Opens the row for an editor: reads it if it hasn't been, and hands saved edits to `onRestore`. */ +export function watchAutomationEditorDraft( + key: string, + onRestore: (edits: AutomationDraftEdits) => void +): () => void { + const slot = slotFor(key) + slot.closed = false + slot.onRestore = onRestore + void slot.row.load() + return () => { + if (slot.onRestore === onRestore) { + slot.onRestore = null + } + } +} + +export function saveAutomationEditorDraft(key: string, edits: AutomationDraftEdits | null): void { + const slot = slotFor(key) + if (slot.closed) { + return + } + slot.edits = edits + slot.row.save(edits ? 'deferred' : 'immediate') +} + +// Discarded edits, and edits the host now holds, must not come back. +export function discardAutomationEditorDraft(key: string): void { + const slot = slotFor(key) + slot.dropped ||= slot.row.state() === 'pending' + saveAutomationEditorDraft(key, null) + slot.closed = true +} + +export function dropAutomationEditorDraft(executionHost?: string, automationId?: string): void { + discardAutomationEditorDraft(automationEditorDraftKey(executionHost, automationId)) +} + +export function resetAutomationEditorDraftsForTests(): void { + slots.clear() + resetDurableDraftRowsForTests() +} diff --git a/mobile/src/automations/use-automation-create-attempt.ts b/mobile/src/automations/use-automation-create-attempt.ts index 91dc7af0498..4ac02c416ec 100644 --- a/mobile/src/automations/use-automation-create-attempt.ts +++ b/mobile/src/automations/use-automation-create-attempt.ts @@ -25,7 +25,9 @@ export function useAutomationCreateAttempt( rows: readonly MobileAutomationRow[], creating: boolean, onReconcile: (row: MobileAutomationRow, initialDraft?: AutomationDraft) => void, - isCurrentSnapshot: () => boolean + isCurrentSnapshot: () => boolean, + // The host holds the create, so the form's saved copy must not bring it back for a second one. + onCreated: () => void ) { const attempt = useRef(null) const latestDraft = useRef(null) @@ -60,8 +62,9 @@ export function useAutomationCreateAttempt( } submitted.affirmed = true attempt.current = null + onCreated() onReconcile(saved, Object.keys(changes).length ? { ...baseline, ...changes } : undefined) - }, [rows, creating, onReconcile, isCurrentSnapshot]) + }, [rows, creating, onReconcile, isCurrentSnapshot, onCreated]) return { observeDraft, reset: () => { @@ -140,10 +143,11 @@ export function useAutomationCreateAttempt( } try { submitted.replyId = automationCreateWrite.interpret(response).automation.id - submitted.affirmed = true } catch { throw new Error(AUTOMATION_OUTCOME_UNKNOWN) } + submitted.affirmed = true + onCreated() } } } diff --git a/mobile/src/automations/use-automation-editor-draft.ts b/mobile/src/automations/use-automation-editor-draft.ts new file mode 100644 index 00000000000..6c7333afdb6 --- /dev/null +++ b/mobile/src/automations/use-automation-editor-draft.ts @@ -0,0 +1,44 @@ +import { useEffect, useRef, useState } from 'react' +import type { MobileAutomation } from './automation-reply-schema' +import { automationDraftEdits, newAutomationDraft, type AutomationDraft } from './automation-draft' +import { + discardAutomationEditorDraft, + laySavedEdits, + readAutomationEditorDraft, + saveAutomationEditorDraft, + watchAutomationEditorDraft +} from './automation-editor-draft-row' + +/** The editor's form, with the user's unsaved edits kept in a draft row and laid back over it on reopen. */ +export function useAutomationEditorDraft( + draftKey: string, + current: MobileAutomation | undefined, + initialDraft: AutomationDraft | undefined +) { + const [saved] = useState(() => (initialDraft ? null : readAutomationEditorDraft(draftKey))) + const restored = useRef(saved) + const [draft, setDraft] = useState( + () => initialDraft ?? { ...newAutomationDraft(current), ...saved } + ) + const original = useRef(newAutomationDraft(current)) + + useEffect(() => { + return watchAutomationEditorDraft(draftKey, (edits) => { + restored.current = edits + setDraft((form) => laySavedEdits(form, original.current, edits)) + }) + }, [draftKey]) + + useEffect(() => { + saveAutomationEditorDraft(draftKey, automationDraftEdits(original.current, draft)) + }, [draft, draftKey]) + + return { + draft, + setDraft, + original, + // The form rebuilt from the host's values, with the saved edits on top. + withSavedEdits: (form: AutomationDraft): AutomationDraft => ({ ...form, ...restored.current }), + discard: () => discardAutomationEditorDraft(draftKey) + } +} diff --git a/mobile/src/automations/use-automation-reads.test.tsx b/mobile/src/automations/use-automation-reads.test.tsx index 274a8f070b9..e00a86b5d62 100644 --- a/mobile/src/automations/use-automation-reads.test.tsx +++ b/mobile/src/automations/use-automation-reads.test.tsx @@ -235,7 +235,8 @@ describe('host-owned automation reads', () => { catalog.rows, true, reconciled, - catalog.isCurrentSnapshot + catalog.isCurrentSnapshot, + vi.fn() ) state = { catalog, attempt } return null @@ -315,7 +316,7 @@ describe('host-owned automation reads', () => { const port = automationClient(() => ({ automation: savedAutomation() })) let state: ReturnType | null = null function Probe() { - state = useAutomationCreateAttempt([], true, vi.fn(), () => true) + state = useAutomationCreateAttempt([], true, vi.fn(), () => true, vi.fn()) return null } await act(async () => { @@ -368,7 +369,7 @@ describe('host-owned automation reads', () => { const port = automationClient(() => ({})) let state: ReturnType | null = null function Probe() { - state = useAutomationCreateAttempt([], true, vi.fn(), () => true) + state = useAutomationCreateAttempt([], true, vi.fn(), () => true, vi.fn()) return null } await act(async () => { diff --git a/mobile/src/drafts/durable-draft-rows.ts b/mobile/src/drafts/durable-draft-rows.ts index 6ba217e68b6..4d0dbe5c2e6 100644 --- a/mobile/src/drafts/durable-draft-rows.ts +++ b/mobile/src/drafts/durable-draft-rows.ts @@ -1,4 +1,7 @@ -import type { PageDeviceStore } from '../mobile-web-shell/bridge/page-device-store' +import { + readPageDeviceStore, + type PageDeviceStore +} from '../mobile-web-shell/bridge/page-device-store' import { decodeDeviceStoreValue, encodeDeviceStoreValue @@ -93,6 +96,18 @@ export function deviceStoreDraftBackend( } } +// An older shell grants no store; there the row lives only as long as the page. +const MEMORY_BACKEND: DurableDraftRowBackend = { + read: async () => ({ ok: true, value: null }), + write: async () => true +} + +/** The device store when the shell grants it, else memory only, as before the store existed. */ +export function pageDraftRowBackend(key: string): DurableDraftRowBackend { + const store = readPageDeviceStore() + return store?.available() ? deviceStoreDraftBackend(store, key) : MEMORY_BACKEND +} + function enqueue(row: Row, step: () => Promise): Promise { row.chain = row.chain.then(step).catch((error: unknown) => { console.warn('[draft-rows] a draft could not be saved; it is kept in memory', error)