Skip to content

Scope graph-view and schema-view layouts to the browser tab#1894

Open
kmcginnes wants to merge 5 commits into
mainfrom
session-storage-view-layouts
Open

Scope graph-view and schema-view layouts to the browser tab#1894
kmcginnes wants to merge 5 commits into
mainfrom
session-storage-view-layouts

Conversation

@kmcginnes

@kmcginnes kmcginnes commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator

Description

The graph-view and schema-view layout state (active sidebar tab, sidebar width, view toggles, details-auto-open preference) was persisted as a single localForage value shared across all browser tabs. Opening the app in a second tab clobbered the first tab's layout — last-writer-wins over the whole value.

This scopes both layouts to the browser tab using the same per-tab mechanism the active connection already uses: the live value lives in sessionStorage (survives this tab's reload, never leaks to other tabs), with a shared localForage breadcrumb that seeds a freshly-opened tab from the last-used layout on cold start.

Core change: the per-tab + breadcrumb "trick" that was inlined in activeConnectionStorage.ts is extracted into a generic createSessionScopedAtom<T>. createActiveConfigurationAtom is refactored onto it (so the subtle seed-and-claim logic lives in one place), and both layout atoms now route through it with zod-validated codecs.

Notable details:

  • zod-validated per-tab reads. sessionStorage holds strings, so each layout has a codec. deserialize returns null for an absent value but throws on a corrupt/wrong-shape one; the seam (createSessionScopedAtom) catches, logs, and falls back to the breadcrumb then the default — so a stale or hand-edited value can never crash the top-level-await startup. Detecting corruption is kept separate from deciding what to do about it.
  • Set serialization. Graph-view's activeToggles is a runtime Set; its codec serializes it as an array for JSON and rebuilds the Set on read. Schema-view is plain JSON.
  • No migration. The breadcrumb keeps each concept's existing localForage key and shape, so existing stored layouts seed a fresh tab unchanged.

The new per-tab scope is recorded in an ADR alongside the two existing shared scopes (reconciled maps, blind-write scalars).

Validation

  • pnpm checks passes (types, lint, format).
  • pnpm test passes (full suite, 2086 tests), including new coverage in sessionScopedStorage.test.ts (seed order, corrupt-value + SecurityError recovery, the Set-bearing cold-start claim, and cross-tab isolation/seeding through the real graph-view and schema-view codecs).
  • Reviewer focus: confirm the seeding order (this tab's sessionStorage → shared breadcrumb → default), that a corrupt per-tab value recovers instead of crashing, and that graph-view's activeToggles Set round-trips across a write-then-cold-start sequence.

How to read

  1. packages/graph-explorer/src/core/StateProvider/sessionScopedStorage.ts — start here; the extracted per-tab primitive (seed → claim → write-through) plus parseSessionJson and the corrupt-value recovery seam
  2. packages/graph-explorer/src/core/StateProvider/activeConnectionStorage.ts — the previously-inlined trick, now a thin wrapper over the primitive (behavior-preserving refactor)
  3. packages/graph-explorer/src/core/StateProvider/graphViewLayoutDefaults.ts — the graph-view codec; the activeToggles Set ↔ array serialization
  4. packages/graph-explorer/src/core/StateProvider/schemaViewLayoutDefaults.ts — the schema-view codec (plain JSON)
  5. packages/graph-explorer/src/core/StateProvider/storageAtoms.ts — wires both layout atoms onto the primitive
  6. docs/adr/20260630-per-tab-session-scoped-storage-primitive.md — the storage-scope decision and the three named scopes

Related Issues

Check List

  • I confirm that my contribution is made under the terms of the Apache 2.0 license.
  • I have verified pnpm checks passes with no errors.
  • I have verified pnpm test passes with no failures.
  • I have covered new added functionality with unit tests if necessary.
  • I have updated documentation if necessary.

@kmcginnes kmcginnes force-pushed the session-storage-view-layouts branch from db6f162 to b182877 Compare July 7, 2026 22:46
@kmcginnes kmcginnes marked this pull request as ready for review July 7, 2026 23:19
@kmcginnes kmcginnes force-pushed the session-storage-view-layouts branch from b182877 to 8347655 Compare July 7, 2026 23:32
kmcginnes added 4 commits July 9, 2026 13:27
The two view-layout atoms shared one localForage value across tabs, so
opening the app in a second tab clobbered the first tab's sidebar and
toggle state (last-writer-wins).

Extract the per-tab sessionStorage + shared localForage breadcrumb trick
that createActiveConfigurationAtom() used into a generic
createSessionScopedAtom<T>, and refactor active-config onto it so the
subtle seed logic lives in one named place.

Point both layout atoms at it with zod-validated codecs co-located with
each model: schema-view is plain JSON; graph-view serializes its
activeToggles Set as an array. parseSessionJson() rejects a stale or
hand-edited per-tab value with the wrong shape, falling through to the
breadcrumb. The breadcrumb key/shape are unchanged, so existing stored
layouts are reused with no migration.
Add an ADR recording the per-tab session-scoped storage primitive: the
three named cross-tab scopes (per-tab / shared-reconciled /
shared-blind-write), why layout is per-tab, and how it relates to the
active-connection and per-key-diff-merge ADRs and spike #1876.

Note in the per-key-diff-merge ADR that layout has since moved from a
shared scalar to per-tab session scope, so it is no longer a standing
example of a shared blind-write scalar.

Add a test exercising createSessionScopedAtom with the real
graphViewLayoutCodec over the cold-start claim path: a breadcrumb holding
a native activeToggles Set is seeded as a Set and claimed into
sessionStorage as its array-serialized form. Previously the generic
helper and the Set-bearing codec were only tested in isolation.
parseSessionJson swallowed both JSON and schema-validation errors into an
indistinguishable null, hiding corruption and conflating it with a
legitimate absent value. Separate detecting corruption from deciding what
to do about it: deserialize now returns null only for an absent value and
throws (SyntaxError or ZodError) on a present-but-invalid one.

createSessionScopedAtom owns seeding policy, so it is the seam that
catches: a corrupt per-tab value (or a sessionStorage read that throws a
SecurityError when DOM storage is blocked) is logged and treated as a
miss, falling through to the breadcrumb then the default rather than
crashing app startup.

Update the per-tab-storage ADR to describe the throw-and-recover flow.
- Move the graphViewLayout activeToggles Set rebuild from an object-level
  transform onto the activeToggles field, so the transform sits on the
  field it converts and the object schema infers the runtime shape.
- Correct the SessionValueCodec.deserialize contract doc: it returns null
  only for an absent value and throws on a corrupt one, which the seam
  catches. Do not swallow errors in the codec.
- Drop the unused session() accessor from the test tab harness.
- Add cross-tab coverage for both layout codecs through the real codecs
  (graph view proves the activeToggles Set survives the array round-trip
  across a write-then-cold-start sequence; schema view covers isolation
  and cold-start seeding), matching the active-connection assurances.
@kmcginnes kmcginnes force-pushed the session-storage-view-layouts branch from 8347655 to b2f6ab7 Compare July 9, 2026 18:31
The global test setup already gives every test a fresh IndexedDB backend
(dropInstance + new IDBFactory per test), so the per-describe
beforeEach(localForage.clear()) blocks added no isolation.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant