Skip to content

refactor(ui): read node state/params from Jotai atoms instead of data props#392

Merged
streamer45 merged 9 commits into
mainfrom
devin/1777635754-jotai-node-state-refactor
May 2, 2026
Merged

refactor(ui): read node state/params from Jotai atoms instead of data props#392
streamer45 merged 9 commits into
mainfrom
devin/1777635754-jotai-node-state-refactor

Conversation

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

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

Summary

Refactors node components to read state from per-node Jotai atoms (nodeStateAtom) instead of ReactFlow's data prop. State-change re-renders are now confined to the affected node only.

Params are deliberately NOT read from atoms at the node level — individual controls subscribe directly via useNumericSlider / useTuneNode, confining slider-drag re-renders to just the control that changed.

Before: useNodeStatesSubscription called setNodes() on every state update, forcing new data references → ~39 ConfigurableNode re-renders during session load.

After: Node components read state from nodeStateAtom. useNodeStatesSubscription only patches edge alerts. deepEqual guards prevent no-op atom notifications. Equality guard on writeNodeParam skips writes when value unchanged.

Regression fix: Profiling showed 27× CompositorNode re-renders during slider drags when subscribing to nodeParamsAtom at node level. Fixed by removing useNodeParamsFromAtom from all node components.

Regression test: Layer 1 render-perf test with positive control: a hook subscribing to both state+params atoms proves the harness would detect ≥15 re-renders from the regression, while useNodeStateFromAtom stays at ≤2.

Hook semantics: useNodeStateFromAtom returns atom value directly on the keyed branch (monitor view) — null maps to undefined, never masked by stale data.state fallback. Fallback is only used in design view (no sessionId). sessionId is stable per mount; nullStateAtom satisfies rules of hooks.

