Skip to content

fix(admin): sync slash menu state ref synchronously to avoid stale reads#1004

Merged
ascorbic merged 2 commits into
mainfrom
fix/slash-menu-stale-ref
May 12, 2026
Merged

fix(admin): sync slash menu state ref synchronously to avoid stale reads#1004
ascorbic merged 2 commits into
mainfrom
fix/slash-menu-stale-ref

Conversation

@ascorbic
Copy link
Copy Markdown
Collaborator

What does this PR do?

Fixes a stale ref race in the slash command menu's keyboard handlers, restoring the tests/editor/slash-menu.test.tsx suite to deterministic green. This is the only race actually causing the recurring "Browser Tests" failures we have been seeing on main and on every PR that re-runs Browser Tests (e.g. #1000).

Root cause

slashMenuStateRef is read synchronously inside onKeyDown handlers passed to TipTap's Suggestion plugin. The previous code synced the ref via React.useEffect, which runs after commit -- so the first keyboard event after a state change saw stale state (empty items, null range, stale selectedIndex). On slower CI machines this fired reliably, producing five failing tests:

  • highlights the first item by default
  • moves selection down with ArrowDown
  • moves selection up with ArrowUp from second item
  • wraps selection around when pressing ArrowUp from first item
  • executes selected command on Enter and converts to heading

Fix

Wrap setSlashMenuState with a synchronous setter that writes slashMenuStateRef.current before scheduling the React update. The functional-updater branch writes the ref inside the updater so it stays consistent if React replays the updater (StrictMode), and the value branch writes the ref unconditionally before the setter call. getState() now always returns the most recent intent, which is the contract TipTap's Suggestion plugin relies on.

Test change

Wrap the "highlights first item" assertion in vi.waitFor({ timeout: 3000 }), matching the surrounding selection-state tests. The other arrow/Enter tests already used this pattern; this one was the odd one out.

Background

This is a focused re-application of #974 (closed by the author who believed the fix had already landed via translation work -- it had not; the useEffect sync was still on main, as you can verify in PortableTextEditor.tsx). Reviewer feedback from #974 has been applied:

  • Naming: renamed _setSlashMenuState to setSlashMenuStateRaw (per @ascorbic, clearer than underscore-prefix-as-unused).
  • Comments: documented the invariant (ref reflects intent, not committed state) and the StrictMode idempotency note (per @ask-bonk).
  • Test timeout: explicit { timeout: 3000 } (per @ask-bonk).
  • Scope: dropped the unrelated site-matrix-smoke.test.ts changes from the original PR.

Credit to @ahliweb for the original diagnosis and fix.

Closes #974

Type of change

  • Bug fix
  • Feature (requires maintainer-approved Discussion)
  • Refactor (no behavior change)
  • Translation
  • Documentation
  • Performance improvement
  • Tests
  • Chore (dependencies, CI, tooling)

Checklist

  • I have read CONTRIBUTING.md
  • pnpm typecheck passes
  • pnpm lint passes (no new diagnostics; baseline 41 == post-change 41)
  • pnpm test passes (3 consecutive clean runs of the slash-menu suite locally; 23/23)
  • pnpm format has been run
  • I have added/updated tests for my changes (if applicable)
  • User-visible strings in the admin UI are wrapped for translation (if applicable). Do not include messages.po changes except in translation PRs -- a workflow extracts catalogs on merge to main.
  • I have added a changeset (if this PR changes a published package)
  • New features link to an approved Discussion: https://github.com/emdash-cms/emdash/discussions/...

AI-generated code disclosure

  • This PR includes AI-generated code -- model/tool: Claude Opus 4.7 (opencode), building on the original fix by @ahliweb

Screenshots / test output

Local runs (3/3 clean):

```
=== Run 1 ===
Tests 23 passed (23)
=== Run 2 ===
Tests 23 passed (23)
=== Run 3 ===
Tests 23 passed (23)
```

TipTap's Suggestion plugin reads slashMenuStateRef.current synchronously
inside onKeyDown. The previous useEffect-based sync runs after commit,
so keyboard handlers saw stale state (empty items, null range, stale
selectedIndex) on the first event after a state change. That produced
five intermittent CI failures in tests/editor/slash-menu.test.tsx where
Enter would not execute the selected command and arrow navigation would
skip selections on slower runs.

Wrap setSlashMenuState with a synchronous setter that writes
slashMenuStateRef.current before scheduling the React update, so
getState() always returns the most recent intent.

Also wraps the 'highlights first item' test in vi.waitFor with an
explicit 3s timeout, matching the surrounding selection-state tests.

Original diagnosis and fix from #974 by @ahliweb; this is a focused
re-application with the scope-creep smoke-test commit dropped and the
reviewer feedback from #974 applied (clearer naming, invariant
documented, StrictMode note, explicit waitFor timeout).
Copilot AI review requested due to automatic review settings May 12, 2026 15:25
@cloudflare-workers-and-pages
Copy link
Copy Markdown

cloudflare-workers-and-pages Bot commented May 12, 2026

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
emdash-perf-coordinator 1d0030f May 12 2026, 03:37 PM

@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented May 12, 2026

🦋 Changeset detected

