Skip to content

[lexical-extension][lexical-react] Feature: HMR support for extensions - #8959

Merged
etrepum merged 35 commits into
facebook:mainfrom
mayrang:feat/hmr-extension
Sep 1, 2026
Merged

[lexical-extension][lexical-react] Feature: HMR support for extensions#8959
etrepum merged 35 commits into
facebook:mainfrom
mayrang:feat/hmr-extension

Conversation

@mayrang

@mayrang mayrang commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Description

Lexical relies on object identity for node class registration, command dispatch, and extension deduplication. When a bundler's HMR re-executes a module, those objects get recreated and the previous editor state is lost. This PR adds an HMRExtension to @lexical/extension that preserves editor state, the editable flag, and undo/redo history across HMR cycles. It also splits three @lexical/react plugin modules for better React Fast Refresh compatibility.

HMRExtension

On each HMR cycle, the extension serializes the current EditorState, editable flag, and (when HistoryExtension is present) the full undo/redo stacks to the bundler's HMR data store. When the new editor instance is created, it calls editor.parseEditorState(json) to deserialize the saved state — all nodes are reconstructed with the new class prototypes automatically.

import {buildEditorFromExtensions, configExtension, defineExtension, HMRExtension} from '@lexical/extension';
import {RichTextExtension} from '@lexical/rich-text';
import {HistoryExtension} from '@lexical/history';

const editor = buildEditorFromExtensions(
  defineExtension({
    name: '[root]',
    namespace: 'my-editor',
    dependencies: [
      RichTextExtension,
      HistoryExtension,
      configExtension(HMRExtension, {hot: import.meta.hot ?? null}),
    ],
  }),
);
  • hot: HotContext | null — pass import.meta.hot ?? null; the extension is a no-op when null, so no build-time conditional is needed. The HotContext interface requires only { readonly data: Record<string, unknown> }, so any bundler with a compatible data bag works.
  • Multiple editors on the same page are isolated by namespace automatically. Use id only when two editors share both the same HMR context and namespace.
  • When HistoryExtension is a peer, undo/redo stacks are preserved automatically. The extension detects it at runtime via getPeerDependencyFromEditor — no hard dependency.
  • Corrupted or empty saved state falls back to $initialEditorState gracefully.

Fast Refresh splits

Vite's react-refresh plugin applies state-preserving HMR only when a module exports nothing but React components. Three @lexical/react plugins export hooks, classes, or commands alongside their component, which forces a full remount on every change.

This PR extracts non-component exports into companion *Utils files:

  • LexicalAutoEmbedPluginUtils.tsAutoEmbedOption, EmbedConfig, INSERT_EMBED_COMMAND, URL_MATCHER
  • LexicalCollaborationContextUtils.tsCollaborationContext, useCollaborationContext
  • LexicalTypeaheadMenuPluginUtils.tsPUNCTUATION, useBasicTypeaheadTriggerMatch, SCROLL_TYPEAHEAD_OPTION_INTO_VIEW_COMMAND, getScrollParent

Backwards compatibility

The original component modules re-export everything from their Utils counterpart, so existing import paths keep working. No API changes.

Design notes

  • The HotContext interface is deliberately minimal. Bundler HMR contexts have wildly different shapes (Vite has accept, dispose, prune; webpack has module.hot.accept). The only shared property is a persistent data bag. Widening this interface is possible if a use case comes up.
  • The *Utils split is one approach to Fast Refresh compatibility. An alternative is // @refresh reset directives on the component files, which forces a full remount but avoids the file split. The split gives more granular HMR boundaries — changes to the Utils file don't invalidate the component, and vice versa.

Test plan

  • 23 unit tests in HMRExtension.test.ts — content preservation, editable flag, undo/redo round-trip, multi-cycle, null hot, corrupted state, empty saved state, no-history peer, namespace isolation, id isolation.
  • pnpm run tsc clean.
  • pnpm vitest run --project unit — all pass.
  • E2E chromium — 776 pass.

@vercel

vercel Bot commented Aug 7, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
lexical Ready Ready Preview Sep 1, 2026 10:19pm UTC
lexical-playground Ready Ready Preview Sep 1, 2026 10:19pm UTC

Request Review

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Aug 7, 2026
@etrepum

etrepum commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator

