Skip to content

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

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

kmcginnes wants to merge 14 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), 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 2 times, most recently from 8347655 to b2f6ab7 Compare July 9, 2026 18:31
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.
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.
createSessionScopedAtom runs a ReadTransform on the shared breadcrumb
before claiming it, which is the only path a shape retired by a newer app
version can arrive by. Cover that it normalizes and claims the normalized
value, that it never touches this tab's own zod-validated session value or
the default, and that a stored nodes-styling sidebar item lands on the
combined styles panel.
@kmcginnes
kmcginnes force-pushed the session-storage-view-layouts branch from c3c888e to 3c1fb70 Compare September 21, 2026 15:37
resolveSessionStorage only guards the initial access, so a later setItem
could still throw QuotaExceededError when storage fills, or SecurityError
where DOM storage is blocked. That throw escaped the Jotai setter and would
take down the React subtree that set the atom.

writeSession now owns serialization and swallows a write failure with a
warning. The atom has already updated in memory and the shared breadcrumb
still persists through the queue, so the only cost is this tab's
warm-reload value.
The per-tab storage ADR named the three cross-tab scopes but the glossary
never picked them up, leaving Graph View Layout, Schema View Layout, and
Storage Scope itself undefined. Also corrects the Graph Database entry,
which still described layout as IndexedDB-persisted app state.
The assertion computed its expected value with the same serialize call it
was testing, so a serialize that dropped a field would have changed both
sides together and still passed. Write the on-disk JSON out instead.
Moving both layouts onto createSessionScopedAtom left several docs
describing the shape it replaced. The backward-compatibility rule in
testing.md only triggered on atomWithLocalForage, so it no longer covered
either layout type it was written for, and its cross-tab example is typed
to that factory and cannot open a per-tab atom. product.md still listed
layout as IndexedDB-persisted. Three ADRs still said the per-tab mechanism
and the read transform live where this branch moved them from.

Also trims the Storage Scope glossary entry to the definition, since it had
duplicated the ADR's per-scope atom inventory and the two copies already
disagreed, and corrects that inventory to the glossary's own term for
Styles. Drops the stale "one set of tests" claim from the new ADR: the
active-connection tests stay as behavior-preservation evidence.
writeSession guarded codec.serialize alongside the storage call, so a codec
defect was logged as a write failure and the per-tab layer silently stopped
working for the rest of the tab's life. A serialize throw is a defect, not a
storage condition. Only setItem and removeItem are guarded now.
The type and the parser were two hand-written declarations of one shape, and
nothing tied them together: adding an optional field to the type compiled
clean, then zod stripped it on read. The field survived in the breadcrumb
through structured clone but vanished on every warm reload of the tab that
set it.

The schema is now the only declaration, matching what schemaViewLayoutSchema
already did for the schema view.
"Layout" meant two things: the Cytoscape algorithm that positions vertices,
and the per-tab sidebar and toggle state this branch made per-tab. The bare
word now belongs to the algorithm, and the per-tab state is a View Layout,
which matches the graphViewLayout and schemaViewLayout identifiers.

