diff --git a/.gitignore b/.gitignore index 511c66bff7e..8c4f427327d 100644 --- a/.gitignore +++ b/.gitignore @@ -47,3 +47,4 @@ npm-debug.log* # locally as part of its build step — they don't need to be committed. examples/*/pnpm-lock.yaml scripts/__tests__/integration/fixtures/*/pnpm-lock.yaml +*.tsbuildinfo diff --git a/examples/extension-sveltekit-ssr-hydration/package.json b/examples/extension-sveltekit-ssr-hydration/package.json index 4e28a51ec94..476c7545200 100644 --- a/examples/extension-sveltekit-ssr-hydration/package.json +++ b/examples/extension-sveltekit-ssr-hydration/package.json @@ -28,7 +28,7 @@ "@lexical/table": "0.44.0", "@lexical/tailwind": "0.44.0", "@lexical/utils": "0.44.0", - "@playwright/test": "^1.58.2", + "@playwright/test": "^1.60.0", "@sveltejs/adapter-auto": "^6.1.1", "@sveltejs/kit": "^2.53.3", "@sveltejs/vite-plugin-svelte": "^6.2.4", @@ -40,7 +40,7 @@ "eslint-plugin-svelte": "^3.15.0", "globals": "^16.5.0", "lexical": "0.44.0", - "playwright": "^1.58.2", + "playwright": "^1.60.0", "prettier": "^3.8.1", "prettier-plugin-svelte": "^3.5.0", "prettier-plugin-tailwindcss": "^0.7.2", diff --git a/examples/nextjs-code-shiki/package.json b/examples/nextjs-code-shiki/package.json index 21b3ad2b960..e1e98a8a3ff 100644 --- a/examples/nextjs-code-shiki/package.json +++ b/examples/nextjs-code-shiki/package.json @@ -22,7 +22,7 @@ "react-dom": "^19" }, "devDependencies": { - "@playwright/test": "^1.51.1", + "@playwright/test": "^1.60.0", "@types/node": "^20", "@types/react": "^19.2.14", "@types/react-dom": "^19.1.9", diff --git a/package.json b/package.json index bf49c0f4ef4..8825129d90a 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,6 @@ "flow-typed-install": "flow-typed install -s -o --ignoreDeps dev peer bundled", "tsc": "tsc", "tsc-scripts": "tsc -p ./tsconfig.scripts.json", - "tsc-test": "tsc -p ./tsconfig.test.json", "tsc-extension": "pnpm --filter @lexical/devtools run compile", "tsc-website": "pnpm --filter @lexical/website run tsc", "tsc-watch": "tsc -w", @@ -90,7 +89,7 @@ "lint": "eslint ./", "lint:fix": "eslint ./ --fix", "prettier": "prettier --list-different .", - "ci-check": "npm-run-all --parallel tsc tsc-scripts tsc-test tsc-extension tsc-website flow prettier lint", + "ci-check": "npm-run-all --parallel tsc tsc-scripts tsc-extension tsc-website flow prettier lint", "prettier:fix": "prettier --write .", "prepare-ci": "pnpm run build-playground-dev", "prepare-ci-prod": "pnpm run build-playground-prod", @@ -126,7 +125,7 @@ "@eslint/js": "^10.0.0", "@lexical/eslint-plugin": "workspace:*", "@lexical/eslint-plugin-internal": "workspace:*", - "@playwright/test": "^1.59.1", + "@playwright/test": "^1.60.0", "@prettier/sync": "^0.6.1", "@rollup/plugin-alias": "^6.0.0", "@rollup/plugin-babel": "^7.0.0", diff --git a/packages/lexical-code-shiki/src/CodeHighlighterShiki.ts b/packages/lexical-code-shiki/src/CodeHighlighterShiki.ts index a7ab6a8b329..b9d1096ecc7 100644 --- a/packages/lexical-code-shiki/src/CodeHighlighterShiki.ts +++ b/packages/lexical-code-shiki/src/CodeHighlighterShiki.ts @@ -157,7 +157,14 @@ function $codeNodeTransform( let inFlight = false; if (!isCodeThemeLoaded(theme)) { loadCodeTheme(theme, editor, nodeKey); - inFlight = true; + // Only the highlight path (a resolved language) consumes the theme. With + // no language the text is plainified, which needs no theme, so don't defer + // the split on a theme load that won't be used — otherwise a code block + // with `defaultLanguage: null` stays an unsplit TextNode until the theme + // happens to finish loading. + if (language) { + inFlight = true; + } } // dynamic import of languages diff --git a/packages/lexical-html/src/DOMRenderExtension.ts b/packages/lexical-html/src/DOMRenderExtension.ts index 0b72420a1c9..0e0ad9e10b1 100644 --- a/packages/lexical-html/src/DOMRenderExtension.ts +++ b/packages/lexical-html/src/DOMRenderExtension.ts @@ -6,12 +6,27 @@ * */ import type {DOMRenderConfig, DOMRenderExtensionOutput} from './types'; +import type {InitialEditorConfig} from 'lexical'; import {defineExtension, RootNode, shallowMergeConfig} from 'lexical'; import {compileDOMRenderConfigOverrides} from './compileDOMRenderConfigOverrides'; import {DOMRenderExtensionName} from './constants'; -import {contextFromPairs} from './ContextRecord'; +import { + createEditorContextRecord, + DOMRenderRuntimeImpl, + filterEditorInstalled, +} from './DOMRenderRuntime'; + +/** @internal The result returned from {@link DOMRenderExtension}'s `init`. */ +interface DOMRenderInitResult { + /** + * The `nodes` and base `dom` captured from the editor config before `dom` + * is overwritten with the compiled config — the only fields the runtime + * needs to recompile. + */ + initialEditorConfig: Pick; +} /** * @experimental @@ -24,12 +39,18 @@ export const DOMRenderExtension = defineExtension< DOMRenderConfig, typeof DOMRenderExtensionName, DOMRenderExtensionOutput, - void + DOMRenderInitResult >({ build(editor, config, state) { - return { - defaults: contextFromPairs(config.contextDefaults, undefined), - }; + const {initialEditorConfig} = state.getInitResult(); + const editorContext = createEditorContextRecord(config.contextDefaults); + const runtime = new DOMRenderRuntimeImpl( + editor, + initialEditorConfig, + config.overrides, + editorContext, + ); + return {defaults: editorContext, runtime}; }, config: { contextDefaults: [], @@ -49,7 +70,18 @@ export const DOMRenderExtension = defineExtension< ]), }, init(editorConfig, config) { - editorConfig.dom = compileDOMRenderConfigOverrides(editorConfig, config); + // Capture the user's base `dom` (before we overwrite it) and `nodes` so the + // runtime can recompile from scratch when overrides toggle. + const initialEditorConfig: DOMRenderInitResult['initialEditorConfig'] = { + dom: editorConfig.dom, + nodes: editorConfig.nodes, + }; + const editorContext = createEditorContextRecord(config.contextDefaults); + const installed = filterEditorInstalled(config.overrides, editorContext); + editorConfig.dom = compileDOMRenderConfigOverrides(editorConfig, { + overrides: installed, + }); + return {initialEditorConfig}; }, mergeConfig(config, partial) { const merged = shallowMergeConfig(config, partial); diff --git a/packages/lexical-html/src/DOMRenderRuntime.ts b/packages/lexical-html/src/DOMRenderRuntime.ts new file mode 100644 index 00000000000..3712cae5546 --- /dev/null +++ b/packages/lexical-html/src/DOMRenderRuntime.ts @@ -0,0 +1,265 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ +import type { + AnyDOMRenderMatch, + AnyRenderStateConfigPairOrUpdater, + ContextRecord, + DOMRenderRuntime, + RenderContextReader, + RenderStateConfig, +} from './types'; +import type { + EditorDOMRenderConfig, + InitialEditorConfig, + Klass, + LexicalEditor, + LexicalNode, +} from 'lexical'; + +import { + $fullReconcile, + $isLexicalNode, + DEFAULT_EDITOR_DOM_CONFIG, +} from 'lexical'; + +import {compileDOMRenderConfigOverrides} from './compileDOMRenderConfigOverrides'; +import {DOMRenderContextSymbol} from './constants'; +import { + contextFromPairs, + getContextRecord, + getContextValue, +} from './ContextRecord'; + +type RenderContextRecord = ContextRecord; + +function makeReader(record: RenderContextRecord): RenderContextReader { + return { + get(cfg: RenderStateConfig): V { + return getContextValue(record, cfg); + }, + }; +} + +/** + * The mutable, writable editor-level context record. Reads of a render state + * during reconciliation (and as the base layer of a session) fall through to + * this record, and it is the layer the `disabledForEditor` predicates read. + * + * @internal + */ +export function createEditorContextRecord( + contextDefaults: readonly AnyRenderStateConfigPairOrUpdater[], +): RenderContextRecord { + const parent = Object.create(null) as RenderContextRecord; + return contextFromPairs(contextDefaults, parent) || parent; +} + +/** + * Filter the configured overrides down to those that are resident in the + * editor's render config, removing any whose `disabledForEditor` predicate + * returns `true` for the given editor context. + * + * @internal + */ +export function filterEditorInstalled( + overrides: readonly AnyDOMRenderMatch[], + record: RenderContextRecord, +): AnyDOMRenderMatch[] { + const reader = makeReader(record); + return overrides.filter( + o => !(o.disabledForEditor && o.disabledForEditor(reader)), + ); +} + +function sameOverrides( + a: readonly AnyDOMRenderMatch[], + b: readonly AnyDOMRenderMatch[], +): boolean { + if (a.length !== b.length) { + return false; + } + for (let i = 0; i < a.length; i++) { + if (a[i] !== b[i]) { + return false; + } + } + return true; +} + +function symmetricDiff( + prev: readonly AnyDOMRenderMatch[], + next: readonly AnyDOMRenderMatch[], +): AnyDOMRenderMatch[] { + const prevSet = new Set(prev); + const nextSet = new Set(next); + const changed: AnyDOMRenderMatch[] = []; + for (const o of prev) { + if (!nextSet.has(o)) { + changed.push(o); + } + } + for (const o of next) { + if (!prevSet.has(o)) { + changed.push(o); + } + } + return changed; +} + +/** + * Build a predicate matching the nodes an override targets — `'*'` matches + * everything, a node class matches by `instanceof`, and a guard is used as-is. + */ +function nodeMatcher(o: AnyDOMRenderMatch): (node: LexicalNode) => boolean { + if (o.nodes === '*') { + return () => true; + } + const matchers = o.nodes.map(match => { + const klass = match as Klass; + return $isLexicalNode(klass.prototype) + ? (node: LexicalNode) => node instanceof klass + : (match as (node: LexicalNode) => boolean); + }); + return node => matchers.some(f => f(node)); +} + +/** + * Build a predicate matching the nodes whose DOM must be recreated for the + * given override change, or `null` when no live re-render is needed. + * + * `$createDOM`/`$getDOMSlot` produce the element and slot, and `$decorateDOM` + * may add DOM that only a fresh `$createDOM` can revert — so toggling any of + * them recreates the affected nodes. `$updateDOM` is diff-driven and applies on + * the next node update, and export-only hooks ($exportDOM/$shouldInclude/…) + * don't touch the live DOM, so neither needs a re-render. Recreating every + * affected node is the simple, always-correct choice; toggles are rare, so the + * cost is acceptable and can be optimized later if needed. + */ +function recreatePredicate( + changed: readonly AnyDOMRenderMatch[], +): ((node: LexicalNode) => boolean) | null { + const matchers: ((node: LexicalNode) => boolean)[] = []; + for (const o of changed) { + if (o.$createDOM || o.$getDOMSlot || o.$decorateDOM) { + matchers.push(nodeMatcher(o)); + } + } + return matchers.length === 0 ? null : node => matchers.some(f => f(node)); +} + +/** + * Per-editor runtime backing {@link DOMRenderExtension}'s conditional + * overrides and imperative editor context. See {@link DOMRenderRuntime}. + * + * @internal + */ +export class DOMRenderRuntimeImpl implements DOMRenderRuntime { + readonly editor: LexicalEditor; + /** + * The `nodes` and base `dom` captured at `init` (before `dom` was + * overwritten with the compiled config) — the clean base for every recompile. + */ + readonly initialEditorConfig: Pick; + readonly overrides: readonly AnyDOMRenderMatch[]; + readonly editorContext: RenderContextRecord; + readonly hasSessionGates: boolean; + installed: readonly AnyDOMRenderMatch[]; + + /** Memoized session configs keyed by the set of session-disabled overrides. */ + private readonly sessionCache = new Map(); + + constructor( + editor: LexicalEditor, + initialEditorConfig: Pick, + overrides: readonly AnyDOMRenderMatch[], + editorContext: RenderContextRecord, + ) { + this.editor = editor; + this.initialEditorConfig = initialEditorConfig; + this.overrides = overrides; + this.editorContext = editorContext; + this.installed = filterEditorInstalled(overrides, editorContext); + this.hasSessionGates = overrides.some(o => o.disabledForSession); + } + + setContextValue(cfg: RenderStateConfig, value: V): void { + const prev = this.installed; + this.editorContext[cfg.key] = value; + const next = filterEditorInstalled(this.overrides, this.editorContext); + if (sameOverrides(prev, next)) { + return; + } + const changed = symmetricDiff(prev, next); + this.installed = next; + this.sessionCache.clear(); + const dom = compileDOMRenderConfigOverrides(this.initialEditorConfig, { + overrides: next as AnyDOMRenderMatch[], + }); + this.editor._config.dom = dom; + + const recreate = recreatePredicate(changed); + if (!recreate) { + // $updateDOM-only or export-only change: the recompiled config is enough. + return; + } + + // Re-render through a full reconcile, which reuses the existing node + // instances (no node-map mutation, so no spurious mutation/collaboration + // changes). The affected nodes must be unmounted and recreated — the removed + // override may have produced or decorated DOM that only a fresh $createDOM + // reverts — so install a transient $updateDOM that reports a recreate for + // matching nodes. + // + // This mutates the (shared) active config, so the reconcile MUST run and + // finish synchronously before the original is restored on the next line — + // hence `discrete`, and hence this must not be called from within an + // editor.update (where the commit would defer). A deferred update would + // either restore the wrapper before the reconcile reads it (no recreate) or + // leave it armed across a window where an unrelated reconcile would + // spuriously recreate matching nodes. No history tag is needed: a full + // reconcile marks no nodes dirty, which history merges/discards without + // pushing. + const base = dom.$updateDOM; + dom.$updateDOM = (nextNode, prevNode, el, editor) => + recreate(nextNode) ? true : base(nextNode, prevNode, el, editor); + this.editor.update($fullReconcile, {discrete: true}); + dom.$updateDOM = base; + } + + getSessionConfig(): EditorDOMRenderConfig { + const resident = this.editor._config.dom || DEFAULT_EDITOR_DOM_CONFIG; + if (!this.hasSessionGates) { + return resident; + } + const reader = makeReader( + getContextRecord(DOMRenderContextSymbol, this.editor) || + this.editorContext, + ); + const disabledKeys: string[] = []; + const sessionSet: AnyDOMRenderMatch[] = []; + this.installed.forEach((o, i) => { + if (o.disabledForSession && o.disabledForSession(reader)) { + disabledKeys.push(String(i)); + } else { + sessionSet.push(o); + } + }); + if (disabledKeys.length === 0) { + return resident; + } + const key = disabledKeys.join(','); + let cfg = this.sessionCache.get(key); + if (!cfg) { + cfg = compileDOMRenderConfigOverrides(this.initialEditorConfig, { + overrides: sessionSet, + }); + this.sessionCache.set(key, cfg); + } + return cfg; + } +} diff --git a/packages/lexical-html/src/RenderContext.ts b/packages/lexical-html/src/RenderContext.ts index c7b52a1fe04..df6f471d735 100644 --- a/packages/lexical-html/src/RenderContext.ts +++ b/packages/lexical-html/src/RenderContext.ts @@ -5,8 +5,10 @@ * LICENSE file in the root directory of this source tree. * */ +import type {EditorDOMRenderConfig} from 'lexical'; + import {getPeerDependencyFromEditor} from '@lexical/extension'; -import {$getEditor, LexicalEditor} from 'lexical'; +import {$getEditor, $getEditorDOMRenderConfig, LexicalEditor} from 'lexical'; import {DOMRenderContextSymbol, DOMRenderExtensionName} from './constants'; import { @@ -86,6 +88,73 @@ export function $getRenderContextValue( return getContextValue(getRenderContext(editor), cfg); } +function getRuntime(editor: LexicalEditor) { + const dep = getPeerDependencyFromEditor( + editor, + DOMRenderExtensionName, + ); + return dep ? dep.output.runtime : undefined; +} + +/** + * Imperatively set a value in the persistent editor render context. + * + * Unlike {@link $withRenderContext} (which scopes values to a callback), this + * persists on the editor. If the change flips any override's + * `disabledForEditor` result, the resident render config is recompiled and the + * affected nodes are re-rendered. No-op if {@link DOMRenderExtension} is not + * installed. + * + * @experimental + */ +export function $setRenderContextValue( + cfg: RenderStateConfig, + value: V, + editor: LexicalEditor = $getEditor(), +): void { + const runtime = getRuntime(editor); + if (runtime) { + runtime.setContextValue(cfg, value); + } +} + +/** + * Imperatively update a value in the persistent editor render context with an + * updater function. See {@link $setRenderContextValue}. + * + * @experimental + */ +export function $updateRenderContextValue( + cfg: RenderStateConfig, + updater: (prev: V) => V, + editor: LexicalEditor = $getEditor(), +): void { + const runtime = getRuntime(editor); + if (runtime) { + runtime.setContextValue( + cfg, + updater(getContextValue(runtime.editorContext, cfg)), + ); + } +} + +/** + * Resolve the {@link EditorDOMRenderConfig} to use for the current + * export/generate session, applying any `disabledForSession` overrides against + * the active session context. Falls back to the editor's resident config when + * {@link DOMRenderExtension} is not installed. + * + * @experimental + */ +export function $getSessionDOMRenderConfig( + editor: LexicalEditor = $getEditor(), +): EditorDOMRenderConfig { + const runtime = getRuntime(editor); + return runtime + ? runtime.getSessionConfig() + : $getEditorDOMRenderConfig(editor); +} + /** * Execute a callback within a render context with the given config pairs. * @experimental diff --git a/packages/lexical-html/src/__tests__/unit/DOMRenderConditionalOverrides.test.ts b/packages/lexical-html/src/__tests__/unit/DOMRenderConditionalOverrides.test.ts new file mode 100644 index 00000000000..9fd01106b88 --- /dev/null +++ b/packages/lexical-html/src/__tests__/unit/DOMRenderConditionalOverrides.test.ts @@ -0,0 +1,232 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import {buildEditorFromExtensions} from '@lexical/extension'; +import { + $generateHtmlFromNodes, + $setRenderContextValue, + $withRenderContext, + contextValue, + createRenderState, + domOverride, + DOMRenderExtension, +} from '@lexical/html'; +import { + $createLineBreakNode, + $createParagraphNode, + $createTextNode, + $getNodeByKeyOrThrow, + $getRoot, + $isElementNode, + $isLineBreakNode, + configExtension, + defineExtension, + isHTMLElement, + type LexicalEditor, + LineBreakNode, + ParagraphNode, +} from 'lexical'; +import {describe, expect, test} from 'vitest'; + +const WrapDisabled = createRenderState('wrapDisabled', () => false); +const Terse = createRenderState('terse', () => false); + +const WRAP_ATTR = 'data-wrap'; + +function makeEditor() { + return buildEditorFromExtensions( + defineExtension({ + $initialEditorState: () => { + $getRoot().append( + $createParagraphNode().append( + $createTextNode('a'), + $createLineBreakNode(), + $createTextNode('b'), + ), + ); + }, + dependencies: [ + configExtension(DOMRenderExtension, { + overrides: [ + // Structural override gated per-editor: wraps each
in a . + domOverride( + [LineBreakNode], + { + $createDOM: (node, $next) => { + const inner = $next(); + const wrapper = document.createElement('span'); + wrapper.setAttribute(WRAP_ATTR, 'true'); + wrapper.appendChild(inner); + return wrapper; + }, + $updateDOM: (node, _prev, dom, $next) => { + if ( + (dom.tagName === 'SPAN' && dom.hasAttribute(WRAP_ATTR)) !== + true + ) { + return true; + } + return $next(); + }, + }, + {disabledForEditor: ctx => ctx.get(WrapDisabled)}, + ), + // Export-only override gated per-session: tags every element. + domOverride( + '*', + { + $exportDOM: (node, $next) => { + const rval = $next(); + if (isHTMLElement(rval.element)) { + rval.element.setAttribute('data-terse', 'true'); + } + return rval; + }, + }, + {disabledForSession: ctx => !ctx.get(Terse)}, + ), + ], + }), + ], + name: 'test', + }), + ); +} + +function getLineBreakKey(editor: LexicalEditor): string { + return editor.read(() => { + const paragraph = $getRoot().getFirstChildOrThrow(); + if ($isElementNode(paragraph)) { + for (const child of paragraph.getChildren()) { + if ($isLineBreakNode(child)) { + return child.getKey(); + } + } + } + throw new Error('Expected a LineBreakNode in the first paragraph'); + }); +} + +describe('DOMRender conditional overrides', () => { + test('disabledForEditor removes the override and recreates live DOM', () => { + using editor = makeEditor(); + const div = document.createElement('div'); + editor.setRootElement(div); + + const lbKey = getLineBreakKey(editor); + + // Enabled by default (disabled=false): the node's DOM is the wrapper. + const enabledDom = editor.getElementByKey(lbKey); + expect(enabledDom).not.toBe(null); + expect(enabledDom?.tagName).toBe('SPAN'); + expect(enabledDom?.hasAttribute(WRAP_ATTR)).toBe(true); + expect(div.querySelector(`span[${WRAP_ATTR}]`)).toBe(enabledDom); + + // Disable: the override is removed and the node's DOM is *recreated* as a + // bare
— verified by element identity, not just an in-place update. + $setRenderContextValue(WrapDisabled, true, editor); + const disabledDom = editor.getElementByKey(lbKey); + expect(disabledDom?.tagName).toBe('BR'); + expect(disabledDom).not.toBe(enabledDom); + expect(div.querySelector(`span[${WRAP_ATTR}]`)).toBe(null); + + // Re-enable: recreated again as a fresh (a new element identity). + $setRenderContextValue(WrapDisabled, false, editor); + const reenabledDom = editor.getElementByKey(lbKey); + expect(reenabledDom?.tagName).toBe('SPAN'); + expect(reenabledDom).not.toBe(enabledDom); + expect(reenabledDom).not.toBe(disabledDom); + }); + + test('disabledForEditor re-render reuses node instances (no node map clone)', () => { + using editor = makeEditor(); + editor.setRootElement(document.createElement('div')); + + const lbKey = getLineBreakKey(editor); + const before = editor.read(() => $getNodeByKeyOrThrow(lbKey)); + $setRenderContextValue(WrapDisabled, true, editor); + + // A full reconcile recreates the DOM but reuses the same node instance — + // marking a read-only node writable always returns a new instance, so + // identity equality proves the node was never cloned (editor state / + // collaboration untouched). + editor.read(() => { + expect($getNodeByKeyOrThrow(lbKey)).toBe(before); + }); + }); + + test('disabledForEditor $decorateDOM recreates to apply and revert decoration', () => { + const DecorateDisabled = createRenderState('decorateDisabled', () => false); + using editor = buildEditorFromExtensions( + defineExtension({ + $initialEditorState: () => { + $getRoot().append( + $createParagraphNode().append($createTextNode('x')), + ); + }, + dependencies: [ + configExtension(DOMRenderExtension, { + overrides: [ + domOverride( + [ParagraphNode], + { + $decorateDOM: (_node, _prev, dom) => { + dom.setAttribute('data-decorated', 'true'); + }, + }, + {disabledForEditor: ctx => ctx.get(DecorateDisabled)}, + ), + ], + }), + ], + name: 'decorate-test', + }), + ); + const div = document.createElement('div'); + editor.setRootElement(div); + + // Enabled by default: $decorateDOM applied. + expect(div.querySelector('p[data-decorated]')).not.toBe(null); + + // Disable: the node is recreated, so the additive decoration is gone — a + // plain re-render of the remaining decorate chain could not remove it. + $setRenderContextValue(DecorateDisabled, true, editor); + expect(div.querySelector('p[data-decorated]')).toBe(null); + + // Re-enable: decoration re-applied. + $setRenderContextValue(DecorateDisabled, false, editor); + expect(div.querySelector('p[data-decorated]')).not.toBe(null); + }); + + test('disabledForSession gates export participation', () => { + using editor = makeEditor(); + editor.setRootElement(document.createElement('div')); + + // Cold export: the per-session override is not installed. + editor.read(() => { + expect($generateHtmlFromNodes(editor)).not.toContain('data-terse'); + }); + // Terse export: the per-session override is installed for this walk. + editor.read(() => { + expect( + $withRenderContext( + [contextValue(Terse, true)], + editor, + )(() => $generateHtmlFromNodes(editor)), + ).toContain('data-terse'); + }); + }); + + test('disabledForSession does not affect live reconciliation', () => { + using editor = makeEditor(); + const div = document.createElement('div'); + editor.setRootElement(div); + // The export-only ('*') override must never tag the live DOM. + expect(div.querySelector('[data-terse]')).toBe(null); + }); +}); diff --git a/packages/lexical-html/src/compileDOMRenderConfigOverrides.ts b/packages/lexical-html/src/compileDOMRenderConfigOverrides.ts index 5d635638b3c..b018e61febe 100644 --- a/packages/lexical-html/src/compileDOMRenderConfigOverrides.ts +++ b/packages/lexical-html/src/compileDOMRenderConfigOverrides.ts @@ -390,8 +390,8 @@ function identity(v: T) { } export function compileDOMRenderConfigOverrides( - editorConfig: InitialEditorConfig, - {overrides}: DOMRenderConfig, + editorConfig: Pick, + {overrides}: Pick, ): EditorDOMRenderConfig { const prerender = precompileDOMRenderConfigOverrides(editorConfig, overrides); const dom = { diff --git a/packages/lexical-html/src/domOverride.ts b/packages/lexical-html/src/domOverride.ts index 2f6f4b0c79f..e38c1ca424a 100644 --- a/packages/lexical-html/src/domOverride.ts +++ b/packages/lexical-html/src/domOverride.ts @@ -5,28 +5,42 @@ * LICENSE file in the root directory of this source tree. * */ -import type {AnyDOMRenderMatch, DOMRenderMatch, NodeMatch} from './types'; +import type { + AnyDOMRenderMatch, + DOMOverrideOptions, + DOMRenderMatch, + DOMRenderMatchConfig, + NodeMatch, +} from './types'; import type {LexicalNode} from 'lexical'; /** * A convenience function for type inference when constructing DOM overrides for * use with {@link DOMRenderExtension}. * + * The optional `options` argument controls *whether* the override is installed + * based only on render context — `disabledForEditor` gates residency in the + * editor's render pipeline (reconciliation), `disabledForSession` gates + * participation in a single export/generate session. See {@link DOMOverrideOptions}. + * * @experimental * @__NO_SIDE_EFFECTS__ */ export function domOverride( nodes: '*', - config: Omit, 'nodes'>, + config: DOMRenderMatchConfig, + options?: DOMOverrideOptions, ): DOMRenderMatch; export function domOverride( nodes: readonly NodeMatch[], - config: Omit, 'nodes'>, + config: DOMRenderMatchConfig, + options?: DOMOverrideOptions, ): DOMRenderMatch; export function domOverride( nodes: AnyDOMRenderMatch['nodes'], - config: Omit, + config: Omit, + options?: DOMOverrideOptions, ): AnyDOMRenderMatch { - return {...config, nodes}; + return {...config, ...options, nodes}; } diff --git a/packages/lexical-html/src/index.ts b/packages/lexical-html/src/index.ts index e40c5f2908b..084f9a35016 100644 --- a/packages/lexical-html/src/index.ts +++ b/packages/lexical-html/src/index.ts @@ -41,6 +41,7 @@ import { import {contextValue} from './ContextRecord'; import {$inlineStylesFromStyleSheetsDOM} from './import/inlineStylesFromStyleSheets'; import { + $getSessionDOMRenderConfig, $withRenderContext, RenderContextExport, RenderContextRoot, @@ -111,6 +112,9 @@ export { } from './import'; export { $getRenderContextValue, + $getSessionDOMRenderConfig, + $setRenderContextValue, + $updateRenderContextValue, $withRenderContext, createRenderState, RenderContextExport, @@ -121,10 +125,13 @@ export type { AnyRenderStateConfig, AnyRenderStateConfigPairOrUpdater, ContextPairOrUpdater, + DOMOverrideOptions, DOMRenderConfig, DOMRenderExtensionOutput, DOMRenderMatch, + DOMRenderMatchConfig, NodeMatch, + RenderContextReader, } from './types'; const IGNORE_TAGS = new Set(['STYLE', 'SCRIPT']); @@ -180,7 +187,7 @@ export function $generateDOMFromNodes( editor, )(() => { const root = $getRoot(); - const domConfig = $getEditorDOMRenderConfig(editor); + const domConfig = $getSessionDOMRenderConfig(editor); const parentElementAppend = container.append.bind(container); for (const topLevelNode of root.getChildren()) { @@ -214,7 +221,7 @@ export function $generateDOMFromRoot( editor, )(() => { const selection = null; - const domConfig = $getEditorDOMRenderConfig(editor); + const domConfig = $getSessionDOMRenderConfig(editor); const parentElementAppend = container.append.bind(container); $appendNodesToHTML(editor, root, parentElementAppend, selection, domConfig); return container; diff --git a/packages/lexical-html/src/types.ts b/packages/lexical-html/src/types.ts index ede16b24a5c..346e32b143e 100644 --- a/packages/lexical-html/src/types.ts +++ b/packages/lexical-html/src/types.ts @@ -11,6 +11,7 @@ import type { BaseSelection, DOMExportOutput, DOMSlotForNode, + EditorDOMRenderConfig, Klass, LexicalEditor, LexicalNode, @@ -91,6 +92,76 @@ export type AnyContextConfigPairOrUpdater = export interface DOMRenderExtensionOutput { /** @internal */ defaults: undefined | ContextRecord; + /** @internal */ + runtime: DOMRenderRuntime; +} + +/** + * @experimental + * + * A read-only view of a render context layer, passed to the + * {@link DOMOverrideOptions} predicates so they can decide whether an + * override should be installed based only on context values. + */ +export interface RenderContextReader { + get(cfg: RenderStateConfig): V; +} + +/** + * @experimental + * @internal + * + * Per-editor runtime state for {@link DOMRenderExtension} that backs the + * imperative editor context ({@link createRenderState} writes via + * `$setRenderContextValue`) and the conditional install of overrides. + */ +export interface DOMRenderRuntime { + /** + * The mutable, persistent editor-level context record. Reads of a + * {@link RenderStateConfig} during reconciliation (and as the base layer + * during a session) fall through to this record. It is also the layer + * that {@link DOMOverrideOptions.disabledForEditor} predicates read from. + */ + readonly editorContext: ContextRecord; + /** + * Imperatively set a value in the editor context. If the change flips any + * override's `disabledForEditor` result, the resident render config is + * recompiled and the affected nodes are re-rendered (recreating DOM for + * structural overrides). + */ + setContextValue(cfg: RenderStateConfig, value: V): void; + /** + * Resolve the {@link EditorDOMRenderConfig} for the current export/generate + * session, applying any {@link DOMOverrideOptions.disabledForSession} + * predicates against the active session context. Returns the resident + * config when no session gating applies. + */ + getSessionConfig(): EditorDOMRenderConfig; +} + +/** + * @experimental + * + * Options for {@link domOverride} controlling *whether* an override is + * installed, based only on render context. Both predicates default to + * "not disabled". + */ +export interface DOMOverrideOptions { + /** + * Gate residency in the editor's render config (used by reconciliation and + * as the base for export/generate). Evaluated against the persistent editor + * context at compile time, and re-evaluated when that context changes via + * `$setRenderContextValue`; a change recompiles the config and re-renders + * affected nodes. Return `true` to remove the override. Default: not disabled. + */ + disabledForEditor?: (ctx: RenderContextReader) => boolean; + /** + * Gate participation in a single export/generate session. Evaluated once at + * the start of each session against that session's context. Has no effect on + * live reconciliation (which is not a session). Return `true` to remove the + * override for that session. Default: not disabled. + */ + disabledForSession?: (ctx: RenderContextReader) => boolean; } /** @@ -374,4 +445,26 @@ export interface DOMRenderMatch { $next: () => boolean, editor: LexicalEditor, ) => boolean; + /** + * Set via {@link domOverride}'s options argument, not directly. See + * {@link DOMOverrideOptions.disabledForEditor}. + */ + disabledForEditor?: (ctx: RenderContextReader) => boolean; + /** + * Set via {@link domOverride}'s options argument, not directly. See + * {@link DOMOverrideOptions.disabledForSession}. + */ + disabledForSession?: (ctx: RenderContextReader) => boolean; } + +/** + * @experimental + * + * The hook fields of a {@link DOMRenderMatch} — i.e. without `nodes` or the + * {@link DOMOverrideOptions} predicates, which are passed separately to + * {@link domOverride}. + */ +export type DOMRenderMatchConfig = Omit< + DOMRenderMatch, + 'nodes' | keyof DOMOverrideOptions +>; diff --git a/packages/lexical-playground/__tests__/e2e/Tables.spec.mjs b/packages/lexical-playground/__tests__/e2e/Tables.spec.mjs index 619539f4c7e..06bd677c9c8 100644 --- a/packages/lexical-playground/__tests__/e2e/Tables.spec.mjs +++ b/packages/lexical-playground/__tests__/e2e/Tables.spec.mjs @@ -31,6 +31,7 @@ import { deleteTableColumns, deleteTableRows, dragMouse, + evaluate, expect, focusEditor, getExpectedDateTimeHtml, @@ -136,6 +137,58 @@ test.describe.parallel('Tables', () => { ); }); + test(`Selection placed on a element resolves into the first cell`, async ({ + page, + isPlainText, + isCollab, + }) => { + test.skip(isPlainText || isCollab); + await initialize({isCollab, page}); + + await focusEditor(page); + await insertTable(page, 2, 2); + + // Type into the last cell so Lexical has a definite prior selection + // there. Without the fix, prior to landing the caret on , the + // resolution falls back to that last cell. + await page.keyboard.press('Tab'); + await page.keyboard.press('Tab'); + await page.keyboard.press('Tab'); + await page.keyboard.type('last'); + + // Force the DOM caret onto a child of the table's , + // mimicking what Firefox 150+ does on some navigation actions. + await evaluate(page, () => { + const col = document.querySelector( + 'div[contenteditable="true"] table > colgroup > col', + ); + window.getSelection().setBaseAndExtent(col, 0, col, 0); + }); + + // Allow Lexical to process the selection change. + await sleep(50); + + // The DOM caret must not be left inside the / region + // (the reconciler should have written it back to the resolved cell). + const domAnchorNodeName = await evaluate( + page, + () => window.getSelection().anchorNode?.nodeName ?? null, + ); + expect(domAnchorNodeName).not.toBe('COL'); + expect(domAnchorNodeName).not.toBe('COLGROUP'); + + // Typing should land in the first cell, not extend "last". + await page.keyboard.type('X'); + const cellTexts = await evaluate(page, () => { + const cells = document.querySelectorAll( + 'div[contenteditable="true"] table th, div[contenteditable="true"] table td', + ); + return Array.from(cells).map(c => c.textContent); + }); + expect(cellTexts[0]).toBe('X'); + expect(cellTexts[cellTexts.length - 1]).toBe('last'); + }); + test(`Can type inside of table cell`, async ({ page, isPlainText, diff --git a/packages/lexical-playground/src/plugins/TerseExportExtension.ts b/packages/lexical-playground/src/plugins/TerseExportExtension.ts index 0d0b85d7cec..37ea8f89ebc 100644 --- a/packages/lexical-playground/src/plugins/TerseExportExtension.ts +++ b/packages/lexical-playground/src/plugins/TerseExportExtension.ts @@ -5,8 +5,9 @@ * LICENSE file in the root directory of this source tree. * */ +import type {DOMOverrideOptions} from '@lexical/html'; + import { - $getRenderContextValue, createRenderState, domOverride, DOMRenderExtension, @@ -19,62 +20,74 @@ import { } from 'lexical'; export const RenderContextTerse = createRenderState('isTerse', Boolean); + +/** + * Install these overrides only for export sessions where terse output was + * requested (`RenderContextTerse` is set). Non-terse exports never enter the + * terse middleware at all, rather than running it and bailing per node. + */ +const terseOnly: DOMOverrideOptions = { + disabledForSession: ctx => !ctx.get(RenderContextTerse), +}; + export const TerseExportExtension = defineExtension({ dependencies: [ configExtension(DOMRenderExtension, { - // TODO use an #8567 overlay when we have that feature overrides: [ - domOverride('*', { - $exportDOM: (node, $next, editor) => { - const rval = $next(); - const {element} = rval; - if ( - isHTMLElement(element) && - $getRenderContextValue(RenderContextTerse) - ) { - // Strip all theme classes - for (const className of Array.from(element.classList)) { - if (className.startsWith('PlaygroundEditorTheme__')) { - element.classList.remove(className); + domOverride( + '*', + { + $exportDOM: (node, $next) => { + const rval = $next(); + const {element} = rval; + if (isHTMLElement(element)) { + // Strip all theme classes + for (const className of Array.from(element.classList)) { + if (className.startsWith('PlaygroundEditorTheme__')) { + element.classList.remove(className); + } + } + // Strip white-space: pre-wrap when not necessary + if ( + element.style.getPropertyValue('white-space') === + 'pre-wrap' && + !/^\s|\s$|\s\s/.test(element.textContent) + ) { + element.style.setProperty('white-space', null); + } + if (element.classList.length === 0) { + element.removeAttribute('class'); + } + if (element.style.length === 0) { + // For some reason this getAttribute prevents style="" + element.getAttribute('style'); + element.removeAttribute('style'); } } - // Strip white-space: pre-wrap when not necessary - if ( - element.style.getPropertyValue('white-space') === 'pre-wrap' && - !/^\s|\s$|\s\s/.test(element.textContent) - ) { - element.style.setProperty('white-space', null); - } - if (element.classList.length === 0) { - element.removeAttribute('class'); - } - if (element.style.length === 0) { - // For some reason this getAttribute prevents style="" - element.getAttribute('style'); - element.removeAttribute('style'); - } - } - return rval; + return rval; + }, }, - }), - domOverride([ParagraphNode], { - $exportDOM: (node, $next, editor) => { - const rval = $next(); - const {element} = rval; - if ( - isHTMLElement(element) && - $getRenderContextValue(RenderContextTerse) - ) { - // clear any empty br - if (node.isEmpty()) { - for (const el of element.querySelectorAll(':scope > br')) { - el.remove(); + terseOnly, + ), + domOverride( + [ParagraphNode], + { + $exportDOM: (node, $next) => { + const rval = $next(); + const {element} = rval; + if (isHTMLElement(element)) { + // clear any empty br + if (node.isEmpty()) { + for (const el of element.querySelectorAll(':scope > br')) { + el.remove(); + } } } - } - return rval; + return rval; + }, }, - }), + terseOnly, + ), ], }), ], diff --git a/packages/lexical-playground/src/plugins/VisibleLineBreakExtension.ts b/packages/lexical-playground/src/plugins/VisibleLineBreakExtension.ts index 222a95f1a00..d622da9c4e0 100644 --- a/packages/lexical-playground/src/plugins/VisibleLineBreakExtension.ts +++ b/packages/lexical-playground/src/plugins/VisibleLineBreakExtension.ts @@ -6,17 +6,17 @@ * */ import {$isCodeNode} from '@lexical/code-core'; +import {effect, namedSignals} from '@lexical/extension'; import { - effect, - getExtensionDependencyFromEditor, - namedSignals, -} from '@lexical/extension'; -import {domOverride, DOMRenderExtension} from '@lexical/html'; + $setRenderContextValue, + createRenderState, + domOverride, + DOMRenderExtension, +} from '@lexical/html'; import { configExtension, defineExtension, isHTMLElement, - type LexicalEditor, LineBreakNode, safeCast, } from 'lexical'; @@ -31,10 +31,12 @@ import { * `LineBreakNode` subclass required, behaviour attaches via * `DOMRenderExtension` configuration. * - * `disabled` toggles the wrap at runtime without recreating the editor. - * Flipping the signal forces a no-op `LineBreakNode` transform so every - * existing `LineBreakNode` gets re-rendered through the `$createDOM` / - * `$updateDOM` overrides below. + * `disabled` toggles the wrap at runtime without recreating the editor. The + * override is installed conditionally via `disabledForEditor`, so when disabled + * it is removed from the render pipeline entirely rather than no-oping per + * node. Flipping the signal mirrors it into the editor render context with + * `$setRenderContextValue`, which recompiles the render config and recreates + * the existing `LineBreakNode` DOM through the new config. */ const VISIBLE_LINEBREAK_CLASS = 'visible-linebreak'; const VISIBLE_LINEBREAK_ATTR = 'data-lexical-visible-linebreak'; @@ -43,12 +45,13 @@ export interface VisibleLineBreakConfig { disabled: boolean; } -function $isDisabled(editor: LexicalEditor): boolean { - return getExtensionDependencyFromEditor( - editor, - VisibleLineBreakExtension, - ).output.disabled.peek(); -} +/** + * Editor render context state mirroring the extension's `disabled` signal. + */ +export const VisibleLineBreakDisabled = createRenderState( + 'visibleLineBreakDisabled', + () => false, +); function $skipForCodeChild(node: LineBreakNode): boolean { // Code blocks convey line structure visually — skip the visible @@ -74,32 +77,35 @@ export const VisibleLineBreakExtension = defineExtension({ config: safeCast({disabled: false}), dependencies: [ configExtension(DOMRenderExtension, { - // TODO use an #8567 overlay when we have that feature overrides: [ - domOverride([LineBreakNode], { - $createDOM: (node, $next, editor) => { - const inner = $next(); - if ($isDisabled(editor) || $skipForCodeChild(node)) { - return inner; - } - const wrapper = document.createElement('span'); - wrapper.className = VISIBLE_LINEBREAK_CLASS; - wrapper.setAttribute(VISIBLE_LINEBREAK_ATTR, 'true'); - wrapper.appendChild(inner); - return wrapper; - }, - $getDOMSlot: (_node, dom, $next, _editor) => { - const br = dom.querySelector(':scope > br'); - return isHTMLElement(br) ? $next().withElement(br) : $next(); - }, - $updateDOM: (node, _prev, dom, $next, editor) => { - const wantsWrap = !$isDisabled(editor) && !$skipForCodeChild(node); - if (wantsWrap !== hasOurWrap(dom)) { - return true; - } - return $next(); + domOverride( + [LineBreakNode], + { + $createDOM: (node, $next) => { + const inner = $next(); + if ($skipForCodeChild(node)) { + return inner; + } + const wrapper = document.createElement('span'); + wrapper.className = VISIBLE_LINEBREAK_CLASS; + wrapper.setAttribute(VISIBLE_LINEBREAK_ATTR, 'true'); + wrapper.appendChild(inner); + return wrapper; + }, + $getDOMSlot: (_node, dom, $next) => { + const br = dom.querySelector(':scope > br'); + return isHTMLElement(br) ? $next().withElement(br) : $next(); + }, + $updateDOM: (node, _prev, dom, $next) => { + const wantsWrap = !$skipForCodeChild(node); + if (wantsWrap !== hasOurWrap(dom)) { + return true; + } + return $next(); + }, }, - }), + {disabledForEditor: ctx => ctx.get(VisibleLineBreakDisabled)}, + ), ], }), ], @@ -107,12 +113,11 @@ export const VisibleLineBreakExtension = defineExtension({ register: (editor, _config, state) => { const stores = state.getOutput(); return effect(() => { - // Subscribe to the signal so the effect re-runs on every flip. - void stores.disabled.value; - // Force every existing LineBreakNode to re-render via the overrides - // by registering and immediately unregistering a no-op transform — - // the register call marks all existing LineBreakNode instances dirty. - editor.registerNodeTransform(LineBreakNode, () => {})(); + $setRenderContextValue( + VisibleLineBreakDisabled, + stores.disabled.value, + editor, + ); }); }, }); diff --git a/packages/lexical-react/src/LexicalTablePlugin.ts b/packages/lexical-react/src/LexicalTablePlugin.ts index f2e16348a24..a55baee53fd 100644 --- a/packages/lexical-react/src/LexicalTablePlugin.ts +++ b/packages/lexical-react/src/LexicalTablePlugin.ts @@ -17,8 +17,8 @@ import { registerTableSelectionObserver, setScrollableTablesActive, TableCellNode, - TableNode, } from '@lexical/table'; +import {$fullReconcile} from 'lexical'; import {useEffect, useState} from 'react'; export interface TablePluginProps { @@ -66,9 +66,11 @@ export function TablePlugin({ const hadHorizontalScroll = $isScrollableTablesActive(editor); if (hadHorizontalScroll !== hasHorizontalScroll) { setScrollableTablesActive(editor, hasHorizontalScroll); - // Registering the transform has the side-effect of marking all existing - // TableNodes as dirty. The handler is immediately unregistered. - editor.registerNodeTransform(TableNode, () => {})(); + // Re-render existing tables through the new scroll-wrapper config without + // cloning every TableNode the way marking them dirty would. A full + // reconcile marks no nodes dirty, so it's deferred (no synchronous render + // from this effect) and produces no history entry. + editor.update($fullReconcile); } }, [editor, hasHorizontalScroll]); diff --git a/packages/lexical-rich-text/src/__tests__/unit/RichTextInlineDecoratorMoveToEnd.test.ts b/packages/lexical-rich-text/src/__tests__/unit/RichTextInlineDecoratorMoveToEnd.test.ts index a1f9fbf16e8..a5b6aa1548b 100644 --- a/packages/lexical-rich-text/src/__tests__/unit/RichTextInlineDecoratorMoveToEnd.test.ts +++ b/packages/lexical-rich-text/src/__tests__/unit/RichTextInlineDecoratorMoveToEnd.test.ts @@ -151,6 +151,15 @@ describe('MOVE_TO_END no-op cases (Issue #8555)', () => { paragraph.select(1, 1); }, }, + { + label: 'decorator-only element (no selectable text)', + setup: () => { + const decorator = $createTestDecoratorNode().setIsInline(true); + const paragraph = $createParagraphNode().append(decorator); + $getRoot().clear().append(paragraph); + paragraph.select(0, 0); + }, + }, ])('no-op: $label', ({setup}) => { using editor = buildEditorFromExtensions({ $initialEditorState: setup, @@ -166,3 +175,51 @@ describe('MOVE_TO_END no-op cases (Issue #8555)', () => { expect(snapshotSelection(editor)).toEqual(before); }); }); + +describe('MOVE_TO_END decorator-only safety (crash fix)', () => { + test('Cmd+ArrowRight on decorator-only element does not throw', () => { + using editor = buildEditorFromExtensions({ + $initialEditorState: () => { + const decorator = $createTestDecoratorNode().setIsInline(true); + const paragraph = $createParagraphNode().append(decorator); + $getRoot().clear().append(paragraph); + paragraph.select(0, 0); + }, + dependencies: [RichTextExtension], + name: 'test', + nodes: [TestDecoratorNode], + }); + + // Should not throw — previously this would crash with selectEnd() on empty element + expect(() => dispatchMoveToEnd(editor, false)).not.toThrow(); + + editor.read(() => { + const selection = $getSelection(); + assert($isRangeSelection(selection)); + // Selection remains valid + expect(selection.anchor.type).toBeDefined(); + }); + }); + + test('Shift+Cmd+ArrowRight on decorator-only element does not throw', () => { + using editor = buildEditorFromExtensions({ + $initialEditorState: () => { + const decorator = $createTestDecoratorNode().setIsInline(true); + const paragraph = $createParagraphNode().append(decorator); + $getRoot().clear().append(paragraph); + paragraph.select(0, 0); + }, + dependencies: [RichTextExtension], + name: 'test', + nodes: [TestDecoratorNode], + }); + + const before = snapshotSelection(editor); + + // Handler bails safely on decorator-only elements (no selectable text) + dispatchMoveToEnd(editor, true); + + // Selection unchanged — handler returned false + expect(snapshotSelection(editor)).toEqual(before); + }); +}); diff --git a/packages/lexical-rich-text/src/__tests__/unit/RichTextInlineDecoratorMoveToStart.test.ts b/packages/lexical-rich-text/src/__tests__/unit/RichTextInlineDecoratorMoveToStart.test.ts index 67fc0a772c8..795354b9f61 100644 --- a/packages/lexical-rich-text/src/__tests__/unit/RichTextInlineDecoratorMoveToStart.test.ts +++ b/packages/lexical-rich-text/src/__tests__/unit/RichTextInlineDecoratorMoveToStart.test.ts @@ -153,6 +153,15 @@ describe('MOVE_TO_START no-op cases (Issue #8555)', () => { paragraph.select(0, 0); }, }, + { + label: 'decorator-only element (no selectable text)', + setup: () => { + const decorator = $createTestDecoratorNode().setIsInline(true); + const paragraph = $createParagraphNode().append(decorator); + $getRoot().clear().append(paragraph); + paragraph.select(1, 1); + }, + }, ])('no-op: $label', ({setup}) => { using editor = buildEditorFromExtensions({ $initialEditorState: setup, @@ -168,3 +177,27 @@ describe('MOVE_TO_START no-op cases (Issue #8555)', () => { expect(snapshotSelection(editor)).toEqual(before); }); }); + +describe('MOVE_TO_START decorator-only safety (crash fix)', () => { + test('Cmd+ArrowLeft on decorator-only element does not throw', () => { + using editor = buildEditorFromExtensions({ + $initialEditorState: () => { + const decorator = $createTestDecoratorNode().setIsInline(true); + const paragraph = $createParagraphNode().append(decorator); + $getRoot().clear().append(paragraph); + paragraph.select(1, 1); + }, + dependencies: [RichTextExtension], + name: 'test', + nodes: [TestDecoratorNode], + }); + + expect(() => dispatchMoveToStart(editor, false)).not.toThrow(); + + editor.read(() => { + const selection = $getSelection(); + assert($isRangeSelection(selection)); + expect(selection.anchor.type).toBeDefined(); + }); + }); +}); diff --git a/packages/lexical-rich-text/src/index.ts b/packages/lexical-rich-text/src/index.ts index 6c4d5557e27..81e384a794b 100644 --- a/packages/lexical-rich-text/src/index.ts +++ b/packages/lexical-rich-text/src/index.ts @@ -1296,6 +1296,11 @@ export function registerRichText( if (!$isDecoratorNode(firstChild) || !firstChild.isInline()) { return false; } + const lastDescendant = element.getLastDescendant(); + if (lastDescendant == null || $isDecoratorNode(lastDescendant)) { + // No selectable text — fall through to native browser behavior. + return false; + } // Native browser cursor traversal stops at the inline decorator's // contenteditable=false boundary when the caret starts at element // offset 0, so MOVE_TO_END leaves the caret stuck. Move it ourselves. @@ -1330,6 +1335,11 @@ export function registerRichText( if (!$isDecoratorNode(firstChild) || !firstChild.isInline()) { return false; } + const lastDescendant = focusBlock.getLastDescendant(); + if (lastDescendant == null || $isDecoratorNode(lastDescendant)) { + // No selectable text — fall through to native browser behavior. + return false; + } // Cross-block selections fall through to native handling. The // Chromium boundary bug only matters when both endpoints sit // inside the block whose first child is the inline decorator. diff --git a/packages/lexical-table/src/LexicalTableExtension.ts b/packages/lexical-table/src/LexicalTableExtension.ts index 441d3b0d61a..bc8ad4ec364 100644 --- a/packages/lexical-table/src/LexicalTableExtension.ts +++ b/packages/lexical-table/src/LexicalTableExtension.ts @@ -8,7 +8,7 @@ import {effect, namedSignals} from '@lexical/extension'; import {mergeRegister} from '@lexical/utils'; -import {defineExtension, safeCast} from 'lexical'; +import {$fullReconcile, defineExtension, safeCast} from 'lexical'; import {TableCellNode} from './LexicalTableCellNode'; import { @@ -74,9 +74,11 @@ export const TableExtension = defineExtension({ const hadHorizontalScroll = $isScrollableTablesActive(editor); if (hadHorizontalScroll !== hasHorizontalScroll) { setScrollableTablesActive(editor, hasHorizontalScroll); - // Registering the transform has the side-effect of marking all existing - // TableNodes as dirty. The handler is immediately unregistered. - editor.registerNodeTransform(TableNode, () => {})(); + // Re-render existing tables through the new scroll-wrapper config + // without cloning every TableNode the way marking them dirty would. A + // full reconcile marks no nodes dirty, so it's deferred (no + // synchronous render from this effect) and produces no history entry. + editor.update($fullReconcile); } }), registerTablePlugin(editor, stores), diff --git a/packages/lexical-table/src/__tests__/unit/LexicalTableExtension.test.ts b/packages/lexical-table/src/__tests__/unit/LexicalTableExtension.test.ts index 93b86bd0226..9227ba96626 100644 --- a/packages/lexical-table/src/__tests__/unit/LexicalTableExtension.test.ts +++ b/packages/lexical-table/src/__tests__/unit/LexicalTableExtension.test.ts @@ -98,6 +98,43 @@ describe('TableExtension', () => { }); }); + it('repaints existing tables when hasHorizontalScroll toggles', async () => { + const div = document.createElement('div'); + editor.setRootElement(div); + editor.update( + () => { + $getRoot().selectEnd(); + editor.dispatchCommand(INSERT_TABLE_COMMAND, {columns: '2', rows: '2'}); + }, + {discrete: true}, + ); + + const {hasHorizontalScroll} = getExtensionDependencyFromEditor( + editor, + TableExtension, + ).output; + + // Default config enables horizontal scroll: the table is wrapped in the + // scrollable
. + expect(div.querySelector('.table-scrollable-wrapper > table')).not.toBe( + null, + ); + + // Toggling the signal re-renders existing tables via a (deferred) full + // reconcile, removing the wrapper. + hasHorizontalScroll.value = false; + await Promise.resolve(); + expect(div.querySelector('.table-scrollable-wrapper')).toBe(null); + expect(div.querySelector('table')).not.toBe(null); + + // And restored when re-enabled. + hasHorizontalScroll.value = true; + await Promise.resolve(); + expect(div.querySelector('.table-scrollable-wrapper > table')).not.toBe( + null, + ); + }); + it('Prevents nested tables by default', async () => { editor.update( () => { diff --git a/packages/lexical-utils/src/index.ts b/packages/lexical-utils/src/index.ts index 5efd3c5c8a7..94cb3a03c7b 100644 --- a/packages/lexical-utils/src/index.ts +++ b/packages/lexical-utils/src/index.ts @@ -25,6 +25,7 @@ import { $cloneWithProperties, $createParagraphNode, $findMatchingParent, + $fullReconcile, $getAdjacentChildCaret, $getAdjacentSiblingOrParentSiblingCaret, $getCaretInDirection, @@ -477,7 +478,6 @@ export function $restoreEditorState( editor: LexicalEditor, editorState: EditorState, ): void { - const FULL_RECONCILE = 2; const nodeMap = new Map(); const activeEditorState = editor._pendingEditorState; @@ -489,7 +489,7 @@ export function $restoreEditorState( activeEditorState._nodeMap = nodeMap; } - editor._dirtyType = FULL_RECONCILE; + $fullReconcile(); const selection = editorState._selection; $setSelection(selection === null ? null : selection.clone()); } diff --git a/packages/lexical-website/docs/concepts/node-state.md b/packages/lexical-website/docs/concepts/node-state.md index 20fb873cf36..05a41f9ccf8 100644 --- a/packages/lexical-website/docs/concepts/node-state.md +++ b/packages/lexical-website/docs/concepts/node-state.md @@ -327,20 +327,17 @@ Current: - Pre-registration system for nodes to declare expected state and serialize them as top-level properties (`flat`) with `$config` (see [#7260](https://github.com/facebook/lexical/issues/7260)). +- Can be integrated with + [DOMRenderExtension](../serialization/dom-render.md) for editor + rendering and HTML export +- Can be integrated with + [DOMImportExtension](../serialization/dom-import.md) for HTML import Future: -- Does not yet integrate directly with importDOM, createDOM or - exportDOM (see [#7259](https://github.com/facebook/lexical/issues/7259)) - Does not yet support direct integration with Yjs, e.g. you can not store a Y.Map as a NodeState value (see [#7293](https://github.com/facebook/lexical/issues/7293)) -- There isn't yet an easy way to listen for updates to NodeState - without registering listeners for every class - (see [#7321](https://github.com/facebook/lexical/pull/7321)) -- Similarly, there isn't the equivalent of a node transform for - NodeState. Transforms must be registered on individual node - classes. ## Node State Style Example diff --git a/packages/lexical-website/docs/serialization/dom-import.md b/packages/lexical-website/docs/serialization/dom-import.md index 3510f06a599..c1635c3a12c 100644 --- a/packages/lexical-website/docs/serialization/dom-import.md +++ b/packages/lexical-website/docs/serialization/dom-import.md @@ -15,6 +15,8 @@ The legacy static `importDOM` machinery and `$generateNodesFromDOM` entry are unchanged and remain the supported default for production apps that don't want to track an experimental API. +The DOMImportExtension API was introduced in Lexical v0.45.0 + ::: The DOM import system in `@lexical/html` lets you convert any HTML or diff --git a/packages/lexical-website/docs/serialization/dom-render.md b/packages/lexical-website/docs/serialization/dom-render.md index bfed141d631..3f2679f5809 100644 --- a/packages/lexical-website/docs/serialization/dom-render.md +++ b/packages/lexical-website/docs/serialization/dom-render.md @@ -16,6 +16,9 @@ default `$generateHtmlFromNodes` entry are unchanged and remain the supported default for production apps that don't want to track an experimental API. +The DOMRenderExtension API was introduced in Lexical v0.44.0, +with significant additions in v0.45.0 + ::: `DOMRenderExtension` lets you override how Lexical nodes are rendered @@ -126,6 +129,10 @@ you always want to layer on top of what others have already done. ::: +`domOverride` also accepts an optional third `options` argument to install +an override only under certain conditions — see +[Conditional overrides](#conditional-overrides). + ### Matching nodes `domOverride` takes either `'*'` (matches every node) or an array of @@ -366,6 +373,14 @@ const html = $withRenderContext( )(() => $generateHtmlFromNodes(editor, selection)); ``` +For a value that should **persist on the editor** rather than scope to a +single callback, set it imperatively with `$setRenderContextValue` (or +`$updateRenderContextValue` for an updater). This is the editor-scoped, +persistent counterpart to `$withRenderContext`, and it's what drives +[conditional overrides](#conditional-overrides): a write that changes a value +read by a `disabledForEditor` predicate recompiles the render config and +re-renders the affected nodes. + ### Built-in render states `@lexical/html` ships two render states out of the box: @@ -380,6 +395,132 @@ const html = $withRenderContext( the root node should appear differently in a full-document export than as a child of some other element. +## Conditional overrides + +By default every override is always installed. Pass an optional third +`options` argument to `domOverride` to install an override only under certain +conditions, decided purely from the [render context](#render-context): + +```ts +domOverride(nodes, config, { + // Install in the editor's render pipeline (reconciliation + the base for + // export) only when this returns false. Default: always installed. + disabledForEditor?: (ctx) => boolean, + // Participate in a single export/generate session only when this returns + // false. Default: always participates. + disabledForSession?: (ctx) => boolean, +}); +``` + +Each predicate receives a read-only view of the render context +(`ctx.get(state)`) and decides *whether the override exists*, rather than +running on every node and bailing out internally. + +### `disabledForEditor` — runtime toggles + +`disabledForEditor` reads the **persistent editor context** and gates whether +the override is part of the editor's compiled render config. Since the +in-editor render path uses that config, this is the scope that controls live +reconciliation. + +Toggle it with `$setRenderContextValue` (see +[Render context](#render-context)). When a write flips the predicate's result, +the config is recompiled and the affected nodes are re-rendered — recreating +their DOM, since an override that produced or decorated an element can only be +undone by a fresh `createDOM`. + +```ts +import { + $setRenderContextValue, + createRenderState, + domOverride, + DOMRenderExtension, +} from '@lexical/html'; +import {LineBreakNode} from 'lexical'; + +const LineBreakWrapDisabled = createRenderState( + 'lineBreakWrapDisabled', + () => false, +); + +configExtension(DOMRenderExtension, { + overrides: [ + domOverride( + [LineBreakNode], + { + $createDOM(node, $next) { + const wrapper = document.createElement('span'); + wrapper.className = 'visible-linebreak'; + wrapper.appendChild($next()); + return wrapper; + }, + // … $getDOMSlot to expose the inner
, $updateDOM to recreate when + // the wrap state changes … + }, + {disabledForEditor: (ctx) => ctx.get(LineBreakWrapDisabled)}, + ), + ], +}); + +// Later — e.g. from a settings change. The override is removed from the +// pipeline entirely when disabled (no per-node checks remain), and existing +// line breaks re-render without the wrapper. +$setRenderContextValue(LineBreakWrapDisabled, true, editor); +``` + +Because the override isn't in the dispatch chain at all when disabled, there's +no per-node cost — the advantage over checking a flag inside the hook. + +### `disabledForSession` — per-export gating + +`disabledForSession` is evaluated once at the start of each export/generate +session (`$generateHtmlFromNodes`, `$generateDOMFromNodes`, +`$generateDOMFromRoot`) against that session's context. It controls whether the +override participates in *that walk only*, and has **no effect on live +reconciliation** — reconciliation isn't a session, so there's nothing for the +predicate to read. + +This suits export transforms you only want for certain serializations (e.g. a +"terse" copy) without paying for the middleware on every export: + +```ts +const TerseExport = createRenderState('terseExport', () => false); + +configExtension(DOMRenderExtension, { + overrides: [ + domOverride( + '*', + { + $exportDOM(node, $next) { + const result = $next(); + // … strip theme classes / unneeded styles … + return result; + }, + }, + {disabledForSession: (ctx) => !ctx.get(TerseExport)}, + ), + ], +}); + +// A normal export skips the override entirely… +const html = editor.read(() => $generateHtmlFromNodes(editor)); + +// …while a terse export opts in for that one walk: +const terseHtml = editor.read(() => + $withRenderContext([contextValue(TerseExport, true)], editor)(() => + $generateHtmlFromNodes(editor), + ), +); +``` + +### Choosing a scope + +| You want to… | Use | +| --- | --- | +| Turn a render behavior on/off for the whole editor at runtime | `disabledForEditor` + `$setRenderContextValue` | +| Include an export transform only for certain serializations | `disabledForSession` + `$withRenderContext` | +| Branch behavior inside an always-installed override | read the context with `$getRenderContextValue` (no options needed) | + ## Entry points Three top-level helpers consume the configured overrides: @@ -405,6 +546,9 @@ Current: `RenderContextRoot`) lets overrides branch on the calling mode. - A single declaration applies to both in-editor reconciliation and HTML export. +- Conditional installation via `disabledForEditor` / `disabledForSession`, + with imperative `$setRenderContextValue` / `$updateRenderContextValue` to + toggle editor-scoped overrides at runtime. Future: diff --git a/packages/lexical/src/LexicalSelection.ts b/packages/lexical/src/LexicalSelection.ts index 57418ca3078..31a12b30a7d 100644 --- a/packages/lexical/src/LexicalSelection.ts +++ b/packages/lexical/src/LexicalSelection.ts @@ -80,12 +80,14 @@ import { $getRoot, $hasAncestor, $isRootOrShadowRoot, + $isSelectionCapturedInDecorator, $isTokenOrSegmented, $isTokenOrTab, $setCompositionKey, doesContainSurrogatePair, getDOMSelection, getElementByKeyOrThrow, + getNodeKeyFromDOMNode, getWindow, INTERNAL_$isBlock, isHTMLElement, @@ -677,7 +679,7 @@ export class RangeSelection implements BaseSelection { if (resolvedSelectionPoints === null) { return; } - const [anchorPoint, focusPoint] = resolvedSelectionPoints; + const [anchorPoint, focusPoint, dirty] = resolvedSelectionPoints; this.anchor.set( anchorPoint.key, anchorPoint.offset, @@ -685,6 +687,9 @@ export class RangeSelection implements BaseSelection { true, ); this.focus.set(focusPoint.key, focusPoint.offset, focusPoint.type, true); + if (dirty) { + this.dirty = true; + } // Firefox will use an element point rather than a text point in some cases, // so we normalize for that $normalizeSelection(this); @@ -2285,9 +2290,17 @@ function $internalResolveSelectionPoint( offset: number, lastPoint: null | PointType, editor: LexicalEditor, -): null | PointType { +): null | [point: PointType, dirty: boolean] { let resolvedOffset = offset; let resolvedNode: TextNode | LexicalNode | null; + // True when the DOM position is not directly representable in the + // Lexical tree (e.g. the caret landed inside a void/empty element + // such as or in another unmanaged subtree) and the resolution + // had to walk up to a Lexical ancestor. The caller marks the + // resulting selection dirty so the reconciler writes a valid DOM + // caret back instead of leaving the user's cursor "stuck" inside + // unmanaged DOM. + let dirty = false; // If we have selection on an element, we will // need to figure out (using the offset) what text // node should be selected. @@ -2303,10 +2316,38 @@ function $internalResolveSelectionPoint( const blockCursorElement = editor._blockCursorElement; // If the anchor is the same as length, then this means we // need to select the very last text node. - if (resolvedOffset === childNodesLength) { + if (resolvedOffset === childNodesLength && childNodesLength > 0) { moveSelectionToEnd = true; resolvedOffset = childNodesLength - 1; } + if ( + getNodeKeyFromDOMNode(dom, editor) === undefined && + dom !== editor.getRootElement() && + !$isSelectionCapturedInDecorator(dom) + ) { + // The DOM caret is sitting on a node that has no Lexical key + // (e.g. inside an unmanaged , or any unmanaged + // scaffolding around a DOMSlot — wrap elements, contenteditable=false + // labels, badges, etc.). Resolution will walk up to find a Lexical + // ancestor below, so the resulting Lexical position will not + // correspond to where the DOM caret currently is. Mark the + // selection dirty so the reconciler writes a valid DOM caret back + // at the resolved Lexical position. + // + // Exceptions where the DOM caret is intentionally somewhere + // Lexical doesn't own and we should NOT force-sync it: + // - the editor root element (tracked separately in + // _keyToDOMMap as 'root'; has no __lexicalKey_* attribute); + // - anything inside a DecoratorNode subtree (the decorator owns + // its own DOM and may manage its own selection — for inputs + // isSelectionCapturedInDecoratorInput rejects earlier, but + // non-input decorator content also shouldn't be force-synced). + // + // Void elements that ARE Lexical nodes (LineBreakNode
, + // empty decorator containers, etc.) have keys, so this check + // leaves their existing resolution-to-parent behavior alone. + dirty = true; + } let childDOM = childNodes[resolvedOffset]; let hasBlockCursor = false; if (childDOM === blockCursorElement) { @@ -2419,7 +2460,10 @@ function $internalResolveSelectionPoint( resolvedElement = resolvedElement.getParentOrThrow(); } if ($isElementNode(resolvedElement)) { - return $createPoint(resolvedElement.__key, resolvedOffset, 'element'); + return [ + $createPoint(resolvedElement.__key, resolvedOffset, 'element'), + dirty, + ]; } } } else { @@ -2429,11 +2473,14 @@ function $internalResolveSelectionPoint( if (!$isTextNode(resolvedNode)) { return null; } - return $createPoint( - resolvedNode.__key, - $getTextNodeOffset(resolvedNode, resolvedOffset, 'clamp'), - 'text', - ); + return [ + $createPoint( + resolvedNode.__key, + $getTextNodeOffset(resolvedNode, resolvedOffset, 'clamp'), + 'text', + ), + dirty, + ]; } function resolveSelectionPointOnBoundary( @@ -2525,7 +2572,7 @@ function $internalResolveSelectionPoints( focusOffset: number, editor: LexicalEditor, lastSelection: null | BaseSelection, -): null | [PointType, PointType] { +): null | [anchor: PointType, focus: PointType, dirty: boolean] { if ( anchorDOM === null || focusDOM === null || @@ -2533,24 +2580,26 @@ function $internalResolveSelectionPoints( ) { return null; } - const resolvedAnchorPoint = $internalResolveSelectionPoint( + const resolvedAnchor = $internalResolveSelectionPoint( anchorDOM, anchorOffset, $isRangeSelection(lastSelection) ? lastSelection.anchor : null, editor, ); - if (resolvedAnchorPoint === null) { + if (resolvedAnchor === null) { return null; } - const resolvedFocusPoint = $internalResolveSelectionPoint( + const resolvedFocus = $internalResolveSelectionPoint( focusDOM, focusOffset, $isRangeSelection(lastSelection) ? lastSelection.focus : null, editor, ); - if (resolvedFocusPoint === null) { + if (resolvedFocus === null) { return null; } + const [resolvedAnchorPoint, anchorDirty] = resolvedAnchor; + const [resolvedFocusPoint, focusDirty] = resolvedFocus; if (__DEV__) { $validatePoint('anchor', resolvedAnchorPoint); $validatePoint('focus', resolvedFocusPoint); @@ -2576,7 +2625,7 @@ function $internalResolveSelectionPoints( lastSelection, ); - return [resolvedAnchorPoint, resolvedFocusPoint]; + return [resolvedAnchorPoint, resolvedFocusPoint, anchorDirty || focusDirty]; } export function $isBlockElementNode( @@ -2716,7 +2765,8 @@ export function $internalCreateRangeSelection( if (resolvedSelectionPoints === null) { return null; } - const [resolvedAnchorPoint, resolvedFocusPoint] = resolvedSelectionPoints; + const [resolvedAnchorPoint, resolvedFocusPoint, dirty] = + resolvedSelectionPoints; let format = 0; let style = ''; if ($isRangeSelection(lastSelection)) { @@ -2735,12 +2785,16 @@ export function $internalCreateRangeSelection( } } } - return new RangeSelection( + const newSelection = new RangeSelection( resolvedAnchorPoint, resolvedFocusPoint, format, style, ); + if (dirty) { + newSelection.dirty = true; + } + return newSelection; } function $validatePoint(name: 'anchor' | 'focus', point: PointType): void { diff --git a/packages/lexical/src/LexicalUpdates.ts b/packages/lexical/src/LexicalUpdates.ts index c7a2d6474d0..aa95268b6cd 100644 --- a/packages/lexical/src/LexicalUpdates.ts +++ b/packages/lexical/src/LexicalUpdates.ts @@ -130,6 +130,19 @@ export function getActiveEditor(): LexicalEditor { return activeEditor; } +/** + * Schedule a full reconcile of the active editor, so that every node is + * re-rendered through the current {@link EditorDOMRenderConfig} on the next + * commit. Unlike {@link LexicalNode.markDirty}, this does not clone or + * otherwise mutate the node map, so no mutation/collaboration listeners + * observe a change. Must be called within an `editor.update`. + * + * @internal + */ +export function $fullReconcile(): void { + getActiveEditor()._dirtyType = FULL_RECONCILE; +} + function collectBuildInformation(): string { let compatibleEditors = 0; const incompatibleEditors = new Set(); @@ -422,7 +435,7 @@ export function parseEditorState( editor._dirtyElements = new Map(); editor._dirtyLeaves = new Set(); editor._cloneNotNeeded = new Set(); - editor._dirtyType = 0; + editor._dirtyType = NO_DIRTY_NODES; activeEditorState = editorState; isReadOnlyMode = false; activeEditor = editor; diff --git a/packages/lexical/src/LexicalUtils.ts b/packages/lexical/src/LexicalUtils.ts index c244fdddfb7..5d25e6793a1 100644 --- a/packages/lexical/src/LexicalUtils.ts +++ b/packages/lexical/src/LexicalUtils.ts @@ -69,6 +69,7 @@ import { ELEMENT_TYPE_TO_FORMAT, HAS_DIRTY_NODES, LTR_REGEX, + NO_DIRTY_NODES, PROTOTYPE_CONFIG_METHOD, RTL_REGEX, TEXT_TYPE_TO_FORMAT, @@ -341,7 +342,10 @@ export function $setNodeKey( editor._dirtyLeaves.add(key); } editor._cloneNotNeeded.add(key); - editor._dirtyType = HAS_DIRTY_NODES; + // Don't downgrade FULL_RECONCILE; upgrade only when nothing has been marked yet. + if (editor._dirtyType === NO_DIRTY_NODES) { + editor._dirtyType = HAS_DIRTY_NODES; + } node.__key = key; } @@ -483,7 +487,10 @@ export function internalMarkNodeAsDirty(node: LexicalNode): void { internalMarkParentElementsAsDirty(parent, nodeMap, dirtyElements); } const key = latest.__key; - editor._dirtyType = HAS_DIRTY_NODES; + // Don't downgrade FULL_RECONCILE; upgrade only when nothing has been marked yet. + if (editor._dirtyType === NO_DIRTY_NODES) { + editor._dirtyType = HAS_DIRTY_NODES; + } if ($isElementNode(node)) { dirtyElements.set(key, true); } else { diff --git a/packages/lexical/src/index.ts b/packages/lexical/src/index.ts index 5a3ed921795..fd9dbb62d19 100644 --- a/packages/lexical/src/index.ts +++ b/packages/lexical/src/index.ts @@ -254,7 +254,11 @@ export { type RawTextVisitor, tokenizeRawText, } from './LexicalSelection'; -export {$parseSerializedNode, isCurrentlyReadOnlyMode} from './LexicalUpdates'; +export { + $fullReconcile, + $parseSerializedNode, + isCurrentlyReadOnlyMode, +} from './LexicalUpdates'; export { $addUpdateTag, $applyNodeReplacement, diff --git a/packages/lexical/src/nodes/LexicalRootNode.ts b/packages/lexical/src/nodes/LexicalRootNode.ts index abea45add2a..6c17486a6d4 100644 --- a/packages/lexical/src/nodes/LexicalRootNode.ts +++ b/packages/lexical/src/nodes/LexicalRootNode.ts @@ -48,15 +48,11 @@ export class RootNode extends ElementNode { getTextContent(): string { const cachedText = this.__cachedText; - if ( - isCurrentlyReadOnlyMode() || - getActiveEditor()._dirtyType === NO_DIRTY_NODES - ) { - if (cachedText !== null) { - return cachedText; - } - } - return super.getTextContent(); + return cachedText !== null && + (isCurrentlyReadOnlyMode() || + getActiveEditor()._dirtyType === NO_DIRTY_NODES) + ? cachedText + : super.getTextContent(); } remove(): never { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 38d8e4e3894..fcb53096cfb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -66,8 +66,8 @@ importers: specifier: workspace:* version: link:packages/lexical-eslint-plugin-internal '@playwright/test': - specifier: ^1.59.1 - version: 1.59.1 + specifier: ^1.60.0 + version: 1.60.0 '@prettier/sync': specifier: ^0.6.1 version: 0.6.1(prettier@3.8.1) @@ -3600,8 +3600,8 @@ packages: resolution: {integrity: sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==} engines: {node: '>=20.0.0'} - '@playwright/test@1.59.1': - resolution: {integrity: sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==} + '@playwright/test@1.60.0': + resolution: {integrity: sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==} engines: {node: '>=18'} hasBin: true @@ -10084,13 +10084,13 @@ packages: platform@1.3.6: resolution: {integrity: sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==} - playwright-core@1.59.1: - resolution: {integrity: sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==} + playwright-core@1.60.0: + resolution: {integrity: sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==} engines: {node: '>=18'} hasBin: true - playwright@1.59.1: - resolution: {integrity: sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==} + playwright@1.60.0: + resolution: {integrity: sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==} engines: {node: '>=18'} hasBin: true @@ -16545,9 +16545,9 @@ snapshots: tslib: 2.8.1 tsyringe: 4.10.0 - '@playwright/test@1.59.1': + '@playwright/test@1.60.0': dependencies: - playwright: 1.59.1 + playwright: 1.60.0 '@pnpm/config.env-replace@1.1.0': {} @@ -24119,11 +24119,11 @@ snapshots: platform@1.3.6: {} - playwright-core@1.59.1: {} + playwright-core@1.60.0: {} - playwright@1.59.1: + playwright@1.60.0: dependencies: - playwright-core: 1.59.1 + playwright-core: 1.60.0 optionalDependencies: fsevents: 2.3.2 diff --git a/scripts/__tests__/integration/fixtures/lexical-esm-astro-react/package.json b/scripts/__tests__/integration/fixtures/lexical-esm-astro-react/package.json index acd999dfaec..97509c299bd 100644 --- a/scripts/__tests__/integration/fixtures/lexical-esm-astro-react/package.json +++ b/scripts/__tests__/integration/fixtures/lexical-esm-astro-react/package.json @@ -24,7 +24,7 @@ }, "sideEffects": false, "devDependencies": { - "@playwright/test": "^1.51.1", + "@playwright/test": "^1.60.0", "typescript": "^5.9.2" }, "optionalDependencies": { diff --git a/scripts/update-tsconfig.mjs b/scripts/update-tsconfig.mjs index 76dbfbf39f9..46755e7c14e 100644 --- a/scripts/update-tsconfig.mjs +++ b/scripts/update-tsconfig.mjs @@ -114,13 +114,16 @@ const WEBSITE_EXTRA_PATHS = [ ['@site/*', ['./*']], ]; -// The monorepo's package path aliases only need to exist for the configs -// that resolve undeclared cross-package imports: the unit-test typecheck -// (tests import sibling packages without declaring them) and the website -// (it type-checks the @examples sources). The root tsconfig.json and -// tsconfig.build.json are hand-maintained and resolve via the `source` -// export condition (customConditions) instead, so they are not generated -// here. +// The monorepo's package path aliases exist for two reasons: +// - The unit-test typecheck (tsconfig.test.json) needs them because tests +// import sibling packages without declaring them, including deep subpaths +// like `*/src/__tests__/utils` that aren't part of the public exports. +// - VSCode walks up from a test file to the nearest tsconfig.json (the root +// one) and uses that for editor diagnostics. To make those resolve to the +// same source the unit tests use, the root tsconfig also carries the test +// paths. tsconfig.build.json is hand-maintained and resolves via the +// `source` export condition (customConditions) instead — it is not +// generated here. async function updateAllTsconfig() { const prettierConfig = (await prettier.resolveConfig(new URL(import.meta.url).pathname)) || {}; @@ -130,6 +133,12 @@ async function updateAllTsconfig() { prettierConfig, test: true, }); + await updateTsconfig({ + extraPaths: [], + jsonFileName: './tsconfig.json', + prettierConfig, + test: true, + }); await updateTsconfig({ extraPaths: WEBSITE_EXTRA_PATHS, jsonFileName: './packages/lexical-website/tsconfig.json', diff --git a/tsconfig.json b/tsconfig.json index 91ff3ae57a6..85495a082db 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -11,7 +11,298 @@ "skipLibCheck": true, "resolveJsonModule": true, "allowJs": true, - "customConditions": ["source"] + "customConditions": ["source"], + "paths": { + "lexical": ["./packages/lexical/src/index.ts"], + "@lexical/clipboard": ["./packages/lexical-clipboard/src/index.ts"], + "@lexical/code": ["./packages/lexical-code/src/index.ts"], + "@lexical/code-core": ["./packages/lexical-code-core/src/index.ts"], + "@lexical/code-prism": ["./packages/lexical-code-prism/src/index.ts"], + "@lexical/code-shiki": ["./packages/lexical-code-shiki/src/index.ts"], + "@lexical/devtools-core": [ + "./packages/lexical-devtools-core/src/index.ts" + ], + "@lexical/dragon": ["./packages/lexical-dragon/src/index.ts"], + "@lexical/eslint-plugin": [ + "./packages/lexical-eslint-plugin/src/index.ts" + ], + "@lexical/extension": ["./packages/lexical-extension/src/index.ts"], + "@lexical/file": ["./packages/lexical-file/src/index.ts"], + "@lexical/hashtag": ["./packages/lexical-hashtag/src/index.ts"], + "@lexical/headless": ["./packages/lexical-headless/src/index.ts"], + "@lexical/headless/dom": ["./packages/lexical-headless/src/dom.ts"], + "@lexical/history": ["./packages/lexical-history/src/index.ts"], + "@lexical/html": ["./packages/lexical-html/src/index.ts"], + "@lexical/internal/devInvariant": [ + "./packages/lexical-internal/src/devInvariant.ts" + ], + "@lexical/internal/formatDevErrorMessage": [ + "./packages/lexical-internal/src/formatDevErrorMessage.ts" + ], + "@lexical/internal/formatDevWarningMessage": [ + "./packages/lexical-internal/src/formatDevWarningMessage.ts" + ], + "@lexical/internal/formatProdErrorMessage": [ + "./packages/lexical-internal/src/formatProdErrorMessage.ts" + ], + "@lexical/internal/formatProdWarningMessage": [ + "./packages/lexical-internal/src/formatProdWarningMessage.ts" + ], + "@lexical/internal/invariant": [ + "./packages/lexical-internal/src/invariant.ts" + ], + "@lexical/internal/version": [ + "./packages/lexical-internal/src/version.ts" + ], + "@lexical/internal/warnOnlyOnce": [ + "./packages/lexical-internal/src/warnOnlyOnce.ts" + ], + "@lexical/link": ["./packages/lexical-link/src/index.ts"], + "@lexical/list": ["./packages/lexical-list/src/index.ts"], + "@lexical/mark": ["./packages/lexical-mark/src/index.ts"], + "@lexical/markdown": ["./packages/lexical-markdown/src/index.ts"], + "@lexical/offset": ["./packages/lexical-offset/src/index.ts"], + "@lexical/overflow": ["./packages/lexical-overflow/src/index.ts"], + "@lexical/plain-text": ["./packages/lexical-plain-text/src/index.ts"], + "@lexical/react/ExtensionComponent": [ + "./packages/lexical-react/src/ExtensionComponent.tsx" + ], + "@lexical/react/LexicalAutoEmbedPlugin": [ + "./packages/lexical-react/src/LexicalAutoEmbedPlugin.tsx" + ], + "@lexical/react/LexicalAutoFocusPlugin": [ + "./packages/lexical-react/src/LexicalAutoFocusPlugin.ts" + ], + "@lexical/react/LexicalAutoLinkPlugin": [ + "./packages/lexical-react/src/LexicalAutoLinkPlugin.ts" + ], + "@lexical/react/LexicalBlockWithAlignableContents": [ + "./packages/lexical-react/src/LexicalBlockWithAlignableContents.tsx" + ], + "@lexical/react/LexicalCharacterLimitPlugin": [ + "./packages/lexical-react/src/LexicalCharacterLimitPlugin.tsx" + ], + "@lexical/react/LexicalCheckListPlugin": [ + "./packages/lexical-react/src/LexicalCheckListPlugin.tsx" + ], + "@lexical/react/LexicalClearEditorPlugin": [ + "./packages/lexical-react/src/LexicalClearEditorPlugin.ts" + ], + "@lexical/react/LexicalClickableLinkPlugin": [ + "./packages/lexical-react/src/LexicalClickableLinkPlugin.tsx" + ], + "@lexical/react/LexicalCollaborationContext": [ + "./packages/lexical-react/src/LexicalCollaborationContext.tsx" + ], + "@lexical/react/LexicalCollaborationPlugin": [ + "./packages/lexical-react/src/LexicalCollaborationPlugin.tsx" + ], + "@lexical/react/LexicalComposer": [ + "./packages/lexical-react/src/LexicalComposer.tsx" + ], + "@lexical/react/LexicalComposerContext": [ + "./packages/lexical-react/src/LexicalComposerContext.ts" + ], + "@lexical/react/LexicalContentEditable": [ + "./packages/lexical-react/src/LexicalContentEditable.tsx" + ], + "@lexical/react/LexicalDecoratorBlockNode": [ + "./packages/lexical-react/src/LexicalDecoratorBlockNode.ts" + ], + "@lexical/react/LexicalDraggableBlockPlugin": [ + "./packages/lexical-react/src/LexicalDraggableBlockPlugin.tsx" + ], + "@lexical/react/LexicalEditorRefPlugin": [ + "./packages/lexical-react/src/LexicalEditorRefPlugin.tsx" + ], + "@lexical/react/LexicalErrorBoundary": [ + "./packages/lexical-react/src/LexicalErrorBoundary.tsx" + ], + "@lexical/react/LexicalExtensionComposer": [ + "./packages/lexical-react/src/LexicalExtensionComposer.tsx" + ], + "@lexical/react/LexicalExtensionEditorComposer": [ + "./packages/lexical-react/src/LexicalExtensionEditorComposer.tsx" + ], + "@lexical/react/LexicalHashtagPlugin": [ + "./packages/lexical-react/src/LexicalHashtagPlugin.ts" + ], + "@lexical/react/LexicalHistoryPlugin": [ + "./packages/lexical-react/src/LexicalHistoryPlugin.ts" + ], + "@lexical/react/LexicalHorizontalRuleNode": [ + "./packages/lexical-react/src/LexicalHorizontalRuleNode.tsx" + ], + "@lexical/react/LexicalHorizontalRulePlugin": [ + "./packages/lexical-react/src/LexicalHorizontalRulePlugin.ts" + ], + "@lexical/react/LexicalLinkPlugin": [ + "./packages/lexical-react/src/LexicalLinkPlugin.ts" + ], + "@lexical/react/LexicalListPlugin": [ + "./packages/lexical-react/src/LexicalListPlugin.ts" + ], + "@lexical/react/LexicalMarkdownShortcutPlugin": [ + "./packages/lexical-react/src/LexicalMarkdownShortcutPlugin.tsx" + ], + "@lexical/react/LexicalNestedComposer": [ + "./packages/lexical-react/src/LexicalNestedComposer.tsx" + ], + "@lexical/react/LexicalNodeContextMenuPlugin": [ + "./packages/lexical-react/src/LexicalNodeContextMenuPlugin.tsx" + ], + "@lexical/react/LexicalNodeEventPlugin": [ + "./packages/lexical-react/src/LexicalNodeEventPlugin.ts" + ], + "@lexical/react/LexicalNodeMenuPlugin": [ + "./packages/lexical-react/src/LexicalNodeMenuPlugin.tsx" + ], + "@lexical/react/LexicalOnChangePlugin": [ + "./packages/lexical-react/src/LexicalOnChangePlugin.ts" + ], + "@lexical/react/LexicalPlainTextPlugin": [ + "./packages/lexical-react/src/LexicalPlainTextPlugin.tsx" + ], + "@lexical/react/LexicalRichTextPlugin": [ + "./packages/lexical-react/src/LexicalRichTextPlugin.tsx" + ], + "@lexical/react/LexicalSelectionAlwaysOnDisplay": [ + "./packages/lexical-react/src/LexicalSelectionAlwaysOnDisplay.tsx" + ], + "@lexical/react/LexicalTabIndentationPlugin": [ + "./packages/lexical-react/src/LexicalTabIndentationPlugin.tsx" + ], + "@lexical/react/LexicalTableOfContentsPlugin": [ + "./packages/lexical-react/src/LexicalTableOfContentsPlugin.tsx" + ], + "@lexical/react/LexicalTablePlugin": [ + "./packages/lexical-react/src/LexicalTablePlugin.ts" + ], + "@lexical/react/LexicalTreeView": [ + "./packages/lexical-react/src/LexicalTreeView.tsx" + ], + "@lexical/react/LexicalTypeaheadMenuPlugin": [ + "./packages/lexical-react/src/LexicalTypeaheadMenuPlugin.tsx" + ], + "@lexical/react/ReactExtension": [ + "./packages/lexical-react/src/ReactExtension.tsx" + ], + "@lexical/react/ReactPluginHostExtension": [ + "./packages/lexical-react/src/ReactPluginHostExtension.tsx" + ], + "@lexical/react/ReactProviderExtension": [ + "./packages/lexical-react/src/ReactProviderExtension.tsx" + ], + "@lexical/react/TreeViewExtension": [ + "./packages/lexical-react/src/TreeViewExtension.tsx" + ], + "@lexical/react/useExtensionComponent": [ + "./packages/lexical-react/src/useExtensionComponent.tsx" + ], + "@lexical/react/useExtensionSignalValue": [ + "./packages/lexical-react/src/useExtensionSignalValue.ts" + ], + "@lexical/react/useLexicalEditable": [ + "./packages/lexical-react/src/useLexicalEditable.ts" + ], + "@lexical/react/useLexicalIsTextContentEmpty": [ + "./packages/lexical-react/src/useLexicalIsTextContentEmpty.ts" + ], + "@lexical/react/useLexicalNodeSelection": [ + "./packages/lexical-react/src/useLexicalNodeSelection.ts" + ], + "@lexical/react/useLexicalSubscription": [ + "./packages/lexical-react/src/useLexicalSubscription.tsx" + ], + "@lexical/react/useLexicalTextEntity": [ + "./packages/lexical-react/src/useLexicalTextEntity.ts" + ], + "@lexical/rich-text": ["./packages/lexical-rich-text/src/index.ts"], + "@lexical/selection": ["./packages/lexical-selection/src/index.ts"], + "@lexical/table": ["./packages/lexical-table/src/index.ts"], + "@lexical/tailwind": ["./packages/lexical-tailwind/src/index.ts"], + "@lexical/text": ["./packages/lexical-text/src/index.ts"], + "@lexical/utils": ["./packages/lexical-utils/src/index.ts"], + "@lexical/yjs": ["./packages/lexical-yjs/src/index.ts"], + "lexical/src": ["./packages/lexical/src"], + "lexical/src/__tests__/utils": [ + "./packages/lexical/src/__tests__/utils/index.tsx" + ], + "lexical/src/*": ["./packages/lexical/src/*"], + "@lexical/clipboard/src": ["./packages/lexical-clipboard/src"], + "@lexical/clipboard/src/*": ["./packages/lexical-clipboard/src/*"], + "@lexical/code/src": ["./packages/lexical-code/src"], + "@lexical/code/src/*": ["./packages/lexical-code/src/*"], + "@lexical/code-core/src": ["./packages/lexical-code-core/src"], + "@lexical/code-core/src/*": ["./packages/lexical-code-core/src/*"], + "@lexical/code-prism/src": ["./packages/lexical-code-prism/src"], + "@lexical/code-prism/src/*": ["./packages/lexical-code-prism/src/*"], + "@lexical/code-shiki/src": ["./packages/lexical-code-shiki/src"], + "@lexical/code-shiki/src/*": ["./packages/lexical-code-shiki/src/*"], + "@lexical/devtools-core/src": ["./packages/lexical-devtools-core/src"], + "@lexical/devtools-core/src/*": [ + "./packages/lexical-devtools-core/src/*" + ], + "@lexical/dragon/src": ["./packages/lexical-dragon/src"], + "@lexical/dragon/src/*": ["./packages/lexical-dragon/src/*"], + "@lexical/eslint-plugin/src": ["./packages/lexical-eslint-plugin/src"], + "@lexical/eslint-plugin/src/*": [ + "./packages/lexical-eslint-plugin/src/*" + ], + "@lexical/extension/src": ["./packages/lexical-extension/src"], + "@lexical/extension/src/*": ["./packages/lexical-extension/src/*"], + "@lexical/file/src": ["./packages/lexical-file/src"], + "@lexical/file/src/*": ["./packages/lexical-file/src/*"], + "@lexical/hashtag/src": ["./packages/lexical-hashtag/src"], + "@lexical/hashtag/src/*": ["./packages/lexical-hashtag/src/*"], + "@lexical/headless/src": ["./packages/lexical-headless/src"], + "@lexical/headless/src/__tests__/utils": [ + "./packages/lexical-headless/src/__tests__/utils/index.ts" + ], + "@lexical/headless/src/*": ["./packages/lexical-headless/src/*"], + "@lexical/history/src": ["./packages/lexical-history/src"], + "@lexical/history/src/*": ["./packages/lexical-history/src/*"], + "@lexical/html/src": ["./packages/lexical-html/src"], + "@lexical/html/src/*": ["./packages/lexical-html/src/*"], + "@lexical/internal/src": ["./packages/lexical-internal/src"], + "@lexical/internal/src/*": ["./packages/lexical-internal/src/*"], + "@lexical/link/src": ["./packages/lexical-link/src"], + "@lexical/link/src/*": ["./packages/lexical-link/src/*"], + "@lexical/list/src": ["./packages/lexical-list/src"], + "@lexical/list/src/*": ["./packages/lexical-list/src/*"], + "@lexical/mark/src": ["./packages/lexical-mark/src"], + "@lexical/mark/src/*": ["./packages/lexical-mark/src/*"], + "@lexical/markdown/src": ["./packages/lexical-markdown/src"], + "@lexical/markdown/src/*": ["./packages/lexical-markdown/src/*"], + "@lexical/offset/src": ["./packages/lexical-offset/src"], + "@lexical/offset/src/*": ["./packages/lexical-offset/src/*"], + "@lexical/overflow/src": ["./packages/lexical-overflow/src"], + "@lexical/overflow/src/*": ["./packages/lexical-overflow/src/*"], + "@lexical/plain-text/src": ["./packages/lexical-plain-text/src"], + "@lexical/plain-text/src/*": ["./packages/lexical-plain-text/src/*"], + "@lexical/react/src": ["./packages/lexical-react/src"], + "@lexical/react/src/__tests__/utils": [ + "./packages/lexical-react/src/__tests__/utils/index.tsx" + ], + "@lexical/react/src/*": ["./packages/lexical-react/src/*"], + "@lexical/rich-text/src": ["./packages/lexical-rich-text/src"], + "@lexical/rich-text/src/*": ["./packages/lexical-rich-text/src/*"], + "@lexical/selection/src": ["./packages/lexical-selection/src"], + "@lexical/selection/src/__tests__/utils": [ + "./packages/lexical-selection/src/__tests__/utils/index.ts" + ], + "@lexical/selection/src/*": ["./packages/lexical-selection/src/*"], + "@lexical/table/src": ["./packages/lexical-table/src"], + "@lexical/table/src/*": ["./packages/lexical-table/src/*"], + "@lexical/tailwind/src": ["./packages/lexical-tailwind/src"], + "@lexical/tailwind/src/*": ["./packages/lexical-tailwind/src/*"], + "@lexical/text/src": ["./packages/lexical-text/src"], + "@lexical/text/src/*": ["./packages/lexical-text/src/*"], + "@lexical/utils/src": ["./packages/lexical-utils/src"], + "@lexical/utils/src/*": ["./packages/lexical-utils/src/*"], + "@lexical/yjs/src": ["./packages/lexical-yjs/src"], + "@lexical/yjs/src/*": ["./packages/lexical-yjs/src/*"] + } }, "include": ["./libdefs", "./packages"], "exclude": [ @@ -23,7 +314,6 @@ "./packages/*/*.js", "./packages/lexical-devtools/**", "./packages/lexical-website/**", - "**/__tests__/**", "**/__bench__/**" ], "typedocOptions": {"logLevel": "Verbose"}