examples can’t depend on new exports, we’d have to create a new dev-examples for this until this api is published

@mayrang

mayrang commented Aug 7, 2026

Copy link
Copy Markdown
Contributor Author

Done — moved the example to dev-examples/hmr/ and reverted examples/extension-sveltekit-ssr-hydration to its original inline implementation.

@etrepum etrepum left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The strategy here needs some consideration for multiple editors on the page, and it should create a new editor state rather than trying to mutate nodes that might be frozen already.

ChatGPT review

Review findings

  1. [P1] Prototype replacement fails for reconciled custom nodes
    swapNodePrototypes() calls Object.setPrototypeOf() directly on nodes from the saved editor state. In development builds, however, the reconciler freezes nodes after rendering them into the DOM. A genuine HMR update that replaces a custom node class produces a different prototype, so changing the prototype of the frozen saved node throws a TypeError.

The surrounding catch converts that exception into “Starting fresh,” meaning the extension loses the editor state in precisely the custom-node HMR scenario it is intended to support. The current unit tests do not expose this because they never attach a root element, so their states are not reconciled and frozen, and they recreate editors with the same node classes.

The restoration mechanism should avoid mutating frozen nodes—for example, clone the state into new node instances with the newly registered classes—or otherwise establish a supported core-level mechanism for remapping node prototypes.

  1. [P1] Every editor created from the same hot module shares one saved state
    The extension stores its state under the single constant key lexicalHMR. Each effect then unconditionally writes that key in the module’s shared hot.data object.

Consequently, two editors configured with the same import.meta.hot context overwrite one another. On initial mounting, the second editor can restore the first editor’s content; after that, whichever editor updates last becomes the state restored by both editors following HMR. This is a common arrangement when a module renders multiple instances of the same editor component.

Saved data needs to be namespaced per editor instance, using an explicit stable identifier in HMRConfig or a similarly stable key that survives the reload. Tests should cover two editors sharing one HotContext and verify that their content and history remain independent.

Add HMRExtension to @lexical/extension that preserves editor state, editable
flag, and undo/redo history across HMR cycles. The extension saves state to
the bundler's HMR data store and restores it with prototype swaps when the
new editor instance is created.

Split non-component exports from LexicalTypeaheadMenuPlugin,
LexicalAutoEmbedPlugin, and LexicalCollaborationContext into companion
*Utils files for better Fast Refresh boundaries.

Add FAQ documentation covering HMRExtension usage, Fast Refresh
compatibility, and the @refresh reset fallback.
examples/ depends on npm-published packages and can't import
HMRExtension until the next release. Restore the inline
implementation in the SvelteKit example and add a dev-examples/hmr
app that imports HMRExtension from the workspace package.
…o/redo preservation, dev warnings

- Add `id` config option for multi-editor HMR isolation with stable hot.data keys
- Preserve undo/redo history across HMR cycles via HistoryExtension peer detection
- Add dev warnings for empty `id` string and multiple editors without `id`
- Add Flow types: HotContext, HMRConfig, HMRExtension declaration
- Expand example App with editable toggle to exercise editable flag preservation
- Update FAQ docs with HMRExtension usage, Fast Refresh compat notes, and fallback directive
- 21 unit tests covering content, editable, undo/redo, multi-cycle, corruption, and dev warnings
Update pnpm-lock.yaml to resolve peer dep specifier mismatch introduced when
rebasing feat/hmr-extension onto the current main. The dev-examples/hmr
workspace was referencing terser@5.48.0 peer specifiers while the snapshots
section (inherited from main) had migrated to terser@5.49.2.

Run `pnpm install --no-frozen-lockfile` to regenerate correct entries.

@etrepum etrepum left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

It looks like something is wrong with the lockfile causing all of CI to fail. We could probably use namespace in addition to id to generate the HMR key, some editors may already set that separately for different editors on the page.

claude and others added 3 commits August 31, 2026 22:10
…nstead of losing the document to them

## Description

Three ways a payload written by a different build of this extension could cost
the whole document, and one packaging fix.

**A missing selection threw.** `$restoreSelection` guarded with `=== null`, so
a family state carrying no `selection` at all reached `serialized.type` on
`undefined`. That throw happens after the parse, outside its try, so
`HMRExtension` discarded the document and its history over a caret it could
not read. It guards with `!= null` now — the same reasoning as the `history`
field, which already did.

