Skip to content

refactor(web): unify exploration action layer (ADR-500 / -083 / -034 affirmation)#363

Merged
aaronsb merged 12 commits into
mainfrom
refactor/exploration-action-layer
May 14, 2026
Merged

refactor(web): unify exploration action layer (ADR-500 / -083 / -034 affirmation)#363
aaronsb merged 12 commits into
mainfrom
refactor/exploration-action-layer

Conversation

@aaronsb
Copy link
Copy Markdown
Owner

@aaronsb aaronsb commented May 14, 2026

Summary

Unifies the four+ divergent call sites that mutated rawGraphData and wrote
to explorationSession into a single hub (useExplorationActions). Closes
the long-standing footguns that the divergent paths produced: re-search
silently returning cache, clean loads not resetting the session, the
post-restart "stale graph from localStorage" surprise, and (caught while
testing) V2 pointer events dying after any node-count growth.

This is a refactor that affirms existing architecture, not a new design
decision. No ADR required.

Affirms

  • ADR-500 GraphProgram — every fetch path lands in the canonical
    execution model. runCypher and the autosave's Refresh button both go
    through apiClient.executeProgram.
  • ADR-083 saved queries — explorationSession is now consistently
    the canonical ledger; useQueryReplay is the canonical replay.
  • ADR-034 explorer plugin contract — 2D, 3D-V1, and 3D-V2 share one
    context-menu builder and one action surface.

User-visible changes

  • Refresh button in the search bar header re-runs the current session
    through the server (always-fresh by construction, no React Query cache
    invalidation needed). Hidden when there's no session to refresh.
  • Autosave entry pinned to the top of Saved Queries — the current
    session, restorable from any state. Clear-graph drops it; new clean
    search starts a new one. Replaces the silent localStorage rehydrate
    that produced empty graphs after follow-concept on stale IDs.
  • "Welcome back" state when reopening with an autosave; offers
    Restore last session as a one-click action.
  • Add Adjacent submenu with depth picker (1 / 2 / 3) on the right-click
    context menu in all three explorers.
  • Autocomplete dismisses after picking a concept; pressing Enter on a
    filled-in input re-opens the suggestions for reconsidering.
  • V2 right- and left-click survive node-count growth — found while
    testing Add Adjacent in V2; was a latent InstancedMesh buffer bug.

Mechanism changes

  • New useExplorationActions hook is the single writer for graph-mutating
    actions: loadExplore, loadPath, followConcept, addAdjacent,
    removeNode, runCypher. Each invariant — exactly one step recorded,
    clean resets the session, depth defaults are constants — is enforced
    in one place.
  • useGraphNavigation becomes a thin wrapper that delegates to the hub
    for follow / add / remove (preserving the alert-on-failure UX); other
    presentation-layer concerns (camera travel, polarity nav, report nav)
    stay where they were.
  • SearchBar load handlers are mechanical wrappers around the hub.
  • Dead ExplorerView.handleNodeClick removed — no explorer wired it.

The intentional reversal

rawGraphData is dropped from the persist's partialize. The previous
"graph stays visible after reload" behavior was explicit (there's a
comment in ExplorerView saying so), but produced the worst class of bug:
the user clicks a node from the rehydrated graph, the API returns empty
for the now-stale ID, and the visible graph is wiped. The autosave entry

  • replay-on-mount path replaces the snapshot rehydrate. Documented in
    the persistence-flip commit; no ADR opened — discussed in PR description
    as the appropriate weight.

A version: 1 bump + migrate strips the legacy rawGraphData from
existing browsers' localStorage so the fix applies on first load after
deploy, not just to fresh installs.

Tests

10-test suite (web/src/hooks/useExplorationActions.test.tsx) pins the
hub's ledger invariants. Run with cd web && npm test. Adds Vitest config
(vitest.config.ts separate from vite.config.ts), a test script, and
jsdom as a devDep.