Cleanup: Removed wasted set() before atomFamily.remove(), removed duplicated 5-line atom comments from node components (canonical home is the hook JSDoc per AGENTS.md), documented compositor layers exception (#398).

Follow-ups: #397 (drop Zustand nodeStates mirror), #398 (compositor remote param sync via atom).

Review & Testing Checklist for Human

  • Monitor view slider interaction: Open a compositor session → drag opacity/rotation sliders → verify smooth response, no visible lag or jank. This is the scenario that regressed to 27× re-renders.
  • Multi-node state indicators: Start a session with multiple nodes → verify state badges (Initializing → Running) update correctly on each node independently.
  • Edge alerts: Trigger a slow-input-timeout degradation → verify edge warning badges still appear (this is the only remaining useNodeStatesSubscription responsibility).
  • Design view: Open design view (no session) → verify node components render correctly using data.state fallback.

Notes

  • Budget for ConfigurableNode renders during session load is 42 (was 45 on main). The improvement is modest for session load; the main win is during interactive param tuning where renders drop from 27× to 1-2× per slider drag.
  • Compositor layers are an exception to the "controls subscribe to params atom" pattern — they read data.params from the node component prop. Remote param sync for compositor is tracked in fix(ui): compositor node should read params from Jotai atom for remote sync #398.
  • writeNodeParams uses deepMerge + deepEqual which re-walks the same tree a key-level check could short-circuit. Not load-bearing, but noted for future optimization.
  • Key files: ui/src/hooks/useNodeAtoms.ts, ui/src/hooks/useNodeStatesSubscription.ts, ui/src/nodes/{CompositorNode,ConfigurableNode,AudioGainNode}.tsx, ui/src/stores/sessionAtoms.ts

Closes #320

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


Devin Review

Status Commit
🟢 Reviewed 901893e
Open in Devin Review (Staging)

… props

Node components (ConfigurableNode, AudioGainNode, CompositorNode) now read
their state and params directly from per-node Jotai atom families
(nodeStateAtom, nodeParamsAtom) via two new hooks:

  - useNodeStateFromAtom(nodeId, sessionId, fallback)
  - useNodeParamsFromAtom(nodeId, sessionId, fallback)

This replaces the previous approach where useNodeStatesSubscription patched
ReactFlow node data objects via setNodes(), which created new data references
and forced areNodePropsEqual to see every node as changed on each state
update — causing ~39 ConfigurableNode re-renders during session load.

With atoms, a state change on node A only re-renders node A's component;
other nodes' memo barriers remain intact.

Key changes:
- New useNodeAtoms.ts hook with useNodeStateFromAtom/useNodeParamsFromAtom
- useNodeStatesSubscription no longer calls setNodes(); only patches edges
- Three node components use atom hooks for state + params
- MonitorView no longer passes setNodes to the subscription hook
- Perf test budget tightened from 45 to 20

Closes #320

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

staging-devin-ai-integration[bot]

This comment was marked as resolved.

Add deepEqual guard before writing to nodeStateAtom so that duplicate
state values (common during steady-state after session load) do not
trigger unnecessary Jotai subscriber notifications.  This is the
atom-side equivalent of the deepEqual check that the old setNodes()
patching path had.

Also adjusts the ConfigurableNode render budget from 20 to 25 — the
first CI run observed 21 renders (down from ~39 baseline), confirming
the improvement.  The extra headroom accounts for timing-dependent
state-transition batching variance across CI runners.

Signed-off-by: StreamKit Devin <devin@streamkit.dev>
Co-Authored-By: Claudio Costa <cstcld91@gmail.com>
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 new potential issues.

Open in Devin Review (Staging)
Debug

Playground

Comment thread ui/src/hooks/useNodeAtoms.ts
Comment thread ui/src/hooks/useNodeAtoms.ts Outdated
Comment on lines 130 to 140
export function batchWriteNodeStates(updates: Map<string, Record<string, NodeState>>): void {
for (const [sessionId, nodeUpdates] of updates) {
for (const [nodeId, state] of Object.entries(nodeUpdates)) {
sessionStore.set(nodeStateAtom(nodeKey(sessionId, nodeId)), state);
const key = nodeKey(sessionId, nodeId);
const current = sessionStore.get(nodeStateAtom(key));
if (!deepEqual(current, state)) {
sessionStore.set(nodeStateAtom(key), state);
}
}
}
}
Copy link
Copy Markdown
Contributor Author

@staging-devin-ai-integration staging-devin-ai-integration Bot May 1, 2026

Choose a reason for hiding this comment

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

📝 Info: deepEqual guard on batchWriteNodeStates prevents no-op re-renders

The new deepEqual check at ui/src/stores/sessionAtoms.ts:134-137 is the atom-side equivalent of the deepEqual guard that the old setNodes() patching path had in useNodeStatesSubscription. Without it, every RAF flush would write to the atom even if the state value is identical, causing Jotai to notify all subscribers and trigger unnecessary re-renders. This is critical for the migration to work correctly since node components now subscribe directly to these atoms.

Open in Devin Review (Staging)

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

Debug

Playground

if (throttleTimer !== null) clearTimeout(throttleTimer);
};
}, [selectedSessionId, setNodes, setEdges, pipelineRef]);
}, [selectedSessionId, setEdges, pipelineRef]);
Copy link
Copy Markdown
Contributor Author

@staging-devin-ai-integration staging-devin-ai-integration Bot May 1, 2026

Choose a reason for hiding this comment

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

📝 Info: Removal of setNodes from useNodeStatesSubscription dependency array

The useEffect dependency array at ui/src/hooks/useNodeStatesSubscription.ts:256 removes setNodes and keeps only [selectedSessionId, setEdges, pipelineRef]. This is consistent with the body changes — setNodes is no longer called anywhere in the effect. The setNodes import from @xyflow/react is also removed from the file's imports. The interface UseNodeStatesSubscriptionOptions at line 109-114 no longer includes setNodes, and the call site in MonitorView.tsx:549-554 no longer passes it. The removal is complete and consistent across all sites.

