From 8d82ea9c0b7d557ffedf1d59a16ff8fa478ca7a0 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Tue, 14 Apr 2026 19:13:06 +0200 Subject: [PATCH] feat: draft persistence on Form via persistKey --- apps/web/src/components/ui/form.tsx | 87 ++++++++++++++++++++++++++++- 1 file changed, 86 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/ui/form.tsx b/apps/web/src/components/ui/form.tsx index a6a18752..96dfadc0 100644 --- a/apps/web/src/components/ui/form.tsx +++ b/apps/web/src/components/ui/form.tsx @@ -6,16 +6,101 @@ import { type Label as LabelPrimitive, Slot as SlotPrimitive } from "radix-ui"; import { Controller, type ControllerProps, + type DefaultValues, type FieldPath, type FieldValues, FormProvider, useFormContext, + type UseFormReturn, } from "react-hook-form"; import { cn } from "@/utils/ui"; import { Label } from "./label"; -const Form = FormProvider; +const DRAFT_DEBOUNCE_MS = 500; + +export interface DraftStorage { + getItem(key: string): string | null; + setItem(key: string, value: string): void; + removeItem(key: string): void; +} + +type FormProps< + TFieldValues extends FieldValues = FieldValues, + TContext = unknown, +> = UseFormReturn & { + children: React.ReactNode; + persistKey?: string; + storage?: DraftStorage; +}; + +function Form< + TFieldValues extends FieldValues = FieldValues, + TContext = unknown, +>({ + persistKey, + storage, + children, + ...methods +}: FormProps) { + const { watch, reset } = methods; + // To change keys after mount, re-mount the component with key={persistKey} + const persistKeyOnMount = React.useRef(persistKey); + const storageOnMount = React.useRef(storage); + const resetRef = React.useRef(reset); + + React.useEffect(() => { + if (!persistKeyOnMount.current) return; + const store = storageOnMount.current ?? window.localStorage; + try { + const stored = store.getItem(persistKeyOnMount.current); + if (stored) { + resetRef.current( + JSON.parse(stored) as DefaultValues, + ); + } + } catch { + // Storage may be unavailable (private browsing, storage blocked) + } + }, []); + + React.useEffect(() => { + if (!persistKey) return; + const store = storageOnMount.current ?? window.localStorage; + let timer: ReturnType; + const subscription = watch((values) => { + clearTimeout(timer); + timer = setTimeout(() => { + try { + store.setItem(persistKey, JSON.stringify(values)); + } catch { + // Storage may be full or blocked + } + }, DRAFT_DEBOUNCE_MS); + }); + return () => { + clearTimeout(timer); + subscription.unsubscribe(); + }; + }, [persistKey, watch]); + + return {children}; +} + +export function clearFormDraft({ + key, + storage, +}: { + key: string; + storage?: DraftStorage; +}): void { + const store = storage ?? window.localStorage; + try { + store.removeItem(key); + } catch { + // Storage may be unavailable + } +} type FormFieldContextValue< TFieldValues extends FieldValues = FieldValues,