Skip to content

Phase 1: React editor foundation (store, REST, blocks, shell) - #291

Merged
ViewFromTheBox merged 46 commits into
reactfrom
add/phase-one
Apr 15, 2026
Merged

ViewFromTheBox merged 46 commits into
reactfrom
add/phase-one

Conversation

@ViewFromTheBox

@ViewFromTheBox ViewFromTheBox commented Apr 14, 2026 •

Copy link
Copy Markdown
Member

Description

Phase 1 of the React editor migration lands on the long-running react branch. This brings the React editor from an empty shell to a working authoring surface: Zustand store, undo/redo, dnd-kit reordering, paragraph/heading blocks with a shared rich-text toolbar, a sidebar inserter with slash command, REST load/save with autosave, and the Blade mount view that boots it. The Alpine editor remnants and Phase 0 spike harness have been removed.

Closes: Phase 1 scope (issues #264–#276, #290)

Type of Change

  • New feature (adds new functionality)
  • Breaking change (removes the Alpine editor in favor of React)

Motivation and Context

`add/phase-one` is the integration branch where every Phase 1 merge request has been landing. Now that the last item (Alpine cleanup, #290) is merged and CodeRabbit findings on this branch have been addressed, the accumulated phase is ready to roll up into `react` as a single reviewable unit before Phase 2 work starts.

Changes Made

How Has This Been Tested?

Testing Environment:

  • Operating System: macOS
  • PHP Version: 8.4.3
  • Project Version: visual-editor `add/phase-one`

Tests Performed:

  1. `./vendor/bin/pest` — 25 passed, 69 assertions
  2. `npx vitest run` — 196 passed across 22 test files
  3. `npx tsc --noEmit` — clean
  4. CodeRabbit CLI review against `react` base — 2 findings, both addressed in this branch

Accessibility Tests Run

  • Keyboard navigation tested
  • Screen reader tested
  • Color contrast verified
  • ARIA labels checked

Details: Individual phase 1 MRs covered a11y for their components (toolbar, inserter, shell). A full cross-cutting a11y sweep is planned for a later phase once the editor surface stabilizes.

Tests Added

  • Unit tests added/updated
  • Integration tests added/updated
  • All tests passing

Test details: Each sub-MR landed its own unit + feature tests; totals above reflect the suite on this branch.

Test plan

  • Merge to `react` and verify CI passes
  • Smoke-test the editor boot, load, edit, autosave, and reload flow in the dev app
  • Confirm CodeRabbit auto-review on this PR surfaces nothing new

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Full visual editor UI (paragraph & heading blocks, rich text formatting, links, slash‑command inserter), block drag‑and‑drop reordering, undo/redo, and autosave with retry/error reporting; REST API endpoints to load/save post block trees and list block types; editor mount page/route added.
  • Documentation

    • Phase‑1 strategy, REST API, Tiptap and DnD findings added.
  • Tests

    • Extensive new unit/integration test coverage for editor UI, store/history, inserter, DnD, autosave, and REST client.
  • Chores

    • Test tooling and build config updates; minor project housekeeping.

ViewFromTheBox and others added 30 commits April 13, 2026 14:52
Stand up a production-ready Vite pipeline for the new visual editor package
alongside the existing Phase 0 spike build. Adds a placeholder entry point,
a dedicated editor Vite config that externalizes React/ReactDOM/JSX runtime,
a dev HTML mount, and new npm scripts (`dev:editor`, `build:editor`). The
spike build at public/editor-spike/ is untouched.

- New entry: resources/js/visual-editor/editor/main.tsx (placeholder)
- New dev HTML: resources/js/visual-editor/editor/index.html
- New config: vite.editor.config.ts (root switches per command, builds to dist/editor/)
- Externalize react, react/jsx-runtime, react/jsx-dev-runtime, react-dom, react-dom/client
- tsconfig.json: add @editor/* path alias + visual-editor include
- package.json: add build:editor and dev:editor scripts
- .gitignore: ignore dist/ and .vite/ cache directories

Closes #263
…n-vite-config-react-externalized

feat: Phase 1.1 — production Vite config with dev server + externalized React
Adds the Phase 1.2 entry points so host apps can render the React editor:

- resources/views/editor/mount.blade.php renders a #ve-root element
  carrying data-post-id, data-post-type and data-api-base, and pulls
  the editor entry through @viteReactRefresh + @Vite.
- resources/js/visual-editor/editor/main.tsx reads and types those
  data attributes, mounts an EditorShell placeholder, and logs clear
  console errors when the mount point or required attributes are
  missing instead of failing silently.
- resources/js/visual-editor/editor/index.html is updated so the
  standalone Vite dev spike still boots against the new #ve-root
  contract.
- routes/web.php registers a new /editor route alongside the existing
  /editor-spike route, which stays in place until the Phase 1 cleanup.
- A Feature test locks in the view, route, and boot script contract.

Closes #264
readEditorConfig previously accepted whitespace-only values like
"   " because the raw dataset strings were truthy. Trim each value
before the guard so malformed attributes log the clear console.error
instead of mounting with garbage config.

Refs #264
…nt-view-react-editor-boot

feat: blade mount view and boot script for React editor
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
…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.
…r-store

feat: Zustand editor store — block tree, selection, dirty flag (#265)
Add a hand-rolled history middleware wrapping the Zustand editor store.
Content-affecting actions (insertBlock, updateBlockAttributes, removeBlock,
moveBlock, replaceBlocks) push the previous blocks snapshot onto a bounded
100-entry past stack; selection and dirty-flag actions do not. Rapid
updateBlockAttributes calls targeting the same block within a 500ms window
coalesce into a single history entry, so undoing a typing burst rolls back
the whole burst at once.

Adds undo() / redo() actions, useCanUndo / useCanRedo selectors, and 19
Vitest cases covering round-trips, bottom/top no-ops, selection isolation,
coalescing (same block, cross-block, window expiry, non-update barrier),
bounded stack eviction, and action no-ops.

Closes #266
Time-travel could previously leave the store at a blocks snapshot that
diverges from the last markClean checkpoint while still reporting
isDirty=false, because undo/redo spread the prior state verbatim. Set
isDirty=true in both handlers so navigating the history is always treated
as a modification relative to the last saved baseline.
…tory-store

feat: undo/redo history layer on editor store
Documents the per-extension audit of @tiptap/starter-kit and the decision to
replace Tiptap's stock Paragraph node with a custom BlockParagraph that
dispatches split/merge to the Zustand block store. Produces the final Tiptap
dependency list consumed by #272.

Closes #267
…tension-audit-paragraph-node-strategy

docs: Tiptap extension audit and paragraph node strategy (Phase 1)
Validate that @dnd-kit/core + @dnd-kit/sortable coexist with @tiptap/react
before #271 integrates dnd-kit into production BlockList. The harness lives
under resources/js/visual-editor/editor/__dnd-kit-spike__/, reuses the same
useTiptap hook production blocks use, and is excluded from the editor
bundle via rollupOptions.input (dist/editor/main.js stays 1.20 kB).

All four interaction checks from PHASE-0-FINDINGS.md finding #3 pass:
drag handles do not interfere with Tiptap text selection, pointer sensor
uses an 8px distance threshold to avoid contenteditable conflicts, stable
id-keyed reorders preserve Tiptap state in remaining blocks, and the
keyboard sensor is scoped to the handle so Tiptap keyboard handling is
never hijacked. Findings and carry-forward constraints for #271 are
documented in docs/phase-1-dnd-kit-findings.md.

Closes #268
SpikeBlock.tsx imports CSS from @dnd-kit/utilities, which was previously
only available transitively through @dnd-kit/sortable. Pin it explicitly
so the import resolves independently of sortable's install tree.
…-harness

feat: dnd-kit + Tiptap spike harness (#268)
Port BlockContextProvider, useInnerBlocksProps, and useBlockPreview from
the Phase 0 spike into resources/js/visual-editor/editor/primitives/ with
strict types and Zustand store integration.

- BlockContext: typed selector hook with runtime type guard (no `as T`
  escape hatches) and stable refs across renders
- EditorStoreContext: new React context exposing the editor store to
  primitives
- useInnerBlocksProps: reads children from the Zustand store, exposes
  appendBlock/removeBlock/moveBlock APIs, and propagates clicks to store
  selection via capture
- useBlockPreview: renders inert, aria-hidden, pointer-events:none
  previews backed by an ephemeral store
- Block registry ported to the production tree
- Vitest coverage for each primitive (happy path + edge cases)

Spike copies under resources/js/editor-spike/primitives/ are left in
place per PHASE-0-FINDINGS.md; cleanup happens in the final Phase 1
issue.

Closes #269

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Ensures the spy is restored even if the toThrow assertion fails,
preventing spy leakage between tests.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…-spike-primitives

feat: productionize spike primitives with store integration
Build the React component skeleton that frames the editor: EditorShell,
Canvas, BlockList, and BlockWrapper. No block-type-specific code — just
the layout, the canvas that holds the tree, the block list that iterates
the store, and the block wrapper that handles selection, hover, and a
drag-handle slot for every block type.

- EditorShell wraps the tree in EditorStoreProvider, installs global
  keyboard bindings (Cmd/Ctrl+Z undo, Cmd/Ctrl+Shift+Z and Ctrl+Y redo),
  and renders a dirty/selection statusbar
- Canvas provides the editable chrome region
- BlockList uses the productionized useInnerBlocksProps from #269 and
  maps top-level blocks to BlockWrappers
- BlockWrapper adds hover/selection outlines, a drag-handle slot for
  #271, and delegates rendering to RenderBlock from the primitives
- Minimal editor.css for block boundaries, selection/hover states, and
  drag-handle affordance
- main.tsx now creates a real editor store seeded with two placeholder
  blocks and renders <EditorShell store={store} />
- Vitest coverage for BlockWrapper, BlockList, and EditorShell (16 new
  tests) — selection flows, keyboard undo/redo, hover/select classes,
  statusbar state

Closes #270

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…canvas-blocklist-blockwrapper

feat: editor shell — EditorShell, Canvas, BlockList, BlockWrapper
Wraps BlockList in DndContext + SortableContext with pointer (distance: 8)
and keyboard sensors, turns the BlockWrapper drag-handle slot into a real
accessible button via setActivatorNodeRef, and dispatches moveBlock on
drop for top-level reorders only. Adds a DragOverlay, screen-reader
announcements, and instructions covering the full keyboard drag flow
(space to pick up, arrows to move, space to drop, escape to cancel).

Tests cover wiring (handles, sensors, click-through, a11y) and drag
dispatch by mocking DndContext to drive onDragEnd for pointer and
keyboard paths — jsdom lacks layout, so the sensors cannot compute drop
targets through it.

Closes #271

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…list-integration

feat: integrate dnd-kit into production BlockList (top-level)
Adds the first two production block types and a shared Bold/Italic/Link
toolbar. Both blocks use a minimal Tiptap editor (document + paragraph or
heading + text + hard-break + bold/italic/link) with a block-level keymap
that dispatches Enter and Backspace back to the Zustand store.

- Split (Enter) and merge (Backspace at start) are committed via
  replaceBlocks so each operation is a single undo step.
- Backspace-merge places the cursor at the merge boundary via a pending
  cursor sidechannel, not at the end of the combined content.
- Icons are served via @fortawesome/react-fontawesome with an Icon
  wrapper; the toolbar and block drag handle both consume it.
- Adds registerCoreBlocks() and wires it into the editor boot so the
  initial document contains a heading + paragraph.

Closes #272
`clip: rect(...)` has been deprecated in favor of `clip-path: inset(50%)`
for the visually-hidden pattern. Swap it so the rule keeps working in
modern browsers without deprecation warnings.
…ding-rich-text-toolbar

feat: paragraph/heading blocks with shared rich-text toolbar
Adds the first two ways to insert new blocks into the canvas: a sidebar
panel with a searchable block list, and an in-editor slash command that
replaces the current paragraph with a chosen block type.

- New `editor/inserter/` module: cached-snapshot registry, built-in
  seeds for paragraph/heading, block factories, API loader with silent
  fallback (for #274), `insertBlockAtSelection`, and an atomic
  `replaceBlockWithInserterBlock` that commits replacement in a single
  undo step.
- `InserterPanel` renders a searchable, click-to-insert list wired into
  the editor shell next to the canvas.
- `SlashCommandPopover` renders the slash menu; `ParagraphEdit`
  detects `/query` in the block's plain text, filters the list,
  handles ArrowUp/ArrowDown/Escape via a window listener, routes Enter
  through Tiptap's keymap to commit the replacement, and suppresses
  the backspace-merge path while the slash popover is open.
- History snapshots now capture `{ blocks, selection }` so undo/redo
  restore the caret to the block that was being edited at the time of
  the change. This fixes the focus-loss after undoing a slash command
  replacement — without it, the replaced block's clientId was gone
  and nothing was selected, leaving the editor unfocused.
- Boot sequence calls `loadInserterBlocks({ apiBase })`.

Closes #273
ViewFromTheBox and others added 9 commits April 14, 2026 14:46
- BlockTreeRule: enforce clientId uniqueness and bound recursion via
  MAX_DEPTH (10) and MAX_NODES (1000) so untrusted payloads cannot
  blow the stack or burn unbounded CPU.
- BlockTypeRegistry::register(): trim + validate names against a
  namespaced lowercase-kebab pattern and throw InvalidArgumentException
  on empty or malformed input.
- VisualEditorPost: switch from the Laravel 11+ casts() method to the
  \$casts property so the package stays compatible with the declared
  illuminate/support >=5.3 constraint.
- docs/phase-1-rest-api.md: clarify that the api + auth middleware
  stack is the default and overridable via config.
- Add Pest coverage for duplicate clientIds, depth limit, node limit,
  and BlockTypeRegistry name validation.
Mirrors the existing GET coverage so both read and write paths exercise
the policy rejection when artisanpack.visual-editor.authorization.restrict_by_owner
is enabled. Also asserts the original blocks are unchanged to confirm
the Gate check fires before the controller persists anything.
…s-blocks

feat: REST API for posts GET/PUT and blocks GET (Phase 1.12)
Boots the React editor from the package's REST endpoints so opening
/editor/{id} loads the post via GET, edits debounce a PUT autosave,
and reloads pick up persisted changes. Adds a direct Zustand store
subscription in useAutosave so debounce/save cycles survive every
isDirty transition, not just the first one.

Includes Vitest coverage for the REST client, autosave happy/error/
retry paths, and the EditorBoot load + retry flow.

Closes #275

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…s idle

When saveStatus.status === 'idle', the label falls back to the dirty/saved
text but statusToken was still reporting 'idle', so data-ve-save-status
could diverge from what the user sees.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…or-rest-load-save-persistence

feat: wire editor to REST load, autosave, reload persistence
Phase 1.14 — completes the React migration's Phase 1 goal. After this
lands, the visual editor tree contains no Alpine-era or spike-era
scaffolding.

- Remove resources/js/editor-spike/ (Phase 0 architecture spike)
- Remove resources/js/visual-editor/editor/__dnd-kit-spike__/ (Phase 1
  dnd-kit harness that depended on @spike/richtext/useTiptap)
- Remove editor-spike route, blade view, index.html entry, and the
  visual-editor-spike-assets publish tag
- Consolidate vite.editor.config.ts into the root vite.config.ts and
  drop the spike-only vite.config.ts; simplify npm scripts
- Drop the @spike path alias from tsconfig.json and vitest.config.ts
- Move the Tiptap/jsdom test setup into
  resources/js/visual-editor/editor/test-setup.ts
- Update EditorMountViewTest route assertion and remove
  public/editor-spike/ from .gitignore
- Mark Phase 1 as landed in REACT-MIGRATION.md

Closes #276

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…pine-editor-phase-0-spike

chore: delete Alpine editor remnants and Phase 0 spike
- BlockTypeRegistry::unregister() now trims the name so entries
  registered with surrounding whitespace can be removed correctly
- EditorBoot accepts the full props object so the postType prop
  required by callers (main.tsx, tests) passes type-checking under
  noUnusedParameters while remaining reserved for block-context wiring

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Apr 14, 2026 •

Copy link
Copy Markdown

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 6210d4ba-2ece-475d-9927-f4aeac085a9e

📥 Commits

Reviewing files that changed from the base of the PR and between b59d5ab and a0888a2.

📒 Files selected for processing (2)
  • resources/js/visual-editor/editor/rest/__tests__/postRestClient.test.ts
  • resources/js/visual-editor/editor/rest/postRestClient.ts
✅ Files skipped from review due to trivial changes (1)
  • resources/js/visual-editor/editor/rest/tests/postRestClient.test.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • resources/js/visual-editor/editor/rest/postRestClient.ts

📝 Walkthrough

Walkthrough

Phase‑0 editor‑spike artifacts were removed and replaced by a Phase‑1 visual editor: a React + TipTap frontend with a Zustand store, dnd‑kit reordering, inserter UI, autosave and REST endpoints, server-side models/controllers/validation/policies, migrations, and many tests and build-tooling updates.

Changes

Cohort / File(s) Summary
Ignore & Build
/.gitignore, vite.config.ts, vitest.config.ts, tsconfig.json
Stopped ignoring public/editor-spike/; added dist/ and .vite/; switched aliases from @spike→@editor; updated Vite/Vitest/TS configs, dev server, and build inputs/outputs.
Deps & Scripts
package.json, composer.json
Added frontend deps (dnd‑kit, FontAwesome, extra @tiptap packages, zustand, testing libs) and PHP dev dep orchestra/testbench; added Composer test script.
Docs
REACT-MIGRATION.md, docs/phase-1-*.md
Updated migration status and added Phase‑1 docs: dnd‑kit findings, REST API spec, and TipTap strategy.
Spike removal
Removed: resources/views/editor-spike.blade.php, resources/js/editor-spike/**, resources/js/editor-spike/__tests__/*
Deleted the entire editor‑spike app, assets, mocks, blocks, primitives, and spike tests.
New editor entry & view
resources/views/editor/mount.blade.php, resources/js/visual-editor/editor/index.html, resources/js/visual-editor/editor/main.tsx
Added Blade mount view, HTML entry, and TS bootstrap that reads data-* config, registers blocks, loads inserter blocks, and mounts the app.
Frontend — store & types
resources/js/visual-editor/editor/store/*
New Zustand-based editor store, tree utilities, client-id generator, types, hooks, history/undo-redo, and index exports.
Frontend — store tests
resources/js/visual-editor/editor/store/__tests__/*
Comprehensive tests for store initialization, mutations, history/coalescing, selectors, and undo/redo.
Frontend — primitives & contexts
resources/js/visual-editor/editor/primitives/*
New BlockContext, EditorStoreContext, ReadOnlyContext, InnerBlocks + RenderBlock, BlockPreview, and barrel exports.
Frontend — primitives tests
resources/js/visual-editor/editor/primitives/__tests__/*
Tests for BlockContext, EditorStoreContext, useInnerBlocksProps, and useBlockPreview.
Frontend — registry
resources/js/visual-editor/editor/registry/*
Block definition registry with name validation and index re-exports.
Frontend — core blocks
resources/js/visual-editor/editor/blocks/*
Paragraph and Heading block implementations (TipTap integration, split/merge, slash-command, level normalization), registration, and tests.
Frontend — shared block editor utilities
resources/js/visual-editor/editor/blocks/shared/*
Shared useBlockTiptap hook, blockEditorRegistry, split/merge logic, splitContent utilities, and barrel export.
Frontend — components & UI
resources/js/visual-editor/editor/components/*, resources/js/visual-editor/editor/components/editor.css
EditorBoot, EditorShell, Canvas, BlockList (dnd‑kit), BlockWrapper (sortable handle), RichTextToolbar, InserterPanel, SlashCommandPopover, Icon component, styling, and barrel.
Frontend — components tests
resources/js/visual-editor/editor/components/__tests__/*
Tests covering BlockList rendering/selection/DnD, BlockWrapper states, EditorBoot load/retry, EditorShell status/undo-redo, InserterPanel search/insert, RichTextToolbar features.
Frontend — inserter system & tests
resources/js/visual-editor/editor/inserter/*, .../__tests__/*
Inserter registry, builtin inserter blocks, API loader, filter logic, insert/replace actions, and tests for registry/loader/filter/insert.
Frontend — REST & autosave
resources/js/visual-editor/editor/rest/*
Post REST client (fetchPost/savePost), useAutosave hook (debounce, exponential backoff, retries), index re-exports, and tests.
Backend — migrations & models
database/migrations/2026_04_14_000000_create_ve_contents_table.php, database/migrations/testing/2026_04_14_000000_create_users_table.php, src/Models/VisualEditorPost.php
Added ve_contents migration, testing users migration, and VisualEditorPost model with blocks cast.
Backend — routes, controllers & request
routes/api.php, routes/web.php, src/Http/Controllers/VisualEditorPostsController.php, src/Http/Controllers/VisualEditorBlocksController.php, src/Http/Requests/UpdatePostBlocksRequest.php
Added /visual-editor/api routes (posts GET/PUT, blocks GET), controllers, and form request using BlockTreeRule. Replaced /editor-spike route with /editor serving mount view and params.
Backend — validation & policy
src/Rules/BlockTreeRule.php, src/Policies/VisualEditorPostPolicy.php
New recursive BlockTree validation rule (shape/depth/node-count/clientId uniqueness with dot-path on first failure) and VisualEditorPostPolicy with optional owner restriction.
Backend — registry & provider
src/Registries/BlockTypeRegistry.php, src/VisualEditorServiceProvider.php
BlockTypeRegistry seeded with core/paragraph and core/heading; service provider binds registry, loads migrations, registers API routes and policy.
Backend — tests & test infra
tests/TestCase.php, tests/TestUser.php, tests/Pest.php, tests/Feature/*, tests/Unit/*
Switched tests to Orchestra Testbench, in-memory SQLite setup, TestUser model, Pest config, and added feature/unit tests for mount view, posts/blocks API, and registry.

Sequence Diagram(s)

sequenceDiagram
  participant Browser as Browser
  participant Boot as EditorBoot
  participant Store as EditorStore
  participant Autosave as useAutosave
  participant REST as REST API
  participant Controller as PostsController
  participant DB as Database

  Browser->>Boot: load (reads data-* from mount)
  Boot->>REST: GET /visual-editor/api/posts/{post}
  REST->>Controller: VisualEditorPostsController::show
  Controller->>DB: query ve_contents
  DB-->>Controller: post payload
  Controller-->>Boot: JSON payload
  Boot->>Store: createEditorStore(payload.blocks)

  Note over Browser,Store: User edits content
  Browser->>Store: mutate blocks (insert/update)
  Store-->>Autosave: isDirty change triggers debounce
  Autosave->>REST: PUT /visual-editor/api/posts/{post} (debounced/with CSRF)
  REST->>Controller: VisualEditorPostsController::update
  Controller->>DB: persist blocks
  DB-->>Controller: saved record
  Controller-->>Autosave: response (saved)
  Autosave-->>Store: markClean (if snapshot matches)
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~75 minutes

Possibly related PRs

Poem

🐰 I hopped from spike to Phase One bright,
Blocks now dance with drag-and-drop delight.
TipTap hums while autosave keeps score,
REST and store tuck data in a drawer.
Tests nibble carrots — the editor springs light!

✨ 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 add/phase-one

@ViewFromTheBox ViewFromTheBox moved this from Backlog to In Review in ArtisanPack UI Overview Apr 14, 2026
@ViewFromTheBox ViewFromTheBox linked an issue Apr 14, 2026 that may be closed by this pull request
14 tasks done
@ViewFromTheBox
ViewFromTheBox marked this pull request as ready for review April 14, 2026 22:08

@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: 11

🧹 Nitpick comments (18)
resources/js/visual-editor/editor/components/Icon.tsx (1)

11-19: Treat whitespace-only titles as missing.

A title like " " currently sets role="img" without a meaningful label. Trim first to avoid accidental accessibility noise.

Suggested refactor
 export function Icon({ icon, className, title }: IconProps) {
-    const hasTitle = typeof title === 'string' && title.length > 0;
+    const normalizedTitle = typeof title === 'string' ? title.trim() : '';
+    const hasTitle = normalizedTitle.length > 0;
 
     return (
         <FontAwesomeIcon
             icon={icon}
             className={className}
-            title={title}
+            title={hasTitle ? normalizedTitle : undefined}
             role={hasTitle ? 'img' : undefined}
             aria-hidden={hasTitle ? undefined : true}
         />
     );
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@resources/js/visual-editor/editor/components/Icon.tsx` around lines 11 - 19,
The component currently treats whitespace-only strings as valid titles; update
the title handling by trimming before checking length: change hasTitle to use
title.trim() (e.g., hasTitle = typeof title === 'string' && title.trim().length
> 0), pass the trimmed title to FontAwesomeIcon when hasTitle is true
(title={hasTitle ? title.trim() : undefined}), and keep role and aria-hidden
logic based on this new hasTitle so whitespace-only titles are treated as
missing.
database/migrations/2026_04_14_000000_create_ve_contents_table.php (1)

29-29: Add foreign key constraint to author_id to prevent orphaned ownership records.

Since author_id references the users table (based on policy authorization logic), define a foreign key with nullOnDelete() to maintain referential integrity while preserving the nullable design:

$table->foreignId( 'author_id' )->nullable()->constrained( 'users' )->nullOnDelete();

This prevents scenarios where a user is deleted but orphaned ve_contents records remain, which could affect access control audits.

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

In `@database/migrations/2026_04_14_000000_create_ve_contents_table.php` at line
29, Replace the nullable unsignedBigInteger column definition for author_id in
the CreateVeContentsTable migration with a nullable foreign key that references
users and uses nullOnDelete; specifically update the schema change around the
author_id definition in the CreateVeContentsTable migration (the ve_contents
table setup) to use a foreignId constrained to 'users' with nullOnDelete so
deleted users set author_id to null and prevent orphaned ownership records.
routes/web.php (1)

7-13: Hardcoded route parameters limit real-world usage.

The route hardcodes postId => '1' and postType => 'page', which is fine for Phase 1 scaffolding/demo purposes but won't support editing arbitrary posts. Consider parameterizing this route or documenting that it's a temporary dev entry point.

-Route::get('/editor', function () {
+Route::get('/editor/{postType}/{postId}', function (string $postType, string $postId) {
     return view('visual-editor::editor.mount', [
-        'postId' => '1',
-        'postType' => 'page',
+        'postId' => $postId,
+        'postType' => $postType,
         'apiBase' => '/api',
     ]);
 })->name('visual-editor.editor');
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@routes/web.php` around lines 7 - 13, The route closure for
Route::get('/editor', ...)->name('visual-editor.editor') currently passes
hardcoded view params 'postId' => '1' and 'postType' => 'page'; change it to
accept dynamic inputs (e.g., route parameters like {post} and {type} or read
from query string) and forward those values into the view
'visual-editor::editor.mount' instead of the hardcoded literals,
validating/casting the inputs as needed (or mark the route clearly as a
temporary dev-only entry in comments/config if you intend to keep hardcoded
defaults). Ensure you update any callers/links to use the new parameters and
keep the route name 'visual-editor.editor' unchanged.
resources/js/visual-editor/editor/rest/__tests__/postRestClient.test.ts (1)

93-132: Add CSRF-header parity test for savePost.

fetchPost validates CSRF header behavior, but savePost currently doesn’t. Adding that assertion would close a small regression gap.

✅ Suggested test addition
 describe('savePost', () => {
@@
     it('throws PostRestError on validation failure', async () => {
@@
     });
+
+    it('attaches a CSRF token when provided', async () => {
+        const fetchImpl = vi.fn().mockResolvedValue(mockJsonResponse(samplePayload));
+
+        await savePost('1', blocks, {
+            apiBase: '/visual-editor/api',
+            fetchImpl,
+            csrfToken: 'token-xyz',
+        });
+
+        const headers = fetchImpl.mock.calls[0][1].headers as Headers;
+        expect(headers.get('X-CSRF-TOKEN')).toBe('token-xyz');
+    });
 });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@resources/js/visual-editor/editor/rest/__tests__/postRestClient.test.ts`
around lines 93 - 132, The savePost tests are missing a CSRF-header assertion;
update the 'PUTs the block tree and returns the persisted payload' spec for
savePost to also assert the fetch call includes the same CSRF header checked in
fetchPost (e.g. ensure (init.headers as Headers).get('X-WP-Nonce') (or the
project’s CSRF header name) is present and equals the expected test nonce value
provided by the test setup), locating the call via the savePost test's
fetchImpl.mock.calls and adding the header assertion alongside the existing
method/content-type/body checks.
tests/Feature/EditorMountViewTest.php (1)

14-15: Add explicit file-read assertions for clearer failures.

If file_get_contents returns false, current failures become less actionable than they need to be.

♻️ Proposed refactor
 $viewPath = __DIR__.'/../../resources/views/editor/mount.blade.php';
 $routesPath = __DIR__.'/../../routes/web.php';
 $bootPath = __DIR__.'/../../resources/js/visual-editor/editor/main.tsx';
+
+function mustReadFile(string $path): string {
+    $contents = file_get_contents($path);
+    expect($contents)->not->toBeFalse();
+    return $contents;
+}
@@
 test('mount blade view renders the ve-root element with data attributes', function () use ($viewPath) {
-    $contents = file_get_contents($viewPath);
+    $contents = mustReadFile($viewPath);
@@
 test('mount blade view pulls the editor entry through `@vite`', function () use ($viewPath) {
-    $contents = file_get_contents($viewPath);
+    $contents = mustReadFile($viewPath);
@@
 test('package routes register the /editor path', function () use ($routesPath) {
-    $contents = file_get_contents($routesPath);
+    $contents = mustReadFile($routesPath);
@@
 test('boot script targets `#ve-root` and logs a clear error when missing', function () use ($bootPath) {
-    $contents = file_get_contents($bootPath);
+    $contents = mustReadFile($bootPath);

Also applies to: 23-24, 29-30, 40-41

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

In `@tests/Feature/EditorMountViewTest.php` around lines 14 - 15, The test reads
view files using file_get_contents into $contents but doesn't assert the read
succeeded, so failures are unclear when file_get_contents returns false; update
EditorMountViewTest to assert the read before further assertions (e.g. use
$this->assertNotFalse($contents) or $this->assertIsString($contents) and
optionally $this->assertNotEmpty($contents)) immediately after each
file_get_contents call (references: variable $contents and the
file_get_contents(...) calls present in the test at the multiple spots noted) so
subsequent assertions operate on a valid string.
resources/js/visual-editor/editor/rest/__tests__/useAutosave.test.tsx (1)

51-53: Move onState out of render.

Calling onState(state) during render makes the harness impure and will become noisy fast if a future caller updates React state there. Push the notification into an effect instead.

♻️ Proposed refactor
+import { useEffect } from 'react';
+
 function AutosaveHarness({
     store,
     fetchImpl,
     onState,
@@
     const state = useAutosave({
         store,
         postId: '1',
         clientOptions: { apiBase: '/visual-editor/api', fetchImpl },
         debounceMs,
         retryBaseMs,
         maxRetries,
     });
 
-    if (onState) {
-        onState(state);
-    }
+    useEffect(() => {
+        onState?.(state);
+    }, [onState, state]);
 
     return <div data-testid="status">{state.status}</div>;
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@resources/js/visual-editor/editor/rest/__tests__/useAutosave.test.tsx` around
lines 51 - 53, The test currently calls onState(state) during render (inside the
render path where onState is checked), which makes the harness impure; change
this by removing the direct call from render and invoking onState from a React
effect instead: create a useEffect (or the test harness equivalent) that runs
when state (and onState) change, and inside it guard if (onState)
onState(state); include onState and state in the effect dependency array so
notifications occur on updates and avoid invoking callbacks during render.
src/Policies/VisualEditorPostPolicy.php (1)

84-91: Method name isOwnerOrAdmin is misleading.

The method only checks ownership, not admin status. Consider renaming to isOwner to accurately reflect its behavior, or add actual admin role checking if that's intended.

♻️ Proposed rename for clarity
-	protected function isOwnerOrAdmin( Authenticatable $user, VisualEditorPost $post ): bool
+	protected function isOwner( Authenticatable $user, VisualEditorPost $post ): bool
 	{
 		if ( null === $post->author_id ) {
 			return true;
 		}

 		return (int) $post->author_id === (int) $user->getAuthIdentifier();
 	}

Also update the calls on lines 42 and 60:

-			return $this->isOwnerOrAdmin( $user, $post );
+			return $this->isOwner( $user, $post );
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/Policies/VisualEditorPostPolicy.php` around lines 84 - 91, The method
VisualEditorPostPolicy::isOwnerOrAdmin only checks ownership, so rename it to
isOwner (or implement real admin-checking if admin behavior is desired); update
all call sites that invoke isOwnerOrAdmin (e.g., the two places that currently
call it in this policy) to call isOwner instead, and update any PHPDoc/type
hints and tests referencing isOwnerOrAdmin to use the new name so behavior and
naming stay consistent.
resources/js/visual-editor/editor/components/SlashCommandPopover.tsx (1)

16-22: Consider adding ARIA listbox semantics for better screen reader support.

The popover has an aria-label, but adding role="listbox" on the <ul> and role="option" on list items with aria-selected would improve screen reader navigation. This can be deferred to the accessibility sweep mentioned in the PR objectives.

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

In `@resources/js/visual-editor/editor/components/SlashCommandPopover.tsx` around
lines 16 - 22, The popover's markup in SlashCommandPopover lacks ARIA listbox
semantics; update the rendered list container (the <ul> in the component) to
include role="listbox" and each list item (rendered options in the component's
map) to include role="option" and an appropriate aria-selected attribute
reflecting selection state (true/false); ensure you update the render logic in
the SlashCommandPopover component where the option elements are created so
screen readers can navigate the list properly.
resources/js/visual-editor/editor/blocks/paragraph/edit.tsx (1)

111-138: Reference to editor before assignment in callbacks.

The onEnter and onBackspaceAtStart callbacks reference editor (lines 124, 133) but editor is assigned from the return value of useBlockTiptap. Since hooks return synchronously and these callbacks are only invoked after the editor is mounted, this works in practice. However, the TypeScript checker may flag this as a potential issue, and the pattern is subtle.

Consider restructuring to make the dependency clearer, or add a comment explaining why this is safe.

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

In `@resources/js/visual-editor/editor/blocks/paragraph/edit.tsx` around lines 111
- 138, The callbacks passed into useBlockTiptap (onEnter, onBackspaceAtStart)
reference the editor variable returned from useBlockTiptap which can look like a
forward reference to TypeScript; either capture the editor in a stable ref and
use that ref.current inside onEnter/onBackspaceAtStart, or rewrite the callbacks
to receive the editor instance (or a guaranteed non-null typed editor) before
calling handleBlockEnter/handleBlockBackspace; update references to
useBlockTiptap, onEnter, onBackspaceAtStart, editor, handleBlockEnter,
handleBlockBackspace, slashActiveRef, selectSlashBlockAtIndex, filteredRef,
slashIndexRef, store and clientId accordingly, or alternatively add a concise
inline comment explaining why the current pattern is safe and assert the editor
type to satisfy TypeScript.
resources/js/visual-editor/editor/components/EditorShell.tsx (1)

140-153: Clarify the comment about select elements.

The comment suggests <select> elements should keep native behavior, but they don't have native undo/redo—so the current return false is correct. Consider rewording to avoid confusion.

📝 Suggested comment clarification
-    // Non-contenteditable focusable fields (e.g. `<select>`) should also keep
-    // their native behavior. The Tiptap editor surfaces are contenteditable,
-    // so those still route through the store's undo/redo.
-    return false;
+    // Contenteditable elements (TipTap) and other focusable fields like
+    // <select> don't have native undo/redo, so they route through the
+    // store's history.
+    return false;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@resources/js/visual-editor/editor/components/EditorShell.tsx` around lines
140 - 153, The comment in shouldDeferToNativeHistory is misleading about
<select> elements implying they have native undo/redo; update the explanatory
comment around shouldDeferToNativeHistory (and the sentence mentioning
`<select>`) to state that non-contenteditable focusable controls (e.g.,
<select>) should preserve their native keyboard behavior even though they do not
provide native undo/redo, and clarify that only contenteditable Tiptap surfaces
route undo/redo through the store; leave the logic (return false for other
elements) unchanged.
resources/js/visual-editor/editor/inserter/__tests__/inserterRegistry.test.ts (1)

181-190: Consider strengthening the assertion for "ignores non-ok responses".

The test verifies fetchImpl was called but doesn't assert the expected outcome (built-ins remain registered, no API blocks added). Adding assertions similar to the "falls back to built-ins" test would make this more robust.

💡 Suggested improvement
     it('ignores non-ok responses', async () => {
         const fetchImpl = vi.fn().mockResolvedValue({
             ok: false,
             json: async () => ({ blocks: [] }),
         }) as unknown as typeof fetch;
 
         await loadInserterBlocks({ apiBase: 'https://example.test/api', fetchImpl });
 
         expect(fetchImpl).toHaveBeenCalledOnce();
+        const names = getInserterBlocks().map((block) => block.name);
+        expect(names).toContain('ve/paragraph');
+        expect(names).toContain('ve/heading');
     });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@resources/js/visual-editor/editor/inserter/__tests__/inserterRegistry.test.ts`
around lines 181 - 190, The test currently only asserts fetchImpl was called;
update it to also assert that no API blocks were added and built-in inserters
remain registered after calling loadInserterBlocks with a non-ok response. After
invoking loadInserterBlocks({ apiBase: ..., fetchImpl }), query the inserter
registry (use the inserterRegistry/getAll/getRegisteredInserters helper used
elsewhere in tests) to assert built-in inserters are still present and that no
new blocks from the API exist, mirroring the assertions used in the "falls back
to built-ins" test.
resources/js/visual-editor/editor/rest/postRestClient.ts (1)

99-111: Harden blocks validation before returning PostPayload.

isPostPayload() only checks that blocks is an array. A malformed block entry still passes here and won't fail until store or render code touches it, which makes bad API data look like a client-side regression. Consider validating the Block shape recursively at this boundary too.

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

In `@resources/js/visual-editor/editor/rest/postRestClient.ts` around lines 99 -
111, The type guard isPostPayload currently only checks blocks is an array; add
a recursive validation that each element matches the Block shape before
returning PostPayload. Implement (or use) an isBlock(value: unknown): value is
Block helper that verifies required Block properties and types (and nested
fields if any), then change the blocks check in isPostPayload to
Array.isArray(source.blocks) && source.blocks.every(isBlock). Keep the rest of
the checks (id, title, updated_at) unchanged and ensure the new isBlock helper
is exported/defined near isPostPayload so the guard reliably rejects malformed
API data early.
resources/js/visual-editor/editor/primitives/useInnerBlocksProps.tsx (1)

152-170: Duplicate of findBlock from treeUtils.

findBlockInState duplicates the findBlock function already exported from ../store/treeUtils. Consider importing and reusing it to reduce code duplication.

♻️ Reuse existing findBlock
 import type { Block, EditorStore } from '../store';
-import { useChildren } from '../store';
+import { useChildren, findBlock } from '../store';
 import { getBlock as getBlockDefinition, type BlockDefinition } from '../registry';
 import { BlockContextProvider } from './BlockContext';
 import { useEditorStore } from './EditorStoreContext';

 // ... rest of file ...

 function useLiveBlock(store: EditorStore, clientId: string): Block | undefined {
-    return useStore(store, (state) => findBlockInState(state.blocks, clientId));
+    return useStore(store, (state) => findBlock(state.blocks, clientId));
 }
-
-function findBlockInState(blocks: Block[], clientId: string): Block | undefined {
-    for (const block of blocks) {
-        if (block.clientId === clientId) {
-            return block;
-        }
-
-        const found = findBlockInState(block.innerBlocks, clientId);
-
-        if (found !== undefined) {
-            return found;
-        }
-    }
-
-    return undefined;
-}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@resources/js/visual-editor/editor/primitives/useInnerBlocksProps.tsx` around
lines 152 - 170, The function findBlockInState duplicates existing logic; remove
findBlockInState and import the shared findBlock from ../store/treeUtils, then
update useLiveBlock to call findBlock(store.state.blocks, clientId) (or pass the
same parameters as the imported findBlock) so useLiveBlock uses the centralized
findBlock implementation; ensure any type signatures match and update the import
list accordingly.
resources/js/visual-editor/editor/store/editorStore.ts (2)

160-196: No-op check could short-circuit earlier.

The targetIndex === currentIndex check at line 190 happens after array mutations. Moving it earlier avoids unnecessary work, though the current implementation is functionally correct.

♻️ Optional: Early no-op detection
             moveBlock: (clientId: string, location: MoveLocation) => {
                 applyContentChange((state) => {
                     const parentClientId = location.parentClientId ?? null;
 
                     if (parentClientId !== null) {
                         return null;
                     }
 
                     const currentIndex = state.blocks.findIndex(
                         (block) => block.clientId === clientId
                     );
 
                     if (currentIndex === -1) {
                         return null;
                     }
 
+                    // Compute target index before mutation to enable early exit
+                    const maxIndex = state.blocks.length - 1;
+                    const targetIndex =
+                        location.index < 0
+                            ? 0
+                            : location.index > maxIndex
+                                ? maxIndex
+                                : location.index;
+
+                    if (targetIndex === currentIndex) {
+                        return null;
+                    }
+
                     const block = state.blocks[currentIndex];
                     const withoutBlock = state.blocks.slice();
 
                     withoutBlock.splice(currentIndex, 1);
-
-                    const targetIndex =
-                        location.index < 0
-                            ? 0
-                            : location.index > withoutBlock.length
-                                ? withoutBlock.length
-                                : location.index;
-
                     withoutBlock.splice(targetIndex, 0, block);
 
-                    if (targetIndex === currentIndex) {
-                        return null;
-                    }
-
                     return { blocks: withoutBlock };
                 }, null);
             },
🤖 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 160 -
196, The moveBlock handler performs array splices before checking if the move is
a no-op; inside moveBlock (within applyContentChange) compute currentIndex and
targetIndex first and if targetIndex === currentIndex return null immediately to
avoid creating withoutBlock and doing splice operations; update the logic in
moveBlock so the early no-op check happens before creating withoutBlock or
mutating the blocks array, keeping the rest of the behavior (returning { blocks:
withoutBlock } and using parentClientId handling) unchanged.

62-66: Consider using slice for history bounds trimming.

The while loop with shift() works but is less idiomatic. Since HISTORY_LIMIT is constant, you can compute the slice directly.

♻️ Optional: Use slice for trimming
-    const nextPast = history.past.concat([previousSnapshot]);
-
-    while (nextPast.length > HISTORY_LIMIT) {
-        nextPast.shift();
-    }
+    const combined = history.past.concat([previousSnapshot]);
+    const nextPast = combined.length > HISTORY_LIMIT
+        ? combined.slice(combined.length - HISTORY_LIMIT)
+        : combined;
🤖 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 62 - 66,
The current history trimming builds nextPast via
history.past.concat([previousSnapshot]) and then removes old entries with a
while loop and shift(); replace that with a direct slice to keep only the most
recent HISTORY_LIMIT entries: compute nextPast as
history.past.concat([previousSnapshot]) and then set nextPast =
nextPast.slice(-HISTORY_LIMIT) (or equivalent) so you avoid the loop and shifts;
update references to nextPast, history.past, previousSnapshot and HISTORY_LIMIT
accordingly.
resources/js/visual-editor/editor/rest/useAutosave.ts (1)

204-217: Subscription conditions could be clearer.

The two conditions (lines 209-211 and 214-216) handle the dirty-transition case and the edge case where the store is dirty but no save is pending. Consider consolidating with a comment explaining the intent.

♻️ Optional: Clarify subscription logic
         const unsubscribe = store.subscribe((next, previous) => {
             if (disposed) {
                 return;
             }
 
-            if (next.isDirty && !inFlight && !previous.isDirty) {
-                schedule(configRef.current.debounceMs);
-                return;
-            }
-
-            if (next.isDirty && !inFlight && timerId === null) {
+            // Schedule a save when the store becomes dirty and no save is
+            // already pending (either in-flight or debounce-waiting).
+            const shouldSchedule = next.isDirty && !inFlight && timerId === null;
+            const justBecameDirty = next.isDirty && !previous.isDirty;
+
+            if (shouldSchedule || (justBecameDirty && !inFlight)) {
                 schedule(configRef.current.debounceMs);
             }
         });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@resources/js/visual-editor/editor/rest/useAutosave.ts` around lines 204 -
217, The subscription has two overlapping conditions in store.subscribe that
both call schedule and are hard to read; consolidate them into a single clear
condition and add a brief comment explaining intent: replace the two separate if
blocks that check (next.isDirty && !inFlight && !previous.isDirty) and
(next.isDirty && !inFlight && timerId === null) with one condition that
schedules when next.isDirty && !inFlight && (previous.isDirty === false ||
timerId === null), keep the disposed early-return, and attach a comment above
the combined condition explaining that we schedule a save when the store becomes
dirty or when it remains dirty but no debounce timer is active.
resources/js/visual-editor/editor/store/treeUtils.ts (1)

98-128: Consider avoiding intermediate array allocation.

The filter().map() chain creates an intermediate array. For large block trees, this could be optimized to a single pass using reduce or a manual loop. However, given typical editor usage patterns, this is unlikely to be a bottleneck.

♻️ Optional: Single-pass implementation
 export function removeFromTree(blocks: Block[], clientId: string): Block[] {
     let changed = false;
+    const next: Block[] = [];
 
-    const next = blocks
-        .filter((block) => {
-            if (block.clientId === clientId) {
-                changed = true;
-
-                return false;
-            }
-
-            return true;
-        })
-        .map((block) => {
-            if (block.innerBlocks.length === 0) {
-                return block;
-            }
-
-            const nextInner = removeFromTree(block.innerBlocks, clientId);
-
-            if (nextInner === block.innerBlocks) {
-                return block;
-            }
-
-            changed = true;
-
-            return { ...block, innerBlocks: nextInner };
-        });
+    for (const block of blocks) {
+        if (block.clientId === clientId) {
+            changed = true;
+            continue;
+        }
+
+        if (block.innerBlocks.length === 0) {
+            next.push(block);
+            continue;
+        }
+
+        const nextInner = removeFromTree(block.innerBlocks, clientId);
+
+        if (nextInner === block.innerBlocks) {
+            next.push(block);
+        } else {
+            changed = true;
+            next.push({ ...block, innerBlocks: nextInner });
+        }
+    }
 
     return changed ? next : blocks;
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@resources/js/visual-editor/editor/store/treeUtils.ts` around lines 98 - 128,
The current removeFromTree function creates an intermediate array via
filter().map(); replace that two-pass approach with a single-pass loop or
Array.prototype.reduce to avoid extra allocation: iterate over each block in
blocks, if block.clientId === clientId skip pushing it and mark changed,
otherwise recursively call removeFromTree on block.innerBlocks, if the returned
nextInner differs from block.innerBlocks create a shallow-copied block with
innerBlocks: nextInner (mark changed) and push it to the result array, else push
the original block; at the end return changed ? resultArray : blocks. Keep the
existing function name removeFromTree, parameters (blocks, clientId) and the
changed flag semantics.
resources/js/visual-editor/editor/blocks/shared/blockSplitMerge.ts (1)

121-135: HTML parsing via regex is fragile for edge cases.

The extractInnerHtml and wrapInSameTag functions use regex to parse HTML, which may behave unexpectedly for attributes containing >, nested tags at the boundary, or self-closing elements. The fallback behavior (returning original/inner content) is safe, but consider using a DOM parser for robustness if edge cases arise in practice.

♻️ Optional: DOM-based parsing for robustness
function extractInnerHtml(outerHtml: string): string {
    const template = document.createElement('template');
    template.innerHTML = outerHtml.trim();
    const firstChild = template.content.firstElementChild;
    return firstChild ? firstChild.innerHTML : outerHtml;
}

function wrapInSameTag(templateHtml: string, innerHtml: string): string {
    const template = document.createElement('template');
    template.innerHTML = templateHtml.trim();
    const firstChild = template.content.firstElementChild;
    if (!firstChild) {
        return innerHtml;
    }
    firstChild.innerHTML = innerHtml;
    return firstChild.outerHTML;
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@resources/js/visual-editor/editor/blocks/shared/blockSplitMerge.ts` around
lines 121 - 135, The current extractInnerHtml and wrapInSameTag use fragile
regex-based HTML parsing; replace their implementations to use a DOM template
parser instead: in extractInnerHtml, create a template element, set
template.innerHTML = outerHtml.trim(), get template.content.firstElementChild
and return its .innerHTML or the original outerHtml as fallback; in
wrapInSameTag, parse templateHtml into a template, get firstElementChild, if
missing return innerHtml, otherwise set firstChild.innerHTML = innerHtml and
return firstChild.outerHTML; update the functions named extractInnerHtml and
wrapInSameTag accordingly to use this DOM-based approach and keep the existing
fallback behavior.
🤖 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/blocks/heading/edit.tsx`:
- Around line 86-98: The effect that syncs attributes.level currently calls
editor.chain().focus().setNode('heading', { level }).run() and thus steals DOM
focus; update it to only run when this block is the active selection by gating
the effect with the existing isSelected guard (use the same pattern as the
earlier effect that reads isSelected) or replace the focusful call with a
non-focusing TipTap command (e.g., editor.commands.setNode or
editor.chain().setNode('heading', { level }).run() without .focus()) so the code
path inside the useEffect (which inspects editor.state.doc.firstChild and
compares first.attrs.level to level) only updates the node when isSelected is
true or when using a non-focusing API.

In `@resources/js/visual-editor/editor/blocks/shared/useBlockTiptap.ts`:
- Around line 74-89: The keydown handler handleKeyDown should ignore IME
composition and modifier-key shortcuts: add an early return false if
event.isComposing or any modifier keys are pressed (event.ctrlKey ||
event.metaKey || event.altKey) before checking Enter/Backspace, so
onEnterRef.current() and onBackspaceRef.current() are not invoked during
composition or when Ctrl/Cmd/Alt are held; keep the existing Enter (and
shiftKey) and Backspace logic unchanged otherwise.

In `@resources/js/visual-editor/editor/components/Canvas.tsx`:
- Line 14: The aria-label value "Editor canvas" is hardcoded; update the Canvas
component to use a localized string instead—inject a translation prop or use the
existing i18n utility/context and replace the literal aria-label with the
localized value (e.g., using a prop like canvasLabel or a translation function
such as t('editor.canvas')). Ensure the Canvas component (or its parent)
accepts/provides the localized label and that the aria-label attribute uses that
variable rather than the hardcoded English string.

In `@resources/js/visual-editor/editor/components/editor.css`:
- Around line 111-113: The .ve-richtext rule removes the focus outline and
leaves keyboard users without a visible cue; restore an accessible focus
indicator by removing outline: none and adding a focus state such as
.ve-richtext:focus, .ve-richtext:focus-visible { outline: 2px solid Highlight;
outline-offset: 2px; } (or outline: auto) so the editable surface shows a clear
focus ring when focused. Ensure you edit the .ve-richtext selector (and add the
:focus/:focus-visible rules) in the same stylesheet so the caret/keyboard focus
is always visually indicated.

In `@resources/js/visual-editor/editor/inserter/insertBlockActions.ts`:
- Around line 21-24: The object spread currently allows factory() to override
the generated clientId (newBlock built with clientId: createClientId(),
...factory()), so change the merge order to spread factory() first and then set
clientId: createClientId() (i.e., {...factory(), clientId: createClientId()}) so
the generated id cannot be overridden; apply the same ordering fix to the other
block-construction site that mirrors the constructor logic referenced in the
file (the second factory/constructor that currently spreads factory() after
clientId).

In `@resources/js/visual-editor/editor/main.tsx`:
- Line 62: The call to loadInserterBlocks({ apiBase: config.apiBase }) is
currently invoked with void which discards its Promise and allows unhandled
rejections; update the caller in main.tsx to handle rejections by either
awaiting the call inside an async init function with try/catch or attaching a
.catch(...) to the returned promise and log or surface the error (use
processLogger/console.error or the existing logging utility) so failures
fetching from the REST endpoint are handled gracefully and include context
(e.g., "loadInserterBlocks failed" and the error).

In `@resources/js/visual-editor/editor/primitives/BlockContext.tsx`:
- Around line 54-57: The cache currently only compares lastRawRef.current to
raw, so changing the guard function can leave lastResultRef.current stale; add a
lastGuardRef (useRef) and include a comparison of lastGuardRef.current to guard
in the cache invalidation check, updating lastGuardRef.current whenever you
update lastRawRef/current and re-evaluating lastResultRef.current by calling
guard(raw) so the cached result reflects both raw and the current guard
function.

In `@resources/js/visual-editor/editor/rest/postRestClient.ts`:
- Around line 145-152: fetchPost and savePost currently call fetcher() directly
so network failures throw raw errors; wrap the fetcher(...) invocation in each
of these functions (references: fetchPost, savePost, fetcher,
handlePostResponse) in a try-catch, rethrow if the caught error is an AbortError
(preserve cancellation via options.signal), otherwise throw/return a normalized
PostRestError that includes the original error details so callers only receive
PostRestError for pre-response failures; ensure existing response handling still
calls handlePostResponse(response) after a successful fetch.

In `@src/Models/VisualEditorPost.php`:
- Line 26: Replace the open mass-assignment on the VisualEditorPost model by
removing protected $guarded = [] and declaring a protected $fillable array that
lists only the allowed attributes (e.g., title, body/content, slug,
published_at, any editor-specific fields) — explicitly exclude author_id so it
cannot be mass-assigned; update any tests or factory usage that passed author_id
via create() to set the post author server-side (use relationship assignment or
a factory state) instead of mass assignment.

In `@tests/Feature/VisualEditor/PostsControllerTest.php`:
- Around line 23-34: The helper actingAsUser() always creates a TestUser with
the same email causing unique constraint failures; update the TestUser::create
call in actingAsUser to generate a unique email on each invocation (e.g., append
a timestamp/uniqid/random string or use a factory that ensures uniqueness) and
keep the test()->actingAs($user) behavior unchanged so each test receives a
distinct user email.

In `@vite.config.ts`:
- Around line 27-33: The build config is externalizing React packages in the
external array (entries: 'react', 'react/jsx-runtime', 'react/jsx-dev-runtime',
'react-dom', 'react-dom/client'), which produces unresolved bare imports at
runtime; remove those entries from the external list in vite.config.ts so the
bundler includes React in the app bundle, or alternatively implement a runtime
import resolution (e.g., provide an import map or module resolver) if you
intentionally want them externalized—update the external array or add the
import-map/module-resolution logic accordingly.

---

Nitpick comments:
In `@database/migrations/2026_04_14_000000_create_ve_contents_table.php`:
- Line 29: Replace the nullable unsignedBigInteger column definition for
author_id in the CreateVeContentsTable migration with a nullable foreign key
that references users and uses nullOnDelete; specifically update the schema
change around the author_id definition in the CreateVeContentsTable migration
(the ve_contents table setup) to use a foreignId constrained to 'users' with
nullOnDelete so deleted users set author_id to null and prevent orphaned
ownership records.

In `@resources/js/visual-editor/editor/blocks/paragraph/edit.tsx`:
- Around line 111-138: The callbacks passed into useBlockTiptap (onEnter,
onBackspaceAtStart) reference the editor variable returned from useBlockTiptap
which can look like a forward reference to TypeScript; either capture the editor
in a stable ref and use that ref.current inside onEnter/onBackspaceAtStart, or
rewrite the callbacks to receive the editor instance (or a guaranteed non-null
typed editor) before calling handleBlockEnter/handleBlockBackspace; update
references to useBlockTiptap, onEnter, onBackspaceAtStart, editor,
handleBlockEnter, handleBlockBackspace, slashActiveRef, selectSlashBlockAtIndex,
filteredRef, slashIndexRef, store and clientId accordingly, or alternatively add
a concise inline comment explaining why the current pattern is safe and assert
the editor type to satisfy TypeScript.

In `@resources/js/visual-editor/editor/blocks/shared/blockSplitMerge.ts`:
- Around line 121-135: The current extractInnerHtml and wrapInSameTag use
fragile regex-based HTML parsing; replace their implementations to use a DOM
template parser instead: in extractInnerHtml, create a template element, set
template.innerHTML = outerHtml.trim(), get template.content.firstElementChild
and return its .innerHTML or the original outerHtml as fallback; in
wrapInSameTag, parse templateHtml into a template, get firstElementChild, if
missing return innerHtml, otherwise set firstChild.innerHTML = innerHtml and
return firstChild.outerHTML; update the functions named extractInnerHtml and
wrapInSameTag accordingly to use this DOM-based approach and keep the existing
fallback behavior.

In `@resources/js/visual-editor/editor/components/EditorShell.tsx`:
- Around line 140-153: The comment in shouldDeferToNativeHistory is misleading
about <select> elements implying they have native undo/redo; update the
explanatory comment around shouldDeferToNativeHistory (and the sentence
mentioning `<select>`) to state that non-contenteditable focusable controls
(e.g., <select>) should preserve their native keyboard behavior even though they
do not provide native undo/redo, and clarify that only contenteditable Tiptap
surfaces route undo/redo through the store; leave the logic (return false for
other elements) unchanged.

In `@resources/js/visual-editor/editor/components/Icon.tsx`:
- Around line 11-19: The component currently treats whitespace-only strings as
valid titles; update the title handling by trimming before checking length:
change hasTitle to use title.trim() (e.g., hasTitle = typeof title === 'string'
&& title.trim().length > 0), pass the trimmed title to FontAwesomeIcon when
hasTitle is true (title={hasTitle ? title.trim() : undefined}), and keep role
and aria-hidden logic based on this new hasTitle so whitespace-only titles are
treated as missing.

In `@resources/js/visual-editor/editor/components/SlashCommandPopover.tsx`:
- Around line 16-22: The popover's markup in SlashCommandPopover lacks ARIA
listbox semantics; update the rendered list container (the <ul> in the
component) to include role="listbox" and each list item (rendered options in the
component's map) to include role="option" and an appropriate aria-selected
attribute reflecting selection state (true/false); ensure you update the render
logic in the SlashCommandPopover component where the option elements are created
so screen readers can navigate the list properly.

In
`@resources/js/visual-editor/editor/inserter/__tests__/inserterRegistry.test.ts`:
- Around line 181-190: The test currently only asserts fetchImpl was called;
update it to also assert that no API blocks were added and built-in inserters
remain registered after calling loadInserterBlocks with a non-ok response. After
invoking loadInserterBlocks({ apiBase: ..., fetchImpl }), query the inserter
registry (use the inserterRegistry/getAll/getRegisteredInserters helper used
elsewhere in tests) to assert built-in inserters are still present and that no
new blocks from the API exist, mirroring the assertions used in the "falls back
to built-ins" test.

In `@resources/js/visual-editor/editor/primitives/useInnerBlocksProps.tsx`:
- Around line 152-170: The function findBlockInState duplicates existing logic;
remove findBlockInState and import the shared findBlock from ../store/treeUtils,
then update useLiveBlock to call findBlock(store.state.blocks, clientId) (or
pass the same parameters as the imported findBlock) so useLiveBlock uses the
centralized findBlock implementation; ensure any type signatures match and
update the import list accordingly.

In `@resources/js/visual-editor/editor/rest/__tests__/postRestClient.test.ts`:
- Around line 93-132: The savePost tests are missing a CSRF-header assertion;
update the 'PUTs the block tree and returns the persisted payload' spec for
savePost to also assert the fetch call includes the same CSRF header checked in
fetchPost (e.g. ensure (init.headers as Headers).get('X-WP-Nonce') (or the
project’s CSRF header name) is present and equals the expected test nonce value
provided by the test setup), locating the call via the savePost test's
fetchImpl.mock.calls and adding the header assertion alongside the existing
method/content-type/body checks.

In `@resources/js/visual-editor/editor/rest/__tests__/useAutosave.test.tsx`:
- Around line 51-53: The test currently calls onState(state) during render
(inside the render path where onState is checked), which makes the harness
impure; change this by removing the direct call from render and invoking onState
from a React effect instead: create a useEffect (or the test harness equivalent)
that runs when state (and onState) change, and inside it guard if (onState)
onState(state); include onState and state in the effect dependency array so
notifications occur on updates and avoid invoking callbacks during render.

In `@resources/js/visual-editor/editor/rest/postRestClient.ts`:
- Around line 99-111: The type guard isPostPayload currently only checks blocks
is an array; add a recursive validation that each element matches the Block
shape before returning PostPayload. Implement (or use) an isBlock(value:
unknown): value is Block helper that verifies required Block properties and
types (and nested fields if any), then change the blocks check in isPostPayload
to Array.isArray(source.blocks) && source.blocks.every(isBlock). Keep the rest
of the checks (id, title, updated_at) unchanged and ensure the new isBlock
helper is exported/defined near isPostPayload so the guard reliably rejects
malformed API data early.

In `@resources/js/visual-editor/editor/rest/useAutosave.ts`:
- Around line 204-217: The subscription has two overlapping conditions in
store.subscribe that both call schedule and are hard to read; consolidate them
into a single clear condition and add a brief comment explaining intent: replace
the two separate if blocks that check (next.isDirty && !inFlight &&
!previous.isDirty) and (next.isDirty && !inFlight && timerId === null) with one
condition that schedules when next.isDirty && !inFlight && (previous.isDirty ===
false || timerId === null), keep the disposed early-return, and attach a comment
above the combined condition explaining that we schedule a save when the store
becomes dirty or when it remains dirty but no debounce timer is active.

In `@resources/js/visual-editor/editor/store/editorStore.ts`:
- Around line 160-196: The moveBlock handler performs array splices before
checking if the move is a no-op; inside moveBlock (within applyContentChange)
compute currentIndex and targetIndex first and if targetIndex === currentIndex
return null immediately to avoid creating withoutBlock and doing splice
operations; update the logic in moveBlock so the early no-op check happens
before creating withoutBlock or mutating the blocks array, keeping the rest of
the behavior (returning { blocks: withoutBlock } and using parentClientId
handling) unchanged.
- Around line 62-66: The current history trimming builds nextPast via
history.past.concat([previousSnapshot]) and then removes old entries with a
while loop and shift(); replace that with a direct slice to keep only the most
recent HISTORY_LIMIT entries: compute nextPast as
history.past.concat([previousSnapshot]) and then set nextPast =
nextPast.slice(-HISTORY_LIMIT) (or equivalent) so you avoid the loop and shifts;
update references to nextPast, history.past, previousSnapshot and HISTORY_LIMIT
accordingly.

In `@resources/js/visual-editor/editor/store/treeUtils.ts`:
- Around line 98-128: The current removeFromTree function creates an
intermediate array via filter().map(); replace that two-pass approach with a
single-pass loop or Array.prototype.reduce to avoid extra allocation: iterate
over each block in blocks, if block.clientId === clientId skip pushing it and
mark changed, otherwise recursively call removeFromTree on block.innerBlocks, if
the returned nextInner differs from block.innerBlocks create a shallow-copied
block with innerBlocks: nextInner (mark changed) and push it to the result
array, else push the original block; at the end return changed ? resultArray :
blocks. Keep the existing function name removeFromTree, parameters (blocks,
clientId) and the changed flag semantics.

In `@routes/web.php`:
- Around line 7-13: The route closure for Route::get('/editor',
...)->name('visual-editor.editor') currently passes hardcoded view params
'postId' => '1' and 'postType' => 'page'; change it to accept dynamic inputs
(e.g., route parameters like {post} and {type} or read from query string) and
forward those values into the view 'visual-editor::editor.mount' instead of the
hardcoded literals, validating/casting the inputs as needed (or mark the route
clearly as a temporary dev-only entry in comments/config if you intend to keep
hardcoded defaults). Ensure you update any callers/links to use the new
parameters and keep the route name 'visual-editor.editor' unchanged.

In `@src/Policies/VisualEditorPostPolicy.php`:
- Around line 84-91: The method VisualEditorPostPolicy::isOwnerOrAdmin only
checks ownership, so rename it to isOwner (or implement real admin-checking if
admin behavior is desired); update all call sites that invoke isOwnerOrAdmin
(e.g., the two places that currently call it in this policy) to call isOwner
instead, and update any PHPDoc/type hints and tests referencing isOwnerOrAdmin
to use the new name so behavior and naming stay consistent.

In `@tests/Feature/EditorMountViewTest.php`:
- Around line 14-15: The test reads view files using file_get_contents into
$contents but doesn't assert the read succeeded, so failures are unclear when
file_get_contents returns false; update EditorMountViewTest to assert the read
before further assertions (e.g. use $this->assertNotFalse($contents) or
$this->assertIsString($contents) and optionally
$this->assertNotEmpty($contents)) immediately after each file_get_contents call
(references: variable $contents and the file_get_contents(...) calls present in
the test at the multiple spots noted) so subsequent assertions operate on a
valid string.
🪄 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: 45d8fe12-4033-43d0-b00f-0c0cb6a507a8

📥 Commits

Reviewing files that changed from the base of the PR and between edb9307 and 61926c5.

⛔ Files ignored due to path filters (2)
  • composer.lock is excluded by !**/*.lock, !composer.lock
  • package-lock.json is excluded by !**/package-lock.json, !package-lock.json
📒 Files selected for processing (122)
  • .gitignore
  • REACT-MIGRATION.md
  • composer.json
  • database/migrations/2026_04_14_000000_create_ve_contents_table.php
  • database/migrations/testing/2026_04_14_000000_create_users_table.php
  • docs/phase-1-dnd-kit-findings.md
  • docs/phase-1-rest-api.md
  • docs/phase-1-tiptap-strategy.md
  • index.html
  • package.json
  • resources/js/editor-spike/App.tsx
  • resources/js/editor-spike/__tests__/BlockContext.test.tsx
  • resources/js/editor-spike/__tests__/mocks.test.ts
  • resources/js/editor-spike/__tests__/paragraph.test.tsx
  • resources/js/editor-spike/__tests__/postTitle.test.tsx
  • resources/js/editor-spike/__tests__/queryLoop.test.tsx
  • resources/js/editor-spike/__tests__/smoke.test.tsx
  • resources/js/editor-spike/__tests__/useInnerBlocksProps.test.tsx
  • resources/js/editor-spike/blocks/paragraph/edit.tsx
  • resources/js/editor-spike/blocks/post-title/edit.tsx
  • resources/js/editor-spike/blocks/post-title/index.ts
  • resources/js/editor-spike/blocks/query-loop/edit.tsx
  • resources/js/editor-spike/blocks/query-loop/index.ts
  • resources/js/editor-spike/index.css
  • resources/js/editor-spike/main.tsx
  • resources/js/editor-spike/mocks/blockTree.ts
  • resources/js/editor-spike/mocks/mockApi.ts
  • resources/js/editor-spike/mocks/posts.ts
  • resources/js/editor-spike/primitives/BlockContext.tsx
  • resources/js/editor-spike/primitives/BlockTreeContext.tsx
  • resources/js/editor-spike/primitives/useInnerBlocksProps.tsx
  • resources/js/editor-spike/richtext/useTiptap.ts
  • resources/js/visual-editor/editor/blocks/__tests__/heading.test.tsx
  • resources/js/visual-editor/editor/blocks/__tests__/paragraph.test.tsx
  • resources/js/visual-editor/editor/blocks/__tests__/slashCommand.test.tsx
  • resources/js/visual-editor/editor/blocks/heading/edit.tsx
  • resources/js/visual-editor/editor/blocks/heading/index.ts
  • resources/js/visual-editor/editor/blocks/index.ts
  • resources/js/visual-editor/editor/blocks/paragraph/edit.tsx
  • resources/js/visual-editor/editor/blocks/paragraph/index.ts
  • resources/js/visual-editor/editor/blocks/shared/blockEditorRegistry.ts
  • resources/js/visual-editor/editor/blocks/shared/blockSplitMerge.ts
  • resources/js/visual-editor/editor/blocks/shared/index.ts
  • resources/js/visual-editor/editor/blocks/shared/splitContent.ts
  • resources/js/visual-editor/editor/blocks/shared/useBlockTiptap.ts
  • resources/js/visual-editor/editor/components/BlockList.tsx
  • resources/js/visual-editor/editor/components/BlockWrapper.tsx
  • resources/js/visual-editor/editor/components/Canvas.tsx
  • resources/js/visual-editor/editor/components/EditorBoot.tsx
  • resources/js/visual-editor/editor/components/EditorShell.tsx
  • resources/js/visual-editor/editor/components/Icon.tsx
  • resources/js/visual-editor/editor/components/InserterPanel.tsx
  • resources/js/visual-editor/editor/components/RichTextToolbar.tsx
  • resources/js/visual-editor/editor/components/SlashCommandPopover.tsx
  • resources/js/visual-editor/editor/components/__tests__/BlockList.test.tsx
  • resources/js/visual-editor/editor/components/__tests__/BlockListDnd.test.tsx
  • resources/js/visual-editor/editor/components/__tests__/BlockListDndDispatch.test.tsx
  • resources/js/visual-editor/editor/components/__tests__/BlockWrapper.test.tsx
  • resources/js/visual-editor/editor/components/__tests__/EditorBoot.test.tsx
  • resources/js/visual-editor/editor/components/__tests__/EditorShell.test.tsx
  • resources/js/visual-editor/editor/components/__tests__/InserterPanel.test.tsx
  • resources/js/visual-editor/editor/components/__tests__/RichTextToolbar.test.tsx
  • resources/js/visual-editor/editor/components/editor.css
  • resources/js/visual-editor/editor/components/index.ts
  • resources/js/visual-editor/editor/index.html
  • resources/js/visual-editor/editor/inserter/__tests__/insertBlockActions.test.ts
  • resources/js/visual-editor/editor/inserter/__tests__/inserterRegistry.test.ts
  • resources/js/visual-editor/editor/inserter/builtinInserterBlocks.ts
  • resources/js/visual-editor/editor/inserter/filterInserterBlocks.ts
  • resources/js/visual-editor/editor/inserter/index.ts
  • resources/js/visual-editor/editor/inserter/insertBlockActions.ts
  • resources/js/visual-editor/editor/inserter/inserterRegistry.ts
  • resources/js/visual-editor/editor/inserter/loadInserterBlocks.ts
  • resources/js/visual-editor/editor/main.tsx
  • resources/js/visual-editor/editor/primitives/BlockContext.tsx
  • resources/js/visual-editor/editor/primitives/EditorStoreContext.tsx
  • resources/js/visual-editor/editor/primitives/ReadOnlyContext.tsx
  • resources/js/visual-editor/editor/primitives/__tests__/BlockContext.test.tsx
  • resources/js/visual-editor/editor/primitives/__tests__/EditorStoreContext.test.tsx
  • resources/js/visual-editor/editor/primitives/__tests__/useBlockPreview.test.tsx
  • resources/js/visual-editor/editor/primitives/__tests__/useInnerBlocksProps.test.tsx
  • resources/js/visual-editor/editor/primitives/index.ts
  • resources/js/visual-editor/editor/primitives/useBlockPreview.tsx
  • resources/js/visual-editor/editor/primitives/useInnerBlocksProps.tsx
  • resources/js/visual-editor/editor/registry/blockRegistry.ts
  • resources/js/visual-editor/editor/registry/index.ts
  • resources/js/visual-editor/editor/rest/__tests__/postRestClient.test.ts
  • resources/js/visual-editor/editor/rest/__tests__/useAutosave.test.tsx
  • resources/js/visual-editor/editor/rest/index.ts
  • resources/js/visual-editor/editor/rest/postRestClient.ts
  • resources/js/visual-editor/editor/rest/useAutosave.ts
  • resources/js/visual-editor/editor/store/__tests__/editorStore.test.ts
  • resources/js/visual-editor/editor/store/__tests__/editorStoreHistory.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/ids.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
  • resources/js/visual-editor/editor/test-setup.ts
  • resources/views/editor-spike.blade.php
  • resources/views/editor/mount.blade.php
  • routes/api.php
  • routes/web.php
  • src/Http/Controllers/VisualEditorBlocksController.php
  • src/Http/Controllers/VisualEditorPostsController.php
  • src/Http/Requests/UpdatePostBlocksRequest.php
  • src/Models/VisualEditorPost.php
  • src/Policies/VisualEditorPostPolicy.php
  • src/Registries/BlockTypeRegistry.php
  • src/Rules/BlockTreeRule.php
  • src/VisualEditorServiceProvider.php
  • tests/Feature/EditorMountViewTest.php
  • tests/Feature/VisualEditor/BlocksControllerTest.php
  • tests/Feature/VisualEditor/PostsControllerTest.php
  • tests/Pest.php
  • tests/TestCase.php
  • tests/TestUser.php
  • tests/Unit/VisualEditor/BlockTypeRegistryTest.php
  • tsconfig.json
  • vite.config.ts
  • vitest.config.ts
💤 Files with no reviewable changes (24)
  • resources/js/editor-spike/tests/smoke.test.tsx
  • resources/js/editor-spike/index.css
  • resources/js/editor-spike/mocks/blockTree.ts
  • resources/views/editor-spike.blade.php
  • resources/js/editor-spike/main.tsx
  • resources/js/editor-spike/tests/mocks.test.ts
  • index.html
  • resources/js/editor-spike/blocks/post-title/edit.tsx
  • resources/js/editor-spike/tests/queryLoop.test.tsx
  • resources/js/editor-spike/blocks/paragraph/edit.tsx
  • resources/js/editor-spike/mocks/posts.ts
  • resources/js/editor-spike/tests/paragraph.test.tsx
  • resources/js/editor-spike/blocks/query-loop/edit.tsx
  • resources/js/editor-spike/primitives/useInnerBlocksProps.tsx
  • resources/js/editor-spike/blocks/query-loop/index.ts
  • resources/js/editor-spike/blocks/post-title/index.ts
  • resources/js/editor-spike/mocks/mockApi.ts
  • resources/js/editor-spike/primitives/BlockTreeContext.tsx
  • resources/js/editor-spike/richtext/useTiptap.ts
  • resources/js/editor-spike/tests/postTitle.test.tsx
  • resources/js/editor-spike/primitives/BlockContext.tsx
  • resources/js/editor-spike/tests/BlockContext.test.tsx
  • resources/js/editor-spike/tests/useInnerBlocksProps.test.tsx
  • resources/js/editor-spike/App.tsx

Comment thread resources/js/visual-editor/editor/blocks/heading/edit.tsx
Comment thread resources/js/visual-editor/editor/blocks/shared/useBlockTiptap.ts
Comment thread resources/js/visual-editor/editor/components/Canvas.tsx Outdated
Comment thread resources/js/visual-editor/editor/components/editor.css
Comment thread resources/js/visual-editor/editor/inserter/insertBlockActions.ts
Comment thread resources/js/visual-editor/editor/primitives/BlockContext.tsx Outdated
Comment thread resources/js/visual-editor/editor/rest/postRestClient.ts Outdated
Comment thread src/Models/VisualEditorPost.php Outdated
Comment thread tests/Feature/VisualEditor/PostsControllerTest.php
Comment thread vite.config.ts Outdated
ViewFromTheBox and others added 2 commits April 14, 2026 17:42
- heading/edit.tsx: drop .focus() from level-sync effect so changing
  a heading level no longer steals DOM focus from unrelated blocks
- useBlockTiptap: ignore IME composition and Ctrl/Meta/Alt modifier
  chords in the block-level keydown handler so Enter/Backspace only
  split/merge blocks on actual user intent
- Canvas: accept an optional label prop (default "Editor canvas")
  so the aria-label can be localized once an i18n pipeline lands
- editor.css: add .ve-richtext:focus-visible outline so keyboard
  users get a visible focus indicator on the contenteditable
- insertBlockActions: spread factory() before clientId so the
  generated id cannot be overridden by factory output (applies to
  both insertBlockAtSelection and replaceBlockWithInserterBlock)
- main.tsx: attach .catch() to loadInserterBlocks so REST failures
  are logged instead of becoming unhandled rejections
- BlockContext.useBlockContextValue: include guard identity in the
  cache invalidation check so a new guard re-evaluates the cached
  value even when raw is unchanged
- postRestClient: wrap fetcher() in fetchPost/savePost with a
  normalizer that rethrows AbortError but converts other network
  failures into PostRestError with the original error preserved
- VisualEditorPost: replace $guarded = [] with an explicit
  $fillable list ('title', 'blocks') so author_id cannot be
  mass-assigned from request input
- PostsControllerTest: set author_id via direct property
  assignment in the createPost helper now that it is non-fillable,
  and unique-ify the actingAsUser email via uniqid() to guard
  against any future collisions
- vite.config.ts: drop the React externals so the standalone build
  produces a self-contained bundle (the host app's Vite continues
  to bundle main.tsx through @Vite for the dev-app flow)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

@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: 2

🧹 Nitpick comments (1)
resources/js/visual-editor/editor/primitives/BlockContext.tsx (1)

37-43: Clarify stability contract in the hook docs.

The doc says stability only depends on context value changes, but the implementation also invalidates when guard identity changes. Consider updating this comment so usage expectations are explicit.

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

In `@resources/js/visual-editor/editor/primitives/BlockContext.tsx` around lines
37 - 43, Update the JSDoc for the hook in BlockContext.tsx to state that the
returned reference is stable across renders until the underlying context value
changes or the runtime type guard function identity changes; mention that
changing the guard (the function passed to the hook) will cause the memoized
reference to be invalidated and consumers may re-run, and reference
BlockContextProvider and the guard parameter by name so callers know both inputs
affect stability.
🤖 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/main.tsx`:
- Around line 29-33: The placeholder fallback label in function PlaceholderEdit
is hardcoded; change it to use the WordPress i18n translate helper (e.g., __
from '@wordpress/i18n' or wp.i18n.__) so the default 'Placeholder block' goes
through localization; update the import to include the translate function and
replace the literal in the ternary (attributes.label fallback) with a call like
__('Placeholder block', 'your-text-domain') keeping the rest of PlaceholderEdit
unchanged.

In `@resources/js/visual-editor/editor/rest/postRestClient.ts`:
- Around line 3-8: The PostPayload interface currently types updated_at as
string which is incompatible with the API transformer that can emit null; update
the type of updated_at to allow null (e.g., string | null) and update any other
similar interfaces or occurrences (the other declaration referenced at lines
106-111) so client-side validation accepts the API's nullable updated_at values;
search for PostPayload and the other affected interfaces and change their
updated_at types accordingly.

---

Nitpick comments:
In `@resources/js/visual-editor/editor/primitives/BlockContext.tsx`:
- Around line 37-43: Update the JSDoc for the hook in BlockContext.tsx to state
that the returned reference is stable across renders until the underlying
context value changes or the runtime type guard function identity changes;
mention that changing the guard (the function passed to the hook) will cause the
memoized reference to be invalidated and consumers may re-run, and reference
BlockContextProvider and the guard parameter by name so callers know both inputs
affect stability.
🪄 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: 12fe0088-5507-4887-8b0b-65cf50108dc0

📥 Commits

Reviewing files that changed from the base of the PR and between 61926c5 and 40666ee.

📒 Files selected for processing (11)
  • resources/js/visual-editor/editor/blocks/heading/edit.tsx
  • resources/js/visual-editor/editor/blocks/shared/useBlockTiptap.ts
  • resources/js/visual-editor/editor/components/Canvas.tsx
  • resources/js/visual-editor/editor/components/editor.css
  • resources/js/visual-editor/editor/inserter/insertBlockActions.ts
  • resources/js/visual-editor/editor/main.tsx
  • resources/js/visual-editor/editor/primitives/BlockContext.tsx
  • resources/js/visual-editor/editor/rest/postRestClient.ts
  • src/Models/VisualEditorPost.php
  • tests/Feature/VisualEditor/PostsControllerTest.php
  • vite.config.ts
✅ Files skipped from review due to trivial changes (1)
  • resources/js/visual-editor/editor/components/editor.css
🚧 Files skipped from review as they are similar to previous changes (6)
  • resources/js/visual-editor/editor/components/Canvas.tsx
  • vite.config.ts
  • tests/Feature/VisualEditor/PostsControllerTest.php
  • resources/js/visual-editor/editor/blocks/heading/edit.tsx
  • resources/js/visual-editor/editor/inserter/insertBlockActions.ts
  • resources/js/visual-editor/editor/blocks/shared/useBlockTiptap.ts

Comment on lines +29 to +33
function PlaceholderEdit({ attributes }: BlockEditProps) {
const label =
typeof attributes.label === 'string' ? attributes.label : 'Placeholder block';

return <p className="ve-placeholder-block">{label}</p>;

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 | 🟡 Minor

Localize the placeholder fallback label.

'Placeholder block' is user-visible (Line 31) and currently hardcoded. This should go through the same localization path as other editor UI strings.

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

In `@resources/js/visual-editor/editor/main.tsx` around lines 29 - 33, The
placeholder fallback label in function PlaceholderEdit is hardcoded; change it
to use the WordPress i18n translate helper (e.g., __ from '@wordpress/i18n' or
wp.i18n.__) so the default 'Placeholder block' goes through localization; update
the import to include the translate function and replace the literal in the
ternary (attributes.label fallback) with a call like __('Placeholder block',
'your-text-domain') keeping the rest of PlaceholderEdit unchanged.

Comment thread resources/js/visual-editor/editor/rest/postRestClient.ts
VisualEditorPostsController emits $post->updated_at?->toIso8601String(),
which is string|null. The client-side PostPayload interface and
isPostPayload guard were narrower (string only), so a nullable
timestamp would fail validation at handlePostResponse and surface as
a "did not match expected post shape" error.

Widen the type to string|null and accept null in the guard.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

@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: 1

🤖 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/rest/postRestClient.ts`:
- Around line 99-111: The blocks array check in isPostPayload is too loose and
allows malformed block entries into state; update isPostPayload (and PostPayload
validation) to ensure source.blocks is an array and every element is a non-null
object with the specific properties and types your store expects (e.g., each
block has numeric id, string type, and the fields the editorStore uses for block
operations), e.g. replace the Array.isArray(source.blocks) check with a
source.blocks.every(...) predicate that asserts required keys and types so
invalid blocks are rejected before being written to editorStore 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: 7212eee0-3c20-4076-9654-f5b95c78c0f9

📥 Commits

Reviewing files that changed from the base of the PR and between 40666ee and b59d5ab.

📒 Files selected for processing (1)
  • resources/js/visual-editor/editor/rest/postRestClient.ts

Comment thread resources/js/visual-editor/editor/rest/postRestClient.ts
isPostPayload only checked Array.isArray(source.blocks), so a
malformed block (missing clientId/name, non-object attributes,
non-array innerBlocks) would pass the REST response guard and land
in the editor store, where store actions assume Block invariants.

Add isBlockShape as a recursive type guard that asserts each of
the four Block fields and walks innerBlocks, and call it from
isPostPayload via source.blocks.every(isBlockShape). Cover the
new path with a fetchPost test whose payload has a valid outer
block but an invalid nested innerBlock.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
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: Editor foundation — Vite, Zustand, Tiptap, REST, paragraphs and headings

1 participant