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
2 changes: 2 additions & 0 deletions docs/adr/20260709-read-time-transform-for-persisted-values.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ 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-07-10:** `transformVertexStyles` (in `vertexStylesTransform.ts`) is a second consumer, applied to both `user-vertex-styles` and `shared-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:

1. **No write-back.** The corrected value lives in memory; the stale value stays in storage until an unrelated write rewrites the key. This is fine because the transform is pure and total, so re-running it every load is free — convergence buys nothing. It is also why the transform must never throw or do I/O: it seeds atom init with no failure channel.
Expand Down
41 changes: 41 additions & 0 deletions docs/adr/20260710-coerce-retired-round-polygon-shapes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# ADR — Coerce retired round-polygon shapes at read boundaries

- **Status:** Accepted
- **Date:** 2026-07-10
- **Related:** ADR `read-time-transform-for-persisted-values` (the mechanism); issue #1922 (the defect); PR #1886 (exposed the shapes in the picker). Cytoscape 3.34.0 is the latest published version.

## Context

Cytoscape's round-polygon canvas renderer degenerates at 24px (our node size) for six shapes: `round-triangle`, `round-pentagon`, `round-hexagon`, `round-heptagon`, `round-octagon`, `round-tag`. The corner computation collapses, rendering each as a formless blob and producing invalid edge-endpoint coordinates that cause connected edges to disappear. `round-rectangle` and `round-diamond` are unaffected (they use a different code path). No upstream fix exists; the one related issue (cytoscape/cytoscape.js#3282) describes a crash, not this visual collapse.

The shapes became selectable in the UI via #1886, so users may have stored them in IndexedDB (user-vertex-styles, shared-vertex-styles) or in exported styling files. We must handle that existing data.

## Decision

Keep the six values in `SHAPE_STYLES` and `ShapeStyle` so older styling files still pass Zod validation on import. Remove them from the picker (`NODE_SHAPE`) so they can't be newly selected. At the storage-read boundary, coerce each to its non-round counterpart via a `ReadTransform` on both vertex-styles atoms (`transformVertexStyles`). The mapping preserves the user's visual-differentiation intent:

| Broken shape | Coerced to |
| ---------------- | ---------- |
| `round-triangle` | `triangle` |
| `round-pentagon` | `pentagon` |
| `round-hexagon` | `hexagon` |
| `round-heptagon` | `heptagon` |
| `round-octagon` | `octagon` |
| `round-tag` | `tag` |

Import stores the value verbatim — the ReadTransform coerces it on the next read from storage, so the import path stays pure and the original persisted value is never overwritten.

## Considered Options

- **Coerce at read boundaries, no write-back (chosen).** Non-destructive: stored originals survive, so a future cytoscape fix can restore them by removing the transform. Each broken shape maps to its sharp-cornered sibling, preserving shape semantics.
- **Reject on import.** Would fail the whole file under the atomic parser contract (`20260624-styling-file-format.md`), punishing users for data the app itself produced. Not acceptable.
- **Remove from `SHAPE_STYLES` / `ShapeStyle`.** Same as rejection: the Zod enum would fail old files that contain the values.
- **Coerce at import time (Zod `.transform()`).** Bakes the coerced value into storage permanently and loses the original on re-export. Makes the transform irreversible — a cytoscape fix can no longer restore the user's original choice.
- **Patch cytoscape (fork or monkey-patch).** High maintenance burden for a rendering detail that may be fixed upstream. Deferred unless upstream remains broken long-term.
- **Coerce to a single flat replacement (`round-rectangle`).** Collapses six visually distinct shapes into one, losing differentiation intent. Per-shape mapping costs one extra `Map` entry per shape and preserves semantics.

## Consequences

- Users who stored a broken shape see its non-round counterpart after upgrading — a strict improvement over the previous blob-with-no-edges.
- Styling-file round-trips are lossless in storage (never written back) but lossy in memory (the read value differs from the stored value). This is the read-time-transform ADR's purity contract applied to a new case.
- If cytoscape fixes the rendering, reversal is: restore the six entries in `NODE_SHAPE`, delete `BROKEN_SHAPE_REPLACEMENTS` and the `transformVertexStyles` ReadTransform. Stored originals reappear intact.
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
defaultSchemaViewLayout,
transformSchemaViewLayout,
} from "./schemaViewLayoutDefaults";
import { transformVertexStyles } from "./vertexStylesTransform";

// Run migrations before the atoms preload so they read the migrated data.
// Each migration owns its own failure reporting (surfacing through the
Expand Down Expand Up @@ -95,7 +96,7 @@ const [
atomWithLocalForage(
"user-vertex-styles",
new Map<VertexType, VertexStyleStorage>(),
{ reconcile: reconcileMapByKey },
{ reconcile: reconcileMapByKey, transform: transformVertexStyles },
),
/** User-defined edge style overrides, keyed by type. */
atomWithLocalForage(
Expand All @@ -111,7 +112,7 @@ const [
atomWithLocalForage(
"shared-vertex-styles",
new Map<VertexType, VertexStyleStorage>(),
{ reconcile: reconcileMapByKey },
{ reconcile: reconcileMapByKey, transform: transformVertexStyles },
),
/** Shared edge styles, loaded from a styling file. */
atomWithLocalForage(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
import { createStore } from "jotai";
import localforage from "localforage";

import { createVertexType, type VertexType } from "@/core/entities";

import type { ShapeStyle, VertexStyleStorage } from "./graphStyles";

import { atomWithLocalForage, reconcileMapByKey } from "./atomWithLocalForage";
import {
coerceBrokenShape,
transformVertexStyles,
} from "./vertexStylesTransform";

function vertexMap(
entries: Array<[string, Omit<VertexStyleStorage, "type">]>,
): Map<VertexType, VertexStyleStorage> {
return new Map(
entries.map(([name, fields]) => {
const type = createVertexType(name);
return [type, { ...fields, type }];
}),
);
}

/**
* BACKWARD COMPATIBILITY — PERSISTED DATA
*
* Vertex styles are persisted to IndexedDB via localForage. Prior to this
* change, the six round-polygon shapes (round-triangle, round-pentagon,
* round-hexagon, round-heptagon, round-octagon, round-tag) could be stored
* via the shape picker (exposed in #1886). These shapes render incorrectly in
* cytoscape at 24px and are now coerced to their non-round counterpart at
* load time via a ReadTransform.
*
* DO NOT delete or weaken these tests without confirming that the shapes are
* no longer in the wild or that cytoscape has fixed the rendering defect.
*/
describe("backward compatibility: retired round-polygon shapes in storage", () => {
beforeEach(async () => {
await localforage.clear();
});

it("coerces a stored broken shape through the full atomWithLocalForage pipeline", async () => {
const store = createStore();
const key = "test-vertex-styles-compat";

const legacyData = new Map<VertexType, VertexStyleStorage>([
[
createVertexType("Airport"),
{
type: createVertexType("Airport"),
shape: "round-hexagon" as ShapeStyle,
color: "#ff0000",
},
],
[
createVertexType("City"),
{
type: createVertexType("City"),
shape: "ellipse" as ShapeStyle,
color: "#00ff00",
},
],
]);

await localforage.setItem(key, legacyData);

const atom = await atomWithLocalForage(
key,
new Map<VertexType, VertexStyleStorage>(),
{ reconcile: reconcileMapByKey, transform: transformVertexStyles },
);

const value = store.get(atom);

expect(value.get(createVertexType("Airport"))).toStrictEqual({
type: createVertexType("Airport"),
shape: "hexagon",
color: "#ff0000",
});
expect(value.get(createVertexType("City"))).toStrictEqual({
type: createVertexType("City"),
shape: "ellipse",
color: "#00ff00",
});
});

it("does not write back the coerced value to storage", async () => {
const key = "test-vertex-styles-no-writeback";

const legacyData = new Map<VertexType, VertexStyleStorage>([
[
createVertexType("Airport"),
{
type: createVertexType("Airport"),
shape: "round-tag" as ShapeStyle,
},
],
]);

await localforage.setItem(key, legacyData);

await atomWithLocalForage(key, new Map<VertexType, VertexStyleStorage>(), {
reconcile: reconcileMapByKey,
transform: transformVertexStyles,
});

const storedAfterLoad =
await localforage.getItem<Map<VertexType, VertexStyleStorage>>(key);
expect(storedAfterLoad!.get(createVertexType("Airport"))!.shape).toBe(
"round-tag",
);
});
});

describe("coerceBrokenShape", () => {
it.each([
["round-triangle", "triangle"],
["round-pentagon", "pentagon"],
["round-hexagon", "hexagon"],
["round-heptagon", "heptagon"],
["round-octagon", "octagon"],
["round-tag", "tag"],
] as [ShapeStyle, ShapeStyle][])("coerces %s to %s", (broken, expected) => {
expect(coerceBrokenShape(broken)).toBe(expected);
});

it.each([
"ellipse",
"rectangle",
"roundrectangle",
"round-rectangle",
"round-diamond",
"star",
"diamond",
"triangle",
"tag",
] as ShapeStyle[])("passes %s through unchanged", shape => {
expect(coerceBrokenShape(shape)).toBe(shape);
});
});

describe("transformVertexStyles", () => {
it("returns the same reference when no shapes need coercion", () => {
const styles = vertexMap([
["A", { shape: "ellipse" }],
["B", { color: "#fff" }],
]);

expect(transformVertexStyles(styles)).toBe(styles);
});

it.each([
["round-triangle", "triangle"],
["round-pentagon", "pentagon"],
["round-hexagon", "hexagon"],
["round-heptagon", "heptagon"],
["round-octagon", "octagon"],
["round-tag", "tag"],
] as [ShapeStyle, ShapeStyle][])(
"coerces %s to %s in a stored map",
(broken, expected) => {
const styles = vertexMap([["X", { shape: broken }]]);
const result = transformVertexStyles(styles);
expect(result.get(createVertexType("X"))!.shape).toBe(expected);
},
);

it("does not coerce round-rectangle or round-diamond", () => {
const styles = vertexMap([
["A", { shape: "round-rectangle" }],
["B", { shape: "round-diamond" }],
]);

expect(transformVertexStyles(styles)).toBe(styles);
});

it("preserves other fields on a coerced entry", () => {
const styles = vertexMap([
["A", { shape: "round-tag", color: "#ff0000", borderWidth: 2 }],
]);

const result = transformVertexStyles(styles);
const entry = result.get(createVertexType("A"))!;
expect(entry.shape).toBe("tag");
expect(entry.color).toBe("#ff0000");
expect(entry.borderWidth).toBe(2);
});

it("handles an empty map", () => {
const styles = vertexMap([]);
expect(transformVertexStyles(styles)).toBe(styles);
});

it("passes through entries without a shape field", () => {
const styles = vertexMap([["A", { color: "#abc" }]]);
expect(transformVertexStyles(styles)).toBe(styles);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { logger } from "@/utils";

import type { VertexType } from "../entities";
import type { ShapeStyle, VertexStyleStorage } from "./graphStyles";

/**
* Shapes that cytoscape's round-polygon renderer draws incorrectly at small
* node size (24px): their corner computation degenerates, rendering as a blob
* and producing invalid edge endpoints that cause edges to disappear. These are
* kept in {@link SHAPE_STYLES} so older files still parse, but are coerced to
* their sharp-cornered counterpart at every read boundary.
*
* Each broken shape maps to its non-round sibling to preserve the user's
* visual-differentiation intent (a round-hexagon becomes a hexagon, not a
* generic rectangle).
*/
const BROKEN_SHAPE_REPLACEMENTS = new Map<ShapeStyle, ShapeStyle>([
["round-triangle", "triangle"],
["round-pentagon", "pentagon"],
["round-hexagon", "hexagon"],
["round-heptagon", "heptagon"],
["round-octagon", "octagon"],
["round-tag", "tag"],
]);

/** Coerces a broken round-polygon shape to its non-round counterpart, passing all others through. */
export function coerceBrokenShape(shape: ShapeStyle): ShapeStyle {
return BROKEN_SHAPE_REPLACEMENTS.get(shape) ?? shape;
}

/**
* ReadTransform for vertex style maps: coerces broken round-polygon shapes to
* their non-round counterpart at load time. Entries without a `shape` field are
* passed through unchanged. Returns the same reference when no coercion was
* needed.
*/
export function transformVertexStyles(
styles: Map<VertexType, VertexStyleStorage>,
): Map<VertexType, VertexStyleStorage> {
let result: Map<VertexType, VertexStyleStorage> | null = null;

for (const [type, entry] of styles) {
if (entry.shape !== undefined) {
const coerced = coerceBrokenShape(entry.shape);
if (coerced !== entry.shape) {
if (!result) {
result = new Map(styles);
}
logger.debug(
`[vertex-styles] Coercing broken shape "${entry.shape}" to "${coerced}" for type "${type}"`,
);
result.set(type, { ...entry, shape: coerced });
}
}
}

return result ?? styles;
}
6 changes: 0 additions & 6 deletions packages/graph-explorer/src/modules/NodesStyling/nodeShape.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,7 @@ export const NODE_SHAPE = [
{ label: "Rectangle", value: "rectangle" },
{ label: "Rhomboid", value: "rhomboid" },
{ label: "Round Diamond", value: "round-diamond" },
{ label: "Round Heptagon", value: "round-heptagon" },
{ label: "Round Hexagon", value: "round-hexagon" },
{ label: "Round Octagon", value: "round-octagon" },
{ label: "Round Pentagon", value: "round-pentagon" },
{ label: "Round Rectangle", value: "roundrectangle" },
{ label: "Round Tag", value: "round-tag" },
{ label: "Round Triangle", value: "round-triangle" },
{ label: "Star", value: "star" },
{ label: "Tag", value: "tag" },
{ label: "Triangle", value: "triangle" },
Expand Down
Loading