Skip to content

Commit db6f162

Browse files
committed
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.
1 parent 2414d56 commit db6f162

3 files changed

Lines changed: 151 additions & 44 deletions

File tree

packages/graph-explorer/src/core/StateProvider/graphViewLayoutDefaults.ts

Lines changed: 9 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -36,20 +36,15 @@ export type GraphViewLayout = {
3636
* rebuilds the runtime {@link GraphViewLayout}, so a hand-edited or stale
3737
* per-tab value with the wrong shape is rejected rather than seeding bad state.
3838
*/
39-
const serializedGraphViewLayoutSchema = z
40-
.object({
41-
activeSidebarItem: graphViewSidebarItemSchema.nullable(),
42-
sidebar: z.object({ width: z.number() }),
43-
activeToggles: z.array(toggleableViewSchema),
44-
tableView: z.object({ height: z.number() }).optional(),
45-
detailsAutoOpenOnSelection: z.boolean().optional(),
46-
})
47-
.transform(
48-
(value): GraphViewLayout => ({
49-
...value,
50-
activeToggles: new Set(value.activeToggles),
51-
}),
52-
);
39+
const serializedGraphViewLayoutSchema = z.object({
40+
activeSidebarItem: graphViewSidebarItemSchema.nullable(),
41+
sidebar: z.object({ width: z.number() }),
42+
activeToggles: z
43+
.array(toggleableViewSchema)
44+
.transform(toggles => new Set(toggles)),
45+
tableView: z.object({ height: z.number() }).optional(),
46+
detailsAutoOpenOnSelection: z.boolean().optional(),
47+
});
5348

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

packages/graph-explorer/src/core/StateProvider/sessionScopedStorage.test.ts

Lines changed: 137 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,11 @@ import {
1212
} from "./graphViewLayoutDefaults";
1313
import { persistenceStatusStore } from "./persistence";
1414
import { createInMemorySessionStorage } from "./safeSessionStorage";
15+
import {
16+
defaultSchemaViewLayout,
17+
type SchemaViewLayout,
18+
schemaViewLayoutCodec,
19+
} from "./schemaViewLayoutDefaults";
1520
import {
1621
createSessionScopedAtom,
1722
parseSessionJson,
@@ -34,38 +39,47 @@ const counterCodec: SessionValueCodec<Counter> = {
3439
const KEY = "test-counter";
3540

3641
/**
37-
* Simulates one browser tab over the counter atom: its own Jotai store and
38-
* sessionStorage (per-tab), all tabs sharing the one fake-indexeddb. Mirrors
39-
* the active-connection test harness.
42+
* Builds a tab-opener bound to one key/default/codec. Each opened tab has its
43+
* own Jotai store and sessionStorage (per-tab), while all tabs share the one
44+
* fake-indexeddb — exactly how same-origin tabs relate. Mirrors the
45+
* active-connection test harness, parameterized so each real codec can be
46+
* exercised through the same multi-tab sequences rather than only a toy codec.
4047
*/
41-
async function openTab() {
42-
const sessionStorage = createInMemorySessionStorage();
43-
let store = createStore();
44-
let atom = await createSessionScopedAtom<Counter>({
45-
key: KEY,
46-
defaultValue: { count: 0 },
47-
codec: counterCodec,
48-
sessionStorage,
49-
});
50-
return {
51-
read: () => store.get(atom),
52-
write: (value: Counter) => {
53-
store.set(atom, value);
54-
return persistenceStatusStore.waitForIdle();
55-
},
56-
session: () => sessionStorage.getItem(KEY),
57-
reload: async () => {
58-
store = createStore();
59-
atom = await createSessionScopedAtom<Counter>({
60-
key: KEY,
61-
defaultValue: { count: 0 },
62-
codec: counterCodec,
63-
sessionStorage,
64-
});
65-
},
48+
function tabOpener<T>(
49+
key: string,
50+
defaultValue: T,
51+
codec: SessionValueCodec<T>,
52+
) {
53+
return async function openTab() {
54+
const sessionStorage = createInMemorySessionStorage();
55+
let store = createStore();
56+
let atom = await createSessionScopedAtom<T>({
57+
key,
58+
defaultValue,
59+
codec,
60+
sessionStorage,
61+
});
62+
return {
63+
read: () => store.get(atom),
64+
write: (value: T) => {
65+
store.set(atom, value);
66+
return persistenceStatusStore.waitForIdle();
67+
},
68+
reload: async () => {
69+
store = createStore();
70+
atom = await createSessionScopedAtom<T>({
71+
key,
72+
defaultValue,
73+
codec,
74+
sessionStorage,
75+
});
76+
},
77+
};
6678
};
6779
}
6880

81+
const openTab = tabOpener<Counter>(KEY, { count: 0 }, counterCodec);
82+
6983
describe("createSessionScopedAtom", () => {
7084
beforeEach(async () => {
7185
await localForage.clear();
@@ -278,3 +292,98 @@ describe("createSessionScopedAtom with the graph view layout codec", () => {
278292
).toStrictEqual(breadcrumb);
279293
});
280294
});
295+
296+
// The two layout atoms ride the same primitive as the active connection, so
297+
// they get the same multi-tab assurances active-connection has — proven
298+
// through their real codecs, not the toy counter codec. The graph view codec
299+
// is the interesting one: its activeToggles Set must survive the array
300+
// serialization across a write-in-one-tab / cold-start-in-another sequence.
301+
describe("graph view layout across tabs", () => {
302+
const openGraphViewTab = tabOpener<GraphViewLayout>(
303+
"graph-view-layout",
304+
defaultGraphViewLayout,
305+
graphViewLayoutCodec,
306+
);
307+
308+
beforeEach(async () => {
309+
await localForage.clear();
310+
});
311+
312+
test("changing layout in one tab does not change an already-open tab", async () => {
313+
const tabB = await openGraphViewTab();
314+
const tabBLayout: GraphViewLayout = {
315+
...defaultGraphViewLayout,
316+
activeSidebarItem: "filters",
317+
activeToggles: new Set(["graph-viewer"]),
318+
};
319+
await tabB.write(tabBLayout);
320+
321+
const tabA = await openGraphViewTab();
322+
await tabA.write({
323+
...defaultGraphViewLayout,
324+
activeSidebarItem: "edges-styling",
325+
});
326+
327+
expect(tabB.read()).toStrictEqual(tabBLayout);
328+
});
329+
330+
test("a later tab cold-starts to the layout an earlier tab wrote, with toggles rebuilt as a Set", async () => {
331+
const earlierTab = await openGraphViewTab();
332+
const written: GraphViewLayout = {
333+
...defaultGraphViewLayout,
334+
activeSidebarItem: "expand",
335+
activeToggles: new Set(["table-view"]),
336+
sidebar: { width: 512 },
337+
};
338+
await earlierTab.write(written);
339+
340+
const freshTab = await openGraphViewTab();
341+
342+
const seeded = freshTab.read();
343+
expect(seeded).toStrictEqual(written);
344+
expect(seeded.activeToggles).toBeInstanceOf(Set);
345+
});
346+
});
347+
348+
describe("schema view layout across tabs", () => {
349+
const openSchemaViewTab = tabOpener<SchemaViewLayout>(
350+
"schema-view-layout",
351+
defaultSchemaViewLayout,
352+
schemaViewLayoutCodec,
353+
);
354+
355+
beforeEach(async () => {
356+
await localForage.clear();
357+
});
358+
359+
test("changing layout in one tab does not change an already-open tab", async () => {
360+
const tabB = await openSchemaViewTab();
361+
const tabBLayout: SchemaViewLayout = {
362+
...defaultSchemaViewLayout,
363+
activeSidebarItem: "nodes-styling",
364+
};
365+
await tabB.write(tabBLayout);
366+
367+
const tabA = await openSchemaViewTab();
368+
await tabA.write({
369+
...defaultSchemaViewLayout,
370+
activeSidebarItem: "edges-styling",
371+
});
372+
373+
expect(tabB.read()).toStrictEqual(tabBLayout);
374+
});
375+
376+
test("a later tab cold-starts to the layout an earlier tab wrote", async () => {
377+
const earlierTab = await openSchemaViewTab();
378+
const written: SchemaViewLayout = {
379+
...defaultSchemaViewLayout,
380+
activeSidebarItem: "edges-styling",
381+
sidebar: { width: 480 },
382+
};
383+
await earlierTab.write(written);
384+
385+
const freshTab = await openSchemaViewTab();
386+
387+
expect(freshTab.read()).toStrictEqual(written);
388+
});
389+
});

packages/graph-explorer/src/core/StateProvider/sessionScopedStorage.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,11 @@ import { createWriteThroughAtom } from "./writeThroughAtom";
1515
*
1616
* `serialize` returns `null` to mean "remove the per-tab key" — used when the
1717
* value is the kind of empty/cleared state that should not seed a later reload.
18-
* `deserialize` returns `null` when the stored string is missing or corrupt, so
19-
* seeding falls through to the breadcrumb rather than adopting a bad value.
18+
* `deserialize` returns `null` for an absent value (a legitimate miss); a
19+
* present-but-invalid value is corrupt and **throws** — the seam
20+
* (`createSessionScopedAtom`) catches it and treats it as a miss, so detecting
21+
* corruption stays separate from deciding what to do about it. Do not swallow
22+
* errors here.
2023
*/
2124
export type SessionValueCodec<T> = {
2225
serialize: (value: T) => string | null;

0 commit comments

Comments
 (0)