Skip to content

[lexical-extension][*] Refactor: publish @lexical/extension as subpath exports with compiler+lint enforcement - #9161

Merged
etrepum merged 11 commits into
facebook:mainfrom
etrepum:codex/9155-subpath-enforcement
Sep 12, 2026
Merged

etrepum merged 11 commits into
facebook:mainfrom
etrepum:codex/9155-subpath-enforcement

Conversation

@etrepum

@etrepum etrepum commented Sep 11, 2026

Copy link
Copy Markdown
Collaborator

Description

Publish @lexical/extension as individual subpaths so consumers, including Meta's www builds, can load only the required modules without relying on tree-shaking. Existing barrel imports remain fully compatible.

  • Add the reusable subpathImports() Rollup/Vite plugin in @lexical/compiler and enable strict rewriting in all package builds.
  • Keep shared modules external between entries, preserving export identities and a single signals runtime.
  • Keep barrel imports across packages and relative imports within the extension source, enforced by ESLint.
  • Move Flow declarations into their owning subpaths and re-export them from the barrel.

Closes #9153
Closes #9155 (this PR supersedes it)

Test plan

Before

Editor dependencies reach the extension barrel, pulling in unrelated extensions without tree-shaking. After a www build:

$ rg -o "require\('LexicalExtension'\)" packages/lexical-rich-text/dist/LexicalRichText.dev.js
require('LexicalExtension')

After

npm and www development/production checks confirm the barrel and unrelated extensions are excluded, all 58 barrel exports preserve identity, and signals batching works across entries.

potatowagon and others added 3 commits September 11, 2026 02:28
…on as subpath exports

## Description

`@lexical/extension` publishes a single entry point that contains both the
builder core and all 20 bundled extension implementations. Anything that
builds an editor — `LexicalExtensionComposer`, or `buildEditorFromExtensions`
directly — pulls the whole module even when it uses nothing but
`defineExtension` and `LexicalBuilder`.

That is invisible to a bundler that can tree-shake, which is most of them.
It is not invisible to a consumer that compiles the published bundles
without shaking them: Meta's www vendors this repo's Rollup output as one
pre-bundled CommonJS module per package, so requiring the entry point costs
39.9 kB raw / 12.9 kB gzipped there regardless of what is used. The same
applies to any consumer loading `dist/` through a CJS path.

This changes the package to the shape `@lexical/react` already uses: one
export per `src/*.ts`, with `index.ts` kept as a barrel so
`import {...} from '@lexical/extension'` continues to resolve everything.

- `main`/`module` are dropped from `package.json`, which is what makes
  `updateVersion.mjs` generate one export entry per source file rather than
  a single `.` entry. The exports map, flow stubs, tsconfigs and www stubs
  in this diff are all generated by `pnpm run update-packages`.
- Sibling imports inside the package move from relative (`./signals`) to
  package-qualified (`@lexical/extension/signals`), matching
  `@lexical/react`. This is what keeps shared code shared: a relative import
  is inlined into each entry that uses it, so with 18 modules importing
  `./signals`, splitting without this would put 18 copies of the signals
  re-export — and, in the www build where `@preact/signals-core` is not
  external, 18 copies of the signals runtime — into the output. Signals
  state is module-scope, so duplicates do not merely cost bytes, they break
  batching and effect tracking. Verified below that exactly one module
  contains it.
- `@lexical/react`'s own imports move to the subpaths, so the split
  actually reaches the React entry points rather than being available in
  principle.

No public API changes: every symbol previously exported from
`@lexical/extension` is still exported from it.

## Test plan

### Before

Everything reachable from `LexicalExtensionComposer`, www build at 0.50.0:

```
LexicalExtension        39906 raw  12860 gz   (20 extensions + signals runtime)
```

### After

`pnpm run build-www`, then walking `require()` edges from
`LexicalExtensionComposer`:

```
LexicalExtensionConfig                              733 raw    458 gz
LexicalExtensionDeepThemeMergeInPlace               700 raw    457 gz
LexicalExtensionExtensionRep                       5233 raw   1683 gz
LexicalExtensionGetExtensionDependencyFromEditor    966 raw    580 gz
LexicalExtensionInitialStateExtension              1176 raw    666 gz
LexicalExtensionLexicalBuilder                     5783 raw   2224 gz
TOTAL                                             14591 raw   6068 gz
```

