Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix CypherEditor state handling and add tests #251

Merged
merged 10 commits into from
Aug 2, 2024
5 changes: 5 additions & 0 deletions .changeset/ninety-panthers-pretend.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@neo4j-cypher/react-codemirror": patch
---

Simplify detection and handling of value prop updates
1,057 changes: 556 additions & 501 deletions package-lock.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions packages/react-codemirror/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@
"@vitejs/plugin-react": "^4.3.1",
"concurrently": "^8.2.1",
"esbuild": "^0.19.4",
"jsdom": "^24.1.1",
"lodash": "^4.17.21",
"playwright": "^1.45.3",
"react": "^18.2.0",
Expand Down
197 changes: 197 additions & 0 deletions packages/react-codemirror/src/CypherEditor.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
// @vitest-environment jsdom

import { EditorView } from '@codemirror/view';
import { createRef } from 'react';
import { createRoot } from 'react-dom/client';
import { act } from 'react-dom/test-utils';
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
import { DEBOUNCE_TIME } from './constants';
import { CypherEditor } from './CypherEditor';

const container = document.createElement('div');
let root: ReturnType<typeof createRoot>;

const ref = createRef<CypherEditor>();
let value = '';
const onChange = vi.fn((v: string) => {
value = v;
rerender();
});

global.IS_REACT_ACT_ENVIRONMENT = true;

/** Avoids crash in test environment */
function mockEditorView(editorView: EditorView) {
editorView.coordsAtPos = vi.fn().mockReturnValue({
left: 0,
top: 0,
right: 0,
bottom: 0,
});
}

async function debounce() {
await new Promise((resolve) => setTimeout(resolve, DEBOUNCE_TIME));
}

function getEditorValue() {
return ref.current.editorView.current.state.doc.toString();
}

function rerender() {
act(() => {
root.render(<CypherEditor value={value} onChange={onChange} ref={ref} />);
});
}

beforeEach(() => {
root = createRoot(container);
act(() => {
root.render(<CypherEditor value={value} onChange={onChange} ref={ref} />);
});
mockEditorView(ref.current.editorView.current);
});

afterEach(() => {
act(() => {
root.unmount();
});
value = '';
vi.clearAllMocks();
});

test('editorValue updates props.value after debounce', async () => {
ref.current.setValueAndFocus('new value');
expect(onChange).not.toHaveBeenCalled();
expect(getEditorValue()).toBe('new value');
expect(value).toBe('');
await debounce();

expect(onChange).toHaveBeenCalledOnce();
expect(getEditorValue()).toBe('new value');
expect(value).toBe('new value');
});

test('editorValue updates should not be applied twice', async () => {
const dispatch = vi.spyOn(ref.current.editorView.current, 'dispatch');

ref.current.setValueAndFocus('new value');
await debounce();

expect(onChange).toHaveBeenCalledOnce();
expect(dispatch).toHaveBeenCalledOnce(); // it gets called once for the initial setValueAndFocus
});

test('props.value updates editorValue', () => {
value = 'external value';
rerender();

expect(getEditorValue()).toBe('external value');
expect(value).toBe('external value');
});

test('props.value set to undefined preserves editorValue', () => {
// 1. value is set initially
value = 'initial';
rerender();

// 2. value is set to undefined
value = undefined;
rerender();

expect(onChange).not.toHaveBeenCalled();
expect(value).toBeUndefined();
expect(getEditorValue()).toBe('initial');
});

// value updates from outside onExecute are overwritten by pending updates
test.fails('new props.value should cancel onChange', async () => {
// 1. value is updated internally
ref.current.setValueAndFocus('update');

// 2. editor is rerendered with a new value while a value update is still pending
value = 'new external value';
rerender();

await debounce();

// expect(onChange).not.toHaveBeenCalled();
expect(getEditorValue()).toBe('new external value');
expect(value).toBe('new external value');
});

