[*] Refactor: Publish packages from their root directory#8554
Draft
etrepum wants to merge 25 commits into
Draft
Conversation
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.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
…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.
…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.
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).
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.
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).
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
Make each public package directory itself the publishable npm package so
pnpm linkandfile:protocol consumers can point at packages/ directly. Build artifacts now live entirely under packages//dist and package.jsonexports/main/module/typesreference that path, removing the separatenpm/tree the release used to construct.updateVersion.mjs rewrites public packages with
./dist/...paths, adds afileswhitelist, 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 rewritesworkspace:*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:
.devor.prodvariants 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>.jsshims 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