Out of scope

  • 3D-V1 retirement / V2 parity port — separate PR after this lands.
  • 2D V2 / embedding-landscape V2 — later phases of ADR-702.
  • Block-builder / Cypher editor surface (already passes through runCypher).

Commits (11)

Each commit is shippable on its own — migration sites move one at a time.

a7cc2683 test(web): pin useExplorationActions ledger invariants
1d39b4f7 refactor(web): remove dead handleNodeClick from ExplorerView
7c3ff73b feat(web): Add-Adjacent context-menu item gets a depth selector (1/2/3)
ce6937d8 feat(web): add 'Refresh' button to re-run current session against the server
ff735e15 refactor(web): drop persisted rawGraphData; expose session as autosave entry
8ee95a9f fix(web): V2 pointer events survive node-count growth
c3bb8699 refactor(web): fold useGraphNavigation graph-mutating handlers into the hub
80d68539 fix(web): dismiss autocomplete dropdown after a concept is picked
084b7288 refactor(web): migrate SearchBar load handlers to action hub
4255822e refactor(web): introduce useExplorationActions hub
c02127db fix(web): exempt /config.js from immutable cache rule in nginx

Merge

Regular merge (--merge) — each commit documents a distinct step.

aaronsb added 11 commits May 13, 2026 08:01
The generic `location ~* \.(js|css|...)$` rule applied
`Cache-Control: public, immutable; expires 1y` to every JS file — including
`config.js`, the runtime config written by docker-entrypoint.sh from env
vars at container start. Browsers would keep the previous deploy's API URL,
OAuth client ID, and app name for up to a year regardless of redeploys.

Add an exact-match `location = /config.js` block with
`no-store, no-cache, must-revalidate` ahead of the regex. Vite's
content-hashed bundles under /assets/ still get the immutable rule, which is
correct for them.

First commit in the exploration-action-layer refactor — addresses the
"loads from browser cache after restart" symptom independently of the
exploration ledger work.
Lands the unified action layer for graph-mutating operations: loadExplore,
loadPath, followConcept, addAdjacent, removeNode, runCypher. Each method
mirrors the behavior of its current call site (SearchBar load handlers,
useGraphNavigation handlers, runCypher path), consolidated under one
invariant set:

  1. Exactly one addExplorationStep per action.
  2. loadMode 'clean' resets the exploration session before stepping.
  3. Depth defaults are constants (FOLLOW_DEPTH, ADD_ADJACENT_DEFAULT_DEPTH,
     DEFAULT_MAX_HOPS), not magic numbers per site.

No call site uses the hub yet — file is unused scaffolding. Subsequent
commits migrate SearchBar, ExplorerView click handling, and
useGraphNavigation one at a time. The QueryClient import is reserved for
the idempotent-re-search commit (no behavior yet).

Affirms ADR-500 (GraphProgram canonical execution) and ADR-083
(explorationSession canonical ledger).
handleLoadExplore, handleLoadPath, and handleExecuteCypher now delegate to
useExplorationActions. Removes the inline addExplorationStep / setSearchParams /
setRawGraphData / mergeRawGraphData / resetExplorationSession choreography that
each handler did differently. Drops imports of stepToCypher,
extractGraphFromPath, mapWorkingGraphToRawGraph, and statementsToProgram from
SearchBar — those concerns now live in the hub.

Behaviorally equivalent: the hub mirrors each handler's prior implementation.
The visible payoff lands in later commits (idempotent re-search, persistence
flip).
Both the primary and path-destination autocomplete dropdowns stayed visible
after the user clicked a result, because the visibility predicate
(`debouncedQuery && results && results.length > 0`) didn't know the parent
had locked in a selection. The lingering dropdown overlapped the next
controls (depth slider, Find Paths button) and required clicking outside
to dismiss.