// value updates from outside onExecute are overwritten by pending updates
test.fails(
'new props.value set to same value should cancel onChange',
async () => {
// 1. value is set initially
value = 'same value';
rerender();

// 2. value is updated internally
ref.current.setValueAndFocus('update');

// 3. editor is rerendered with a new value while a value update is still pending
value = 'same value';
rerender();

await debounce();

// expect(onChange).not.toHaveBeenCalled();
expect(getEditorValue()).toBe('same value');
expect(value).toBe('same value');
},
);

test('rerender should not cancel onChange', async () => {
// 1. value is updated ínternally
ref.current.setValueAndFocus('changed');

// 2. editor is rerendered while a value update is still pending
rerender();

await debounce();

expect(onChange).toHaveBeenCalledOnce();
expect(getEditorValue()).toBe('changed');
expect(value).toBe('changed');
});

test('rerender with a previous update should not cancel onChange', async () => {
// 1. value is updated ínternally
ref.current.setValueAndFocus('changed');
await debounce();

// 2. value is updated ínternally again
ref.current.setValueAndFocus('new change');

// 3. editor is rerendered while a value update is still pending
rerender();

await debounce();

expect(onChange).toHaveBeenCalledTimes(2);
expect(getEditorValue()).toBe('new change');
expect(value).toBe('new change');
});

test('rerender with prior external update should not cancel onChange', async () => {
// 1. value is set initially
ref.current.setValueAndFocus('initial');
await debounce();

// 2. value is updated externally
value = 'external update';
rerender();

// 3. value is updated internally
ref.current.setValueAndFocus('internal update');

// 4. editor is rerendered while a value update is still pending
rerender();

await debounce();

expect(getEditorValue()).toBe('internal update');
expect(value).toBe('internal update');
});
14 changes: 5 additions & 9 deletions packages/react-codemirror/src/CypherEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
import { type DbSchema } from '@neo4j-cypher/language-support';
import debounce from 'lodash.debounce';
import { Component, createRef } from 'react';
import { DEBOUNCE_TIME } from './constants';
import {
replaceHistory,
replMode as historyNavigation,
Expand Down Expand Up @@ -265,8 +266,6 @@ export class CypherEditor extends Component<
editorView: React.MutableRefObject<EditorView> = createRef();
private schemaRef: React.MutableRefObject<CypherConfig> = createRef();

private latestDispatchedValue: string | undefined;

/**
* Focus the editor
*/
Expand Down Expand Up @@ -316,10 +315,9 @@ export class CypherEditor extends Component<
private debouncedOnChange = this.props.onChange
? debounce(
((value, viewUpdate) => {
this.latestDispatchedValue = value;
this.props.onChange(value, viewUpdate);
}) satisfies CypherEditorProps['onChange'],
200,
DEBOUNCE_TIME,
)
: undefined;

Expand Down Expand Up @@ -374,8 +372,6 @@ export class CypherEditor extends Component<
]
: [];

this.latestDispatchedValue = this.props.value;

