diff --git a/src/app/infrastructure/storage-persist.test.ts b/src/app/infrastructure/storage-persist.test.ts new file mode 100644 index 0000000..9513e9b --- /dev/null +++ b/src/app/infrastructure/storage-persist.test.ts @@ -0,0 +1,75 @@ +import { afterEach, describe, expect, test, vi } from 'vitest'; +import { requestPersistentStorage } from './storage-persist'; + +const realNavigator = globalThis.navigator; + +/** Swap `navigator` for one exposing the given `storage` (or none). */ +function withStorage(storage: unknown) { + Object.defineProperty(globalThis, 'navigator', { + value: storage === undefined ? {} : { storage }, + configurable: true, + }); +} + +afterEach(() => { + Object.defineProperty(globalThis, 'navigator', { value: realNavigator, configurable: true }); + vi.restoreAllMocks(); +}); + +describe('requestPersistentStorage', () => { + test('reports unsupported when the StorageManager API is absent', async () => { + withStorage(undefined); + expect(await requestPersistentStorage()).toEqual({ supported: false }); + }); + + test('reports unsupported when persist/persisted are missing', async () => { + withStorage({ estimate: () => Promise.resolve({}) }); + expect(await requestPersistentStorage()).toEqual({ supported: false }); + }); + + test('does not re-request when already persisted', async () => { + const persist = vi.fn(); + withStorage({ persisted: vi.fn().mockResolvedValue(true), persist }); + expect(await requestPersistentStorage()).toEqual({ + supported: true, + persisted: true, + alreadyPersisted: true, + }); + expect(persist).not.toHaveBeenCalled(); + }); + + test('requests persistence once and reports granted', async () => { + const persist = vi.fn().mockResolvedValue(true); + withStorage({ persisted: vi.fn().mockResolvedValue(false), persist }); + expect(await requestPersistentStorage()).toEqual({ + supported: true, + persisted: true, + alreadyPersisted: false, + }); + expect(persist).toHaveBeenCalledTimes(1); + }); + + test('reports not granted when the request is denied', async () => { + withStorage({ + persisted: vi.fn().mockResolvedValue(false), + persist: vi.fn().mockResolvedValue(false), + }); + expect(await requestPersistentStorage()).toEqual({ + supported: true, + persisted: false, + alreadyPersisted: false, + }); + }); + + test('never rejects — a thrown request resolves to not-persisted', async () => { + withStorage({ + persisted: vi.fn().mockRejectedValue(new Error('blocked')), + persist: vi.fn(), + }); + expect(await requestPersistentStorage()).toEqual({ + supported: true, + persisted: false, + alreadyPersisted: false, + }); + }); +}); diff --git a/src/app/infrastructure/storage-persist.ts b/src/app/infrastructure/storage-persist.ts new file mode 100644 index 0000000..17ebdc2 --- /dev/null +++ b/src/app/infrastructure/storage-persist.ts @@ -0,0 +1,45 @@ +/** + * Persistent-storage request (web.dev → council; see reference/principles/web-dev.md). + * + * By default browser storage is **best-effort**: under storage pressure the + * browser may evict the whole origin's IndexedDB + Cache API data. For a + * local-first app whose workspace lives only in the browser, that is data loss. + * `navigator.storage.persist()` asks the browser not to evict us. + * + * Behaviour per web.dev guidance: + * - feature-detect; do nothing where the API is absent (never throw), + * - check `persisted()` first and request **at most once** — don't nag, + * - stay silent on denial: Chromium decides automatically from heuristics + * (installed / engagement / notifications), and there is nothing the user + * can act on, so no toast. + * + * Browser-only; the rest of the app reaches it through `orchestration/pwa`. + */ + +export type PersistResult = + | { supported: false } + | { supported: true; persisted: boolean; alreadyPersisted: boolean }; + +/** + * Ensure persistent storage, requesting it once if not already granted. + * Resolves to the outcome; never rejects. + */ +export async function requestPersistentStorage(): Promise { + const storage = typeof navigator !== 'undefined' ? navigator.storage : undefined; + if ( + !storage || + typeof storage.persist !== 'function' || + typeof storage.persisted !== 'function' + ) { + return { supported: false }; + } + try { + const alreadyPersisted = await storage.persisted(); + if (alreadyPersisted) return { supported: true, persisted: true, alreadyPersisted: true }; + const persisted = await storage.persist(); + return { supported: true, persisted, alreadyPersisted: false }; + } catch { + // A rejected request is not actionable by the user — report not-persisted. + return { supported: true, persisted: false, alreadyPersisted: false }; + } +} diff --git a/src/app/orchestration/pwa.ts b/src/app/orchestration/pwa.ts new file mode 100644 index 0000000..05a99f4 --- /dev/null +++ b/src/app/orchestration/pwa.ts @@ -0,0 +1,49 @@ +/** + * PWA wiring (web.dev → council; see reference/principles/web-dev.md). + * + * Two startup concerns, both browser-only and called once from `main.tsx`: + * + * - **Service-worker update prompt.** The build uses `registerType: 'prompt'` + * (vite.config), so a freshly-installed service worker parks in the *waiting* + * state and never takes over until the app explicitly reloads. Without + * consuming the registration that posture silently means "never update", so we + * surface `onNeedRefresh` as a **durable** info toast with a **Reload** action + * that calls `updateSW()` (web.dev "update" flow via vite-plugin-pwa). + * - **Persistent storage.** Request it once at startup so the local-first + * workspace is not evicted under pressure (see `infrastructure/storage-persist`). + * + * The `virtual:pwa-register` import is provided by vite-plugin-pwa at build time; + * it is only imported here (not in tested modules) so the test runner never has + * to resolve the virtual module. + */ + +import { registerSW } from 'virtual:pwa-register'; +import { notify } from '../stores/NotificationStore'; +import { requestPersistentStorage } from '../infrastructure/storage-persist'; + +/** Register the service worker and prompt the user when an update is ready. */ +export function registerServiceWorker(): void { + const updateSW = registerSW({ + onNeedRefresh() { + notify({ + kind: 'info', + title: 'Update available', + message: 'A new version of Astrolabe is ready.', + durable: true, // wait for the user — don't time out an update offer + action: { label: 'Reload', onClick: () => void updateSW(true) }, + }); + }, + onOfflineReady() { + notify({ + kind: 'success', + title: 'Ready to work offline', + message: 'Astrolabe is cached and will work without a connection.', + }); + }, + }); +} + +/** Ask the browser to keep our storage from being evicted (fire-and-forget). */ +export function initPersistentStorage(): void { + void requestPersistentStorage(); +} diff --git a/src/main.tsx b/src/main.tsx index 685e2d9..85253aa 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -4,6 +4,7 @@ import { initPanes, wirePanes } from './app/orchestration/panes'; import { initPreviewFitMode, wirePreviewFitMode } from './app/orchestration/preferences'; import { initSettings, wireSettings } from './app/orchestration/settings'; import { initSnippetSort, wireSnippetSort } from './app/orchestration/snippet-sort'; +import { initPersistentStorage, registerServiceWorker } from './app/orchestration/pwa'; import { initApp } from './app/orchestration/startup'; import { initTheme, wireTheme } from './app/orchestration/theme'; import '../styles/base.css'; @@ -37,4 +38,9 @@ wireSettings(); // hydration resolves. void initApp(); +// PWA: register the service worker (prompt the user to reload on an update) and +// request persistent storage so the local-first workspace isn't evicted (web.dev). +registerServiceWorker(); +initPersistentStorage(); + createRoot(document.getElementById('app')!).render();