6 of 33 modules, down from the whole package: **-25,315 raw / -6,792 gz,
53% of the gzipped cost.** The signals runtime is no longer reachable from
an editor build at all, and the barrel is not reachable either.

Signals is in exactly one module (www build, where it is inlined rather
than external):

```
$ grep -l "preact-signals" packages/lexical-extension/dist/*.prod.js
packages/lexical-extension/dist/LexicalExtensionSignals.prod.js
```

Backwards compatibility — all 58 value exports still resolve from the
barrel after the split:

```
barrel runtime exports after split: 58
value exports expected: 58
MISSING from barrel: none
```

Suites:

```
$ pnpm run test-unit
 Test Files  324 passed (324)
      Tests  7790 passed | 1 skipped (7791)

$ npx vitest run scripts/__tests__/integration/tree-shaking.test.mjs
 Test Files  1 passed (1)
      Tests  270 passed (270)

$ pnpm run ci-check
(tsc, tsc-scripts, tsc-extension, tsc-website, flow, prettier, lint)
exit 0
```

E2E and browser suites were not run in this environment.
…cal-website] Bug Fix: build through subpaths and restore Flow declarations

## Description

PR facebook#9155 splits @lexical/extension into public subpaths, but other packages still import its barrel and pull every extension into builds without tree-shaking. The new Flow subpaths are also empty.

Add the public @lexical/compiler/SubpathImports Rollup/Vite pass and enable it in strict mode for all monorepo package builds. Derive named import and re-export targets from the installed package's source exports, preserve aliases and type-only imports, externalize public sibling entries, and reject runtime barrel access that cannot be narrowed. Leave executable root entries and packages without a sideEffects:false promise intact. Expand transparent star forwards to explicit value exports.

Move the existing Flow declarations into their owning subpaths, retain the barrel as re-exports, fill the remaining subpath declarations, and add the missing core keyboard-shortcut declarations those subpaths reference. Keep the existing barrel API and shared object identities.

This commit is directly on potatowagon/lexical extension-subpath-exports at 7cb9bb0.

## Test plan

### Before

After pnpm run build --www:

```
$ rg -o "require\\('LexicalExtension'\\)" packages/lexical-rich-text/dist/LexicalRichText.dev.js
require('LexicalExtension')
```

The new Flow subpath files contain only their license and module-name comments.

### After

```
$ pnpm exec vitest run --project unit --project scripts-unit --maxWorkers=4
 Test Files  326 passed (326)
      Tests  7858 passed | 1 skipped (7859)

$ pnpm exec vitest run --project unit --project scripts-unit packages/lexical-compiler scripts/__tests__/unit/extensionFlowSubpaths.test.ts
 Test Files  6 passed (6)
      Tests  145 passed (145)

$ pnpm exec vitest run --project scripts-unit scripts/__tests__/unit/extensionFlowSubpaths.test.ts
 Test Files  1 passed (1)
      Tests  33 passed (33)
```

Built npm development and production output with pnpm run build --prod --release, then ran the subpath-imports and tree-shaking integration files against those artifacts with an isolated Vitest config (bypassing only the global setup that rebuilds and packs every package):

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

The integration tests disable tree-shaking and inspect the real dependency graph. Both variants exclude the extension barrel, AutoFocus and HMR, contain one signals entry, and retain identical barrel/subpath values.

pnpm exec tsc --noEmit, pnpm run tsc-scripts, focused ESLint, Prettier and git diff --check pass.

Both pnpm run build --www and pnpm run build --www --prod pass. Executing those actual CommonJS modules with www module-name resolution verifies all 58 barrel values are identical to their owners, cross-entry signals batching emits [0, 2], and a rich-text editor can be built and disposed.

Flow declaration parsing, export coverage and intra-package/core import coverage pass. The Flow checker could not start in this environment (Operation not permitted; server exit 98). Browser and E2E suites were not run.
…xtension source imports consistent

## Description

