mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
Wire SW update prompt and request persistent storage (M6, web.dev)
This commit is contained in:
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<PersistResult> {
|
||||
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 };
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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(<App />);
|
||||
|
||||
Reference in New Issue
Block a user