Skip to content

refactor: remove Zustand nodeStates mirror, subscribe to Jotai atoms directly#417

Merged
streamer45 merged 5 commits into
mainfrom
devin/1778348510-remove-zustand-nodestates-mirror
May 10, 2026
Merged

refactor: remove Zustand nodeStates mirror, subscribe to Jotai atoms directly#417
streamer45 merged 5 commits into
mainfrom
devin/1778348510-remove-zustand-nodestates-mirror

Conversation

@staging-devin-ai-integration
Copy link
Copy Markdown
Contributor

@staging-devin-ai-integration staging-devin-ai-integration Bot commented May 9, 2026

Summary

After the Jotai atom refactor in #392, useNodeStatesSubscription was half-vestigial — it only patched edge alerts but still subscribed to all of Zustand nodeStates with a 100ms throttle and forced a dual-write in websocket.ts. This PR removes the Zustand nodeStates mirror entirely and subscribes directly to Jotai atoms.

Changes

  • Rename useNodeStatesSubscriptionuseEdgeAlertSubscription (reflects actual responsibility)
  • Subscribe directly to per-node Jotai state atoms via sessionStore.sub(nodeStateAtom(...)) instead of Zustand nodeStates
  • Drop batchUpdateNodeStatesMulti call from websocket.ts — the Jotai write is the only one needed
  • Fully remove nodeStates from Zustand SessionData and all related actions (updateNodeState, batchUpdateNodeStates, batchUpdateNodeStatesMulti)
  • Remove 100ms throttle — edge alerts are infrequent, the throttle added latency without benefit
  • Migrate SessionItem / SessionInfoChip from Zustand nodeStates to a new useSessionNodeStates hook that aggregates Jotai atoms keyed by pipeline node IDs
  • Shallow-equality guard in useSessionNodeStates prevents unnecessary re-renders when atom values haven't changed (e.g. duplicate writes within a single RAF flush)
  • Disposed guard in both hooks prevents stale microtask callbacks from patching the wrong session graph after effect cleanup
  • Reset topoEffectRanRef on effect re-run to prevent stale patches during session transitions
  • renderHook tests for both useEdgeAlertSubscription and useSessionNodeStates — covering edge patching gating, recovery, reference stability, and session status integration

Subsumes #409 (closed). Closes #397.

Review & Testing Checklist for Human

  • Edge alerts in Monitor View: Connect a pipeline with a slow input timeout scenario and verify the edge alert badges appear/disappear correctly on the flow canvas
  • Session status in sidebar: Verify the session status badge (running/degraded/stopped) in the monitor sidebar updates correctly as nodes change state
  • Session info chip: Verify the expandable session info chip on the canvas top bar shows correct status and issues
  • Reference stability: Verify in React DevTools that SessionItem / SessionInfoChip don't re-render on every RAF flush when node states haven't changed (shallow-equality guard)

Notes

  • The useSessionNodeStates hook reads node IDs from Zustand pipeline (low-frequency structural data) and states from per-node Jotai atoms (high-frequency updates), following the state management split documented in agent_docs/ui-development.md
  • Tests use await act(async () => ...) for atom changes because the hooks use queueMicrotask coalescing — microtasks need an async act boundary to flush within the test

Link to Devin session: https://staging.itsdev.in/sessions/f56b91a30f8240d69b062e71d013d755
Requested by: @streamer45


Devin Review

Status Commit
🕐 Outdated 7983eac (HEAD is a376ab5)

Run Devin Review

Open in Devin Review (Staging)

…directly