**The payload was barely validated.** `isSerializedEditorStateFamily` checked
that each node version had a `json` property and that state indices were in
range, and nothing else: `first`/`last`/`size`, `slots`, `slotHost` and
`selection` all went unchecked, which is how the throw above was reachable. It
now checks each field it later relies on, so a payload this build cannot read
is reported as one — before rebuilding starts, where saying so is still cheap.

**A parse that did not finish looked like an empty document.**
`parseEditorState` routes a throw to `editor._onError`, which an application is
free to log rather than rethrow. The node versions would then be built but
never linked, and every state would come back describing a root with no
children: a silently blank document, no warning, previous content gone. The
rebuild records that it finished, and a family that did not comes back as
states the caller cannot use, which is the fallback it already reports.

**`@lexical/extension` imported types from `@lexical/history`.** That is the
dependency the other way around, and it left `@lexical/extension` with an
undeclared one: anyone type-checking it through its `source` export condition
without `@lexical/history` installed got an unresolved import. History is an
optional peer here — an editor without it restores just the same — so the two
shapes this extension touches are declared locally, and a unit test assigns
each to the other so they cannot drift apart unnoticed.

## Test plan

### Before

The new tests against the previous `editorStateFamily.ts`:

```
$ npx vitest run --project unit packages/lexical-extension/src/__tests__/unit/HMRExtension.test.ts

 × restores a payload whose states carry no selection
 × rejects a payload whose node versions are shaped differently

AssertionError: expected 'initial' to be 'typed'   ← the document, lost to a TypeError
AssertionError: expected "warn" to be called with arguments: [ StringContaining{…} ]
+   TypeError {

      Tests  2 failed | 51 passed (53)
```

### After

```
$ npx vitest run --project unit

 Test Files  285 passed (285)
      Tests  4637 passed | 1 skipped (4638)
```

```
$ npx tsc --noEmit -p .
$ npm run build-types
$ npx flow check
No errors!
$ npx eslint packages/lexical-extension/src packages/lexical-react/src packages/lexical-history/src
$ npx prettier --check packages/lexical-extension packages/lexical-react packages/lexical-history
Checking formatting...
All matched files use Prettier code style!
$ node scripts/build.mjs
```

The record-that-it-finished guard has no test of its own: with the validation
above, reaching it takes a throw from something other than a malformed payload,
which a test would have to fake rather than provoke.
claude and others added 2 commits September 1, 2026 03:43
…not a document

## Description

**An EditorState with nothing in it was not reported as empty.**
`EditorState.isEmpty()` tested `_nodeMap.size === 1`, so a state whose node map
holds nothing at all — emptier than a root-only one — came back as non-empty.
`setEditorState` uses that test to refuse a state it should not commit, so
instead of the invariant it commits an editor with no root: `$getRoot()` then
returns `undefined` and the editor is bricked, as a scratch run confirmed
(`TypeError: Cannot read properties of undefined (reading 'getTextContent')`).
The test is `<= 1` now, which turns that case into the invariant it was always
meant to hit.

**A family state describing no document was handed over anyway.** That is how
the HMR restore reached the case above: a payload whose state lists no nodes,
or none of them a root, rebuilds into an editor state with an empty node map,
and `!restoredState.isEmpty()` waved it through. The rebuild now returns `null`
for a state with no root, the same as for one whose nodes could not be rebuilt,
so a single such state costs that state — an undo entry is dropped, or the
document falls back to `$initialEditorState` with the warning that already
covers it — rather than the family or the editor.

**A restore that threw was announced as one that happened.** `restoreCount` was
incremented from a flag set before `setEditorState` and the history restore, so
a throw in either logged "Starting fresh" and still told dependents the editor
had been restored — `SharedHistoryExtension` re-linking a nested editor's
`HistoryState` on the strength of it. The flag is set once everything in the
branch has succeeded.

**`HMROutput` was public in Flow but not in TypeScript.** The new output type
was declared in `flow/LexicalExtension.js.flow` but never re-exported from the
package entry point, so the two surfaces disagreed and
`scripts/lint-flow-types.mjs` reported it. It is exported from `index.ts` now.

## Test plan

### Before

