Skip to content

[*] Refactor: Publish packages from their root directory#8554

Merged
etrepum merged 34 commits into
facebook:mainfrom
etrepum:claude/kind-johnson-RexEG
May 27, 2026
Merged

[*] Refactor: Publish packages from their root directory#8554
etrepum merged 34 commits into
facebook:mainfrom
etrepum:claude/kind-johnson-RexEG

Conversation

@etrepum

@etrepum etrepum commented May 24, 2026

Copy link
Copy Markdown
Collaborator

Description

Make each public package directory itself the publishable npm package so pnpm link and file: protocol consumers can point at packages/ directly. Build artifacts now live entirely under packages//dist and package.json exports/main/module/types reference that path, removing the separate npm/ tree the release used to construct.

  • updateVersion.mjs rewrites public packages with ./dist/... paths, adds a files whitelist, and copies the monorepo LICENSE into each package so a checkout is publishable without extra prep.

  • prepare-release.mjs becomes a publish-time guard: it verifies every path the exports map references actually exists on disk (catching the "publish a dev build" footgun) and that README/LICENSE are present.

  • release.mjs publishes from packages/

    directly; pnpm rewrites workspace:* automatically.

  • Integration test setup packs from the package root.

  • viteModuleResolution stops re-prefixing dist/ since the exports map already includes it.

  • Dev/prod/release builds all emit the same on-disk shape: .dev or .prod variants for each entry point plus matching fork modules (Foo.mjs, Foo.js, Foo.node.mjs). The fork unconditionally re-exports the available variant in dev-only or prod-only builds and uses the runtime NODE_ENV check only when both were built. No fake variant is ever emitted, so the publish guard catches dev-only builds that lack the prod files their exports map promises.

  • TypeScript declarations and Flow stubs are copied into dist on every npm build, not just release, so linked consumers get types out of the box.

  • tsconfig excludes the internal www <Name>.js shims at the package root so tsc doesn't follow their require() into the compiled dist.

Test plan

New integration tests to ensure that pnpm link works

Here's a demo repo to show that lexical can be used as a vendored typescript source directly with minimal configuration required https://github.com/etrepum/vendored-lexical-demo (via subtree of this PR's branch)

Make each public package directory itself the publishable npm package so
`pnpm link` and `file:` protocol consumers can point at packages/<name>
directly. Build artifacts now live entirely under packages/<name>/dist
and package.json `exports`/`main`/`module`/`types` reference that path,
removing the separate `npm/` tree the release used to construct.

Phase 1 (layout):
- updateVersion.mjs rewrites public packages with `./dist/...` paths,
  adds a `files` whitelist, and copies the monorepo LICENSE into each
  package so a checkout is publishable without extra prep.
- prepare-release.mjs becomes a publish-time guard: it verifies every
  path the exports map references actually exists on disk (catching the
  "publish a dev build" footgun) and that README/LICENSE are present.
- release.mjs publishes from packages/<dir> directly; pnpm rewrites
  `workspace:*` automatically.
- Integration test setup packs from the package root.
- viteModuleResolution stops re-prefixing `dist/` since the exports map
  already includes it.

Phase 2 (build outputs):
- Dev/prod/release builds all emit the same on-disk shape: `.dev` or
  `.prod` variants for each entry point plus matching fork modules
  (`Foo.mjs`, `Foo.js`, `Foo.node.mjs`). The fork unconditionally
  re-exports the available variant in dev-only or prod-only builds and
  uses the runtime NODE_ENV check only when both were built. No fake
  variant is ever emitted, so the publish guard catches dev-only builds
  that lack the prod files their exports map promises.
- TypeScript declarations and Flow stubs are copied into dist on every
  npm build, not just release, so linked consumers get types out of the
  box.
- tsconfig excludes the internal www `<Name>.js` shims at the package
  root so tsc doesn't follow their require() into the compiled dist.
@vercel

vercel Bot commented May 24, 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, Comment May 27, 2026 9:16pm
lexical-playground Ready Ready Preview, Comment May 27, 2026 9:16pm

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 May 24, 2026
@etrepum etrepum added the extended-tests Run extended e2e tests on a PR label May 24, 2026
…heckout fixture

