Skip to content
This repository was archived by the owner on Aug 6, 2026. It is now read-only.

refactor(web): route browser persistence through one validated seam - #3651

Merged
trunk-io[bot] merged 2 commits into
mainfrom
posthog-code/web-storage-seam
Jul 22, 2026
Merged

refactor(web): route browser persistence through one validated seam#3651
trunk-io[bot] merged 2 commits into
mainfrom
posthog-code/web-storage-seam

Conversation

@gantoine

Copy link
Copy Markdown
Member

Why

The web host uses localStorage as its single persistence layer, but the getItem → JSON.parse → guard → JSON.stringify → setItem boilerplate was hand-rolled across five per-device stores. This consolidates all browser-storage access into one seam and adds Zod-validated versioning so future shape changes don't require hand-written migrations.

What

One seam — apps/web/src/web-local-store.ts. All localStorage access routes through it (grep confirms zero direct window.localStorage elsewhere):

  • createRecordStore(key, entrySchema) — the Record<string, Entry> registries (workspaces, archive, task-metadata). Validates per entry on load and drops only stale rows.
  • readValidated(key, schema, fallback) — the browser-tabs snapshot (single object; drops to empty + re-seeds if incompatible).
  • Raw readJson / writeJson / removeKey — auth session/preferences (record types owned by core, no local schema).
  • rawLocalStorage — the zustand persist backend.

Versioning via Zod, not migrations. Every persisted store is a discardable per-device cache validated on read; invalid data is shed and rebuilt from the server. Evolving a shape is now a schema edit. Reuses the canonical workspaceSchema / tabsSnapshotSchema from @posthog/shared (no drift from the desktop service), and derives the two web-local types (WebArchivedTask, TaskMetadata) from their schemas via z.infer.

IndexedDB left untouched. It holds only the non-extractable AES-GCM auth cipher key (web-auth-adapters.ts), which localStorage physically cannot store without exposing its raw bytes — the property that keeps a stolen token dump undecryptable offline. This is documented as a deliberate exemption, not a second app-state store.

Docs. Adds a "Web Host" section to AGENTS.md (build story + storage policy) and fixes a stale web-container.ts comment that referenced a nonexistent test.

Behavior / risk

No behavioral change for valid data. On first load after this lands, any persisted cache that fails validation is dropped and rebuilt (these are per-device sidebar/tab caches, not durable data). No async ripple into host-agnostic core/ui — reads stay synchronous.

Testing

  • pnpm --filter @posthog/web typecheck
  • biome check ✅ (also enforced by pre-commit)

🤖 Generated with Claude Code

localStorage is the web host's single persistence layer, but the
getItem -> parse -> guard -> stringify -> setItem boilerplate was hand-rolled
across five per-device stores. Consolidate all access into web-local-store.ts:

- createRecordStore(key, entrySchema) for the Record<string, Entry> registries
  (workspaces, archive, task-metadata); readValidated(key, schema, fallback) for
  the browser-tabs snapshot; raw readJson/writeJson/removeKey for the auth
  session/preferences; rawLocalStorage for the zustand backend.
- Every persisted store is now validated against a Zod schema on read and drops
  what no longer fits (per-entry for registries), so evolving a shape is a schema
  edit rather than a hand-written localStorage migration. Reuse the canonical
  workspaceSchema / tabsSnapshotSchema from @posthog/shared; derive the two
  web-local types (WebArchivedTask, TaskMetadata) from their schemas via z.infer.

IndexedDB is left untouched: it holds only the non-extractable auth cipher key
(web-auth-adapters.ts), which localStorage cannot store without exposing its raw
bytes. Documented the web-host build story and this storage policy in AGENTS.md,
and fixed a stale web-container.ts comment referencing a nonexistent test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@trunk-io

trunk-io Bot commented Jul 21, 2026

Copy link
Copy Markdown

😎 Merged successfully - details.

@github-actions

github-actions Bot commented Jul 21, 2026

Copy link
Copy Markdown

React Doctor found no issues in the changed files. 🎉

Reviewed by React Doctor for commit c9428ab.

Comment thread apps/web/src/web-auth-adapters.ts Outdated
@veria-ai