```
$ npx vitest run --project unit packages/lexical-extension/.../HMRExtension.test.ts packages/lexical/.../LexicalEditorState.test.ts

 × rejects a payload whose states describe no document
 × isEmpty

AssertionError: expected false to be true

      Tests  2 failed | 59 passed (61)
```

and the editor a rootless state leaves behind, from a scratch run:

```
BRICKED: Cannot read properties of undefined (reading 'getTextContent')
```

```
$ node scripts/lint-flow-types.mjs | grep HMROutput
packages/lexical-extension/flow/LexicalExtension.js.flow:149:12 - warning: Flow export 'HMROutput' does not have a TypeScript declaration
```

### After

```
$ npx vitest run --project unit

 Test Files  285 passed (285)
      Tests  4640 passed | 1 skipped (4641)

$ node scripts/lint-flow-types.mjs | grep -c HMROutput
0
```

```
$ npx tsc --noEmit -p .
$ npm run build-types
$ npx flow check
No errors!
$ npx eslint packages/lexical/src packages/lexical-extension/src
$ npx prettier --check packages/lexical/src packages/lexical-extension
Checking formatting...
All matched files use Prettier code style!
$ node scripts/build.mjs
```

The `restoreCount` change has no test of its own: reaching it needs
`setEditorState` or the history restore to throw on a payload that passed
validation, which a test would have to fake rather than provoke. A test does
cover the counter's ordinary contract — zero when there was nothing to restore,
one after a restore.
claude and others added 2 commits September 1, 2026 15:42
…ean predicate

## Description

`getSharedParentKey` returned `string | null`, but the string was always its
own `hmrKey` argument echoed back, and the one caller only tested the result
against `null`. The signature invited a future caller to treat the return
value as "the parent's key" distinct from the argument, a branch that could
never do anything. It is now `sharesParentHMRKey`, returning the boolean it
always was. Internal only — the function is not exported, and the shared-key
warning behaves exactly as before.

## Test plan

### After

```
$ npx vitest run --project unit packages/lexical-extension/src/__tests__/unit/HMRExtension.test.ts

 Test Files  1 passed (1)
      Tests  55 passed (55)
```

```
$ npx prettier --check packages/lexical-extension/src/HMRExtension.ts
All matched files use Prettier code style!
$ npx eslint packages/lexical-extension/src/HMRExtension.ts
$ npx tsc --noEmit -p .
```

@etrepum etrepum left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I think the only thing left here is updating the flow stubs for the extracted utils modules

## Description

The new utility entry points had placeholder Flow stubs with no exports, so Flow consumers could not import their public APIs. Declare the moved auto-embed, collaboration context, menu option, and typeahead utility APIs with signatures matching their TypeScript sources.

## Test plan

### Before

`node scripts/lint-flow-types.mjs`

```
packages/lexical-react/src/LexicalAutoEmbedPluginUtils.ts:21:1 - warning: Missing flow export for TypeScript export 'EmbedMatchResult'
packages/lexical-react/src/LexicalCollaborationContextUtils.ts:19:1 - warning: Missing flow export for TypeScript export 'CollaborationContextType'
packages/lexical-react/src/LexicalMenuOption.ts:24:1 - warning: Missing flow export for TypeScript export 'MenuOption'
packages/lexical-react/src/LexicalTypeaheadMenuPluginUtils.ts:47:1 - warning: Missing flow export for TypeScript export 'useBasicTypeaheadTriggerMatch'
```

### After

`pnpm run flow`

```
Running Flow...
No errors!
```

`pnpm run test-unit packages/lexical-extension/src/__tests__/unit/HMRExtension.test.ts packages/lexical-react/src/__tests__/unit/LexicalTypeaheadMenuPluginUtils.test.ts`

```
Test Files  2 passed (2)
Tests  57 passed (57)
```

`pnpm run ci-check`

```
$ npm-run-all --parallel tsc tsc-scripts tsc-extension tsc-website flow prettier lint
Running Flow...
No errors!
```
@etrepum
etrepum added this pull request to the merge queue Sep 1, 2026
Merged via the queue into facebook:main with commit 27a5fc2 Sep 1, 2026
42 checks passed
@etrepum etrepum mentioned this pull request Sep 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. extended-tests Run extended e2e tests on a PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants