From eee99aff1e1b3dc84e1dbb32eb30da8a199922dd Mon Sep 17 00:00:00 2001 From: LeSingh1 Date: Sat, 8 Aug 2026 23:10:22 -0700 Subject: [PATCH 1/4] [lexical-history] Bug Fix: SharedHistoryExtension forwards the parent maxDepth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description `SharedHistoryExtension` redirects a nested editor's history signals at the parent's, so both editors share one undo stack. It forwards four of the five writable signals: ```js batch(() => { output.delay.value = parentOutput.delay.value; output.historyState.value = parentOutput.historyState.value; output.now.value = parentOutput.now.value; // Note that toggling the parent history will force this to be changed output.disabled.value = parentOutput.disabled.value; }); // maxDepth is never copied ``` `maxDepth` is a config-derived, writable `Signal` on `HistoryExtensionOutput` exactly like the others, and it caps the stack in `applyChange`. The child's `registerHistory` therefore runs with the *parent's* `historyState` but the *child's* own `maxDepth`, which defaults to `null` — no cap. So every history event originating in the nested editor pushes onto the shared `undoStack` without applying the limit the application configured on the parent. An app that sets `maxDepth` to bound memory silently loses that bound for anything typed in a nested editor (the playground's sticky notes use `SharedHistoryExtension`). The `HistoryExtensionOutput` docstring has the same omission — it enumerates `delay`, `disabled`, `historyState` and `now` as the signals `SharedHistoryExtension` redirects. `maxDepth` was added later and was missed in both places, so this updates the doc alongside the code. ## Test plan New unit test `packages/lexical-history/src/__tests__/unit/SharedHistoryMaxDepth.test.ts`. Deliberately a new file: PR #8953 (also mine) edits the `HistoryExtension maxDepth` block in `LexicalHistory.test.tsx`, and keeping these apart avoids a self-conflict. The second case asserts the four signals that already forward — it passes before and after, which is what pins the gap to `maxDepth` alone. ### Before ``` $ npx vitest run packages/lexical-history/src/__tests__/unit/SharedHistoryMaxDepth.test.ts × the child adopts the parent maxDepth 7ms AssertionError: expected null to be 7 // Object.is equality Tests 1 failed | 1 passed (2) ``` ### After ``` $ npx vitest run packages/lexical-history/src/__tests__/unit/SharedHistoryMaxDepth.test.ts Tests 2 passed (2) ``` --- .../unit/SharedHistoryMaxDepth.test.ts | 93 +++++++++++++++++++ packages/lexical-history/src/index.ts | 10 +- 2 files changed, 100 insertions(+), 3 deletions(-) create mode 100644 packages/lexical-history/src/__tests__/unit/SharedHistoryMaxDepth.test.ts diff --git a/packages/lexical-history/src/__tests__/unit/SharedHistoryMaxDepth.test.ts b/packages/lexical-history/src/__tests__/unit/SharedHistoryMaxDepth.test.ts new file mode 100644 index 00000000000..23a5f4c1ac5 --- /dev/null +++ b/packages/lexical-history/src/__tests__/unit/SharedHistoryMaxDepth.test.ts @@ -0,0 +1,93 @@ +/** + * 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, + getExtensionDependencyFromEditor, +} from '@lexical/extension'; +import {HistoryExtension, SharedHistoryExtension} from '@lexical/history'; +import {configExtension} from 'lexical'; +import {describe, expect, test} from 'vitest'; + +const PARENT_MAX_DEPTH = 7; +const PARENT_DELAY = 123; + +function buildParent() { + return buildEditorFromExtensions({ + dependencies: [ + configExtension(HistoryExtension, { + delay: PARENT_DELAY, + maxDepth: PARENT_MAX_DEPTH, + }), + ], + name: 'shared-max-depth-parent', + }); +} + +function buildChild() { + return buildEditorFromExtensions({ + dependencies: [SharedHistoryExtension], + name: 'shared-max-depth-child', + }); +} + +describe('SharedHistoryExtension forwards the parent history signals', () => { + test('the child adopts the parent maxDepth', () => { + using parent = buildParent(); + using child = buildChild(); + + const shared = getExtensionDependencyFromEditor( + child, + SharedHistoryExtension, + ); + shared.output.parentEditor.value = parent; + + const parentHistory = getExtensionDependencyFromEditor( + parent, + HistoryExtension, + ); + const childHistory = getExtensionDependencyFromEditor( + child, + HistoryExtension, + ); + + // Premise: the parent really carries the configured cap, and the child's + // own default differs from it. + expect(parentHistory.output.maxDepth.value).toBe(PARENT_MAX_DEPTH); + + // The child pushes onto the parent's shared undoStack, so it has to apply + // the parent's cap rather than its own default. + expect(childHistory.output.maxDepth.value).toBe(PARENT_MAX_DEPTH); + }); + + test('the already-forwarded signals still arrive', () => { + using parent = buildParent(); + using child = buildChild(); + + const shared = getExtensionDependencyFromEditor( + child, + SharedHistoryExtension, + ); + shared.output.parentEditor.value = parent; + + const parentHistory = getExtensionDependencyFromEditor( + parent, + HistoryExtension, + ); + const childHistory = getExtensionDependencyFromEditor( + child, + HistoryExtension, + ); + + expect(childHistory.output.delay.value).toBe(PARENT_DELAY); + expect(childHistory.output.now.value).toBe(parentHistory.output.now.value); + expect(childHistory.output.historyState.value).toBe( + parentHistory.output.historyState.value, + ); + }); +}); diff --git a/packages/lexical-history/src/index.ts b/packages/lexical-history/src/index.ts index 52bbd6fe875..c95a1cfad72 100644 --- a/packages/lexical-history/src/index.ts +++ b/packages/lexical-history/src/index.ts @@ -660,9 +660,9 @@ interface HistoryExtensionInit { /** * The output signals exposed by {@link HistoryExtension}. * - * Config-derived signals (`delay`, `disabled`, `historyState`, `now`) are - * writable so that peer extensions such as {@link SharedHistoryExtension} can - * redirect them at runtime. The `canUndo` / `canRedo` signals are + * Config-derived signals (`delay`, `disabled`, `historyState`, `maxDepth`, + * `now`) are writable so that peer extensions such as + * {@link SharedHistoryExtension} can redirect them at runtime. The `canUndo` / `canRedo` signals are * **readonly** for consumers — they are derived from the current * {@link HistoryState} and kept in sync automatically. */ @@ -819,6 +819,10 @@ export const SharedHistoryExtension = /* @__PURE__ */ defineExtension({ output.delay.value = parentOutput.delay.value; output.historyState.value = parentOutput.historyState.value; output.now.value = parentOutput.now.value; + // The cap must come from the parent too: the child pushes onto the + // parent's shared undoStack, so applying the child's own (default + // null) maxDepth would silently void the limit the app configured. + output.maxDepth.value = parentOutput.maxDepth.value; // Note that toggling the parent history will force this to be changed output.disabled.value = parentOutput.disabled.value; }); From 994f3c37b3cd2d2432d43c26419d69634cf5d258 Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Sun, 9 Aug 2026 10:37:59 -0700 Subject: [PATCH 2/4] Test all forwarded output signals --- .../unit/SharedHistoryMaxDepth.test.ts | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/packages/lexical-history/src/__tests__/unit/SharedHistoryMaxDepth.test.ts b/packages/lexical-history/src/__tests__/unit/SharedHistoryMaxDepth.test.ts index 23a5f4c1ac5..0f1b316c711 100644 --- a/packages/lexical-history/src/__tests__/unit/SharedHistoryMaxDepth.test.ts +++ b/packages/lexical-history/src/__tests__/unit/SharedHistoryMaxDepth.test.ts @@ -58,11 +58,11 @@ describe('SharedHistoryExtension forwards the parent history signals', () => { // Premise: the parent really carries the configured cap, and the child's // own default differs from it. - expect(parentHistory.output.maxDepth.value).toBe(PARENT_MAX_DEPTH); + expect(parentHistory.output.maxDepth.peek()).toBe(PARENT_MAX_DEPTH); // The child pushes onto the parent's shared undoStack, so it has to apply // the parent's cap rather than its own default. - expect(childHistory.output.maxDepth.value).toBe(PARENT_MAX_DEPTH); + expect(childHistory.output.maxDepth.peek()).toBe(PARENT_MAX_DEPTH); }); test('the already-forwarded signals still arrive', () => { @@ -83,11 +83,17 @@ describe('SharedHistoryExtension forwards the parent history signals', () => { child, HistoryExtension, ); - - expect(childHistory.output.delay.value).toBe(PARENT_DELAY); - expect(childHistory.output.now.value).toBe(parentHistory.output.now.value); - expect(childHistory.output.historyState.value).toBe( - parentHistory.output.historyState.value, + const keys = [ + 'delay', + 'historyState', + 'now', + 'maxDepth', + 'disabled', + ] as const; + expect( + Object.fromEntries(keys.map(k => [k, childHistory.output[k].peek()])), + ).toEqual( + Object.fromEntries(keys.map(k => [k, parentHistory.output[k].peek()])), ); }); }); From 4f67fe895cfe3160ff1e3e254e05c3912e15acde Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Sun, 9 Aug 2026 10:40:41 -0700 Subject: [PATCH 3/4] reflow jsdoc --- packages/lexical-history/src/index.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/lexical-history/src/index.ts b/packages/lexical-history/src/index.ts index c95a1cfad72..e51e0dea7ff 100644 --- a/packages/lexical-history/src/index.ts +++ b/packages/lexical-history/src/index.ts @@ -662,8 +662,9 @@ interface HistoryExtensionInit { * * Config-derived signals (`delay`, `disabled`, `historyState`, `maxDepth`, * `now`) are writable so that peer extensions such as - * {@link SharedHistoryExtension} can redirect them at runtime. The `canUndo` / `canRedo` signals are - * **readonly** for consumers — they are derived from the current + * {@link SharedHistoryExtension} can redirect them at runtime. + * The `canUndo` / `canRedo` signals are **readonly** for + * consumers — they are derived from the current * {@link HistoryState} and kept in sync automatically. */ export interface HistoryExtensionOutput { From 6a7730e26fc1191e8786194b014520f94c0e0ad7 Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Sun, 9 Aug 2026 10:48:44 -0700 Subject: [PATCH 4/4] Raise e2e timing threshold from 60s to 100s --- packages/lexical-playground/__tests__/e2e/ShadowDOM.spec.mjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/lexical-playground/__tests__/e2e/ShadowDOM.spec.mjs b/packages/lexical-playground/__tests__/e2e/ShadowDOM.spec.mjs index 7e50b06c1b4..2d4230be45e 100644 --- a/packages/lexical-playground/__tests__/e2e/ShadowDOM.spec.mjs +++ b/packages/lexical-playground/__tests__/e2e/ShadowDOM.spec.mjs @@ -503,10 +503,10 @@ test.describe('Shadow DOM', () => { const t0 = Date.now(); await page.keyboard.type('a'.repeat(1000), {delay: 0}); const elapsed = Date.now() - t0; - // 60s is loose enough for CI Firefox (which has hit ~40s on + // Loose enough for CI macOS webkit (which has hit ~68s on // shared runners) but tight enough to still flag a real regression // — locally this run is around 1–2s. - expect(elapsed).toBeLessThan(60_000); + expect(elapsed).toBeLessThan(100_000); const text = await page .locator('div[contenteditable="true"]') .first()