The subpath PR rewrites extension consumers in lexical-react and sibling imports inside lexical-extension by hand, while other source consumers still use the barrel. Now that the compiler rewrites package imports during the build, this leaves two source conventions to maintain.

Restore all 102 affected imports and re-exports across 44 source files: use the @lexical/extension barrel across package boundaries and relative paths within lexical-extension. Enforce the convention with ESLint and document it in the contributor guide and compiler README. Published package builds continue to rewrite to public subpaths, and downstream users can still use either the barrel or subpaths.

Extend the no-tree-shaking integration checks to the React composer with rich-text, covering the imports restored here.

## Test plan

### Before

With the new lint rule and before the source migration:

```
$ pnpm exec eslint packages/lexical-react/src/LexicalExtensionComposer.tsx
  8:1  error  '@lexical/extension/getExtensionDependencyFromEditor' import is restricted from being used by a pattern. Import from @lexical/extension in consumers, or use a relative import within that package. The build rewrites source imports to public subpaths  no-restricted-imports
  9:1  error  '@lexical/extension/LexicalBuilder' import is restricted from being used by a pattern. Import from @lexical/extension in consumers, or use a relative import within that package. The build rewrites source imports to public subpaths                    no-restricted-imports

✖ 2 problems (2 errors, 0 warnings)
```

### After

```
$ pnpm exec vitest run --project unit packages/lexical-compiler/src/__tests__/unit/subpathImports.test.ts packages/lexical-extension packages/lexical-react --maxWorkers=4
 Test Files  52 passed (52)
      Tests  406 passed (406)

$ pnpm run build --prod --release
$ pnpm exec vitest run --config /tmp/lexical-work/verification.config.mjs
 Test Files  2 passed (2)
      Tests  277 passed (277)
```

The isolated verification config runs scripts/__tests__/integration/subpath-imports.test.mjs and tree-shaking.test.mjs against the freshly built packages, bypassing only global setup that rebuilds and packs the entire repository. Both React and rich-text bundles exclude the extension barrel, AutoFocus and HMR with tree-shaking disabled and retain one signals entry. Barrel and subpath identities match.

pnpm exec tsc --noEmit, pnpm run tsc-scripts, ESLint on all modified JavaScript/TypeScript files, Prettier, and git diff --check pass. An AST comparison confirms every import/export binding is preserved across all 44 modified source files.

pnpm run build --www --prod --release also passes. Executing the actual www development and production modules verifies all 58 barrel exports retain their identities, cross-entry signals batching emits [0, 2], and a rich-text editor builds and disposes successfully. Walking dependencies from the React composer and rich-text editor excludes the extension barrel, AutoFocus and HMR in both variants.

Flow, browser and E2E suites were not rerun for this import-only source refactor.
@vercel

vercel Bot commented Sep 11, 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 12, 2026 4:35am UTC
lexical-playground Ready Ready Preview Sep 12, 2026 4:35am 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 Sep 11, 2026
@etrepum etrepum changed the title Codex/9155 subpath enforcement [lexical-extension] Refactor: publish @lexical/extension as subpath exports with compiler enforcement Sep 11, 2026
@etrepum etrepum changed the title [lexical-extension] Refactor: publish @lexical/extension as subpath exports with compiler enforcement [lexical-extension][*] Refactor: publish @lexical/extension as subpath exports with compiler enforcement Sep 11, 2026
@etrepum etrepum changed the title [lexical-extension][*] Refactor: publish @lexical/extension as subpath exports with compiler enforcement [lexical-extension][*] Refactor: publish @lexical/extension as subpath exports with compiler+lint enforcement Sep 11, 2026
…n foreground checks

## Description

The new extension Flow declarations produce 27 errors: config interfaces do not satisfy the object config bound, readonly properties were declared writeonly, and ExtensionRep imports the signal value as a type.

Use object type aliases for extension configs, readonly properties for state IDs, signals and HMR data, and remove the unused signal import. Run Flow's full-check command from the existing check script so CI checks the entire project in the foreground without requiring a background server.

## Test plan

### Before

```
$ pnpm run ci-check
Error: Failed to start server. exited prematurely with code 98.
Could not start Flow server!
ERROR: "flow" exited with 78.

$ node_modules/.bin/flow full-check --show-all-errors
Found 27 errors
```

