fix(admin): preserve translation content when switching between locales#887
fix(admin): preserve translation content when switching between locales#887
Conversation
ContentEditor stays mounted during TanStack Router param-only navigation between translations, but PortableTextEditor freezes its initial content via useMemo([]) on mount, so the editor showed the previous locale's body after switching. Subsequent edits silently overwrote the new translation's formData on autosave. Fix: synchronously reset form state during render when item.id changes (React 'store info from previous renders' idiom), and key FieldRenderer by item.id so all field editors remount cleanly on the switch.
🦋 Changeset detectedLatest commit: fb7df2e The changes in this PR will be included in the next version bump. This PR includes changesets to release 13 packages
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 |
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
emdash-perf-coordinator | fb7df2e | May 01 2026, 02:21 PM |
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
emdash-i18n | fb7df2e | May 01 2026, 02:21 PM |
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
docs | fb7df2e | May 01 2026, 02:22 PM |
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
emdash-playground | fb7df2e | May 01 2026, 02:22 PM |
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
emdash-demo-cache | fb7df2e | May 01 2026, 02:23 PM |
@emdash-cms/admin
@emdash-cms/auth
@emdash-cms/blocks
@emdash-cms/cloudflare
emdash
create-emdash
@emdash-cms/gutenberg-to-portable-text
@emdash-cms/x402
@emdash-cms/plugin-ai-moderation
@emdash-cms/plugin-atproto
@emdash-cms/plugin-audit-log
@emdash-cms/plugin-color
@emdash-cms/plugin-embeds
@emdash-cms/plugin-forms
@emdash-cms/plugin-webhook-notifier
commit: |
|
/review |
|
My adversarial review summary: Trace of the fix:
Edge cases checked:
One minor concern: the post-render Verdict: The fix is correct, well-tested, the TDD approach is sound (test fails when either half of fix is reverted, per PR description), and the changeset is appropriate. I see no bugs. LGTM! |
There was a problem hiding this comment.
Pull request overview
Fixes an admin editor bug where switching between translations (different item.id on the same mounted route) left Portable Text content stale and could corrupt the newly selected translation’s formData on subsequent edits/autosave.
Changes:
- Reset editor/form state synchronously when
item.idchanges to avoid a one-render lag during translation switches. - Key field editors by
name:itemIdto force clean remounts (sidestepping PortableTextEditor’s intentional “freeze initial value on mount” behavior). - Add a regression test covering translation/item switching and include a changeset for
@emdash-cms/admin.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| packages/admin/src/components/ContentEditor.tsx | Synchronously resets state on item.id change and keys FieldRenderer to force remount on translation switch. |
| packages/admin/tests/components/ContentEditor.test.tsx | Adds a targeted regression test using a PortableTextEditor mock that freezes initial value to reproduce the bug. |
| .changeset/fix-translation-switch-stale-editor.md | Patch changeset documenting the stale Portable Text content fix on translation switch. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| bylines: nextBylines, | ||
| }), | ||
| ); | ||
| pendingAutosaveStateRef.current = null; |
| // Synchronously reset form state when the underlying item changes (e.g. a | ||
| // translation switch where TanStack Router keeps ContentEditor mounted but | ||
| // swaps `item` for a different id). The post-render useEffect below also | ||
| // syncs item -> formData, but it runs *after* the first render with the new | ||
| // item, leaving children (notably PortableTextEditor, which freezes its |
Without this cleanup, ContentEditor's portableTextEditor state slot keeps pointing at a destroyed TipTap instance during the brief remount window when switching translations (FieldRenderer is re-keyed by item.id), so DocumentOutline briefly renders against a destroyed editor. PortableTextEditor's onEditorReady contract now widens to (Editor | null) and the effect returns a cleanup that calls onEditorReady(null), letting consumers that pass a setState dispatcher (like setPortableTextEditor) clear the slot before the next instance mounts. Verified by TDD against the real component: the new test in PortableTextEditor.test.tsx fails when the cleanup is removed.
What does this PR do?
Fixes a bug where switching between translations of the same content in the admin editor (e.g. clicking "Edit" on a French translation while viewing the English version) would leave the previous locale's body in the Portable Text editor. Worse: any subsequent edit silently overwrote the new translation's content in
formData, corrupting it on autosave.Root cause (two layers):
PortableTextEditorfreezes its initial content viauseMemo(() => ..., [])on mount and has no effect to reconcile incomingvaluechanges. This is intentional — re-creating the TipTap instance on every render caused infinite loops with the Suggestion plugin.ContentEditorreused the same<FieldRenderer>instance on translation switch (key was justname), and theformDatareset for the new item happened in a post-renderuseEffect— one render behind, so even if a remount were forced it would capture stale data.Translations switch via TanStack Router
<Link>to/content/$collection/$idwith a differentid. The route component stays mounted; only theitemprop changes.Fix (two parts in
ContentEditor.tsx):previousItemIdstate gate that synchronously resetsformData,slug,slugTouched,status,internalBylines,lastSavedData, and clearspendingAutosaveStateRefwhenitem.idchanges during render. IncludinglastSavedDatain the synchronous reset keepsisDirtystable through the switch (noSaved -> Save -> SavedSaveButton flicker).<FieldRenderer>by `${name}:${item?.id ?? "new"}` so all field editors remount cleanly on item id change. Sidesteps the deliberate `useMemo([])` memoization in PortableTextEditor without unwinding it.Closes #
Type of change
Checklist
AI-generated code disclosure
Screenshots / test output
TDD: wrote a failing test first that mocks `PortableTextEditor` to mirror the real freeze behaviour (captures initial `value` once via `useState` initializer, never reconciles), then renders a wrapper that swaps `item` between two locales without unmounting `ContentEditor`. The test asserts both that the displayed content updates and that the editor remounts (`mountCount >= 2`). Verified empirically that reverting either half of the fix (the synchronous reset OR the `key={item.id}` change) makes the test fail — both halves are required.
Adversarial review pass: ran the `adversarial-reviewer` agent against the fix; no critical or high-severity findings. Three medium/low items raised — applied the in-scope ones (avoid `isDirty` flicker by including `lastSavedData` in the synchronous reset; reset mock mount counter in `beforeEach`). Skipped one out-of-scope latent issue (stale `portableTextEditor` ref across remount window — pre-existing, separate fix).
```
Test Files 60 passed (60)
Tests 839 passed (839)
```