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
45 changes: 45 additions & 0 deletions .playwright/tests/renderCycle.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { test, expect } from '@playwright/test';

const ROOT_SELECTOR = '[data-testid="test-render-cycle-root"]';
const EDITOR_SELECTOR = `${ROOT_SELECTOR} .eti-editor [contenteditable="true"]`;
const TOGGLE_BUTTON_SELECTOR = '[data-testid="toggle-variant-button"]';
const VARIANT_OUTPUT_SELECTOR = '[data-testid="variant-output"]';
const PAGE_PATH = '/test-render-cycle';

test.describe('EnrichedTextInput render cycle', () => {
test('does not throw on simultaneous defaultValue and htmlStyle change', async ({
page,
}) => {
const pageErrors: Error[] = [];
const consoleErrors: string[] = [];

page.on('pageerror', (error) => pageErrors.push(error));
page.on('console', (message) => {
if (message.type() === 'error') {
consoleErrors.push(message.text());
}
});

await page.goto(PAGE_PATH);
await page.waitForSelector(EDITOR_SELECTOR);

await expect(page.locator(EDITOR_SELECTOR)).toContainText('Variant A');

await page.click(TOGGLE_BUTTON_SELECTOR);
await expect(page.locator(VARIANT_OUTPUT_SELECTOR)).toHaveText('b');
await expect(page.locator(EDITOR_SELECTOR)).toContainText('Variant B');

await page.click(TOGGLE_BUTTON_SELECTOR);
await expect(page.locator(VARIANT_OUTPUT_SELECTOR)).toHaveText('a');
await expect(page.locator(EDITOR_SELECTOR)).toContainText('Variant A');

const editor = page.locator(EDITOR_SELECTOR);
await editor.click();
await expect(editor).toBeFocused();
await editor.pressSequentially(' more text');
await expect(editor).toContainText('Variant A more text');

expect(pageErrors).toEqual([]);
expect(consoleErrors).toEqual([]);
});
});
5 changes: 5 additions & 0 deletions apps/example-web/src/RouteSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { VisualRegression } from './testScreens/VisualRegression';
import { TestSubmitProps } from './testScreens/TestSubmitProps';
import { TestEnrichedText } from './testScreens/TestEnrichedText';
import { TestEllipsize } from './testScreens/TestEllipsize';
import { TestRenderCycle } from './testScreens/TestRenderCycle';
import { useEffect, useState } from 'react';

export default function RouteSelector() {
Expand Down Expand Up @@ -50,5 +51,9 @@ export default function RouteSelector() {
return <TestEllipsize />;
}

if (path === '/test-render-cycle') {
return <TestRenderCycle />;
}

return <App />;
}
70 changes: 70 additions & 0 deletions apps/example-web/src/testScreens/TestRenderCycle.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { useMemo, useRef, useState } from 'react';
import {
EnrichedTextInput,
type EnrichedTextInputInstance,
type HtmlStyle,
} from 'react-native-enriched-html';
import { WEB_DEFAULT_HTML_STYLE } from '../defaultHtmlStyle';

const VARIANTS = {
a: {
defaultValue: '<p>Variant A</p>',
htmlStyle: WEB_DEFAULT_HTML_STYLE,
},
b: {
defaultValue: '<h1>Variant B</h1>',
htmlStyle: { ...WEB_DEFAULT_HTML_STYLE, h1: { fontSize: 48 } },
},
} as const satisfies Record<
string,
{ defaultValue: string; htmlStyle: HtmlStyle }
>;

export function TestRenderCycle() {
const ref = useRef<EnrichedTextInputInstance>(null);
const [variant, setVariant] = useState<keyof typeof VARIANTS>('a');

const { defaultValue, htmlStyle } = useMemo(
() => VARIANTS[variant],
[variant]
);

return (
<div data-testid="test-render-cycle-root">
<div
className="editor-wrapper"
style={editorContainerStyle}
data-testid="editor-container"
onClick={() => ref.current?.focus()}
>
<EnrichedTextInput
ref={ref}
defaultValue={defaultValue}
htmlStyle={htmlStyle}
placeholder="Test editor"
autoFocus
editable
scrollEnabled
/>
</div>

<button
type="button"
data-testid="toggle-variant-button"
onClick={() => {
setVariant((prev) => (prev === 'a' ? 'b' : 'a'));
}}
>
Toggle variant
</button>

<pre data-testid="variant-output">{variant}</pre>
</div>
);
}

const editorContainerStyle = {
backgroundColor: '#ddd',
padding: '16px',
borderRadius: '8px',
} as const;
44 changes: 25 additions & 19 deletions src/web/EnrichedTextInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -87,12 +87,13 @@ import {
sanitizeMentionAttributes,
} from './sanitization/htmlSanitizer';
import { assertBrowserEnvironment } from './utils/assertBrowserEnvironment';
import { runSafelyInEditor } from './utils/runSafelyInEditor';

function runFocused(
editor: Editor,
apply: (chain: ChainedCommands) => ChainedCommands
) {
apply(editor.chain().focus()).run();
runSafelyInEditor(editor, (e) => apply(e.chain().focus()).run());
}

export const EnrichedTextInput = ({
Expand Down Expand Up @@ -180,7 +181,7 @@ export const EnrichedTextInput = ({
const text = nativeLeafText(doc, 0, doc.content.size);
onSubmitEditingRef.current?.(adaptWebToNativeEvent(event, { text }));
if (sb === 'blurAndSubmit') {
editorInstanceRef.current?.commands.blur();
runSafelyInEditor(editorInstanceRef.current, (e) => e.commands.blur());
}
return true;
}
Expand Down Expand Up @@ -259,7 +260,9 @@ export const EnrichedTextInput = ({
autofocus: autoFocus,
onCreate: ({ editor: _editor }) => {
// Setting initial content in this way ensures all custom plugins are run and applied
_editor.commands.setContent(tiptapContent ?? '');
runSafelyInEditor(_editor, (e) =>
e.commands.setContent(tiptapContent ?? '')
);
},
onFocus: ({ event }) => {
onFocus?.(adaptWebToNativeEvent(event, { target: -1 }));
Expand Down Expand Up @@ -319,7 +322,9 @@ export const EnrichedTextInput = ({
}, [editor, returnKeyType]);

useEffect(() => {
editor?.commands.normalizeBoldInStyledHeadings();
runSafelyInEditor(editor, (e) =>
e.commands.normalizeBoldInStyledHeadings()
);
}, [editor, resolvedHtmlStyle]);

const getMentionCallbacks = useCallback(
Expand All @@ -336,14 +341,16 @@ export const EnrichedTextInput = ({
useImperativeHandle(
ref,
(): EnrichedTextInputInstance => ({
focus: () => editor.commands.focus(),
blur: () => editor.commands.blur(),
focus: () => runSafelyInEditor(editor, (e) => e.commands.focus()),
blur: () => runSafelyInEditor(editor, (e) => e.commands.blur()),
setValue: (value: string) =>
editor.commands.setContent(
prepareHtmlForTiptap(
value,
useHtmlNormalizerRef.current,
sanitizationConfigRef.current
runSafelyInEditor(editor, (e) =>
e.commands.setContent(
prepareHtmlForTiptap(
value,
useHtmlNormalizerRef.current,
sanitizationConfigRef.current
)
)
),
setSelection: (start, end) => {
Expand Down Expand Up @@ -381,23 +388,22 @@ export const EnrichedTextInput = ({
toggleCheckboxList: (checked: boolean) =>
runFocused(editor, (c) => c.toggleCheckboxList(checked)),
setLink: (start: number, end: number, text: string, url: string) =>
setLink(editor, start, end, text, url),
runSafelyInEditor(editor, (e) => setLink(e, start, end, text, url)),
removeLink: (start: number, end: number) =>
removeLink(editor, start, end),
runSafelyInEditor(editor, (e) => removeLink(e, start, end)),
startMention: (indicator: string) => {
startMention(editor, indicator, mentionIndicatorsRef.current);
runSafelyInEditor(editor, (e) =>
startMention(e, indicator, mentionIndicatorsRef.current)
);
},
setMention: (
indicator: string,
text: string,
attributes?: Record<string, string>
) => {
checkMentionAttributes(attributes);
setMention(
editor,
indicator,
text,
sanitizeMentionAttributes(attributes)
runSafelyInEditor(editor, (e) =>
setMention(e, indicator, text, sanitizeMentionAttributes(attributes))
);
},
setImage: (src: string, width: number, height: number) =>
Expand Down
37 changes: 37 additions & 0 deletions src/web/__tests__/runSafelyInEditor.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import type { Editor } from '@tiptap/react';
import { runSafelyInEditor } from '../utils/runSafelyInEditor';

function makeEditor(isDestroyed: boolean): Editor {
return { isDestroyed } as Editor;
}

describe('runSafelyInEditor', () => {
test('runs the callback and returns its result when editor is alive', () => {
const editor = makeEditor(false);
const callback = jest.fn((e: Editor) => e);

const result = runSafelyInEditor(editor, callback);

expect(callback).toHaveBeenCalledWith(editor);
expect(result).toBe(editor);
});

test('does not run the callback and returns null when editor is destroyed', () => {
const editor = makeEditor(true);
const callback = jest.fn();

const result = runSafelyInEditor(editor, callback);

expect(callback).not.toHaveBeenCalled();
expect(result).toBeNull();
});

test('does not run the callback and returns null when editor is null', () => {
const callback = jest.fn();

const result = runSafelyInEditor(null, callback);

expect(callback).not.toHaveBeenCalled();
expect(result).toBeNull();
});
});
11 changes: 11 additions & 0 deletions src/web/utils/runSafelyInEditor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import type { Editor } from '@tiptap/react';

export function runSafelyInEditor<T>(
editor: Editor | null,
callback: (editor: Editor) => T
): T | null {
if (editor && !editor.isDestroyed) {
return callback(editor);
}
return null;
}
Loading