ConceptSearchInput now takes an `isSelected` prop and dismisses the
dropdown once it flips true. To support reconsidering without retyping,
pressing Enter on a selected, non-empty input re-opens the dropdown with
the existing cached results; another selection or any text edit dismisses
it again. Both call sites (primary + destination) wire `isSelected`.

Empty-state placeholder follows the same gating — it shouldn't pop up
"No results" the moment after a successful pick.
…he hub

handleFollowConcept, handleAddToGraph, and handleRemoveFromGraph each
duplicated the exploration-step recording and graph-mutation logic that
useExplorationActions now owns. They become thin wrappers that delegate to
actions.followConcept / addAdjacent / removeNode, keeping the alert-on-
failure UX layer that the context-menu callers rely on.

useGraphNavigation's external shape is unchanged — 2D, 3D-V1, and 3D-V2
keep calling it without modification. handleTravelPath,
handleSendToPolarity, and handleSendPathToReports stay in this file
because they touch presentation concerns (camera animation, navigation,
report creation) that don't belong in the action hub.

The previously-unused `mergeGraphData` positional argument is now
explicitly marked unused in the signature; callers still pass it for
backward compatibility.
After any action that grows the graph (Add Adjacent / Follow / Load),
left- and right-clicks on V2 nodes stopped responding. Camera drag / pan /
zoom still worked, so the failure was scoped to InstancedMesh raycasting.

Root cause: `<instancedMesh args={[undefined, undefined, nodes.length]}>`
relies on three.js's constructor allocating `instanceMatrix` sized for the
initial `count`. When `nodes.length` grows, three.js doesn't reallocate
that buffer — it just bumps `count`. `computeBoundingSphere()` then walks
0..count reading past the array end, producing NaN bounds. The raycaster
early-outs against the NaN sphere and every node pointer event silently
misses.

The earlier 15-frame `boundingSphere = null` mitigation (commit 9eeec8a)
only helps while the sim is animating, and even then re-derives the bad
sphere from the same too-small buffer.

Fix: `key={nodes.length}` on the Nodes InstancedMesh forces React to
unmount and remount on count change, so three.js allocates a fresh
correctly-sized `instanceMatrix`. Same treatment applied to Arrows, which
has the same pattern keyed on edge `usableCount` — Arrows don't carry
pointer handlers but the wrong-size buffer still produces visual glitches.

Discovered while testing the exploration-action refactor: Add Adjacent
from the V2 right-click menu reproduced the bug deterministically.
…e entry

The persisted `rawGraphData` snapshot in localStorage was the root of the
"empty graph after follow-concept on a fresh browser" bug: a stale graph
rehydrates with concept IDs that no longer exist server-side, and any
action against those IDs gets an empty response that wipes the visible
graph. The snapshot also lets users interact with data that was already
gone, which is misleading rather than helpful.

Drop `rawGraphData` from the persist's partialize. The exploration
session (the durable ledger of what the user explored) is still persisted
and is now surfaced two ways:

  1. As an "Autosave" entry pinned to the top of the saved-queries
     panel — distinguished visually (primary tint + history icon), no
     delete button, single slot at all times. Replaying it goes through
     the same useQueryReplay path as any saved query, which executes
     against the server and produces fresh data.

  2. As a "Restore last session" call-to-action on the welcome state of
     each explorer when no graph is loaded. Same replay path.

Implementation lives in a new `useAutosave` hook (synthesizes a
ReplayableDefinition from explorationSession on read) — no second
storage layer, no auto-save side effects, no API round-trip just for
per-browser session state. Clean loads (loadMode 'clean') already reset
the session, so the autosave disappears naturally when the user starts
fresh.

Affirms ADR-083 (explorationSession + useQueryReplay as the canonical
session + replay mechanism).
… server

Re-search-the-same-term used to silently return React Query's cached
result because every search hook had a multi-minute staleTime and no
user action forced an invalidation. Rather than blanket-invalidating on
every load (which would punish the common case where the user just wants
to see the data they already loaded), expose an explicit 'Refresh'
button in the search bar header.