### After

```
$ pnpm run ci-check
$ npm-run-all --parallel tsc tsc-scripts tsc-extension tsc-website flow prettier lint
$ pnpm --filter @lexical/devtools run compile
$ pnpm --filter @lexical/website run tsc
$ tsc -p ./tsconfig.scripts.json
$ node ./scripts/check-flow-types.mjs
$ prettier --list-different .
$ tsc
Running Flow...
$ eslint ./
$ tsc --noEmit
$ tsc -p tsconfig.json
Found 0 errors

$ pnpm exec vitest run --project scripts-unit scripts/__tests__/unit/extensionFlowSubpaths.test.ts
 Test Files  1 passed (1)
      Tests  33 passed (33)
```

ci-check exits 0. Browser and E2E suites were not rerun for these declaration and tooling changes.
…cal-website] Bug Fix: enforce subpath imports and preserve public API boundaries

## Description

The subpath pass misses runtime imports inside exported declarations, drops enum exports when expanding stars, rejects decorated TypeScript and unmappable default imports in non-strict mode, and reuses stale consumer transforms after a barrel changes.

Traverse exported declaration bodies, preserve runtime enums and namespaces while excluding erased declarations, parse both standard and legacy decorators, and preserve unsupported default imports unless strict mode is enabled. Invalidate cached transforms when the export mapping changes, with a regression test that exercises a real Rollup watch rebuild.

Keep ExtensionRep, deepThemeMergeInPlace and editorStateFamily under src/internal rather than publishing new subpaths. Regenerate export and path metadata, remove their public Flow stubs, and retain the existing barrel API and shared runtime identities. Correct the CompiledKeyboardShortcuts Flow declaration to export only its type, with a real Flow consumer test for the factory and the invalid value import.

Use consistent compiler Flow re-export paths and document the foreground Flow check cost. Fix the existing tsconfig generator to parse JSONC and replace only the paths object, preserving hand-maintained comments and settings.

## Test plan

### Before

With the new regression tests against the original implementations:

```
$ pnpm exec vitest run --project unit packages/lexical-compiler/src/__tests__/unit/subpathImports.test.ts
 Test Files  1 failed (1)
      Tests  17 failed | 19 passed (36)

$ pnpm exec vitest run --project scripts-unit scripts/__tests__/unit/keyboardShortcutFlow.test.ts
 Test Files  1 failed (1)
      Tests  1 failed | 1 passed (2)
```

The watch rebuild returns the old value after retargeting the barrel. Flow accepts importing and constructing the nonexistent CompiledKeyboardShortcuts value.

### After

```
$ pnpm run ci-check
Found 0 errors

$ pnpm exec vitest run --project unit --project scripts-unit packages/lexical-compiler packages/lexical-extension scripts/__tests__/unit/extensionFlowSubpaths.test.ts scripts/__tests__/unit/keyboardShortcutFlow.test.ts
 Test Files  19 passed (19)
      Tests  321 passed (321)

$ pnpm exec vitest run --project unit packages/lexical-compiler/src/__tests__/unit/subpathImports.test.ts
 Test Files  1 passed (1)
      Tests  36 passed (36)

$ pnpm run build --prod --release
$ pnpm exec vitest run --config /tmp/lexical-review-integration.config.mjs
 Test Files  2 passed (2)
      Tests  274 passed (274)

$ pnpm run build --www --prod --release
$ node /tmp/verify-review-www.mjs
dev: 58 export identities preserved; signals [0, 2]; rich-text editor works; React/rich-text graphs exclude the barrel; private helpers have no entries
prod: 58 export identities preserved; signals [0, 2]; rich-text editor works; React/rich-text graphs exclude the barrel; private helpers have no entries
```

ci-check exits 0. The isolated integration config runs subpath-imports.test.mjs and tree-shaking.test.mjs against the freshly built npm artifacts, bypassing only global setup that rebuilds and packs every package. Flow accepts the type and factory and rejects the missing runtime export. JSONC path regeneration is idempotent and preserves comments. Browser and E2E suites were not rerun.
…n subpath imports

