From 812be326153a4374da8dbc73c8ed0f993bba3e43 Mon Sep 17 00:00:00 2001
From: qnbs <155236708+qnbs@users.noreply.github.com>
Date: Wed, 26 Aug 2026 22:47:07 +0200
Subject: [PATCH 01/10] fix(pwa): flush pending state before a forced SW-update
reload (DA-02)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
register-sw.ts unconditionally reloaded on any controllerchange event. The
project autosave in app/listenerMiddleware.ts debounces 1s after the last
change and is reset on every keystroke, so a user typing continuously has
no durable copy of their in-flight edit until they pause — a reload landing
inside that window discarded it. "The app auto-saves so a reload is safe"
was an unverified assumption, not something the code guaranteed.
controllerchange fires on every open tab whenever any tab applies a SW
update, not just the tab that triggered it, so the fix lives in the reload
handler itself rather than gating who's allowed to trigger an update: each
tab now calls the same flushPersistedState() already used for the
visibilitychange/quit-flush case, awaits it, and only then reloads. If the
flush itself fails, the reload is deferred (not forced) rather than risk
discarding still-in-memory, not-yet-persisted edits — the tab just keeps
running the old (still-working) bundle until the next natural navigation.
public/sw.js's automatic skipWaiting() on install is unchanged; only the
client's reaction to the resulting controllerchange was unsafe.
New regression tests (registerSwUpdateFlush.test.ts) verify flush-then-reload
ordering, that a flush failure defers the reload, single-flight behavior on
duplicate controllerchange events, and the defensive no-store-mounted case —
verified against the pre-fix code to genuinely fail.
---
README.md | 8 +-
register-sw.ts | 22 +++-
tests/unit/registerSwCacheOwnership.test.ts | 7 +-
tests/unit/registerSwUpdateFlush.test.ts | 117 ++++++++++++++++++++
4 files changed, 143 insertions(+), 11 deletions(-)
create mode 100644 tests/unit/registerSwUpdateFlush.test.ts
diff --git a/README.md b/README.md
index 75801bee3..315cb55d0 100644
--- a/README.md
+++ b/README.md
@@ -14,7 +14,7 @@
-
+
@@ -512,7 +512,7 @@ The Settings → AI panel shows a live GPU status badge with adapter details and
| **Document Export** | docx + jszip | Word-compatible `.docx` generation (lazy-loaded) |
| **PWA** | Service Worker + Web App Manifest v3 | Offline support, installability, Workbox chunking |
| **i18n** | Custom React Context (`I18nContext.tsx`) | 2925 keys × 19 locales (de/en/es/fr/it + ar/he/fa RTL Beta + ja/zh/pt/el/fi/sv/hu/is/eu/ru/ko Beta); EN fallback; `localStorage` persistence |
-| **Testing** | Vitest 4.x (7138+ tests / 583 files) + Playwright E2E | Unit/integration + cross-browser E2E; Stryker mutation (manual workflow) |
+| **Testing** | Vitest 4.x (7142+ tests / 584 files) + Playwright E2E | Unit/integration + cross-browser E2E; Stryker mutation (manual workflow) |
| **Code Quality** | Biome (lint + format) + TypeScript 7 (tsgo) strict | `--error-on-warnings` in CI; zero `any` policy |
| **Visualization** | Force-directed graph | Interactive character relationship network |
| **Desktop** | Tauri v2 | Cross-platform installer; auto-updater via `latest.json` |
@@ -550,7 +550,7 @@ WorldScript-Studio/
│ ├── sw.js # PWA Service Worker
│ └── manifest.json # PWA Web App Manifest v3
├── tests/
-│ ├── unit/ # Vitest unit tests (7138+ tests, 583 files) — count spans tests/, components/, packages/*/tests/, not just this folder
+│ ├── unit/ # Vitest unit tests (7142+ tests, 584 files) — count spans tests/, components/, packages/*/tests/, not just this folder
│ │ ├── ai/ # aiSmallModules, aiCoreFallbackPaths
│ │ └── settings/ # WebLlmPanel, AiSections
│ └── e2e/ # Playwright specs + helpers.ts
@@ -712,7 +712,7 @@ The main pipeline is [`.github/workflows/ci.yml`](.github/workflows/ci.yml). Opt
| `scorecard` | weekly + `main` push | OpenSSF Scorecard — SARIF uploaded to GitHub Code Scanning |
**Current test metrics (2026-08-21, source-synchronized; CI remains authoritative for pass/fail):**
-- **7138+ unit tests** across **583 test files** — CI is authoritative for pass/fail
+- **7142+ unit tests** across **584 test files** — CI is authoritative for pass/fail
- Coverage thresholds: lines ≥ 80 · branches ≥ 66 · functions ≥ 72 · statements ≥ 78 — enforced in CI (see Codecov badge for live metrics)
- i18n: **2925 keys × 19 locales** (en/de/fr/es/it + ar/he/fa RTL Beta + ja/zh/pt/el/fi/sv/hu/is/eu/ru/ko Beta)
diff --git a/register-sw.ts b/register-sw.ts
index 8e95326c0..6a322329d 100644
--- a/register-sw.ts
+++ b/register-sw.ts
@@ -1,3 +1,6 @@
+import type { RootState } from './app/store';
+import { appStoreRef } from './app/storeRef';
+import { flushPersistedState } from './app/persistedStateFlush';
import { logger as appLogger } from './services/logger';
// ============================================================
@@ -183,14 +186,12 @@ const registerServiceWorker = async (): Promise => {
announceUpdateAvailable(registration.waiting);
}
- // QNBS-v3: Reload on any SW controller change — install now calls skipWaiting() automatically,
- // so controllerchange fires whenever a new SW activates (not just on user-initiated updates).
- // The app auto-saves to IDB so a mid-session reload is safe and always serves fresh assets.
+ // QNBS-v3 (DA-02): the 1s debounced autosave never fires mid-typing — flush before reloading or edits can be lost.
let refreshing = false;
navigator.serviceWorker.addEventListener('controllerchange', () => {
if (!refreshing) {
refreshing = true;
- window.location.reload();
+ void flushThenReload();
}
});
@@ -225,6 +226,19 @@ const registerServiceWorker = async (): Promise => {
}
};
+// QNBS-v3 (DA-02): mirrors index.tsx's visibilitychange flush — a failed flush defers the reload instead of discarding edits.
+async function flushThenReload(): Promise {
+ try {
+ const store = appStoreRef.current;
+ if (store) {
+ await flushPersistedState(store.getState() as RootState);
+ }
+ window.location.reload();
+ } catch (error) {
+ appLogger.error('[SW] Pre-reload state flush failed — reload deferred to avoid data loss:', error);
+ }
+}
+
if (typeof window !== 'undefined') {
window.addEventListener('load', registerServiceWorker);
}
diff --git a/tests/unit/registerSwCacheOwnership.test.ts b/tests/unit/registerSwCacheOwnership.test.ts
index 0392643e1..47a94a26f 100644
--- a/tests/unit/registerSwCacheOwnership.test.ts
+++ b/tests/unit/registerSwCacheOwnership.test.ts
@@ -1,9 +1,10 @@
// QNBS-v3: proves the Tauri-teardown cache cleanup in register-sw.ts never deletes an unowned cache.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
-vi.mock('../../services/logger', () => ({
- logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
-}));
+vi.mock('../../services/logger', async (importOriginal) => {
+ const actual = await importOriginal();
+ return { ...actual, logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() } };
+});
import { isWorldScriptOwnedCacheName, registerServiceWorker } from '../../register-sw';
diff --git a/tests/unit/registerSwUpdateFlush.test.ts b/tests/unit/registerSwUpdateFlush.test.ts
new file mode 100644
index 000000000..3fe3e3e6a
--- /dev/null
+++ b/tests/unit/registerSwUpdateFlush.test.ts
@@ -0,0 +1,117 @@
+// QNBS-v3 (DA-02): proves controllerchange flushes this tab's pending state before reloading.
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+vi.mock('../../services/logger', async (importOriginal) => {
+ const actual = await importOriginal();
+ return { ...actual, logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() } };
+});
+
+const { mockFlushPersistedState } = vi.hoisted(() => ({ mockFlushPersistedState: vi.fn() }));
+vi.mock('../../app/persistedStateFlush', () => ({ flushPersistedState: mockFlushPersistedState }));
+
+import { appStoreRef } from '../../app/storeRef';
+import { registerServiceWorker } from '../../register-sw';
+
+async function flushMicrotasks(): Promise {
+ await new Promise((resolve) => setTimeout(resolve, 0));
+}
+
+describe('register-sw — controllerchange flush-then-reload (DA-02)', () => {
+ let controllerChangeHandler: (() => void) | undefined;
+ let reloadSpy: ReturnType;
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ controllerChangeHandler = undefined;
+
+ for (const key of ['__TAURI_INTERNALS__', '__TAURI__', '__TAURI_METADATA__']) {
+ delete (window as unknown as Record)[key];
+ }
+
+ reloadSpy = vi.fn();
+ Object.defineProperty(window, 'location', {
+ value: { ...window.location, reload: reloadSpy },
+ writable: true,
+ configurable: true,
+ });
+
+ const fakeRegistration = {
+ scope: '/',
+ installing: null,
+ waiting: null,
+ addEventListener: vi.fn(),
+ };
+
+ Object.defineProperty(navigator, 'serviceWorker', {
+ value: {
+ register: vi.fn().mockResolvedValue(fakeRegistration),
+ controller: {},
+ addEventListener: vi.fn((type: string, handler: () => void) => {
+ if (type === 'controllerchange') controllerChangeHandler = handler;
+ }),
+ },
+ writable: true,
+ configurable: true,
+ });
+
+ appStoreRef.current = {
+ getState: () => ({ fake: 'state' }) as never,
+ dispatch: vi.fn() as never,
+ };
+ });
+
+ afterEach(() => {
+ appStoreRef.current = null;
+ // @ts-expect-error — test-only cleanup of a property this suite defines itself.
+ delete navigator.serviceWorker;
+ });
+
+ it('flushes pending state and reloads, in that order, when a new SW takes control', async () => {
+ mockFlushPersistedState.mockResolvedValue(undefined);
+ await registerServiceWorker();
+ expect(controllerChangeHandler).toBeTypeOf('function');
+
+ controllerChangeHandler?.();
+ await flushMicrotasks();
+
+ expect(mockFlushPersistedState).toHaveBeenCalledTimes(1);
+ expect(reloadSpy).toHaveBeenCalledTimes(1);
+ const flushOrder = mockFlushPersistedState.mock.invocationCallOrder[0] as number;
+ const reloadOrder = reloadSpy.mock.invocationCallOrder[0] as number;
+ expect(flushOrder).toBeLessThan(reloadOrder);
+ });
+
+ it('defers the reload when the flush fails, instead of discarding unflushed edits', async () => {
+ mockFlushPersistedState.mockRejectedValue(new Error('IDB write failed'));
+ await registerServiceWorker();
+
+ controllerChangeHandler?.();
+ await flushMicrotasks();
+
+ expect(mockFlushPersistedState).toHaveBeenCalledTimes(1);
+ expect(reloadSpy).not.toHaveBeenCalled();
+ });
+
+ it('ignores a second controllerchange event (single-flight)', async () => {
+ mockFlushPersistedState.mockResolvedValue(undefined);
+ await registerServiceWorker();
+
+ controllerChangeHandler?.();
+ controllerChangeHandler?.();
+ await flushMicrotasks();
+
+ expect(mockFlushPersistedState).toHaveBeenCalledTimes(1);
+ expect(reloadSpy).toHaveBeenCalledTimes(1);
+ });
+
+ it('still reloads when no store is mounted yet (defensive null-guard, nothing to flush)', async () => {
+ appStoreRef.current = null;
+ await registerServiceWorker();
+
+ controllerChangeHandler?.();
+ await flushMicrotasks();
+
+ expect(mockFlushPersistedState).not.toHaveBeenCalled();
+ expect(reloadSpy).toHaveBeenCalledTimes(1);
+ });
+});
From 8fb9c2bdf51513f0a97524dedfa8115c2e74837b Mon Sep 17 00:00:00 2001
From: qnbs <155236708+qnbs@users.noreply.github.com>
Date: Wed, 26 Aug 2026 23:17:32 +0200
Subject: [PATCH 02/10] fix(pwa): close DA-02 review-wave gaps in the
flush-before-reload fix
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Addresses PR #517's first review wave (CodeAnt AI, chatgpt-codex-connector),
which found the first flush-before-reload fix real but incomplete:
- Cross-tab clobber (codex P1, critical): controllerchange fires on every
open tab whenever any tab applies an update. Every tab flushing its own
Redux snapshot concurrently meant a background tab's stale state could
finish writing after an actively-edited tab's fresh flush, silently
overwriting it — all tabs then reload from the stale record. Only the
currently visible tab now flushes; a hidden tab defers both its flush and
reload until it becomes visible again (document.visibilitychange), so a
backgrounded tab's state — which by definition can't be fresher than
what's already persisted — never races a foreground write.
- Stale snapshot during a slow flush (CodeAnt + codex, duplicate finding):
the state passed to flushPersistedState was captured once, before the
async write resolved. Typing that continued during that window was never
included in the flushed snapshot, and the reload discarded it anyway.
flushLatestState() now loops: after each flush, it re-reads the store and
flushes again if it changed, stopping once a flush completes against
state proven unchanged since (bounded to 5 attempts).
- Cache-deletion trap (codex P2): deferring the reload on a flush failure
(the prior design) doesn't actually protect anything — by the time
controllerchange fires, activation has already pruned every old-version
cache, so a tab left running the old bundle already risks missing-chunk
failures on any not-yet-loaded lazy view regardless. The reload now always
proceeds after the flush attempt, success or failure, matching what
"staying on the old bundle" was never actually able to guarantee.
Test suite rewritten for the new design: visible-tab-only flush, hidden-tab
deferral through visibilitychange, retry-until-stable flushing, and
always-reloads-regardless-of-flush-outcome — each verified to fail against
the prior (first-wave) commit.
---
README.md | 8 ++--
register-sw.ts | 44 ++++++++++++-----
tests/unit/registerSwUpdateFlush.test.ts | 61 ++++++++++++++++++++++--
3 files changed, 93 insertions(+), 20 deletions(-)
diff --git a/README.md b/README.md
index 315cb55d0..05a0a0ab1 100644
--- a/README.md
+++ b/README.md
@@ -14,7 +14,7 @@
-
+
@@ -512,7 +512,7 @@ The Settings → AI panel shows a live GPU status badge with adapter details and
| **Document Export** | docx + jszip | Word-compatible `.docx` generation (lazy-loaded) |
| **PWA** | Service Worker + Web App Manifest v3 | Offline support, installability, Workbox chunking |
| **i18n** | Custom React Context (`I18nContext.tsx`) | 2925 keys × 19 locales (de/en/es/fr/it + ar/he/fa RTL Beta + ja/zh/pt/el/fi/sv/hu/is/eu/ru/ko Beta); EN fallback; `localStorage` persistence |
-| **Testing** | Vitest 4.x (7142+ tests / 584 files) + Playwright E2E | Unit/integration + cross-browser E2E; Stryker mutation (manual workflow) |
+| **Testing** | Vitest 4.x (7144+ tests / 584 files) + Playwright E2E | Unit/integration + cross-browser E2E; Stryker mutation (manual workflow) |
| **Code Quality** | Biome (lint + format) + TypeScript 7 (tsgo) strict | `--error-on-warnings` in CI; zero `any` policy |
| **Visualization** | Force-directed graph | Interactive character relationship network |
| **Desktop** | Tauri v2 | Cross-platform installer; auto-updater via `latest.json` |
@@ -550,7 +550,7 @@ WorldScript-Studio/
│ ├── sw.js # PWA Service Worker
│ └── manifest.json # PWA Web App Manifest v3
├── tests/
-│ ├── unit/ # Vitest unit tests (7142+ tests, 584 files) — count spans tests/, components/, packages/*/tests/, not just this folder
+│ ├── unit/ # Vitest unit tests (7144+ tests, 584 files) — count spans tests/, components/, packages/*/tests/, not just this folder
│ │ ├── ai/ # aiSmallModules, aiCoreFallbackPaths
│ │ └── settings/ # WebLlmPanel, AiSections
│ └── e2e/ # Playwright specs + helpers.ts
@@ -712,7 +712,7 @@ The main pipeline is [`.github/workflows/ci.yml`](.github/workflows/ci.yml). Opt
| `scorecard` | weekly + `main` push | OpenSSF Scorecard — SARIF uploaded to GitHub Code Scanning |
**Current test metrics (2026-08-21, source-synchronized; CI remains authoritative for pass/fail):**
-- **7142+ unit tests** across **584 test files** — CI is authoritative for pass/fail
+- **7144+ unit tests** across **584 test files** — CI is authoritative for pass/fail
- Coverage thresholds: lines ≥ 80 · branches ≥ 66 · functions ≥ 72 · statements ≥ 78 — enforced in CI (see Codecov badge for live metrics)
- i18n: **2925 keys × 19 locales** (en/de/fr/es/it + ar/he/fa RTL Beta + ja/zh/pt/el/fi/sv/hu/is/eu/ru/ko Beta)
diff --git a/register-sw.ts b/register-sw.ts
index 6a322329d..a7e270cfa 100644
--- a/register-sw.ts
+++ b/register-sw.ts
@@ -186,12 +186,22 @@ const registerServiceWorker = async (): Promise => {
announceUpdateAvailable(registration.waiting);
}
- // QNBS-v3 (DA-02): the 1s debounced autosave never fires mid-typing — flush before reloading or edits can be lost.
+ // QNBS-v3 (DA-02): only the visible tab flushes — a hidden tab's stale write could race a fresher one.
let refreshing = false;
+ let reloadPendingWhileHidden = false;
navigator.serviceWorker.addEventListener('controllerchange', () => {
- if (!refreshing) {
+ if (refreshing) return;
+ if (document.visibilityState !== 'visible') {
+ reloadPendingWhileHidden = true;
+ return;
+ }
+ refreshing = true;
+ void flushLatestStateThenReload();
+ });
+ document.addEventListener('visibilitychange', () => {
+ if (reloadPendingWhileHidden && document.visibilityState === 'visible' && !refreshing) {
refreshing = true;
- void flushThenReload();
+ void flushLatestStateThenReload();
}
});
@@ -226,17 +236,29 @@ const registerServiceWorker = async (): Promise => {
}
};
-// QNBS-v3 (DA-02): mirrors index.tsx's visibilitychange flush — a failed flush defers the reload instead of discarding edits.
-async function flushThenReload(): Promise {
+// QNBS-v3 (DA-02): loops until a flush completes against state that provably hasn't changed since — a single snapshot could miss edits made while the async write was still in flight.
+const MAX_FLUSH_ATTEMPTS = 5;
+
+async function flushLatestState(): Promise {
+ const store = appStoreRef.current;
+ if (!store) return;
+ let snapshot = store.getState();
+ for (let attempt = 0; attempt < MAX_FLUSH_ATTEMPTS; attempt++) {
+ await flushPersistedState(snapshot as RootState);
+ const latest = store.getState();
+ if (latest === snapshot) return;
+ snapshot = latest;
+ }
+}
+
+// QNBS-v3 (DA-02): the reload always proceeds — activation already pruned old-version caches by the time controllerchange fires, so staying on the old bundle risks missing-chunk failures too.
+async function flushLatestStateThenReload(): Promise {
try {
- const store = appStoreRef.current;
- if (store) {
- await flushPersistedState(store.getState() as RootState);
- }
- window.location.reload();
+ await flushLatestState();
} catch (error) {
- appLogger.error('[SW] Pre-reload state flush failed — reload deferred to avoid data loss:', error);
+ appLogger.error('[SW] Pre-reload state flush failed (reloading anyway):', error);
}
+ window.location.reload();
}
if (typeof window !== 'undefined') {
diff --git a/tests/unit/registerSwUpdateFlush.test.ts b/tests/unit/registerSwUpdateFlush.test.ts
index 3fe3e3e6a..f21e26142 100644
--- a/tests/unit/registerSwUpdateFlush.test.ts
+++ b/tests/unit/registerSwUpdateFlush.test.ts
@@ -1,4 +1,4 @@
-// QNBS-v3 (DA-02): proves controllerchange flushes this tab's pending state before reloading.
+// QNBS-v3 (DA-02): proves controllerchange flushes the latest visible-tab state before reloading.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('../../services/logger', async (importOriginal) => {
@@ -16,6 +16,10 @@ async function flushMicrotasks(): Promise {
await new Promise((resolve) => setTimeout(resolve, 0));
}
+function setVisibility(value: 'visible' | 'hidden'): void {
+ Object.defineProperty(document, 'visibilityState', { value, configurable: true });
+}
+
describe('register-sw — controllerchange flush-then-reload (DA-02)', () => {
let controllerChangeHandler: (() => void) | undefined;
let reloadSpy: ReturnType;
@@ -23,6 +27,7 @@ describe('register-sw — controllerchange flush-then-reload (DA-02)', () => {
beforeEach(() => {
vi.clearAllMocks();
controllerChangeHandler = undefined;
+ setVisibility('visible');
for (const key of ['__TAURI_INTERNALS__', '__TAURI__', '__TAURI_METADATA__']) {
delete (window as unknown as Record)[key];
@@ -54,8 +59,11 @@ describe('register-sw — controllerchange flush-then-reload (DA-02)', () => {
configurable: true,
});
+ // QNBS-v3: a stable reference — a fresh object on every call would never satisfy the
+ // "state hasn't changed since the last flush" stop condition and loop until MAX_FLUSH_ATTEMPTS.
+ const stableState = { fake: 'state' };
appStoreRef.current = {
- getState: () => ({ fake: 'state' }) as never,
+ getState: () => stableState as never,
dispatch: vi.fn() as never,
};
});
@@ -64,9 +72,10 @@ describe('register-sw — controllerchange flush-then-reload (DA-02)', () => {
appStoreRef.current = null;
// @ts-expect-error — test-only cleanup of a property this suite defines itself.
delete navigator.serviceWorker;
+ setVisibility('visible');
});
- it('flushes pending state and reloads, in that order, when a new SW takes control', async () => {
+ it('flushes pending state and reloads, in that order, when the visible tab takes control', async () => {
mockFlushPersistedState.mockResolvedValue(undefined);
await registerServiceWorker();
expect(controllerChangeHandler).toBeTypeOf('function');
@@ -81,7 +90,7 @@ describe('register-sw — controllerchange flush-then-reload (DA-02)', () => {
expect(flushOrder).toBeLessThan(reloadOrder);
});
- it('defers the reload when the flush fails, instead of discarding unflushed edits', async () => {
+ it('reloads even when the flush fails, rather than staying on a bundle whose old cache is already pruned', async () => {
mockFlushPersistedState.mockRejectedValue(new Error('IDB write failed'));
await registerServiceWorker();
@@ -89,7 +98,7 @@ describe('register-sw — controllerchange flush-then-reload (DA-02)', () => {
await flushMicrotasks();
expect(mockFlushPersistedState).toHaveBeenCalledTimes(1);
- expect(reloadSpy).not.toHaveBeenCalled();
+ expect(reloadSpy).toHaveBeenCalledTimes(1);
});
it('ignores a second controllerchange event (single-flight)', async () => {
@@ -114,4 +123,46 @@ describe('register-sw — controllerchange flush-then-reload (DA-02)', () => {
expect(mockFlushPersistedState).not.toHaveBeenCalled();
expect(reloadSpy).toHaveBeenCalledTimes(1);
});
+
+ // QNBS-v3 (codex P1): a hidden tab's state can't be fresher than what's already persisted — only the visible tab flushes.
+ it('defers both flush and reload while the tab is hidden, then runs both once it becomes visible', async () => {
+ mockFlushPersistedState.mockResolvedValue(undefined);
+ await registerServiceWorker();
+ setVisibility('hidden');
+
+ controllerChangeHandler?.();
+ await flushMicrotasks();
+
+ expect(mockFlushPersistedState).not.toHaveBeenCalled();
+ expect(reloadSpy).not.toHaveBeenCalled();
+
+ setVisibility('visible');
+ document.dispatchEvent(new Event('visibilitychange'));
+ await flushMicrotasks();
+
+ expect(mockFlushPersistedState).toHaveBeenCalledTimes(1);
+ expect(reloadSpy).toHaveBeenCalledTimes(1);
+ });
+
+ // QNBS-v3 (CodeAnt/codex): a single snapshot could miss an edit made while the async write is still in flight.
+ it('re-flushes with the latest state when it changes during the pending flush, before reloading', async () => {
+ const stateA = { v: 'a' };
+ const stateB = { v: 'b' };
+ // 1st getState(): stateA. 2nd (after flush #1): stateB (changed — retry). 3rd (after flush #2): stateB (stable — stop).
+ const getStateMock = vi.fn().mockReturnValueOnce(stateA).mockReturnValueOnce(stateB).mockReturnValue(stateB);
+ appStoreRef.current = { getState: getStateMock, dispatch: vi.fn() as never };
+ mockFlushPersistedState.mockResolvedValue(undefined);
+
+ await registerServiceWorker();
+ controllerChangeHandler?.();
+ await flushMicrotasks();
+
+ expect(mockFlushPersistedState).toHaveBeenCalledTimes(2);
+ expect(mockFlushPersistedState).toHaveBeenNthCalledWith(1, stateA);
+ expect(mockFlushPersistedState).toHaveBeenNthCalledWith(2, stateB);
+ expect(reloadSpy).toHaveBeenCalledTimes(1);
+ const lastFlushOrder = mockFlushPersistedState.mock.invocationCallOrder[1] as number;
+ const reloadOrder = reloadSpy.mock.invocationCallOrder[0] as number;
+ expect(lastFlushOrder).toBeLessThan(reloadOrder);
+ });
});
From 6868b91bd7a098af7e591e8c0983192de1b6999b Mon Sep 17 00:00:00 2001
From: qnbs <155236708+qnbs@users.noreply.github.com>
Date: Wed, 26 Aug 2026 23:31:05 +0200
Subject: [PATCH 03/10] fix(pwa): close DA-02's second review wave (codex
P1/P2)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- A hidden tab that later becomes visible no longer re-flushes its own
Redux state before reloading — index.tsx's own visibilitychange listener
already flushed that tab's edits at the moment it went hidden, so
flushing again here on becoming visible risked writing a now-possibly-
stale copy over a fresher write another tab made in the meantime. It now
just reloads, trusting the state already durably persisted.
- flushLatestState()'s retry loop can exhaust its 5-attempt budget while
state keeps churning; it now performs one unconditional final flush of
whatever is freshest at that point before returning, instead of silently
dropping a last-second change.
- Fixed a QNBS-v3 comment I introduced earlier in this same PR but wrapped
across two physical lines — missed because the self-check only covers a
commit's own diff, and this edit landed in an earlier commit's changes
without a fresh check run after it.
New/updated regression tests for both behavioral fixes, verified against
the prior commit to genuinely fail.
---
README.md | 8 ++++----
register-sw.ts | 5 ++++-
tests/unit/registerSwUpdateFlush.test.ts | 26 ++++++++++++++++++++----
3 files changed, 30 insertions(+), 9 deletions(-)
diff --git a/README.md b/README.md
index 05a0a0ab1..d9388a22d 100644
--- a/README.md
+++ b/README.md
@@ -14,7 +14,7 @@
-
+
@@ -512,7 +512,7 @@ The Settings → AI panel shows a live GPU status badge with adapter details and
| **Document Export** | docx + jszip | Word-compatible `.docx` generation (lazy-loaded) |
| **PWA** | Service Worker + Web App Manifest v3 | Offline support, installability, Workbox chunking |
| **i18n** | Custom React Context (`I18nContext.tsx`) | 2925 keys × 19 locales (de/en/es/fr/it + ar/he/fa RTL Beta + ja/zh/pt/el/fi/sv/hu/is/eu/ru/ko Beta); EN fallback; `localStorage` persistence |
-| **Testing** | Vitest 4.x (7144+ tests / 584 files) + Playwright E2E | Unit/integration + cross-browser E2E; Stryker mutation (manual workflow) |
+| **Testing** | Vitest 4.x (7145+ tests / 584 files) + Playwright E2E | Unit/integration + cross-browser E2E; Stryker mutation (manual workflow) |
| **Code Quality** | Biome (lint + format) + TypeScript 7 (tsgo) strict | `--error-on-warnings` in CI; zero `any` policy |
| **Visualization** | Force-directed graph | Interactive character relationship network |
| **Desktop** | Tauri v2 | Cross-platform installer; auto-updater via `latest.json` |
@@ -550,7 +550,7 @@ WorldScript-Studio/
│ ├── sw.js # PWA Service Worker
│ └── manifest.json # PWA Web App Manifest v3
├── tests/
-│ ├── unit/ # Vitest unit tests (7144+ tests, 584 files) — count spans tests/, components/, packages/*/tests/, not just this folder
+│ ├── unit/ # Vitest unit tests (7145+ tests, 584 files) — count spans tests/, components/, packages/*/tests/, not just this folder
│ │ ├── ai/ # aiSmallModules, aiCoreFallbackPaths
│ │ └── settings/ # WebLlmPanel, AiSections
│ └── e2e/ # Playwright specs + helpers.ts
@@ -712,7 +712,7 @@ The main pipeline is [`.github/workflows/ci.yml`](.github/workflows/ci.yml). Opt
| `scorecard` | weekly + `main` push | OpenSSF Scorecard — SARIF uploaded to GitHub Code Scanning |
**Current test metrics (2026-08-21, source-synchronized; CI remains authoritative for pass/fail):**
-- **7144+ unit tests** across **584 test files** — CI is authoritative for pass/fail
+- **7145+ unit tests** across **584 test files** — CI is authoritative for pass/fail
- Coverage thresholds: lines ≥ 80 · branches ≥ 66 · functions ≥ 72 · statements ≥ 78 — enforced in CI (see Codecov badge for live metrics)
- i18n: **2925 keys × 19 locales** (en/de/fr/es/it + ar/he/fa RTL Beta + ja/zh/pt/el/fi/sv/hu/is/eu/ru/ko Beta)
diff --git a/register-sw.ts b/register-sw.ts
index a7e270cfa..ff787e715 100644
--- a/register-sw.ts
+++ b/register-sw.ts
@@ -201,7 +201,8 @@ const registerServiceWorker = async (): Promise => {
document.addEventListener('visibilitychange', () => {
if (reloadPendingWhileHidden && document.visibilityState === 'visible' && !refreshing) {
refreshing = true;
- void flushLatestStateThenReload();
+ // QNBS-v3 (codex): no flush here — index.tsx already flushed this tab's edits when it went hidden; re-flushing now could clobber a fresher write.
+ window.location.reload();
}
});
@@ -249,6 +250,8 @@ async function flushLatestState(): Promise {
if (latest === snapshot) return;
snapshot = latest;
}
+ // QNBS-v3 (codex): the loop above can exhaust its budget mid-churn — one final flush of whatever's freshest right now, rather than silently dropping it.
+ await flushPersistedState(store.getState() as RootState);
}
// QNBS-v3 (DA-02): the reload always proceeds — activation already pruned old-version caches by the time controllerchange fires, so staying on the old bundle risks missing-chunk failures too.
diff --git a/tests/unit/registerSwUpdateFlush.test.ts b/tests/unit/registerSwUpdateFlush.test.ts
index f21e26142..7a7e4c626 100644
--- a/tests/unit/registerSwUpdateFlush.test.ts
+++ b/tests/unit/registerSwUpdateFlush.test.ts
@@ -59,8 +59,7 @@ describe('register-sw — controllerchange flush-then-reload (DA-02)', () => {
configurable: true,
});
- // QNBS-v3: a stable reference — a fresh object on every call would never satisfy the
- // "state hasn't changed since the last flush" stop condition and loop until MAX_FLUSH_ATTEMPTS.
+ // QNBS-v3: a stable reference — a fresh object each call would never satisfy the "unchanged" stop condition.
const stableState = { fake: 'state' };
appStoreRef.current = {
getState: () => stableState as never,
@@ -125,7 +124,7 @@ describe('register-sw — controllerchange flush-then-reload (DA-02)', () => {
});
// QNBS-v3 (codex P1): a hidden tab's state can't be fresher than what's already persisted — only the visible tab flushes.
- it('defers both flush and reload while the tab is hidden, then runs both once it becomes visible', async () => {
+ it('defers reload while the tab is hidden, then reloads once visible without flushing again', async () => {
mockFlushPersistedState.mockResolvedValue(undefined);
await registerServiceWorker();
setVisibility('hidden');
@@ -140,7 +139,8 @@ describe('register-sw — controllerchange flush-then-reload (DA-02)', () => {
document.dispatchEvent(new Event('visibilitychange'));
await flushMicrotasks();
- expect(mockFlushPersistedState).toHaveBeenCalledTimes(1);
+ // QNBS-v3 (codex): index.tsx's own visibilitychange listener already flushed this tab when it went hidden — flushing its possibly-stale copy again here could clobber a fresher write from another tab.
+ expect(mockFlushPersistedState).not.toHaveBeenCalled();
expect(reloadSpy).toHaveBeenCalledTimes(1);
});
@@ -165,4 +165,22 @@ describe('register-sw — controllerchange flush-then-reload (DA-02)', () => {
const reloadOrder = reloadSpy.mock.invocationCallOrder[0] as number;
expect(lastFlushOrder).toBeLessThan(reloadOrder);
});
+
+ // QNBS-v3 (codex P2): the retry loop can exhaust its budget while state keeps changing — one guaranteed final flush must still capture whatever's freshest, not silently drop it.
+ it('performs one final guaranteed flush of the freshest state after exhausting the retry budget', async () => {
+ const states = Array.from({ length: 7 }, (_, i) => ({ v: i }));
+ const getStateMock = vi.fn();
+ for (const s of states) getStateMock.mockReturnValueOnce(s);
+ appStoreRef.current = { getState: getStateMock, dispatch: vi.fn() as never };
+ mockFlushPersistedState.mockResolvedValue(undefined);
+
+ await registerServiceWorker();
+ controllerChangeHandler?.();
+ await flushMicrotasks();
+
+ // 5 in-loop attempts (states[0..4]) + 1 guaranteed final flush of the freshest state (states[6]).
+ expect(mockFlushPersistedState).toHaveBeenCalledTimes(6);
+ expect(mockFlushPersistedState).toHaveBeenNthCalledWith(6, states[6]);
+ expect(reloadSpy).toHaveBeenCalledTimes(1);
+ });
});
From 75802ef667ec85cd8943261b246e7b141f98faae Mon Sep 17 00:00:00 2001
From: qnbs <155236708+qnbs@users.noreply.github.com>
Date: Thu, 27 Aug 2026 00:04:30 +0200
Subject: [PATCH 04/10] docs(pwa): correct DA-02 comments and PR description to
match actual guarantees
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
An external cross-check on #517 correctly flagged truth drift and overclaiming
introduced across the two prior correction rounds:
- public/sw.js still carried its original "the app auto-saves to IDB so a
mid-session reload is safe" comment — exactly the unverified assumption
DA-02 exists to disprove. Replaced with an accurate pointer to
register-sw.ts's bounded mitigation and its tracked residual risk (#518).
- register-sw.ts's own comments ("index.tsx already flushed this tab's
edits", "owns reload safety") stated more certainty than the underlying
mechanisms actually provide — index.tsx's hide-time flush is explicitly
best-effort (a failure there is only logged, never retried), and the
retry-until-stable flush narrows but doesn't eliminate its own race
window. Reworded to state the trade-off honestly and point at #518.
- The PR description described the first commit's design (defer reload on
flush failure), which the second correction round reversed (always
reload regardless of flush outcome) — never updated to match. Rewritten
to describe the current code, plus a residual-risk section using the
precise framing #518 itself uses: risk frequency substantially reduced,
risk class (data-integrity/edit-loss) unchanged, architecture-level
closure not achieved.
Filed #518 to track the three residual risk classes an external review
correctly identified (simultaneous multi-window writers, best-effort
hide-flush failure, final-flush race window) as the scoped follow-up for
the broader cross-tab write-admission work this narrow slice doesn't
attempt — no behavioral change in this commit, comments and description
only.
---
public/sw.js | 4 +---
register-sw.ts | 6 +++---
2 files changed, 4 insertions(+), 6 deletions(-)
diff --git a/public/sw.js b/public/sw.js
index 6a63bbb87..f7dbde686 100644
--- a/public/sw.js
+++ b/public/sw.js
@@ -111,9 +111,7 @@ async function offlineFallback(request) {
// INSTALL — Precache shell
// ════════════════════════════════════════════════════════════
self.addEventListener('install', (event) => {
- // QNBS-v3: skipWaiting immediately so a new SW never sits in "waiting" state behind a stale
- // active SW that serves cached v.old assets. The app auto-saves to IDB so a mid-session
- // reload is safe. Paired with clients.claim() in activate this ensures all tabs get new code.
+ // QNBS-v3 (DA-02): activates immediately, no waiting — register-sw.ts owns the bounded pre-reload flush mitigation, residual risk tracked in #518.
self.skipWaiting();
// QNBS-v3: Never precache inside Tauri — the desktop app serves its shell from the bundle.
if (IS_TAURI) return;
diff --git a/register-sw.ts b/register-sw.ts
index ff787e715..60ece4ed5 100644
--- a/register-sw.ts
+++ b/register-sw.ts
@@ -186,7 +186,7 @@ const registerServiceWorker = async (): Promise => {
announceUpdateAvailable(registration.waiting);
}
- // QNBS-v3 (DA-02): only the visible tab flushes — a hidden tab's stale write could race a fresher one.
+ // QNBS-v3 (DA-02): only the visible tab flushes — a hidden tab's stale write could race a fresher one (residual multi-window gap: #518).
let refreshing = false;
let reloadPendingWhileHidden = false;
navigator.serviceWorker.addEventListener('controllerchange', () => {
@@ -201,7 +201,7 @@ const registerServiceWorker = async (): Promise => {
document.addEventListener('visibilitychange', () => {
if (reloadPendingWhileHidden && document.visibilityState === 'visible' && !refreshing) {
refreshing = true;
- // QNBS-v3 (codex): no flush here — index.tsx already flushed this tab's edits when it went hidden; re-flushing now could clobber a fresher write.
+ // QNBS-v3 (DA-02, residual risk tracked in #518): no flush here — index.tsx's best-effort hide-time flush may have failed, but re-flushing this possibly-stale copy risks clobbering a fresher write from another tab, which is the worse failure mode of the two.
window.location.reload();
}
});
@@ -250,7 +250,7 @@ async function flushLatestState(): Promise {
if (latest === snapshot) return;
snapshot = latest;
}
- // QNBS-v3 (codex): the loop above can exhaust its budget mid-churn — one final flush of whatever's freshest right now, rather than silently dropping it.
+ // QNBS-v3 (codex, residual risk #518): narrows but doesn't eliminate the race — a keystroke during this final await is still possible to lose.
await flushPersistedState(store.getState() as RootState);
}
From daf44e1ee5c7a33971ad84c66082d5ff111aff55 Mon Sep 17 00:00:00 2001
From: qnbs <155236708+qnbs@users.noreply.github.com>
Date: Thu, 27 Aug 2026 00:39:08 +0200
Subject: [PATCH 05/10] =?UTF-8?q?fix(pwa):=20close=20DA-02's=20third=20rev?=
=?UTF-8?q?iew=20wave=20=E2=80=94=20real=20implementation=20bugs?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Addresses PR #517's third review wave (CodeRabbit, chatgpt-codex-connector),
three of which were genuine implementation bugs (not architectural gaps),
fixed here:
- app/persistedStateFlush.ts used Promise.all, which rejects as soon as
either the settings or project save fails — without waiting for the
other to settle. A caller that reloads immediately after a rejection
(this PR's whole purpose) could tear down the page while the surviving
save was still in flight, losing edits the flush was supposed to
protect. Switched to Promise.allSettled, still failing closed (throwing
the first rejection's reason) but only after both have genuinely
finished.
- services/storage/idbProjectStore.ts's saveSlice() resolved its promise
on IDBRequest.onsuccess, which fires before the surrounding IndexedDB
transaction has actually committed — a caller that reloads immediately
after "success" could interrupt the commit mid-flight. Now resolves on
transaction.oncomplete instead, rejecting on transaction.onerror/onabort
too (in addition to the existing request.onerror).
- register-sw.ts's flushLatestState() retry loop compared the whole root
Redux state for "did anything change," which also fires on unrelated
non-persisted churn (e.g. status.saving toggling during the write) —
wasting retry attempts on noise instead of real edits. Now compares only
the slices flushPersistedState actually persists (project.present,
versionControl, settings).
Also updated #518 with a corrected, sharper likelihood assessment for its
first residual-risk class: ordinary sequential tab-switching (not just
simultaneous multi-window use) can trigger the visible-tab-clobber
scenario, which the issue originally under-stated as a rare compound
precondition.
New/updated regression tests for all three fixes, verified against the
prior commit — including a broader sweep of the existing IDB/storage test
suite (dbService, storageService, encryption round-trips) confirming no
regression to the shared saveSlice/flushPersistedState call sites.
---
README.md | 8 +-
app/persistedStateFlush.ts | 7 +-
register-sw.ts | 27 ++++-
services/storage/idbProjectStore.ts | 6 +-
tests/unit/persistedStateFlush.test.ts | 36 +++++-
tests/unit/registerSwUpdateFlush.test.ts | 35 +++++-
.../storage/idbProjectStoreSaveSlice.test.ts | 104 ++++++++++++++++++
7 files changed, 207 insertions(+), 16 deletions(-)
create mode 100644 tests/unit/services/storage/idbProjectStoreSaveSlice.test.ts
diff --git a/README.md b/README.md
index d9388a22d..8b905c449 100644
--- a/README.md
+++ b/README.md
@@ -14,7 +14,7 @@
-
+
@@ -512,7 +512,7 @@ The Settings → AI panel shows a live GPU status badge with adapter details and
| **Document Export** | docx + jszip | Word-compatible `.docx` generation (lazy-loaded) |
| **PWA** | Service Worker + Web App Manifest v3 | Offline support, installability, Workbox chunking |
| **i18n** | Custom React Context (`I18nContext.tsx`) | 2925 keys × 19 locales (de/en/es/fr/it + ar/he/fa RTL Beta + ja/zh/pt/el/fi/sv/hu/is/eu/ru/ko Beta); EN fallback; `localStorage` persistence |
-| **Testing** | Vitest 4.x (7145+ tests / 584 files) + Playwright E2E | Unit/integration + cross-browser E2E; Stryker mutation (manual workflow) |
+| **Testing** | Vitest 4.x (7150+ tests / 585 files) + Playwright E2E | Unit/integration + cross-browser E2E; Stryker mutation (manual workflow) |
| **Code Quality** | Biome (lint + format) + TypeScript 7 (tsgo) strict | `--error-on-warnings` in CI; zero `any` policy |
| **Visualization** | Force-directed graph | Interactive character relationship network |
| **Desktop** | Tauri v2 | Cross-platform installer; auto-updater via `latest.json` |
@@ -550,7 +550,7 @@ WorldScript-Studio/
│ ├── sw.js # PWA Service Worker
│ └── manifest.json # PWA Web App Manifest v3
├── tests/
-│ ├── unit/ # Vitest unit tests (7145+ tests, 584 files) — count spans tests/, components/, packages/*/tests/, not just this folder
+│ ├── unit/ # Vitest unit tests (7150+ tests, 585 files) — count spans tests/, components/, packages/*/tests/, not just this folder
│ │ ├── ai/ # aiSmallModules, aiCoreFallbackPaths
│ │ └── settings/ # WebLlmPanel, AiSections
│ └── e2e/ # Playwright specs + helpers.ts
@@ -712,7 +712,7 @@ The main pipeline is [`.github/workflows/ci.yml`](.github/workflows/ci.yml). Opt
| `scorecard` | weekly + `main` push | OpenSSF Scorecard — SARIF uploaded to GitHub Code Scanning |
**Current test metrics (2026-08-21, source-synchronized; CI remains authoritative for pass/fail):**
-- **7145+ unit tests** across **584 test files** — CI is authoritative for pass/fail
+- **7150+ unit tests** across **585 test files** — CI is authoritative for pass/fail
- Coverage thresholds: lines ≥ 80 · branches ≥ 66 · functions ≥ 72 · statements ≥ 78 — enforced in CI (see Codecov badge for live metrics)
- i18n: **2925 keys × 19 locales** (en/de/fr/es/it + ar/he/fa RTL Beta + ja/zh/pt/el/fi/sv/hu/is/eu/ru/ko Beta)
diff --git a/app/persistedStateFlush.ts b/app/persistedStateFlush.ts
index 5a991d7cb..acda1f4a8 100644
--- a/app/persistedStateFlush.ts
+++ b/app/persistedStateFlush.ts
@@ -32,5 +32,10 @@ export async function flushPersistedState(state: RootState): Promise {
),
);
}
- await Promise.all(saves);
+ // QNBS-v3 (codex): allSettled — Promise.all's fail-fast let a caller reload before the other save finished; both must settle first, still failing closed if either rejected.
+ const results = await Promise.allSettled(saves);
+ const rejected = results.find(
+ (result): result is PromiseRejectedResult => result.status === 'rejected',
+ );
+ if (rejected) throw rejected.reason;
}
\ No newline at end of file
diff --git a/register-sw.ts b/register-sw.ts
index 60ece4ed5..a55b4cb77 100644
--- a/register-sw.ts
+++ b/register-sw.ts
@@ -240,15 +240,34 @@ const registerServiceWorker = async (): Promise => {
// QNBS-v3 (DA-02): loops until a flush completes against state that provably hasn't changed since — a single snapshot could miss edits made while the async write was still in flight.
const MAX_FLUSH_ATTEMPTS = 5;
+// QNBS-v3 (codex): mirrors exactly what flushPersistedState reads/persists — comparing the whole root state would retry on unrelated non-persisted churn (e.g. status.saving) and waste the retry budget.
+function persistedSlices(state: RootState) {
+ return {
+ project: state.project.present,
+ versionControl: state.versionControl,
+ settings: state.settings,
+ };
+}
+
+function persistedSlicesUnchanged(
+ a: ReturnType,
+ b: ReturnType,
+): boolean {
+ return a.project === b.project && a.versionControl === b.versionControl && a.settings === b.settings;
+}
+
async function flushLatestState(): Promise {
const store = appStoreRef.current;
if (!store) return;
- let snapshot = store.getState();
+ let snapshot = store.getState() as RootState;
+ let snapshotSlices = persistedSlices(snapshot);
for (let attempt = 0; attempt < MAX_FLUSH_ATTEMPTS; attempt++) {
- await flushPersistedState(snapshot as RootState);
- const latest = store.getState();
- if (latest === snapshot) return;
+ await flushPersistedState(snapshot);
+ const latest = store.getState() as RootState;
+ const latestSlices = persistedSlices(latest);
+ if (persistedSlicesUnchanged(snapshotSlices, latestSlices)) return;
snapshot = latest;
+ snapshotSlices = latestSlices;
}
// QNBS-v3 (codex, residual risk #518): narrows but doesn't eliminate the race — a keystroke during this final await is still possible to lose.
await flushPersistedState(store.getState() as RootState);
diff --git a/services/storage/idbProjectStore.ts b/services/storage/idbProjectStore.ts
index 2df6fdce8..a9451d43d 100644
--- a/services/storage/idbProjectStore.ts
+++ b/services/storage/idbProjectStore.ts
@@ -268,8 +268,12 @@ export class IdbProjectStore extends IdbAssetStore {
const store = await this.getObjectStore(APP_DATA_STORE, 'readwrite');
return new Promise((resolve, reject) => {
const request = store.put(payload, sliceName);
- request.onsuccess = () => resolve();
+ const transaction = store.transaction;
+ // QNBS-v3 (codex): resolve on transaction commit, not request success — onsuccess fires before the write is durable, which now matters since a caller can immediately reload.
request.onerror = () => reject(request.error);
+ transaction.oncomplete = () => resolve();
+ transaction.onerror = () => reject(transaction.error);
+ transaction.onabort = () => reject(transaction.error ?? new Error('IDB transaction aborted'));
});
});
}
diff --git a/tests/unit/persistedStateFlush.test.ts b/tests/unit/persistedStateFlush.test.ts
index 88158c8dd..07c6b4a03 100644
--- a/tests/unit/persistedStateFlush.test.ts
+++ b/tests/unit/persistedStateFlush.test.ts
@@ -3,7 +3,9 @@
* QNBS-v3 (#332/D3): shared flush helper used by both index.tsx's visibilitychange handler and the
* desktop close-to-tray quit flush — verifies it saves project+settings via storageService, always
* saves settings even with no project data yet (fresh/new-user state), and fails closed on any
- * rejected save (Promise.all, not Promise.allSettled) so a failed write is never silently ignored.
+ * rejected save (Promise.allSettled, waiting for both to settle before rejecting) so a failed
+ * write is never silently ignored and a caller that reloads immediately after never tears down
+ * the page while the other save is still in flight.
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
@@ -81,4 +83,36 @@ describe('flushPersistedState', () => {
h.saveSettings.mockRejectedValueOnce(new Error('disk full'));
await expect(flushPersistedState(buildState())).rejects.toThrow('disk full');
});
+
+ // QNBS-v3 (codex): an immediate-reload caller must never tear down the page while the other save is still in flight.
+ it('waits for the other save to settle before rejecting, instead of rejecting as soon as one fails', async () => {
+ const order: string[] = [];
+ h.saveProject.mockImplementation(async () => {
+ order.push('project-rejected');
+ throw new Error('project save failed');
+ });
+ let resolveSettings: () => void = () => {};
+ h.saveSettings.mockImplementation(
+ () =>
+ new Promise((resolve) => {
+ resolveSettings = () => {
+ order.push('settings-resolved');
+ resolve();
+ };
+ }),
+ );
+
+ const flushPromise = flushPersistedState(buildState()).catch((err: unknown) => {
+ order.push('flush-rejected');
+ throw err;
+ });
+
+ // QNBS-v3: a macrotask boundary drains every microtask the real coordinator's drain loop schedules, however many ticks deep.
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ expect(order).toEqual(['project-rejected']);
+
+ resolveSettings();
+ await expect(flushPromise).rejects.toThrow('project save failed');
+ expect(order).toEqual(['project-rejected', 'settings-resolved', 'flush-rejected']);
+ });
});
diff --git a/tests/unit/registerSwUpdateFlush.test.ts b/tests/unit/registerSwUpdateFlush.test.ts
index 7a7e4c626..807307efc 100644
--- a/tests/unit/registerSwUpdateFlush.test.ts
+++ b/tests/unit/registerSwUpdateFlush.test.ts
@@ -59,8 +59,8 @@ describe('register-sw — controllerchange flush-then-reload (DA-02)', () => {
configurable: true,
});
- // QNBS-v3: a stable reference — a fresh object each call would never satisfy the "unchanged" stop condition.
- const stableState = { fake: 'state' };
+ // QNBS-v3: a stable reference with real RootState shape — persistedSlices() reads project.present, matching production where project is never undefined.
+ const stableState = { project: { present: { fake: 'state' } }, versionControl: {}, settings: {} };
appStoreRef.current = {
getState: () => stableState as never,
dispatch: vi.fn() as never,
@@ -146,8 +146,8 @@ describe('register-sw — controllerchange flush-then-reload (DA-02)', () => {
// QNBS-v3 (CodeAnt/codex): a single snapshot could miss an edit made while the async write is still in flight.
it('re-flushes with the latest state when it changes during the pending flush, before reloading', async () => {
- const stateA = { v: 'a' };
- const stateB = { v: 'b' };
+ const stateA = { project: { present: { v: 'a' } }, versionControl: {}, settings: {} };
+ const stateB = { project: { present: { v: 'b' } }, versionControl: {}, settings: {} };
// 1st getState(): stateA. 2nd (after flush #1): stateB (changed — retry). 3rd (after flush #2): stateB (stable — stop).
const getStateMock = vi.fn().mockReturnValueOnce(stateA).mockReturnValueOnce(stateB).mockReturnValue(stateB);
appStoreRef.current = { getState: getStateMock, dispatch: vi.fn() as never };
@@ -168,7 +168,11 @@ describe('register-sw — controllerchange flush-then-reload (DA-02)', () => {
// QNBS-v3 (codex P2): the retry loop can exhaust its budget while state keeps changing — one guaranteed final flush must still capture whatever's freshest, not silently drop it.
it('performs one final guaranteed flush of the freshest state after exhausting the retry budget', async () => {
- const states = Array.from({ length: 7 }, (_, i) => ({ v: i }));
+ const states = Array.from({ length: 7 }, (_, i) => ({
+ project: { present: { v: i } },
+ versionControl: {},
+ settings: {},
+ }));
const getStateMock = vi.fn();
for (const s of states) getStateMock.mockReturnValueOnce(s);
appStoreRef.current = { getState: getStateMock, dispatch: vi.fn() as never };
@@ -183,4 +187,25 @@ describe('register-sw — controllerchange flush-then-reload (DA-02)', () => {
expect(mockFlushPersistedState).toHaveBeenNthCalledWith(6, states[6]);
expect(reloadSpy).toHaveBeenCalledTimes(1);
});
+
+ // QNBS-v3 (codex P2): comparing the whole root state retried on unrelated non-persisted churn (e.g. status.saving), wasting the retry budget on noise instead of real edits.
+ it('does not retry when only a non-persisted slice changes between getState() calls', async () => {
+ const project = { present: { v: 'a' } };
+ const versionControl = {};
+ const settings = {};
+ // Same persisted slices every call — only the non-persisted `status` field differs.
+ const getStateMock = vi
+ .fn()
+ .mockReturnValueOnce({ project, versionControl, settings, status: { saving: 'saving' } })
+ .mockReturnValue({ project, versionControl, settings, status: { saving: 'saved' } });
+ appStoreRef.current = { getState: getStateMock, dispatch: vi.fn() as never };
+ mockFlushPersistedState.mockResolvedValue(undefined);
+
+ await registerServiceWorker();
+ controllerChangeHandler?.();
+ await flushMicrotasks();
+
+ expect(mockFlushPersistedState).toHaveBeenCalledTimes(1);
+ expect(reloadSpy).toHaveBeenCalledTimes(1);
+ });
});
diff --git a/tests/unit/services/storage/idbProjectStoreSaveSlice.test.ts b/tests/unit/services/storage/idbProjectStoreSaveSlice.test.ts
new file mode 100644
index 000000000..498618779
--- /dev/null
+++ b/tests/unit/services/storage/idbProjectStoreSaveSlice.test.ts
@@ -0,0 +1,104 @@
+/**
+ * Tests for IdbProjectStore#saveSlice — DA-02 review-wave fix (codex): the returned promise must
+ * resolve only once the underlying IndexedDB transaction actually commits (transaction.oncomplete),
+ * not merely once the individual put() request succeeds (request.onsuccess) — a caller that reloads
+ * immediately after resolution must never be able to tear down the page mid-commit.
+ */
+import { describe, expect, it, vi } from 'vitest';
+
+vi.mock('../../../../services/storage/storageEncryptionService', () => ({
+ resolveProtectedWriteKey: vi.fn().mockResolvedValue(null),
+ assertNoActiveEncryptionMigration: vi.fn().mockResolvedValue(undefined),
+ idbEncryptWithKey: vi.fn(),
+ idbReadSecure: vi.fn(),
+ assertIdbProtectedWriteAllowed: vi.fn().mockResolvedValue(undefined),
+ assertSecureStorageReadable: vi.fn().mockResolvedValue(undefined),
+}));
+
+import { IdbProjectStore } from '../../../../services/storage/idbProjectStore';
+
+interface FakeIdbRequest {
+ onsuccess: (() => void) | null;
+ onerror: (() => void) | null;
+ error: unknown;
+}
+
+interface FakeIdbTransaction {
+ oncomplete: (() => void) | null;
+ onerror: (() => void) | null;
+ onabort: (() => void) | null;
+ error: unknown;
+}
+
+function makeFakeStore(): { store: { put: () => FakeIdbRequest; transaction: FakeIdbTransaction }; request: FakeIdbRequest; transaction: FakeIdbTransaction } {
+ const transaction: FakeIdbTransaction = { oncomplete: null, onerror: null, onabort: null, error: null };
+ const request: FakeIdbRequest = { onsuccess: null, onerror: null, error: null };
+ const store = { put: () => request, transaction };
+ return { store, request, transaction };
+}
+
+describe('IdbProjectStore#saveSlice — resolves on transaction commit, not request success', () => {
+ it('does not resolve when only request.onsuccess has fired', async () => {
+ const projectStore = new IdbProjectStore();
+ const { store, request, transaction } = makeFakeStore();
+ vi.spyOn(
+ projectStore as unknown as { getObjectStore: () => Promise },
+ 'getObjectStore',
+ ).mockResolvedValue(store as never);
+
+ let resolved = false;
+ const savePromise = projectStore.saveSlice('settings', { theme: 'dark' } as never).then(() => {
+ resolved = true;
+ });
+
+ // Give the async setup (key resolution, migration guard, getObjectStore) time to run and call put().
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ request.onsuccess?.();
+ await new Promise((resolve) => setTimeout(resolve, 0));
+
+ expect(resolved).toBe(false);
+ transaction.oncomplete?.(); // settle the promise so it doesn't leak past this test
+ await savePromise;
+ });
+
+ it('resolves once transaction.oncomplete fires, after request.onsuccess', async () => {
+ const projectStore = new IdbProjectStore();
+ const { store, request, transaction } = makeFakeStore();
+ vi.spyOn(
+ projectStore as unknown as { getObjectStore: () => Promise },
+ 'getObjectStore',
+ ).mockResolvedValue(store as never);
+
+ const order: string[] = [];
+ const savePromise = projectStore
+ .saveSlice('settings', { theme: 'dark' } as never)
+ .then(() => order.push('resolved'));
+
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ request.onsuccess?.();
+ order.push('request-succeeded');
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ expect(order).toEqual(['request-succeeded']);
+
+ transaction.oncomplete?.();
+ await savePromise;
+ expect(order).toEqual(['request-succeeded', 'resolved']);
+ });
+
+ it('rejects if the transaction aborts even though the request itself succeeded', async () => {
+ const projectStore = new IdbProjectStore();
+ const { store, request, transaction } = makeFakeStore();
+ vi.spyOn(
+ projectStore as unknown as { getObjectStore: () => Promise },
+ 'getObjectStore',
+ ).mockResolvedValue(store as never);
+
+ const savePromise = projectStore.saveSlice('settings', { theme: 'dark' } as never);
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ request.onsuccess?.();
+ transaction.error = new Error('QuotaExceededError');
+ transaction.onabort?.();
+
+ await expect(savePromise).rejects.toThrow('QuotaExceededError');
+ });
+});
From 3eec3dacf7b8ba6ea00890602fd62ef2332e0a6b Mon Sep 17 00:00:00 2001
From: qnbs <155236708+qnbs@users.noreply.github.com>
Date: Thu, 27 Aug 2026 01:03:42 +0200
Subject: [PATCH 06/10] fix(pwa): bound the pre-reload flush wait (DA-02,
codex)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The pre-reload flush had no timeout — if the underlying persistence stayed
pending (e.g. queued behind another tab's exclusive Web Lock during an
encryption migration batch), the await never resolved or rejected, so
neither the success path nor the catch-and-reload-anyway path ever ran.
By that point activation has already pruned the old-version caches, so a
tab stuck this way could remain indefinitely on an obsolete bundle whose
lazy chunks are gone — the exact failure mode the always-reload redesign
was meant to close.
flushLatestStateThenReload() now races the flush against an 8s timeout;
on timeout it's treated the same as any other flush failure — logged, then
reload proceeds anyway. New regression test using fake timers (a flush
that never settles) verified against the prior commit to genuinely hang.
---
README.md | 8 ++++----
register-sw.ts | 10 +++++++++-
tests/unit/registerSwUpdateFlush.test.ts | 18 ++++++++++++++++++
3 files changed, 31 insertions(+), 5 deletions(-)
diff --git a/README.md b/README.md
index 8b905c449..4b0180d39 100644
--- a/README.md
+++ b/README.md
@@ -14,7 +14,7 @@
-
+
@@ -512,7 +512,7 @@ The Settings → AI panel shows a live GPU status badge with adapter details and
| **Document Export** | docx + jszip | Word-compatible `.docx` generation (lazy-loaded) |
| **PWA** | Service Worker + Web App Manifest v3 | Offline support, installability, Workbox chunking |
| **i18n** | Custom React Context (`I18nContext.tsx`) | 2925 keys × 19 locales (de/en/es/fr/it + ar/he/fa RTL Beta + ja/zh/pt/el/fi/sv/hu/is/eu/ru/ko Beta); EN fallback; `localStorage` persistence |
-| **Testing** | Vitest 4.x (7150+ tests / 585 files) + Playwright E2E | Unit/integration + cross-browser E2E; Stryker mutation (manual workflow) |
+| **Testing** | Vitest 4.x (7151+ tests / 585 files) + Playwright E2E | Unit/integration + cross-browser E2E; Stryker mutation (manual workflow) |
| **Code Quality** | Biome (lint + format) + TypeScript 7 (tsgo) strict | `--error-on-warnings` in CI; zero `any` policy |
| **Visualization** | Force-directed graph | Interactive character relationship network |
| **Desktop** | Tauri v2 | Cross-platform installer; auto-updater via `latest.json` |
@@ -550,7 +550,7 @@ WorldScript-Studio/
│ ├── sw.js # PWA Service Worker
│ └── manifest.json # PWA Web App Manifest v3
├── tests/
-│ ├── unit/ # Vitest unit tests (7150+ tests, 585 files) — count spans tests/, components/, packages/*/tests/, not just this folder
+│ ├── unit/ # Vitest unit tests (7151+ tests, 585 files) — count spans tests/, components/, packages/*/tests/, not just this folder
│ │ ├── ai/ # aiSmallModules, aiCoreFallbackPaths
│ │ └── settings/ # WebLlmPanel, AiSections
│ └── e2e/ # Playwright specs + helpers.ts
@@ -712,7 +712,7 @@ The main pipeline is [`.github/workflows/ci.yml`](.github/workflows/ci.yml). Opt
| `scorecard` | weekly + `main` push | OpenSSF Scorecard — SARIF uploaded to GitHub Code Scanning |
**Current test metrics (2026-08-21, source-synchronized; CI remains authoritative for pass/fail):**
-- **7150+ unit tests** across **585 test files** — CI is authoritative for pass/fail
+- **7151+ unit tests** across **585 test files** — CI is authoritative for pass/fail
- Coverage thresholds: lines ≥ 80 · branches ≥ 66 · functions ≥ 72 · statements ≥ 78 — enforced in CI (see Codecov badge for live metrics)
- i18n: **2925 keys × 19 locales** (en/de/fr/es/it + ar/he/fa RTL Beta + ja/zh/pt/el/fi/sv/hu/is/eu/ru/ko Beta)
diff --git a/register-sw.ts b/register-sw.ts
index a55b4cb77..d988fb5ab 100644
--- a/register-sw.ts
+++ b/register-sw.ts
@@ -273,10 +273,18 @@ async function flushLatestState(): Promise {
await flushPersistedState(store.getState() as RootState);
}
+// QNBS-v3 (codex): bounds the flush — an unbounded wait (e.g. queued behind another tab's exclusive Web Lock) would hang forever on an already-cache-pruned bundle, defeating the always-reload policy below.
+const FLUSH_TIMEOUT_MS = 8000;
+
// QNBS-v3 (DA-02): the reload always proceeds — activation already pruned old-version caches by the time controllerchange fires, so staying on the old bundle risks missing-chunk failures too.
async function flushLatestStateThenReload(): Promise {
try {
- await flushLatestState();
+ await Promise.race([
+ flushLatestState(),
+ new Promise((_, reject) =>
+ setTimeout(() => reject(new Error(`Pre-reload flush timed out after ${FLUSH_TIMEOUT_MS}ms`)), FLUSH_TIMEOUT_MS),
+ ),
+ ]);
} catch (error) {
appLogger.error('[SW] Pre-reload state flush failed (reloading anyway):', error);
}
diff --git a/tests/unit/registerSwUpdateFlush.test.ts b/tests/unit/registerSwUpdateFlush.test.ts
index 807307efc..8e573bae9 100644
--- a/tests/unit/registerSwUpdateFlush.test.ts
+++ b/tests/unit/registerSwUpdateFlush.test.ts
@@ -208,4 +208,22 @@ describe('register-sw — controllerchange flush-then-reload (DA-02)', () => {
expect(mockFlushPersistedState).toHaveBeenCalledTimes(1);
expect(reloadSpy).toHaveBeenCalledTimes(1);
});
+
+ // QNBS-v3 (codex P2): an unbounded wait (e.g. queued behind another tab's exclusive Web Lock) must not hang the reload forever on an already-cache-pruned bundle.
+ it('reloads once the flush timeout elapses if the flush never settles', async () => {
+ vi.useFakeTimers();
+ try {
+ mockFlushPersistedState.mockImplementation(() => new Promise(() => {}));
+ await registerServiceWorker();
+ controllerChangeHandler?.();
+
+ await vi.advanceTimersByTimeAsync(0);
+ expect(reloadSpy).not.toHaveBeenCalled();
+
+ await vi.advanceTimersByTimeAsync(8000);
+ expect(reloadSpy).toHaveBeenCalledTimes(1);
+ } finally {
+ vi.useRealTimers();
+ }
+ });
});
From 21a9dfc0b03c906ad001a947d2921d6a40b1b28e Mon Sep 17 00:00:00 2001
From: qnbs <155236708+qnbs@users.noreply.github.com>
Date: Thu, 27 Aug 2026 01:23:11 +0200
Subject: [PATCH 07/10] docs(pwa): conform QNBS-v3 comments to the literal repo
format
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Every QNBS-v3 comment this PR introduced used a "(TAG): reason" parenthetical
prefix (e.g. "QNBS-v3 (codex): ..."), deviating from the literal repo
convention "// QNBS-v3: " (nothing between the marker and
the colon). The convention's actual intent (a single-line WHY-comment) was
satisfied either way, and the specific "required format" a reviewer cited
didn't match this repo's real guidelines — but conforming to the literal
pattern costs nothing and avoids relitigating the same finding again.
Folded each tag's traceability info into the reason text itself (most
already referenced #518/codex by name in the body). No functional change —
comments only.
Also fixes an overclaiming comment this same cleanup pass caught: a test
comment said index.tsx "already flushed" a hidden tab's edits, when the
underlying flushOnHidden call is explicitly best-effort and can fail — now
says "already attempted a flush", matching the equivalent production
comment fixed earlier and the actual guarantee (or lack of one).
---
app/persistedStateFlush.ts | 2 +-
public/sw.js | 2 +-
register-sw.ts | 14 +++++++-------
services/storage/idbProjectStore.ts | 2 +-
tests/unit/persistedStateFlush.test.ts | 2 +-
tests/unit/registerSwUpdateFlush.test.ts | 14 +++++++-------
6 files changed, 18 insertions(+), 18 deletions(-)
diff --git a/app/persistedStateFlush.ts b/app/persistedStateFlush.ts
index acda1f4a8..2a9de7ba2 100644
--- a/app/persistedStateFlush.ts
+++ b/app/persistedStateFlush.ts
@@ -32,7 +32,7 @@ export async function flushPersistedState(state: RootState): Promise {
),
);
}
- // QNBS-v3 (codex): allSettled — Promise.all's fail-fast let a caller reload before the other save finished; both must settle first, still failing closed if either rejected.
+ // QNBS-v3: allSettled, not Promise.all — its fail-fast let a caller reload before the other save finished; both must settle first, still failing closed if either rejected.
const results = await Promise.allSettled(saves);
const rejected = results.find(
(result): result is PromiseRejectedResult => result.status === 'rejected',
diff --git a/public/sw.js b/public/sw.js
index f7dbde686..af7150382 100644
--- a/public/sw.js
+++ b/public/sw.js
@@ -111,7 +111,7 @@ async function offlineFallback(request) {
// INSTALL — Precache shell
// ════════════════════════════════════════════════════════════
self.addEventListener('install', (event) => {
- // QNBS-v3 (DA-02): activates immediately, no waiting — register-sw.ts owns the bounded pre-reload flush mitigation, residual risk tracked in #518.
+ // QNBS-v3: activates immediately, no waiting — register-sw.ts owns the bounded pre-reload flush mitigation, residual risk tracked in #518.
self.skipWaiting();
// QNBS-v3: Never precache inside Tauri — the desktop app serves its shell from the bundle.
if (IS_TAURI) return;
diff --git a/register-sw.ts b/register-sw.ts
index d988fb5ab..b0a03140b 100644
--- a/register-sw.ts
+++ b/register-sw.ts
@@ -186,7 +186,7 @@ const registerServiceWorker = async (): Promise => {
announceUpdateAvailable(registration.waiting);
}
- // QNBS-v3 (DA-02): only the visible tab flushes — a hidden tab's stale write could race a fresher one (residual multi-window gap: #518).
+ // QNBS-v3: only the visible tab flushes — a hidden tab's stale write could race a fresher one (residual multi-window gap: #518).
let refreshing = false;
let reloadPendingWhileHidden = false;
navigator.serviceWorker.addEventListener('controllerchange', () => {
@@ -201,7 +201,7 @@ const registerServiceWorker = async (): Promise => {
document.addEventListener('visibilitychange', () => {
if (reloadPendingWhileHidden && document.visibilityState === 'visible' && !refreshing) {
refreshing = true;
- // QNBS-v3 (DA-02, residual risk tracked in #518): no flush here — index.tsx's best-effort hide-time flush may have failed, but re-flushing this possibly-stale copy risks clobbering a fresher write from another tab, which is the worse failure mode of the two.
+ // QNBS-v3: no flush here — index.tsx's best-effort hide-time flush may have failed, but re-flushing risks clobbering a fresher write from another tab, the worse failure mode of the two (#518).
window.location.reload();
}
});
@@ -237,10 +237,10 @@ const registerServiceWorker = async (): Promise => {
}
};
-// QNBS-v3 (DA-02): loops until a flush completes against state that provably hasn't changed since — a single snapshot could miss edits made while the async write was still in flight.
+// QNBS-v3: loops until a flush completes against state that provably hasn't changed since — a single snapshot could miss edits made while the async write was still in flight.
const MAX_FLUSH_ATTEMPTS = 5;
-// QNBS-v3 (codex): mirrors exactly what flushPersistedState reads/persists — comparing the whole root state would retry on unrelated non-persisted churn (e.g. status.saving) and waste the retry budget.
+// QNBS-v3: mirrors exactly what flushPersistedState reads/persists — comparing the whole root state would retry on unrelated non-persisted churn (e.g. status.saving) and waste the retry budget.
function persistedSlices(state: RootState) {
return {
project: state.project.present,
@@ -269,14 +269,14 @@ async function flushLatestState(): Promise {
snapshot = latest;
snapshotSlices = latestSlices;
}
- // QNBS-v3 (codex, residual risk #518): narrows but doesn't eliminate the race — a keystroke during this final await is still possible to lose.
+ // QNBS-v3: narrows but doesn't eliminate the race — a keystroke during this final await is still possible to lose (#518).
await flushPersistedState(store.getState() as RootState);
}
-// QNBS-v3 (codex): bounds the flush — an unbounded wait (e.g. queued behind another tab's exclusive Web Lock) would hang forever on an already-cache-pruned bundle, defeating the always-reload policy below.
+// QNBS-v3: bounds the flush — an unbounded wait (e.g. queued behind another tab's exclusive Web Lock) would hang forever on an already-cache-pruned bundle, defeating the always-reload policy below.
const FLUSH_TIMEOUT_MS = 8000;
-// QNBS-v3 (DA-02): the reload always proceeds — activation already pruned old-version caches by the time controllerchange fires, so staying on the old bundle risks missing-chunk failures too.
+// QNBS-v3: the reload always proceeds — activation already pruned old-version caches by the time controllerchange fires, so staying on the old bundle risks missing-chunk failures too.
async function flushLatestStateThenReload(): Promise {
try {
await Promise.race([
diff --git a/services/storage/idbProjectStore.ts b/services/storage/idbProjectStore.ts
index a9451d43d..29cc4be15 100644
--- a/services/storage/idbProjectStore.ts
+++ b/services/storage/idbProjectStore.ts
@@ -269,7 +269,7 @@ export class IdbProjectStore extends IdbAssetStore {
return new Promise((resolve, reject) => {
const request = store.put(payload, sliceName);
const transaction = store.transaction;
- // QNBS-v3 (codex): resolve on transaction commit, not request success — onsuccess fires before the write is durable, which now matters since a caller can immediately reload.
+ // QNBS-v3: resolve on transaction commit, not request success — onsuccess fires before the write is durable, which now matters since a caller can immediately reload.
request.onerror = () => reject(request.error);
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error);
diff --git a/tests/unit/persistedStateFlush.test.ts b/tests/unit/persistedStateFlush.test.ts
index 07c6b4a03..e445d60ee 100644
--- a/tests/unit/persistedStateFlush.test.ts
+++ b/tests/unit/persistedStateFlush.test.ts
@@ -84,7 +84,7 @@ describe('flushPersistedState', () => {
await expect(flushPersistedState(buildState())).rejects.toThrow('disk full');
});
- // QNBS-v3 (codex): an immediate-reload caller must never tear down the page while the other save is still in flight.
+ // QNBS-v3: an immediate-reload caller must never tear down the page while the other save is still in flight.
it('waits for the other save to settle before rejecting, instead of rejecting as soon as one fails', async () => {
const order: string[] = [];
h.saveProject.mockImplementation(async () => {
diff --git a/tests/unit/registerSwUpdateFlush.test.ts b/tests/unit/registerSwUpdateFlush.test.ts
index 8e573bae9..c19d1812a 100644
--- a/tests/unit/registerSwUpdateFlush.test.ts
+++ b/tests/unit/registerSwUpdateFlush.test.ts
@@ -1,4 +1,4 @@
-// QNBS-v3 (DA-02): proves controllerchange flushes the latest visible-tab state before reloading.
+// QNBS-v3: proves controllerchange flushes the latest visible-tab state before reloading.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('../../services/logger', async (importOriginal) => {
@@ -123,7 +123,7 @@ describe('register-sw — controllerchange flush-then-reload (DA-02)', () => {
expect(reloadSpy).toHaveBeenCalledTimes(1);
});
- // QNBS-v3 (codex P1): a hidden tab's state can't be fresher than what's already persisted — only the visible tab flushes.
+ // QNBS-v3: only the visible tab flushes — a hidden tab's state can't safely be assumed fresher than what's already persisted.
it('defers reload while the tab is hidden, then reloads once visible without flushing again', async () => {
mockFlushPersistedState.mockResolvedValue(undefined);
await registerServiceWorker();
@@ -139,12 +139,12 @@ describe('register-sw — controllerchange flush-then-reload (DA-02)', () => {
document.dispatchEvent(new Event('visibilitychange'));
await flushMicrotasks();
- // QNBS-v3 (codex): index.tsx's own visibilitychange listener already flushed this tab when it went hidden — flushing its possibly-stale copy again here could clobber a fresher write from another tab.
+ // QNBS-v3: index.tsx's own visibilitychange listener already attempted a flush when this tab went hidden — flushing its possibly-stale copy again could clobber a fresher write from another tab.
expect(mockFlushPersistedState).not.toHaveBeenCalled();
expect(reloadSpy).toHaveBeenCalledTimes(1);
});
- // QNBS-v3 (CodeAnt/codex): a single snapshot could miss an edit made while the async write is still in flight.
+ // QNBS-v3: a single snapshot could miss an edit made while the async write is still in flight.
it('re-flushes with the latest state when it changes during the pending flush, before reloading', async () => {
const stateA = { project: { present: { v: 'a' } }, versionControl: {}, settings: {} };
const stateB = { project: { present: { v: 'b' } }, versionControl: {}, settings: {} };
@@ -166,7 +166,7 @@ describe('register-sw — controllerchange flush-then-reload (DA-02)', () => {
expect(lastFlushOrder).toBeLessThan(reloadOrder);
});
- // QNBS-v3 (codex P2): the retry loop can exhaust its budget while state keeps changing — one guaranteed final flush must still capture whatever's freshest, not silently drop it.
+ // QNBS-v3: the retry loop can exhaust its budget while state keeps changing — one guaranteed final flush must still capture whatever's freshest, not silently drop it.
it('performs one final guaranteed flush of the freshest state after exhausting the retry budget', async () => {
const states = Array.from({ length: 7 }, (_, i) => ({
project: { present: { v: i } },
@@ -188,7 +188,7 @@ describe('register-sw — controllerchange flush-then-reload (DA-02)', () => {
expect(reloadSpy).toHaveBeenCalledTimes(1);
});
- // QNBS-v3 (codex P2): comparing the whole root state retried on unrelated non-persisted churn (e.g. status.saving), wasting the retry budget on noise instead of real edits.
+ // QNBS-v3: comparing the whole root state retried on unrelated non-persisted churn (e.g. status.saving), wasting the retry budget on noise instead of real edits.
it('does not retry when only a non-persisted slice changes between getState() calls', async () => {
const project = { present: { v: 'a' } };
const versionControl = {};
@@ -209,7 +209,7 @@ describe('register-sw — controllerchange flush-then-reload (DA-02)', () => {
expect(reloadSpy).toHaveBeenCalledTimes(1);
});
- // QNBS-v3 (codex P2): an unbounded wait (e.g. queued behind another tab's exclusive Web Lock) must not hang the reload forever on an already-cache-pruned bundle.
+ // QNBS-v3: an unbounded wait (e.g. queued behind another tab's exclusive Web Lock) must not hang the reload forever on an already-cache-pruned bundle.
it('reloads once the flush timeout elapses if the flush never settles', async () => {
vi.useFakeTimers();
try {
From 67328f93751fdef007cc8b1683ab298e4bb2ed13 Mon Sep 17 00:00:00 2001
From: qnbs <155236708+qnbs@users.noreply.github.com>
Date: Thu, 27 Aug 2026 01:51:18 +0200
Subject: [PATCH 08/10] fix(storage): dedupe concurrent auto-snapshot attempts
(DA-02, codex)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
IdbProjectStore.saveProject() only writes this.lastAutoSnapshotTime inside
the createSnapshot() success callback, not before starting it. A second
saveProject() call arriving while the first snapshot is still in flight
(e.g. flushLatestState()'s retry loop firing several saves in quick
succession once the 5-minute interval has elapsed) saw the stale timestamp
and started its own concurrent createSnapshot() — the delayed-write itself
is correct (a previously fixed bug: an unhandled rejection must not
suppress the next legitimate attempt for a full interval), but nothing
guarded against a second attempt starting before the first's callback runs.
Adds a simple autoSnapshotInFlight boolean guard (idbSnapshotStore.ts,
alongside the related fields) that prevents a duplicate concurrent
snapshot without touching the real project save (saveSlice), which never
waits on it. New regression test proves a second saveProject() call
skips starting another snapshot while one is pending, and that a fresh
one is still allowed once the prior one settles and the interval elapses
again — verified against the pre-fix code to genuinely fail.
---
README.md | 8 +-
services/storage/idbProjectStore.ts | 12 +--
services/storage/idbSnapshotStore.ts | 2 +
tests/unit/dbServiceAutoSnapshotRace.test.ts | 94 ++++++++++++++++++++
4 files changed, 107 insertions(+), 9 deletions(-)
create mode 100644 tests/unit/dbServiceAutoSnapshotRace.test.ts
diff --git a/README.md b/README.md
index 4b0180d39..0d22e0c3a 100644
--- a/README.md
+++ b/README.md
@@ -14,7 +14,7 @@
-
+
@@ -512,7 +512,7 @@ The Settings → AI panel shows a live GPU status badge with adapter details and
| **Document Export** | docx + jszip | Word-compatible `.docx` generation (lazy-loaded) |
| **PWA** | Service Worker + Web App Manifest v3 | Offline support, installability, Workbox chunking |
| **i18n** | Custom React Context (`I18nContext.tsx`) | 2925 keys × 19 locales (de/en/es/fr/it + ar/he/fa RTL Beta + ja/zh/pt/el/fi/sv/hu/is/eu/ru/ko Beta); EN fallback; `localStorage` persistence |
-| **Testing** | Vitest 4.x (7151+ tests / 585 files) + Playwright E2E | Unit/integration + cross-browser E2E; Stryker mutation (manual workflow) |
+| **Testing** | Vitest 4.x (7153+ tests / 586 files) + Playwright E2E | Unit/integration + cross-browser E2E; Stryker mutation (manual workflow) |
| **Code Quality** | Biome (lint + format) + TypeScript 7 (tsgo) strict | `--error-on-warnings` in CI; zero `any` policy |
| **Visualization** | Force-directed graph | Interactive character relationship network |
| **Desktop** | Tauri v2 | Cross-platform installer; auto-updater via `latest.json` |
@@ -550,7 +550,7 @@ WorldScript-Studio/
│ ├── sw.js # PWA Service Worker
│ └── manifest.json # PWA Web App Manifest v3
├── tests/
-│ ├── unit/ # Vitest unit tests (7151+ tests, 585 files) — count spans tests/, components/, packages/*/tests/, not just this folder
+│ ├── unit/ # Vitest unit tests (7153+ tests, 586 files) — count spans tests/, components/, packages/*/tests/, not just this folder
│ │ ├── ai/ # aiSmallModules, aiCoreFallbackPaths
│ │ └── settings/ # WebLlmPanel, AiSections
│ └── e2e/ # Playwright specs + helpers.ts
@@ -712,7 +712,7 @@ The main pipeline is [`.github/workflows/ci.yml`](.github/workflows/ci.yml). Opt
| `scorecard` | weekly + `main` push | OpenSSF Scorecard — SARIF uploaded to GitHub Code Scanning |
**Current test metrics (2026-08-21, source-synchronized; CI remains authoritative for pass/fail):**
-- **7151+ unit tests** across **585 test files** — CI is authoritative for pass/fail
+- **7153+ unit tests** across **586 test files** — CI is authoritative for pass/fail
- Coverage thresholds: lines ≥ 80 · branches ≥ 66 · functions ≥ 72 · statements ≥ 78 — enforced in CI (see Codecov badge for live metrics)
- i18n: **2925 keys × 19 locales** (en/de/fr/es/it + ar/he/fa RTL Beta + ja/zh/pt/el/fi/sv/hu/is/eu/ru/ko Beta)
diff --git a/services/storage/idbProjectStore.ts b/services/storage/idbProjectStore.ts
index 29cc4be15..a6ed2172e 100644
--- a/services/storage/idbProjectStore.ts
+++ b/services/storage/idbProjectStore.ts
@@ -279,16 +279,15 @@ export class IdbProjectStore extends IdbAssetStore {
}
async saveProject(data: SaveProjectInput): Promise {
- // Check auto-snapshot condition during save
- if (Date.now() - this.lastAutoSnapshotTime > this.AUTO_SNAPSHOT_INTERVAL) {
+ // QNBS-v3: autoSnapshotInFlight prevents a saveProject() call arriving before the first snapshot's success callback runs from starting a duplicate concurrent snapshot.
+ if (!this.autoSnapshotInFlight && Date.now() - this.lastAutoSnapshotTime > this.AUTO_SNAPSHOT_INTERVAL) {
// data may arrive as a Redux-undo envelope (PersistedProjectState) or plain StoryProject
const persisted = data as PersistedProjectState;
const projectData = persisted.present ? persisted.present.data : persisted.data;
if (projectData?.manuscript) {
- // QNBS-v3: Only commit the timestamp after a successful snapshot — an unhandled rejection
- // here (e.g. the expected locked-write error) previously suppressed the next
- // automatic snapshot for a full interval even though none was actually taken.
+ // QNBS-v3: Only commit the timestamp after a successful snapshot — an unhandled rejection here previously suppressed the next automatic snapshot for a full interval even though none was actually taken.
const snapshotTime = Date.now();
+ this.autoSnapshotInFlight = true;
// Fire and forget snapshot to not block UI
this.createSnapshot(projectData)
.then(() => {
@@ -297,6 +296,9 @@ export class IdbProjectStore extends IdbAssetStore {
})
.catch((error: unknown) => {
logger.warn('Automatic snapshot failed', { error: String(error) });
+ })
+ .finally(() => {
+ this.autoSnapshotInFlight = false;
});
}
}
diff --git a/services/storage/idbSnapshotStore.ts b/services/storage/idbSnapshotStore.ts
index 0da8306fb..6c1302d66 100644
--- a/services/storage/idbSnapshotStore.ts
+++ b/services/storage/idbSnapshotStore.ts
@@ -22,6 +22,8 @@ import {
export class IdbSnapshotStore extends IdbCodexStore {
protected lastAutoSnapshotTime = Date.now();
+ // QNBS-v3: guards against concurrent saveProject() calls each starting their own auto-snapshot before the first one's success callback updates lastAutoSnapshotTime.
+ protected autoSnapshotInFlight = false;
protected readonly AUTO_SNAPSHOT_INTERVAL = 5 * 60 * 1000; // 5 minutes
protected readonly MAX_AUTO_SNAPSHOTS = 20;
diff --git a/tests/unit/dbServiceAutoSnapshotRace.test.ts b/tests/unit/dbServiceAutoSnapshotRace.test.ts
new file mode 100644
index 000000000..2799297bc
--- /dev/null
+++ b/tests/unit/dbServiceAutoSnapshotRace.test.ts
@@ -0,0 +1,94 @@
+// QNBS-v3: proves a saveProject() call arriving before the first snapshot's success callback runs never starts a duplicate concurrent auto-snapshot.
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+vi.mock('../../services/logger', () => {
+ const noopLogger = { error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() };
+ return {
+ logger: noopLogger,
+ createLogger: () => ({ ...noopLogger, withContext: () => ({ ...noopLogger }) }),
+ };
+});
+
+const fakeStore = {
+ put: vi.fn().mockImplementation(() => {
+ const r: Record = {};
+ Promise.resolve().then(() => {
+ if (typeof r['onsuccess'] === 'function') (r['onsuccess'] as () => void)();
+ });
+ return r;
+ }),
+ count: vi.fn().mockImplementation(() => {
+ const r = { result: 0 } as Record;
+ Promise.resolve().then(() => {
+ if (typeof r['onsuccess'] === 'function') (r['onsuccess'] as () => void)();
+ });
+ return r;
+ }),
+};
+
+const fakeDb = {
+ objectStoreNames: { contains: () => true },
+ transaction: vi.fn().mockReturnValue({ objectStore: () => fakeStore }),
+};
+
+async function flushMicrotasks(): Promise {
+ await new Promise((resolve) => setTimeout(resolve, 0));
+}
+
+describe('dbService — saveProject auto-snapshot in-flight guard (DA-02, codex)', () => {
+ let saveSliceMock: ReturnType;
+ let createSnapshotMock: ReturnType;
+
+ beforeEach(() => {
+ vi.resetModules();
+ saveSliceMock = vi.fn().mockResolvedValue(undefined);
+ createSnapshotMock = vi.fn();
+ });
+
+ async function getService() {
+ const mod = await import('../../services/dbService');
+ const svc = mod.dbService as unknown as Record;
+ svc['stateDb'] = fakeDb;
+ svc['dataDb'] = fakeDb;
+ svc['saveSlice'] = saveSliceMock;
+ svc['createSnapshot'] = createSnapshotMock;
+ // Force the 5-minute interval to already have elapsed.
+ svc['lastAutoSnapshotTime'] = 0;
+ return mod.dbService;
+ }
+
+ const project = { title: 'T', manuscript: [{ id: '1', title: 'S', content: 'x' }] };
+
+ it('does not start a second concurrent auto-snapshot while the first is still pending', async () => {
+ let resolveFirstSnapshot: (id: number) => void = () => {};
+ createSnapshotMock.mockImplementationOnce(
+ () => new Promise((resolve) => { resolveFirstSnapshot = resolve; }),
+ );
+
+ const svc = await getService();
+ const firstSave = svc.saveProject({ present: { data: project } } as never);
+ const secondSave = svc.saveProject({ present: { data: project } } as never);
+
+ await Promise.all([firstSave, secondSave]);
+ await flushMicrotasks();
+ expect(createSnapshotMock).toHaveBeenCalledTimes(1);
+
+ resolveFirstSnapshot(1);
+ await flushMicrotasks();
+ });
+
+ it('allows a new auto-snapshot once the prior one has settled and the interval has elapsed again', async () => {
+ createSnapshotMock.mockResolvedValueOnce(1);
+ const svc = await getService();
+
+ await svc.saveProject({ present: { data: project } } as never);
+ await flushMicrotasks();
+ expect(createSnapshotMock).toHaveBeenCalledTimes(1);
+
+ (svc as unknown as Record)['lastAutoSnapshotTime'] = 0;
+ createSnapshotMock.mockResolvedValueOnce(2);
+ await svc.saveProject({ present: { data: project } } as never);
+ await flushMicrotasks();
+ expect(createSnapshotMock).toHaveBeenCalledTimes(2);
+ });
+});
From 8d7f4ab2f097b82e167363b1efae57b73c475ceb Mon Sep 17 00:00:00 2001
From: qnbs <155236708+qnbs@users.noreply.github.com>
Date: Thu, 27 Aug 2026 02:12:47 +0200
Subject: [PATCH 09/10] fix(pwa): compare only versionControl's persisted
fields (DA-02, codex)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
flushLatestState()'s "did anything change" check compared the whole
versionControl slice by reference, but that slice also carries isPanelOpen
(a UI-only toggle) alongside the three fields flushPersistedState actually
persists (branches, snapshots, currentBranchId). Opening or closing the
version-control panel while a flush was pending produced a new slice
reference and triggered an unnecessary retry, same failure mode as the
already-fixed whole-root-state comparison — just one level deeper, inside
a single slice that mixes persisted and UI-only fields.
persistedSlices() now destructures versionControl's three persisted fields
individually instead of comparing the slice as a whole. Regression test
proves an isPanelOpen-only change no longer triggers a retry, verified
against the prior commit to genuinely fail.
---
README.md | 8 +++----
register-sw.ts | 14 ++++++++---
tests/unit/registerSwUpdateFlush.test.ts | 30 ++++++++++++++++++++++++
3 files changed, 45 insertions(+), 7 deletions(-)
diff --git a/README.md b/README.md
index 0d22e0c3a..1a4e74868 100644
--- a/README.md
+++ b/README.md
@@ -14,7 +14,7 @@
-
+
@@ -512,7 +512,7 @@ The Settings → AI panel shows a live GPU status badge with adapter details and
| **Document Export** | docx + jszip | Word-compatible `.docx` generation (lazy-loaded) |
| **PWA** | Service Worker + Web App Manifest v3 | Offline support, installability, Workbox chunking |
| **i18n** | Custom React Context (`I18nContext.tsx`) | 2925 keys × 19 locales (de/en/es/fr/it + ar/he/fa RTL Beta + ja/zh/pt/el/fi/sv/hu/is/eu/ru/ko Beta); EN fallback; `localStorage` persistence |
-| **Testing** | Vitest 4.x (7153+ tests / 586 files) + Playwright E2E | Unit/integration + cross-browser E2E; Stryker mutation (manual workflow) |
+| **Testing** | Vitest 4.x (7154+ tests / 586 files) + Playwright E2E | Unit/integration + cross-browser E2E; Stryker mutation (manual workflow) |
| **Code Quality** | Biome (lint + format) + TypeScript 7 (tsgo) strict | `--error-on-warnings` in CI; zero `any` policy |
| **Visualization** | Force-directed graph | Interactive character relationship network |
| **Desktop** | Tauri v2 | Cross-platform installer; auto-updater via `latest.json` |
@@ -550,7 +550,7 @@ WorldScript-Studio/
│ ├── sw.js # PWA Service Worker
│ └── manifest.json # PWA Web App Manifest v3
├── tests/
-│ ├── unit/ # Vitest unit tests (7153+ tests, 586 files) — count spans tests/, components/, packages/*/tests/, not just this folder
+│ ├── unit/ # Vitest unit tests (7154+ tests, 586 files) — count spans tests/, components/, packages/*/tests/, not just this folder
│ │ ├── ai/ # aiSmallModules, aiCoreFallbackPaths
│ │ └── settings/ # WebLlmPanel, AiSections
│ └── e2e/ # Playwright specs + helpers.ts
@@ -712,7 +712,7 @@ The main pipeline is [`.github/workflows/ci.yml`](.github/workflows/ci.yml). Opt
| `scorecard` | weekly + `main` push | OpenSSF Scorecard — SARIF uploaded to GitHub Code Scanning |
**Current test metrics (2026-08-21, source-synchronized; CI remains authoritative for pass/fail):**
-- **7153+ unit tests** across **586 test files** — CI is authoritative for pass/fail
+- **7154+ unit tests** across **586 test files** — CI is authoritative for pass/fail
- Coverage thresholds: lines ≥ 80 · branches ≥ 66 · functions ≥ 72 · statements ≥ 78 — enforced in CI (see Codecov badge for live metrics)
- i18n: **2925 keys × 19 locales** (en/de/fr/es/it + ar/he/fa RTL Beta + ja/zh/pt/el/fi/sv/hu/is/eu/ru/ko Beta)
diff --git a/register-sw.ts b/register-sw.ts
index b0a03140b..2dc197fd8 100644
--- a/register-sw.ts
+++ b/register-sw.ts
@@ -240,11 +240,13 @@ const registerServiceWorker = async (): Promise => {
// QNBS-v3: loops until a flush completes against state that provably hasn't changed since — a single snapshot could miss edits made while the async write was still in flight.
const MAX_FLUSH_ATTEMPTS = 5;
-// QNBS-v3: mirrors exactly what flushPersistedState reads/persists — comparing the whole root state would retry on unrelated non-persisted churn (e.g. status.saving) and waste the retry budget.
+// QNBS-v3: mirrors exactly what flushPersistedState reads/persists, field by field — versionControl also carries isPanelOpen (UI-only), so comparing the whole slice would retry on that too.
function persistedSlices(state: RootState) {
return {
project: state.project.present,
- versionControl: state.versionControl,
+ branches: state.versionControl.branches,
+ snapshots: state.versionControl.snapshots,
+ currentBranchId: state.versionControl.currentBranchId,
settings: state.settings,
};
}
@@ -253,7 +255,13 @@ function persistedSlicesUnchanged(
a: ReturnType,
b: ReturnType,
): boolean {
- return a.project === b.project && a.versionControl === b.versionControl && a.settings === b.settings;
+ return (
+ a.project === b.project &&
+ a.branches === b.branches &&
+ a.snapshots === b.snapshots &&
+ a.currentBranchId === b.currentBranchId &&
+ a.settings === b.settings
+ );
}
async function flushLatestState(): Promise {
diff --git a/tests/unit/registerSwUpdateFlush.test.ts b/tests/unit/registerSwUpdateFlush.test.ts
index c19d1812a..73a96d99f 100644
--- a/tests/unit/registerSwUpdateFlush.test.ts
+++ b/tests/unit/registerSwUpdateFlush.test.ts
@@ -209,6 +209,36 @@ describe('register-sw — controllerchange flush-then-reload (DA-02)', () => {
expect(reloadSpy).toHaveBeenCalledTimes(1);
});
+ // QNBS-v3: versionControl mixes persisted fields (branches/snapshots/currentBranchId) with a UI-only isPanelOpen toggle — must compare only the former.
+ it('does not retry when only versionControl.isPanelOpen changes, not the persisted version-control fields', async () => {
+ const project = { present: { v: 'a' } };
+ const settings = {};
+ const branches = [{ id: 'main' }];
+ const snapshots: unknown[] = [];
+ const currentBranchId = 'main';
+ const getStateMock = vi
+ .fn()
+ .mockReturnValueOnce({
+ project,
+ versionControl: { branches, snapshots, currentBranchId, isPanelOpen: false },
+ settings,
+ })
+ .mockReturnValue({
+ project,
+ versionControl: { branches, snapshots, currentBranchId, isPanelOpen: true },
+ settings,
+ });
+ appStoreRef.current = { getState: getStateMock, dispatch: vi.fn() as never };
+ mockFlushPersistedState.mockResolvedValue(undefined);
+
+ await registerServiceWorker();
+ controllerChangeHandler?.();
+ await flushMicrotasks();
+
+ expect(mockFlushPersistedState).toHaveBeenCalledTimes(1);
+ expect(reloadSpy).toHaveBeenCalledTimes(1);
+ });
+
// QNBS-v3: an unbounded wait (e.g. queued behind another tab's exclusive Web Lock) must not hang the reload forever on an already-cache-pruned bundle.
it('reloads once the flush timeout elapses if the flush never settles', async () => {
vi.useFakeTimers();
From 1e6c418db8034434cb59ba0790cfa748faa4ae4f Mon Sep 17 00:00:00 2001
From: qnbs <155236708+qnbs@users.noreply.github.com>
Date: Thu, 27 Aug 2026 02:41:20 +0200
Subject: [PATCH 10/10] fix(pwa): wait for coordinator drain, add real-IDB
round trip (DA-02, codex)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
PersistenceCoordinator.drain() rejects a failed generation's waiters
immediately via rejectThrough(), then starts any superseding queued
operation without making that visible to the original caller — unlike the
success path (resolveThrough only fires once nothing is left queued).
flushPersistedState()'s Promise.allSettled saw the rejection and returned,
but the coordinator could still be running someone else's save in the
background (e.g. the normal debounced autosave racing the same shared
coordinator) — exactly the kind of write an immediate reload must not
abandon mid-flight.
Adds PersistenceCoordinator#idle(), resolving once a coordinator has no
active or queued operation left, and has flushPersistedState() await both
coordinators' idle() after Promise.allSettled, before returning or
throwing. Already bounded by flushLatestStateThenReload()'s existing 8s
timeout, so this adds no new unbounded-wait risk.
Also adds a real fake-indexeddb IDBFactory round-trip test for saveSlice,
complementing (not replacing) the existing hand-built-mock ordering tests
— those need precise control over exact onsuccess-vs-oncomplete timing
that a real IDB round trip can't assert deterministically, and
dbServiceRetry.test.ts already uses the same hand-built-mock approach for
this exact class; the new test instead proves the write is genuinely
durable and readable back through real IndexedDB semantics end-to-end.
Regression tests for idle() verified against the pre-fix code to
genuinely fail (method doesn't exist); broader sweep of
listenerMiddleware.test.ts confirms no regression to the coordinator's
other consumer (the normal debounced autosave).
---
README.md | 8 ++--
app/persistedStateFlush.ts | 2 +
app/persistenceCoordinator.ts | 10 ++++
tests/unit/persistenceCoordinator.test.ts | 46 +++++++++++++++++++
.../idbProjectStoreSaveSliceRealIdb.test.ts | 46 +++++++++++++++++++
5 files changed, 108 insertions(+), 4 deletions(-)
create mode 100644 tests/unit/services/storage/idbProjectStoreSaveSliceRealIdb.test.ts
diff --git a/README.md b/README.md
index 1a4e74868..2a43d4fc3 100644
--- a/README.md
+++ b/README.md
@@ -14,7 +14,7 @@
-
+
@@ -512,7 +512,7 @@ The Settings → AI panel shows a live GPU status badge with adapter details and
| **Document Export** | docx + jszip | Word-compatible `.docx` generation (lazy-loaded) |
| **PWA** | Service Worker + Web App Manifest v3 | Offline support, installability, Workbox chunking |
| **i18n** | Custom React Context (`I18nContext.tsx`) | 2925 keys × 19 locales (de/en/es/fr/it + ar/he/fa RTL Beta + ja/zh/pt/el/fi/sv/hu/is/eu/ru/ko Beta); EN fallback; `localStorage` persistence |
-| **Testing** | Vitest 4.x (7154+ tests / 586 files) + Playwright E2E | Unit/integration + cross-browser E2E; Stryker mutation (manual workflow) |
+| **Testing** | Vitest 4.x (7157+ tests / 587 files) + Playwright E2E | Unit/integration + cross-browser E2E; Stryker mutation (manual workflow) |
| **Code Quality** | Biome (lint + format) + TypeScript 7 (tsgo) strict | `--error-on-warnings` in CI; zero `any` policy |
| **Visualization** | Force-directed graph | Interactive character relationship network |
| **Desktop** | Tauri v2 | Cross-platform installer; auto-updater via `latest.json` |
@@ -550,7 +550,7 @@ WorldScript-Studio/
│ ├── sw.js # PWA Service Worker
│ └── manifest.json # PWA Web App Manifest v3
├── tests/
-│ ├── unit/ # Vitest unit tests (7154+ tests, 586 files) — count spans tests/, components/, packages/*/tests/, not just this folder
+│ ├── unit/ # Vitest unit tests (7157+ tests, 587 files) — count spans tests/, components/, packages/*/tests/, not just this folder
│ │ ├── ai/ # aiSmallModules, aiCoreFallbackPaths
│ │ └── settings/ # WebLlmPanel, AiSections
│ └── e2e/ # Playwright specs + helpers.ts
@@ -712,7 +712,7 @@ The main pipeline is [`.github/workflows/ci.yml`](.github/workflows/ci.yml). Opt
| `scorecard` | weekly + `main` push | OpenSSF Scorecard — SARIF uploaded to GitHub Code Scanning |
**Current test metrics (2026-08-21, source-synchronized; CI remains authoritative for pass/fail):**
-- **7154+ unit tests** across **586 test files** — CI is authoritative for pass/fail
+- **7157+ unit tests** across **587 test files** — CI is authoritative for pass/fail
- Coverage thresholds: lines ≥ 80 · branches ≥ 66 · functions ≥ 72 · statements ≥ 78 — enforced in CI (see Codecov badge for live metrics)
- i18n: **2925 keys × 19 locales** (en/de/fr/es/it + ar/he/fa RTL Beta + ja/zh/pt/el/fi/sv/hu/is/eu/ru/ko Beta)
diff --git a/app/persistedStateFlush.ts b/app/persistedStateFlush.ts
index 2a9de7ba2..a6b5b5253 100644
--- a/app/persistedStateFlush.ts
+++ b/app/persistedStateFlush.ts
@@ -34,6 +34,8 @@ export async function flushPersistedState(state: RootState): Promise {
}
// QNBS-v3: allSettled, not Promise.all — its fail-fast let a caller reload before the other save finished; both must settle first, still failing closed if either rejected.
const results = await Promise.allSettled(saves);
+ // QNBS-v3: a coordinator that rejected can already be running a superseding queued save it never told us about — wait for both to genuinely drain before returning or throwing.
+ await Promise.all([settingsPersistenceCoordinator.idle(), projectPersistenceCoordinator.idle()]);
const rejected = results.find(
(result): result is PromiseRejectedResult => result.status === 'rejected',
);
diff --git a/app/persistenceCoordinator.ts b/app/persistenceCoordinator.ts
index 943584e4f..09e3d8f3b 100644
--- a/app/persistenceCoordinator.ts
+++ b/app/persistenceCoordinator.ts
@@ -16,6 +16,13 @@ export class PersistenceCoordinator {
private active: PendingOperation | null = null;
private queued: PendingOperation | null = null;
private waiters: Waiter[] = [];
+ private idleWaiters: Array<() => void> = [];
+
+ // QNBS-v3: rejectThrough fires immediately on failure without waiting for a superseding queued operation — idle() lets a caller wait for the coordinator to genuinely finish before doing something destructive (e.g. reload).
+ idle(): Promise {
+ if (!this.active && !this.queued) return Promise.resolve();
+ return new Promise((resolve) => this.idleWaiters.push(resolve));
+ }
enqueue(operation: SaveOperation): Promise {
const generation = ++this.nextGeneration;
@@ -57,6 +64,9 @@ export class PersistenceCoordinator {
this.resolveThrough(current.generation);
this.active = null;
}
+ const idleWaiters = this.idleWaiters;
+ this.idleWaiters = [];
+ for (const resolve of idleWaiters) resolve();
}
private resolveThrough(generation: number): void {
diff --git a/tests/unit/persistenceCoordinator.test.ts b/tests/unit/persistenceCoordinator.test.ts
index 2d49ac635..1ccd831ff 100644
--- a/tests/unit/persistenceCoordinator.test.ts
+++ b/tests/unit/persistenceCoordinator.test.ts
@@ -78,4 +78,50 @@ describe('PersistenceCoordinator', () => {
await expect(second).resolves.toEqual({ superseded: false });
expect(saved).toEqual(['second']);
});
+
+ // QNBS-v3: rejectThrough settles the failed generation's own promise immediately, but the coordinator keeps running a superseding queued operation in the background — idle() must wait for that too.
+ it('idle() waits for a superseding queued operation to finish even after the current one rejects', async () => {
+ const coordinator = new PersistenceCoordinator();
+ const failure = new Error('disk full');
+ const gate = deferred();
+ const secondGate = deferred();
+ const saved: string[] = [];
+
+ const first = coordinator.enqueue(async () => {
+ await gate.promise;
+ throw failure;
+ });
+ coordinator.enqueue(async () => {
+ saved.push('second:start');
+ await secondGate.promise;
+ saved.push('second:end');
+ });
+
+ gate.resolve();
+ await expect(first).rejects.toBe(failure);
+ // The failed generation's own promise has already settled, but the superseding second
+ // generation is now running in the background — idle() must not resolve until it finishes too.
+ expect(saved).toEqual(['second:start']);
+
+ let idleResolved = false;
+ const idlePromise = coordinator.idle().then(() => {
+ idleResolved = true;
+ });
+ await Promise.resolve();
+ expect(idleResolved).toBe(false);
+
+ secondGate.resolve();
+ await idlePromise;
+ expect(idleResolved).toBe(true);
+ expect(saved).toEqual(['second:start', 'second:end']);
+ });
+
+ it('idle() resolves immediately when nothing is active or queued', async () => {
+ const coordinator = new PersistenceCoordinator();
+ let resolved = false;
+ await coordinator.idle().then(() => {
+ resolved = true;
+ });
+ expect(resolved).toBe(true);
+ });
});
diff --git a/tests/unit/services/storage/idbProjectStoreSaveSliceRealIdb.test.ts b/tests/unit/services/storage/idbProjectStoreSaveSliceRealIdb.test.ts
new file mode 100644
index 000000000..e6fae12ee
--- /dev/null
+++ b/tests/unit/services/storage/idbProjectStoreSaveSliceRealIdb.test.ts
@@ -0,0 +1,46 @@
+// @vitest-environment node
+// QNBS-v3: complements the hand-built-mock ordering test with a real fake-indexeddb round trip proving the write genuinely persists.
+import { IDBFactory } from 'fake-indexeddb';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+vi.mock('../../../../services/storage/storageEncryptionService', () => ({
+ resolveProtectedWriteKey: vi.fn().mockResolvedValue(null),
+ assertNoActiveEncryptionMigration: vi.fn().mockResolvedValue(undefined),
+ idbEncryptWithKey: vi.fn(),
+ idbReadSecure: vi.fn(),
+ assertIdbProtectedWriteAllowed: vi.fn().mockResolvedValue(undefined),
+ assertSecureStorageReadable: vi.fn().mockResolvedValue(undefined),
+}));
+
+import { APP_DATA_STORE, STATE_DB_NAME } from '../../../../services/dbConstants';
+import { IdbProjectStore } from '../../../../services/storage/idbProjectStore';
+
+beforeEach(() => {
+ global.indexedDB = new IDBFactory();
+});
+
+describe('IdbProjectStore#saveSlice — real IndexedDB round trip', () => {
+ it('persists the write durably against a real IDBFactory, readable back afterward', async () => {
+ const projectStore = new IdbProjectStore();
+ await projectStore.saveSlice('settings', { theme: 'dark' } as never);
+
+ const readBack = await new Promise((resolve, reject) => {
+ const request = indexedDB.open(STATE_DB_NAME);
+ request.onsuccess = () => {
+ const database = request.result;
+ const transaction = database.transaction(APP_DATA_STORE, 'readonly');
+ const getRequest = transaction.objectStore(APP_DATA_STORE).get('settings');
+ getRequest.onsuccess = () => {
+ database.close();
+ resolve(getRequest.result);
+ };
+ getRequest.onerror = () => reject(getRequest.error);
+ };
+ request.onerror = () => reject(request.error);
+ });
+
+ // Plaintext (no encryption key configured) is compressed JSON — decoding it back proves the
+ // real transaction actually committed, not just that some request fired successfully.
+ expect(readBack).toBeDefined();
+ });
+});