Skip to content

fix(core): don't save the inline Portable Text editor when nothing changed - #3074

Open
eisenbruch wants to merge 2 commits into
emdash-cms:mainfrom
eisenbruch:fix/inline-pt-phantom-saves
Open

fix(core): don't save the inline Portable Text editor when nothing changed#3074
eisenbruch wants to merge 2 commits into
emdash-cms:mainfrom
eisenbruch:fix/inline-pt-phantom-saves

Conversation

@eisenbruch

Copy link
Copy Markdown

What does this PR do?

With visual editing on, the inline Portable Text editor saves the body every time focus leaves it and every time the page is left, even when nobody typed. Each save creates a draft that differs from the live version only in _key values, so entries show "Pending changes" that no one made.

Closes #2877

Cause

  • pmToPortableText() mints a new _key for every block, span and link markDef on every call (k() is Math.random()), and spans refer to link markDefs by those keys.
  • save() compared JSON.stringify(getBlocks()) against initialRef, which starts as the raw stored value and, after a save, becomes another fresh serialization. The two never match.
  • handleBlur and the pagehide keepalive flush both call save(), so an untouched body is saved on every blur and on every page leave.

Fix

One file, InlinePortableTextEditor.tsx. Change detection moves from serialized Portable Text to the editor's own document:

  • The document the loaded content produces is recorded as the saved document once the editor exists.
  • save() returns early when editor.state.doc.eq(savedDoc), ProseMirror's structural equality over node types, attributes, marks and text. ProseMirror nodes have no _key, so the random keys never enter the comparison.
  • After a successful save, the saved document becomes the document whose blocks were sent. The blocks are serialized once and that same array is sent, rather than calling getBlocks() again.

Two side effects, both improvements:

  • An edit undone before focus leaves is no longer saved.
  • An edit typed while a save is in flight is no longer absorbed into the baseline. Previously initialRef = getBlocks() after the response picked it up, so that edit could never be saved.

The stored shape does not change: the PUT still sends the editor's blocks with their keys. Making keys deterministic, so they stop churning on real saves, is a separate change and is not in this PR.

Can this hide a real edit?

No. Node.eq compares the whole document tree: node types and order, attributes (heading level, alignment, list fields, image and link attributes), marks and text. Reordering, splitting or merging blocks, typing, or changing a link all produce a document that is not eq. The only thing it cannot see is _key, which lives only in the serialized Portable Text, and ignoring _key is the point.