Also documents the per-tab scope for users in both feature docs, mirroring
the wording connections.md already uses for the Active Connection, and dates
the storage-scope ADR to when it landed so it sorts after the ADR it cites.
The shared comment claimed both layout suites existed because their codecs
carry risk. That holds for graph view, whose activeToggles Set has to survive
the array round trip, and not for schema view, whose codec is structurally
the counter codec. Name what each one actually guards so neither reads as
redundant with the other.
Comment on lines +92 to +95
const breadcrumb = await localForage.getItem<T>(key);
if (breadcrumb !== null) {
seedValue = transform ? transform(breadcrumb) : breadcrumb;
writeSession(sessionStorage, key, codec, seedValue);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The per-tab leg is hardened and the breadcrumb leg is trusted, so the self-heal guarantee holds on one side only.

readSessionSeed wraps codec.deserialize in try/catch, logs, and returns null so a bad per-tab value falls through — that is the documented behavior and the corrupt-value and SecurityError tests pin it. This branch has no equivalent. localForage.getItem<T>(key) asserts the breadcrumb as T without parsing it, then transform and writeSession both run outside any try.

That matters because of the interaction with two other lines. writeSession deliberately places const serialized = codec.serialize(value) outside its own try at line 160 (correct, for a genuine defect), and graphViewLayoutCodec.serialize spreads [...layout.activeToggles] at graphViewLayoutDefaults.ts:100. transformGraphViewLayout only touches activeSidebarItem, so a breadcrumb whose activeToggles is absent or non-iterable reaches that spread untouched, throws TypeError, and rejects this createSessionScopedAtom call — which storageAtoms.ts awaits inside a top-level Promise.all with no surrounding try. That is a blank app rather than a degraded one.

The ADR states the self-heal narrowly and correctly, scoping it to "a corrupt or stale per-tab value." The PR description generalizes it to "a stale or hand-edited value can never crash the top-level-await startup," and that word is what I am questioning.

Likelihood is bounded, which is why I read this as important rather than blocking: activeToggles has been a Set in the persisted shape since before the user-layout to graph-view-layout rename, and migrateUserLayout copies the legacy value verbatim, so a well-formed legacy breadcrumb round-trips fine. The reachable triggers are a hand-edited IndexedDB value — the same input class the per-tab leg was hardened against — and a restored config backup or rolled-back app version writing a different native shape, which is precisely the path "no migration" exists to protect.

Two ways out, and I don't have a strong preference:

  1. Parse the breadcrumb through the same schema before claiming it, so both legs share one corruption seam and a throw falls through to defaultValue. Costs one parse per cold start plus a corrupt-breadcrumb test, and makes the general claim true.
  2. Narrow the PR description to match what the ADR already says. Costs nothing, but leaves the hand-edit and rollback vectors able to take down boot.

Either is defensible for a non-critical view preference. Worth noting there is currently no test for a corrupt breadcrumb, only for a corrupt per-tab value.

Comment thread CONTEXT.md
- **Styles** are scoped per **Vertex Type** (**Vertex Styles**) and **Edge Type** (**Edge Styles**)
- The **Graph View**, **Data Table View**, and **Schema View** all render from the same **Session** and **Schema**
- Each browser tab has its own **View Layout** per view, the same divergence as **Active Connection**
- A **Layout** positions **Vertices** on the **Graph View** canvas and is not part of any **View Layout**

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A question about how absolute you want this, because my in-flight #2144 collides with it and I am not assuming the glossary is the side that should bend.

This invariant, plus the Layout entry's _Avoid_: View Layout (a different concept) and the closed enumeration in Schema View Layout ("active sidebar panel, sidebar width, and the details-auto-open preference"), together say a Layout is never part of a View Layout. #2144 adds a required layoutAlgorithm: LayoutName field directly to SchemaViewLayout and defaults it in transformSchemaViewLayout. The moment it lands, a Layout is part of a View Layout and that enumeration is incomplete. Since this PR is sequenced first, the text would be merged as settled fact and then falsified.

I can see two resolutions and I think the choice is yours as the owner of the domain model:

  1. The invariant is what you meant, and Persist schema view layout selection #2144 is wrong. Then layoutAlgorithm does not belong in SchemaViewLayout at all and I move it to its own atom in Persist schema view layout selection #2144. Costs me an atom and keeps the concept boundary sharp, which is a real benefit: it would mean "View Layout" stays purely UI chrome.
  2. The invariant is over-stated. Then scoping it to the Graph View ("it is the algorithm, not part of the Graph View Layout's UI state") and opening the Schema View Layout list with "such as" costs one line here and saves a doc-correcting commit in Persist schema view layout selection #2144.

For what it is worth, #2145 does not conflict — its Graph Arrangement entry also treats Layout as the algorithm only, carrying _Avoid_: Layout (only the algorithm). It does add a sixth Layout-adjacent term to this region of the glossary, so whichever way this goes, the six read better if they agree on where the algorithm lives.

Happy to take the work in #2144 either way. I just want the decision made here rather than discovered later.

Comment thread CONTEXT.md
Comment on lines +104 to +106
**Storage Scope**:
The cross-tab behavior a persisted atom picks at creation, so scope is a visible decision rather than a side effect of which factory was reached for. Three named scopes: **per-tab**, where tabs diverge and a fresh tab starts from the value most recently used; **shared-reconciled**, where a Map-keyed collection is merged per key across tabs; and **shared-blind-write**, where each write is the whole value. See the `per-tab-session-scoped-storage-primitive` ADR for which atoms use which, and `per-key-diff-merge-cross-tab-reconciliation` for the merge rule.
_Avoid_: Persistence mode, storage strategy

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor, and the term itself clearly earns its place — it names a decision the code really does make at atom creation.

The length is what I'd push back on. CONTEXT-FORMAT.md asks for "one or two sentences max" and "define what it IS, not what it does," and docs/agents/domain.md splits what into the glossary and why into ADRs. This entry enumerates all three scopes with their merge and divergence mechanics and points at two ADRs, which reads closer to an ADR abstract than a definition. The neighbouring Layout and View Layout entries are tight by comparison.

Something like: "The cross-tab persistence behavior a persisted atom picks at creation — per-tab (tabs diverge), shared-reconciled (merged per key), or shared-blind-write. See the storage ADRs for which atoms use which." The per-scope mechanics are already in the new ADR, so restating them here is the part that could go.

Entirely your call on which detail an agent needs inline versus behind a link — that is the judgement the glossary exists to encode, so I'm not going to be confident about it from outside.

- **Shared-reconciled** — `atomWithLocalForage` with `reconcileMapByKey`. Map-keyed collections genuinely shared across tabs, merged per key (`per-key-diff-merge`). Backs Connections, Schema, Vertex and Edge Styles, Sessions.
- **Shared-blind-write** — `atomWithLocalForage` with no reconciler. Scalars where each write is the whole intended value and tabs need not diverge. Backs the boolean/number settings (e.g. `showDebugActions`).

`createActiveConfigurationAtom` is refactored onto the primitive rather than left as a parallel implementation, so the seed-and-claim logic lives in exactly one place. Its own tests stay, narrowed to what the wrapper still owns: the empty-string-is-a-miss codec rule, the bare-string round trip, and the `resolveSessionStorage` fallback. They also stand as behavior-preservation evidence for the refactor.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Small factual correction: nothing was narrowed. git diff bfc06442 HEAD -- packages/graph-explorer/src/core/StateProvider/activeConnectionStorage.test.ts is empty — all ten tests are unchanged, including the multi-tab cases.

Worth fixing because the reality is stronger than the claim. Retaining the full pre-existing suite unmodified is better behavior-preservation evidence for the refactor than retaining a narrowed subset would be, and it is the thing that made me comfortable with the createActiveConfigurationAtom rewrite. Suggest rewording to say the tests were kept intact as behavior-preservation evidence.

});
const store = createStore();

expect(() => store.set(atom, { count: 5 })).toThrow(TypeError);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Small one, and it is the only one of the three new type-form assertions where your justifying comment does not reach.

docs/agents/testing.md:79 asks for the instance form (toThrow(new FooError(a, b))) over toThrow(FooError), because the type form passes even when the code built the error with the wrong data. The two in graphViewLayoutDefaults.test.ts and schemaViewLayoutDefaults.test.ts are correctly exempt and you said why inline: SyntaxError messages are V8-specific and ZodError's issues shape moved between zod 3 and 4, so pinning an instance there would couple those tests to engine and library versions.

This one is different. The TypeError is thrown by the test's own brokenCodec at line 214 with a fixed message, so there is no version coupling to avoid and the instance form is available:

expect(() => store.set(atom, { count: 5 })).toThrow(
  new TypeError("activeToggles is not iterable"),
);

Strictly stronger here: it would catch a regression where writeSession swallowed the codec's error and rethrew a different TypeError, which is exactly the laundering this test exists to prevent. As written, any TypeError from anywhere in the setter satisfies it.

Context rather than criticism: these three are the repo's first type-form assertions. The other eight toThrow sites all use the instance form.

This branch has not been deployed

No deployments
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.

2 participants