Open in Devin Review (Staging)

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

Debug

Playground

Comment thread ui/src/hooks/useNodeAtoms.ts Outdated
Comment on lines +40 to +42
const key = sessionId ? nodeKey(sessionId, nodeId) : null;
const atomState = useAtomValue(key ? nodeStateAtom(key) : nullStateAtom);
return (key ? atomState : null) ?? fallback ?? undefined;
Copy link
Copy Markdown
Contributor Author

@staging-devin-ai-integration staging-devin-ai-integration Bot May 1, 2026

Choose a reason for hiding this comment

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

📝 Info: Conditional atom selection in hooks is Rules-of-Hooks compliant

Both useNodeStateFromAtom and useNodeParamsFromAtom use a pattern like useAtomValue(key ? nodeStateAtom(key) : nullStateAtom). This is safe because useAtomValue is always called exactly once — only the argument changes. The ternary selects which atom to subscribe to, not whether to call the hook. Jotai handles atom switching correctly by unsubscribing from the old atom and subscribing to the new one. The nullStateAtom and nullParamsAtom serve as stable no-op subscription targets for design view (no sessionId).

Open in Devin Review (Staging)

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

Debug

Playground

Wrap the { ...fallback, ...atomParams } spread in useMemo so the
returned object is referentially stable across state-only re-renders.
Without this, every useNodeStateFromAtom-triggered re-render also
produced a new params reference, defeating React.memo on
SchemaControls children and re-running the useCompositorLayers
sync effect unnecessarily.

Signed-off-by: StreamKit Devin <devin@streamkit.dev>
Co-Authored-By: Claudio Costa <cstcld91@gmail.com>
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 3 new potential issues.

Open in Devin Review (Staging)
Debug

Playground

Comment thread ui/src/stores/sessionAtoms.ts
Comment thread ui/src/hooks/useNodeAtoms.ts Outdated
Comment thread ui/src/nodes/CompositorNode.tsx Outdated
Comment on lines +122 to +123
const state = useNodeStateFromAtom(id, data.sessionId, data.state);
const params = useNodeParamsFromAtom(id, data.sessionId, data.params ?? {});
Copy link
Copy Markdown
Contributor Author

@staging-devin-ai-integration staging-devin-ai-integration Bot May 1, 2026

Choose a reason for hiding this comment

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

📝 Info: CompositorNode correctly calls useNodeStateFromAtom outside its inner Jotai Provider

CompositorNode wraps part of its render tree in <Provider store={store}> for compositor-specific atoms (ui/src/nodes/CompositorNode.tsx:373). The useNodeStateFromAtom call at line 125 is in the component body outside this Provider, so it resolves against the default Jotai store — the same one referenced by sessionStore in sessionAtoms.ts. This is correct; if the hook were called inside the Provider it would read from the compositor's private store and miss the session state atoms entirely.

Open in Devin Review (Staging)

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

Debug

Playground

writeNodeParams: deepMerge always creates a new object reference,
causing Jotai to notify subscribers even when params haven't
changed (e.g. echo-back from nodeparamschanged WS events during
slider drags).  Add a deepEqual check after the merge so the atom
is only written when the result actually differs.

seedPipelineAtoms: skip writing to nodeStateAtom when the atom
already holds a deeply equal value — prevents duplicate re-renders
when the pipeline seed arrives after WebSocket events have already
populated the same state.

Adjust ConfigurableNode render budget from 25 to 42.  The render
count depends on the number of distinct state transitions per node
during session load (timing-dependent across RAF frames).  With
~7 ConfigurableNode instances each transitioning through 3-5
states, 21-39 total renders are expected; 42 provides headroom
while still catching meaningful regressions vs the prior max: 45.

Signed-off-by: StreamKit Devin <devin@streamkit.dev>
Co-Authored-By: Claudio Costa <cstcld91@gmail.com>
staging-devin-ai-integration[bot]

