{"version":3,"file":"page-state.cjs","names":[],"sources":["../../src/page-state.ts"],"sourcesContent":["/**\n * @deijose/nix-ionic / page-state.ts\n *\n * Opt-in page-state persistence protocol. Allows pages to save and restore\n * serializable state across navigation, cache eviction, and app reloads.\n *\n * Key design rules:\n *   - **Only serializable data** — JSON.stringify is used; DOM nodes, functions,\n *     symbols, class instances with methods are rejected.\n *   - **Never persist DOM/view instances** — the protocol validates values\n *     before storage and throws on non-serializable content.\n *   - **Opt-in** — pages must explicitly call `save()` to persist state.\n *   - **Per cache key** — state is keyed by route path + params + query,\n *     matching the IonRouterOutlet cache key logic.\n *   - **Storage choice** — `sessionStorage` (default, cleared on tab close)\n *     or `localStorage` (persists across sessions).\n *\n * @example Basic usage in a page component\n * ```ts\n * import { signal, html } from \"@deijose/nix-js\";\n * import { createPageState, IonPage } from \"@deijose/nix-ionic\";\n *\n * class SearchPage extends IonPage {\n *   private query = signal(\"\");\n *   private results = signal<string[]>([]);\n *   private pageState = createPageState(\"search\", {\n *     // Declare which signals are persistable\n *     query: this.query,\n *     results: this.results,\n *   });\n *\n *   override onMount() {\n *     // Restore saved state on mount\n *     this.pageState.restore();\n *   }\n *\n *   override onUnmount() {\n *     // Save state before leaving\n *     this.pageState.save();\n *   }\n *\n *   override render() {\n *     return html`\n *       <ion-content>\n *         <ion-searchbar value=${() => this.query.value} @input=${(e: any) => {\n *           this.query.value = e.target.value;\n *           this.pageState.save(); // save on change\n *         }}></ion-searchbar>\n *         <ion-list>\n *           ${() => this.results.value.map(r => html`<ion-item>${r}</ion-item>`)}\n *         </ion-list>\n *       </ion-content>\n *     `;\n *   }\n * }\n * ```\n *\n * @example With localStorage (persists across app restarts)\n * ```ts\n * const pageState = createPageState(\"cart\", {\n *   items: cartItems,\n *   total: cartTotal,\n * }, { storage: \"local\" });\n * ```\n */\n\n// --- Types ---\n\n/** Storage backend selection. */\nexport type StorageBackend = \"session\" | \"local\";\n\n/** Options for page-state persistence. */\nexport interface PageStateOptions {\n    /**\n     * Storage backend: `\"session\"` (sessionStorage, cleared on tab close)\n     * or `\"local\"` (localStorage, persists across sessions).\n     * @default \"session\"\n     */\n    storage?: StorageBackend;\n    /**\n     * Namespace prefix for storage keys. Defaults to \"nix-ionic\".\n     * Useful for multi-app scenarios on the same origin.\n     */\n    namespace?: string;\n    /**\n     * Additional key suffix (e.g. user ID) to isolate state between users.\n     */\n    keySuffix?: string;\n}\n\n/**\n * A map of signal names to signals. Each signal's value must be serializable.\n */\nexport type SignalMap = Record<string, { value: unknown }>;\n\n/**\n * Page-state persistence controller. Created per page instance.\n */\nexport interface PageState {\n    /**\n     * Save the current state of all declared signals to storage.\n     * Only serializable values are stored; non-serializable values are\n     * silently skipped (with a console.warn in dev).\n     */\n    save(): void;\n    /**\n     * Restore saved state from storage into the declared signals.\n     * Returns true if state was found and restored, false otherwise.\n     */\n    restore(): boolean;\n    /**\n     * Clear saved state for this page's key.\n     */\n    clear(): void;\n    /**\n     * Get the storage key that would be used (for debugging).\n     */\n    readonly key: string;\n}\n\n// --- Serialization validation ---\n\n/**\n * Check if a value is serializable (can survive JSON.stringify + parse).\n * Returns true for: primitives, plain arrays, plain objects.\n * Returns false for: functions, symbols, DOM nodes, class instances,\n * undefined, circular references.\n */\nfunction isSerializable(value: unknown): boolean {\n    if (value === undefined) return false;\n    if (value === null) return true;\n    if (typeof value === \"function\") return false;\n    if (typeof value === \"symbol\") return false;\n    if (typeof value === \"bigint\") return false; // JSON.stringify throws on bigint\n\n    // DOM nodes and elements\n    if (typeof window !== \"undefined\" && value instanceof Node) return false;\n    if (typeof window !== \"undefined\" && value instanceof Element) return false;\n    if (typeof window !== \"undefined\" && value instanceof DocumentFragment) return false;\n\n    // Primitives\n    if (typeof value !== \"object\") return true;\n\n    // Arrays — check each element\n    if (Array.isArray(value)) {\n        return value.every(isSerializable);\n    }\n\n    // Plain objects — check constructor and each value\n    const proto = Object.getPrototypeOf(value);\n    if (proto !== null && proto !== Object.prototype) {\n        // Class instances (has non-trivial prototype) — reject\n        return false;\n    }\n\n    try {\n        // Final check: can it survive a round-trip?\n        JSON.stringify(value);\n        return true;\n    } catch {\n        return false; // circular reference or other stringify error\n    }\n}\n\n// --- Storage abstraction ---\n\nfunction getStorage(backend: StorageBackend): Storage | null {\n    if (typeof window === \"undefined\") return null;\n    return backend === \"local\" ? window.localStorage : window.sessionStorage;\n}\n\n// --- Factory ---\n\n/**\n * Create a page-state persistence controller.\n *\n * @param pageId Unique identifier for the page (e.g. route path).\n * @param signals Map of signal names to signals whose values should be persisted.\n * @param options Persistence options.\n *\n * @example\n * ```ts\n * const state = createPageState(\"search\", {\n *   query: searchQuery,\n *   filters: filterSignal,\n * }, { storage: \"local\" });\n *\n * // On page mount:\n * state.restore();\n *\n * // On page leave or data change:\n * state.save();\n * ```\n */\nexport function createPageState(\n    pageId: string,\n    signals: SignalMap,\n    options: PageStateOptions = {},\n): PageState {\n    const {\n        storage: backend = \"session\",\n        namespace = \"nix-ionic\",\n        keySuffix = \"\",\n    } = options;\n\n    const baseKey = `${namespace}:${pageId}${keySuffix ? \":\" + keySuffix : \"\"}`;\n\n    function save(): void {\n        const storage = getStorage(backend);\n        if (!storage) return;\n\n        const data: Record<string, unknown> = {};\n        let hasData = false;\n\n        for (const [name, sig] of Object.entries(signals)) {\n            const value = sig.value;\n            if (!isSerializable(value)) {\n                if (typeof console !== \"undefined\" && console.warn) {\n                    console.warn(\n                        `[nix-ionic] PageState: skipping non-serializable value for \"${name}\" ` +\n                        `on page \"${pageId}\". Only serializable data (primitives, plain arrays, ` +\n                        `plain objects) can be persisted. DOM nodes, functions, and class ` +\n                        `instances are not allowed.`,\n                    );\n                }\n                continue;\n            }\n            data[name] = value;\n            hasData = true;\n        }\n\n        if (hasData) {\n            try {\n                storage.setItem(baseKey, JSON.stringify(data));\n            } catch {\n                // Quota exceeded or storage disabled — fail silently\n                if (typeof console !== \"undefined\" && console.warn) {\n                    console.warn(\n                        `[nix-ionic] PageState: failed to save state for page \"${pageId}\" ` +\n                        `(storage quota exceeded or storage disabled).`,\n                    );\n                }\n            }\n        }\n    }\n\n    function restore(): boolean {\n        const storage = getStorage(backend);\n        if (!storage) return false;\n\n        let raw: string | null = null;\n        try {\n            raw = storage.getItem(baseKey);\n        } catch {\n            return false; // storage access denied\n        }\n\n        if (!raw) return false;\n\n        let data: Record<string, unknown>;\n        try {\n            data = JSON.parse(raw);\n        } catch {\n            // Corrupted data — clear it\n            try { storage.removeItem(baseKey); } catch { /* ignore */ }\n            return false;\n        }\n\n        for (const [name, sig] of Object.entries(signals)) {\n            if (name in data) {\n                sig.value = data[name];\n            }\n        }\n\n        return true;\n    }\n\n    function clear(): void {\n        const storage = getStorage(backend);\n        if (!storage) return;\n        try {\n            storage.removeItem(baseKey);\n        } catch { /* ignore */ }\n    }\n\n    return {\n        save,\n        restore,\n        clear,\n        get key() { return baseKey; },\n    };\n}\n\n// --- Batch helpers ---\n\n/**\n * Clear all nix-ionic page-state entries from a storage backend.\n * Useful for logout flows.\n *\n * @example\n * ```ts\n * import { clearAllPageState } from \"@deijose/nix-ionic\";\n *\n * function logout() {\n *   clearAllPageState(); // sessionStorage\n *   clearAllPageState(\"local\"); // localStorage\n * }\n * ```\n */\nexport function clearAllPageState(backend: StorageBackend = \"session\", namespace = \"nix-ionic\"): void {\n    const storage = getStorage(backend);\n    if (!storage) return;\n\n    const prefix = `${namespace}:`;\n    const keysToRemove: string[] = [];\n\n    try {\n        for (let i = 0; i < storage.length; i++) {\n            const key = storage.key(i);\n            if (key && key.startsWith(prefix)) {\n                keysToRemove.push(key);\n            }\n        }\n        for (const key of keysToRemove) {\n            storage.removeItem(key);\n        }\n    } catch { /* ignore */ }\n}\n\n// --- Serialization utilities (exported for testing) ---\n\nexport { isSerializable };\n"],"mappings":"gGAgIA,SAAS,EAAe,EAAyB,CAC7C,GAAI,IAAU,IAAA,GAAW,MAAO,GAChC,GAAI,IAAU,KAAM,MAAO,GAQ3B,GAPI,OAAO,GAAU,YACjB,OAAO,GAAU,UACjB,OAAO,GAAU,UAGjB,OAAO,OAAW,KAAe,aAAiB,MAClD,OAAO,OAAW,KAAe,aAAiB,SAClD,OAAO,OAAW,KAAe,aAAiB,iBAAkB,MAAO,GAG/E,GAAI,OAAO,GAAU,SAAU,MAAO,GAGtC,GAAI,MAAM,QAAQ,EAAM,CACpB,OAAO,EAAM,MAAM,EAAe,CAItC,IAAM,EAAQ,OAAO,eAAe,EAAM,CAC1C,GAAI,IAAU,MAAQ,IAAU,OAAO,UAEnC,MAAO,GAGX,GAAI,CAGA,OADA,KAAK,UAAU,EAAM,CACd,QACH,CACJ,MAAO,IAMf,SAAS,EAAW,EAAyC,CAEzD,OADI,OAAO,OAAW,IAAoB,KACnC,IAAY,QAAU,OAAO,aAAe,OAAO,eA0B9D,SAAgB,EACZ,EACA,EACA,EAA4B,EAAE,CACrB,CACT,GAAM,CACF,QAAS,EAAU,UACnB,YAAY,YACZ,YAAY,IACZ,EAEE,EAAU,GAAG,EAAU,GAAG,IAAS,EAAY,IAAM,EAAY,KAEvE,SAAS,GAAa,CAClB,IAAM,EAAU,EAAW,EAAQ,CACnC,GAAI,CAAC,EAAS,OAEd,IAAM,EAAgC,EAAE,CACpC,EAAU,GAEd,IAAK,GAAM,CAAC,EAAM,KAAQ,OAAO,QAAQ,EAAQ,CAAE,CAC/C,IAAM,EAAQ,EAAI,MAClB,GAAI,CAAC,EAAe,EAAM,CAAE,CACpB,OAAO,QAAY,KAAe,QAAQ,MAC1C,QAAQ,KACJ,+DAA+D,EAAK,aACxD,EAAO,kJAGtB,CAEL,SAEJ,EAAK,GAAQ,EACb,EAAU,GAGd,GAAI,EACA,GAAI,CACA,EAAQ,QAAQ,EAAS,KAAK,UAAU,EAAK,CAAC,MAC1C,CAEA,OAAO,QAAY,KAAe,QAAQ,MAC1C,QAAQ,KACJ,yDAAyD,EAAO,iDAEnE,EAMjB,SAAS,GAAmB,CACxB,IAAM,EAAU,EAAW,EAAQ,CACnC,GAAI,CAAC,EAAS,MAAO,GAErB,IAAI,EAAqB,KACzB,GAAI,CACA,EAAM,EAAQ,QAAQ,EAAQ,MAC1B,CACJ,MAAO,GAGX,GAAI,CAAC,EAAK,MAAO,GAEjB,IAAI,EACJ,GAAI,CACA,EAAO,KAAK,MAAM,EAAI,MAClB,CAEJ,GAAI,CAAE,EAAQ,WAAW,EAAQ,MAAU,EAC3C,MAAO,GAGX,IAAK,GAAM,CAAC,EAAM,KAAQ,OAAO,QAAQ,EAAQ,CACzC,KAAQ,IACR,EAAI,MAAQ,EAAK,IAIzB,MAAO,GAGX,SAAS,GAAc,CACnB,IAAM,EAAU,EAAW,EAAQ,CAC9B,KACL,GAAI,CACA,EAAQ,WAAW,EAAQ,MACvB,GAGZ,MAAO,CACH,OACA,UACA,QACA,IAAI,KAAM,CAAE,OAAO,GACtB,CAmBL,SAAgB,EAAkB,EAA0B,UAAW,EAAY,YAAmB,CAClG,IAAM,EAAU,EAAW,EAAQ,CACnC,GAAI,CAAC,EAAS,OAEd,IAAM,EAAS,GAAG,EAAU,GACtB,EAAyB,EAAE,CAEjC,GAAI,CACA,IAAK,IAAI,EAAI,EAAG,EAAI,EAAQ,OAAQ,IAAK,CACrC,IAAM,EAAM,EAAQ,IAAI,EAAE,CACtB,GAAO,EAAI,WAAW,EAAO,EAC7B,EAAa,KAAK,EAAI,CAG9B,IAAK,IAAM,KAAO,EACd,EAAQ,WAAW,EAAI,MAEvB"}