this.editorState.current = EditorState.create({
extensions: [
keyBindingCompartment.of(
Expand Down Expand Up @@ -440,10 +436,10 @@ export class CypherEditor extends Component<
const currentCmValue = this.editorView.current.state?.doc.toString() ?? '';

if (
this.props.value !== undefined &&
this.props.value !== this.latestDispatchedValue
this.props.value !== undefined && // If the component becomes uncontolled, we just leave the value as is
this.props.value !== prevProps.value && // The value prop has changed, we need to update the editor
this.props.value !== currentCmValue // No need to dispatch an update if the value is the same
) {
this.debouncedOnChange?.cancel();
this.editorView.current.dispatch({
changes: {
from: 0,
Expand Down
1 change: 1 addition & 0 deletions packages/react-codemirror/src/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const DEBOUNCE_TIME = 200;
32 changes: 32 additions & 0 deletions packages/react-codemirror/src/e2e_tests/autoCompletion.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,38 @@ test('get completions when typing and can accept completions with tab', async ({
await expect(component).toContainText('RETURN');
});

test('get completions when typing in controlled component', async ({
mount,
page,
}) => {
let value = '';
const onChange = (val: string) => {
value = val;
void component.update(<CypherEditor value={val} onChange={onChange} />);
};

const component = await mount(
<CypherEditor value={value} onChange={onChange} />,
);
const textField = page.getByRole('textbox');

await textField.fill('RETU');
await page.waitForTimeout(500); // wait for debounce

await expect(
page.locator('.cm-tooltip-autocomplete').getByText('RETURN'),
).toBeVisible();

// We need to wait for the editor to realise there is a completion open
// so that it does not just indent with tab key
await page.waitForTimeout(500);
await textField.press('Tab');

await expect(page.locator('.cm-tooltip-autocomplete')).not.toBeVisible();

await expect(component).toContainText('RETURN');
});

test('can complete labels', async ({ mount, page }) => {
const component = await mount(
<CypherEditor
Expand Down
46 changes: 23 additions & 23 deletions packages/react-codemirror/src/e2e_tests/debounce.spec.tsx
Original file line number Diff line number Diff line change
@@ -1,30 +1,30 @@
import { expect, test } from '@playwright/experimental-ct-react';
import { DEBOUNCE_TIME } from '../constants';
import { CypherEditor } from '../CypherEditor';
import { CypherEditorPage } from './e2eUtils';

const DEBOUNCE_TIMER = 200;
// value updates from outside onExecute are overwritten by pending updates
test.fail(
'external updates should override debounced updates',
async ({ mount, page }) => {
const editorPage = new CypherEditorPage(page);
let value = '';

test('external updates should override debounced updates', async ({
mount,
page,
}) => {
const editorPage = new CypherEditorPage(page);
let value = '';

const onChange = (val: string) => {
value = val;
void component.update(<CypherEditor value={val} onChange={onChange} />);
};
const onChange = (val: string) => {
value = val;
void component.update(<CypherEditor value={val} onChange={onChange} />);
};

const component = await mount(
<CypherEditor value={value} onChange={onChange} />,
);
const component = await mount(
<CypherEditor value={value} onChange={onChange} />,
);

await editorPage.getEditor().pressSequentially('RETURN 1');
onChange('foo');
await page.waitForTimeout(DEBOUNCE_TIMER);
await expect(component).toContainText('foo');
});
await editorPage.getEditor().pressSequentially('RETURN 1');
onChange('foo');
await page.waitForTimeout(DEBOUNCE_TIME);
await expect(component).toContainText('foo');
},
);

test('onExecute updates should override debounce updates', async ({
mount,
Expand Down Expand Up @@ -53,14 +53,14 @@ test('onExecute updates should override debounce updates', async ({

await editorPage.getEditor().pressSequentially('RETURN 1');
await editorPage.getEditor().press('Control+Enter');
await page.waitForTimeout(DEBOUNCE_TIMER);
await page.waitForTimeout(DEBOUNCE_TIME);
await expect(component).not.toContainText('RETURN 1');

await editorPage.getEditor().pressSequentially('RETURN 1');
await editorPage.getEditor().pressSequentially('');
await editorPage.getEditor().pressSequentially('RETURN 1');
await editorPage.getEditor().press('Control+Enter');
await page.waitForTimeout(DEBOUNCE_TIMER);
await page.waitForTimeout(DEBOUNCE_TIME);
await expect(component).not.toContainText('RETURN 1');
});

Expand Down Expand Up @@ -94,7 +94,7 @@ test('onExecute should fire after debounced updates', async ({
await editorPage.getEditor().press('Control+Enter');
await editorPage.getEditor().fill('RETURN 2');
await editorPage.getEditor().press('Control+Enter');
await page.waitForTimeout(DEBOUNCE_TIMER);
await page.waitForTimeout(DEBOUNCE_TIME);
await expect(component).toContainText('RETURN 2');
expect(executedCommand).toBe('RETURN 2');
});
3 changes: 3 additions & 0 deletions packages/react-codemirror/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,8 @@ export default defineConfig({
'**/.{idea,git,cache,output,temp}/**',
'**/e2e_tests/**',
],
// Fix for error in pipeline, see https://github.com/vitest-dev/vitest/discussions/6131
minWorkers: 1,
maxWorkers: 1,
},
});