This comment was marked as resolved.

Node components no longer subscribe to nodeParamsAtom via
useNodeParamsFromAtom.  This caused the entire CompositorNode subtree
(~1.7s per render) to re-render on every slider drag tick — 27 times
during a single drag in the profiling data.

Individual controls (sliders, toggles, text inputs) already subscribe
to the params atom directly via useNumericSlider / useTuneNode, which
confines re-renders to just the affected control rather than the full
node subtree.

State (useNodeStateFromAtom) remains atom-driven — state transitions
still only re-render the affected node, not every node on the canvas.

Removes useNodeParamsFromAtom and nullParamsAtom (now unused).

Signed-off-by: StreamKit Devin <devin@streamkit.dev>
Co-Authored-By: Claudio Costa <cstcld91@gmail.com>
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 3 new potential issues.

Open in Devin Review (Staging)
Debug

Playground

Copy link
Copy Markdown
Contributor Author

@staging-devin-ai-integration staging-devin-ai-integration Bot May 1, 2026

Choose a reason for hiding this comment

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

🚩 deepEqual guard not applied to batchWriteNodeStats — intentional asymmetry

The PR adds deepEqual guards to batchWriteNodeStates (ui/src/stores/sessionAtoms.ts:128-137) and writeNodeParams to avoid unnecessary Jotai notifications. However, batchWriteNodeStats (ui/src/stores/sessionAtoms.ts:140-147) does NOT have a similar guard. This is presumably intentional since stats atoms are only read on-demand (e.g., tooltip hover via nullStatsAtom opt-out pattern) and change frequently with genuinely different numeric values, making deepEqual comparisons overhead without benefit. Worth confirming this assumption holds.

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.

Correct — intentional. Stats values are genuinely different on each RAF flush (counters increment monotonically) so deepEqual would be pure overhead. The nullStatsAtom opt-out pattern means stats atoms are only subscribed when tooltips are open, so the notification cost is already minimal.

// Params are NOT read from the atom here — slider/toggle/text controls
// subscribe directly via useNumericSlider / useTuneNode, which avoids
// full-subtree re-renders on every drag tick.
const state = useNodeStateFromAtom(id, data.sessionId, data.state);
Copy link
Copy Markdown
Contributor Author

@staging-devin-ai-integration staging-devin-ai-integration Bot May 1, 2026

Choose a reason for hiding this comment

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

📝 Info: data.state becomes stale after initial topology build — by design

The topology effect in ui/src/views/MonitorView.tsx:1057-1060 reads the current state from the Jotai atom (or pipeline fallback) and writes it into data.state at build time. After that, data.state is never updated by useNodeStatesSubscription (which no longer patches nodes). This means data.state may become stale. However, node components pass data.state only as the fallback parameter to useNodeStateFromAtom, so it's only used when the atom hasn't been populated yet (e.g., the very first render before seedPipelineAtoms runs). Since seedPipelineAtoms is called when pipeline data first arrives — before the topology effect builds nodes — the fallback path is effectively dead code in the monitor view. It remains useful for design view where sessionId is absent.

Open in Devin Review (Staging)

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

Debug

Playground

Comment on lines 229 to 235

// ── Throttled patch ────────────────────────────────────────────────
// Apply immediately if enough time elapsed since the last patch;
// otherwise buffer and apply after the throttle window. During
// session-load bursts this collapses ~8 individual setNodes calls
// (each triggering a ~20 ms MonitorViewContent re-render) into 2–3.
// otherwise buffer and apply after the throttle window.
pendingNodeStates = nodeStates;
const now = performance.now();
const elapsed = now - lastPatchTime;
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 throttling still operates even though node patching was the original driver

