From ff95744fa05f761582b625cf81e667ad2ecaa68a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Hamburger=20Gr=C3=B8ngaard?= Date: Thu, 18 Jun 2026 15:36:53 +0200 Subject: [PATCH] fix: keep `EngineString`/`TextString` uncompiled so the DOM-reconcile effect runs every render `TextString` keeps the DOM text node in sync with the model through a dependency-less layout effect that runs on every render and rewrites the node whenever it has drifted from the model. That is the only thing that resets a character the browser wrote directly into a text node (native contenteditable insertion) when the model edit lands somewhere else, for example a single character typed right after toggling a decorator at a collapsed cursor: the browser writes into the focus span while the model insert creates a new decorated span, so the focus span's DOM text and model text diverge. `string.tsx` is in the Tier-3 react-compiler un-exclusion set, so the published `lib` and the vitest suite compile it. The compiler memoizes the `` element that `EngineString` returns, keyed on `leafText`; when the text is unchanged it reuses the element, React bails on re-rendering `TextString`, and the reconcile effect never runs. The stray character is then stranded in the DOM. The hosted playground never showed this because its build carves the whole engine out of react-compiler, so its `EngineString` produces a fresh element every render and the effect always fires. Mark `EngineString` and `TextString` with `"use no memo"`. The directive lives in the source, so every build (lib, tests, playground) honors it uniformly without depending on three separate compiler-boundary configs agreeing. The element is recreated each render, `TextString` re-renders, and the reconcile effect runs as designed. Net behavior change is confined to compiled builds, where a native edit that diverged from the model is now reconciled away instead of ghosting; `tests/string-reconcile-under-react-compiler.test.tsx` pins it (red before, green after, in the compiled browser build). --- .changeset/string-reconcile-react-compiler.md | 11 +++++ .../src/engine/react/components/string.tsx | 15 +++++++ ...ng-reconcile-under-react-compiler.test.tsx | 41 +++++++++++++++++++ 3 files changed, 67 insertions(+) create mode 100644 .changeset/string-reconcile-react-compiler.md create mode 100644 packages/editor/tests/string-reconcile-under-react-compiler.test.tsx diff --git a/.changeset/string-reconcile-react-compiler.md b/.changeset/string-reconcile-react-compiler.md new file mode 100644 index 0000000000..d68b800d2d --- /dev/null +++ b/.changeset/string-reconcile-react-compiler.md @@ -0,0 +1,11 @@ +--- +"@portabletext/editor": patch +--- + +fix: keep `EngineString`/`TextString` uncompiled so the DOM-reconcile effect runs every render + +In builds that compile the editor with React Compiler, a browser edit that +diverged from the model could leave a stray character in the rendered text +while the document itself stayed correct (for example, typing immediately +after toggling a decorator at a collapsed cursor). The rendered text now +always matches the document again. diff --git a/packages/editor/src/engine/react/components/string.tsx b/packages/editor/src/engine/react/components/string.tsx index 06c8f570e2..2ebe58d4e2 100644 --- a/packages/editor/src/engine/react/components/string.tsx +++ b/packages/editor/src/engine/react/components/string.tsx @@ -55,6 +55,15 @@ const EngineString = (props: { path: Path text: PortableTextSpan }) => { + // `EngineString` returns a `` element whose only prop is the + // leaf's text. React Compiler memoizes that element on `text`, so when the + // text is unchanged it reuses the previous element and React bails on + // re-rendering `TextString`. That defeats `TextString`'s reconcile-on-render + // layout effect (see below), which is the only thing that resets a text node + // the browser mutated out from under the model (native insertion). Opt this + // component out of the compiler so the element is recreated every render and + // `TextString` always gets a chance to reconcile. + 'use no memo' const {isLast, leaf, parent, path, text} = props const editor = useEngineStatic() const parentPath = getParentPath(path) @@ -92,6 +101,12 @@ const EngineString = (props: { * Leaf strings with text in them. */ const TextString = (props: {text: string; isTrailing?: boolean}) => { + // This component reconciles the DOM text node against the model on every + // render (the dependency-less layout effect below). React Compiler must not + // memoize it: a skipped render is a skipped reconcile, which strands any + // text the browser wrote ahead of the model. Keep it uncompiled regardless + // of which build (lib, tests, playground) sets the react-compiler boundary. + 'use no memo' const {text, isTrailing = false} = props const isInNewPipeline = useContext(NewPipelineContext) const ref = useRef(null) diff --git a/packages/editor/tests/string-reconcile-under-react-compiler.test.tsx b/packages/editor/tests/string-reconcile-under-react-compiler.test.tsx new file mode 100644 index 0000000000..0ae36ef51b --- /dev/null +++ b/packages/editor/tests/string-reconcile-under-react-compiler.test.tsx @@ -0,0 +1,41 @@ +import {createTestKeyGenerator} from '@portabletext/test' +import {describe, expect, test, vi} from 'vitest' +import {userEvent} from 'vitest/browser' +import {defineSchema} from '../src' +import {IS_MAC} from '../src/internal-utils/is-hotkey' +import {createTestEditor} from '../src/test/vitest' +import {toTextspec} from '../test-utils/to-textspec' + +// `TextString` reconciles the DOM text node against the model on every render +// via a dependency-less layout effect. React Compiler compiles `string.tsx` +// in this build, and would otherwise memoize `EngineString`'s `` +// element on the unchanged leaf text, skip the re-render, and skip the +// reconcile. `EngineString`/`TextString` carry a `"use no memo"` directive so +// that cannot happen. This test exercises the path that exposed it: a native +// single-character insertion lands in the focus span while a pending decorator +// toggle routes the model insert into a new span, so the focus span's DOM text +// diverges from its model text and only the reconcile effect can fix it. +describe('string reconcile under react compiler', () => { + test('Scenario: a native edit that diverges from the model is reconciled away', async () => { + const {editor, locator} = await createTestEditor({ + keyGenerator: createTestKeyGenerator(), + schemaDefinition: defineSchema({decorators: [{name: 'strong'}]}), + }) + + await userEvent.click(locator) + await userEvent.type(locator, 'foo') + + await userEvent.keyboard( + IS_MAC ? '{Meta>}b{/Meta}' : '{Control>}b{/Control}', + ) + await userEvent.type(locator, 'bar') + + await vi.waitFor(() => { + expect(toTextspec(editor.getSnapshot().context)).toEqual( + 'B: foo[strong:bar|]', + ) + }) + + expect(locator.element().textContent).toEqual('foobar') + }) +})