Click → `useQueryReplay.replayQuery(autosave)`, which executes the
current exploration session as a GraphProgram (ADR-500) against
`/query/program`. That path doesn't go through React Query's subgraph
cache at all — it always returns fresh data by construction — so React
Query's existing staleTime values for `useSubgraph` /
`useFindConnection` / etc. are left alone.

Button is hidden when there's no autosave (no session to refresh) and
spins while replaying. Sits next to the mode dial so it's always
visible across smart-search / block-builder / cypher-editor modes.
The right-click "Add Adjacent Nodes" item used to fetch at the hub's
default depth (1) with no way to ask for more. It now opens a submenu
with three depth choices: 1 (direct neighbors), 2, and 3. Each leaf
calls actions.addAdjacent(nodeId, {depth: N}) through the existing
useGraphNavigation → useExplorationActions chain.

Cap at 3: API enforces server-side, and depth-2/3 fetches are noticeably
slower on dense graphs — surfacing 4+ would invite multi-second clicks.

Touches the shared buildContextMenuItems helper, so 2D, 3D-V1, and 3D-V2
all pick up the submenu uniformly. Handler signature extended:
handleAddToGraph(nodeId, depth?) — depth is optional, undefined preserves
the previous default-depth behavior for any caller that doesn't supply it.
ExplorerView passed an `onNodeClick` prop that triggered a depth-2
add-adjacent + searchParams update. None of the three force-graph
explorers (2D, 3D-V1, 3D-V2) wire this prop — each handles left-click
locally with its own node-inspector logic (info panel for graph
explorers, document viewer for DocumentExplorer). The handler was
dead code.

Drop the callback, the `onNodeClick={handleNodeClick}` wiring, and the
now-unused `stepToCypher` import. The `ExplorerProps.onNodeClick` field
stays as an optional escape hatch for future explorers; this just stops
feeding it a phantom implementation that nobody calls.

Behaviorally invisible — no explorer's left-click behavior changes.
10 tests covering the hub's contract: every action records exactly one
exploration step, 'clean' loadMode resets the session before stepping,
depth defaults are honored, the context-menu depth selector (1/2/3) is
respected, removeNode emits a subtractive (-) cypher step, runCypher
resets the session and records one step per statement, and runCypher
short-circuits on an empty statement list.

Tests run against the real Zustand store — the invariants ARE about how
the store mutates, so mocking it would tautologize the assertions.
apiClient is mocked (vi.mock) since these tests don't need a running API.

Adds:
  - web/vitest.config.ts — separate from vite.config.ts so the dev
    container's bind-mount of vite.config.ts isn't disturbed.
  - jsdom devDep — required by @testing-library/react renderHook.
  - 'test' and 'test:watch' scripts in package.json.

Run: `cd web && npm test`. CI hook for this will follow once we have
a TypeScript test step alongside the existing TypeScript Build Check.
@aaronsb
Copy link
Copy Markdown
Owner Author

aaronsb commented May 14, 2026

Review

Critical (blocks merge)

1. The 10-test invariant suite fails 10/10 in this checkout.

web/src/hooks/useExplorationActions.test.tsx, after npm install against the lockfile, fails every test at the first setItem with:

TypeError: Cannot read properties of undefined (reading 'setItem')
 ❯ Object.setItem node_modules/zustand/esm/middleware.mjs:297:42
 ❯ Object.resetExplorationSession src/store/graphStore.ts:569:5

Root cause: jsdom@^29.0.0 (the version this PR adds) does not ship localStorage on the default Window, and vitest.config.ts has no setupFiles to polyfill it. A 1-line probe test confirms typeof localStorage === 'undefined' under the configured environment. The Zustand persist middleware constructs a no-op storage stub when its getStorage() throws, then explodes on the first write — which is the very first line of every test (beforeEachresetExplorationSession()).