The throttle logic in useNodeStatesSubscription (ui/src/hooks/useNodeStatesSubscription.ts:230-253) was originally designed to collapse multiple setNodes calls during session-load bursts. Now that the hook only patches edges (slow-input-timeout alerts), the throttle still applies but the performance benefit is diminished — edge alert changes are infrequent compared to node state transitions. The throttle adds latency (up to 100ms) to edge alert appearance. This isn't a bug but could be simplified in a follow-up since the primary motivation (avoiding costly MonitorViewContent re-renders) no longer applies.

(Refers to lines 143-253)

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.

Good observation. The throttle is now overkill since it only gates setEdges (edge alerts are infrequent). Could be simplified to a direct setEdges call in a follow-up — the 100ms latency isn't user-visible for edge alerts but the code complexity is unnecessary.

Adds Layer 1 render-performance tests that verify the critical invariant:
node components subscribe to state atoms (useNodeStateFromAtom) but NOT
to params atoms.  This catches the class of regression where adding
useNodeParamsFromAtom to node components caused 27× full-subtree
re-renders during slider drags.

Three scenarios tested:
- 20 rapid writeNodeParam calls → 0 additional renders (isolation)
- 4 state transitions → renders proportional to distinct states
- Mixed param/state writes → only state changes trigger re-renders

Also adds equality guard to writeNodeParam (skips atom write when
value is unchanged).

Signed-off-by: StreamKit Devin <devin@streamkit.dev>
Co-Authored-By: Claudio Costa <cstcld91@gmail.com>
staging-devin-ai-integration[bot]

This comment was marked as resolved.

Signed-off-by: StreamKit Devin <devin@streamkit.dev>
Co-Authored-By: Claudio Costa <cstcld91@gmail.com>
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 0 new potential issues.

Open in Devin Review (Staging)
Debug

Playground

…eanup

- monitor-session-load-perf: fix doc comment (state only, not state/params)
  and clarify budget rationale (CI variance, not 10x claim)
- useNodeAtoms.render-perf.test: fix incorrect deepEqual deduplication
  comment — sequence has no consecutive duplicates
