Skip to content

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

Draft
etrepum wants to merge 25 commits into
facebook:mainfrom
etrepum:claude/kind-johnson-RexEG
Draft

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

Conversation

@etrepum
Copy link
Copy Markdown
Collaborator

@etrepum etrepum commented May 24, 2026

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

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
Copy link
Copy Markdown

vercel Bot commented May 24, 2026

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

Project Deployment Actions Updated (UTC)
lexical Ready Ready Preview, Comment May 25, 2026 7:06pm
lexical-playground Ready Ready Preview, Comment May 25, 2026 7:06pm

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.
claude added 3 commits May 25, 2026 17:09
Drop the facebook#5918 underscore-aliasing hotfix (import as FOO_, re-export as
`export const FOO: boolean = FOO_`). That existed so the generated d.ts
wouldn't reference the then-private `shared` module. @lexical/internal is a
published package now, so import the constants normally (CAN_USE_DOM and
IS_FIREFOX are also used internally) and re-export them. The emitted d.ts
references @lexical/internal, which the private-module validator allows.
@lexical/test-utils only provided an `act` shim that fell back to
react-dom/test-utils on pre-19 React. The monorepo builds against React 19,
which exports `act` from `react` directly, so the shim is unnecessary.
Import `act` from `react` in the 25 unit-test files that used it (164 call
sites) and delete the package. update-tsconfig no longer needs its
private-package branch (no private package's modules are imported by name),
and tsconfig.test.json / the vitest aliases regenerate without it.
…_ENV

Replace the inline `process.env.NODE_ENV !== 'production'` dev guards with a
single `const __DEV__ = process.env.NODE_ENV !== 'production'` per module
plus `if (__DEV__)` usage. This restores the readable __DEV__ idiom while
keeping source mode alias-free (each module derives the flag from the
bundler-substituted process.env.NODE_ENV rather than a bare global). The
production build still dead-code-eliminates the dev branches: the replace
plugin folds the const to `false` and terser drops the guarded blocks
(verified — prod bundle unchanged at 146KB with zero __DEV__ references).
claude added 2 commits May 25, 2026 17:52
Shrink @lexical/internal to just the build/transform/version internals
(invariant, devInvariant, format* ×4, version, warnOnlyOnce) by moving the
rest to where they belong:

- environment + canUseDOM -> lexical core. Core is the heaviest user and
  they were already public via @lexical/utils, so export them from `lexical`
  (CAN_USE_DOM, IS_APPLE, IS_FIREFOX, …); @lexical/utils now re-exports them
  from `lexical`. Other consumers (react, plain-text, rich-text, table,
  devtools) import from `lexical`.
- simpleDiffWithCursor -> @lexical/yjs (its only consumer) as an internal,
  unexported module.
- caretFromPoint -> @lexical/clipboard, marked @internal; rich-text (which
  already depends on clipboard) imports it from there.

update-version now reconciles the @lexical/internal dependency (adds it
where source imports it, removes it where it no longer does), so packages
that only used the moved modules drop the dep. Flow stubs and generated
tsconfig paths updated accordingly.
… out of builds

@lexical/internal is meant to be fully inlined into every other package's
build (it's published only so the `source` export condition resolves). Add
an integration test that greps every public package's dist (JS/MJS/.d.ts)
and fails if any references the `@lexical/internal` specifier, guarding
against it accidentally leaking out as an external dependency.
The @lexical/internal extraction moved the environment constants into the
lexical core (`./environment`) and caretFromPoint into @lexical/clipboard
(`./caretFromPoint`). Three unit tests still mocked the old, now-nonexistent
`@lexical/internal/*` specifiers, so `vi.mock` silently no-opped and the
tests ran against real jsdom values (green locally, red in CI).

Make internal source modules addressable by a stable specifier: generate
`<pkg>/src/*` deep-source aliases in tsconfig.test.json and translate those
wildcards into capture-group Vite aliases. Tests can now mock the exact
module the source imports relatively:
- LexicalIosKoreanIME: `vi.mock('lexical/src/environment', ...)`
- HandleTextDrop: `vi.mock('@lexical/clipboard/src/caretFromPoint', ...)`
  (named export, was a default)

Also fix useMenuAnchorRef, whose full `vi.mock('lexical')` replacement
dropped every other export (e.g. defineExtension, now pulled in
transitively); switch it to an importOriginal partial mock that only
overrides CAN_USE_DOM.
claude added 4 commits May 25, 2026 18:57
CAN_USE_DOM is the primitive the rest of environment.ts derives from and was
only used in-package (environment, LexicalUtils, the public re-export). The
separate file was inherited from the old shared/ layout with no reason to keep
it apart, so fold the constant into environment.ts. The iOS IME test mocks the
environment module, so it now also supplies CAN_USE_DOM.
…tyleValueForProperty

The start/end boundary node is excluded only when the selection touches it
exactly at its edge (start at the node's end, or end at offset 0). Evaluate
that offset condition once before the loop and set startNode/endNode only when
the boundary should be skipped, leaving the loop to do plain identity checks.
The first render was wrapped in an unawaited act() followed by a manual
microtask flush. Awaiting act() flushes the render's effects directly, so the
Promise.resolve() workaround is no longer needed.
- Update the @lexical/internal README: it now holds invariant/devInvariant,
  error/warning message formatting, warnOnlyOnce, and LEXICAL_VERSION — the
  DOM/environment helpers moved out to lexical core.
- Restore `__DEV__` references in comments that were reworded to "development";
  __DEV__ is still the mechanism (a per-module const).
- Drop the obsolete @lexical/utils comment about the removed facebook#5918 re-export
  workaround (the constants come from lexical now, not @lexical/internal).
claude added 2 commits May 25, 2026 19:02
codes.json is release-managed (entries are assigned by the release tooling),
so it should not change in a feature PR. A prod build on this branch had
auto-assigned codes 343-354 for pre-existing reconciler messages that main
leaves uncoded; drop them so the registry matches main again.
The source-mode fixture only imported `lexical`, so it proved source
resolution for the core package alone. Import `@lexical/rich-text` and
`@lexical/extension` too (which transitively pull in @lexical/clipboard,
@lexical/selection, @lexical/utils and @lexical/internal) and assert each
linked package resolves to its monorepo source directory and that no package's
prebuilt dist artifact leaks into the bundle — confirming every package links
against every other in source mode.
Replace the two `continue` clauses with a single condition: a node is styled
when it is a text node and not the excluded boundary for its position
(startNode at the head, endNode elsewhere). node.is(undefined) is false, so
unset boundaries skip nothing.
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.

2 participants