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') + }) +})