- useNodeAtoms.ts: document compositor layers exception (reads data.params
  not atom, see #398)
- sessionAtoms.ts: remove wasted set() before atomFamily.remove() in
  clearNodeParams and clearSessionAtoms — remove() only clears the family
  cache, existing subscribers keep their last value until unmount

Signed-off-by: StreamKit Devin <devin@streamkit.dev>
Co-Authored-By: Claudio Costa <cstcld91@gmail.com>
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 2 new potential issues.

Open in Devin Review (Staging)
Debug

Playground

Comment on lines 193 to 204
for (const key of stateKeys) {
sessionStore.set(nodeStateAtom(key), null);
nodeStateAtom.remove(key);
}
for (const key of statsKeys) {
sessionStore.set(nodeStatsAtom(key), null);
nodeStatsAtom.remove(key);
}
for (const key of viewKeys) {
sessionStore.set(nodeViewDataAtom(key), undefined);
nodeViewDataAtom.remove(key);
}
for (const key of paramKeys) {
sessionStore.set(nodeParamsAtom(key), {});
nodeParamsAtom.remove(key);
}
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: atomFamily.remove() without prior set() changes subscriber behavior on teardown

The old clearSessionAtoms and clearNodeParams called sessionStore.set(atom, null/empty) before atomFamily.remove(key). The new code skips the set and calls remove directly. This means any component still subscribed to the old atom instance (e.g. during an async unmount race) will see the last real value rather than a reset sentinel. In practice this is safe because these paths run on session-destroy or draft-deletion where components are unmounting, but it's a subtle behavioral difference worth noting — if a future cleanup path is added where the component outlives the remove, subscribers would hold stale data. See ui/src/stores/sessionAtoms.ts:193-204 for clearSessionAtoms and ui/src/stores/sessionAtoms.ts:102-104 for clearNodeParams.

Open in Devin Review (Staging)

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

Debug

Playground

Comment on lines +108 to +111
const state = useNodeStateFromAtom(id, data.sessionId, data.state);
const params = data.params as Record<string, unknown>;

const propGain = (params?.gain as number) ?? 1.0;
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: Stale data.params after topology build is safe due to atom-based control subscriptions

With the removal of setNodes patching from useNodeStatesSubscription, data.params is now set only during topology builds and never updated mid-session. This could seem problematic since controls read data.params as propValue (e.g. AudioGainNode.tsx:111). However, the pipeline-level params (apiNode.params) were already static — the updateNodeParams Zustand write was commented out in websocket.ts:344-345 with a warning. Live param changes flow through writeNodeParams → Jotai nodeParamsAtomuseNumericSlider / useTuneNode, completely bypassing data.params. So data.params effectively serves as the initial seed value, which is correct.

Open in Devin Review (Staging)

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

Debug

Playground

- useNodeStateFromAtom: stop masking null with stale fallback on keyed
  branch — return atom value directly (null→undefined), use fallback
  only in design view (no sessionId)
- useNodeAtoms.render-perf.test: add positive control hook that subscribes
  to both state and params atoms, proving the harness would detect the
  regression (≥15 re-renders) if useNodeStateFromAtom accidentally
  subscribed to nodeParamsAtom
- Document that sessionId is stable per mount (nullStateAtom satisfies
  rules of hooks, not a runtime toggle)
- Remove duplicated 5-line atom comment from AudioGainNode, CompositorNode,
  ConfigurableNode — canonical home is the hook JSDoc per AGENTS.md

Signed-off-by: StreamKit Devin <devin@streamkit.dev>
Co-Authored-By: Claudio Costa <cstcld91@gmail.com>
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 2 new potential issues.

Open in Devin Review (Staging)
Debug

Playground

Comment on lines +122 to +123
const canvasWidth = (params?.width as number) ?? 1280;
const canvasHeight = (params?.height as number) ?? 720;
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: Redundant optional chaining after nullish coalescing

In CompositorNode.tsx:120, params is assigned data.params ?? {}, guaranteeing it's never null/undefined. Yet lines 122-123 use params?.width and params?.height with optional chaining. Similarly in AudioGainNode.tsx:103-105, params is cast as Record<string, unknown> (which could be undefined if data.params is undefined, but the interface types params as { gain: number } so it shouldn't be). The optional chaining is harmless but stylistically inconsistent with the preceding null guard.

Open in Devin Review (Staging)

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

Debug

Playground

).toBeDefined();
assertRenderBudget(snapshot, 'ConfigurableNode', {
max: 45,
max: 42,
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: E2E render budget tightened from 45 to 42

The test at e2e/tests/monitor-session-load-perf.spec.ts:199 tightens ConfigurableNode max renders from 45 to 42. The comment explains this reflects the new atom-based architecture where state changes only re-render the affected node. The budget is described as accommodating CI variance. If CI environments have scheduling jitter, this 7% reduction may occasionally cause flaky failures. Worth monitoring after merge.

Open in Devin Review (Staging)

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

Debug

Playground

@streamer45 streamer45 merged commit e51c61c into main May 2, 2026
13 checks passed
@streamer45 streamer45 deleted the devin/1777635754-jotai-node-state-refactor branch May 2, 2026 10:29
streamer45 added a commit that referenced this pull request May 10, 2026
…directly (#417)

* refactor: remove Zustand nodeStates mirror, subscribe to Jotai atoms 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>

* fix: guard queued microtasks with disposed flag in both hooks

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>

* refactor: remove nodeStates from Zustand, add renderHook tests

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>

* chore: document effect ordering dependency on topoEffectRanRef

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: StreamKit Devin <devin@streamkit.dev>
Co-authored-by: Claudio Costa <cstcld91@gmail.com>
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.

perf(ui): read node state from Jotai atoms instead of ReactFlow data props

2 participants