## Description

The subpath import pass rejects valid CommonJS dependencies, retains stale mappings in Vite development servers, expands implicit type exports into runtime exports, misses static template imports and TypeScript import assignments in strict mode, and generates invalid code for quoted export names.

Preserve CommonJS wrapper syntax while still inspecting imports. Resolve local export bindings before expanding stars, retaining merged runtime declarations and hoisted variables. Recognize static templates and import assignments, and preserve quoted names through barrel resolution and code generation. Keep the original parser diagnostic if the legacy-decorator retry also fails.

Refresh mappings on Vite hot updates, watch newly discovered mapping files, and invalidate cached consumers even when rewriting removed their barrel dependency. Keep the existing Rollup watch hook and update the structural TypeScript/Flow plugin interfaces and README.

## Test plan

### Before

New regression cases against efe5cfd:

```
$ pnpm exec vitest run --project unit packages/lexical-compiler/src/__tests__/unit/subpathImports.test.ts
 Test Files  1 failed (1)
      Tests  14 failed | 36 passed (50)
```

Failures reproduce all five findings, including a real Vite server update and complete CommonJS/quoted-export bundles.

### After

```
$ pnpm exec vitest run --project unit packages/lexical-compiler
 Test Files  5 passed (5)
      Tests  143 passed (143)

$ pnpm run ci-check
Found 0 errors

$ pnpm run build --prod --release
$ pnpm exec vitest run --config /tmp/lexical-review-integration.config.mjs
 Test Files  2 passed (2)
      Tests  274 passed (274)
```

ci-check exits 0, including TypeScript, Flow, ESLint and Prettier. The Vite regression verifies cached consumers after a barrel edit, a package export-map edit, and an edit to the newly watched barrel. The isolated integration configuration runs subpath-imports.test.mjs and tree-shaking.test.mjs against freshly built npm artifacts, bypassing only global setup that rebuilds and packs every package.

```
$ pnpm run build --www --prod --release
$ node /tmp/verify-review-www.mjs
dev: 58 export identities preserved; signals [0, 2]; rich-text editor works; React/rich-text graphs exclude the barrel; private helpers have no entries
prod: 58 export identities preserved; signals [0, 2]; rich-text editor works; React/rich-text graphs exclude the barrel; private helpers have no entries
```

Browser and E2E suites were not rerun.
…tion

## Description

subpathImports parses every JavaScript module before downstream transforms but only enabled JSX for .jsx and .tsx. Projects that compile JSX in .js files therefore failed even when a module had no Lexical imports.

Enable JSX for JavaScript extensions while keeping it disabled in .ts, .mts and .cts, preserving TypeScript generics and angle-bracket assertions. Add real Rollup bundles for .js, .mjs, .cjs and .jsx, both with and without barrel imports, with tree-shaking disabled.

Also wrap the two long README prose passages and prune tracked IDs that are no longer in Vite's module graph when the mapping changes.

## Test plan

### Before

New regression tests against 849955e:

```
$ pnpm exec vitest run --project unit packages/lexical-compiler/src/__tests__/unit/subpathImports.test.ts
 Test Files  1 failed (1)
      Tests  3 failed | 54 passed (57)
```

The .js, .mjs and .cjs JSX bundles fail at parsing; the .jsx and non-JSX TypeScript controls pass.

### After

```
$ pnpm run ci-check
Found 0 errors

$ pnpm exec vitest run --project unit packages/lexical-compiler
 Test Files  5 passed (5)
      Tests  150 passed (150)
```

ci-check exits 0, including TypeScript, Flow, ESLint and Prettier. After the README and stale-ID cleanups, focused Prettier, ESLint and all compiler tests were rerun and pass. The Rollup fixtures export "span" after the downstream JSX transform and continue to exclude unrelated barrel dependencies. Browser/E2E suites and full package builds were not rerun for this parser-option change.
## Description

subpathImports previously required a compiler release whenever a consumer used syntax outside the default Babel parser plugins. Add a parserPlugins option matching pureAnnotations, with TypeScript and Flow declarations and README documentation. Accept both plugin names and [name, options] tuples, appended to filename-aware defaults.