Latest commit: 1d0030f

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 13 packages
Name Type
@emdash-cms/admin Patch
emdash Patch
@emdash-cms/cloudflare Patch
@emdash-cms/fixture-perf-site Patch
@emdash-cms/perf-demo-site Patch
@emdash-cms/cache-demo-site Patch
@emdash-cms/auth Patch
@emdash-cms/blocks Patch
@emdash-cms/gutenberg-to-portable-text Patch
@emdash-cms/x402 Patch
create-emdash Patch
@emdash-cms/auth-atproto Patch
@emdash-cms/plugin-embeds Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@cloudflare-workers-and-pages
Copy link
Copy Markdown

cloudflare-workers-and-pages Bot commented May 12, 2026

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
emdash-i18n 1d0030f May 12 2026, 03:37 PM

@cloudflare-workers-and-pages
Copy link
Copy Markdown

cloudflare-workers-and-pages Bot commented May 12, 2026

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
docs 1d0030f May 12 2026, 03:38 PM

@cloudflare-workers-and-pages
Copy link
Copy Markdown

cloudflare-workers-and-pages Bot commented May 12, 2026

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
emdash-demo-cache 1d0030f May 12 2026, 03:39 PM

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented May 12, 2026

Open in StackBlitz

@emdash-cms/admin

npm i https://pkg.pr.new/@emdash-cms/admin@1004

@emdash-cms/auth

npm i https://pkg.pr.new/@emdash-cms/auth@1004

@emdash-cms/blocks

npm i https://pkg.pr.new/@emdash-cms/blocks@1004

@emdash-cms/cloudflare

npm i https://pkg.pr.new/@emdash-cms/cloudflare@1004

emdash

npm i https://pkg.pr.new/emdash@1004

create-emdash

npm i https://pkg.pr.new/create-emdash@1004

@emdash-cms/gutenberg-to-portable-text

npm i https://pkg.pr.new/@emdash-cms/gutenberg-to-portable-text@1004

@emdash-cms/x402

npm i https://pkg.pr.new/@emdash-cms/x402@1004

@emdash-cms/plugin-ai-moderation

npm i https://pkg.pr.new/@emdash-cms/plugin-ai-moderation@1004

@emdash-cms/plugin-atproto

npm i https://pkg.pr.new/@emdash-cms/plugin-atproto@1004

@emdash-cms/plugin-audit-log

npm i https://pkg.pr.new/@emdash-cms/plugin-audit-log@1004

@emdash-cms/plugin-color

npm i https://pkg.pr.new/@emdash-cms/plugin-color@1004

@emdash-cms/plugin-embeds

npm i https://pkg.pr.new/@emdash-cms/plugin-embeds@1004

@emdash-cms/plugin-forms

npm i https://pkg.pr.new/@emdash-cms/plugin-forms@1004

@emdash-cms/plugin-webhook-notifier

npm i https://pkg.pr.new/@emdash-cms/plugin-webhook-notifier@1004

commit: 1d0030f

@cloudflare-workers-and-pages
Copy link
Copy Markdown

cloudflare-workers-and-pages Bot commented May 12, 2026

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
emdash-playground 1d0030f May 12 2026, 03:41 PM

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR aims to eliminate a stale-ref race in the admin PortableTextEditor slash-command menu by synchronizing the slash menu state ref in a way that TipTap’s synchronous onKeyDown handlers can read reliably, and by making the flaky slash-menu browser tests deterministic.

Changes:

  • Replace the useEffect-based slashMenuStateRef synchronization with a wrapped setter intended to keep the ref current for synchronous keyboard reads.
  • Adjust the slash-menu test to wait for the DOM-selected state with an explicit vi.waitFor({ timeout: 3000 }).
  • Add a changeset documenting the fix and bumping release versions.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.

File Description
packages/admin/src/components/PortableTextEditor.tsx Introduces a wrapped setSlashMenuState intended to keep slashMenuStateRef in sync for TipTap Suggestion keyboard handling.
packages/admin/tests/editor/slash-menu.test.tsx Updates the “highlights the first item by default” test to use vi.waitFor to reduce timing flakiness.
.changeset/fix-slash-menu-stale-ref.md Adds release notes/bumps for the stale ref race fix.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread packages/admin/src/components/PortableTextEditor.tsx Outdated
…l updates

Address Copilot review on #1004: the previous implementation wrote
slashMenuStateRef.current inside the functional updater passed to
setSlashMenuStateRaw, which means the ref was only updated when React
later executed the updater -- not synchronously at the call site. A
TipTap onKeyDown handler firing synchronously after onStateChange could
still read stale items/range/selectedIndex via getState().

Compute next from slashMenuStateRef.current immediately, assign the ref,
then enqueue a value-based update. This makes the ref the canonical
'latest intent' for any synchronous reader between setter call and React
commit. As a side effect, the StrictMode double-invoke note no longer
applies (we no longer pass a function to setSlashMenuStateRaw).
@ascorbic ascorbic enabled auto-merge (squash) May 12, 2026 15:38
@ascorbic ascorbic merged commit 35791ff into main May 12, 2026
39 checks passed
@ascorbic ascorbic deleted the fix/slash-menu-stale-ref branch May 12, 2026 15:45
@emdashbot emdashbot Bot mentioned this pull request May 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants