From b9e3ee448b1ab5aed4b15a2f72e8e0a1f44e80f4 Mon Sep 17 00:00:00 2001 From: Kris McGinnes Date: Mon, 29 Jun 2026 20:42:51 -0500 Subject: [PATCH 01/14] Scope graph-view and schema-view layouts to the browser tab The two view-layout atoms shared one localForage value across tabs, so opening the app in a second tab clobbered the first tab's sidebar and toggle state (last-writer-wins). Extract the per-tab sessionStorage + shared localForage breadcrumb trick that createActiveConfigurationAtom() used into a generic createSessionScopedAtom, and refactor active-config onto it so the subtle seed logic lives in one named place. Point both layout atoms at it with zod-validated codecs co-located with each model: schema-view is plain JSON; graph-view serializes its activeToggles Set as an array. parseSessionJson() rejects a stale or hand-edited per-tab value with the wrong shape, falling through to the breadcrumb. The breadcrumb key/shape are unchanged, so existing stored layouts are reused with no migration. --- .../StateProvider/activeConnectionStorage.ts | 76 ++----- .../graphViewLayoutDefaults.test.ts | 37 +++- .../StateProvider/graphViewLayoutDefaults.ts | 52 ++++- .../schemaViewLayoutDefaults.test.ts | 32 +++ .../StateProvider/schemaViewLayoutDefaults.ts | 29 ++- .../sessionScopedStorage.test.ts | 205 ++++++++++++++++++ .../StateProvider/sessionScopedStorage.ts | 123 +++++++++++ .../src/core/StateProvider/storageAtoms.ts | 15 +- 8 files changed, 502 insertions(+), 67 deletions(-) create mode 100644 packages/graph-explorer/src/core/StateProvider/sessionScopedStorage.test.ts create mode 100644 packages/graph-explorer/src/core/StateProvider/sessionScopedStorage.ts 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..74d25a923 100644 --- a/packages/graph-explorer/src/core/StateProvider/graphViewLayoutDefaults.test.ts +++ b/packages/graph-explorer/src/core/StateProvider/graphViewLayoutDefaults.test.ts @@ -1,6 +1,8 @@ import { - type GraphViewLayout, + defaultGraphViewLayout, + graphViewLayoutCodec, transformGraphViewLayout, + type GraphViewLayout, } from "./graphViewLayoutDefaults"; /** @@ -57,3 +59,36 @@ 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 a missing or corrupt value as a miss", () => { + expect(graphViewLayoutCodec.deserialize(null)).toBeNull(); + expect(graphViewLayoutCodec.deserialize("{ not json")).toBeNull(); + expect(graphViewLayoutCodec.deserialize("{}")).toBeNull(); + }); +}); diff --git a/packages/graph-explorer/src/core/StateProvider/graphViewLayoutDefaults.ts b/packages/graph-explorer/src/core/StateProvider/graphViewLayoutDefaults.ts index 2a1e096a4..defd8afd0 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 @@ -44,6 +55,27 @@ 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), + tableView: z.object({ height: z.number() }).optional(), + detailsAutoOpenOnSelection: z.boolean().optional(), + }) + .transform( + (value): GraphViewLayout => ({ + ...value, + activeToggles: new Set(value.activeToggles), + }), + ); + /** Default height for the table view panel in pixels. */ export const DEFAULT_TABLE_VIEW_HEIGHT = 300; @@ -70,3 +102,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, serializedGraphViewLayoutSchema), +}; diff --git a/packages/graph-explorer/src/core/StateProvider/schemaViewLayoutDefaults.test.ts b/packages/graph-explorer/src/core/StateProvider/schemaViewLayoutDefaults.test.ts index 1f4c86900..c61e91f6f 100644 --- a/packages/graph-explorer/src/core/StateProvider/schemaViewLayoutDefaults.test.ts +++ b/packages/graph-explorer/src/core/StateProvider/schemaViewLayoutDefaults.test.ts @@ -1,4 +1,6 @@ import { + defaultSchemaViewLayout, + schemaViewLayoutCodec, transformSchemaViewLayout, type SchemaViewLayout, } from "./schemaViewLayoutDefaults"; @@ -53,3 +55,33 @@ 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 a missing or corrupt value as a miss", () => { + expect(schemaViewLayoutCodec.deserialize(null)).toBeNull(); + expect(schemaViewLayoutCodec.deserialize("{ not json")).toBeNull(); + expect(schemaViewLayoutCodec.deserialize("{}")).toBeNull(); + }); +}); 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..60dfb8a30 --- /dev/null +++ b/packages/graph-explorer/src/core/StateProvider/sessionScopedStorage.test.ts @@ -0,0 +1,205 @@ +import { createStore } from "jotai"; +import localForage from "localforage"; +import { beforeEach, describe, expect, test } from "vitest"; +import { z } from "zod"; + +import { persistenceStatusStore } from "./persistence"; +import { createInMemorySessionStorage } from "./safeSessionStorage"; +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"; + +/** + * Simulates one browser tab over the counter atom: its own Jotai store and + * sessionStorage (per-tab), all tabs sharing the one fake-indexeddb. Mirrors + * the active-connection test harness. + */ +async function openTab() { + const sessionStorage = createInMemorySessionStorage(); + let store = createStore(); + let atom = await createSessionScopedAtom({ + key: KEY, + defaultValue: { count: 0 }, + codec: counterCodec, + sessionStorage, + }); + return { + read: () => store.get(atom), + write: (value: Counter) => { + store.set(atom, value); + return persistenceStatusStore.waitForIdle(); + }, + session: () => sessionStorage.getItem(KEY), + reload: async () => { + store = createStore(); + atom = await createSessionScopedAtom({ + key: KEY, + defaultValue: { count: 0 }, + codec: counterCodec, + sessionStorage, + }); + }, + }; +} + +describe("createSessionScopedAtom", () => { + beforeEach(async () => { + await localForage.clear(); + }); + + 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("treats a corrupt session value as a miss 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 }); + }); + + 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", () => { + beforeEach(async () => { + await localForage.clear(); + }); + + 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 }); + }); +}); 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..05ad527d9 --- /dev/null +++ b/packages/graph-explorer/src/core/StateProvider/sessionScopedStorage.ts @@ -0,0 +1,123 @@ +import type { z } from "zod"; + +import localForage from "localforage"; + +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` when the stored string is missing or corrupt, so + * seeding falls through to the breadcrumb rather than adopting a bad value. + */ +export type SessionValueCodec = { + serialize: (value: T) => string | null; + deserialize: (raw: string | null) => T | null; +}; + +/** + * Parses a sessionStorage JSON string against `schema`, returning `null` for a + * missing, unparseable, or schema-invalid value so the caller treats it as a + * miss. This keeps a stale or hand-edited per-tab value from seeding the atom + * with the wrong shape. + */ +export function parseSessionJson( + raw: string | null, + schema: z.ZodType, +): T | null { + if (raw === null || raw === "") { + return null; + } + try { + const result = schema.safeParse(JSON.parse(raw)); + return result.success ? result.data : null; + } catch { + return null; + } +} + +/** + * 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 = codec.deserialize(sessionStorage.getItem(key)); + 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.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})`, + ); +} + +/** 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 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. */ From d76ef52efb9e24066c130028556f67b395749c60 Mon Sep 17 00:00:00 2001 From: Kris McGinnes Date: Tue, 30 Jun 2026 11:24:37 -0500 Subject: [PATCH 02/14] Document per-tab storage scope and cover the Set-claim path Add an ADR recording the per-tab session-scoped storage primitive: the three named cross-tab scopes (per-tab / shared-reconciled / shared-blind-write), why layout is per-tab, and how it relates to the active-connection and per-key-diff-merge ADRs and spike #1876. Note in the per-key-diff-merge ADR that layout has since moved from a shared scalar to per-tab session scope, so it is no longer a standing example of a shared blind-write scalar. Add a test exercising createSessionScopedAtom with the real graphViewLayoutCodec over the cold-start claim path: a breadcrumb holding a native activeToggles Set is seeded as a Set and claimed into sessionStorage as its array-serialized form. Previously the generic helper and the Set-bearing codec were only tested in isolation. --- ...key-diff-merge-cross-tab-reconciliation.md | 2 +- ...er-tab-session-scoped-storage-primitive.md | 42 +++++++++++++++ .../sessionScopedStorage.test.ts | 51 +++++++++++++++++++ 3 files changed, 94 insertions(+), 1 deletion(-) create mode 100644 docs/adr/20260630-per-tab-session-scoped-storage-primitive.md 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..8668a0aec --- /dev/null +++ b/docs/adr/20260630-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, 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` validates the parsed string with **zod** and returns `null` for a missing, unparseable, or wrong-shape value, so a stale or hand-edited per-tab value falls through to the breadcrumb instead of seeding a bad shape. 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` returning `null` falls through to the breadcrumb then the default; 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/sessionScopedStorage.test.ts b/packages/graph-explorer/src/core/StateProvider/sessionScopedStorage.test.ts index 60dfb8a30..2eeee7bc0 100644 --- a/packages/graph-explorer/src/core/StateProvider/sessionScopedStorage.test.ts +++ b/packages/graph-explorer/src/core/StateProvider/sessionScopedStorage.test.ts @@ -3,6 +3,11 @@ import localForage from "localforage"; import { beforeEach, describe, expect, test } from "vitest"; import { z } from "zod"; +import { + defaultGraphViewLayout, + type GraphViewLayout, + graphViewLayoutCodec, +} from "./graphViewLayoutDefaults"; import { persistenceStatusStore } from "./persistence"; import { createInMemorySessionStorage } from "./safeSessionStorage"; import { @@ -203,3 +208,49 @@ describe("createSessionScopedAtom across tabs", () => { 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"; + + beforeEach(async () => { + await localForage.clear(); + }); + + 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); + }); +}); From 8ad67b07b5b54b694c2848ac855c0d7eba835be6 Mon Sep 17 00:00:00 2001 From: Kris McGinnes Date: Mon, 21 Sep 2026 10:12:59 -0500 Subject: [PATCH 03/14] Throw on a corrupt per-tab value and recover at the seam parseSessionJson swallowed both JSON and schema-validation errors into an indistinguishable null, hiding corruption and conflating it with a legitimate absent value. Separate detecting corruption from deciding what to do about it: deserialize now returns null only for an absent value and throws (SyntaxError or ZodError) on a present-but-invalid one. createSessionScopedAtom owns seeding policy, so it is the seam that catches: a corrupt per-tab value (or a sessionStorage read that throws a SecurityError when DOM storage is blocked) is logged and treated as a miss, falling through to the breadcrumb then the default rather than crashing app startup. Update the per-tab-storage ADR to describe the throw-and-recover flow. --- ...er-tab-session-scoped-storage-primitive.md | 4 +- .../graphViewLayoutDefaults.test.ts | 16 +++++-- .../schemaViewLayoutDefaults.test.ts | 16 +++++-- .../sessionScopedStorage.test.ts | 28 ++++++++++- .../StateProvider/sessionScopedStorage.ts | 46 ++++++++++++++----- 5 files changed, 89 insertions(+), 21 deletions(-) diff --git a/docs/adr/20260630-per-tab-session-scoped-storage-primitive.md b/docs/adr/20260630-per-tab-session-scoped-storage-primitive.md index 8668a0aec..e123a5ca7 100644 --- a/docs/adr/20260630-per-tab-session-scoped-storage-primitive.md +++ b/docs/adr/20260630-per-tab-session-scoped-storage-primitive.md @@ -22,7 +22,7 @@ Extract the per-tab + breadcrumb mechanism into one primitive, `createSessionSco `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` validates the parsed string with **zod** and returns `null` for a missing, unparseable, or wrong-shape value, so a stale or hand-edited per-tab value falls through to the breadcrumb instead of seeding a bad shape. 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. +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 @@ -36,7 +36,7 @@ A per-tab value crosses two backings with different serialization needs, so the - **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` returning `null` falls through to the breadcrumb then the default; breadcrumb **write** failures still surface through the persistence-status path (`storage-layer-owns-persistence-failure`). Appropriate for non-critical view preferences. +- **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/graphViewLayoutDefaults.test.ts b/packages/graph-explorer/src/core/StateProvider/graphViewLayoutDefaults.test.ts index 74d25a923..98dec22c3 100644 --- a/packages/graph-explorer/src/core/StateProvider/graphViewLayoutDefaults.test.ts +++ b/packages/graph-explorer/src/core/StateProvider/graphViewLayoutDefaults.test.ts @@ -1,3 +1,5 @@ +import { z } from "zod"; + import { defaultGraphViewLayout, graphViewLayoutCodec, @@ -86,9 +88,17 @@ describe("graphViewLayoutCodec", () => { ).toStrictEqual(defaultGraphViewLayout); }); - test("treats a missing or corrupt value as a miss", () => { + test("treats an absent value as a miss", () => { expect(graphViewLayoutCodec.deserialize(null)).toBeNull(); - expect(graphViewLayoutCodec.deserialize("{ not json")).toBeNull(); - expect(graphViewLayoutCodec.deserialize("{}")).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/schemaViewLayoutDefaults.test.ts b/packages/graph-explorer/src/core/StateProvider/schemaViewLayoutDefaults.test.ts index c61e91f6f..9c23d84a8 100644 --- a/packages/graph-explorer/src/core/StateProvider/schemaViewLayoutDefaults.test.ts +++ b/packages/graph-explorer/src/core/StateProvider/schemaViewLayoutDefaults.test.ts @@ -1,3 +1,5 @@ +import { z } from "zod"; + import { defaultSchemaViewLayout, schemaViewLayoutCodec, @@ -79,9 +81,17 @@ describe("schemaViewLayoutCodec", () => { ).toStrictEqual(defaultSchemaViewLayout); }); - test("treats a missing or corrupt value as a miss", () => { + test("treats an absent value as a miss", () => { expect(schemaViewLayoutCodec.deserialize(null)).toBeNull(); - expect(schemaViewLayoutCodec.deserialize("{ not json")).toBeNull(); - expect(schemaViewLayoutCodec.deserialize("{}")).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/sessionScopedStorage.test.ts b/packages/graph-explorer/src/core/StateProvider/sessionScopedStorage.test.ts index 2eeee7bc0..5bb5f1c66 100644 --- a/packages/graph-explorer/src/core/StateProvider/sessionScopedStorage.test.ts +++ b/packages/graph-explorer/src/core/StateProvider/sessionScopedStorage.test.ts @@ -1,8 +1,10 @@ import { createStore } from "jotai"; import localForage from "localforage"; -import { beforeEach, describe, expect, test } from "vitest"; +import { beforeEach, describe, expect, test, vi } from "vitest"; import { z } from "zod"; +import { logger } from "@/utils"; + import { defaultGraphViewLayout, type GraphViewLayout, @@ -113,7 +115,7 @@ describe("createSessionScopedAtom", () => { expect(store.get(atom)).toStrictEqual({ count: 42 }); }); - test("treats a corrupt session value as a miss and falls back to the breadcrumb", async () => { + 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"); @@ -127,6 +129,28 @@ describe("createSessionScopedAtom", () => { 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 () => { diff --git a/packages/graph-explorer/src/core/StateProvider/sessionScopedStorage.ts b/packages/graph-explorer/src/core/StateProvider/sessionScopedStorage.ts index 05ad527d9..c58a2093d 100644 --- a/packages/graph-explorer/src/core/StateProvider/sessionScopedStorage.ts +++ b/packages/graph-explorer/src/core/StateProvider/sessionScopedStorage.ts @@ -2,6 +2,8 @@ import type { z } from "zod"; import localForage from "localforage"; +import { logger } from "@/utils"; + import type { ReadTransform } from "./atomWithLocalForage"; import { persistThroughQueue } from "./persistence"; @@ -24,10 +26,12 @@ export type SessionValueCodec = { }; /** - * Parses a sessionStorage JSON string against `schema`, returning `null` for a - * missing, unparseable, or schema-invalid value so the caller treats it as a - * miss. This keeps a stale or hand-edited per-tab value from seeding the atom - * with the wrong shape. + * 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, @@ -36,12 +40,7 @@ export function parseSessionJson( if (raw === null || raw === "") { return null; } - try { - const result = schema.safeParse(JSON.parse(raw)); - return result.success ? result.data : null; - } catch { - return null; - } + return schema.parse(JSON.parse(raw)); } /** @@ -82,7 +81,7 @@ export async function createSessionScopedAtom({ transform?: ReadTransform; sessionStorage?: Storage; }) { - let seedValue = codec.deserialize(sessionStorage.getItem(key)); + 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 @@ -109,6 +108,31 @@ export async function createSessionScopedAtom({ ); } +/** + * 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, From a47a44edf5f946ade654be224506d49af20979ea Mon Sep 17 00:00:00 2001 From: Kris McGinnes Date: Tue, 30 Jun 2026 17:20:46 -0500 Subject: [PATCH 04/14] Tighten layout codecs and extend cross-tab test coverage - Move the graphViewLayout activeToggles Set rebuild from an object-level transform onto the activeToggles field, so the transform sits on the field it converts and the object schema infers the runtime shape. - Correct the SessionValueCodec.deserialize contract doc: it returns null only for an absent value and throws on a corrupt one, which the seam catches. Do not swallow errors in the codec. - Drop the unused session() accessor from the test tab harness. - Add cross-tab coverage for both layout codecs through the real codecs (graph view proves the activeToggles Set survives the array round-trip across a write-then-cold-start sequence; schema view covers isolation and cold-start seeding), matching the active-connection assurances. --- .../StateProvider/graphViewLayoutDefaults.ts | 23 +-- .../sessionScopedStorage.test.ts | 165 +++++++++++++++--- .../StateProvider/sessionScopedStorage.ts | 7 +- 3 files changed, 151 insertions(+), 44 deletions(-) diff --git a/packages/graph-explorer/src/core/StateProvider/graphViewLayoutDefaults.ts b/packages/graph-explorer/src/core/StateProvider/graphViewLayoutDefaults.ts index defd8afd0..ce0ba4d8e 100644 --- a/packages/graph-explorer/src/core/StateProvider/graphViewLayoutDefaults.ts +++ b/packages/graph-explorer/src/core/StateProvider/graphViewLayoutDefaults.ts @@ -61,20 +61,15 @@ export type GraphViewLayout = { * 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), - tableView: z.object({ height: z.number() }).optional(), - detailsAutoOpenOnSelection: z.boolean().optional(), - }) - .transform( - (value): GraphViewLayout => ({ - ...value, - activeToggles: new Set(value.activeToggles), - }), - ); +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; diff --git a/packages/graph-explorer/src/core/StateProvider/sessionScopedStorage.test.ts b/packages/graph-explorer/src/core/StateProvider/sessionScopedStorage.test.ts index 5bb5f1c66..6679007c4 100644 --- a/packages/graph-explorer/src/core/StateProvider/sessionScopedStorage.test.ts +++ b/packages/graph-explorer/src/core/StateProvider/sessionScopedStorage.test.ts @@ -12,6 +12,11 @@ import { } from "./graphViewLayoutDefaults"; import { persistenceStatusStore } from "./persistence"; import { createInMemorySessionStorage } from "./safeSessionStorage"; +import { + defaultSchemaViewLayout, + type SchemaViewLayout, + schemaViewLayoutCodec, +} from "./schemaViewLayoutDefaults"; import { createSessionScopedAtom, parseSessionJson, @@ -34,38 +39,47 @@ const counterCodec: SessionValueCodec = { const KEY = "test-counter"; /** - * Simulates one browser tab over the counter atom: its own Jotai store and - * sessionStorage (per-tab), all tabs sharing the one fake-indexeddb. Mirrors - * the active-connection test harness. + * 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. */ -async function openTab() { - const sessionStorage = createInMemorySessionStorage(); - let store = createStore(); - let atom = await createSessionScopedAtom({ - key: KEY, - defaultValue: { count: 0 }, - codec: counterCodec, - sessionStorage, - }); - return { - read: () => store.get(atom), - write: (value: Counter) => { - store.set(atom, value); - return persistenceStatusStore.waitForIdle(); - }, - session: () => sessionStorage.getItem(KEY), - reload: async () => { - store = createStore(); - atom = await createSessionScopedAtom({ - key: KEY, - defaultValue: { count: 0 }, - codec: counterCodec, - sessionStorage, - }); - }, +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", () => { beforeEach(async () => { await localForage.clear(); @@ -278,3 +292,98 @@ describe("createSessionScopedAtom with the graph view layout codec", () => { ).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, + ); + + beforeEach(async () => { + await localForage.clear(); + }); + + 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); + }); +}); + +describe("schema view layout across tabs", () => { + const openSchemaViewTab = tabOpener( + "schema-view-layout", + defaultSchemaViewLayout, + schemaViewLayoutCodec, + ); + + beforeEach(async () => { + await localForage.clear(); + }); + + 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 index c58a2093d..c41a40a50 100644 --- a/packages/graph-explorer/src/core/StateProvider/sessionScopedStorage.ts +++ b/packages/graph-explorer/src/core/StateProvider/sessionScopedStorage.ts @@ -17,8 +17,11 @@ import { createWriteThroughAtom } from "./writeThroughAtom"; * * `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` when the stored string is missing or corrupt, so - * seeding falls through to the breadcrumb rather than adopting a bad value. + * `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; From ea899dbbaf2fc8d232889b5d549d10e2ccc649f3 Mon Sep 17 00:00:00 2001 From: Kris McGinnes Date: Fri, 10 Jul 2026 09:40:34 -0500 Subject: [PATCH 05/14] Drop redundant localForage.clear() from the session storage tests The global test setup already gives every test a fresh IndexedDB backend (dropInstance + new IDBFactory per test), so the per-describe beforeEach(localForage.clear()) blocks added no isolation. --- .../sessionScopedStorage.test.ts | 22 +------------------ 1 file changed, 1 insertion(+), 21 deletions(-) diff --git a/packages/graph-explorer/src/core/StateProvider/sessionScopedStorage.test.ts b/packages/graph-explorer/src/core/StateProvider/sessionScopedStorage.test.ts index 6679007c4..c1aca9ba7 100644 --- a/packages/graph-explorer/src/core/StateProvider/sessionScopedStorage.test.ts +++ b/packages/graph-explorer/src/core/StateProvider/sessionScopedStorage.test.ts @@ -1,6 +1,6 @@ import { createStore } from "jotai"; import localForage from "localforage"; -import { beforeEach, describe, expect, test, vi } from "vitest"; +import { describe, expect, test, vi } from "vitest"; import { z } from "zod"; import { logger } from "@/utils"; @@ -81,10 +81,6 @@ function tabOpener( const openTab = tabOpener(KEY, { count: 0 }, counterCodec); describe("createSessionScopedAtom", () => { - beforeEach(async () => { - await localForage.clear(); - }); - test("cold start seeds from the persisted breadcrumb and claims it into this tab", async () => { await localForage.setItem(KEY, { count: 7 }); const sessionStorage = createInMemorySessionStorage(); @@ -211,10 +207,6 @@ describe("createSessionScopedAtom", () => { }); describe("createSessionScopedAtom across tabs", () => { - beforeEach(async () => { - await localForage.clear(); - }); - test("writing in one tab does not change an already-open tab", async () => { const tabB = await openTab(); await tabB.write({ count: 2 }); @@ -255,10 +247,6 @@ describe("createSessionScopedAtom across tabs", () => { describe("createSessionScopedAtom with the graph view layout codec", () => { const LAYOUT_KEY = "graph-view-layout"; - beforeEach(async () => { - await localForage.clear(); - }); - test("cold start claims a Set-bearing breadcrumb into sessionStorage as its array form", async () => { const breadcrumb: GraphViewLayout = { activeSidebarItem: "filters", @@ -305,10 +293,6 @@ describe("graph view layout across tabs", () => { graphViewLayoutCodec, ); - beforeEach(async () => { - await localForage.clear(); - }); - test("changing layout in one tab does not change an already-open tab", async () => { const tabB = await openGraphViewTab(); const tabBLayout: GraphViewLayout = { @@ -352,10 +336,6 @@ describe("schema view layout across tabs", () => { schemaViewLayoutCodec, ); - beforeEach(async () => { - await localForage.clear(); - }); - test("changing layout in one tab does not change an already-open tab", async () => { const tabB = await openSchemaViewTab(); const tabBLayout: SchemaViewLayout = { From 3c1fb70a117ef7e524d9da253294be54bc5cb240 Mon Sep 17 00:00:00 2001 From: Kris McGinnes Date: Mon, 21 Sep 2026 10:21:03 -0500 Subject: [PATCH 06/14] Cover the breadcrumb read transform in the session-scoped atom createSessionScopedAtom runs a ReadTransform on the shared breadcrumb before claiming it, which is the only path a shape retired by a newer app version can arrive by. Cover that it normalizes and claims the normalized value, that it never touches this tab's own zod-validated session value or the default, and that a stored nodes-styling sidebar item lands on the combined styles panel. --- .../sessionScopedStorage.test.ts | 69 +++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/packages/graph-explorer/src/core/StateProvider/sessionScopedStorage.test.ts b/packages/graph-explorer/src/core/StateProvider/sessionScopedStorage.test.ts index c1aca9ba7..76506bc20 100644 --- a/packages/graph-explorer/src/core/StateProvider/sessionScopedStorage.test.ts +++ b/packages/graph-explorer/src/core/StateProvider/sessionScopedStorage.test.ts @@ -9,6 +9,7 @@ import { defaultGraphViewLayout, type GraphViewLayout, graphViewLayoutCodec, + transformGraphViewLayout, } from "./graphViewLayoutDefaults"; import { persistenceStatusStore } from "./persistence"; import { createInMemorySessionStorage } from "./safeSessionStorage"; @@ -204,6 +205,53 @@ describe("createSessionScopedAtom", () => { 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", () => { @@ -279,6 +327,27 @@ describe("createSessionScopedAtom with the graph view layout codec", () => { 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"); + }); }); // The two layout atoms ride the same primitive as the active connection, so From e6e4cc7dc22a6c2e62fde75b51dfc12be0bc70c0 Mon Sep 17 00:00:00 2001 From: Kris McGinnes Date: Mon, 21 Sep 2026 12:48:40 -0500 Subject: [PATCH 07/14] Tolerate a failing per-tab sessionStorage write resolveSessionStorage only guards the initial access, so a later setItem could still throw QuotaExceededError when storage fills, or SecurityError where DOM storage is blocked. That throw escaped the Jotai setter and would take down the React subtree that set the atom. writeSession now owns serialization and swallows a write failure with a warning. The atom has already updated in memory and the shared breadcrumb still persists through the queue, so the only cost is this tab's warm-reload value. --- .../sessionScopedStorage.test.ts | 24 ++++++++++++ .../StateProvider/sessionScopedStorage.ts | 38 ++++++++++++++----- 2 files changed, 53 insertions(+), 9 deletions(-) diff --git a/packages/graph-explorer/src/core/StateProvider/sessionScopedStorage.test.ts b/packages/graph-explorer/src/core/StateProvider/sessionScopedStorage.test.ts index 76506bc20..63720550a 100644 --- a/packages/graph-explorer/src/core/StateProvider/sessionScopedStorage.test.ts +++ b/packages/graph-explorer/src/core/StateProvider/sessionScopedStorage.test.ts @@ -182,6 +182,30 @@ describe("createSessionScopedAtom", () => { 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("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. diff --git a/packages/graph-explorer/src/core/StateProvider/sessionScopedStorage.ts b/packages/graph-explorer/src/core/StateProvider/sessionScopedStorage.ts index c41a40a50..3ee3fc88f 100644 --- a/packages/graph-explorer/src/core/StateProvider/sessionScopedStorage.ts +++ b/packages/graph-explorer/src/core/StateProvider/sessionScopedStorage.ts @@ -92,7 +92,7 @@ export async function createSessionScopedAtom({ const breadcrumb = await localForage.getItem(key); if (breadcrumb !== null) { seedValue = transform ? transform(breadcrumb) : breadcrumb; - writeSession(sessionStorage, key, codec.serialize(seedValue)); + writeSession(sessionStorage, key, codec, seedValue); } } @@ -102,7 +102,7 @@ export async function createSessionScopedAtom({ // 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)); + writeSession(sessionStorage, key, codec, nextValue); persistThroughQueue(key, async () => { await localForage.setItem(key, nextValue); }); @@ -136,15 +136,35 @@ function readSessionSeed( } } -/** Applies a serialized value to sessionStorage, removing the key for `null`. */ -function writeSession( +/** + * Serializes a value into this tab's sessionStorage, removing the key when the + * codec returns `null`. + * + * A write can throw `QuotaExceededError` once storage fills, or `SecurityError` + * where DOM storage is blocked — `resolveSessionStorage` only guards the initial + * access, 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 the atom. + */ +function writeSession( sessionStorage: Storage, key: string, - serialized: string | null, + codec: SessionValueCodec, + value: T, ) { - if (serialized === null) { - sessionStorage.removeItem(key); - } else { - sessionStorage.setItem(key, serialized); + try { + const serialized = codec.serialize(value); + 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, + ); } } From 10dce520f405919bb526e0cbf484ba6ae050130c Mon Sep 17 00:00:00 2001 From: Kris McGinnes Date: Mon, 21 Sep 2026 12:49:39 -0500 Subject: [PATCH 08/14] Add layout and storage-scope terms to the domain glossary The per-tab storage ADR named the three cross-tab scopes but the glossary never picked them up, leaving Graph View Layout, Schema View Layout, and Storage Scope itself undefined. Also corrects the Graph Database entry, which still described layout as IndexedDB-persisted app state. --- CONTEXT.md | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/CONTEXT.md b/CONTEXT.md index 9d42611ef..0671f300e 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**: @@ -85,6 +85,18 @@ _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) +**Graph View Layout**: +Per-tab UI state for the Graph View — active sidebar panel, sidebar width, active content toggles, table-view height, and the details-auto-open preference. A per-tab Storage Scope concept (`createSessionScopedAtom`, key `"graph-view-layout"`): it survives that tab's reload but not its close, and a fresh tab cold-starts from the shared breadcrumb. +_Avoid_: Graph preferences, graph settings + +**Schema View Layout**: +Per-tab UI state for the Schema View — active sidebar panel, sidebar width, and the details-auto-open preference. Same per-tab Storage Scope as Graph View Layout (key `"schema-view-layout"`). +_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** (`createSessionScopedAtom`) keeps the live value in sessionStorage with a shared localForage breadcrumb seeding a fresh tab on cold start, so tabs diverge — it backs Active Connection, Graph View Layout, and Schema View Layout; **shared-reconciled** (`atomWithLocalForage` with `reconcileMapByKey`) merges Map-keyed collections per key across tabs — it backs Connections, Schema, Vertex and Edge Styles, and Sessions; **shared-blind-write** (`atomWithLocalForage` with no reconciler) writes the whole value each time, for scalars where tabs need not diverge. See the `per-tab-session-scoped-storage-primitive` and `per-key-diff-merge-cross-tab-reconciliation` ADRs. +_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 +161,8 @@ _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 **Graph View Layout** and **Schema View Layout**, the same divergence as **Active Connection** +- Every persisted atom picks one of the three **Storage Scopes** at creation ## Example dialogue From f42f0e789b988ad92cd5ed3345081850df352f69 Mon Sep 17 00:00:00 2001 From: Kris McGinnes Date: Mon, 21 Sep 2026 13:47:54 -0500 Subject: [PATCH 09/14] Pin the claimed per-tab layout to its literal serialized form The assertion computed its expected value with the same serialize call it was testing, so a serialize that dropped a field would have changed both sides together and still passed. Write the on-disk JSON out instead. --- .../core/StateProvider/sessionScopedStorage.test.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/packages/graph-explorer/src/core/StateProvider/sessionScopedStorage.test.ts b/packages/graph-explorer/src/core/StateProvider/sessionScopedStorage.test.ts index 63720550a..1033074d6 100644 --- a/packages/graph-explorer/src/core/StateProvider/sessionScopedStorage.test.ts +++ b/packages/graph-explorer/src/core/StateProvider/sessionScopedStorage.test.ts @@ -342,11 +342,18 @@ describe("createSessionScopedAtom with the graph view layout codec", () => { 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. + // 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( - graphViewLayoutCodec.serialize(breadcrumb), + 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); From f959849e259a14f2ae5620312830c4504b082b69 Mon Sep 17 00:00:00 2001 From: Kris McGinnes Date: Mon, 21 Sep 2026 13:47:57 -0500 Subject: [PATCH 10/14] Point the storage docs at the per-tab factory Moving both layouts onto createSessionScopedAtom left several docs describing the shape it replaced. The backward-compatibility rule in testing.md only triggered on atomWithLocalForage, so it no longer covered either layout type it was written for, and its cross-tab example is typed to that factory and cannot open a per-tab atom. product.md still listed layout as IndexedDB-persisted. Three ADRs still said the per-tab mechanism and the read transform live where this branch moved them from. Also trims the Storage Scope glossary entry to the definition, since it had duplicated the ADR's per-scope atom inventory and the two copies already disagreed, and corrects that inventory to the glossary's own term for Styles. Drops the stale "one set of tests" claim from the new ADR: the active-connection tests stay as behavior-preservation evidence. --- CONTEXT.md | 6 +++--- docs/adr/20260618-per-tab-active-connection.md | 2 +- docs/adr/20260619-storage-layer-owns-persistence-failure.md | 2 +- .../20260630-per-tab-session-scoped-storage-primitive.md | 4 ++-- .../20260709-read-time-transform-for-persisted-values.md | 1 + docs/agents/product.md | 3 ++- docs/agents/testing.md | 5 +++-- 7 files changed, 13 insertions(+), 10 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index 0671f300e..483779378 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -86,15 +86,15 @@ Visual representation of the Schema — shows vertex types and their edge connec _Avoid_: Schema Explorer (legacy route name) **Graph View Layout**: -Per-tab UI state for the Graph View — active sidebar panel, sidebar width, active content toggles, table-view height, and the details-auto-open preference. A per-tab Storage Scope concept (`createSessionScopedAtom`, key `"graph-view-layout"`): it survives that tab's reload but not its close, and a fresh tab cold-starts from the shared breadcrumb. +Per-tab UI state for the Graph View — active sidebar panel, sidebar width, active content toggles, table-view height, and the details-auto-open preference. A per-tab Storage Scope concept: it survives that tab's reload but not its close, and a fresh tab starts from the layout most recently used. _Avoid_: Graph preferences, graph settings **Schema View Layout**: -Per-tab UI state for the Schema View — active sidebar panel, sidebar width, and the details-auto-open preference. Same per-tab Storage Scope as Graph View Layout (key `"schema-view-layout"`). +Per-tab UI state for the Schema View — active sidebar panel, sidebar width, and the details-auto-open preference. Same per-tab Storage Scope as Graph View Layout. _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** (`createSessionScopedAtom`) keeps the live value in sessionStorage with a shared localForage breadcrumb seeding a fresh tab on cold start, so tabs diverge — it backs Active Connection, Graph View Layout, and Schema View Layout; **shared-reconciled** (`atomWithLocalForage` with `reconcileMapByKey`) merges Map-keyed collections per key across tabs — it backs Connections, Schema, Vertex and Edge Styles, and Sessions; **shared-blind-write** (`atomWithLocalForage` with no reconciler) writes the whole value each time, for scalars where tabs need not diverge. See the `per-tab-session-scoped-storage-primitive` and `per-key-diff-merge-cross-tab-reconciliation` ADRs. +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**: 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/20260630-per-tab-session-scoped-storage-primitive.md b/docs/adr/20260630-per-tab-session-scoped-storage-primitive.md index e123a5ca7..1a6fbb800 100644 --- a/docs/adr/20260630-per-tab-session-scoped-storage-primitive.md +++ b/docs/adr/20260630-per-tab-session-scoped-storage-primitive.md @@ -17,10 +17,10 @@ That made three distinct cross-tab storage behaviors in the codebase, only two o 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-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 with one set of tests. +`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. 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/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. From 1bce1e9d0ac82ceaaaf2e86ec76893fd8ac22d92 Mon Sep 17 00:00:00 2001 From: Kris McGinnes Date: Mon, 21 Sep 2026 17:21:40 -0500 Subject: [PATCH 11/14] Let a throwing codec escape the per-tab write writeSession guarded codec.serialize alongside the storage call, so a codec defect was logged as a write failure and the per-tab layer silently stopped working for the rest of the tab's life. A serialize throw is a defect, not a storage condition. Only setItem and removeItem are guarded now. --- .../sessionScopedStorage.test.ts | 21 +++++++++++++++++++ .../StateProvider/sessionScopedStorage.ts | 19 ++++++++++------- 2 files changed, 32 insertions(+), 8 deletions(-) diff --git a/packages/graph-explorer/src/core/StateProvider/sessionScopedStorage.test.ts b/packages/graph-explorer/src/core/StateProvider/sessionScopedStorage.test.ts index 1033074d6..79efa88c5 100644 --- a/packages/graph-explorer/src/core/StateProvider/sessionScopedStorage.test.ts +++ b/packages/graph-explorer/src/core/StateProvider/sessionScopedStorage.test.ts @@ -206,6 +206,27 @@ describe("createSessionScopedAtom", () => { 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. diff --git a/packages/graph-explorer/src/core/StateProvider/sessionScopedStorage.ts b/packages/graph-explorer/src/core/StateProvider/sessionScopedStorage.ts index 3ee3fc88f..c58c2eae6 100644 --- a/packages/graph-explorer/src/core/StateProvider/sessionScopedStorage.ts +++ b/packages/graph-explorer/src/core/StateProvider/sessionScopedStorage.ts @@ -140,13 +140,16 @@ function readSessionSeed( * Serializes a value into this tab's sessionStorage, removing the key when the * codec returns `null`. * - * A write can throw `QuotaExceededError` once storage fills, or `SecurityError` - * where DOM storage is blocked — `resolveSessionStorage` only guards the initial - * access, 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 the atom. + * 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, @@ -154,8 +157,8 @@ function writeSession( codec: SessionValueCodec, value: T, ) { + const serialized = codec.serialize(value); try { - const serialized = codec.serialize(value); if (serialized === null) { sessionStorage.removeItem(key); } else { From 1e3b15ac41c37edd203882a9dc16491a7231d833 Mon Sep 17 00:00:00 2001 From: Kris McGinnes Date: Mon, 21 Sep 2026 17:21:50 -0500 Subject: [PATCH 12/14] Derive GraphViewLayout from its schema The type and the parser were two hand-written declarations of one shape, and nothing tied them together: adding an optional field to the type compiled clean, then zod stripped it on read. The field survived in the breadcrumb through structured clone but vanished on every warm reload of the tab that set it. The schema is now the only declaration, matching what schemaViewLayoutSchema already did for the schema view. --- .../StateProvider/graphViewLayoutDefaults.ts | 24 +++++++------------ 1 file changed, 9 insertions(+), 15 deletions(-) diff --git a/packages/graph-explorer/src/core/StateProvider/graphViewLayoutDefaults.ts b/packages/graph-explorer/src/core/StateProvider/graphViewLayoutDefaults.ts index ce0ba4d8e..72e4cb657 100644 --- a/packages/graph-explorer/src/core/StateProvider/graphViewLayoutDefaults.ts +++ b/packages/graph-explorer/src/core/StateProvider/graphViewLayoutDefaults.ts @@ -46,22 +46,15 @@ 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; -}; - /** - * 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. + * 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 serializedGraphViewLayoutSchema = z.object({ +const graphViewLayoutSchema = z.object({ activeSidebarItem: graphViewSidebarItemSchema.nullable(), sidebar: z.object({ width: z.number() }), activeToggles: z @@ -70,6 +63,7 @@ const serializedGraphViewLayoutSchema = z.object({ 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; @@ -105,5 +99,5 @@ export const graphViewLayoutCodec: SessionValueCodec = { ...layout, activeToggles: [...layout.activeToggles], }), - deserialize: raw => parseSessionJson(raw, serializedGraphViewLayoutSchema), + deserialize: raw => parseSessionJson(raw, graphViewLayoutSchema), }; From b328b5536fe4d43cc0162cc5df01aabb4a042284 Mon Sep 17 00:00:00 2001 From: Kris McGinnes Date: Mon, 21 Sep 2026 17:35:37 -0500 Subject: [PATCH 13/14] Split Layout from View Layout in the glossary "Layout" meant two things: the Cytoscape algorithm that positions vertices, and the per-tab sidebar and toggle state this branch made per-tab. The bare word now belongs to the algorithm, and the per-tab state is a View Layout, which matches the graphViewLayout and schemaViewLayout identifiers. Also documents the per-tab scope for users in both feature docs, mirroring the wording connections.md already uses for the Active Connection, and dates the storage-scope ADR to when it landed so it sorts after the ADR it cites. --- CONTEXT.md | 17 +++++++++++++---- ...per-tab-session-scoped-storage-primitive.md} | 0 docs/features/graph-view.md | 2 ++ docs/features/schema-view.md | 2 +- 4 files changed, 16 insertions(+), 5 deletions(-) rename docs/adr/{20260630-per-tab-session-scoped-storage-primitive.md => 20260921-per-tab-session-scoped-storage-primitive.md} (100%) diff --git a/CONTEXT.md b/CONTEXT.md index 483779378..3cae700ab 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -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,12 +85,20 @@ _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**: -Per-tab UI state for the Graph View — active sidebar panel, sidebar width, active content toggles, table-view height, and the details-auto-open preference. A per-tab Storage Scope concept: it survives that tab's reload but not its close, and a fresh tab starts from the layout most recently used. +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**: -Per-tab UI state for the Schema View — active sidebar panel, sidebar width, and the details-auto-open preference. Same per-tab Storage Scope as Graph 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**: @@ -161,7 +169,8 @@ _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 **Graph View Layout** and **Schema View Layout**, the same divergence as **Active Connection** +- 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/20260630-per-tab-session-scoped-storage-primitive.md b/docs/adr/20260921-per-tab-session-scoped-storage-primitive.md similarity index 100% rename from docs/adr/20260630-per-tab-session-scoped-storage-primitive.md rename to docs/adr/20260921-per-tab-session-scoped-storage-primitive.md 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. From ff175ba293813b6d73832c6d5d1d146927d06b17 Mon Sep 17 00:00:00 2001 From: Kris McGinnes Date: Mon, 21 Sep 2026 18:49:01 -0500 Subject: [PATCH 14/14] Say what each layout cross-tab suite is for The shared comment claimed both layout suites existed because their codecs carry risk. That holds for graph view, whose activeToggles Set has to survive the array round trip, and not for schema view, whose codec is structurally the counter codec. Name what each one actually guards so neither reads as redundant with the other. --- .../core/StateProvider/sessionScopedStorage.test.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/graph-explorer/src/core/StateProvider/sessionScopedStorage.test.ts b/packages/graph-explorer/src/core/StateProvider/sessionScopedStorage.test.ts index 79efa88c5..90e209e47 100644 --- a/packages/graph-explorer/src/core/StateProvider/sessionScopedStorage.test.ts +++ b/packages/graph-explorer/src/core/StateProvider/sessionScopedStorage.test.ts @@ -402,11 +402,9 @@ describe("createSessionScopedAtom with the graph view layout codec", () => { }); }); -// 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. +// 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", @@ -450,6 +448,10 @@ describe("graph view layout across tabs", () => { }); }); +// 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",