Phase 3 of the package-root-publish reorg: expose each package's
TypeScript source under a `source` export condition so a bundler
configured with `resolve.conditions: ['source', ...]` can consume
Lexical without building dist artifacts first. The typical use case is
`pnpm link` from a local Lexical checkout, where edits to the source
files are picked up by the downstream bundler on the next request.

- updateVersion.mjs:
  - exportEntry emits a `source` entry (sibling to import/require)
    pointing at ./src/<entry>.{ts,tsx,js}. For multi-entry packages the
    source path is derived from the src filename iterated when building
    the exports map; for main-style packages it falls back to
    src/index.{ts,tsx,js}.
  - withBrowser keeps `source` first so it wins over `browser` when both
    conditions are enabled by the consumer.
  - The public `files` whitelist gains `src` plus negations for
    `__tests__`, `__bench__`, and `*.{test,bench}.{ts,tsx}` so consumers
    get source without 100+ KB of test ballast per package.
  - updateDependencies preserves `link:`/`file:`/`portal:` dep pins so
    fixtures and examples can keep pointing at the local checkout
    across update-version runs.

- New integration fixture
  scripts/__tests__/integration/fixtures/lexical-link-source-mode/:
  consumes `lexical` via `link:` to packages/lexical, configures Vite
  with `resolve.conditions: ['source', ...]` and a `shared/*` alias
  back into packages/shared/src, and asserts the resulting bundle
  inlines the Lexical class (proving source was compiled rather than
  the prebuilt artifact).

- Test harness (scripts/__tests__/integration/utils.mjs) gains
  describeLinkedFixture, used by prepare-release.test.mjs whenever a
  fixture's package.json has a `link:` dep. It wipes
  node_modules/lockfile and runs `pnpm install --ignore-workspace`
  before building so each run hits the linked package freshly.

- Docs: new maintainers-guide-link.md walks through both the built-
  artifact and source-mode workflows, including the `shared/` aliasing
  requirement and bundler caveats. The existing prepare-release section
  is rewritten to reflect the new publish-from-package-root flow.
…rnal so source mode needs no consumer config

Source-mode consumption (the `source` export condition over a linked
checkout) previously required the consumer to alias the private `shared/*`
module space, define `__DEV__`, and tolerate `invariant`/version helpers
that only worked after the build transform. This removes the `shared`
caveat at its root.

shared is dismantled and its modules move to where they belong:
- Cross-package runtime utilities (invariant, devInvariant, canUseDOM,
  environment, warnOnlyOnce, simpleDiffWithCursor, caretFromPoint, the four
  format* helpers, plus a new version module) become `@lexical/internal` —
  a real, published-but-internal leaf package. It resolves through normal
  package resolution (so source mode needs no alias) but the monorepo build
  keeps inlining it into every other package via a rollup alias, so npm and
  www bundle shapes are byte-identical to before and no new runtime
  dependency is actually executed. update-version adds it as a dependency
  wherever source imports it.
- React-only helpers (useLayoutEffect, reactPatches) move into
  @lexical/react's existing src/shared/ as relative internal modules.
- React test helpers (react-test-utils) move to a new private
  @lexical/test-utils package; the invariant/devInvariant/warnOnlyOnce
  vitest auto-mocks move with their modules.
- The monorepo vite tooling (lexicalMonorepoPlugin, viteModuleResolution)
  moves to scripts/vite/ where build tooling lives; all importers updated.

invariant/devInvariant now have real in-situ implementations (interpolate
`%s` and throw/warn) instead of throwing "meant to be replaced at compile
time". The transformErrorMessages plugin still rewrites call sites in built
output (now importing `@lexical/internal/format*`), so the runtime impl only
executes in untransformed/source mode.

LEXICAL_VERSION becomes a generated `@lexical/internal/version` module:
`process.env.LEXICAL_VERSION ?? '<ver>+source'`. The build replaces the env
ref (minification drops the fallback) so artifacts are unchanged; source
mode gets the literal, regenerated by update-version alongside the bump.

The lexical-link-source-mode integration fixture drops its `shared` alias
and realpathSync walk entirely — its vite config is now just the `source`
condition plus `define: {__DEV__}` — and still verifies the bundle is
compiled from source. tsconfig/flow/viteModuleResolution updated; docs in
maintainers-guide-link.md rewritten to show the zero-alias setup.
… so source mode needs zero config