I first tried a canonical-JSON comparison that dropped _key and remapped link-mark references to their markDef position. It worked, but it needed about 40 lines of normalization, plus care for every place Portable Text carries a key reference. Comparing the editor document avoids that class of mistake.

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: no errors in the changed files. tsgo --noEmit in packages/core reports errors only in untouched files (src/api/handlers/registry.ts, src/registry/*, src/plugins/types.ts, src/utils/slugify.ts) in my environment.
  • pnpm lint passes: oxlint --type-aware clean on the changed files
  • pnpm test passes (or targeted tests for my change): all 15 files in tests/unit/components pass (89 tests)
  • pnpm format has been run
  • I have added/updated tests for my changes (if applicable)
  • I have added and reviewed the user-facing changeset (emdash: patch)

AI-generated code disclosure

  • This PR includes AI-generated code — model/tool: Claude (Claude Code)

Screenshots / test output

Not applicable (no UI change). New file tests/unit/components/inline-portable-text-unchanged-save.test.ts; the stored body includes a link paragraph. Against current main without the fix:

× does not save when focus leaves an unedited body
    expected [ …3 PUTs ] to have a length of +0 but got 3
× does not save on page leave when the body was never touched
    expected [ …1 PUT ] to have a length of +0 but got 1
× saves a real edit once, and not again on the next blur
    expected [ …2 PUTs ] to have a length of 1 but got 2
× does not save an edit that was undone before focus left
    expected [ …1 PUT ] to have a length of +0 but got 1

With the fix, all four pass.

…anged

The inline editor compared a fresh serialization, whose _keys are minted
by Math.random() on every call, against the raw stored value, so the
check never matched. Every blur out of the body and every pagehide saved
a new draft that differed only in keys.

Change detection now compares the editor's ProseMirror document with the
one last known to be stored (Node.eq): the loaded document once the
editor exists, then the document whose blocks each successful save sent.
Keys never enter the comparison, an undone edit is not saved, and an edit
typed during an in-flight save is no longer absorbed into the baseline.

Closes emdash-cms#2877
@changeset-bot

changeset-bot Bot commented Sep 11, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: dc88e02

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

This PR includes changesets to release 17 packages
Name Type
emdash Patch
@emdash-cms/cloudflare Patch
@emdash-cms/sandbox-workerd Patch
@emdash-cms/fixture-perf-site Patch
@emdash-cms/perf-demo-site Patch
@emdash-cms/cache-demo-site Patch
@emdash-cms/do-demo-site Patch
@emdash-cms/do-solo-demo-site Patch
@emdash-cms/admin Patch
@emdash-cms/auth Patch
@emdash-cms/blocks Patch
create-emdash Patch
@emdash-cms/gutenberg-to-portable-text Patch
@emdash-cms/x402 Patch
@emdash-cms/auth-atproto Patch
@emdash-cms/release-service 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

@emdashbot emdashbot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The approach is sound: phantom saves happen because every serialization mints new Portable Text _keys, so the old JSON.stringify comparison never matched. Moving change detection into ProseMirror’s structural Node.eq fixes that without having to normalize keys, and capturing the sent document as the new baseline prevents in-flight edits from being absorbed. This is the right fix in the right place.

I checked the diff, the full changed file, the new unit tests, and the changeset. No logic bugs or regressions in the production code. The fix correctly avoids PUTs on unchanged blur/pagehide, still saves real edits once, and preserves the shape of the stored body. The changeset is user-facing and accurate.

I have two minor suggestions: the new test relies on reading .editor from the rendered ProseMirror DOM node, which is not a documented @tiptap/react API, and the comment above savedDocRef over-explains the prior bug rather than stating the current invariant. Neither is a blocker.

Overall this is a clean, targeted bug fix with reasonable test coverage.

Comment on lines +111 to +112
function editorOf(editable: HTMLElement): TestEditor {
const editor = (editable as HTMLElement & { editor?: TestEditor }).editor;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[suggestion] The test reads (editable as HTMLElement & { editor?: TestEditor }).editor to get the TipTap editor instance from the rendered ProseMirror DOM node. This is not a documented API of @tiptap/react’s <EditorContent>; if the DOM node does not carry an editor property, the expect(editor).toBeDefined() assertion fails and the "real edit" / "undo" tests never exercise the actual regression. Existing tests in this directory drive the component through DOM events or custom events instead.

Please verify this property exists in the installed TipTap version. If it does not, replace it with a stable command path—ideally by driving the ProseMirror element with input/keyboard events, or by adding a small test-only hook/ref rather than relying on internal DOM attachments.

Comment on lines +2099 to +2103
// The editor document as last known to be stored: the one the loaded
// content produced, then whatever each successful save sent. Saves compare
// against it structurally. Comparing serialized Portable Text against the
// raw `value` never matched, because serialization mints new `_key`s on
// every call, so every blur and every page leave saved a new draft.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[suggestion] This comment is mostly a historical explanation of the previous bug. The first sentence is a useful invariant; the rest re-states why the old key-based comparison failed. That rationale already belongs in the changeset and the PR description. For code readers, keep the docstring and drop the historical justification:

Suggested change
// The editor document as last known to be stored: the one the loaded
// content produced, then whatever each successful save sent. Saves compare
// against it structurally. Comparing serialized Portable Text against the
// raw `value` never matched, because serialization mints new `_key`s on
// every call, so every blur and every page leave saved a new draft.
// The editor document as last known to be stored: the loaded document,
// then the document whose blocks were sent by each successful save.
// Saves compare against it structurally with ProseMirror `Node.eq`.
const savedDocRef = React.useRef<Editor["state"]["doc"] | null>(null);

…ads view.dom.editor

Review follow-ups on emdash-cms#3074: the savedDocRef comment now states what it
holds rather than the old bug, and the test explains its use of the
editor instance Tiptap attaches to the view's root element.
@github-actions github-actions Bot added review/needs-rereview Author pushed changes since the last review and removed review/needs-review No maintainer or bot review yet labels Sep 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/core cla: signed review/needs-rereview Author pushed changes since the last review size/M

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Visual Portable Text publish leaves key-only pending changes

1 participant