veria-ai Bot commented Jul 21, 2026

Copy link
Copy Markdown

PR overview

All previously flagged issues have been addressed. No open security concerns remain on this pull request.

Security review

No open security issues remain on this pull request.

Fixed/addressed: 1 · PR risk: 0/10

@greptile-apps

greptile-apps Bot commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Security Review

A failed localStorage deletion is now hidden from the logout path. If storage access is blocked temporarily, the persisted session can survive logout and be loaded later.

Prompt To Fix All With AI
Fix the following 3 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 3
apps/web/src/web-local-store.ts:43-49
**Failed Logout Leaves Session Stored**

`WebAuthSessionStore.clearCurrent()` now returns normally when `localStorage.removeItem` throws. If storage access is temporarily blocked, the old session remains under `SESSION_KEY` and can be loaded after access recovers or the page reloads, even though logout appeared to complete.

### Issue 2 of 3
apps/web/src/web-local-store.ts:120-126
**Persistence Failures Become Successes**

When quota is exhausted or storage is unavailable, this backend hides the `setItem` exception from the renderer persistence layer, which already awaits and logs failed writes. Zustand therefore treats drafts, settings, and layout updates as persisted even though they disappear after reload.

```suggestion
  setItem: (name: string, value: string): void => {
    window.localStorage.setItem(name, value);
  },
```

### Issue 3 of 3
apps/web/src/web-local-store.ts:33-41
**Auth Writes Silently Disappear**

`WebAuthSessionStore.saveCurrent()` and preference updates now return normally when `setItem` fails, whereas their previous direct writes surfaced the error. Under quota pressure, login or preference changes can appear successful but vanish on reload because callers receive no failure signal.

Reviews (1): Last reviewed commit: "refactor(web): route browser persistence..." | Re-trigger Greptile

Comment thread apps/web/src/web-local-store.ts
Comment thread apps/web/src/web-local-store.ts
Comment thread apps/web/src/web-local-store.ts
…ackend

Review flagged that the seam's best-effort swallow was applied where the
original direct writes let errors propagate, turning failures into silent
successes:

- Auth session/preferences writes and the logout clear now use strict variants
  (writeJsonStrict / removeKeyStrict) that propagate. A swallowed clearCurrent()
  would report logout complete while the session stayed in localStorage,
  recoverable on reload.
- rawLocalStorage (zustand persist backend) lets setItem/removeItem throw again;
  the renderer persistence layer already awaits and logs failed writes, so
  swallowing reported dropped drafts/settings/layout writes as success.

Best-effort writeJson/removeKey stay for the rebuildable per-device caches, where
a dropped write only costs cross-reload persistence.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@gantoine

Copy link
Copy Markdown
Member Author

Addressed the review feedback in c9428ab. All three comments were the same root issue — the seam's best-effort swallow was applied where the original direct writes let errors propagate, turning failures into silent successes. Fixed by splitting the seam into two tiers:

  • Auth session/preferences + logout (veria-ai, greptile P1 security / P2): saveCurrent, the preferences write, and clearCurrent now use strict variants (writeJsonStrict / removeKeyStrict) that propagate. A swallowed clearCurrent() would have reported logout complete while the session stayed in localStorage, recoverable on reload — that's the regression, now gone.
  • Zustand backend (greptile P1): rawLocalStorage.setItem/removeItem let storage errors throw again (matching the original web-storage.ts), so the renderer persistence layer's await/log path sees real failures instead of dropped drafts/settings/layout writes looking successful.

The best-effort writeJson/removeKey stay only for the rebuildable per-device caches (workspaces, archive, task-metadata, browser-tabs), where a dropped write costs cross-reload persistence of a cache that re-derives — not correctness. The two tiers are documented at the top of web-local-store.ts.

@gantoine
gantoine requested a review from a team July 22, 2026 12:41
@gantoine

Copy link
Copy Markdown
Member Author

/trunk merge

@trunk-io
trunk-io Bot merged commit 0305ec2 into main Jul 22, 2026
31 checks passed
@trunk-io
trunk-io Bot deleted the posthog-code/web-storage-seam branch July 22, 2026 13:16
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants