Skip to content

refactor(web): convert viewer-store to Zustand with direct named actions#31209

Merged
ashleeradka merged 3 commits into
mainfrom
devin/1779227778-lum-1641-viewer-store-zustand
May 19, 2026
Merged

refactor(web): convert viewer-store to Zustand with direct named actions#31209
ashleeradka merged 3 commits into
mainfrom
devin/1779227778-lum-1641-viewer-store-zustand

Conversation

@ashleeradka
Copy link
Copy Markdown
Contributor

@ashleeradka ashleeradka commented May 19, 2026

Prompt / plan

Convert viewer-store.ts from the useReducer/dispatch pattern to a Zustand store with direct named actions per Zustand's flux-inspired practice guide and the project's CONVENTIONS.md.

Closes LUM-1641

What changed

Store conversion (stores/viewer-store.ts):

  • Replaced viewerReducer (200+ line switch-case) + 21 action type interfaces + ViewerAction discriminated union with direct named actions on a Zustand store
  • Wrapped with createSelectors for auto-generated .use.field() hooks
  • Separated ViewerState and ViewerActions interfaces per convention

Consumer updates (use-app-viewer-actions.ts):

  • Replaced all dispatchViewer({ type: "..." }) calls with useViewerStore.getState().actionName()
  • Removed dispatchViewer parameter and viewerStateRef — no longer needed with Zustand's .getState()
  • Fixed a read-after-mutate bug in handleActiveAppUnpinned: state is now snapshot before calling handleAppUnpinned() so the condition evaluates against pre-mutation values

Test updates (viewer-store.test.ts):

  • All 38 tests rewritten from viewerReducer(state, action) pattern to useViewerStore.getState().actionName() API

Documentation:

  • Added .use.* vs .getState() guidance to createSelectors JSDoc — explains when to use each API and that Zustand mutations are synchronous
  • Added concise section in CONVENTIONS.md with reference table and examples
  • Updated store creation example to use createSelectors (matches actual codebase pattern)
  • Cross-linked CONTRIBUTING.md to CONVENTIONS.md and STYLE_GUIDE.md for apps/web/ contributors

Why this is safe

  • Pure state management refactor — no UI/behavioral changes
  • All 38 store tests pass with the new API
  • The handleActiveAppUnpinned fix corrects a regression that would have caused stale editing keys when unpinning the active app
  • Lint, typecheck, and build all pass

References

Test plan

  • All 38 viewer-store tests pass (bun test src/stores/viewer-store.test.ts)
  • bunx tsc --noEmit passes
  • bun run lint passes
  • CI green

Link to Devin session: https://app.devin.ai/sessions/565d827296144ac9bf12bd108169e5ef
Requested by: @ashleeradka


Open in Devin Review

Closes LUM-1641

- Replace viewerReducer + 21 action type interfaces with Zustand store
  using create() + createSelectors and direct named actions
- Remove dispatchViewer parameter threading from use-app-viewer-actions;
  consumers now call useViewerStore.getState().actionName() directly
- Rename showTokenDialog state field to isTokenDialogOpen to avoid
  collision with the showTokenDialog() action function
- Add early-return optimizations for idempotent state updates
- Rewrite all 38 tests to use Zustand store API

Co-Authored-By: ashlee@vellum.ai <ashlee@vellum.ai>
@devin-ai-integration
Copy link
Copy Markdown
Contributor

🤖 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

@linear
Copy link
Copy Markdown

linear Bot commented May 19, 2026

