[lexical-extension][lexical-react] Feature: HMR support for extensions - #8959
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
examples can’t depend on new exports, we’d have to create a new dev-examples for this until this api is published |
|
Done — moved the example to |
etrepum
left a comment
There was a problem hiding this comment.
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
- [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.
- [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
left a comment
There was a problem hiding this comment.
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.
9e61db0 to
e572d77
Compare
…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.
…e-aqpssr' into feat/hmr-extension
…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.
…e-aqpssr' into feat/hmr-extension
…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 .
```
…e-aqpssr' into feat/hmr-extension
etrepum
left a comment
There was a problem hiding this comment.
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! ```
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
HMRExtensionto@lexical/extensionthat preserves editor state, the editable flag, and undo/redo history across HMR cycles. It also splits three@lexical/reactplugin modules for better React Fast Refresh compatibility.HMRExtension
On each HMR cycle, the extension serializes the current
EditorState, editable flag, and (whenHistoryExtensionis present) the full undo/redo stacks to the bundler's HMR data store. When the new editor instance is created, it callseditor.parseEditorState(json)to deserialize the saved state — all nodes are reconstructed with the new class prototypes automatically.hot: HotContext | null— passimport.meta.hot ?? null; the extension is a no-op whennull, so no build-time conditional is needed. TheHotContextinterface requires only{ readonly data: Record<string, unknown> }, so any bundler with a compatible data bag works.namespaceautomatically. Useidonly when two editors share both the same HMR context and namespace.HistoryExtensionis a peer, undo/redo stacks are preserved automatically. The extension detects it at runtime viagetPeerDependencyFromEditor— no hard dependency.$initialEditorStategracefully.Fast Refresh splits
Vite's
react-refreshplugin applies state-preserving HMR only when a module exports nothing but React components. Three@lexical/reactplugins export hooks, classes, or commands alongside their component, which forces a full remount on every change.This PR extracts non-component exports into companion
*Utilsfiles:LexicalAutoEmbedPluginUtils.ts—AutoEmbedOption,EmbedConfig,INSERT_EMBED_COMMAND,URL_MATCHERLexicalCollaborationContextUtils.ts—CollaborationContext,useCollaborationContextLexicalTypeaheadMenuPluginUtils.ts—PUNCTUATION,useBasicTypeaheadTriggerMatch,SCROLL_TYPEAHEAD_OPTION_INTO_VIEW_COMMAND,getScrollParentBackwards compatibility
The original component modules re-export everything from their Utils counterpart, so existing import paths keep working. No API changes.
Design notes
HotContextinterface is deliberately minimal. Bundler HMR contexts have wildly different shapes (Vite hasaccept,dispose,prune; webpack hasmodule.hot.accept). The only shared property is a persistentdatabag. Widening this interface is possible if a use case comes up.*Utilssplit is one approach to Fast Refresh compatibility. An alternative is// @refresh resetdirectives 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
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 tscclean.pnpm vitest run --project unit— all pass.