Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
41 changes: 41 additions & 0 deletions docs/adr/20260630-per-tab-session-scoped-storage-primitive.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# Per-tab session-scoped storage as a reusable primitive

## Status

accepted

## Context

The per-tab Active Connection decision (`per-tab-active-connection`) solved one concept by hand: hold the live value in `sessionStorage`, keep the existing localForage key as a shared last-writer-wins breadcrumb, and **claim** the breadcrumb into `sessionStorage` on cold start so a reload reads the tab's own value back. That logic lived inline in `activeConnectionStorage.ts`.

Graph-view and schema-view layout (active sidebar tab, sidebar width, view toggles, the details-auto-open preference) had the same shape of problem as Active Connection, not the shape the reconciliation ADR addresses. Layout is a property of **what this tab is looking at**, not a global user preference: two tabs exploring different connections each want their own sidebar and toggle state. As shared `atomWithLocalForage` scalars they were last-writer-wins across tabs — a second tab's layout silently became the first tab's on its next cold start. Reconciliation (`per-key-diff-merge`) is the wrong tool: layout has no sibling entries to preserve, so a per-key merge is meaningless; what it wants is per-tab **divergence**, exactly like Active Connection.

That made three distinct cross-tab storage behaviors in the codebase, only two of them named, and the third (per-tab) implemented once as a one-off. Adding a second and third per-tab concept by copy-pasting the subtle seed-and-claim logic was the wrong move.

## Decision

Extract the per-tab + breadcrumb mechanism into one primitive, `createSessionScopedAtom<T>` (`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<T>`**: the breadcrumb keeps the native value (structured clone preserves a `Set`), while `sessionStorage` holds only strings. The codec's `deserialize` returns `null` only for an absent value (a legitimate miss) and validates a present value with **zod**, _throwing_ on an unparseable or wrong-shape value rather than swallowing it. Detecting corruption is thus separate from deciding what to do about it: the seam (`createSessionScopedAtom`) catches the throw, logs it, and treats it as a miss so a stale or hand-edited per-tab value falls through to the breadcrumb instead of seeding a bad shape or crashing startup. Graph-view layout's codec serializes its `activeToggles` `Set` as an array and rebuilds it on read via a zod `.transform`; schema-view layout is plain JSON. Active Connection's value is a bare id string, so its codec passes the string through and skips zod.

## Considered Options

- **Copy the seed-and-claim logic into each layout atom.** Rejected: duplicates the load-bearing, easy-to-get-subtly-wrong claim logic across three atoms, with three sets of near-identical tests.
- **Leave layout as shared (status quo).** Rejected: the clobber that motivated the per-tab Active Connection decision applies to layout for the same reason.
- **Reconcile layout per key (`atomWithLocalForage` + a reconciler).** Rejected: layout has no sibling entries; it wants per-tab divergence, not a merge. Reconciling it would reintroduce cross-tab coupling.
- **Per-tab + breadcrumb extracted to a primitive (chosen).** Names the third scope, removes the duplication, and unifies Active Connection onto it.

## Consequences

- **No migration.** The breadcrumb keeps each concept's existing localForage key and native shape; the codec only governs the per-tab `sessionStorage` round-trip. Existing stored layouts seed a fresh tab unchanged.
- **Cold start can resume a layout set in a different tab** (last-writer-wins breadcrumb), the same honest "resume the most recent" semantics Active Connection accepts. Per the storage model — read once at creation, never re-read (`per-key-diff-merge` context) — no scope here has ever had live cross-tab sync; an already-open tab does not reflect another tab's change until it reloads.
- **A corrupt or stale per-tab value self-heals.** `deserialize` throws on a present-but-invalid value (and reading a blocked `sessionStorage` can throw a `SecurityError`); the seam catches either, logs a warning, and falls through to the breadcrumb then the default rather than crashing startup. Breadcrumb **write** failures still surface through the persistence-status path (`storage-layer-owns-persistence-failure`). Appropriate for non-critical view preferences.
- **Rule for new atoms.** A concept that should diverge per tab uses `createSessionScopedAtom` with a codec; a shared Map-keyed collection uses `reconcileMapByKey` (`per-key-diff-merge`); a shared scalar uses plain `atomWithLocalForage`. Scope is now a visible choice at the atom's creation, not an implicit consequence of which factory was reached for.
- The `sessionStorage` backing stays injectable, so per-tab isolation is tested directly with separate stores and separate `sessionStorage` mocks over one shared mock localForage.
- This primitive is the `perTab` adapter that spike #1876 proposes to formalize alongside the other two scopes. This ADR records the **scope** decision; #1876 may later relocate where the logic lives without re-deciding it.
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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<ConfigurationId | null> = {
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<ConfigurationId | null>(
ACTIVE_CONNECTION_STORAGE_KEY,
);
if (seedValue !== null) {
sessionStorage.setItem(ACTIVE_CONNECTION_STORAGE_KEY, seedValue);
}
}

return createWriteThroughAtom<ConfigurationId | null>(
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<ConfigurationId | null>({
key: ACTIVE_CONNECTION_STORAGE_KEY,
defaultValue: null,
codec: activeConnectionCodec,
sessionStorage,
});
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { describe, expect, test } from "vitest";

import {
defaultGraphViewLayout,
graphViewLayoutCodec,
type GraphViewLayout,
} from "./graphViewLayoutDefaults";

describe("graphViewLayoutCodec", () => {
test("round-trips a layout through serialize/deserialize, preserving the toggles Set", () => {
const layout: GraphViewLayout = {
activeSidebarItem: "filters",
activeToggles: new Set(["graph-viewer"]),
sidebar: { width: 321 },
tableView: { height: 250 },
detailsAutoOpenOnSelection: false,
};

const restored = graphViewLayoutCodec.deserialize(
graphViewLayoutCodec.serialize(layout),
);

expect(restored).toStrictEqual(layout);
expect(restored?.activeToggles).toBeInstanceOf(Set);
});

test("round-trips the default layout", () => {
expect(
graphViewLayoutCodec.deserialize(
graphViewLayoutCodec.serialize(defaultGraphViewLayout),
),
).toStrictEqual(defaultGraphViewLayout);
});

test("treats an absent value as a miss", () => {
expect(graphViewLayoutCodec.deserialize(null)).toBeNull();
expect(graphViewLayoutCodec.deserialize("")).toBeNull();
});

test("throws on a corrupt value so the seam can discard it", () => {
expect(() => graphViewLayoutCodec.deserialize("{ not json")).toThrow();
expect(() => graphViewLayoutCodec.deserialize("{}")).toThrow();
});
});
Original file line number Diff line number Diff line change
@@ -1,18 +1,29 @@
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<typeof toggleableViewSchema>;
/** 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",
"nodes-styling",
"edges-styling",
"namespaces",
] as const;
export type GraphViewSidebarItem = (typeof graphViewSidebarItems)[number];
]);
export type GraphViewSidebarItem = z.infer<typeof graphViewSidebarItemSchema>;
/** The sidebar panels as a readonly tuple, e.g. for random test selection. */
export const graphViewSidebarItems = graphViewSidebarItemSchema.options;

/** Persisted layout preferences for the graph view. */
export type GraphViewLayout = {
Expand All @@ -23,6 +34,22 @@ export type GraphViewLayout = {
detailsAutoOpenOnSelection?: boolean;
};

/**
* The graph view layout as JSON holds it: `activeToggles` is an array because a
* `Set` does not survive `JSON.stringify`. The schema parses this shape and
* rebuilds the runtime {@link GraphViewLayout}, so a hand-edited or stale
* per-tab value with the wrong shape is rejected rather than seeding bad state.
*/
const serializedGraphViewLayoutSchema = z.object({
activeSidebarItem: graphViewSidebarItemSchema.nullable(),
sidebar: z.object({ width: z.number() }),
activeToggles: z
.array(toggleableViewSchema)
.transform(toggles => new Set(toggles)),
tableView: z.object({ height: z.number() }).optional(),
detailsAutoOpenOnSelection: z.boolean().optional(),
});

/** Default height for the table view panel in pixels. */
export const DEFAULT_TABLE_VIEW_HEIGHT = 300;

Expand All @@ -37,3 +64,13 @@ export const defaultGraphViewLayout: GraphViewLayout = {
sidebar: { width: DEFAULT_SIDEBAR_WIDTH },
tableView: { height: DEFAULT_TABLE_VIEW_HEIGHT },
};

/** Per-tab session codec; serializes the toggles Set as an array for JSON. */
export const graphViewLayoutCodec: SessionValueCodec<GraphViewLayout> = {
serialize: layout =>
JSON.stringify({
...layout,
activeToggles: [...layout.activeToggles],
}),
deserialize: raw => parseSessionJson(raw, serializedGraphViewLayoutSchema),
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { describe, expect, test } from "vitest";

import {
defaultSchemaViewLayout,
schemaViewLayoutCodec,
type SchemaViewLayout,
} from "./schemaViewLayoutDefaults";

describe("schemaViewLayoutCodec", () => {
test("round-trips a layout through serialize/deserialize", () => {
const layout: SchemaViewLayout = {
activeSidebarItem: "nodes-styling",
sidebar: { width: 321 },
detailsAutoOpenOnSelection: false,
};

expect(
schemaViewLayoutCodec.deserialize(
schemaViewLayoutCodec.serialize(layout),
),
).toStrictEqual(layout);
});

test("round-trips the default layout", () => {
expect(
schemaViewLayoutCodec.deserialize(
schemaViewLayoutCodec.serialize(defaultSchemaViewLayout),
),
).toStrictEqual(defaultSchemaViewLayout);
});

test("treats an absent value as a miss", () => {
expect(schemaViewLayoutCodec.deserialize(null)).toBeNull();
expect(schemaViewLayoutCodec.deserialize("")).toBeNull();
});

test("throws on a corrupt value so the seam can discard it", () => {
expect(() => schemaViewLayoutCodec.deserialize("{ not json")).toThrow();
expect(() => schemaViewLayoutCodec.deserialize("{}")).toThrow();
});
});
Original file line number Diff line number Diff line change
@@ -1,23 +1,38 @@
import { z } from "zod";

import { DEFAULT_SIDEBAR_WIDTH } from "./graphViewLayoutDefaults";
import {
parseSessionJson,
type SessionValueCodec,
} from "./sessionScopedStorage";

/** Identifiers for the schema view sidebar panels. */
export const schemaViewSidebarItems = [
export const schemaViewSidebarItemSchema = z.enum([
"details",
"nodes-styling",
"edges-styling",
] as const;
export type SchemaViewSidebarItem = (typeof schemaViewSidebarItems)[number];
]);
export type SchemaViewSidebarItem = z.infer<typeof schemaViewSidebarItemSchema>;
/** 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<typeof schemaViewLayoutSchema>;

/** Initial layout state used when no persisted layout exists. */
export const defaultSchemaViewLayout: SchemaViewLayout = {
activeSidebarItem: "details",
sidebar: { width: DEFAULT_SIDEBAR_WIDTH },
detailsAutoOpenOnSelection: true,
};

/** Per-tab session codec; the schema view layout is plain JSON. */
export const schemaViewLayoutCodec: SessionValueCodec<SchemaViewLayout> = {
serialize: layout => JSON.stringify(layout),
deserialize: raw => parseSessionJson(raw, schemaViewLayoutSchema),
};
Loading
Loading