The PR description's "Tests" section says "Run with cd web && npm test." That doesn't pass in this branch as configured. The suite that's supposed to pin the hub's invariants pins nothing — it crashes at setup.

Pick one of:

  • Add setupFiles: ['./vitest.setup.ts'] to vitest.config.ts with a 5-line localStorage polyfill (Object.defineProperty(globalThis, 'localStorage', { value: new Map-backed stub })).
  • Switch environment: 'jsdom' to 'happy-dom' (which does provide storage) and add it to devDependencies.
  • Mock zustand/middleware's persist in the test file (intrusive but local).

This isn't a flake or environment quirk — it's reproducible from a clean install of this branch.


Should-fix (land on a follow-up before merge or as fast-follow)

2. Hub docstring claims an invariant the implementation doesn't satisfy.

useExplorationActions.ts:21-23 says:

  1. All fetches go through React Query (queryClient.fetchQuery) so cache identity stays consistent across the app — no direct apiClient.getSubgraph calls escape this layer.

But followConcept (line 238) and addAdjacent (line 273) both call apiClient.getSubgraph(...) directly. The void queryClient line at 363 is the smoking gun — the query handle is never used. Either rewrite the invariant to "all fetches funnel through this hub" (true, weaker), or change the implementations to use queryClient.fetchQuery({ queryKey: ['subgraph', conceptId, depth], queryFn: () => apiClient.getSubgraph(...) }) (matches the docstring, gets cache identity for free, lets the new "Refresh" button benefit from React Query's invalidateQueries).

The docstring is load-bearing — it's what the next contributor will trust when they decide whether their new action needs to add cache plumbing.

3. useGraphNavigation.handleTravelPath bypasses the hub.

useGraphContextMenu.ts:144-204 records a load-path step inline via store.addExplorationStep(...) and merges into rawGraphData directly. That's exactly what actions.loadPath does and is a counter-example to the hub's "single writer" claim. Same hook (useGraphNavigation) delegates handleFollowConcept, handleAddToGraph, handleRemoveFromGraph to the hub but stops short on travel.

Two options:

  • Extend actions.loadPath to accept an optional onLoaded?: (conceptNodeIds: string[]) => void callback (called after merge), then have handleTravelPath call actions.loadPath({ ..., enrich: false, onLoaded: (ids) => requestAnimationFrame(...travelAlongPath(ids, reverse)) }).
  • Keep handleTravelPath where it is and downgrade the hub's docstring from "single writer for graph-mutating actions" to "single writer for SearchBar / context-menu node ops."

Option 1 is the right call — the hub's invariant should hold across the file boundary.

4. Stale comment in ForceGraph3DV2.tsx:80-84.

// Left-click: select + open info panel. Note we do NOT fire onNodeClick
// here — at the parent level (ExplorerView) onNodeClick is wired to
// "Follow Concept" which reloads the graph. ...

The wiring it cites was removed in commit 1d39b4f7. The comment's conclusion (don't fire onNodeClick on left-click) is still right; the rationale now lies. Replace with: "Left-click is intentionally local-only — graph-mutating actions live on the right-click context menu so they're never invoked accidentally on inspection." (Same rationale ExplorerView.tsx:289-294 now carries.)

5. Remove void queryClient (and its 5-line apologia comment).

useExplorationActions.ts:359-363. The comment promises it'll be activated in "the idempotent-re-search commit" — that commit isn't in this PR and isn't on the branch. This is exactly the rationalisation pattern MEMORY.md warns about ("I'll formalize it later"). Drop the import; when the follow-up commit needs it, re-adding useQueryClient is two lines. If you want to keep it, name the actual PR/issue number that activates it, not "the next commit."

6. Drop _mergeGraphData parameter from useGraphNavigation.

