Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 9 additions & 5 deletions packages/lexical-markdown/src/MarkdownExport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,10 +113,13 @@ export function createSelectionMarkdownExport(

return selection => {
const output = [];
const children = $getRoot().getChildren();
// The separator depends on the previously *emitted* block, not on the
// previous root child: unselected children produce no output, so keying
// off the child index would prefix a newline to the first emitted block
// whenever the selection starts below the top of the document.
let previousExported: LexicalNode | null = null;

for (let i = 0; i < children.length; i++) {
const child = children[i];
for (const child of $getRoot().getChildren()) {
const {shouldInclude, markdown} = $processNodeForSelection(
child,
selection,
Expand All @@ -129,12 +132,13 @@ export function createSelectionMarkdownExport(
if (shouldInclude && markdown != null) {
output.push(
isNewlineDelimited &&
i > 0 &&
previousExported !== null &&
!isEmptyParagraph(child) &&
!isEmptyParagraph(children[i - 1])
!isEmptyParagraph(previousExported)
? '\n'.concat(markdown)
: markdown,
);
previousExported = child;
}
}
return output.join('\n');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2704,6 +2704,34 @@ describe('$convertSelectionToMarkdownString', () => {
expect(result).toBe('Hello **Bold**');
});

it('does not prefix a newline when the selection starts after the first block', () => {
const editor = createTestEditor();
editor.update(
() => {
const root = $getRoot();
const firstText = $createTextNode('First');
const secondText = $createTextNode('Second');
const thirdText = $createTextNode('Third');
root.append(
$createParagraphNode().append(firstText),
$createParagraphNode().append(secondText),
$createParagraphNode().append(thirdText),
);
$setSelectionFromCaretRange(
$getCaretRange(
$getTextPointCaret(secondText, 'next', 0),
$getTextPointCaret(thirdText, 'next', 5),
),
);
},
{discrete: true},
);
const result = editor.read('latest', () =>
$convertSelectionToMarkdownString(TRANSFORMERS, $getSelection()),
);
expect(result).toBe('Second\n\nThird');
});

it('returns empty string for null selection', () => {
const result = $convertSelectionToMarkdownString(TRANSFORMERS, null);
expect(result).toBe('');
Expand Down
1 change: 1 addition & 0 deletions packages/lexical-table/src/LexicalTableObserver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,7 @@ export class TableObserver {
this.table = getTable(tableNode, tableElement);
});
});
this.listenersToRemove.add(() => observer.disconnect());
this.editor.read('latest', () => {
const {tableNode, tableElement} = this.$lookup();
this.table = getTable(tableNode, tableElement);
Expand Down
141 changes: 141 additions & 0 deletions packages/lexical-table/src/__tests__/unit/LexicalTableObserver.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
/**
* 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 {
$createTableNodeWithDimensions,
getTableObserverFromTableElement,
type HTMLTableElementWithWithTableSelectionState,
TableExtension,
} from '@lexical/table';
import {
$getRoot,
defineExtension,
type LexicalEditorWithDispose,
} from 'lexical';
import {afterEach, assert, beforeEach, describe, expect, it, vi} from 'vitest';

interface TrackedObserver {
disconnectCount: number;
targets: Node[];
}

describe('TableObserver tracking MutationObserver teardown (#9073)', () => {
let editor: LexicalEditorWithDispose;
let container: HTMLDivElement;
let trackedObservers: TrackedObserver[];
let RealMutationObserver: typeof MutationObserver;

beforeEach(() => {
// Instrument MutationObserver so the test can see the observer that
// TableObserver.trackTable() creates (it is not otherwise reachable).
trackedObservers = [];
RealMutationObserver = globalThis.MutationObserver;
globalThis.MutationObserver = class extends RealMutationObserver {
tracked: TrackedObserver = {disconnectCount: 0, targets: []};
constructor(callback: MutationCallback) {
super(callback);
trackedObservers.push(this.tracked);
}
observe(target: Node, options?: MutationObserverInit) {
this.tracked.targets.push(target);
super.observe(target, options);
}
disconnect() {
this.tracked.disconnectCount++;
super.disconnect();
}
};

container = document.createElement('div');
document.body.appendChild(container);
editor = buildEditorFromExtensions(
defineExtension({
dependencies: [TableExtension],
name: 'table-observer-test',
}),
);
editor.setRootElement(container);
editor.update(
() => {
$getRoot()
.clear()
.append($createTableNodeWithDimensions(2, 2, false));
},
{discrete: true},
);
});

afterEach(() => {
editor.dispose();
document.body.removeChild(container);
globalThis.MutationObserver = RealMutationObserver;
});

function getTableElement(): HTMLTableElementWithWithTableSelectionState {
const tableElement = container.querySelector('table');
assert(tableElement !== null, 'Expected table element');
return tableElement as HTMLTableElementWithWithTableSelectionState;
}

function getTrackingObserver(
tableElement: HTMLTableElement,
): TrackedObserver {
const tracking = trackedObservers.filter(tracked =>
tracked.targets.includes(tableElement),
);
expect(tracking.length).toBe(1);
return tracking[0];
}

it('disconnects the tracking MutationObserver in removeListeners()', () => {
const tableElement = getTableElement();
const trackingObserver = getTrackingObserver(tableElement);
expect(trackingObserver.disconnectCount).toBe(0);

const tableObserver = getTableObserverFromTableElement(tableElement);
assert(tableObserver !== null, 'Expected TableObserver on table element');
tableObserver.removeListeners();

expect(trackingObserver.disconnectCount).toBe(1);
});

it('does not fire the tracking MutationObserver for mutations of the detached table after editor teardown', async () => {
const tableElement = getTableElement();
const trackingObserver = getTrackingObserver(tableElement);

// Swallow errors reported from MutationObserver microtasks (they bypass
// editor onError) so a regression fails this test's assertions instead
// of crashing the run with an unhandled error.
const uncaughtErrors: string[] = [];
const onWindowError = (event: ErrorEvent) => {
uncaughtErrors.push(event.message);
event.preventDefault();
};
window.addEventListener('error', onWindowError);
try {
// Queue a mutation record in the same task as teardown; disconnect()
// must also clear the record queue so it is never delivered.
tableElement.classList.add('queued-before-teardown');
editor.dispose();
expect(trackingObserver.disconnectCount).toBe(1);

// The leaked-observer callback ran through editor.read(); after
// teardown it must not run at all.
const readSpy = vi.spyOn(editor, 'read');
tableElement.classList.add('mutated-after-teardown');
// Flush the MutationObserver microtask checkpoint.
await Promise.resolve();
await Promise.resolve();

expect(readSpy).not.toHaveBeenCalled();
expect(uncaughtErrors).toEqual([]);
} finally {
window.removeEventListener('error', onWindowError);
}
});
});
Loading
Loading