Skip to content

feat: Zustand editor store — block tree, selection, dirty flag (#265) - #279

Merged
ViewFromTheBox merged 2 commits into
add/phase-onefrom
feature/265-zustand-editor-store
Apr 13, 2026
Merged

ViewFromTheBox merged 2 commits into
add/phase-onefrom
feature/265-zustand-editor-store

Conversation

@ViewFromTheBox

@ViewFromTheBox ViewFromTheBox commented Apr 13, 2026 •

Copy link
Copy Markdown
Member

Description

Adds the Phase 1 state foundation for the React editor migration. Introduces a plain Zustand store (no middleware) that owns the block tree, selection, and dirty flag, replacing the Phase 0 spike's shared-mutable-state + `__bumpTemplate` pattern called out as finding #1 in `PHASE-0-FINDINGS.md`.

Closes: #265

Type of Change

  • New feature (adds new functionality)

Related Issue

Issue: #265

Motivation and Context

Phase 0 proved the React + Tiptap architecture works, but the spike leaned on a mutable block tree and a `__bumpTemplate` callback to force preview re-renders. Every other Phase 1 issue (REST load/save, inserter, undo/redo, Tiptap wiring) needs a real state layer to build on. This PR delivers that layer with immutable updates and memoized selectors, unblocking #266 (undo/redo), #274 (persistence), and the paragraph/heading blocks.

Changes Made

  • Added `zustand` as a runtime dependency (core only, no middleware)
  • New `resources/js/visual-editor/editor/store/` module:
    • `types.ts` — `Block`, `Selection`, `EditorState`, `EditorActions`, `EditorStoreState`
    • `treeUtils.ts` — pure immutable tree helpers (`findBlock`, `findChildren`, `findParent`, `insertIntoTree`, `removeFromTree`, `updateAttributesInTree`)
    • `editorStore.ts` — `createEditorStore()` factory plus `useBlock` / `useChildren` / `useSelection` / `useIsDirty` React hooks
    • `index.ts` — barrel export
  • Actions: `insertBlock`, `updateBlockAttributes`, `removeBlock`, `moveBlock` (top-level only per Phase 1 scope), `replaceBlocks`, `select`, `clearSelection`, `markDirty`, `markClean`
  • Dirty flag flips on every mutation and clears only via `markClean()`; selection changes do not flip it
  • `vitest.config.ts` include pattern extended to cover `resources/js/visual-editor/**`

How Has This Been Tested?

Testing Environment:

  • Operating System: macOS (Darwin 25.3.0)
  • Node: project-pinned via package-lock
  • Project Version: 1.0.0-spike

Tests Performed:

  1. `npm test` — 85 tests passing (47 new, 38 existing spike tests untouched)
  2. `npx tsc --noEmit` — clean
  3. CodeRabbit review against `add/phase-one` — no findings

Accessibility Tests Run

Details: This PR ships no rendered markup, so there is nothing to keyboard- or screen-reader-test. Accessibility work lives in the shell/canvas issues that consume this store.

Tests Added

  • Unit tests added/updated
  • All tests passing

Test details:

  • `editorStore.test.ts` (36 tests) — every action, immutability of updates, dirty-flag semantics, nested insert/update/remove, missing-clientId no-ops, round-trip nested tree
  • `editorStoreSelectors.test.tsx` (11 tests) — `useBlock`/`useChildren`/`useSelection`/`useIsDirty` via `@testing-library/react` `renderHook`, including reactivity after mutations and nested lookups

Documentation

  • Inline code documentation added

Documentation details: Types and action signatures are self-documenting via TypeScript. No wiki/README changes needed until the store is wired into UI in a later Phase 1 issue.

Screenshots

N/A — pure data layer.

Pre-Submission Checklist

  • Followed contributing guidelines
  • Checked for other open PRs for same update
  • Code passes all tests
  • Code has been linted (tsc clean, CodeRabbit clean)
  • Accessibility tests completed (N/A — data layer)
  • Code follows project style guide
  • Self-review completed
  • No new warnings generated

Summary by CodeRabbit

  • New Features

    • Added an editor state system with block tree operations (insert, update attributes, remove, move, replace), selection handling, and dirty-state tracking.
    • Exposed reactive hooks for reading blocks, children, selection, and dirty state.
  • Tests

    • Added comprehensive test suites covering store behavior and hook reactivity.
  • Chores

    • Added Zustand dependency and expanded test discovery configuration.

Add the Phase 1 state foundation that replaces the Phase 0 spike's
shared-mutable-state + __bumpTemplate pattern. createEditorStore()
returns a plain zustand store (no middleware) owning the block tree,
selection, and dirty flag, with immutable insert/update/remove/move,
replaceBlocks, select/clearSelection, and markDirty/markClean actions.
Selectors (useBlock, useChildren, useSelection, useIsDirty) are memoized
React hooks. Vitest coverage spans every action and selector including
nested trees, edge cases, and round-trip mutations.

Closes #265
@coderabbitai

coderabbitai Bot commented Apr 13, 2026 •

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

Adds a new Zustand-based editor store (blocks, selection, isDirty), immutable tree utilities, TypeScript types, React hooks, Vitest configuration and comprehensive tests covering actions and selectors.

Changes

Cohort / File(s) Summary
Foundation & Config
package.json, vitest.config.ts
Added zustand dependency (^5.0.12) and expanded Vitest include pattern to cover resources/js/visual-editor/**.
Type Definitions
resources/js/visual-editor/editor/store/types.ts
New types: Block, Selection, InsertLocation, MoveLocation, EditorState, EditorActions, EditorStoreState.
Tree Utilities
resources/js/visual-editor/editor/store/treeUtils.ts
New immutable tree helpers: findBlock, findParent, findChildren, insertIntoTree, removeFromTree, updateAttributesInTree (clamped indices, structural sharing, no-op returns when unchanged).
Store Implementation
resources/js/visual-editor/editor/store/editorStore.ts
New Zustand store factory createEditorStore(initialBlocks), actions (insertBlock, updateBlockAttributes, removeBlock, moveBlock (top-level only), replaceBlocks, selection and dirty controls), and hooks useBlock, useChildren, useSelection, useIsDirty.
Public Exports
resources/js/visual-editor/editor/store/index.ts
Barrel re-exports: store creator, hooks, tree utilities, and types for external consumption.
Tests
resources/js/visual-editor/editor/store/__tests__/editorStore.test.ts, resources/js/visual-editor/editor/store/__tests__/editorStoreSelectors.test.tsx
Added Vitest suites covering store actions (immutability, dirty flag, edge cases) and hooks (selectors, reactive updates).

Sequence Diagram(s)

sequenceDiagram
    autonumber
    participant Component as "Component\n(useBlock/useChildren)"
    participant Hook as "Hook wrapper\n(useStore)"
    participant Store as "Zustand Store\n(createEditorStore)"
    participant Utils as "treeUtils\n(find/insert/remove/update)"
    Component->>Hook: subscribe(clientId / parentId)
    Hook->>Store: read blocks / selection / isDirty
    Component->>Store: dispatch action (insert/update/remove/move/select)
    Store->>Utils: call insertIntoTree/removeFromTree/updateAttributesInTree
    Utils-->>Store: return new blocks (or same if no-op)
    Store-->>Hook: update subscribers
    Hook-->>Component: re-render with updated data
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐰 I hopped into code, a tiny engineer,
I planted Zustand roots with cheer;
Blocks and selections, tidy and bright,
Dirty flags blinked into the night.
Hooray — the editor grows, hop-hop, delight! 🥕✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title accurately describes the main addition: a Zustand-backed editor store managing block tree, selection, and dirty flag state.
Linked Issues check ✅ Passed All requirements from issue #265 are satisfied: zustand dependency added, createEditorStore with required state properties, all actions implemented, memoized selectors/hooks provided, comprehensive vitest coverage included, and dirty flag semantics verified.
Out of Scope Changes check ✅ Passed All changes are directly scoped to #265: store foundation, tree utilities, types, barrel exports, tests, and vitest config updates. No undo/redo, persistence, or middleware beyond core zustand—correctly deferred to future phases.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/265-zustand-editor-store

Comment @coderabbitai help to get the list of available commands and usage tips.

@ViewFromTheBox ViewFromTheBox moved this from Backlog to In Review in ArtisanPack UI Overview Apr 13, 2026
@ViewFromTheBox
ViewFromTheBox marked this pull request as ready for review April 13, 2026 20:53

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@resources/js/visual-editor/editor/store/editorStore.ts`:
- Around line 23-27: The store is retaining caller-owned references: when
setting blocks from initialBlocks in createEditorStore (and in the
replaceBlocks(newBlocks) handler) you must deep-clone the incoming block
arrays/trees so the store owns its copy; update createEditorStore to clone
initialBlocks before assigning to the state (blocks) and update the
replaceBlocks implementation to deep-clone newBlocks (including nested block
objects) before set(...) so external mutations no longer bypass Zustand and
subscribers/isDirty stay in sync.
- Around line 29-38: The insertBlock action currently sets isDirty
unconditionally; change insertBlock to call insertIntoTree and compare the
returned blocks reference to the previous state.blocks and only set isDirty:
true when the returned blocks !== state.blocks (i.e., the tree actually
changed). Also modify insertIntoTree so that when the provided parentClientId is
unknown it returns the original blocks object (preserving reference identity)
rather than a new copy; this aligns insertBlock and other clientId-based actions
so stale parent ids become a true no-op.
- Around line 53-69: The removeBlock handler only clears selection when the
removed clientId exactly matches state.selection.clientId; instead, after
computing nextBlocks (via removeFromTree) check whether the currently selected
id (state.selection.clientId) still exists in nextBlocks and if not set
selection to initialSelection. Update the removeBlock function to compute
nextBlocks, detect selection existence (e.g., via a tree search helper or
existing utility) and assign nextSelection = selectionExists ? state.selection :
initialSelection before returning the new state.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 926a47f3-c1e1-4426-80bd-0f3d9a112fba

📥 Commits

Reviewing files that changed from the base of the PR and between ff0a951 and 47f3fdf.

⛔ Files ignored due to path filters (1)
  • package-lock.json is excluded by !**/package-lock.json, !package-lock.json
📒 Files selected for processing (8)
  • package.json
  • resources/js/visual-editor/editor/store/__tests__/editorStore.test.ts
  • resources/js/visual-editor/editor/store/__tests__/editorStoreSelectors.test.tsx
  • resources/js/visual-editor/editor/store/editorStore.ts
  • resources/js/visual-editor/editor/store/index.ts
  • resources/js/visual-editor/editor/store/treeUtils.ts
  • resources/js/visual-editor/editor/store/types.ts
  • vitest.config.ts

Comment on lines +23 to +27
export function createEditorStore(initialBlocks: Block[] = []): EditorStore {
return createStore<EditorStoreState>((set) => ({
blocks: initialBlocks,
selection: initialSelection,
isDirty: false,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Clone incoming block trees before putting them in store state.

blocks: initialBlocks and replaceBlocks(newBlocks) both keep caller-owned references alive inside the store. Any later mutation of those arrays or nested blocks will bypass Zustand entirely, so subscribers will not fire and isDirty will stay out of sync. That breaks the Phase 1 goal that the store owns the editor tree.

Also applies to: 111-117

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@resources/js/visual-editor/editor/store/editorStore.ts` around lines 23 - 27,
The store is retaining caller-owned references: when setting blocks from
initialBlocks in createEditorStore (and in the replaceBlocks(newBlocks) handler)
you must deep-clone the incoming block arrays/trees so the store owns its copy;
update createEditorStore to clone initialBlocks before assigning to the state
(blocks) and update the replaceBlocks implementation to deep-clone newBlocks
(including nested block objects) before set(...) so external mutations no longer
bypass Zustand and subscribers/isDirty stay in sync.

Comment thread resources/js/visual-editor/editor/store/editorStore.ts Outdated
Comment thread resources/js/visual-editor/editor/store/editorStore.ts
…remove

Two CodeRabbit follow-ups on the Zustand editor store:

1. insertIntoTree used blocks.map(...) which always returned a new array,
   so insertBlock with an unknown parentClientId produced a fresh blocks
   reference and flipped isDirty. It now tracks a changed flag and returns
   the original blocks when nothing matched, making insertBlock a true
   no-op for stale parent ids.

2. removeBlock only cleared the selection when the removed clientId
   exactly matched the current selection. Removing an ancestor containing
   the selected block left a dangling clientId pointing at nothing. It
   now checks whether the selected block still exists in nextBlocks and
   resets selection to the initial value when it does not.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

♻️ Duplicate comments (1)
resources/js/visual-editor/editor/store/editorStore.ts (1)

31-34: ⚠️ Potential issue | 🟠 Major

Clone inbound block trees before storing them.

Line 33 and Line 128 retain caller-owned references (initialBlocks / newBlocks). External mutation can bypass Zustand updates and desync subscribers/dirty semantics.

Proposed fix
+function cloneBlocks(blocks: Block[]): Block[] {
+    return blocks.map((block) => ({
+        ...block,
+        attributes: { ...block.attributes },
+        innerBlocks: cloneBlocks(block.innerBlocks),
+    }));
+}
+
 export function createEditorStore(initialBlocks: Block[] = []): EditorStore {
     return createStore<EditorStoreState>((set) => ({
-        blocks: initialBlocks,
+        blocks: cloneBlocks(initialBlocks),
         selection: initialSelection,
         isDirty: false,
@@
         replaceBlocks: (newBlocks: Block[]) => {
+            const nextBlocks = cloneBlocks(newBlocks);
             set((state) => ({
                 ...state,
-                blocks: newBlocks,
+                blocks: nextBlocks,
                 selection: initialSelection,
                 isDirty: true,
             }));
         },

Also applies to: 125-131

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@resources/js/visual-editor/editor/store/editorStore.ts` around lines 31 - 34,
The store is retaining caller-owned references (initialBlocks and newBlocks)
which allows external mutation to bypass Zustand updates; fix by deep-cloning
inbound block trees before storing or returning them: in createEditorStore
ensure the initialBlocks assigned to EditorStoreState.blocks is a deep clone
(e.g., via structuredClone or a dedicated cloneBlocks/cloneBlockTree helper) and
likewise clone newBlocks in the update path (the function that writes newBlocks
into state) so the store never holds external references; reference
createEditorStore, initialBlocks, newBlocks, and EditorStoreState when making
these changes.
🧹 Nitpick comments (1)
resources/js/visual-editor/editor/store/editorStore.ts (1)

152-168: Move selector logic into hooks with memoization to avoid unnecessary rerenders from unrelated tree mutations.

useBlock and useChildren subscribe to the full state.blocks tree. Any block mutation updates the blocks reference, triggering both hooks' useMemo to recompute even when the specific block or children being searched haven't changed.

Refactor sketch
 export function useBlock(store: EditorStore, clientId: string | null): Block | undefined {
-    const blocks = useStore(store, (state) => state.blocks);
-
-    return useMemo(() => {
-        if (clientId === null) {
-            return undefined;
-        }
-
-        return findBlock(blocks, clientId);
-    }, [blocks, clientId]);
+    const selector = useMemo(
+        () => (state: EditorStoreState) =>
+            clientId === null ? undefined : findBlock(state.blocks, clientId),
+        [clientId]
+    );
+
+    return useStore(store, selector);
 }
 
 export function useChildren(store: EditorStore, parentClientId: string | null): Block[] {
-    const blocks = useStore(store, (state) => state.blocks);
-
-    return useMemo(() => findChildren(blocks, parentClientId), [blocks, parentClientId]);
+    const selector = useMemo(
+        () => (state: EditorStoreState) => findChildren(state.blocks, parentClientId),
+        [parentClientId]
+    );
+
+    return useStore(store, selector);
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@resources/js/visual-editor/editor/store/editorStore.ts` around lines 152 -
168, useBlock and useChildren currently subscribe to the entire state.blocks
object so any mutation triggers recomputation; change them to select only the
needed slice from the store instead of the whole tree: in useBlock, call
useStore(store, (state) => findBlock(state.blocks, clientId)) (or a selector
that returns undefined when clientId is null) and remove the outer useMemo, and
in useChildren, call useStore(store, (state) => findChildren(state.blocks,
parentClientId)) (again handling null parentClientId inside the selector) so the
hooks only re-render when their specific block/children result changes.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@resources/js/visual-editor/editor/store/editorStore.ts`:
- Around line 31-34: The store is retaining caller-owned references
(initialBlocks and newBlocks) which allows external mutation to bypass Zustand
updates; fix by deep-cloning inbound block trees before storing or returning
them: in createEditorStore ensure the initialBlocks assigned to
EditorStoreState.blocks is a deep clone (e.g., via structuredClone or a
dedicated cloneBlocks/cloneBlockTree helper) and likewise clone newBlocks in the
update path (the function that writes newBlocks into state) so the store never
holds external references; reference createEditorStore, initialBlocks,
newBlocks, and EditorStoreState when making these changes.

---

Nitpick comments:
In `@resources/js/visual-editor/editor/store/editorStore.ts`:
- Around line 152-168: useBlock and useChildren currently subscribe to the
entire state.blocks object so any mutation triggers recomputation; change them
to select only the needed slice from the store instead of the whole tree: in
useBlock, call useStore(store, (state) => findBlock(state.blocks, clientId)) (or
a selector that returns undefined when clientId is null) and remove the outer
useMemo, and in useChildren, call useStore(store, (state) =>
findChildren(state.blocks, parentClientId)) (again handling null parentClientId
inside the selector) so the hooks only re-render when their specific
block/children result changes.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: f28606fc-499b-40a6-b438-37405ec4e7e3

📥 Commits

Reviewing files that changed from the base of the PR and between 47f3fdf and 5973c7d.

📒 Files selected for processing (3)
  • resources/js/visual-editor/editor/store/__tests__/editorStore.test.ts
  • resources/js/visual-editor/editor/store/editorStore.ts
  • resources/js/visual-editor/editor/store/treeUtils.ts
✅ Files skipped from review due to trivial changes (2)
  • resources/js/visual-editor/editor/store/tests/editorStore.test.ts
  • resources/js/visual-editor/editor/store/treeUtils.ts

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Development

Successfully merging this pull request may close these issues.

Phase 1.3: Zustand editor store — block tree, selection, dirty flag

1 participant