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/20260630-per-tab-session-scoped-storage-primitive.md b/docs/adr/20260630-per-tab-session-scoped-storage-primitive.md new file mode 100644 index 000000000..d5c99131c --- /dev/null +++ b/docs/adr/20260630-per-tab-session-scoped-storage-primitive.md @@ -0,0 +1,41 @@ +# 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, User Preferences, 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 with one set of tests. + +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. +- **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/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 new file mode 100644 index 000000000..f7f856349 --- /dev/null +++ b/packages/graph-explorer/src/core/StateProvider/graphViewLayoutDefaults.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, test } from "vitest"; + +import { + defaultGraphViewLayout, + graphViewLayoutCodec, + type GraphViewLayout, +} from "./graphViewLayoutDefaults"; + +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", () => { + expect(() => graphViewLayoutCodec.deserialize("{ not json")).toThrow(); + expect(() => graphViewLayoutCodec.deserialize("{}")).toThrow(); + }); +}); diff --git a/packages/graph-explorer/src/core/StateProvider/graphViewLayoutDefaults.ts b/packages/graph-explorer/src/core/StateProvider/graphViewLayoutDefaults.ts index 812750127..ace5cff0f 100644 --- a/packages/graph-explorer/src/core/StateProvider/graphViewLayoutDefaults.ts +++ b/packages/graph-explorer/src/core/StateProvider/graphViewLayoutDefaults.ts @@ -1,9 +1,18 @@ +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", @@ -11,8 +20,10 @@ export const graphViewSidebarItems = [ "nodes-styling", "edges-styling", "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; /** Persisted layout preferences for the graph view. */ export type GraphViewLayout = { @@ -23,6 +34,22 @@ export type GraphViewLayout = { detailsAutoOpenOnSelection?: boolean; }; +/** + * The graph view layout as JSON holds it: `activeToggles` is an array because a + * `Set` does not survive `JSON.stringify`. The schema parses this shape and + * rebuilds the runtime {@link GraphViewLayout}, so a hand-edited or stale + * per-tab value with the wrong shape is rejected rather than seeding bad state. + */ +const serializedGraphViewLayoutSchema = 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(), +}); + /** Default height for the table view panel in pixels. */ export const DEFAULT_TABLE_VIEW_HEIGHT = 300; @@ -37,3 +64,13 @@ export const defaultGraphViewLayout: GraphViewLayout = { sidebar: { width: DEFAULT_SIDEBAR_WIDTH }, tableView: { height: DEFAULT_TABLE_VIEW_HEIGHT }, }; + +/** 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, serializedGraphViewLayoutSchema), +}; diff --git a/packages/graph-explorer/src/core/StateProvider/schemaViewLayoutDefaults.test.ts b/packages/graph-explorer/src/core/StateProvider/schemaViewLayoutDefaults.test.ts new file mode 100644 index 000000000..08c543d6b --- /dev/null +++ b/packages/graph-explorer/src/core/StateProvider/schemaViewLayoutDefaults.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, test } from "vitest"; + +import { + defaultSchemaViewLayout, + schemaViewLayoutCodec, + type SchemaViewLayout, +} from "./schemaViewLayoutDefaults"; + +describe("schemaViewLayoutCodec", () => { + test("round-trips a layout through serialize/deserialize", () => { + const layout: SchemaViewLayout = { + activeSidebarItem: "nodes-styling", + 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", () => { + expect(() => schemaViewLayoutCodec.deserialize("{ not json")).toThrow(); + expect(() => schemaViewLayoutCodec.deserialize("{}")).toThrow(); + }); +}); diff --git a/packages/graph-explorer/src/core/StateProvider/schemaViewLayoutDefaults.ts b/packages/graph-explorer/src/core/StateProvider/schemaViewLayoutDefaults.ts index c53462b08..b565d9f88 100644 --- a/packages/graph-explorer/src/core/StateProvider/schemaViewLayoutDefaults.ts +++ b/packages/graph-explorer/src/core/StateProvider/schemaViewLayoutDefaults.ts @@ -1,19 +1,28 @@ +import { z } from "zod"; + import { DEFAULT_SIDEBAR_WIDTH } from "./graphViewLayoutDefaults"; +import { + parseSessionJson, + type SessionValueCodec, +} from "./sessionScopedStorage"; /** Identifiers for the schema view sidebar panels. */ -export const schemaViewSidebarItems = [ +export const schemaViewSidebarItemSchema = z.enum([ "details", "nodes-styling", "edges-styling", -] as const; -export type SchemaViewSidebarItem = (typeof schemaViewSidebarItems)[number]; +]); +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 = { @@ -21,3 +30,9 @@ export const defaultSchemaViewLayout: SchemaViewLayout = { sidebar: { width: DEFAULT_SIDEBAR_WIDTH }, detailsAutoOpenOnSelection: true, }; + +/** 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..d36275b87 --- /dev/null +++ b/packages/graph-explorer/src/core/StateProvider/sessionScopedStorage.test.ts @@ -0,0 +1,369 @@ +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, +} 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("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 }); + }); +}); + +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, and a warm reload + // off it rebuilds the Set rather than seeding from the breadcrumb again. + expect(sessionStorage.getItem(LAYOUT_KEY)).toBe( + graphViewLayoutCodec.serialize(breadcrumb), + ); + expect( + graphViewLayoutCodec.deserialize(sessionStorage.getItem(LAYOUT_KEY)), + ).toStrictEqual(breadcrumb); + }); +}); + +// The two layout atoms ride the same primitive as the active connection, so +// they get the same multi-tab assurances active-connection has — proven +// through their real codecs, not the toy counter codec. The graph view codec +// is the interesting one: its activeToggles Set must survive the array +// serialization across a write-in-one-tab / cold-start-in-another sequence. +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: "edges-styling", + }); + + 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); + }); +}); + +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: "nodes-styling", + }; + await tabB.write(tabBLayout); + + const tabA = await openSchemaViewTab(); + await tabA.write({ + ...defaultSchemaViewLayout, + activeSidebarItem: "edges-styling", + }); + + 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: "edges-styling", + 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..5cdca911f --- /dev/null +++ b/packages/graph-explorer/src/core/StateProvider/sessionScopedStorage.ts @@ -0,0 +1,142 @@ +import type { z } from "zod"; + +import localForage from "localforage"; + +import { logger } from "@/utils"; + +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 sessionStorage The per-tab storage backing. Injectable so multi-tab + * isolation can be tested with separate storages. + */ +export async function createSessionScopedAtom({ + key, + defaultValue, + codec, + sessionStorage = resolveSessionStorage(), +}: { + key: string; + defaultValue: T; + codec: SessionValueCodec; + 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. + seedValue = await localForage.getItem(key); + if (seedValue !== null) { + writeSession(sessionStorage, key, codec.serialize(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.serialize(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; + } +} + +/** Applies a serialized value to sessionStorage, removing the key for `null`. */ +function writeSession( + sessionStorage: Storage, + key: string, + serialized: string | null, +) { + if (serialized === null) { + sessionStorage.removeItem(key); + } else { + sessionStorage.setItem(key, serialized); + } +} diff --git a/packages/graph-explorer/src/core/StateProvider/storageAtoms.ts b/packages/graph-explorer/src/core/StateProvider/storageAtoms.ts index f987a21e2..8b6d94eab 100644 --- a/packages/graph-explorer/src/core/StateProvider/storageAtoms.ts +++ b/packages/graph-explorer/src/core/StateProvider/storageAtoms.ts @@ -9,10 +9,17 @@ import type { SchemaStorageModel } from "./schema"; import { createActiveConfigurationAtom } from "./activeConnectionStorage"; import { atomWithLocalForage, reconcileMapByKey } from "./atomWithLocalForage"; -import { defaultGraphViewLayout } from "./graphViewLayoutDefaults"; +import { + defaultGraphViewLayout, + graphViewLayoutCodec, +} from "./graphViewLayoutDefaults"; import { runUserLayoutMigration } from "./migrateUserLayout"; import { runUserStylingMigration } from "./migrateUserStyling"; -import { defaultSchemaViewLayout } from "./schemaViewLayoutDefaults"; +import { + defaultSchemaViewLayout, + schemaViewLayoutCodec, +} from "./schemaViewLayoutDefaults"; +import { createSessionScopedAtom } from "./sessionScopedStorage"; // Run migrations before the atoms preload so they read the migrated data. // Each migration owns its own failure reporting (surfacing through the @@ -115,8 +122,18 @@ const [ new Map(), reconcileMapByKey, ), - atomWithLocalForage("graph-view-layout", defaultGraphViewLayout), - atomWithLocalForage("schema-view-layout", defaultSchemaViewLayout), + // 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, + }), + createSessionScopedAtom({ + key: "schema-view-layout", + defaultValue: defaultSchemaViewLayout, + codec: schemaViewLayoutCodec, + }), /** Stores the graph session data for each connection. */ atomWithLocalForage>( "graph-sessions",