From a71740f4153aec7fb7002b844f6d9be375d3cb2d Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Tue, 14 Jul 2026 23:55:24 -0400 Subject: [PATCH 1/2] fix(app): stop StrictMode double-fetch from racing the flow renderer useModelData's boot fetch had no cancellation, so React StrictMode's dev-mode mount->cleanup->mount double-invoke fired it twice; the first response still landed after its own effect cleaned up and reset flowDiagrams/model with a fresh reference, retriggering FlowsView's render effect while the prior mount was still settling. Two live React roots ended up writing into the same shared container, surfacing as "Cannot update an unmounted root" / "Attempted to synchronously unmount a root while React was already rendering" on flow-view mount. Guards the fetch effect with the standard ignore-flag cleanup pattern, and separately hardens FlowsView's renderDiagram against any instance whose owning effect was torn down while it awaited ELK layout. --- src/app/hooks/useModelData.ts | 21 ++++++++++++++++--- src/app/views/flow/FlowsView.tsx | 35 ++++++++++++++++++++++++++++---- 2 files changed, 49 insertions(+), 7 deletions(-) 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); From dce2ea2345aa80f3b756997125cf5b7cc19a8549 Mon Sep 17 00:00:00 2001 From: Danilo Alonso Date: Tue, 14 Jul 2026 23:59:14 -0400 Subject: [PATCH 2/2] chore(signals): refresh after flow-search-race fix --- docs/wiki/index.md | 12 ++++++------ docs/wiki/scan.md | 14 +++++++------- 2 files changed, 13 insertions(+), 13 deletions(-) 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 +f4d2d14b4db849f2c97d39176536d5f5c80d100d 1 # 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%)