`__DEV__` is a bare global that only exists after the build's `replace`
step, so a source-mode consumer had to define it or hit a runtime
ReferenceError. That was the last remaining consumer-side requirement for
the `source` export condition. Since Meta consumes Lexical's built www
artifacts (where the flag is already baked) rather than the TS source, we
can drop the `__DEV__` convention.

- Source: every dev guard (`if (__DEV__)`, `__DEV__ && …`, `__DEV__ ? …`)
  becomes `process.env.NODE_ENV !== 'production'` — 34 occurrences across
  14 files. No `!__DEV__` existed, so precedence is unaffected. This is
  also what the fork modules already branch on, so it's consistent rather
  than introducing a second flag.
- build.mjs: the `replace` plugin now bakes `process.env.NODE_ENV`
  (`"production"`/`"development"`) per dev/prod variant instead of
  `__DEV__`. terser folds `"production" !== 'production'` so prod dev-code
  is still DCE'd (verified: dev bundle 570K w/ assertions, prod 146K
  stripped). Fork modules are generated post-rollup and keep their runtime
  NODE_ENV check.
- Dropped the `__DEV__` declarations from libdefs (globals.d.ts,
  environment.js) so tsc flags any straggler, and the now-unused
  `__DEV__: true` vitest defines. The playground validation server sets
  `process.env.NODE_ENV = 'development'` instead of `global.__DEV__`.
- The source-mode fixture and maintainers-guide-link.md drop the
  `define: {__DEV__}` — source mode is now just the `source` resolve
  condition, nothing else (every mainstream bundler substitutes
  process.env.NODE_ENV by default).
With `shared` replaced by the public `@lexical/internal` package, the only
remaining private package vite-built code could reference is
`@lexical/test-utils`, which is test-only and resolved by vitest via
tsconfig paths — never imported by the playground/examples/devtools that
use this resolver. Remove the now-dead `getPrivateModuleEntries` branches
so both source and dist resolution only alias public packages.

Verified: example builds in production (dist) mode and the dev server
resolves `@lexical/*` sources (source mode) with no errors.
…variant mocks

Two follow-ups to the @lexical/internal extraction:

- The auto-generated `flow/*.js.flow` stubs were empty boilerplate; fill
  each with the real type declarations matching the TypeScript source
  (CAN_USE_DOM, environment booleans, invariant/devInvariant, the format*
  helpers, caretFromPoint, simpleDiffWithCursor, warnOnlyOnce,
  LEXICAL_VERSION).

- Drop the vitest auto-mocks for `invariant` and `devInvariant` (and their
  vi.mock() calls). They existed only because the old runtime impls threw
  "meant to be replaced at compile time"; now that the shipped impls
  interpolate `%s` and throw on their own, tests exercise the real code.
  The `warnOnlyOnce` mock stays — it deliberately warns every call so
  module-level `warnOnlyOnce` closures don't leak dedupe state across
  tests (LexicalNestedComposer.test.tsx asserts per-test warnings).
range-selection.ts imported TableSelection with value syntax but only
uses it in a type position (the RangeSelection | TableSelection parameter
of $getSelectionStyleValueForProperty). babel already elided it from the
build, so this is a no-op at runtime, but `import type` states the intent
correctly and keeps it out of the value/dependency graph.
claude added 3 commits May 25, 2026 16:46
…lection

The function only needs anchor/focus to trim boundary text nodes, which is
RangeSelection-specific; move that into the existing $isRangeSelection
guard and widen the parameter to BaseSelection. This drops the lone
@lexical/table type import from the package (it was the only undeclared
cross-package import in any published package) and is a backwards-compatible
widening for callers. Other selection types now style every node they
contain rather than running the range-only edge trim. Flow stub updated to
match.
The playground imported @lexical/code-core, extension, headless, history,
html, markdown, text and yjs without declaring them — it resolved only via
the monorepo's tsconfig path aliases. Add them to dependencies so the
package is self-consistent and resolves through normal package resolution.
… scope path aliases to test + website

The root tsconfig.json carried a ~290-line generated `paths` block aliasing
every package to its src. Now that each package declares its real deps and
exposes a `source` export condition, the root and build configs resolve via
`customConditions: ["source"]` instead, shrinking tsconfig.json to ~30
hand-maintained lines and making `pnpm tsc` validate the actual dependency
graph (it would now flag an under-declared dep).