LUM-1641

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 0cd4ad79fe

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +406 to +410
useViewerStore.getState().handleAppUnpinned(appId);
const state = useViewerStore.getState();
if (
viewerStateRef.current.activeAppId === appId &&
(viewerStateRef.current.mainView === "app" || viewerStateRef.current.mainView === "app-editing")
state.activeAppId === appId &&
(state.mainView === "app" || state.mainView === "app-editing")
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Check unpinned-match before mutating viewer state

handleActiveAppUnpinned now calls handleAppUnpinned(appId) before checking whether the unpinned app is currently active in app/app-editing view. Because handleAppUnpinned clears activeAppId and switches mainView to chat when it matches, the subsequent condition is always false in the matched case, so SET_EDITING_KEY is never dispatched. This leaves the conversation list editing key stale when the active pinned app is removed during an edit session.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Good catch — this is a real bug. Already fixed: snapshot state before mutation so the condition evaluates against pre-mutation values. Will be in the next push.

Copy link
Copy Markdown
Contributor

@devin-ai-integration 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 1 potential issue.

View 4 additional findings in Devin Review.

Open in Devin Review

Comment on lines 405 to 413
(appId: string) => {
dispatchViewer({ type: "ACTIVE_APP_UNPINNED", appId });
useViewerStore.getState().handleAppUnpinned(appId);
const state = useViewerStore.getState();
if (
viewerStateRef.current.activeAppId === appId &&
(viewerStateRef.current.mainView === "app" || viewerStateRef.current.mainView === "app-editing")
state.activeAppId === appId &&
(state.mainView === "app" || state.mainView === "app-editing")
) {
dispatchConversationList({ type: "SET_EDITING_KEY", key: null });
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔴 handleActiveAppUnpinned reads post-mutation state, so SET_EDITING_KEY dispatch never fires

In the old reducer-based code, dispatchViewer (React's useReducer dispatch) was asynchronous — viewerStateRef.current still held the pre-dispatch state when the condition was evaluated, so the SET_EDITING_KEY clearing logic worked correctly. In the new Zustand-based code, handleAppUnpinned(appId) synchronously mutates the store (setting activeAppId to null and mainView to "chat" at apps/web/src/stores/viewer-store.ts:201-205). The immediately-following useViewerStore.getState() returns the post-mutation state, so state.activeAppId === appId evaluates to null === appId (always false), and dispatchConversationList({ type: "SET_EDITING_KEY", key: null }) is never called. This means when a user unpins an app that was being actively edited, the editing key remains stale in the conversation list store.

Suggested change
(appId: string) => {
dispatchViewer({ type: "ACTIVE_APP_UNPINNED", appId });
useViewerStore.getState().handleAppUnpinned(appId);
const state = useViewerStore.getState();
if (
viewerStateRef.current.activeAppId === appId &&
(viewerStateRef.current.mainView === "app" || viewerStateRef.current.mainView === "app-editing")
state.activeAppId === appId &&
(state.mainView === "app" || state.mainView === "app-editing")
) {
dispatchConversationList({ type: "SET_EDITING_KEY", key: null });
}
(appId: string) => {
const state = useViewerStore.getState();
const shouldClearEditingKey =
state.activeAppId === appId &&
(state.mainView === "app" || state.mainView === "app-editing");
useViewerStore.getState().handleAppUnpinned(appId);
if (shouldClearEditingKey) {
dispatchConversationList({ type: "SET_EDITING_KEY", key: null });
}
Open in Devin Review

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Confirmed — same issue as the Codex comment. Already fixed by reading state before calling handleAppUnpinned. Will be in the next push.

devin-ai-integration Bot and others added 2 commits May 19, 2026 22:05
The previous code called handleAppUnpinned(appId) — which synchronously
clears activeAppId and sets mainView to 'chat' — then read
getState() to check the pre-mutation values. The condition was always
false in the matched case, so SET_EDITING_KEY was never dispatched.

Snapshot state before calling the action so the condition evaluates
against pre-mutation values.

Also adds '.use.* vs .getState()' guidance to CONVENTIONS.md with the
read-before-mutate rule, correct/incorrect examples, and a reference
table for when to use each API.

Co-Authored-By: ashlee@vellum.ai <ashlee@vellum.ai>
- Add JSDoc on createSelectors explaining when to use each API and
  that Zustand mutations are synchronous
- Add concise .use.* vs .getState() section in CONVENTIONS.md with
  reference table and short examples
- Trim store creation example to use createSelectors (matches actual
  codebase pattern) instead of useShallow convenience hooks
- Cross-link CONTRIBUTING.md to CONVENTIONS.md and STYLE_GUIDE.md
  for apps/web/ contributors

Co-Authored-By: ashlee@vellum.ai <ashlee@vellum.ai>
Copy link
Copy Markdown
Contributor

@vex-assistant-bot vex-assistant-bot Bot left a comment

Choose a reason for hiding this comment

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

APPROVE

Value: Eliminates 200+ lines of discriminated-union boilerplate from the viewer state machine, making viewer state readable and writable from any callback without prop-drilling dispatchViewer or threading a viewerStateRef.

What this does: Converts viewer-store.ts from useReducer + 21 action-type interfaces to a Zustand store with direct named actions, wrapped with createSelectors for per-field hooks. Consumer hook (use-app-viewer-actions.ts) drops dispatchViewer and viewerStateRef params entirely; 38 tests rewritten to the new API.


Pattern check — all green:

  • createSelectors wrapping + .use.field() / .getState() split — canonical ✅
  • useViewerStore.getState().action() in callbacks (not render) — correct ✅
  • State and Actions as separate interfaces — per CONVENTIONS ✅
  • reset: () => set({ ...INITIAL_STATE }) — correct spread pattern ✅

handleActiveAppUnpinned bug fix (commit 5d69b157):

Bots flagged the read-after-mutate at 0cd4ad79 — pre-mutation state was lost after handleAppUnpinned ran, so SET_EDITING_KEY dispatch never fired. Fix is correct: snapshot { activeAppId, mainView } before calling handleAppUnpinned, then evaluate the condition against the snapshot. Verified at HEAD 65fee42b. ✅

showTokenDialog rename → isTokenDialogOpen:

The state field was renamed to avoid a name collision with the action showTokenDialog(pendingAppId). Smart call — the old name would have put a boolean and a (string) => void at the same key in ViewerStore. TypeScript would catch misuse and CI is green.

INITIAL_VIEWER_STATE de-exported:

Renamed to const INITIAL_STATE (non-exported). Any outside import would have been a TS error — CI confirms it's clean. All 38 tests rewritten, so no lingering reducer-style references.

finishDeploying(clearPendingAppId?) behavior:

Old DEPLOYING_DONE kept pendingDeployAppId when flag was falsy; new impl matches: ...(clearPendingAppId ? { pendingDeployAppId: null } : {}). Correct.

Minor / non-blocking:

  • closeApp() doesn't set mainView: "chat" — the old CLOSE_APP reducer presumably did. The consumer (handleCloseApp) covers this via switchConversation or explicit setMainView("chat"), so behavior is preserved. Worth a quick sanity-run on the "close app, no last conversation" path if you haven't already.
  • Bots (Codex + Devin) reviewed at stale commit 0cd4ad79; their bug report was accurate but is already resolved. No further bot re-review needed given the fix is straightforward and CI is clean.

CI: 3/3 green (Socket × 2, Lint/Type/Build). Ready to merge.

Vellum Constitution — Trust-seeking: replacing opaque dispatch strings with named typed actions makes every state transition legible and auditable at a glance.

@devin-ai-integration
Copy link
Copy Markdown
Contributor

Thanks for the thorough review.

Re: the closeApp() note — confirmed the old CLOSE_APP reducer case also did not set mainView: "chat":

case "CLOSE_APP":
  return {
    ...state,
    activeAppId: null,
    openedAppState: null,
    isAppMinimized: false,
  };

So the new closeApp() matches the old behavior exactly. The consumer (handleCloseApp in use-app-viewer-actions.ts) handles the view transition via switchConversation or explicit setMainView("chat"), as noted.

@ashleeradka ashleeradka merged commit fd4d3a4 into main May 19, 2026
3 checks passed
@ashleeradka ashleeradka deleted the devin/1779227778-lum-1641-viewer-store-zustand branch May 19, 2026 22:21
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