Share parserPluginsFor between the passes while preserving their existing base plugins and pass-specific parsing behavior. Apply the extra plugins to consumers, barrel inspection and star-exported source discovery, including the legacy decorator fallback. Parsing does not replace downstream syntax transforms.

## Test plan

### Before

New regression tests against 64cfbe4:

```
$ pnpm exec vitest run --project unit packages/lexical-compiler/src/__tests__/unit/subpathImports.test.ts
 Test Files  1 failed (1)
      Tests  4 failed | 57 passed (61)
```

### After

```
$ pnpm exec vitest run --project unit packages/lexical-compiler
 Test Files  5 passed (5)
      Tests  154 passed (154)

$ pnpm run ci-check
Found 0 errors
```

ci-check exits 0, including TypeScript, Flow, ESLint and Prettier. Regressions cover doExpressions in consumers and package sources, readonly plugin arrays, TypeScript defaults remaining enabled, and a pipelineOperator options tuple surviving the legacy decorator retry. Existing JavaScript/JSX, CommonJS, TypeScript and watch tests pass for both compiler passes.

```
$ pnpm run build --prod --release
$ pnpm exec vitest run --config /tmp/lexical-review-integration.config.mjs
 Test Files  2 passed (2)
      Tests  274 passed (274)
```

The isolated integration configuration runs subpath-imports.test.mjs and tree-shaking.test.mjs against the freshly built npm artifacts, bypassing only global setup that rebuilds and packs every package. Browser/E2E suites and www builds were not rerun for this change.
## Description

Flow-annotated JavaScript consumers could not use parserPlugins with TypeScript packages because the extra Flow plugins were also applied while inspecting the package's .ts sources. Babel rejects enabling both languages for the same file.

Filter flow and flowComments from extra plugins for TypeScript filenames, recognizing both plugin names and [name, options] tuples. The shared helper applies this rule to both compiler passes without mutating the configured extras. Document the filename-based language selection.

Replace the standard decorator plugin by name during the legacy retry rather than relying on its array position. Leave the stateless parser helper private.

## Test plan

### Before

New regressions against a739aa9:

```
$ pnpm exec vitest run --project unit packages/lexical-compiler/src/__tests__/unit/subpathImports.test.ts
 Test Files  1 failed (1)
      Tests  3 failed | 61 passed (64)
```

### After

```
$ pnpm exec vitest run --project unit packages/lexical-compiler
 Test Files  5 passed (5)
      Tests  157 passed (157)

$ pnpm run ci-check
Found 0 errors
```

The new cases cover flow, a flow options tuple, and flow plus flowComments, with both Flow JavaScript consumers and generic-arrow TypeScript consumers of a TypeScript package. Existing decorator fallback and custom parser-plugin tests also pass.

A direct check against the real @lexical/extension source package with parserPlugins: ['flow'] produces:

```
import {namedSignals} from "@lexical/extension/namedSignals"; export const factory: mixed = namedSignals;
```

Browser/E2E suites and full package builds were not rerun for this parser-selection change.
…writes

## Description

Treat Flow import typeof declarations and specifiers as type-only, including namespace, default, and mixed imports. Preserve erased imports without strict-mode errors or new runtime dependencies, and exclude typeof bindings from runtime export classification.

## Test plan

### Before

The subpathImports regression suite reported 6 failed and 64 passed tests. Flow typeof imports either failed strict validation or became runtime dependencies after Flow stripping.

### After

All 163 compiler tests pass, including six new regressions using @babel/preset-flow to verify erased imports and mixed runtime imports. pnpm run ci-check passes, including Flow (Found 0 errors), TypeScript, ESLint, and Prettier. npm/www builds and browser E2E tests were not rerun for this change.
@etrepum
etrepum added this pull request to the merge queue Sep 12, 2026
Merged via the queue into facebook:main with commit 84eabc3 Sep 12, 2026
46 checks passed
@etrepum
etrepum deleted the codex/9155-subpath-enforcement branch September 12, 2026 15:16
@etrepum etrepum mentioned this pull request Sep 17, 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.

[@lexical/extension] Single entry point bundles all 20 extensions + signals runtime, so building any editor costs ~13KB gz

2 participants