diff --git a/CONTEXT.md b/CONTEXT.md index 9d42611ef..3cae700ab 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -13,7 +13,7 @@ A directed relationship between two vertices (source → target), with a type an _Avoid_: Relationship, link **Graph Database**: -The external graph database a user connects to and explores — the source of all vertices and edges, reached over HTTP via a Connection. It is the user's own data, brought along and queried live; distinct from the local persisted app state (connections, schema cache, styles, sessions, layout) that Graph Explorer keeps in the browser's IndexedDB. +The external graph database a user connects to and explores — the source of all vertices and edges, reached over HTTP via a Connection. It is the user's own data, brought along and queried live; distinct from the local app state (connections, schema cache, styles, sessions) that Graph Explorer keeps in the browser's IndexedDB, plus per-tab layout in sessionStorage. _Avoid_: Database (ambiguous — clarify remote graph database vs. local persisted state) **Connection**: @@ -74,7 +74,7 @@ The set of vertices and edges a user has loaded through exploration for a given _Avoid_: State, workspace **Graph View**: -The interactive canvas where vertices and edges are visualized using Cytoscape.js. Users explore the graph here by expanding neighbors and applying layouts. Nav label: "Graph". +The interactive canvas where vertices and edges are visualized using Cytoscape.js. Users explore the graph here by expanding neighbors and applying a Layout. Nav label: "Graph". _Avoid_: Graph Explorer (ambiguous with the product name) **Data Table View**: @@ -85,6 +85,26 @@ _Avoid_: Data Explorer (legacy route name) Visual representation of the Schema — shows vertex types and their edge connections as a graph. _Avoid_: Schema Explorer (legacy route name) +**Layout**: +The algorithm that positions vertices on the Graph View canvas, chosen from the layout picker and run by Cytoscape (`LayoutName`). The unqualified word always means this. +_Avoid_: View Layout (a different concept, below), graph arrangement + +**View Layout**: +The per-tab UI state of a view: which sidebar panel is active, how wide the sidebar is, and which content is toggled on. A per-tab Storage Scope concept, so it survives a tab's reload but not its close, and a fresh tab starts from the View Layout most recently used. The two are Graph View Layout and Schema View Layout. Never shortened to Layout, which is the positioning algorithm. +_Avoid_: Layout (means the algorithm), preferences, settings + +**Graph View Layout**: +The View Layout for the Graph View — active sidebar panel, sidebar width, active content toggles, table-view height, and the details-auto-open preference. +_Avoid_: Graph preferences, graph settings + +**Schema View Layout**: +The View Layout for the Schema View — active sidebar panel, sidebar width, and the details-auto-open preference. +_Avoid_: Schema preferences, schema settings + +**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 + **Edge Connection**: A schema-level pattern describing how two vertex types can be related via an edge type: sourceVertexType --[edgeType]--> targetVertexType. What the Schema View visualizes. Not an actual edge instance. _Avoid_: Relationship (Gremlin UI term), Object Property (SPARQL UI term) @@ -149,6 +169,9 @@ _Avoid_: Save-status indicator - **Neighbors** are **Vertices** one hop away from a given **Vertex** - **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** +- Every persisted atom picks one of the three **Storage Scopes** at creation ## Example dialogue diff --git a/docs/adr/20260616-per-key-diff-merge-cross-tab-reconciliation.md b/docs/adr/20260616-per-key-diff-merge-cross-tab-reconciliation.md index e32dc153c..db4838147 100644 --- a/docs/adr/20260616-per-key-diff-merge-cross-tab-reconciliation.md +++ b/docs/adr/20260616-per-key-diff-merge-cross-tab-reconciliation.md @@ -29,7 +29,7 @@ The unit of reconciliation is the **key** (collection entry — e.g. one **Verte Reconciliation is an **optional `reconcile` parameter on `atomWithLocalForage`**, not separate machinery. An atom opts in by being created with a reconciler function; without one the atom does a blind whole-value write (the parameter selects the flush at creation — a plain `storage.setItem`, or the re-read-merge-write built by `createReconcilingFlush`). This is deliberately **opt-in rather than required** because reconciliation is correct for only a minority of atoms: - **Map-keyed shared collections reconcile.** The five `Map`-keyed atoms — **Connections** (`configuration`), **Schema**, **User Preferences** (`user-vertex-styles`, `user-edge-styles`, since #1867 split styling into type-keyed maps), and **Sessions** (`graph-sessions`) — all pass the one generic reconciler, `reconcileMapByKey`. -- **Scalars must not.** Layout and the boolean/number settings have no sibling entries to preserve — each write is the whole intended value — so a per-key merge is meaningless for them. +- **Scalars must not.** Layout and the boolean/number settings have no sibling entries to preserve — each write is the whole intended value — so a per-key merge is meaningless for them. (Layout has since moved from shared to per-tab session scope — see ADR `per-tab-session-scoped-storage-primitive` — so it is no longer a shared scalar at all; the boolean/number settings remain the standing examples here.) - **`activeConfiguration` must not.** It deliberately _diverges_ per tab (the #1788 inverse); reconciling it would reintroduce the very behaviour that decision avoids. There is also no universal default reconciler: a reconciler must know the value is key-addressable (`reconcileMapByKey` only works on `Map`s). Making reconciliation _required_ would force every scalar atom to pass a nonsensical reconciler, so "required" would in practice still be "choose a reconciler" with worse ergonomics. The cost of opt-in is that a **new** `Map`-keyed shared collection added with plain `atomWithLocalForage` would silently clobber across tabs until someone notices — mitigated by the rule below rather than by flipping the default. diff --git a/docs/adr/20260618-per-tab-active-connection.md b/docs/adr/20260618-per-tab-active-connection.md index bb0cc5e5e..9281d1bbe 100644 --- a/docs/adr/20260618-per-tab-active-connection.md +++ b/docs/adr/20260618-per-tab-active-connection.md @@ -19,7 +19,7 @@ Split the concept into two roles: A fresh tab's Active Connection is seeded with `sessionStorage value ?? persisted breadcrumb value`, resolved before the atom is created (the breadcrumb read is async, in the factory; the atom itself is then created with an already-resolved seed, so there is no post-mount flash or ordering race). On a cold start — no sessionStorage value — the tab does not merely read the breadcrumb, it **claims** it, writing the seeded value into its own sessionStorage. This is the load-bearing property for per-tab stability: a later reload of that tab reads its own value back rather than re-seeding from a breadcrumb another tab may have since overwritten. (Without the claim, two tabs that both cold-started would each re-seed from the shared breadcrumb on every reload, so one tab switching connections would silently change the other on its next reload.) -Writing the per-tab value and the breadcrumb is funneled through the `activeConfigurationAtom` setter (in `activeConnectionStorage.ts`), so every existing `set(activeConfigurationAtom, …)` call site updates both backings with no change. Session-state reset is not part of this atom; each activation call site still pairs the set with `useResetState()` — a known duplication that a single `useActivateConnection` hook could centralize as a clean additive follow-up. +Writing the per-tab value and the breadcrumb is funneled through the `activeConfigurationAtom` setter (in `activeConnectionStorage.ts`), so every existing `set(activeConfigurationAtom, …)` call site updates both backings with no change. (**Updated 2026-09-21:** the seed-and-claim logic described here was extracted to the generic `createSessionScopedAtom` in `sessionScopedStorage.ts`, which now backs three concepts; `activeConnectionStorage.ts` is a codec plus one call. See ADR `per-tab-session-scoped-storage-primitive`. Behavior is unchanged.) Session-state reset is not part of this atom; each activation call site still pairs the set with `useResetState()` — a known duplication that a single `useActivateConnection` hook could centralize as a clean additive follow-up. ## Considered Options diff --git a/docs/adr/20260619-storage-layer-owns-persistence-failure.md b/docs/adr/20260619-storage-layer-owns-persistence-failure.md index f26aa0228..39712a2bb 100644 --- a/docs/adr/20260619-storage-layer-owns-persistence-failure.md +++ b/docs/adr/20260619-storage-layer-owns-persistence-failure.md @@ -38,5 +38,5 @@ The depth is hidden behind composition, not crammed into one file: `classifyStor - **Status is strictly per-tab.** Consistent with the substrate ADR's "no cross-tab sync primitives," a failed write in one tab shows `failed` there regardless of other tabs, and clears only when that tab itself successfully flushes the key. Honest about whose edit is at risk. - **Recovery is retry + backup, not a write guarantee.** The failure indicator opens a detail dialog; for terminal-quota failures the dialog offers a full configuration backup (`saveLocalForageToFile`), which is read-mostly and so remains viable under quota pressure. Terminal-access failures (private mode, blocked) offer no backup — IndexedDB never opened, so there is nothing to read. We do **not** block reload (`beforeunload`). - **This effort and the cross-tab merge are orthogonal**, meeting only at the `flush` seam — either can ship first without blocking the other. -- **Every IndexedDB write routes through one shared queue, including the Active Connection breadcrumb.** The queue/status are not embedded in `atomWithLocalForage`; they live in a shared `persistThroughQueue` helper that wraps the localForage write itself. Both `atomWithLocalForage` and the per-tab active-connection path (whose synchronous sessionStorage write stays outside the queue) call it, so all IndexedDB writes feed one global status. This dropped the special-case that would have excluded the breadcrumb. +- **Every IndexedDB write routes through one shared queue, including the Active Connection breadcrumb.** The queue/status are not embedded in `atomWithLocalForage`; they live in a shared `persistThroughQueue` helper that wraps the localForage write itself. Both `atomWithLocalForage` and the per-tab path (whose synchronous sessionStorage write stays outside the queue) call it, so all IndexedDB writes feed one global status. (**Updated 2026-09-21:** that per-tab path is now the generic `createSessionScopedAtom`, backing the active connection plus both view layouts. A failed sessionStorage write is logged and swallowed rather than reported, since the breadcrumb write still goes through the queue; see ADR `per-tab-session-scoped-storage-primitive`.) This dropped the special-case that would have excluded the breadcrumb. - **The `void`-setter change lives in the shared `createWriteThroughAtom`**, which the active-connection path also uses. No production call site awaited the old promise (only tests did), so the migration was mechanical. The test seam moved to a layer-level `waitForIdle()` on the status store, replacing per-write promise awaits across the persistence test helpers and the active-connection tests. diff --git a/docs/adr/20260709-read-time-transform-for-persisted-values.md b/docs/adr/20260709-read-time-transform-for-persisted-values.md index 00d3cc2af..56c6f9562 100644 --- a/docs/adr/20260709-read-time-transform-for-persisted-values.md +++ b/docs/adr/20260709-read-time-transform-for-persisted-values.md @@ -12,6 +12,7 @@ Persisted state in IndexedDB (via `atomWithLocalForage`) reloads in its stored s Reshape the value **on read**, via a `transform` option on `atomWithLocalForage`: `transform: (loaded: T) => T` runs on the preloaded value before it seeds the atom. The transform lives beside its type (`transformGraphViewLayout` / `transformSchemaViewLayout`, sharing `transformLegacySidebarItem`) and is wired onto the atom in `storageAtoms.ts`. +- **Updated 2026-09-21:** `transformGraphViewLayout` and `transformSchemaViewLayout` now ride `createSessionScopedAtom` instead, since both layouts moved to per-tab scope (see ADR `per-tab-session-scoped-storage-primitive`). That factory takes the same `ReadTransform` but runs it on the shared localForage breadcrumb only, not on the per-tab value (which its codec validates) or on `defaultValue`. The decision here is unchanged; only the wiring moved. - **Updated 2026-07-10:** `transformVertexStyles` (in `vertexStylesTransform.ts`) is a second consumer, applied to `user-vertex-styles`. It coerces retired round-polygon shapes to their non-round counterpart (see ADR `coerce-retired-round-polygon-shapes`). Values arriving through file import are stored verbatim — the same ReadTransform coerces them on the next load, so both entry points (persisted storage and imported files) converge on the same coercion without the import path needing its own transform. Two decisions here are not obvious from the code: diff --git a/docs/adr/20260921-per-tab-session-scoped-storage-primitive.md b/docs/adr/20260921-per-tab-session-scoped-storage-primitive.md new file mode 100644 index 000000000..1a6fbb800 --- /dev/null +++ b/docs/adr/20260921-per-tab-session-scoped-storage-primitive.md @@ -0,0 +1,42 @@ +# Per-tab session-scoped storage as a reusable primitive + +## Status + +accepted + +## Context + +The per-tab Active Connection decision (`per-tab-active-connection`) solved one concept by hand: hold the live value in `sessionStorage`, keep the existing localForage key as a shared last-writer-wins breadcrumb, and **claim** the breadcrumb into `sessionStorage` on cold start so a reload reads the tab's own value back. That logic lived inline in `activeConnectionStorage.ts`. + +Graph-view and schema-view layout (active sidebar tab, sidebar width, view toggles, the details-auto-open preference) had the same shape of problem as Active Connection, not the shape the reconciliation ADR addresses. Layout is a property of **what this tab is looking at**, not a global user preference: two tabs exploring different connections each want their own sidebar and toggle state. As shared `atomWithLocalForage` scalars they were last-writer-wins across tabs — a second tab's layout silently became the first tab's on its next cold start. Reconciliation (`per-key-diff-merge`) is the wrong tool: layout has no sibling entries to preserve, so a per-key merge is meaningless; what it wants is per-tab **divergence**, exactly like Active Connection. + +That made three distinct cross-tab storage behaviors in the codebase, only two of them named, and the third (per-tab) implemented once as a one-off. Adding a second and third per-tab concept by copy-pasting the subtle seed-and-claim logic was the wrong move. + +## Decision + +Extract the per-tab + breadcrumb mechanism into one primitive, `createSessionScopedAtom` (`core/StateProvider/sessionScopedStorage.ts`), and route every per-tab concept through it. There are now **three named cross-tab storage scopes**, each a deliberate choice at the atom's creation: + +- **Per-tab** — `createSessionScopedAtom`. Live value in `sessionStorage`; shared localForage breadcrumb read once as the cold-start seed and claimed into `sessionStorage`. Tabs diverge. Backs Active Connection, graph-view layout, schema-view layout. +- **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. + +A per-tab value crosses two backings with different serialization needs, so the primitive takes a **`SessionValueCodec`**: the breadcrumb keeps the native value (structured clone preserves a `Set`), while `sessionStorage` holds only strings. The codec's `deserialize` returns `null` only for an absent value (a legitimate miss) and validates a present value with **zod**, _throwing_ on an unparseable or wrong-shape value rather than swallowing it. Detecting corruption is thus separate from deciding what to do about it: the seam (`createSessionScopedAtom`) catches the throw, logs it, and treats it as a miss so a stale or hand-edited per-tab value falls through to the breadcrumb instead of seeding a bad shape or crashing startup. Graph-view layout's codec serializes its `activeToggles` `Set` as an array and rebuilds it on read via a zod `.transform`; schema-view layout is plain JSON. Active Connection's value is a bare id string, so its codec passes the string through and skips zod. + +## Considered Options + +- **Copy the seed-and-claim logic into each layout atom.** Rejected: duplicates the load-bearing, easy-to-get-subtly-wrong claim logic across three atoms, with three sets of near-identical tests. +- **Leave layout as shared (status quo).** Rejected: the clobber that motivated the per-tab Active Connection decision applies to layout for the same reason. +- **Reconcile layout per key (`atomWithLocalForage` + a reconciler).** Rejected: layout has no sibling entries; it wants per-tab divergence, not a merge. Reconciling it would reintroduce cross-tab coupling. +- **Per-tab + breadcrumb extracted to a primitive (chosen).** Names the third scope, removes the duplication, and unifies Active Connection onto it. + +## Consequences + +- **No migration.** The breadcrumb keeps each concept's existing localForage key and native shape; the codec only governs the per-tab `sessionStorage` round-trip. Existing stored layouts seed a fresh tab unchanged. +- **Read-time transforms apply to the breadcrumb only.** A concept whose stored shape an older app version wrote differently passes a `ReadTransform` (`read-time-transform-for-persisted-values`), and the primitive runs it on the breadcrumb before claiming it — that is the only path a retired shape can arrive by. The per-tab value is zod-validated on read, so it cannot carry one, and `defaultValue` is already current. Both layouts use this to remap the retired `nodes-styling`/`edges-styling` sidebar items onto the combined `styles` panel. +- **Cold start can resume a layout set in a different tab** (last-writer-wins breadcrumb), the same honest "resume the most recent" semantics Active Connection accepts. Per the storage model — read once at creation, never re-read (`per-key-diff-merge` context) — no scope here has ever had live cross-tab sync; an already-open tab does not reflect another tab's change until it reloads. +- **A corrupt or stale per-tab value self-heals.** `deserialize` throws on a present-but-invalid value (and reading a blocked `sessionStorage` can throw a `SecurityError`); the seam catches either, logs a warning, and falls through to the breadcrumb then the default rather than crashing startup. Breadcrumb **write** failures still surface through the persistence-status path (`storage-layer-owns-persistence-failure`). Appropriate for non-critical view preferences. +- **Rule for new atoms.** A concept that should diverge per tab uses `createSessionScopedAtom` with a codec; a shared Map-keyed collection uses `reconcileMapByKey` (`per-key-diff-merge`); a shared scalar uses plain `atomWithLocalForage`. Scope is now a visible choice at the atom's creation, not an implicit consequence of which factory was reached for. +- The `sessionStorage` backing stays injectable, so per-tab isolation is tested directly with separate stores and separate `sessionStorage` mocks over one shared mock localForage. +- This primitive is the `perTab` adapter that spike #1876 proposes to formalize alongside the other two scopes. This ADR records the **scope** decision; #1876 may later relocate where the logic lives without re-deciding it. diff --git a/docs/agents/product.md b/docs/agents/product.md index f9871f457..ffaafdd4b 100644 --- a/docs/agents/product.md +++ b/docs/agents/product.md @@ -43,5 +43,6 @@ Graph Explorer is a React-based web application that enables users to visualize - Client-side only — all user data and styles are stored client-side; the backend proxy server stores nothing - IndexedDB via localforage is the primary storage mechanism -- Persisted: user styles and settings, connection configurations, query history, visualization settings, layout preferences +- Persisted: user styles and settings, connection configurations, query history, visualization settings +- Per-tab state lives in sessionStorage instead, so tabs diverge: the active connection and the graph-view and schema-view layouts. Each keeps its localForage key as a cold-start breadcrumb that seeds a freshly-opened tab - Graph data is queried directly from the connected databases and is not owned or persisted by Graph Explorer diff --git a/docs/agents/testing.md b/docs/agents/testing.md index 57b9cd741..03cfa025d 100644 --- a/docs/agents/testing.md +++ b/docs/agents/testing.md @@ -37,7 +37,8 @@ These files are the canonical, always-current examples. Open the closest one and - Hook + `DbState`: `src/core/StateProvider/displayVertex.test.ts` - Query-string generation (Gremlin): `src/connector/gremlin/fetchNeighbors/oneHopTemplate.test.ts`; SPARQL: `src/connector/sparql/fetchNeighbors/oneHopNeighborsTemplate.test.ts` - SPARQL response parsing: `src/connector/sparql/parseAndMapQuads.test.ts` -- Cross-tab persistence: `src/utils/testing/persistence.test.ts` +- Cross-tab persistence, shared localForage atoms: `src/utils/testing/persistence.test.ts` (`PersistenceTab` is typed to `atomWithLocalForage` and cannot open a per-tab atom) +- Cross-tab persistence, per-tab atoms: `src/core/StateProvider/sessionScopedStorage.test.ts` — one `createInMemorySessionStorage()` per simulated tab over the single shared fake-indexeddb - Legacy persisted-shape handling: `src/utils/parseConnectionFile.test.ts` Canonical hook test shape: @@ -83,4 +84,4 @@ Anything persisted to IndexedDB via localForage/Jotai may be reloaded in an olde Group them in a dedicated `describe("backward compatibility: ...")` with a comment block stating the old shape, why the tests exist, and a "do not delete without confirming migration" warning. See `src/utils/parseConnectionFile.test.ts` or `src/core/StateProvider/graphViewLayout.test.ts` for worked examples. -Applies to any object type persisted via `atomWithLocalForage`. Triggers: removing/renaming a property, changing a property's type, adding a required property, or changing a property's semantics. +Applies to any object type persisted via `atomWithLocalForage` or `createSessionScopedAtom`. Triggers: removing/renaming a property, changing a property's type, adding a required property, or changing a property's semantics. For a per-tab atom the old shape arrives on the shared localForage breadcrumb, which is the leg its `transform` runs on. diff --git a/docs/features/graph-view.md b/docs/features/graph-view.md index 96752224d..756591ac0 100644 --- a/docs/features/graph-view.md +++ b/docs/features/graph-view.md @@ -28,6 +28,8 @@ The panel on the right of the graph provides various actions, configuration, and - [**Styles panel**](#styles-panel) of node and edge display options (e.g., color, icon, the property to use for the displayed name), split across a Nodes tab and an Edges tab. - [**Namespaces panel (RDF only)**](#namespace-panel) allows you to shorten the display of Resource URIs within the app based on auto-generated prefixes, commonly-used prefix libraries, or custom prefixes set by the user. Order of priority is set to Custom > Common > Auto-generated. +The active panel, the sidebar width, and which views are toggled on are remembered per browser tab, so two tabs can keep different layouts side by side. When you reopen Graph Explorer after closing all tabs, it resumes the layout you most recently used. + ### Search Panel The Search UI provides two powerful ways to search and interact with your graph database: diff --git a/docs/features/schema-view.md b/docs/features/schema-view.md index 8cac23e0f..778aa97c0 100644 --- a/docs/features/schema-view.md +++ b/docs/features/schema-view.md @@ -19,7 +19,7 @@ The sidebar has two panels: - **Details** — shows properties and connections for the selected node type or edge connection - **Styles** — customize colors and icons for node types and edge types, split across a Nodes tab and an Edges tab -Click the active tab icon to collapse the sidebar to just the icon strip. Click any tab icon to reopen it. Both the active tab and sidebar width are remembered across sessions. +Click the active tab icon to collapse the sidebar to just the icon strip. Click any tab icon to reopen it. The active tab and sidebar width are remembered per browser tab, so two tabs can keep different layouts side by side. When you reopen Graph Explorer after closing all tabs, it resumes the layout you most recently used. The Details panel header includes an "Automatically open on selection" toggle. When enabled (the default), selecting a single node type or edge connection in the schema graph automatically opens the Details panel — so while it is enabled, a collapsed sidebar reopens on your next selection. diff --git a/packages/graph-explorer/src/core/StateProvider/activeConnectionStorage.ts b/packages/graph-explorer/src/core/StateProvider/activeConnectionStorage.ts index 520c3c537..eb049e293 100644 --- a/packages/graph-explorer/src/core/StateProvider/activeConnectionStorage.ts +++ b/packages/graph-explorer/src/core/StateProvider/activeConnectionStorage.ts @@ -1,10 +1,9 @@ -import localForage from "localforage"; - import type { ConfigurationId } from "../ConfigurationProvider"; -import { persistThroughQueue } from "./persistence"; -import { resolveSessionStorage } from "./safeSessionStorage"; -import { createWriteThroughAtom } from "./writeThroughAtom"; +import { + createSessionScopedAtom, + type SessionValueCodec, +} from "./sessionScopedStorage"; /** * Storage key for the active connection. Used for both the per-tab @@ -13,61 +12,34 @@ import { createWriteThroughAtom } from "./writeThroughAtom"; */ export const ACTIVE_CONNECTION_STORAGE_KEY = "active-configuration"; -function readSessionValue(sessionStorage: Storage): ConfigurationId | null { - // Treat an empty/corrupted value as a miss so it falls through to the - // breadcrumb rather than seeding an invalid connection id. - const value = sessionStorage.getItem(ACTIVE_CONNECTION_STORAGE_KEY); - return value ? (value as ConfigurationId) : null; -} +/** + * The active connection id is a bare string, so it round-trips as-is rather + * than through JSON. An empty/cleared value reads back as a miss so seeding + * falls through to the breadcrumb instead of an invalid connection id. + */ +const activeConnectionCodec: SessionValueCodec = { + serialize: value => value, + deserialize: raw => (raw ? (raw as ConfigurationId) : null), +}; /** * Creates the atom holding this tab's active connection. * - * The active connection is per-tab: it lives in sessionStorage so it survives - * a reload of this tab but never leaks to other tabs. Each write also updates a - * shared, persisted breadcrumb in localForage; that breadcrumb is read only - * once, here, to seed a fresh tab on cold start. - * - * Seeding order: this tab's sessionStorage value (warm reload) wins; otherwise - * the persisted breadcrumb (cold start) seeds it. A cold-start seed is claimed - * into this tab's sessionStorage so the tab owns that connection: a later - * reload reads its own value back instead of re-seeding from a breadcrumb that - * another tab may have since moved. + * The active connection is per-tab: it lives in sessionStorage so it survives a + * reload of this tab but never leaks to other tabs, while a shared localForage + * breadcrumb seeds a fresh tab on cold start. See {@link createSessionScopedAtom} + * for the full seeding and write-through behavior. * * @param sessionStorage The per-tab storage backing. Injectable so multi-tab * isolation can be tested with separate storages. */ export async function createActiveConfigurationAtom({ - sessionStorage = resolveSessionStorage(), + sessionStorage, }: { sessionStorage?: Storage } = {}) { - let seedValue = readSessionValue(sessionStorage); - if (seedValue === null) { - // Cold start: seed from the shared breadcrumb and claim it into this tab's - // sessionStorage, so a later reload reads this value back rather than - // re-seeding from a breadcrumb another tab may have since moved. - seedValue = await localForage.getItem( - ACTIVE_CONNECTION_STORAGE_KEY, - ); - if (seedValue !== null) { - sessionStorage.setItem(ACTIVE_CONNECTION_STORAGE_KEY, seedValue); - } - } - - return createWriteThroughAtom( - seedValue, - // The per-tab sessionStorage value updates synchronously; the shared - // localForage breadcrumb is persisted through the queue so its outcome - // joins the global persistence status like any other IndexedDB write. - nextValue => { - if (nextValue === null) { - sessionStorage.removeItem(ACTIVE_CONNECTION_STORAGE_KEY); - } else { - sessionStorage.setItem(ACTIVE_CONNECTION_STORAGE_KEY, nextValue); - } - persistThroughQueue(ACTIVE_CONNECTION_STORAGE_KEY, async () => { - await localForage.setItem(ACTIVE_CONNECTION_STORAGE_KEY, nextValue); - }); - }, - "activeConfigurationAtom", - ); + return createSessionScopedAtom({ + key: ACTIVE_CONNECTION_STORAGE_KEY, + defaultValue: null, + codec: activeConnectionCodec, + sessionStorage, + }); } diff --git a/packages/graph-explorer/src/core/StateProvider/graphViewLayoutDefaults.test.ts b/packages/graph-explorer/src/core/StateProvider/graphViewLayoutDefaults.test.ts index 9dbfb3a6d..98dec22c3 100644 --- a/packages/graph-explorer/src/core/StateProvider/graphViewLayoutDefaults.test.ts +++ b/packages/graph-explorer/src/core/StateProvider/graphViewLayoutDefaults.test.ts @@ -1,6 +1,10 @@ +import { z } from "zod"; + import { - type GraphViewLayout, + defaultGraphViewLayout, + graphViewLayoutCodec, transformGraphViewLayout, + type GraphViewLayout, } from "./graphViewLayoutDefaults"; /** @@ -57,3 +61,44 @@ describe("transformGraphViewLayout backward compatibility", () => { expect(transformGraphViewLayout(layout)).toBe(layout); }); }); + +describe("graphViewLayoutCodec", () => { + test("round-trips a layout through serialize/deserialize, preserving the toggles Set", () => { + const layout: GraphViewLayout = { + activeSidebarItem: "filters", + activeToggles: new Set(["graph-viewer"]), + sidebar: { width: 321 }, + tableView: { height: 250 }, + detailsAutoOpenOnSelection: false, + }; + + const restored = graphViewLayoutCodec.deserialize( + graphViewLayoutCodec.serialize(layout), + ); + + expect(restored).toStrictEqual(layout); + expect(restored?.activeToggles).toBeInstanceOf(Set); + }); + + test("round-trips the default layout", () => { + expect( + graphViewLayoutCodec.deserialize( + graphViewLayoutCodec.serialize(defaultGraphViewLayout), + ), + ).toStrictEqual(defaultGraphViewLayout); + }); + + test("treats an absent value as a miss", () => { + expect(graphViewLayoutCodec.deserialize(null)).toBeNull(); + expect(graphViewLayoutCodec.deserialize("")).toBeNull(); + }); + + test("throws on a corrupt value so the seam can discard it", () => { + // Asserted by type, not instance: these errors come from JSON.parse and + // zod, whose messages shift between engine and library versions. + expect(() => graphViewLayoutCodec.deserialize("{ not json")).toThrow( + SyntaxError, + ); + expect(() => graphViewLayoutCodec.deserialize("{}")).toThrow(z.ZodError); + }); +}); diff --git a/packages/graph-explorer/src/core/StateProvider/graphViewLayoutDefaults.ts b/packages/graph-explorer/src/core/StateProvider/graphViewLayoutDefaults.ts index 2a1e096a4..72e4cb657 100644 --- a/packages/graph-explorer/src/core/StateProvider/graphViewLayoutDefaults.ts +++ b/packages/graph-explorer/src/core/StateProvider/graphViewLayoutDefaults.ts @@ -1,17 +1,28 @@ +import { z } from "zod"; + +import { + parseSessionJson, + type SessionValueCodec, +} from "./sessionScopedStorage"; + /** The two main content views that can be toggled on or off. */ -export const toggleableViews = ["graph-viewer", "table-view"] as const; -export type ToggleableView = (typeof toggleableViews)[number]; +export const toggleableViewSchema = z.enum(["graph-viewer", "table-view"]); +export type ToggleableView = z.infer; +/** The toggleable views as a readonly tuple, e.g. for random test selection. */ +export const toggleableViews = toggleableViewSchema.options; /** Identifiers for the graph view sidebar panels. */ -export const graphViewSidebarItems = [ +export const graphViewSidebarItemSchema = z.enum([ "search", "details", "filters", "expand", "styles", "namespaces", -] as const; -export type GraphViewSidebarItem = (typeof graphViewSidebarItems)[number]; +]); +export type GraphViewSidebarItem = z.infer; +/** The sidebar panels as a readonly tuple, e.g. for random test selection. */ +export const graphViewSidebarItems = graphViewSidebarItemSchema.options; /** * Legacy `activeSidebarItem` values, from when node and edge styling were two @@ -35,14 +46,24 @@ export function transformLegacySidebarItem< : item; } -/** Persisted layout preferences for the graph view. */ -export type GraphViewLayout = { - activeSidebarItem: GraphViewSidebarItem | null; - sidebar: { width: number }; - activeToggles: Set; - tableView?: { height: number }; - detailsAutoOpenOnSelection?: boolean; -}; +/** + * Persisted layout preferences for the graph view, and the single declaration of + * that shape so the runtime type and the parser cannot drift apart. The schema's + * *input* is the JSON the per-tab value holds, where `activeToggles` is an array + * because a `Set` does not survive `JSON.stringify`; its *output* is the runtime + * {@link GraphViewLayout} with the `Set` rebuilt. A stale or hand-edited per-tab + * value with the wrong shape is rejected rather than seeding bad state. + */ +const graphViewLayoutSchema = z.object({ + activeSidebarItem: graphViewSidebarItemSchema.nullable(), + sidebar: z.object({ width: z.number() }), + activeToggles: z + .array(toggleableViewSchema) + .transform(toggles => new Set(toggles)), + tableView: z.object({ height: z.number() }).optional(), + detailsAutoOpenOnSelection: z.boolean().optional(), +}); +export type GraphViewLayout = z.infer; /** Default height for the table view panel in pixels. */ export const DEFAULT_TABLE_VIEW_HEIGHT = 300; @@ -70,3 +91,13 @@ export function transformGraphViewLayout( ? layout : { ...layout, activeSidebarItem }; } + +/** Per-tab session codec; serializes the toggles Set as an array for JSON. */ +export const graphViewLayoutCodec: SessionValueCodec = { + serialize: layout => + JSON.stringify({ + ...layout, + activeToggles: [...layout.activeToggles], + }), + deserialize: raw => parseSessionJson(raw, graphViewLayoutSchema), +}; diff --git a/packages/graph-explorer/src/core/StateProvider/schemaViewLayoutDefaults.test.ts b/packages/graph-explorer/src/core/StateProvider/schemaViewLayoutDefaults.test.ts index 1f4c86900..9c23d84a8 100644 --- a/packages/graph-explorer/src/core/StateProvider/schemaViewLayoutDefaults.test.ts +++ b/packages/graph-explorer/src/core/StateProvider/schemaViewLayoutDefaults.test.ts @@ -1,4 +1,8 @@ +import { z } from "zod"; + import { + defaultSchemaViewLayout, + schemaViewLayoutCodec, transformSchemaViewLayout, type SchemaViewLayout, } from "./schemaViewLayoutDefaults"; @@ -53,3 +57,41 @@ describe("transformSchemaViewLayout backward compatibility", () => { expect(transformSchemaViewLayout(layout)).toBe(layout); }); }); + +describe("schemaViewLayoutCodec", () => { + test("round-trips a layout through serialize/deserialize", () => { + const layout: SchemaViewLayout = { + activeSidebarItem: "styles", + sidebar: { width: 321 }, + detailsAutoOpenOnSelection: false, + }; + + expect( + schemaViewLayoutCodec.deserialize( + schemaViewLayoutCodec.serialize(layout), + ), + ).toStrictEqual(layout); + }); + + test("round-trips the default layout", () => { + expect( + schemaViewLayoutCodec.deserialize( + schemaViewLayoutCodec.serialize(defaultSchemaViewLayout), + ), + ).toStrictEqual(defaultSchemaViewLayout); + }); + + test("treats an absent value as a miss", () => { + expect(schemaViewLayoutCodec.deserialize(null)).toBeNull(); + expect(schemaViewLayoutCodec.deserialize("")).toBeNull(); + }); + + test("throws on a corrupt value so the seam can discard it", () => { + // Asserted by type, not instance: these errors come from JSON.parse and + // zod, whose messages shift between engine and library versions. + expect(() => schemaViewLayoutCodec.deserialize("{ not json")).toThrow( + SyntaxError, + ); + expect(() => schemaViewLayoutCodec.deserialize("{}")).toThrow(z.ZodError); + }); +}); diff --git a/packages/graph-explorer/src/core/StateProvider/schemaViewLayoutDefaults.ts b/packages/graph-explorer/src/core/StateProvider/schemaViewLayoutDefaults.ts index f167b890d..02e3ece1f 100644 --- a/packages/graph-explorer/src/core/StateProvider/schemaViewLayoutDefaults.ts +++ b/packages/graph-explorer/src/core/StateProvider/schemaViewLayoutDefaults.ts @@ -1,18 +1,27 @@ +import { z } from "zod"; + import { DEFAULT_SIDEBAR_WIDTH, transformLegacySidebarItem, } from "./graphViewLayoutDefaults"; +import { + parseSessionJson, + type SessionValueCodec, +} from "./sessionScopedStorage"; /** Identifiers for the schema view sidebar panels. */ -export const schemaViewSidebarItems = ["details", "styles"] as const; -export type SchemaViewSidebarItem = (typeof schemaViewSidebarItems)[number]; +export const schemaViewSidebarItemSchema = z.enum(["details", "styles"]); +export type SchemaViewSidebarItem = z.infer; +/** The sidebar panels as a readonly tuple, e.g. for random test selection. */ +export const schemaViewSidebarItems = schemaViewSidebarItemSchema.options; /** Persisted layout preferences for the schema view. */ -export type SchemaViewLayout = { - activeSidebarItem: SchemaViewSidebarItem | null; - sidebar: { width: number }; - detailsAutoOpenOnSelection?: boolean; -}; +const schemaViewLayoutSchema = z.object({ + activeSidebarItem: schemaViewSidebarItemSchema.nullable(), + sidebar: z.object({ width: z.number() }), + detailsAutoOpenOnSelection: z.boolean().optional(), +}); +export type SchemaViewLayout = z.infer; /** Initial layout state used when no persisted layout exists. */ export const defaultSchemaViewLayout: SchemaViewLayout = { @@ -32,3 +41,9 @@ export function transformSchemaViewLayout( ? layout : { ...layout, activeSidebarItem }; } + +/** Per-tab session codec; the schema view layout is plain JSON. */ +export const schemaViewLayoutCodec: SessionValueCodec = { + serialize: layout => JSON.stringify(layout), + deserialize: raw => parseSessionJson(raw, schemaViewLayoutSchema), +}; diff --git a/packages/graph-explorer/src/core/StateProvider/sessionScopedStorage.test.ts b/packages/graph-explorer/src/core/StateProvider/sessionScopedStorage.test.ts new file mode 100644 index 000000000..90e209e47 --- /dev/null +++ b/packages/graph-explorer/src/core/StateProvider/sessionScopedStorage.test.ts @@ -0,0 +1,492 @@ +import { createStore } from "jotai"; +import localForage from "localforage"; +import { describe, expect, test, vi } from "vitest"; +import { z } from "zod"; + +import { logger } from "@/utils"; + +import { + defaultGraphViewLayout, + type GraphViewLayout, + graphViewLayoutCodec, + transformGraphViewLayout, +} from "./graphViewLayoutDefaults"; +import { persistenceStatusStore } from "./persistence"; +import { createInMemorySessionStorage } from "./safeSessionStorage"; +import { + defaultSchemaViewLayout, + type SchemaViewLayout, + schemaViewLayoutCodec, +} from "./schemaViewLayoutDefaults"; +import { + createSessionScopedAtom, + parseSessionJson, + type SessionValueCodec, +} from "./sessionScopedStorage"; + +type Counter = { count: number }; + +const counterSchema = z.object({ count: z.number() }); + +/** + * A JSON codec that round-trips a small object and rejects anything that does + * not match the schema, so the corrupt-value fallthrough can be exercised. + */ +const counterCodec: SessionValueCodec = { + serialize: value => JSON.stringify(value), + deserialize: raw => parseSessionJson(raw, counterSchema), +}; + +const KEY = "test-counter"; + +/** + * Builds a tab-opener bound to one key/default/codec. Each opened tab has its + * own Jotai store and sessionStorage (per-tab), while all tabs share the one + * fake-indexeddb — exactly how same-origin tabs relate. Mirrors the + * active-connection test harness, parameterized so each real codec can be + * exercised through the same multi-tab sequences rather than only a toy codec. + */ +function tabOpener( + key: string, + defaultValue: T, + codec: SessionValueCodec, +) { + return async function openTab() { + const sessionStorage = createInMemorySessionStorage(); + let store = createStore(); + let atom = await createSessionScopedAtom({ + key, + defaultValue, + codec, + sessionStorage, + }); + return { + read: () => store.get(atom), + write: (value: T) => { + store.set(atom, value); + return persistenceStatusStore.waitForIdle(); + }, + reload: async () => { + store = createStore(); + atom = await createSessionScopedAtom({ + key, + defaultValue, + codec, + sessionStorage, + }); + }, + }; + }; +} + +const openTab = tabOpener(KEY, { count: 0 }, counterCodec); + +describe("createSessionScopedAtom", () => { + test("cold start seeds from the persisted breadcrumb and claims it into this tab", async () => { + await localForage.setItem(KEY, { count: 7 }); + const sessionStorage = createInMemorySessionStorage(); + + const atom = await createSessionScopedAtom({ + key: KEY, + defaultValue: { count: 0 }, + codec: counterCodec, + sessionStorage, + }); + + const store = createStore(); + expect(store.get(atom)).toStrictEqual({ count: 7 }); + expect(sessionStorage.getItem(KEY)).toBe(JSON.stringify({ count: 7 })); + }); + + test("falls back to the default value when neither session nor breadcrumb is present", async () => { + const atom = await createSessionScopedAtom({ + key: KEY, + defaultValue: { count: 0 }, + codec: counterCodec, + sessionStorage: createInMemorySessionStorage(), + }); + + const store = createStore(); + expect(store.get(atom)).toStrictEqual({ count: 0 }); + }); + + test("warm reload keeps this tab's session value over the breadcrumb", async () => { + await localForage.setItem(KEY, { count: 7 }); + const sessionStorage = createInMemorySessionStorage(); + sessionStorage.setItem(KEY, JSON.stringify({ count: 42 })); + + const atom = await createSessionScopedAtom({ + key: KEY, + defaultValue: { count: 0 }, + codec: counterCodec, + sessionStorage, + }); + + const store = createStore(); + expect(store.get(atom)).toStrictEqual({ count: 42 }); + }); + + test("discards a corrupt session value with a warning and falls back to the breadcrumb", async () => { + await localForage.setItem(KEY, { count: 7 }); + const sessionStorage = createInMemorySessionStorage(); + sessionStorage.setItem(KEY, "{ not valid json"); + + const atom = await createSessionScopedAtom({ + key: KEY, + defaultValue: { count: 0 }, + codec: counterCodec, + sessionStorage, + }); + + const store = createStore(); + expect(store.get(atom)).toStrictEqual({ count: 7 }); + expect(vi.mocked(logger.warn)).toHaveBeenCalledOnce(); + }); + + test("recovers when reading sessionStorage throws, falling back to the breadcrumb", async () => { + await localForage.setItem(KEY, { count: 7 }); + const sessionStorage = createInMemorySessionStorage(); + // DOM storage blocked: accessing the value throws a SecurityError rather + // than returning null. The seam must treat this as a miss, not crash boot. + vi.spyOn(sessionStorage, "getItem").mockImplementation(() => { + throw new DOMException("blocked", "SecurityError"); + }); + + const atom = await createSessionScopedAtom({ + key: KEY, + defaultValue: { count: 0 }, + codec: counterCodec, + sessionStorage, + }); + + const store = createStore(); + expect(store.get(atom)).toStrictEqual({ count: 7 }); + expect(vi.mocked(logger.warn)).toHaveBeenCalledOnce(); + }); + + test("writing updates this tab synchronously and the breadcrumb in the background", async () => { + const sessionStorage = createInMemorySessionStorage(); + const atom = await createSessionScopedAtom({ + key: KEY, + defaultValue: { count: 0 }, + codec: counterCodec, + sessionStorage, + }); + const store = createStore(); + + store.set(atom, { count: 5 }); + + expect(store.get(atom)).toStrictEqual({ count: 5 }); + expect(sessionStorage.getItem(KEY)).toBe(JSON.stringify({ count: 5 })); + await persistenceStatusStore.waitForIdle(); + expect(await localForage.getItem(KEY)).toStrictEqual({ count: 5 }); + }); + + test("tolerates a failing per-tab write and still persists the breadcrumb", async () => { + // sessionStorage.setItem can throw QuotaExceededError once storage fills, + // after resolveSessionStorage already handed back a working store. The throw + // must not escape the Jotai setter into the React subtree that set the atom. + const sessionStorage = createInMemorySessionStorage(); + const atom = await createSessionScopedAtom({ + key: KEY, + defaultValue: { count: 0 }, + codec: counterCodec, + sessionStorage, + }); + vi.spyOn(sessionStorage, "setItem").mockImplementation(() => { + throw new DOMException("full", "QuotaExceededError"); + }); + const store = createStore(); + + expect(() => store.set(atom, { count: 5 })).not.toThrow(); + + expect(store.get(atom)).toStrictEqual({ count: 5 }); + expect(vi.mocked(logger.warn)).toHaveBeenCalledOnce(); + await persistenceStatusStore.waitForIdle(); + expect(await localForage.getItem(KEY)).toStrictEqual({ count: 5 }); + }); + + test("lets a throwing codec escape instead of logging it as a write failure", async () => { + // A codec that throws is a defect, not a storage condition, so it must not + // be laundered into the warning that QuotaExceededError gets. + const brokenCodec: SessionValueCodec = { + serialize: () => { + throw new TypeError("activeToggles is not iterable"); + }, + deserialize: raw => parseSessionJson(raw, counterSchema), + }; + const atom = await createSessionScopedAtom({ + key: KEY, + defaultValue: { count: 0 }, + codec: brokenCodec, + sessionStorage: createInMemorySessionStorage(), + }); + const store = createStore(); + + expect(() => store.set(atom, { count: 5 })).toThrow(TypeError); + expect(vi.mocked(logger.warn)).not.toHaveBeenCalled(); + }); + + test("a serialize that returns null removes the per-tab key but still writes the breadcrumb", async () => { + // A codec that refuses to persist the empty state to the per-tab layer, so + // a later reload of this tab does not re-seed from it. + const clearingCodec: SessionValueCodec = { + serialize: value => (value.count === 0 ? null : JSON.stringify(value)), + deserialize: raw => parseSessionJson(raw, counterSchema), + }; + const sessionStorage = createInMemorySessionStorage(); + sessionStorage.setItem(KEY, JSON.stringify({ count: 5 })); + + const atom = await createSessionScopedAtom({ + key: KEY, + defaultValue: { count: 0 }, + codec: clearingCodec, + sessionStorage, + }); + const store = createStore(); + store.set(atom, { count: 0 }); + + expect(sessionStorage.getItem(KEY)).toBeNull(); + await persistenceStatusStore.waitForIdle(); + expect(await localForage.getItem(KEY)).toStrictEqual({ count: 0 }); + }); + + test("normalizes the breadcrumb through transform and claims the normalized value", async () => { + await localForage.setItem(KEY, { count: 7 }); + const sessionStorage = createInMemorySessionStorage(); + + const atom = await createSessionScopedAtom({ + key: KEY, + defaultValue: { count: 0 }, + codec: counterCodec, + transform: loaded => ({ count: loaded.count * 10 }), + sessionStorage, + }); + + const store = createStore(); + expect(store.get(atom)).toStrictEqual({ count: 70 }); + // The tab claims the normalized value, so a later reload reads it back + // rather than re-normalizing an old shape every boot. + expect(sessionStorage.getItem(KEY)).toBe(JSON.stringify({ count: 70 })); + }); + + test("leaves this tab's own session value and the default untransformed", async () => { + const transform = vi.fn((loaded: Counter) => ({ + count: loaded.count * 10, + })); + const warmStorage = createInMemorySessionStorage(); + warmStorage.setItem(KEY, JSON.stringify({ count: 42 })); + + const warmAtom = await createSessionScopedAtom({ + key: KEY, + defaultValue: { count: 0 }, + codec: counterCodec, + transform, + sessionStorage: warmStorage, + }); + const coldAtom = await createSessionScopedAtom({ + key: KEY, + defaultValue: { count: 0 }, + codec: counterCodec, + transform, + sessionStorage: createInMemorySessionStorage(), + }); + + const store = createStore(); + expect(store.get(warmAtom)).toStrictEqual({ count: 42 }); + expect(store.get(coldAtom)).toStrictEqual({ count: 0 }); + expect(transform).not.toHaveBeenCalled(); + }); +}); + +describe("createSessionScopedAtom across tabs", () => { + test("writing in one tab does not change an already-open tab", async () => { + const tabB = await openTab(); + await tabB.write({ count: 2 }); + + const tabA = await openTab(); + await tabA.write({ count: 99 }); + + expect(tabB.read()).toStrictEqual({ count: 2 }); + }); + + test("a tab opened later cold-starts to the value an earlier tab wrote", async () => { + const earlierTab = await openTab(); + await earlierTab.write({ count: 3 }); + + const freshTab = await openTab(); + + expect(freshTab.read()).toStrictEqual({ count: 3 }); + }); + + test("a cold-started tab keeps its value across reload when another tab moves the breadcrumb", async () => { + await localForage.setItem(KEY, { count: 1 }); + const tabA = await openTab(); + expect(tabA.read()).toStrictEqual({ count: 1 }); + + const tabB = await openTab(); + await tabB.write({ count: 2 }); + + await tabA.reload(); + expect(tabA.read()).toStrictEqual({ count: 1 }); + }); +}); + +// The breadcrumb keeps the native value (structured clone preserves the +// activeToggles Set), but the per-tab sessionStorage claim must go through the +// codec, which serializes that Set as an array. This exercises the helper and +// graphViewLayoutCodec together over that exact path — the reason the branch +// exists — rather than each in isolation. +describe("createSessionScopedAtom with the graph view layout codec", () => { + const LAYOUT_KEY = "graph-view-layout"; + + test("cold start claims a Set-bearing breadcrumb into sessionStorage as its array form", async () => { + const breadcrumb: GraphViewLayout = { + activeSidebarItem: "filters", + activeToggles: new Set(["graph-viewer", "table-view"]), + sidebar: { width: 321 }, + tableView: { height: 250 }, + detailsAutoOpenOnSelection: false, + }; + await localForage.setItem(LAYOUT_KEY, breadcrumb); + const sessionStorage = createInMemorySessionStorage(); + + const atom = await createSessionScopedAtom({ + key: LAYOUT_KEY, + defaultValue: defaultGraphViewLayout, + codec: graphViewLayoutCodec, + sessionStorage, + }); + + const store = createStore(); + const seeded = store.get(atom); + expect(seeded.activeToggles).toBeInstanceOf(Set); + expect(seeded).toStrictEqual(breadcrumb); + + // The claimed per-tab value is the array-serialized form, pinned literally + // so a serialize that dropped a field could not satisfy both sides at once. + expect(sessionStorage.getItem(LAYOUT_KEY)).toBe( + JSON.stringify({ + activeSidebarItem: "filters", + activeToggles: ["graph-viewer", "table-view"], + sidebar: { width: 321 }, + tableView: { height: 250 }, + detailsAutoOpenOnSelection: false, + }), + ); + // A warm reload off that value rebuilds the Set rather than re-seeding. + expect( + graphViewLayoutCodec.deserialize(sessionStorage.getItem(LAYOUT_KEY)), + ).toStrictEqual(breadcrumb); + }); + + test("remaps a retired sidebar item in the breadcrumb on cold start", async () => { + // A layout stored before node and edge styling merged into one panel. The + // breadcrumb is the only path a retired shape can arrive by, so without the + // transform the codec would reject the claimed value on every reload and the + // sidebar would point at a panel that no longer exists. + await localForage.setItem(LAYOUT_KEY, { + ...defaultGraphViewLayout, + activeSidebarItem: "nodes-styling", + } as unknown as GraphViewLayout); + + const atom = await createSessionScopedAtom({ + key: LAYOUT_KEY, + defaultValue: defaultGraphViewLayout, + codec: graphViewLayoutCodec, + transform: transformGraphViewLayout, + sessionStorage: createInMemorySessionStorage(), + }); + + expect(createStore().get(atom).activeSidebarItem).toBe("styles"); + }); +}); + +// Graph view is the codec with real risk: its activeToggles Set must survive +// the array serialization across a write-in-one-tab / cold-start-in-another +// sequence, which the toy counter codec above cannot reach. +describe("graph view layout across tabs", () => { + const openGraphViewTab = tabOpener( + "graph-view-layout", + defaultGraphViewLayout, + graphViewLayoutCodec, + ); + + test("changing layout in one tab does not change an already-open tab", async () => { + const tabB = await openGraphViewTab(); + const tabBLayout: GraphViewLayout = { + ...defaultGraphViewLayout, + activeSidebarItem: "filters", + activeToggles: new Set(["graph-viewer"]), + }; + await tabB.write(tabBLayout); + + const tabA = await openGraphViewTab(); + await tabA.write({ + ...defaultGraphViewLayout, + activeSidebarItem: "styles", + }); + + expect(tabB.read()).toStrictEqual(tabBLayout); + }); + + test("a later tab cold-starts to the layout an earlier tab wrote, with toggles rebuilt as a Set", async () => { + const earlierTab = await openGraphViewTab(); + const written: GraphViewLayout = { + ...defaultGraphViewLayout, + activeSidebarItem: "expand", + activeToggles: new Set(["table-view"]), + sidebar: { width: 512 }, + }; + await earlierTab.write(written); + + const freshTab = await openGraphViewTab(); + + const seeded = freshTab.read(); + expect(seeded).toStrictEqual(written); + expect(seeded.activeToggles).toBeInstanceOf(Set); + }); +}); + +// Schema view's codec is structurally the same as the counter codec above, so +// these cover the storageAtoms wiring rather than codec risk: that the atom is +// really built with this codec under the schema-view-layout key. The codec +// itself is covered in schemaViewLayoutDefaults.test.ts. +describe("schema view layout across tabs", () => { + const openSchemaViewTab = tabOpener( + "schema-view-layout", + defaultSchemaViewLayout, + schemaViewLayoutCodec, + ); + + test("changing layout in one tab does not change an already-open tab", async () => { + const tabB = await openSchemaViewTab(); + const tabBLayout: SchemaViewLayout = { + ...defaultSchemaViewLayout, + activeSidebarItem: "styles", + }; + await tabB.write(tabBLayout); + + const tabA = await openSchemaViewTab(); + await tabA.write({ + ...defaultSchemaViewLayout, + activeSidebarItem: "details", + }); + + expect(tabB.read()).toStrictEqual(tabBLayout); + }); + + test("a later tab cold-starts to the layout an earlier tab wrote", async () => { + const earlierTab = await openSchemaViewTab(); + const written: SchemaViewLayout = { + ...defaultSchemaViewLayout, + activeSidebarItem: "styles", + sidebar: { width: 480 }, + }; + await earlierTab.write(written); + + const freshTab = await openSchemaViewTab(); + + expect(freshTab.read()).toStrictEqual(written); + }); +}); diff --git a/packages/graph-explorer/src/core/StateProvider/sessionScopedStorage.ts b/packages/graph-explorer/src/core/StateProvider/sessionScopedStorage.ts new file mode 100644 index 000000000..c58c2eae6 --- /dev/null +++ b/packages/graph-explorer/src/core/StateProvider/sessionScopedStorage.ts @@ -0,0 +1,173 @@ +import type { z } from "zod"; + +import localForage from "localforage"; + +import { logger } from "@/utils"; + +import type { ReadTransform } from "./atomWithLocalForage"; + +import { persistThroughQueue } from "./persistence"; +import { resolveSessionStorage } from "./safeSessionStorage"; +import { createWriteThroughAtom } from "./writeThroughAtom"; + +/** + * Converts a per-tab value to and from the string sessionStorage holds. The + * shared localForage breadcrumb keeps the native value (structured clone, so a + * `Set` survives), so only the per-tab layer needs this string round-trip. + * + * `serialize` returns `null` to mean "remove the per-tab key" — used when the + * value is the kind of empty/cleared state that should not seed a later reload. + * `deserialize` returns `null` for an absent value (a legitimate miss); a + * present-but-invalid value is corrupt and **throws** — the seam + * (`createSessionScopedAtom`) catches it and treats it as a miss, so detecting + * corruption stays separate from deciding what to do about it. Do not swallow + * errors here. + */ +export type SessionValueCodec = { + serialize: (value: T) => string | null; + deserialize: (raw: string | null) => T | null; +}; + +/** + * Parses a sessionStorage JSON string against `schema`. Returns `null` only for + * an absent value (`null` or empty string) — a legitimate miss. A present but + * unparseable or schema-invalid value is corrupt and **throws** (`SyntaxError` + * from `JSON.parse` or `ZodError` from the schema); the seam that owns seeding + * (`createSessionScopedAtom`) catches it, so detecting corruption stays separate + * from deciding what to do about it. + */ +export function parseSessionJson( + raw: string | null, + schema: z.ZodType, +): T | null { + if (raw === null || raw === "") { + return null; + } + return schema.parse(JSON.parse(raw)); +} + +/** + * Creates an atom whose value is scoped to this browser tab. + * + * The value lives in sessionStorage so it survives a reload of this tab but + * never leaks to other tabs. Each write also updates a shared, persisted + * breadcrumb in localForage; that breadcrumb is read only once, here, to seed a + * fresh tab on cold start. + * + * Seeding order: this tab's sessionStorage value (warm reload) wins; otherwise + * the persisted breadcrumb (cold start) seeds it; otherwise `defaultValue`. A + * cold-start seed is claimed into this tab's sessionStorage so the tab owns that + * value: a later reload reads its own value back instead of re-seeding from a + * breadcrumb another tab may have since moved. + * + * @param key Shared storage key, used for both the per-tab sessionStorage value + * and the persisted localForage breadcrumb. + * @param defaultValue Seed when neither the session value nor the breadcrumb is + * present. + * @param codec Converts the value to and from the string sessionStorage holds. + * @param transform Normalizes a breadcrumb written by an older app version. Only + * the breadcrumb needs it: the per-tab value is validated by `codec` on read, so + * it cannot carry a retired shape, and `defaultValue` is already current. + * @param sessionStorage The per-tab storage backing. Injectable so multi-tab + * isolation can be tested with separate storages. + */ +export async function createSessionScopedAtom({ + key, + defaultValue, + codec, + transform, + sessionStorage = resolveSessionStorage(), +}: { + key: string; + defaultValue: T; + codec: SessionValueCodec; + transform?: ReadTransform; + sessionStorage?: Storage; +}) { + let seedValue = readSessionSeed(sessionStorage, key, codec); + if (seedValue === null) { + // Cold start: seed from the shared breadcrumb and claim it into this tab's + // sessionStorage, so a later reload reads this value back rather than + // re-seeding from a breadcrumb another tab may have since moved. + const breadcrumb = await localForage.getItem(key); + if (breadcrumb !== null) { + seedValue = transform ? transform(breadcrumb) : breadcrumb; + writeSession(sessionStorage, key, codec, seedValue); + } + } + + return createWriteThroughAtom( + seedValue ?? defaultValue, + // The per-tab sessionStorage value updates synchronously; the shared + // localForage breadcrumb is persisted through the queue so its outcome + // joins the global persistence status like any other IndexedDB write. + nextValue => { + writeSession(sessionStorage, key, codec, nextValue); + persistThroughQueue(key, async () => { + await localForage.setItem(key, nextValue); + }); + }, + `createSessionScopedAtom(${key})`, + ); +} + +/** + * Reads and decodes this tab's per-tab seed, recovering from a corrupt value. + * + * `codec.deserialize` throws on a present-but-invalid value (and reading + * sessionStorage itself can throw a `SecurityError` when DOM storage is + * blocked). Either is a recoverable miss: a stale or hand-edited per-tab value + * should not crash app startup, so it is logged and treated as absent, letting + * the caller fall through to the shared breadcrumb then the default. + */ +function readSessionSeed( + sessionStorage: Storage, + key: string, + codec: SessionValueCodec, +): T | null { + try { + return codec.deserialize(sessionStorage.getItem(key)); + } catch (error) { + logger.warn( + `Discarding corrupt per-tab value for "${key}"; falling back to the persisted breadcrumb.`, + error, + ); + return null; + } +} + +/** + * Serializes a value into this tab's sessionStorage, removing the key when the + * codec returns `null`. + * + * Only the storage call is guarded. It can throw `QuotaExceededError` once + * storage fills, or `SecurityError` where DOM storage is blocked, because + * `resolveSessionStorage` guards the initial access and not every later write. + * The atom has already updated in memory and the shared breadcrumb still + * persists through the queue, so a failed per-tab write costs this tab its + * warm-reload value and nothing more. Log and continue rather than letting the + * throw escape the Jotai setter and take down the React subtree that set it. + * + * A throw from `codec.serialize` is a defect rather than a storage condition, so + * it stays outside the `try` and propagates. + */ +function writeSession( + sessionStorage: Storage, + key: string, + codec: SessionValueCodec, + value: T, +) { + const serialized = codec.serialize(value); + try { + if (serialized === null) { + sessionStorage.removeItem(key); + } else { + sessionStorage.setItem(key, serialized); + } + } catch (error) { + logger.warn( + `Could not persist the per-tab value for "${key}"; this tab will re-seed from the breadcrumb on its next reload.`, + error, + ); + } +} diff --git a/packages/graph-explorer/src/core/StateProvider/storageAtoms.ts b/packages/graph-explorer/src/core/StateProvider/storageAtoms.ts index 433c489b4..c5f99aa23 100644 --- a/packages/graph-explorer/src/core/StateProvider/storageAtoms.ts +++ b/packages/graph-explorer/src/core/StateProvider/storageAtoms.ts @@ -11,14 +11,17 @@ import { createActiveConfigurationAtom } from "./activeConnectionStorage"; import { atomWithLocalForage, reconcileMapByKey } from "./atomWithLocalForage"; import { defaultGraphViewLayout, + graphViewLayoutCodec, transformGraphViewLayout, } from "./graphViewLayoutDefaults"; import { runUserLayoutMigration } from "./migrateUserLayout"; import { runUserStylingMigration } from "./migrateUserStyling"; import { defaultSchemaViewLayout, + schemaViewLayoutCodec, transformSchemaViewLayout, } from "./schemaViewLayoutDefaults"; +import { createSessionScopedAtom } from "./sessionScopedStorage"; import { transformVertexStyles } from "./vertexStylesTransform"; // Run migrations before the atoms preload so they read the migrated data. @@ -99,10 +102,18 @@ const [ new Map(), { reconcile: reconcileMapByKey }, ), - atomWithLocalForage("graph-view-layout", defaultGraphViewLayout, { + // Layout is per-tab: each tab keeps its own sidebar/toggle state in + // sessionStorage, with a shared localForage breadcrumb seeding a fresh tab. + createSessionScopedAtom({ + key: "graph-view-layout", + defaultValue: defaultGraphViewLayout, + codec: graphViewLayoutCodec, transform: transformGraphViewLayout, }), - atomWithLocalForage("schema-view-layout", defaultSchemaViewLayout, { + createSessionScopedAtom({ + key: "schema-view-layout", + defaultValue: defaultSchemaViewLayout, + codec: schemaViewLayoutCodec, transform: transformSchemaViewLayout, }), /** Stores the graph session data for each connection. */