After the Jotai atom refactor (#392), useNodeStatesSubscription was
half-vestigial — it only patched edge alerts (slow-input-timeout) but
still subscribed to all of Zustand nodeStates with a 100ms throttle
and forced a dual-write in websocket.ts.

- Rename useNodeStatesSubscription → useEdgeAlertSubscription
- Subscribe directly to per-node Jotai state atoms via sessionStore.sub()
  instead of the Zustand store, using queueMicrotask to coalesce rapid
  atom changes from a single batchWriteNodeStates flush
- Drop the batchUpdateNodeStatesMulti call from websocket.ts (the Jotai
  write is the only one needed)
- Remove the 100ms throttle — edge alerts are infrequent and the
  throttle added unnecessary latency
- Migrate SessionInfoChip and SessionItem to read node states from Jotai
  atoms (new useSessionNodeStates hook) instead of Zustand
- Remove batchUpdateNodeStatesMulti and batchUpdateNodeStates from
  sessionStore (dead code after the above changes)

Closes #397

Signed-off-by: StreamKit Devin <devin@streamkit.dev>
Co-Authored-By: Claudio Costa <cstcld91@gmail.com>
@staging-devin-ai-integration
Copy link
Copy Markdown
Contributor Author

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment and CI monitoring

Copy link
Copy Markdown
Contributor Author

@staging-devin-ai-integration staging-devin-ai-integration Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 5 potential issues.

Open in Devin Review (Staging)
Debug

Playground

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚩 Zustand nodeStates are now effectively legacy initial-state storage

After websocket.ts stopped calling batchUpdateNodeStatesMulti, live nodestatechanged events no longer update useSessionStore().sessions[*].nodeStates; they only update Jotai atoms. Current monitor runtime consumers changed by this PR (SessionItem.tsx via useSessionNodeStates, nodes via useNodeStateFromAtom, and edge alerts via useEdgeAlertSubscription) read from atoms, so this did not look like an immediate UI bug. However, sessionStore.ts still exposes nodeStates and updateNodeState, and its tests still validate that API, so future code using getSession(...).nodeStates will observe only pipeline-seeded/legacy state rather than live WebSocket state.

(Refers to lines 9-12)

Open in Devin Review (Staging)

Was this helpful? React with 👍 or 👎 to provide feedback.

Debug

Playground

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: Edge alert updates intentionally only toggle alert presence

The edge-alert patcher returns the existing edge when shouldWarn === isCurrentlyWarned, so an edge that remains in slow_input_timeout will not get refreshed tooltip details if newly_slow_pins, sync_timeout_ms, or the slow-pin set changes while the edge remains warned. This behavior existed in the old logic as well, so I did not flag it as a PR-introduced bug, but it means tooltip metadata can lag until the warning clears and reappears.

(Refers to line 149)

Open in Devin Review (Staging)

Was this helpful? React with 👍 or 👎 to provide feedback.

Debug

Playground

Comment on lines +47 to +50
queueMicrotask(() => {
pending = false;
readAll();
});
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Queued atom flush can update state after the hook has unsubscribed

onAtomChange schedules readAll() in a microtask, but the cleanup only unsubscribes atom listeners and does not cancel or guard an already-queued microtask. If the session item/chip unmounts or switches to a different sessionId after an atom change schedules the microtask, the stale callback still calls setNodeStates for the old session. This can briefly render stale status/issue information on recycled components and performs a React state update after the subscription has been torn down.

Prompt for agents
In ui/src/hooks/useSessionNodeStates.ts, guard the queued microtask so it becomes a no-op after the effect cleanup runs. The hook currently unsubscribes from Jotai atoms but cannot cancel queueMicrotask callbacks already scheduled by onAtomChange. Add an effect-local disposed/cancelled flag that is set in the cleanup and checked before readAll() is invoked from the microtask, while preserving the existing unsubscribe behavior.
Open in Devin Review (Staging)

Was this helpful? React with 👍 or 👎 to provide feedback.

Debug

Playground

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 79216db — added a disposed flag that's set in the cleanup function and checked inside the microtask callback before calling readAll().

Comment on lines +180 to +183
queueMicrotask(() => {
pendingFlush = false;
applyPatch();
});
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Stale edge-alert microtasks can patch the wrong session graph

onAtomChange queues applyPatch() without any cancellation guard, while cleanup only unsubscribes atom listeners. If an atom change schedules the microtask and the selected session/topology changes before it runs, the stale callback still reads pipelineRef.current (which now points at the new graph) using the old selectedSessionId captured by this effect and then calls setEdges. That can remove or add slow-input alert metadata on the newly selected/topology graph using node states from the previous subscription.

Prompt for agents
In ui/src/hooks/useEdgeAlertSubscription.ts, make queued atom-change flushes no-op after the effect has been cleaned up. The current cleanup unsubscribes listeners but cannot cancel queueMicrotask callbacks already scheduled by the old selectedSessionId/topoKey closure. Add an effect-local disposed/cancelled flag checked inside the queued callback before resetting pendingFlush and before calling applyPatch(), and set that flag in the cleanup along with unsubscribing.
Open in Devin Review (Staging)

Was this helpful? React with 👍 or 👎 to provide feedback.

Debug

Playground

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 79216db — added a disposed flag that's set in the cleanup function and checked inside the microtask callback before calling applyPatch().

Comment on lines +53 to +58
const unsubs = nodeIds.map((id) =>
sessionStore.sub(nodeStateAtom(nodeKey(sessionId, id)), onAtomChange)
);

return () => unsubs.forEach((u) => u());
}, [sessionId, pipeline]);
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: New hooks resubscribe to all node atoms whenever the pipeline object changes

Both useSessionNodeStates and useEdgeAlertSubscription derive nodeIds from the current pipeline and subscribe to each per-node atom. Because the dependency is the entire pipeline object in useSessionNodeStates and topoKey in useEdgeAlertSubscription, low-frequency topology changes are handled, and live node-state changes no longer wake the large Zustand store. The tradeoff is that a full unsubscribe/resubscribe occurs when the pipeline object identity changes even if node IDs are unchanged; that is probably acceptable for the intended low-frequency pipeline updates, but reviewers may want to keep an eye on this if setPipeline starts being called more often.

Open in Devin Review (Staging)

Was this helpful? React with 👍 or 👎 to provide feedback.

Debug

Playground

streamkit-devin and others added 4 commits May 9, 2026 17:50
Prevent stale microtask callbacks from running after effect cleanup
when the session or topology changes between scheduling and execution.

Signed-off-by: StreamKit Devin <devin@streamkit.dev>
Co-Authored-By: Claudio Costa <cstcld91@gmail.com>
Port from #409:
- Fully remove nodeStates field from SessionData and related actions
- Add renderHook tests for useEdgeAlertSubscription and useSessionNodeStates
- Add shallow-equality guard in useSessionNodeStates for reference stability
- Reset topoEffectRanRef on effect re-run in useEdgeAlertSubscription
- Remove stale nodeStates comment in useSession.ts
- Clean up nodeState-related tests in sessionStore test files

Signed-off-by: StreamKit Devin <devin@streamkit.dev>
Co-Authored-By: Claudio Costa <cstcld91@gmail.com>
Signed-off-by: StreamKit Devin <devin@streamkit.dev>
Co-Authored-By: Claudio Costa <cstcld91@gmail.com>
@streamer45 streamer45 merged commit a0b17e2 into main May 10, 2026
12 checks passed
@streamer45 streamer45 deleted the devin/1778348510-remove-zustand-nodestates-mirror branch May 10, 2026 20:14
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.

refactor(ui): drop Zustand nodeStates mirror and simplify useNodeStatesSubscription

2 participants