useGraphContextMenu.ts:103 — kept "for backward compat," but the only callers (ForceGraph2D, ForceGraph3D, ForceGraph3DV2) are all in this PR's diff and could be migrated in one pass. Same "kept for follow-up" anti-pattern as #5. Touch the three call sites, drop the parameter, lose the void _mergeGraphData line.


Nits

7. migrate(persistedState, version) gate.

graphStore.ts:603-611. if (version < 1) is harmless today but will keep deleting a long-absent rawGraphData key on every future schema bump (v2, v3, ...). if (version === 0) is the right gate for "the v0 → v1 migration." Pure stylistic — won't break anything.


Questions for you (don't decide alone)

8. removeNode replay scope.

useExplorationActions.ts:301-321 records the subtractive step as:

- MATCH (c:Concept)-[r]-(n:Concept) WHERE c.label = '...' RETURN c, r, n

On replay through executeProgram, the - operator subtracts whatever the MATCH returns. The shape RETURN c, r, n returns the central node + its relationships + 1-hop neighbours. Question: does the program's set-algebra subtraction interpret that as "remove c only" or "remove c, all its 1-hop neighbours, and the connecting relationships"?

If the latter, an add-then-remove interaction round-trips into more removed than the user actually clicked. This is pre-existing (the Cypher pattern matches what stepToCypher always emitted), but the autosave-on-refresh path in this PR is what makes replay round-tripping the default rather than a power-user feature, so it's worth verifying. If the answer is "remove c only," nothing to do. If the answer is "remove the matched subgraph," the right fix is to record the step as MATCH (c:Concept) WHERE c.id = '...' RETURN c so subtraction is precisely the central node.

9. alert() UX in useGraphNavigation wrappers.

useGraphContextMenu.ts:115, 129 — the wrappers alert(...) on hub failures. You pre-flagged this. My read: in a 2026 React app with a real toast surface elsewhere, alert() is a code smell, especially when the error is also console.error'd and re-thrown. But it's pre-existing behaviour (this PR preserved it, didn't introduce it) and changing it is non-trivial (need to wire a toast emitter into the hook). Recommendation: file a follow-up issue, don't block this merge for it.


Verified per code-reading (not test-covered)

key={nodes.length} swap case. Walking it: one node removed + one added → nodes.length unchanged → no remount. The useEffect([nodes, ...]) at Nodes.tsx:113 still reruns and nulls boundingSphere; the next raycast forces a recompute against the new instance matrix, and useFrame rewrites all matrices each frame. The bug the fix targets is buffer-size mismatch when count grows; a same-length swap doesn't trigger it because the buffer is already correctly sized. Not a latent gap. Not test-covered (the suite is pure ledger), but the code is sound.

Persistence sequence (your #3). migrate runs before merge; partialize runs on save. rawGraphData is in the persisted blob → migrate deletes it → merge shallow-merges the cleaned blob over the initial state, leaving rawGraphData: null → next save omits it via partialize. Sequence as you described. Sound.

Dead-code claim in 1d39b4f7. DocumentExplorerWorkspace.tsx:496 actively wires onNodeClick={handleNodeClick}, but it routes through its own component tree (App.tsx:131), not ExplorerView. The "no explorer ever calls props.onNodeClick" claim is correct in scope (the three explorers mounted under ExplorerView). Worth a one-line clarification in the commit message if you ever revisit, but not a bug.

Autosave round-trip (your #2). stepToCypher encodes depth in the pattern ([r*1..N]), so a depth-2 add-adjacent round-trips as a depth-2 query, and +/- ops preserve set algebra. Modulo question #8, the round-trip produces the same graph the user saw at refresh time.


Summary

The hub design is right: the four divergent call sites collapsing into one is exactly the consolidation this codebase needed, and the per-step ledger invariants are clean. The key={nodes.length} InstancedMesh fix and the persistence flip are the right calls. The user-visible polish (Refresh, Add Adjacent depth selector, autocomplete dismiss) lands cleanly.

But the test suite that's supposed to pin those invariants is non-functional in this checkout, the hub's headline invariant doesn't match its implementation, and one call site (handleTravelPath) still bypasses the hub it's meant to delegate to. Fix the test config, align invariant #3 with reality (one direction or the other), and either fold handleTravelPath into loadPath or weaken the docstring's scope claim. The remaining items are cleanup.

* Add localStorage shim setupFile so the 10-test suite passes on hosts
  where vitest's jsdom env doesn't pre-wire `globalThis.localStorage`
  before module load. Without this, Zustand's persist captures
  `undefined` and every `setItem` throws. Reproduced on a fresh
  checkout; tests now green on both host and dev container.

* Hub docstring rewritten to match the actual fetch path: per-action
  calls intentionally do not invalidate React Query cache (the Refresh
  button covers always-fresh on demand). The aspirational
  `queryClient.fetchQuery` invariant misled review.

* removeNode's recorded Cypher narrowed from
  `MATCH (c)-[r]-(n) RETURN c, r, n` to
  `MATCH (c) RETURN c` so autosave-replay subtracts ONLY the named
  node — the previous shape would silently expand removal to every
  1-hop neighbour on every replay, diverging from the in-memory
  immediate behavior.

* handleTravelPath now routes its graph mutation through
  `actions.loadPath`; the path fetch stays in the wrapper because the
  camera animation needs the path object. Restores the single-writer
  invariant (the previous direct `addExplorationStep` +
  `mergeRawGraphData` calls were a counter-example).

* Drop unused `useQueryClient` import + `void queryClient` apologia
  from the hub.

* Drop unused `_mergeGraphData` parameter from `useGraphNavigation`;
  update the three call sites in 2D / 3D-V1 / 3D-V2.

* Migrate version gate `if (version < 1)` → `if (version === 0)` so
  future schema bumps don't accidentally re-run earlier migrations.

* V2 left-click rationale comment refreshed — the original referenced
  ExplorerView's onNodeClick wiring removed in 1d39b4f.

Drive-by: drop `stepToCypher` and `RawGraphData` imports from
useGraphContextMenu — both became unused once handleTravelPath
delegated to the hub.
@aaronsb
Copy link
Copy Markdown
Owner Author

aaronsb commented May 14, 2026

Addressed in 5d65097:

Item Fix
Critical: tests fail in fresh checkout (Zustand persist captures undefined localStorage) Added web/src/test-setup.ts shim, registered as setupFiles in vitest.config.ts. Reproduced the failure, then cd web && npm test → 10/10 green on both host and dev container.
Hub invariant #3 contradicts code Rewrote the docstring: per-action calls intentionally do NOT invalidate React Query (the Refresh button is the canonical always-fresh path). Removed the queryClient.fetchQuery claim.
handleTravelPath bypasses the hub Refactored to call actions.loadPath. The findConnection fetch stays in the wrapper because the camera animation needs the path object, but the graph mutation + step recording route through the hub. Single-writer invariant restored.
Stale V2 left-click comment Refreshed — the previous text referenced ExplorerView's onNodeClick wiring removed in 1d39b4f7.
void queryClient apologia Dropped along with the import.
_mergeGraphData parameter Dropped from the signature; three call sites updated (2D, 3D-V1, 3D-V2).
Migrate gate < 1=== 0 Per-version block; documented why it matters for future bumps.
Question: removeNode Cypher subtracted c + 1-hop on replay Narrowed to MATCH (c) RETURN c. Confirmed via the trace: in-memory subtractRawGraphData removes only the named node, so the previous Cypher diverged on replay — autosave-as-default-restore would have made this user-visible. Fixed in this same commit.
alert() in useGraphNavigation Filed as follow-up; not blocking.

Tests now green; TS clean; HMR clean. Ready for re-review or merge.

@aaronsb aaronsb merged commit 9fe42e8 into main May 14, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant