Skip to content

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

Open
mayrang wants to merge 3 commits into
facebook:mainfrom
mayrang:feat/hmr-extension
Open

[lexical-extension][lexical-react] Feature: HMR support for extensions#8959
mayrang wants to merge 3 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 saves 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 restores them — swapping Object.setPrototypeOf on every node in the saved state so they point at the new class prototypes.

import {buildEditorFromExtensions, configExtension, HMRExtension} from '@lexical/extension';
import {HistoryExtension} from '@lexical/history';

const editor = buildEditorFromExtensions({
  name: '[root]',
  dependencies: [
    HistoryExtension,
    configExtension(HMRExtension, {hot: import.meta.hot ?? null}),
  ],
});
  • hot: HotContext | null — pass import.meta.hot (Vite, SvelteKit) or null in production. The HotContext interface requires only { readonly data: Record<string, unknown> }, so any bundler with a persistent data bag works.
  • 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.
  • The SvelteKit example had an inline HMR implementation that is now replaced by the shared HMRExtension.

Test plan

  • 8 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.
  • pnpm tsc --noEmit -p tsconfig.json clean.
  • pnpm vitest run --project unit -t "HMRExtension" — 8 pass.
  • E2E chromium — 773 pass (identical to main).

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.
@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 (UTC)
lexical Ready Ready Preview Aug 7, 2026 6:40pm
lexical-playground Ready Ready Preview Aug 7, 2026 6:40pm

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

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.
@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.

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.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants