/** * SSR / hydration helpers for forms. * * @module bquery/forms */ import type { Form, FormSnapshot } from './types'; import { escapeForScript } from '../ssr/escape'; const FORM_STATE_ID_PATTERN = /^[A-Za-z0-9_-]+$/; const validateFormStateId = (id: string): string => { const value = String(id); if (!FORM_STATE_ID_PATTERN.test(value)) { throw new Error( 'bQuery forms: form state id must contain only letters, numbers, underscores, or hyphens.' ); } return value; }; /** * `JSON.stringify` replacer that enforces the **guaranteed serialization * boundary** for SSR form state. Values that cannot survive a JSON round-trip * in a meaningful, hydration-safe form are deterministically dropped (the key * is omitted) rather than serialized as `null`, `{}`, or `"[object File]"`: * * - **functions** — not transferable; intentionally dropped. * - **`File` / `Blob` / `FileList`** — binary handles cannot cross the * server→HTML→client boundary; dropped. Re-attach large blobs on the client * after hydration. * - **`bigint`** — would throw in `JSON.stringify`; dropped for safety. * - **`symbol` / `undefined`** — already omitted by `JSON.stringify`; listed * here for completeness. * * This makes the boundary an explicit, tested contract instead of an incidental * side effect of `JSON.stringify`. * * @internal */ const isNonSerializable = (value: unknown): boolean => { if (typeof value === 'function' || typeof value === 'bigint' || typeof value === 'symbol') { return true; } if (typeof Blob !== 'undefined' && value instanceof Blob) return true; if (typeof File !== 'undefined' && value instanceof File) return true; if (typeof FileList !== 'undefined' && value instanceof FileList) return true; return false; }; const stripNonSerializable = (_key: string, value: unknown): unknown => { if (isNonSerializable(value)) return undefined; // An array can't carry a hole, so a dropped element would serialize as // `null` and break the "dropped, never null" contract. Remove the offending // elements from the array instead; nested arrays are handled recursively as // the replacer revisits them. if (Array.isArray(value) && value.some(isNonSerializable)) { return value.filter((element) => !isNonSerializable(element)); } return value; }; /** * Serialize a form snapshot to an inline ``; }; /** * Read a previously-serialized form snapshot from the DOM. Returns `undefined` * if no matching `