diff --git a/docs/wiki/index.md b/docs/wiki/index.md
index aa25ed8..c6aadd1 100644
--- a/docs/wiki/index.md
+++ b/docs/wiki/index.md
@@ -4,7 +4,7 @@ description: Ignatius — Bun/TypeScript markdown-driven ERD modeler with a unif
---
repo
-08b8eeaf632f7cc3e9d655f1b1b1b0369d94bb8c
+f4d2d14b4db849f2c97d39176536d5f5c80d100d1
# Project signals
@@ -48,8 +48,8 @@ No linter or formatter configured in package.json.
| Language | LOC | Files | % |
|----------|-----|-------|---|
-| TypeScript | 59159 | 265 | 70% |
-| Markdown | 20461 | 298 | 24% |
+| TypeScript | 59201 | 265 | 70% |
+| Markdown | 20483 | 298 | 24% |
| CSS | 3004 | 2 | 3% |
| YAML | 1340 | 14 | 1% |
| Shell | 116 | 1 | 0% |
@@ -63,7 +63,7 @@ No linter or formatter configured in package.json.
- CI pipeline: install deps → cache Playwright → build bundle + stable-names → compile binary → run all `test/checks/*.ts` → typecheck (`continue-on-error: true`).
- Release pipeline: [`.github/workflows/release-please.yml`](../../.github/workflows/release-please.yml) (release-please driven; a `build` job gated on `release_created` compiles the 5 platform binaries + checksums and attaches them to the release in the same push-to-main run). [`install.sh`](../../install.sh) (repo root) is the curl-able CLI installer that pulls those binaries from `releases/latest/download`.
- Binary is built locally or in CI via `bun run build:cli`; produces `dist/ignatius`.
-- package.json `name` is `ignatius`, version is `0.12.0`. The repo *directory* is still named `derek-db-generator/` — the one remaining derek reference, a known leftover.
+- package.json `name` is `ignatius`, version is `0.14.0`. The repo *directory* is still named `derek-db-generator/` — the one remaining derek reference, a known leftover.
---
@@ -248,7 +248,7 @@ Exports: `validateModel(model: Model): ValidationResult`, `formatFindingsForStde
**Cross-view search (graph-flow-search):** owns per-surface search state that survives view switches because GraphView and FlowsView stay mounted-but-inactive and DictionaryView is keep-mounted via CSS. Graph search: `graphSearchTerm`/`graphSearchIncludeBody` (`useState`) plus `graphSearchCursorRef` (Enter-to-cycle cursor, reset to `-1` whenever the term or body toggle changes). `graphSearchActive = graphSearchTerm.trim() !== ''`; `graphSearchMatchIds` (`useMemo`) runs `entityMatches` from [`src/app/logic/search.ts`](../../src/app/logic/search.ts) over `model.nodes`, sorted ascending by id; `graphSearchMatches` is `null` when inactive or a `Set` (possibly empty — an active search with zero matches still dims everything) when active, matching the contract `GraphView`'s `searchMatches` prop expects. `handleGraphSearchEnter` advances `graphSearchCursorRef` modulo the match count, wrapping, and calls `graphViewRef.current?.navigateToEntity(nextId)`. Flow search: `flowSearchTerm`/`flowSearchIncludeBody`; `flowSearchActive`; `flowSearchResults` (`useMemo`) calls `searchFlowDiagrams` from the same module; `flowSearchTokens` is the `Set` of each result's `token` (or `null`) threaded into `FlowsView`'s `searchTokens` prop. `handleFlowSearchSelect(diagramId)` and `handleFlowSearchEnter()` (opens the first result) both route through `flowsViewRef.current?.selectDiagramById` — no new navigation machinery. Neither graph nor flow search state ever touches the model, layout fingerprint, layout-store, or URL hash. `graphSearchBarRef`/`flowSearchBarRef` (`SearchBarHandle`) let `handleKeyboardSearchFocus` (wired to `useKeyboardShortcuts`'s `onSearch`) focus the active view's `SearchBar` input; the Dictionary path instead calls `dictViewRef.current?.focusSearch()` since its search input lives inside `DictionaryView`, not a `SearchBar`. The Graph `SearchBar` gets a live `matchCount`/`totalCount`; the Flows `SearchBar` always passes `matchCount={null}` / `totalCount={0}` (no "n of N" readout — the results dropdown is the readout). `bannerRef` measures the global-error banner's real rendered height via `ResizeObserver` (in a `useLayoutEffect`) and writes it into the `--search-bar-top` CSS custom property so `SearchBar` (`.viewer-search-bar`) sits below the banner instead of being occluded by it; the property is removed when the banner isn't visible, letting `styles.css`'s 12px fallback apply.
- **Hooks:**
- - [`src/app/hooks/useModelData.ts`](../../src/app/hooks/useModelData.ts) (158L) — exports `useModelData(opts?)`. Unified SSE subscription + model/flow fetch + findings state. Static mode: reads `window.__MODEL__` / `__FLOW_MODEL__` once on mount. Live mode: boots with parallel `/api/model` + `/api/flow`, then re-fetches on every `model-changed` SSE event. Returns `{ model, findings: ModelFindings, flowDiagrams, flowFindings: FlowFindings, layoutKeyRef, bannerDismissed, setBannerDismissed }`. Exports `ModelFindings` and `FlowFindings` types.
+ - [`src/app/hooks/useModelData.ts`](../../src/app/hooks/useModelData.ts) (173L) — exports `useModelData(opts?)`. Unified SSE subscription + model/flow fetch + findings state. Static mode: reads `window.__MODEL__` / `__FLOW_MODEL__` once on mount. Live mode: boots with parallel `/api/model` + `/api/flow`, then re-fetches on every `model-changed` SSE event. Returns `{ model, findings: ModelFindings, flowDiagrams, flowFindings: FlowFindings, layoutKeyRef, bannerDismissed, setBannerDismissed }`. Exports `ModelFindings` and `FlowFindings` types. **StrictMode double-fetch guard:** the boot `useEffect` declares a local `let ignore = false`, set to `true` in the returned cleanup; every `setState` call inside the boot `Promise.all(...).then(...)` and the SSE `model-changed` handler's `Promise.all(...).then(...)` (and both `.catch()` handlers) is gated on `if (ignore) return` / `if (!ignore)`. Guards against React StrictMode's dev-mode mount→cleanup→mount double-invoke: without the flag, the first (stale) invocation's in-flight fetch still resolves after its own effect cleanup ran and calls `setModel`/`setFlowDiagrams` with a fresh reference, retriggering every effect keyed on `model`/`flowDiagrams` (including the flow renderer's mount effect in `FlowsView.tsx`) while the second (real) mount is still settling.
- [`src/app/hooks/useHashRoute.ts`](../../src/app/hooks/useHashRoute.ts) — exports `useHashRoute(opts?)`. Owns hash read/write and `popstate` back/forward restoration. Seeds from `location.hash` on mount. Accepts an `onRestoreDfd` callback for DFD deep-link restore. **Entity modal history (#6/#8):** `entity=` in the hash is the single source of truth for the modal stack. Opening an entity calls `openEntity(id)` (→ `history.pushState`; deduped when the hash already carries this same entity — an FK hop back to the same entity or a re-open does not stack a duplicate history entry); closing calls `closeEntity()` (→ `history.replaceState`). `popstate` invokes the `onEntityChange(id | null)` option to reconcile the shell — the shell opens/closes the modal in response without pushing another history entry. GraphView no longer writes `entity=`. Returns `{ view, setView, openEntity, closeEntity }`.
- [`src/app/hooks/useThemeMode.ts`](../../src/app/hooks/useThemeMode.ts) (36L) — exports `useThemeMode(themeConfig?, model?)`. Seeds from `window.__THEME_MODE__` or localStorage. Calls `applyThemeCssVars` on change (also re-fires on `model` identity change, so an SSE-delivered model with the same `themeConfig` identity still reapplies CSS vars). Returns `{ themeMode, toggleTheme }`.
- [`src/app/hooks/useKeyboardShortcuts.ts`](../../src/app/hooks/useKeyboardShortcuts.ts) (93L) — registers exactly ONE global `keydown` listener for the unified SPA keyboard shortcuts (g/d/f/l/b/?/[`/`](../..)/Cmd-Ctrl-k). Stale-closure hazard avoided via `configRef` (latest `view`/callbacks stored in a ref, updated each render; listener reads from ref). Editable guard: returns true when focus is in `INPUT`/`TEXTAREA`/`SELECT`/`contenteditable` or inside `.modal`. `KeyboardShortcutsConfig` interface carries: `view`, `onView`, `onToggleLayout`, `onToggleLens`, `onZoomIn`, `onZoomOut`, `onZoomReset`, `onHelp`, `onSearch` (**graph-flow-search**: fires on [`/`](../..) or Cmd/Ctrl+`k` — focuses the active view's search input; both resolve to the same `{type:'search'}` action in `resolveShortcut`, so the hook itself is agnostic to which key triggered it). Dispatches through `resolveShortcut` from [`src/app/logic/shortcuts.ts`](../../src/app/logic/shortcuts.ts). Imported by [`src/app/App.tsx`](../../src/app/App.tsx).
@@ -314,7 +314,7 @@ Exports: `validateModel(model: Model): ValidationResult`, `formatFindingsForStde
- [`src/app/views/graph/navigator.ts`](../../src/app/views/graph/navigator.ts) — `mountNavigator` / `teardownNavigator` / `NavigatorInstance` — cytoscape-navigator lifecycle helpers. `teardownNavigator` calls `nav._removeCyListeners?.()` BEFORE `nav.destroy()`: `destroy()` removes overlay/window listeners but leaks the cy `'resize'` + onRender listeners, which fire `Navigator.resize→boundingBox` on a destroyed core (`isHeadless` of null); removing them while cy is alive fixes that crash. `test-navigator-teardown.ts` pins this call-order contract.
- [`src/app/views/graph/styles.ts`](../../src/app/views/graph/styles.ts) (243L) — `buildStyles(groups, theme, mode)` → cytoscape stylesheet array. **3-tier focus opacity:** `.faded` selector sets `opacity: 0.2` (unrelated tier, changed from 0.3); `.inherited-dim` selector sets `opacity: 0.5` (inherited/ancestral tier); `edge.inherited` opacity changed from 0.85 to 0.5 so dotted rays match their `.inherited-dim` target nodes. Includes `edge.inherited` selector: dotted line, `SPOTLIGHT_LINE_INHERITED[themeMode]` color from [`src/app/dom/theme-css-vars.ts`](../../src/app/dom/theme-css-vars.ts), thinner than direct edges. Length-graded de-emphasis (data set by `organic-layout.ts`'s `gradeEdgeSpans` after layout/drag): `edge[span = "mid"]` → `opacity: 0.5`; `edge[span = "far"]` → `opacity: 0.22, width: 1, z-index: -1` — placed after the identifying/dashed rules so the span grading wins the cascade; long-haul cross-domain wires de-emphasize at overview but stay hoverable/selectable and regain full presence as you zoom in or follow them. **Graph search (graph-flow-search CP2):** `SEARCH_MATCH_BORDER` (`Record` — dark `#fde047` / light `#ca8a04`), a distinct gold/yellow chosen so it never reads as the amber `spotlight-line-out` lineage color or a group's border color, mirroring the Dictionary view's `--dd-search-highlight` yellow so "search" reads as one visual language across surfaces. `.search-dim` sets `opacity: 0.2`; `node.search-match` sets `border-width: 3` / `border-color: SEARCH_MATCH_BORDER[mode]`. Both rules are pushed LAST in the stylesheet array so they win the cascade over the span-graded edge opacity and the per-group border-color rules.
- [`src/app/views/dict/DictionaryView.tsx`](../../src/app/views/dict/DictionaryView.tsx) (1452L) — `DictionaryView` is a `forwardRef` exposing `DictionaryViewHandle { toggleLens(): void; focusSearch(): void }` (`toggleLens` wraps the existing `switchLens`; `focusSearch` — added for **graph-flow-search** — focuses `searchInputRef`, the dict search ``, in response to the shell's [`/`](../..) shortcut). Keep-mounted via CSS `display:none`. Imports `SYNTHETIC_DIAGRAM_IDS` from [`src/flows/flow-derive-levels.ts`](../../src/flows/flow-derive-levels.ts) to exclude synthesized context/L1 diagrams from the DD sidebar. Owns DD CSS Custom Highlight search (CP9), `beforeprint`/`afterprint` (CP10), DD sidebar process nesting with `compareDottedProcesses` (CP24). Its own search (`nodeMatchesSearch`/`processMatchesSearch`/`externalMatchesSearch`/`storeMatchesSearch` from [`src/app/logic/search.ts`](../../src/app/logic/search.ts), always matching columns + body) is unchanged by graph-flow-search — the new title-first, body-opt-in matchers (`entityMatches`, `flowProcessMatches`, etc.) are exclusive to the Graph and Flows `SearchBar` surfaces. `kbd-hint` B badge present on the lens toggle control. **Browse lens (dd-spotlight-grid):** lens toggle (`'read' | 'browse'`) persisted to `localStorage`. Browse lens renders entity groups + Processes / External Entities / Data Stores sections. Spotlight state: `hoverId`, `pinnedId`, `labelHoverCardId`, `focusId` (focus mode). FK connections from `buildSpotlightConnections`; flow connections from `buildFlowSpotlightConnections`. `SpotlightOverlay` mounted when `activeId !== null`. Fixed frosted search bar (`.dict-search-bar`); search and focus are mutually exclusive. **DD inherited lines are SHIFT-GATED:** a `shiftHeld` boolean state is driven by a document-level `keydown`/`keyup` pair on `Shift` + a `window` `blur` reset (registered in a dependency-free `useEffect`); the `inheritedConnections` useMemo returns `[]` unless `shiftHeld && activeId` (deps include `shiftHeld`), and the inherited-id foldings into `spotlitIds`/`focusSet` are wrapped with `if (shiftHeld)` — so without Shift no inherited card is lit, extra-focused, or surfaced as an off-screen chip. FK (solid) and flow (dashed) spotlight lines are unchanged (show on plain hover/pin). `SpotlightOverlay` is unchanged.
- - [`src/app/views/flow/FlowsView.tsx`](../../src/app/views/flow/FlowsView.tsx) (864L) — exports `FlowsView` (forwardRef) and `FlowsViewHandle` type. Owns `FlowChrome` chrome + imperative SVG renderer lifecycle. Calls `buildFlowDocResolver` + `buildAllFlowNodeIds` + `buildFlowNodeUsageIndex`. **dfd-overhaul wiring:** `renderDiagram` calls `computeElkLayout(diagram, { workerFactory })` and passes `elkPositions` + `elkEdgeRoutes` into `FlowDiagramSvg`; `terminateQuietly` is called after ELK resolves; banded `computeFlowLayout` used only on ELK failure. `FlowsViewHandle` exposes: `selectDiagramById`, `resetLayout`, `zoomIn`, `zoomOut`, `setPercent`, `resetZoom`, `openFlowToken`. DFD URL deep-link: `selectDiagramById` uses `findDiagramPath` to build the full breadcrumb stack (survives page refresh). SVG wheel delta tamed to `0.95`/`1.05` per tick (CP23). **Cross-view search (graph-flow-search CP3):** accepts a `searchTokens: ReadonlySet | null` prop (base tokens — `proc:`/`ext:`/`:`/entity `db:` — that keep full opacity; `null` = no active search). Passed as `initialSearchTokens` into `initFlowGraphCore`, which seeds `currentSearchTokens` in its closure and registers a live-update hook via `chromeCallbacks.onRegisterSearchTokens` — the same pattern as the existing `onRegisterRetheme` hook: calling the registered function re-renders `FlowSurface`/`FlowDiagramSvg` in place (`root.render(...)`) with the new token set, no renderer teardown, so the drill-down stack and breadcrumb position survive every keystroke. `FlowsView` stores the registered function in `flowSearchTokensRef`; a dedicated `useEffect` keyed on `[searchTokens, isActive]` calls it whenever `searchTokens` changes while the flow view is active, mirroring the theme-mode `retheme` effect. `FlowDiagramSvg` ([`src/flow-view/FlowDiagramSvg.tsx`](../../src/flow-view/FlowDiagramSvg.tsx)) consumes `searchTokens` in its `nodeOpacity`/`edgeOpacity` helpers: a node dims to `DIM_OPACITY = 0.3` unless `searchTokens.has(baseToken(id))`; an edge dims unless either endpoint's base token matches. `baseToken(id)` strips the render-time `--src`/`--snk`/`--read`/`--write` role-split suffix before comparing, so a role-split node still matches on its underlying token.
+ - [`src/app/views/flow/FlowsView.tsx`](../../src/app/views/flow/FlowsView.tsx) (891L) — exports `FlowsView` (forwardRef) and `FlowsViewHandle` type. Owns `FlowChrome` chrome + imperative SVG renderer lifecycle. Calls `buildFlowDocResolver` + `buildAllFlowNodeIds` + `buildFlowNodeUsageIndex`. **dfd-overhaul wiring:** `renderDiagram` calls `computeElkLayout(diagram, { workerFactory })` and passes `elkPositions` + `elkEdgeRoutes` into `FlowDiagramSvg`; `terminateQuietly` is called after ELK resolves; banded `computeFlowLayout` used only on ELK failure. `FlowsViewHandle` exposes: `selectDiagramById`, `resetLayout`, `zoomIn`, `zoomOut`, `setPercent`, `resetZoom`, `openFlowToken`. DFD URL deep-link: `selectDiagramById` uses `findDiagramPath` to build the full breadcrumb stack (survives page refresh). SVG wheel delta tamed to `0.95`/`1.05` per tick (CP23). **Cross-view search (graph-flow-search CP3):** accepts a `searchTokens: ReadonlySet | null` prop (base tokens — `proc:`/`ext:`/`:`/entity `db:` — that keep full opacity; `null` = no active search). Passed as `initialSearchTokens` into `initFlowGraphCore`, which seeds `currentSearchTokens` in its closure and registers a live-update hook via `chromeCallbacks.onRegisterSearchTokens` — the same pattern as the existing `onRegisterRetheme` hook: calling the registered function re-renders `FlowSurface`/`FlowDiagramSvg` in place (`root.render(...)`) with the new token set, no renderer teardown, so the drill-down stack and breadcrumb position survive every keystroke. `FlowsView` stores the registered function in `flowSearchTokensRef`; a dedicated `useEffect` keyed on `[searchTokens, isActive]` calls it whenever `searchTokens` changes while the flow view is active, mirroring the theme-mode `retheme` effect. `FlowDiagramSvg` ([`src/flow-view/FlowDiagramSvg.tsx`](../../src/flow-view/FlowDiagramSvg.tsx)) consumes `searchTokens` in its `nodeOpacity`/`edgeOpacity` helpers: a node dims to `DIM_OPACITY = 0.3` unless `searchTokens.has(baseToken(id))`; an edge dims unless either endpoint's base token matches. `baseToken(id)` strips the render-time `--src`/`--snk`/`--read`/`--write` role-split suffix before comparing, so a role-split node still matches on its underlying token. **`disposed` teardown guard (race fix):** `initFlowGraphCore` declares `let disposed = false` alongside its existing `renderGen` counter. The async `renderDiagram(diagram)` checks `if (disposed) return` on entry and `if (renderGen !== myGen || disposed) return` after every `await computeElkLayout(diagram)` resolution (both the success path and the `catch` block). The teardown function `initFlowGraphCore` returns sets `disposed = true` as its first statement, before unregistering chrome callbacks or unmounting `svgRoot`. Guards against an orphaned `renderDiagram` continuation resuming after its owning `initFlowGraphCore` instance was torn down while still awaiting ELK (React StrictMode's dev-mode mount→cleanup→mount, or navigating away and back before ELK finishes) — `svgRoot` was still `null` at teardown time so there was nothing to unmount, and without this flag the orphaned call would resume, find the shared `container` DOM node still connected (`App.tsx` keeps every view's container mounted across view switches), and inject a second live React root into it, racing the real instance for the same container.
- [`src/app/views/flow/LegendModal.tsx`](../../src/app/views/flow/LegendModal.tsx) (204L) — `LegendModal` component.
- **Globals:** [`src/app/globals.d.ts`](../../src/app/globals.d.ts) (46L) — `window.__MODEL__`, `window.__THEME_MODE__`, `window.__IGNATIUS_MODE__` ('live'|'static'), `window.__LAYOUT_KEY__`, `window.__FLOW_MODEL__`, `window.__FLOW_LAYOUT_KEYS__`, `window.__IGNATIUS_CY__`, `window.__IGNATIUS_CY_GEN__`, `window.__IGNATIUS_FLOW_READY__`, `window.__IGNATIUS_FLOW_GEN__`, `window.__IGNATIUS_ACTIVE_FLOW_DFD__`, `window.__IGNATIUS_PERF__`.
diff --git a/docs/wiki/scan.md b/docs/wiki/scan.md
index 08b8eea..f4d2d14 100644
--- a/docs/wiki/scan.md
+++ b/docs/wiki/scan.md
@@ -223,7 +223,7 @@
│ │ ├── hooks/ (4)
│ │ │ ├── useHashRoute.ts (c50964c, 121L, 5142ch, 5150B)
│ │ │ ├── useKeyboardShortcuts.ts (5962b6b, 93L, 3529ch, 3545B)
-│ │ │ ├── useModelData.ts (4877d58, 158L, 5483ch, 5609B)
+│ │ │ ├── useModelData.ts (b582ec4, 173L, 6319ch, 6451B)
│ │ │ └── useThemeMode.ts (1e0a3db, 36L, 1642ch, 1642B)
│ │ ├── logic/ (11)
│ │ │ ├── color.ts (8006242, 39L, 1796ch, 1796B)
@@ -492,7 +492,7 @@
│ └── App.tsx (8ff50d4, 78L, 2470ch, 2474B)
├── .gitignore (6d3972b, 53L, 809ch, 811B)
├── .signalsignore (50ea1be, 28L, 1036ch, 1050B)
-├── CHANGELOG.md (871fba9, 218L, 17630ch, 17636B)
+├── CHANGELOG.md (dd55a4d, 240L, 18854ch, 18860B)
├── CLAUDE.md (7e4582d, 181L, 22826ch, 23053B)
├── CONTRIBUTING.md (592685f, 53L, 2043ch, 2047B)
├── README.md (727982d, 69L, 4028ch, 4036B)
@@ -500,19 +500,19 @@
├── bun.lock (01dab35, 107L, 7820ch, 7820B)
├── bunfig.toml (3ab75b0, 2L, 35ch, 35B)
├── install.sh (64a6757, 116L, 3578ch, 3594B)
-├── package.json (2fca4c9, 48L, 1804ch, 1806B)
+├── package.json (5642340, 48L, 1804ch, 1806B)
├── release-please-config.json (9f191fe, 15L, 416ch, 416B)
-├── release-please-manifest.json (c2ec4bd, 3L, 20ch, 20B)
+├── release-please-manifest.json (a0fb1f6, 3L, 20ch, 20B)
└── tsconfig.json (1d9427f, 37L, 870ch, 870B)
## Manifests
-- package.json: name=ignatius, version=0.12.0, scripts=[build, build:bundle, build:cli, build:stable-names, cli, dev, dev:cli, start, test, typecheck]
+- package.json: name=ignatius, version=0.14.0, scripts=[build, build:bundle, build:cli, build:stable-names, cli, dev, dev:cli, start, test, typecheck]
## Languages
-- TypeScript: 59159 LOC (70%), 265 files (45%)
-- Markdown: 20461 LOC (24%), 298 files (50%)
+- TypeScript: 59201 LOC (70%), 265 files (45%)
+- Markdown: 20483 LOC (24%), 298 files (50%)
- CSS: 3004 LOC (3%), 2 files (0%)
- YAML: 1340 LOC (1%), 14 files (2%)
- Shell: 116 LOC (0%), 1 file (0%)
diff --git a/src/app/hooks/useModelData.ts b/src/app/hooks/useModelData.ts
index 2d414db..2c9f0f8 100644
--- a/src/app/hooks/useModelData.ts
+++ b/src/app/hooks/useModelData.ts
@@ -67,6 +67,16 @@ export function useModelData(opts?: UseModelDataOptions): {
};
useEffect(() => {
+ // React StrictMode double-invokes this effect in dev (mount→cleanup→mount).
+ // The boot fetch below has no way to cancel an in-flight request, so without
+ // this flag the FIRST invocation's fetch still resolves after its own
+ // cleanup ran and calls setFlowDiagrams/setModel anyway — a second,
+ // reference-changed update lands shortly after the real one, retriggering
+ // every effect keyed on `flowDiagrams`/`model` (including the flow
+ // renderer's mount effect) while the first mount is still settling. This
+ // is the documented React pattern for fetch-in-effect: a local flag set on
+ // cleanup, checked before every setState in a `.then()`.
+ let ignore = false;
const mode = window.__IGNATIUS_MODE__;
if (mode === 'static') {
@@ -126,24 +136,29 @@ export function useModelData(opts?: UseModelDataOptions): {
Promise.all([doModelFetch(), doFlowFetch()])
.then(([modelPayload, flowPayload]) => {
+ if (ignore) return;
applyModelPayload(modelPayload);
applyFlowPayload(flowPayload);
})
- .catch(err => console.error('[ignatius] boot co-fetch failed:', err));
+ .catch(err => { if (!ignore) console.error('[ignatius] boot co-fetch failed:', err); });
const es = new EventSource('/events');
es.addEventListener('model-changed', () => {
Promise.all([doModelFetch(), doFlowFetch()])
.then(([modelPayload, flowPayload]) => {
+ if (ignore) return;
applyModelPayload(modelPayload);
applyFlowPayload(flowPayload);
setBannerDismissed(false);
onSseRefreshRef.current?.(modelPayload.validation.cleanedModel);
})
- .catch(err => console.error('[ignatius] SSE refetch failed:', err));
+ .catch(err => { if (!ignore) console.error('[ignatius] SSE refetch failed:', err); });
});
- return () => es.close();
+ return () => {
+ ignore = true;
+ es.close();
+ };
}, []);
return {
diff --git a/src/app/views/flow/FlowsView.tsx b/src/app/views/flow/FlowsView.tsx
index b7e0f5e..4268320 100644
--- a/src/app/views/flow/FlowsView.tsx
+++ b/src/app/views/flow/FlowsView.tsx
@@ -283,6 +283,17 @@ export function initFlowGraphCore(
// its generation number; after any await the call checks whether it is still the
// latest — if a newer call started while ELK was computing, the stale call aborts.
let renderGen = 0;
+ // Set true by the teardown function returned below. renderGen alone only
+ // guards against a NEWER renderDiagram call within this same still-alive
+ // instance; it does nothing when the whole instance is torn down (e.g. React
+ // StrictMode's dev-mode mount→cleanup→mount, or the user navigating away
+ // while ELK is still computing) — svgRoot is still null at that point (the
+ // async call hasn't reached "create root" yet), so teardown has nothing to
+ // unmount, and the orphaned continuation later resumes, finds the shared
+ // `container` still connected, and injects a second live React root into it.
+ // Checked after every await in renderDiagram so a disposed instance never
+ // touches the DOM again.
+ let disposed = false;
// Returns the fingerprint for a diagram id from the injected layout keys map.
// Static: window.__FLOW_LAYOUT_KEYS__; live: injected by the app-level flow
@@ -292,6 +303,11 @@ export function initFlowGraphCore(
}
async function renderDiagram(diagram: FlowDiagram) {
+ // This instance was torn down before this call even started (e.g. queued
+ // via resetLayout/drillUp right as the effect cleaned up) — bail before
+ // touching anything.
+ if (disposed) return;
+
// Claim this render generation. Any concurrent renderDiagram that starts
// while we await ELK will bump renderGen and invalidate ours.
renderGen += 1;
@@ -326,7 +342,14 @@ export function initFlowGraphCore(
const elkResult = await computeElkLayout(diagram);
// Guard: if a newer renderDiagram call started while ELK was computing
// (e.g. the user clicked a different DFD), abort — the newer call wins.
- if (renderGen !== myGen) return;
+ // Guard: if this WHOLE instance was torn down while we awaited (e.g.
+ // React StrictMode's dev-mode mount→cleanup→mount, or navigating away
+ // and back before ELK finished) — svgRoot was still null when teardown
+ // ran, so it had nothing to unmount; without this check the orphaned
+ // call would resume, find `container` still connected (App keeps every
+ // view's container mounted across view switches), and inject a second
+ // live React root into it.
+ if (renderGen !== myGen || disposed) return;
elkPositions = elkResult.positions;
// CP4b: capture routed edge geometry alongside positions.
// On ELK failure (catch below), elkEdgeRoutes stays undefined so the
@@ -335,11 +358,11 @@ export function initFlowGraphCore(
} catch (err) {
// ELK layout failure is non-fatal: fall back to banded positions.
console.warn('[ignatius] ELK layout failed, falling back to banded positions:', err);
- if (renderGen !== myGen) return;
+ if (renderGen !== myGen || disposed) return;
}
- // Guard: if the renderer was torn down while we awaited ELK (e.g. view
- // switched away), the container will have been removed — abort.
+ // Guard: if the container element itself was removed from the document
+ // (a full unmount, not just this instance's teardown), abort.
if (!container.isConnected) return;
// Create a fresh full-bleed sub-div for the SVG.
@@ -544,6 +567,10 @@ export function initFlowGraphCore(
}
return () => {
+ // Set before anything else: any renderDiagram call still awaiting ELK at
+ // this point must resume as a no-op instead of racing the next instance
+ // for the shared `container` (see the disposed checks above).
+ disposed = true;
chromeCallbacks?.onResetLayout?.(null);
chromeCallbacks?.onRegisterRetheme?.(null);
chromeCallbacks?.onRegisterSearchTokens?.(null);