this repo has no description
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: persist public form runner progress

+786 -9
+105 -9
components/public-form-runner.tsx
··· 12 12 import { 13 13 useCallback, 14 14 useEffect, 15 + useLayoutEffect, 15 16 useMemo, 16 17 useRef, 17 18 useState, ··· 44 45 isLegacyDefaultCompletionMessage, 45 46 isLegacyDefaultCompletionTitle, 46 47 } from "@/lib/form-defaults"; 47 - import type { PublicForm } from "@/lib/form-types"; 48 + import type { AnswerValue, PublicForm } from "@/lib/form-types"; 49 + import { 50 + createPublicFormDraft, 51 + getPublicFormDraftStorageKey, 52 + readStoredPublicFormDraft, 53 + removeStoredPublicFormDraft, 54 + validatePublicFormDraft, 55 + writeStoredPublicFormDraft, 56 + } from "@/lib/public-form-draft"; 48 57 import { cn } from "@/lib/utils"; 49 58 50 59 async function submitResponse( 51 60 slug: string, 52 - answers: Record<string, string | string[]>, 61 + answers: Record<string, AnswerValue>, 53 62 ) { 54 63 const response = await fetch(`/api/public/forms/${slug}/responses`, { 55 64 method: "POST", ··· 76 85 () => (form.blocks[0] ? [form.blocks[0].id] : []), 77 86 [form.blocks], 78 87 ); 88 + const storageKey = useMemo( 89 + () => getPublicFormDraftStorageKey(form.id), 90 + [form.id], 91 + ); 79 92 const { t } = useI18n(); 80 - const [answers, setAnswers] = useState<Record<string, string | string[]>>({}); 93 + const [answers, setAnswers] = useState<Record<string, AnswerValue>>({}); 81 94 const [route, setRoute] = useState({ history: initialHistory, cursor: 0 }); 82 95 const [toasts, setToasts] = useState<ToastData[]>([]); 83 96 const [isSubmitting, setIsSubmitting] = useState(false); 84 97 const [isComplete, setIsComplete] = useState(false); 98 + const [hasResolvedDraft, setHasResolvedDraft] = useState(false); 85 99 const submitLockRef = useRef(false); 86 100 87 101 const blocksById = useMemo( ··· 132 146 const completionLinkLabel = form.completionLinkLabel?.trim() || null; 133 147 const completionLinkUrl = form.completionLinkUrl?.trim() || null; 134 148 135 - function setAnswer(blockId: string, value: string | string[]) { 149 + function setAnswer(blockId: string, value: AnswerValue) { 136 150 setAnswers((current) => ({ 137 151 ...current, 138 152 [blockId]: value, ··· 151 165 setToasts((current) => current.filter((toast) => toast.id !== id)); 152 166 }, []); 153 167 168 + const hasDraftProgress = useMemo(() => { 169 + const hasAnyAnswer = Object.values(answers).some((value) => 170 + typeof value === "string" ? value.trim().length > 0 : value.length > 0, 171 + ); 172 + 173 + return hasAnyAnswer || cursor > 0 || history.length > initialHistory.length; 174 + }, [answers, cursor, history.length, initialHistory.length]); 175 + 176 + useLayoutEffect(() => { 177 + const storedDraft = readStoredPublicFormDraft(storageKey); 178 + 179 + if (storedDraft.status !== "ok") { 180 + if (storedDraft.status === "invalid") { 181 + removeStoredPublicFormDraft(storageKey); 182 + showToast(t("publicRunner.savedProgressDiscarded"), "error"); 183 + } 184 + 185 + setHasResolvedDraft(true); 186 + return; 187 + } 188 + 189 + const validatedDraft = validatePublicFormDraft(form, storedDraft.draft); 190 + 191 + if (validatedDraft.status === "restored") { 192 + setAnswers(validatedDraft.draft.answers); 193 + setRoute({ 194 + history: validatedDraft.draft.history, 195 + cursor: validatedDraft.draft.cursor, 196 + }); 197 + showToast(t("publicRunner.progressRestored")); 198 + } else { 199 + removeStoredPublicFormDraft(storageKey); 200 + showToast(t("publicRunner.savedProgressDiscarded"), "error"); 201 + } 202 + 203 + setHasResolvedDraft(true); 204 + }, [form, showToast, storageKey, t]); 205 + 206 + useEffect(() => { 207 + if (!hasResolvedDraft || isComplete) { 208 + return; 209 + } 210 + 211 + if (!hasDraftProgress) { 212 + removeStoredPublicFormDraft(storageKey); 213 + return; 214 + } 215 + 216 + const timeoutId = window.setTimeout(() => { 217 + writeStoredPublicFormDraft( 218 + storageKey, 219 + createPublicFormDraft(form, { 220 + answers, 221 + history, 222 + cursor, 223 + }), 224 + ); 225 + }, 250); 226 + 227 + return () => window.clearTimeout(timeoutId); 228 + }, [ 229 + answers, 230 + cursor, 231 + form, 232 + hasDraftProgress, 233 + hasResolvedDraft, 234 + history, 235 + isComplete, 236 + storageKey, 237 + ]); 238 + 154 239 const validateStep = useCallback( 155 - (answerSet: Record<string, string | string[]> = answers) => { 240 + (answerSet: Record<string, AnswerValue> = answers) => { 156 241 if (!currentBlock || currentBlock.type === "TEXT") { 157 242 return true; 158 243 } ··· 328 413 ); 329 414 330 415 const handleContinue = useCallback( 331 - async (answerSet: Record<string, string | string[]> = answers) => { 416 + async (answerSet: Record<string, AnswerValue> = answers) => { 332 417 if (!validateStep(answerSet) || !currentBlock) { 333 418 return; 334 419 } ··· 350 435 try { 351 436 setIsSubmitting(true); 352 437 await submitResponse(form.slug, answerSet); 438 + removeStoredPublicFormDraft(storageKey); 353 439 setIsComplete(true); 354 440 } catch (caughtError) { 355 441 showToast( ··· 384 470 }; 385 471 }); 386 472 }, 387 - [answers, currentBlock, form.blocks, form.slug, showToast, t, validateStep], 473 + [ 474 + answers, 475 + currentBlock, 476 + form.blocks, 477 + form.slug, 478 + showToast, 479 + storageKey, 480 + t, 481 + validateStep, 482 + ], 388 483 ); 389 484 390 485 const handleBack = useCallback(() => { ··· 416 511 417 512 function handleChoiceEnter( 418 513 event: ReactKeyboardEvent<HTMLButtonElement>, 419 - nextAnswer: string | string[], 514 + nextAnswer: AnswerValue, 420 515 ) { 421 516 if ( 422 517 event.key !== "Enter" || ··· 437 532 } 438 533 439 534 useEffect(() => { 440 - if (isComplete || isSubmitting || !currentBlock) { 535 + if (!hasResolvedDraft || isComplete || isSubmitting || !currentBlock) { 441 536 return; 442 537 } 443 538 ··· 477 572 cursor, 478 573 handleBack, 479 574 handleContinue, 575 + hasResolvedDraft, 480 576 isComplete, 481 577 isSubmitting, 482 578 ]);
+231
lib/public-form-draft.test.ts
··· 1 + import { describe, expect, test } from "bun:test"; 2 + 3 + import type { SerializedBlock } from "@/lib/blocks"; 4 + import type { PublicForm } from "@/lib/form-types"; 5 + import { 6 + createPublicFormDraft, 7 + readStoredPublicFormDraft, 8 + validatePublicFormDraft, 9 + } from "@/lib/public-form-draft"; 10 + 11 + function createForm(blocks: SerializedBlock[]): PublicForm { 12 + return { 13 + id: "form-1", 14 + title: "Demo form", 15 + description: "", 16 + completionTitle: "Thanks", 17 + completionMessage: "Done", 18 + completionLinkLabel: null, 19 + completionLinkUrl: null, 20 + showProgress: true, 21 + slug: "demo-form", 22 + blocks, 23 + }; 24 + } 25 + 26 + function createBlock( 27 + overrides: Partial<SerializedBlock> & Pick<SerializedBlock, "id" | "type">, 28 + ): SerializedBlock { 29 + const now = new Date("2026-04-14T00:00:00.000Z"); 30 + 31 + return { 32 + id: overrides.id, 33 + formId: "form-1", 34 + type: overrides.type, 35 + title: overrides.title ?? overrides.id, 36 + description: overrides.description ?? "", 37 + required: overrides.required ?? false, 38 + position: overrides.position ?? 0, 39 + createdAt: overrides.createdAt ?? now, 40 + updatedAt: overrides.updatedAt ?? now, 41 + config: 42 + overrides.config ?? 43 + (overrides.type === "TEXT" 44 + ? { body: "Intro" } 45 + : overrides.type === "SINGLE_CHOICE" 46 + ? { 47 + options: ["yes", "no"], 48 + branchRules: [], 49 + defaultNextBlockId: null, 50 + } 51 + : { 52 + placeholder: "", 53 + validationRegex: null, 54 + branchRules: [], 55 + defaultNextBlockId: null, 56 + }), 57 + }; 58 + } 59 + 60 + const originalWindowDescriptor = Object.getOwnPropertyDescriptor( 61 + globalThis, 62 + "window", 63 + ); 64 + 65 + function mockWindowWithStorage(storage: { 66 + getItem: (key: string) => string | null; 67 + setItem?: (key: string, value: string) => void; 68 + removeItem?: (key: string) => void; 69 + }) { 70 + Object.defineProperty(globalThis, "window", { 71 + configurable: true, 72 + value: { 73 + localStorage: { 74 + getItem: storage.getItem, 75 + setItem: storage.setItem ?? (() => undefined), 76 + removeItem: storage.removeItem ?? (() => undefined), 77 + }, 78 + }, 79 + }); 80 + } 81 + 82 + function restoreWindow() { 83 + if (originalWindowDescriptor) { 84 + Object.defineProperty(globalThis, "window", originalWindowDescriptor); 85 + return; 86 + } 87 + 88 + Reflect.deleteProperty(globalThis, "window"); 89 + } 90 + 91 + describe("public form draft validation", () => { 92 + test("restores a valid linear draft", () => { 93 + const form = createForm([ 94 + createBlock({ id: "intro", type: "TEXT", position: 0 }), 95 + createBlock({ id: "name", type: "SHORT_TEXT", position: 1 }), 96 + createBlock({ id: "notes", type: "LONG_TEXT", position: 2 }), 97 + ]); 98 + const draft = createPublicFormDraft(form, { 99 + answers: { name: "Ada", notes: "Hello" }, 100 + history: ["intro", "name", "notes"], 101 + cursor: 2, 102 + }); 103 + 104 + const result = validatePublicFormDraft(form, draft); 105 + 106 + expect(result.status).toBe("restored"); 107 + if (result.status !== "restored") { 108 + return; 109 + } 110 + 111 + expect(JSON.stringify(result.draft.history)).toBe( 112 + JSON.stringify(["intro", "name", "notes"]), 113 + ); 114 + expect(result.draft.cursor).toBe(2); 115 + expect(JSON.stringify(result.draft.answers)).toBe( 116 + JSON.stringify({ name: "Ada", notes: "Hello" }), 117 + ); 118 + }); 119 + 120 + test("truncates future history to the active cursor when restoring", () => { 121 + const form = createForm([ 122 + createBlock({ id: "intro", type: "TEXT", position: 0 }), 123 + createBlock({ id: "email", type: "SHORT_TEXT", position: 1 }), 124 + createBlock({ id: "notes", type: "LONG_TEXT", position: 2 }), 125 + ]); 126 + const draft = createPublicFormDraft(form, { 127 + answers: { email: "me@example.com", notes: "Saved later step" }, 128 + history: ["intro", "email", "notes"], 129 + cursor: 1, 130 + }); 131 + 132 + const result = validatePublicFormDraft(form, draft); 133 + 134 + expect(result.status).toBe("restored"); 135 + if (result.status !== "restored") { 136 + return; 137 + } 138 + 139 + expect(JSON.stringify(result.draft.history)).toBe( 140 + JSON.stringify(["intro", "email"]), 141 + ); 142 + expect(result.draft.cursor).toBe(1); 143 + }); 144 + 145 + test("marks drafts stale when the form fingerprint changes", () => { 146 + const initialForm = createForm([ 147 + createBlock({ id: "intro", type: "TEXT", position: 0 }), 148 + createBlock({ id: "name", type: "SHORT_TEXT", position: 1 }), 149 + ]); 150 + const changedForm = createForm([ 151 + createBlock({ id: "intro", type: "TEXT", position: 0 }), 152 + createBlock({ 153 + id: "name", 154 + type: "SHORT_TEXT", 155 + position: 1, 156 + required: true, 157 + }), 158 + ]); 159 + const draft = createPublicFormDraft(initialForm, { 160 + answers: { name: "Ada" }, 161 + history: ["intro", "name"], 162 + cursor: 1, 163 + }); 164 + 165 + const result = validatePublicFormDraft(changedForm, draft); 166 + 167 + expect(JSON.stringify(result)).toBe(JSON.stringify({ status: "stale" })); 168 + }); 169 + 170 + test("returns invalid for malformed stored draft json", () => { 171 + try { 172 + mockWindowWithStorage({ 173 + getItem: () => "{not valid json", 174 + }); 175 + 176 + const result = readStoredPublicFormDraft("form-1"); 177 + 178 + expect(JSON.stringify(result)).toBe( 179 + JSON.stringify({ status: "invalid" }), 180 + ); 181 + } finally { 182 + restoreWindow(); 183 + } 184 + }); 185 + 186 + test("returns unavailable when localStorage access throws", () => { 187 + try { 188 + mockWindowWithStorage({ 189 + getItem: () => { 190 + throw new Error("Storage disabled"); 191 + }, 192 + }); 193 + 194 + const result = readStoredPublicFormDraft("form-1"); 195 + 196 + expect(JSON.stringify(result)).toBe( 197 + JSON.stringify({ status: "unavailable" }), 198 + ); 199 + } finally { 200 + restoreWindow(); 201 + } 202 + }); 203 + 204 + test("rejects drafts whose saved route does not match branching", () => { 205 + const form = createForm([ 206 + createBlock({ 207 + id: "path", 208 + type: "SINGLE_CHOICE", 209 + position: 0, 210 + config: { 211 + options: ["yes", "no"], 212 + branchRules: [ 213 + { operator: "equals", value: "yes", targetBlockId: "follow-up" }, 214 + ], 215 + defaultNextBlockId: "fallback", 216 + }, 217 + }), 218 + createBlock({ id: "fallback", type: "SHORT_TEXT", position: 1 }), 219 + createBlock({ id: "follow-up", type: "SHORT_TEXT", position: 2 }), 220 + ]); 221 + const draft = createPublicFormDraft(form, { 222 + answers: { path: "yes" }, 223 + history: ["path", "fallback"], 224 + cursor: 1, 225 + }); 226 + 227 + const result = validatePublicFormDraft(form, draft); 228 + 229 + expect(JSON.stringify(result)).toBe(JSON.stringify({ status: "invalid" })); 230 + }); 231 + });
+232
lib/public-form-draft.ts
··· 1 + import { z } from "zod"; 2 + 3 + import { resolveNextBlockId } from "@/lib/branching"; 4 + import type { SerializedBlock } from "@/lib/blocks"; 5 + import type { AnswerValue, PublicForm } from "@/lib/form-types"; 6 + 7 + export const PUBLIC_FORM_DRAFT_STORAGE_PREFIX = 8 + "lively-forms-public-runner-draft"; 9 + export const PUBLIC_FORM_DRAFT_VERSION = 1; 10 + 11 + const answerValueSchema = z.union([z.string(), z.array(z.string())]); 12 + 13 + const publicFormDraftSchema = z.object({ 14 + version: z.literal(PUBLIC_FORM_DRAFT_VERSION), 15 + formId: z.string().min(1), 16 + fingerprint: z.string().min(1), 17 + answers: z.record(z.string(), answerValueSchema), 18 + history: z.array(z.string()), 19 + cursor: z.number().int().min(0), 20 + savedAt: z.number().int().nonnegative(), 21 + }); 22 + 23 + export type PublicFormDraft = z.infer<typeof publicFormDraftSchema>; 24 + 25 + export type PublicFormDraftReadResult = 26 + | { 27 + status: "missing" | "unavailable" | "invalid"; 28 + } 29 + | { 30 + status: "ok"; 31 + draft: PublicFormDraft; 32 + }; 33 + 34 + export type PublicFormDraftValidationResult = 35 + | { 36 + status: "restored"; 37 + draft: PublicFormDraft; 38 + } 39 + | { 40 + status: "stale" | "invalid"; 41 + }; 42 + 43 + function stableStringify(value: unknown): string { 44 + if (value === null || typeof value !== "object") { 45 + return JSON.stringify(value); 46 + } 47 + 48 + if (Array.isArray(value)) { 49 + return `[${value.map((entry) => stableStringify(entry)).join(",")}]`; 50 + } 51 + 52 + const entries = Object.entries(value) 53 + .sort(([left], [right]) => left.localeCompare(right)) 54 + .map( 55 + ([key, entryValue]) => 56 + `${JSON.stringify(key)}:${stableStringify(entryValue)}`, 57 + ); 58 + 59 + return `{${entries.join(",")}}`; 60 + } 61 + 62 + function hashString(value: string) { 63 + let hash = 5381; 64 + 65 + for (let index = 0; index < value.length; index += 1) { 66 + hash = (hash * 33) ^ value.charCodeAt(index); 67 + } 68 + 69 + return (hash >>> 0).toString(36); 70 + } 71 + 72 + function sanitizeBlocksForFingerprint(blocks: SerializedBlock[]) { 73 + return blocks.map((block) => ({ 74 + id: block.id, 75 + type: block.type, 76 + required: block.required, 77 + position: block.position, 78 + config: block.config, 79 + })); 80 + } 81 + 82 + function sanitizeAnswersForDraft( 83 + answers: Record<string, AnswerValue>, 84 + ): Record<string, AnswerValue> { 85 + return Object.fromEntries( 86 + Object.entries(answers).filter((entry): entry is [string, AnswerValue] => { 87 + const [, value] = entry; 88 + 89 + return ( 90 + typeof value === "string" || 91 + (Array.isArray(value) && 92 + value.every((item) => typeof item === "string")) 93 + ); 94 + }), 95 + ); 96 + } 97 + 98 + export function getPublicFormDraftStorageKey(formId: string) { 99 + return `${PUBLIC_FORM_DRAFT_STORAGE_PREFIX}:${formId}`; 100 + } 101 + 102 + export function createPublicFormDraftFingerprint( 103 + form: Pick<PublicForm, "blocks">, 104 + ) { 105 + return hashString(stableStringify(sanitizeBlocksForFingerprint(form.blocks))); 106 + } 107 + 108 + export function parsePublicFormDraft(value: unknown) { 109 + const parsed = publicFormDraftSchema.safeParse(value); 110 + return parsed.success ? parsed.data : null; 111 + } 112 + 113 + export function readStoredPublicFormDraft( 114 + storageKey: string, 115 + ): PublicFormDraftReadResult { 116 + let rawDraft: string | null = null; 117 + 118 + try { 119 + rawDraft = window.localStorage.getItem(storageKey); 120 + } catch { 121 + return { status: "unavailable" }; 122 + } 123 + 124 + if (!rawDraft) { 125 + return { status: "missing" }; 126 + } 127 + 128 + try { 129 + const draft = parsePublicFormDraft(JSON.parse(rawDraft)); 130 + return draft ? { status: "ok", draft } : { status: "invalid" }; 131 + } catch { 132 + return { status: "invalid" }; 133 + } 134 + } 135 + 136 + export function writeStoredPublicFormDraft( 137 + storageKey: string, 138 + draft: PublicFormDraft, 139 + ) { 140 + try { 141 + window.localStorage.setItem(storageKey, JSON.stringify(draft)); 142 + } catch { 143 + // ignore persistence failures 144 + } 145 + } 146 + 147 + export function removeStoredPublicFormDraft(storageKey: string) { 148 + try { 149 + window.localStorage.removeItem(storageKey); 150 + } catch { 151 + // ignore persistence failures 152 + } 153 + } 154 + 155 + export function createPublicFormDraft( 156 + form: Pick<PublicForm, "id" | "blocks">, 157 + state: { 158 + answers: Record<string, AnswerValue>; 159 + history: string[]; 160 + cursor: number; 161 + }, 162 + ): PublicFormDraft { 163 + return { 164 + version: PUBLIC_FORM_DRAFT_VERSION, 165 + formId: form.id, 166 + fingerprint: createPublicFormDraftFingerprint(form), 167 + answers: sanitizeAnswersForDraft(state.answers), 168 + history: [...state.history], 169 + cursor: state.cursor, 170 + savedAt: Date.now(), 171 + }; 172 + } 173 + 174 + export function validatePublicFormDraft( 175 + form: Pick<PublicForm, "id" | "blocks">, 176 + draft: PublicFormDraft, 177 + ): PublicFormDraftValidationResult { 178 + if ( 179 + draft.formId !== form.id || 180 + draft.fingerprint !== createPublicFormDraftFingerprint(form) 181 + ) { 182 + return { status: "stale" }; 183 + } 184 + 185 + const firstBlock = form.blocks[0]; 186 + 187 + if (!firstBlock) { 188 + return draft.history.length === 0 && draft.cursor === 0 189 + ? { 190 + status: "restored", 191 + draft: { 192 + ...draft, 193 + history: [], 194 + }, 195 + } 196 + : { status: "invalid" }; 197 + } 198 + 199 + if (draft.history.length === 0 || draft.cursor >= draft.history.length) { 200 + return { status: "invalid" }; 201 + } 202 + 203 + const activeHistory = draft.history.slice(0, draft.cursor + 1); 204 + 205 + if (activeHistory[0] !== firstBlock.id) { 206 + return { status: "invalid" }; 207 + } 208 + 209 + for (let index = 0; index < activeHistory.length - 1; index += 1) { 210 + const currentBlockId = activeHistory[index]; 211 + const expectedNextBlockId = activeHistory[index + 1]; 212 + const resolvedNextBlockId = resolveNextBlockId( 213 + form.blocks, 214 + currentBlockId, 215 + draft.answers, 216 + ); 217 + 218 + if (!resolvedNextBlockId || resolvedNextBlockId !== expectedNextBlockId) { 219 + return { status: "invalid" }; 220 + } 221 + } 222 + 223 + return { 224 + status: "restored", 225 + draft: { 226 + ...draft, 227 + answers: sanitizeAnswersForDraft(draft.answers), 228 + history: activeHistory, 229 + cursor: activeHistory.length - 1, 230 + }, 231 + }; 232 + }
+2
locales/en.yml
··· 287 287 invalidTextFormat: Use the expected format before continuing. 288 288 agree: Agree 289 289 doNotAgree: Do not agree 290 + progressRestored: Saved progress restored. 291 + savedProgressDiscarded: Saved progress could not be restored. 290 292 submitError: Could not submit response. 291 293 back: Back 292 294 continue: Continue
+2
locales/ru.yml
··· 287 287 invalidTextFormat: Используйте ожидаемый формат ответа перед продолжением. 288 288 agree: Согласен 289 289 doNotAgree: Не согласен 290 + progressRestored: Сохранённый прогресс восстановлен. 291 + savedProgressDiscarded: Сохранённый прогресс не удалось восстановить. 290 292 submitError: Не удалось отправить ответ. 291 293 back: Назад 292 294 continue: Далее
+2
openspec/changes/archive/2026-04-14-persist-form-runner-progress/.openspec.yaml
··· 1 + schema: spec-driven 2 + created: 2026-04-14
+145
openspec/changes/archive/2026-04-14-persist-form-runner-progress/design.md
··· 1 + ## Context 2 + 3 + The public runner currently keeps its respondent state entirely in React component memory inside `components/public-form-runner.tsx`. That state already includes the pieces needed to resume a session—`answers`, visited `history`, and `cursor`—but a refresh or closed tab recreates the component and drops all progress. 4 + 5 + This change is intentionally scoped to anonymous public forms. There is no respondent account system, no server-side draft model, and no need to synchronize draft state across devices. The existing runner already has branch-aware navigation, client-side validation, and submission cleanup paths, so the main design problem is how to persist and safely restore the runner state without letting stale drafts corrupt a changed published form. 6 + 7 + Key constraints: 8 + - Persistence must remain optional and best-effort; the runner must still work when browser storage is unavailable. 9 + - Restored progress must respect the current branching model and not trust corrupted route history blindly. 10 + - The public form payload does not currently expose a dedicated published-version field for draft invalidation. 11 + - The feature should not require database, API, or authentication changes for v1. 12 + 13 + ## Goals / Non-Goals 14 + 15 + **Goals:** 16 + - Preserve in-progress anonymous runner answers and visited position across refreshes and tab closes in the same browser. 17 + - Restore only drafts that still match the current published form structure. 18 + - Keep branching, validation, progress, and submission behavior consistent after restoration. 19 + - Clear persisted draft data after successful submission. 20 + - Provide lightweight respondent feedback when saved progress is restored or discarded. 21 + 22 + **Non-Goals:** 23 + - Cross-device or cross-browser draft sync. 24 + - Server-side draft persistence or respondent identity. 25 + - Multiple named drafts for the same form. 26 + - A new creator-facing configuration switch for draft persistence. 27 + - Full privacy hardening such as encryption-at-rest in the browser. 28 + 29 + ## Decisions 30 + 31 + ### 1. Use browser `localStorage` as the only persistence layer 32 + Persist runner drafts locally in the browser under a form-scoped storage key. This keeps the feature aligned with the anonymous public runner: no login, no backend writes before submission, and no schema changes. 33 + 34 + Draft persistence will be best-effort. All storage reads/writes will be wrapped in `try/catch`, matching the existing theme-preference pattern, so blocked storage or quota failures degrade gracefully to the current non-persistent behavior. 35 + 36 + Why this approach: 37 + - Matches the user's requested behavior for surviving refreshes. 38 + - Avoids introducing draft APIs, abuse controls, or cleanup jobs. 39 + - Keeps the implementation isolated to the public runner. 40 + 41 + Alternatives considered: 42 + - `sessionStorage`: survives refreshes but not closed tabs, which is weaker than the requested persistence. 43 + - Server-side anonymous drafts: more durable, but adds backend complexity and draft identity problems that are out of scope. 44 + 45 + ### 2. Persist a validated draft payload instead of raw component state 46 + Store a small JSON payload containing: 47 + - `version` 48 + - `formId` or equivalent form-scoped identity 49 + - `fingerprint` 50 + - `answers` 51 + - `history` 52 + - `cursor` 53 + - `savedAt` 54 + 55 + The restore path should validate this payload shape before using it. Invalid or partially corrupted JSON should be ignored and removed. 56 + 57 + Why this approach: 58 + - Keeps the payload explicit and migration-friendly. 59 + - Avoids coupling storage directly to transient React implementation details. 60 + - Makes it straightforward to add a future format bump if needed. 61 + 62 + Alternatives considered: 63 + - Store only `answers`: simpler, but loses the respondent's current step and weakens restoration for branched flows. 64 + - Store the entire component state object: faster initially, but brittle if unrelated UI state changes. 65 + 66 + ### 3. Invalidate drafts using a deterministic form fingerprint derived from the public form payload 67 + Because the public payload does not expose a published revision identifier, compute a deterministic fingerprint from the ordered serialized public blocks that drive routing and validation. The fingerprint should include enough structure to detect changes that could make a saved draft unsafe to reuse, such as block order, block IDs, block types, required flags, and block config. 68 + 69 + On load, compare the stored fingerprint with the fingerprint computed from the current form. A mismatch means the draft is stale and must be discarded. 70 + 71 + Why this approach: 72 + - Prevents restoring answers onto a materially different published form. 73 + - Avoids expanding the server payload just to support draft invalidation. 74 + - Keeps all invalidation logic local to the runner. 75 + 76 + Alternatives considered: 77 + - Add `updatedAt` or a version field to `PublicForm`: workable, but introduces API/type changes for a feature that can be implemented client-side. 78 + - Ignore form changes and always restore: unsafe for branching and validation changes. 79 + 80 + ### 4. Reconstruct restoration safety by replaying the saved route prefix 81 + Do not trust stored `history` and `cursor` blindly. After reading a valid payload and matching fingerprint, replay the saved route prefix from the first block using the saved answers and the existing `resolveNextBlockId` helper. Restore only if the saved visited path remains valid for the current form and the saved cursor points at a block that is still reachable. 82 + 83 + This means the restore flow should: 84 + 1. confirm the first saved block matches the form start 85 + 2. step through the saved visited prefix using current branching rules 86 + 3. stop and discard the draft if any next block does not match the replayed route 87 + 4. restore `answers`, `history`, and `cursor` only after validation succeeds 88 + 89 + Why this approach: 90 + - Keeps restored state aligned with the same routing logic used during live navigation. 91 + - Protects against corrupted or manually edited local storage. 92 + - Preserves the respondent's exact place in branched flows when the draft is valid. 93 + 94 + Alternatives considered: 95 + - Trust the stored route directly: simpler, but unsafe when branching rules or stored payloads drift. 96 + - Recompute only from answers and ignore saved cursor: safer, but can move respondents away from the step they were actively on. 97 + 98 + ### 5. Persist with a short debounce after runner state changes 99 + Save draft updates whenever `answers` or route state changes, but debounce writes slightly so long-text typing does not hammer synchronous `localStorage` writes on every keystroke. The persist effect should also skip writes until the initial restore attempt has completed, avoiding accidental overwrites during hydration. 100 + 101 + Why this approach: 102 + - Keeps the draft reasonably current without excessive write churn. 103 + - Preserves refresh resilience even if a respondent has not clicked Continue yet. 104 + - Minimizes the chance of writing a default empty draft before restore logic runs. 105 + 106 + Alternatives considered: 107 + - Write on every keystroke: simplest, but noisier and less efficient. 108 + - Write only on Continue/Back: misses unsaved typing in the active step. 109 + 110 + ### 6. Clear drafts on successful submission and surface toast-level feedback 111 + When submission succeeds, remove the stored draft before switching to the completion state. Also show lightweight localized feedback when a draft is restored successfully or when a stale/corrupted draft is discarded. 112 + 113 + A toast-level message fits the existing runner UX and avoids blocking the respondent with extra prompts. The initial implementation does not add a separate “resume vs start over” decision dialog. 114 + 115 + Why this approach: 116 + - Prevents old answers from reappearing after a completed response. 117 + - Reuses the runner's existing toast system and localization flow. 118 + - Keeps the UX simple for the first release. 119 + 120 + Alternatives considered: 121 + - Silent restore/discard: lower UI overhead, but more surprising to respondents. 122 + - Modal restore prompt: more explicit, but adds friction and extra state management. 123 + 124 + ## Risks / Trade-offs 125 + 126 + - **Local browser storage can retain sensitive draft answers on shared devices** → Mitigation: scope drafts per form, clear on successful submission, and consider a manual discard control or TTL later. 127 + - **Fingerprinting the full block structure may invalidate drafts after minor copy-only edits** → Mitigation: prefer safe invalidation over restoring against a changed form; losing a stale draft is better than resuming on the wrong route. 128 + - **Storage may be unavailable in some browsers or privacy modes** → Mitigation: treat persistence as best-effort and fall back to current in-memory behavior without breaking the runner. 129 + - **Replay validation may discard some drafts that could theoretically be partially recovered** → Mitigation: keep restoration strict in v1 so resumed sessions are predictable and branch-safe. 130 + 131 + ## Migration Plan 132 + 133 + 1. Add a small public-runner draft helper module for storage keys, payload parsing, fingerprinting, and remove/read/write helpers. 134 + 2. Integrate restore-on-mount logic into `components/public-form-runner.tsx` using the existing branch resolution utilities. 135 + 3. Add debounced persistence and successful-submission cleanup in the runner. 136 + 4. Add localized public-runner feedback strings for restored and discarded drafts. 137 + 5. Verify refresh, close/reopen, stale draft invalidation, branched-path restore, and post-submit cleanup behavior manually and/or with regression tests. 138 + 139 + Rollback strategy: 140 + - Stop reading and writing the draft helper from the public runner. Because the feature is browser-local only, no database or API rollback is needed. 141 + 142 + ## Open Questions 143 + 144 + - Do we want a visible “Start over / clear saved progress” action in the runner after the basic restore flow ships? 145 + - Should browser-local drafts expire automatically after a fixed age, such as 7 or 30 days?
+25
openspec/changes/archive/2026-04-14-persist-form-runner-progress/proposal.md
··· 1 + ## Why 2 + 3 + Refreshing a published form currently resets the public runner back to the start and discards any answers the respondent has already entered. Adding browser-local draft persistence now makes long or interruption-prone forms more forgiving without requiring respondent accounts or backend draft storage. 4 + 5 + ## What Changes 6 + 7 + - Persist in-progress public form answers and visited runner position in browser local storage so respondents can recover from refreshes, tab closes, or accidental navigation away. 8 + - Restore a saved in-progress draft when the same respondent reopens the same published form in the same browser and continue from the last valid visited step. 9 + - Invalidate saved runner drafts when the published form structure no longer matches the saved draft, and clear the saved draft after successful submission. 10 + - Surface lightweight runner feedback when progress is restored or discarded so respondents are not surprised by persisted state. 11 + 12 + ## Capabilities 13 + 14 + ### New Capabilities 15 + - None. 16 + 17 + ### Modified Capabilities 18 + - `anonymous-form-runner`: respondents can resume an in-progress published form from browser-local saved progress instead of losing answers on refresh. 19 + 20 + ## Impact 21 + 22 + - `components/public-form-runner.tsx` runner state management, restoration flow, and submission cleanup 23 + - Public form payload shaping and/or client-side draft fingerprinting used to detect stale saved progress 24 + - Localized public runner copy for restore/discard feedback 25 + - Browser storage behavior for anonymous respondent drafts with no server-side persistence changes
+27
openspec/changes/archive/2026-04-14-persist-form-runner-progress/specs/anonymous-form-runner/spec.md
··· 1 + ## ADDED Requirements 2 + 3 + ### Requirement: Runner preserves in-progress responses in the same browser 4 + The system SHALL save a respondent's in-progress answers and current visited step for a published form in browser-local storage while the respondent is completing the form. When the same published form is reopened in the same browser before successful submission, the system SHALL restore the saved draft and resume from the last valid visited step instead of starting over. 5 + 6 + #### Scenario: Respondent refreshes a form after answering several questions 7 + - **WHEN** a respondent has entered answers on a published form and refreshes the page before submitting 8 + - **THEN** the system restores the saved answers and reopens the runner at the last visited step for that saved draft 9 + 10 + #### Scenario: Respondent reopens the same form in the same browser 11 + - **WHEN** a respondent leaves a published form before submitting and later opens the same form again in the same browser 12 + - **THEN** the system resumes the saved in-progress draft rather than presenting a blank new run 13 + 14 + #### Scenario: Respondent is informed when progress is restored 15 + - **WHEN** the runner successfully restores a saved draft 16 + - **THEN** the system indicates that previously saved progress has been restored 17 + 18 + ### Requirement: Runner discards stale or completed local drafts safely 19 + The system SHALL ignore and clear a saved local draft when that draft no longer matches the current published form structure, and SHALL clear the saved local draft after a successful submission so later visits begin as a new response. 20 + 21 + #### Scenario: Saved draft no longer matches the published form 22 + - **WHEN** a respondent opens a published form and a saved local draft exists for that form but its saved structure fingerprint does not match the current published form 23 + - **THEN** the system discards the saved local draft, starts a fresh runner session, and indicates that saved progress could not be restored 24 + 25 + #### Scenario: Respondent submits a form successfully 26 + - **WHEN** a respondent completes and successfully submits a published form that has a saved local draft 27 + - **THEN** the system clears the saved local draft for that form so reopening the form starts a new response
+15
openspec/changes/archive/2026-04-14-persist-form-runner-progress/tasks.md
··· 1 + ## 1. Draft persistence foundation 2 + 3 + - [x] 1.1 Add a public runner draft helper module for storage keys, payload parsing, fingerprint generation, and safe localStorage read/write/remove operations 4 + - [x] 1.2 Add restore validation that replays saved route history against the current form blocks and rejects stale or corrupted drafts 5 + 6 + ## 2. Public runner integration 7 + 8 + - [x] 2.1 Integrate restore-on-load logic into `components/public-form-runner.tsx` so valid drafts repopulate answers, history, and cursor before interaction begins 9 + - [x] 2.2 Add debounced draft persistence for answer and route changes, plus cleanup on successful submission or stale-draft discard 10 + - [x] 2.3 Add localized public runner feedback for restored progress and discarded saved drafts 11 + 12 + ## 3. Verification 13 + 14 + - [x] 3.1 Verify refresh and close/reopen flows restore the same in-progress answers and visited step in the same browser 15 + - [x] 3.2 Verify branched-path restoration, stale-form invalidation, storage-failure fallback, and post-submit draft cleanup behavior