Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/string-reconcile-react-compiler.md
Original file line number Diff line number Diff line change
@@ -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.
15 changes: 15 additions & 0 deletions packages/editor/src/engine/react/components/string.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,15 @@ const EngineString = (props: {
path: Path
text: PortableTextSpan
}) => {
// `EngineString` returns a `<TextString>` 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)
Expand Down Expand Up @@ -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<HTMLSpanElement>(null)
Expand Down
Original file line number Diff line number Diff line change
@@ -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 `<TextString>`
// 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')
})
})
Loading