The package aliases are still needed by two consumers and are scoped to them:
- tsconfig.test.json: unit tests import sibling packages without declaring
  them (to avoid circular package.json deps) and use deep
  `*/src/__tests__/utils` paths; a new `tsc-test` script type-checks it and
  runs in ci-check. Root tsc now excludes __tests__/__bench__.
- packages/lexical-website/tsconfig.json: type-checks the out-of-workspace
  @examples sources, which only resolve consistently through the alias.

update-tsconfig now generates those two configs (+ devtools) instead of the
root configs. vitest builds its resolve.alias from tsconfig.test.json's
paths (Vite's native resolve.tsconfigPaths only reads the root, which is now
lean). The validation script points ts-node at tsconfig.test.json so it
keeps resolving to src.
The playground validation HTTP server (src/server/validation.ts, the
`validation` npm script, and the tsconfig `ts-node`/tsconfig-paths block)
was dead: nothing imports it, no script or CI invokes it, and
`tsconfig-paths` — required by its ts-node register hook — was never even in
the lockfile, so it couldn't run. Remove the server, the script, the
ts-node tsconfig block, and the now-unused ts-node devDependency rather than
carry a broken path-alias consumer into the new resolution setup.
Track upstream main (10 commits) on this branch. The merge was textually clean;
the only semantic fixup was main's new `shared/environment` import in
LexicalUtils.test.ts, converted to import IS_APPLE from `lexical` (this branch
replaced the `shared` alias with @lexical/internal + lexical-core re-exports).
All other shared/* usages from main auto-merged to the migrated specifiers.
…browser bundles

Consuming the version module via the `source` export condition in a browser
bundle that doesn't define/replace process.env would throw `ReferenceError:
process is not defined`. Read it inside a try/catch so the access can't throw,
falling back to the generated literal. The exact `process.env.LEXICAL_VERSION`
member expression is preserved, so our Rollup build (and a consumer's bundler
`define`) still statically replace it — verified the built dist bakes a clean
literal with no process reference. Optional chaining wouldn't work: it neither
prevents the ReferenceError (undeclared global) nor matches the replacement.
…ude/kind-johnson-RexEG

Track upstream main. Reconciled the new invariant surface from facebook#8519 with this
branch's shared -> @lexical/internal migration:
- LexicalElementNode.ts: resolved the invariant import conflict in favor of
  @lexical/internal/invariant, and dropped the now-unused environment import
  (IS_APPLE_WEBKIT/IS_IOS/IS_SAFARI) — facebook#8519 moved that logic into the new
  LexicalDOMSlot.ts.
- LexicalDOMSlot.ts (new): converted shared/invariant -> @lexical/internal/invariant
  and shared/environment -> ./environment.

Verified: tsc, tsc-test, tsc-scripts, flow, lint, prettier clean; test-unit
3280 pass (incl. facebook#8519's new tests).
…acebook#8569) into claude/kind-johnson-RexEG

Resolved conflicts and reconciled new upstream surface with this branch's
shared -> @lexical/internal migration and per-module __DEV__ convention:
- clipboard.ts: kept @lexical/internal/invariant; dropped $generateNodesFromDOM
  (no longer used after facebook#8528 introduced the DOMImportExtension pipeline).
- code-core / rich-text package.json + pnpm-lock: took main's dependency set
  (adds @lexical/html) and regenerated via update-version + pnpm install, which
  re-applies the @lexical/internal deps and files allowlist.
- Converted new shared/invariant imports (facebook#8528 parseCss.ts/sel.ts, facebook#8569 test)
  to @lexical/internal/invariant.
- Added per-module `const __DEV__` to facebook#8528's compileImportRules.ts / runImport.ts
  (this branch removed the global __DEV__).

Verified: tsc, tsc-test, tsc-scripts, flow, lint, prettier clean; test-unit
3373 pass.
The dom-import (facebook#8528) and node-state-style dev-example vite configs imported
lexicalMonorepoPlugin from the old ../../packages/shared/ path, which this
branch moved to scripts/vite/ (and removed packages/shared). This broke the
integration test's dev-example build. Repointed both to
../../scripts/vite/lexicalMonorepoPlugin, matching every examples/* config.
Verified both dev-examples build against a fresh prod dist.
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.

3 participants