From 687e7cc012c43b62a85c68d71507db06c596d4e8 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Tue, 3 Nov 2026 05:54:39 +0100
Subject: [PATCH] feat: move offline mode onto the encrypted local store
The IndexedDB cache, its crypto, the responder and the page-side sync
go. lib/offline keeps its structure, policy, wipes, sign-out gates and
notices; answers, the outbox and sync now live in lib/store, in a worker
behind lib/store/client/api.ts.
What users get besides that:
- every folder is kept, not just the Inbox and opened ones
- delete for good and empty trash work offline
- a change the server refuses, or that meets a message changed on
another device, is shown instead of dropped
- a send whose response got lost is looked up by a Message-ID fixed when
it was queued, then finished, submitted or sent again
- with the store on, a start paints from the local copy, then connects
An account the app drops by itself no longer wipes its data: only a
sign-out or an explicit removal does. The outbox of the old database
moves over once, in lib/offline/migrate-legacy.ts, and the old database
goes only after the store committed. `isNetworkFailure` now counts
timeouts and proxy 5xx. Sends and outbox requests are never answered
from the device.
Tests moved with the code: cache.test.ts and responder.test.ts are
replaced by lib/store/__tests__/, and review-fixes.test.ts, outbox.test.ts
and client-fallback.test.ts now run the real engine on node:sqlite.
Changed assertions: an uncertain send is looked up rather than left alone
forever, destroy and empty trash are queued rather than refused, and #9
(opened folders) became "a folder the server deleted goes", since all
folders are kept.
---
.../__tests__/offline-settings.test.tsx | 3 +
components/offline/offline-banner.tsx | 15 +-
components/offline/offline-host.tsx | 6 +
components/offline/outbox-list.tsx | 41 +-
integration/tests/22-ent-offline.spec.ts | 33 +-
lib/jmap/client-interface.ts | 4 +-
lib/jmap/client.ts | 127 +++--
lib/jmap/offline-hooks.ts | 24 +-
lib/offline/__tests__/cache.test.ts | 121 -----
lib/offline/__tests__/client-fallback.test.ts | 320 ++++++-----
lib/offline/__tests__/migrate-legacy.test.ts | 176 ++++++
lib/offline/__tests__/outbox.test.ts | 308 +++++++++--
lib/offline/__tests__/responder.test.ts | 122 -----
lib/offline/__tests__/review-fixes.test.ts | 122 ++---
lib/offline/cache.ts | 310 -----------
lib/offline/credentials-rejected.ts | 25 +
lib/offline/crypto.ts | 59 --
lib/offline/db.ts | 219 --------
lib/offline/flush.ts | 168 ++++--
lib/offline/migrate-legacy.ts | 196 +++++++
lib/offline/offline-mode.ts | 510 ++++++++----------
lib/offline/queue.ts | 281 +++-------
lib/offline/responder.ts | 394 --------------
lib/offline/state.ts | 7 +-
lib/offline/sync.ts | 137 -----
lib/pwa-notify/outbox-sync.ts | 24 +
lib/sign-out-cleanup.ts | 7 +-
.../__tests__/helpers/in-process-store.ts | 104 ++++
lib/store/__tests__/helpers/node-adapter.ts | 9 +-
lib/store/__tests__/outbox.test.ts | 2 +-
lib/store/__tests__/page-side.test.ts | 218 ++++++++
lib/store/adapters/wasm.ts | 2 +-
lib/store/client/api.ts | 203 +++++++
lib/store/client/purge.ts | 52 ++
lib/store/client/store-client.ts | 58 +-
lib/store/outbox/calls.ts | 8 +-
lib/store/outbox/items.ts | 1 -
lib/store/outbox/schema.ts | 2 -
lib/store/worker/host.ts | 23 +-
lib/store/worker/protocol.ts | 16 +-
lib/store/worker/worker.ts | 15 +-
locales/ar/common.json | 12 +
locales/ca/common.json | 12 +
locales/cs/common.json | 12 +
locales/da/common.json | 12 +
locales/de/common.json | 12 +
locales/en/common.json | 12 +
locales/es/common.json | 12 +
locales/et/common.json | 12 +
locales/fa/common.json | 12 +
locales/fr/common.json | 12 +
locales/he/common.json | 12 +
locales/hu/common.json | 12 +
locales/it/common.json | 12 +
locales/ja/common.json | 12 +
locales/ko/common.json | 12 +
locales/lv/common.json | 12 +
locales/mn/common.json | 12 +
locales/nb/common.json | 12 +
locales/nl/common.json | 12 +
locales/pl/common.json | 12 +
locales/pt/common.json | 12 +
locales/ro/common.json | 12 +
locales/ru/common.json | 12 +
locales/sk/common.json | 12 +
locales/th/common.json | 12 +
locales/tr/common.json | 12 +
locales/uk/common.json | 12 +
locales/vi/common.json | 12 +
locales/zh-TW/common.json | 12 +
locales/zh/common.json | 12 +
.../__tests__/auth-store-local-copy.test.ts | 151 ++++++
stores/account-store.ts | 19 +-
stores/auth-store.ts | 93 +++-
74 files changed, 2789 insertions(+), 2306 deletions(-)
delete mode 100644 lib/offline/__tests__/cache.test.ts
create mode 100644 lib/offline/__tests__/migrate-legacy.test.ts
delete mode 100644 lib/offline/__tests__/responder.test.ts
delete mode 100644 lib/offline/cache.ts
create mode 100644 lib/offline/credentials-rejected.ts
delete mode 100644 lib/offline/crypto.ts
delete mode 100644 lib/offline/db.ts
create mode 100644 lib/offline/migrate-legacy.ts
delete mode 100644 lib/offline/responder.ts
delete mode 100644 lib/offline/sync.ts
create mode 100644 lib/pwa-notify/outbox-sync.ts
create mode 100644 lib/store/__tests__/helpers/in-process-store.ts
create mode 100644 lib/store/__tests__/page-side.test.ts
create mode 100644 lib/store/client/api.ts
create mode 100644 lib/store/client/purge.ts
create mode 100644 stores/__tests__/auth-store-local-copy.test.ts
diff --git a/components/offline/__tests__/offline-settings.test.tsx b/components/offline/__tests__/offline-settings.test.tsx
index 7cace4d96..73e13bbbf 100644
--- a/components/offline/__tests__/offline-settings.test.tsx
+++ b/components/offline/__tests__/offline-settings.test.tsx
@@ -14,6 +14,7 @@ import { useOfflineStore } from '@/lib/offline/state';
import { useAuthStore } from '@/stores/auth-store';
import { usePolicyStore } from '@/stores/policy-store';
import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy';
+import { setStoreBackend, type StoreBackend } from '@/lib/store/client/api';
function setNoOffline(noOffline: boolean) {
usePolicyStore.setState({
@@ -24,6 +25,8 @@ function setNoOffline(noOffline: boolean) {
}
beforeEach(() => {
+ // jsdom has no OPFS: the store's page side says it's there, as a browser would.
+ setStoreBackend({ supported: () => true } as unknown as StoreBackend);
offline.enableOffline.mockClear();
offline.disableOffline.mockClear();
useAuthStore.setState({ activeAccountId: 'acc' });
diff --git a/components/offline/offline-banner.tsx b/components/offline/offline-banner.tsx
index c04def228..dc179e362 100644
--- a/components/offline/offline-banner.tsx
+++ b/components/offline/offline-banner.tsx
@@ -24,9 +24,11 @@ export function OfflineBanner() {
const browserOffline = useOfflineStore((s) => s.browserOffline || s.startedOffline);
const serverDown = useOfflineStore((s) => !!server && s.downServers.includes(serverKey(server)));
const waiting = useOfflineStore((s) => s.outbox.length);
+ const conflicts = useOfflineStore((s) => s.conflicts.length);
+ const tConflicts = useTranslations('offlineConflicts');
const [open, setOpen] = useState(false);
const offline = browserOffline || serverDown;
- if (IS_LITE || !active || (!offline && waiting === 0 && !open)) return null;
+ if (IS_LITE || !active || (!offline && waiting === 0 && conflicts === 0 && !open)) return null;
return (
<>
)}
+ {conflicts > 0 && (
+
+ )}