Skip to content

Commit bb7e9d3

Browse files
Fix CypherEditor state handling and add tests (#251)
* Update latestDispatchedValue on external edits * Add debounce tests for CypherEditor * Rewrite logic for tracking external edits * Create ninety-panthers-pretend.md * Flush changes onExecute * Restrict Vitests workers to 1 * Improve comment for failing tests * Add tests for bugs due to double update dispatch * Add condition to prevent duplicated editor updates * Extract debounce time to separate file - Allows it to be imported from Playwright tests
1 parent b7e6480 commit bb7e9d3

File tree

9 files changed

+823
-533
lines changed

9 files changed

+823
-533
lines changed

.changeset/ninety-panthers-pretend.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@neo4j-cypher/react-codemirror": patch
3+
---
4+
5+
Simplify detection and handling of value prop updates

package-lock.json

Lines changed: 556 additions & 501 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/react-codemirror/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@
7070
"@vitejs/plugin-react": "^4.3.1",
7171
"concurrently": "^8.2.1",
7272
"esbuild": "^0.19.4",
73+
"jsdom": "^24.1.1",
7374
"lodash": "^4.17.21",
7475
"playwright": "^1.45.3",
7576
"react": "^18.2.0",
Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
// @vitest-environment jsdom
2+
3+
import { EditorView } from '@codemirror/view';
4+
import { createRef } from 'react';
5+
import { createRoot } from 'react-dom/client';
6+
import { act } from 'react-dom/test-utils';
7+
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
8+
import { DEBOUNCE_TIME } from './constants';
9+
import { CypherEditor } from './CypherEditor';
10+
11+
const container = document.createElement('div');
12+
let root: ReturnType<typeof createRoot>;
13+
14+
const ref = createRef<CypherEditor>();
15+
let value = '';
16+
const onChange = vi.fn((v: string) => {
17+
value = v;
18+
rerender();
19+
});
20+
21+
global.IS_REACT_ACT_ENVIRONMENT = true;
22+
23+
/** Avoids crash in test environment */
24+
function mockEditorView(editorView: EditorView) {
25+
editorView.coordsAtPos = vi.fn().mockReturnValue({
26+
left: 0,
27+
top: 0,
28+
right: 0,
29+
bottom: 0,
30+
});
31+
}
32+
33+
async function debounce() {
34+
await new Promise((resolve) => setTimeout(resolve, DEBOUNCE_TIME));
35+
}
36+
37+
function getEditorValue() {
38+
return ref.current.editorView.current.state.doc.toString();
39+
}
40+
41+
function rerender() {
42+
act(() => {
43+
root.render(<CypherEditor value={value} onChange={onChange} ref={ref} />);
44+
});
45+
}
46+
47+
beforeEach(() => {
48+
root = createRoot(container);
49+
act(() => {
50+
root.render(<CypherEditor value={value} onChange={onChange} ref={ref} />);
51+
});
52+
mockEditorView(ref.current.editorView.current);
53+
});
54+
55+
afterEach(() => {
56+
act(() => {
57+
root.unmount();
58+
});
59+
value = '';
60+
vi.clearAllMocks();
61+
});
62+
63+
test('editorValue updates props.value after debounce', async () => {
64+
ref.current.setValueAndFocus('new value');
65+
expect(onChange).not.toHaveBeenCalled();
66+
expect(getEditorValue()).toBe('new value');
67+
expect(value).toBe('');
68+
await debounce();
69+
70+
expect(onChange).toHaveBeenCalledOnce();
71+
expect(getEditorValue()).toBe('new value');
72+
expect(value).toBe('new value');
73+
});
74+
75+
test('editorValue updates should not be applied twice', async () => {
76+
const dispatch = vi.spyOn(ref.current.editorView.current, 'dispatch');
77+
78+
ref.current.setValueAndFocus('new value');
79+
await debounce();
80+
81+
expect(onChange).toHaveBeenCalledOnce();
82+
expect(dispatch).toHaveBeenCalledOnce(); // it gets called once for the initial setValueAndFocus
83+
});
84+
85+
test('props.value updates editorValue', () => {
86+
value = 'external value';
87+
rerender();
88+
89+
expect(getEditorValue()).toBe('external value');
90+
expect(value).toBe('external value');
91+
});
92+
93+
test('props.value set to undefined preserves editorValue', () => {
94+
// 1. value is set initially
95+
value = 'initial';
96+
rerender();
97+
98+
// 2. value is set to undefined
99+
value = undefined;
100+
rerender();
101+
102+
expect(onChange).not.toHaveBeenCalled();
103+
expect(value).toBeUndefined();
104+
expect(getEditorValue()).toBe('initial');
105+
});
106+
107+
// value updates from outside onExecute are overwritten by pending updates
108+
test.fails('new props.value should cancel onChange', async () => {
109+
// 1. value is updated internally
110+
ref.current.setValueAndFocus('update');
111+
112+
// 2. editor is rerendered with a new value while a value update is still pending
113+
value = 'new external value';
114+
rerender();
115+
116+
await debounce();
117+
118+
// expect(onChange).not.toHaveBeenCalled();
119+
expect(getEditorValue()).toBe('new external value');
120+
expect(value).toBe('new external value');
121+
});
122+
123+
// value updates from outside onExecute are overwritten by pending updates
124+
test.fails(
125+
'new props.value set to same value should cancel onChange',
126+
async () => {
127+
// 1. value is set initially
128+
value = 'same value';
129+
rerender();
130+
131+
// 2. value is updated internally
132+
ref.current.setValueAndFocus('update');
133+
134+
// 3. editor is rerendered with a new value while a value update is still pending
135+
value = 'same value';
136+
rerender();
137+
138+
await debounce();
139+
140+
// expect(onChange).not.toHaveBeenCalled();
141+
expect(getEditorValue()).toBe('same value');
142+
expect(value).toBe('same value');
143+
},
144+
);
145+
146+
test('rerender should not cancel onChange', async () => {
147+
// 1. value is updated ínternally
148+
ref.current.setValueAndFocus('changed');
149+
150+
// 2. editor is rerendered while a value update is still pending
151+
rerender();
152+
153+
await debounce();
154+
155+
expect(onChange).toHaveBeenCalledOnce();
156+
expect(getEditorValue()).toBe('changed');
157+
expect(value).toBe('changed');
158+
});
159+
160+
test('rerender with a previous update should not cancel onChange', async () => {
161+
// 1. value is updated ínternally
162+
ref.current.setValueAndFocus('changed');
163+
await debounce();
164+
165+
// 2. value is updated ínternally again
166+
ref.current.setValueAndFocus('new change');
167+
168+
// 3. editor is rerendered while a value update is still pending
169+
rerender();
170+
171+
await debounce();
172+
173+
expect(onChange).toHaveBeenCalledTimes(2);
174+
expect(getEditorValue()).toBe('new change');
175+
expect(value).toBe('new change');
176+
});
177+
178+
test('rerender with prior external update should not cancel onChange', async () => {
179+
// 1. value is set initially
180+
ref.current.setValueAndFocus('initial');
181+
await debounce();
182+
183+
// 2. value is updated externally
184+
value = 'external update';
185+
rerender();
186+
187+
// 3. value is updated internally
188+
ref.current.setValueAndFocus('internal update');
189+
190+
// 4. editor is rerendered while a value update is still pending
191+
rerender();
192+
193+
await debounce();
194+
195+
expect(getEditorValue()).toBe('internal update');
196+
expect(value).toBe('internal update');
197+
});

packages/react-codemirror/src/CypherEditor.tsx

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
import { type DbSchema } from '@neo4j-cypher/language-support';
1717
import debounce from 'lodash.debounce';
1818
import { Component, createRef } from 'react';
19+
import { DEBOUNCE_TIME } from './constants';
1920
import {
2021
replaceHistory,
2122
replMode as historyNavigation,
@@ -265,8 +266,6 @@ export class CypherEditor extends Component<
265266
editorView: React.MutableRefObject<EditorView> = createRef();
266267
private schemaRef: React.MutableRefObject<CypherConfig> = createRef();
267268

268-
private latestDispatchedValue: string | undefined;
269-
270269
/**
271270
* Focus the editor
272271
*/
@@ -316,10 +315,9 @@ export class CypherEditor extends Component<
316315
private debouncedOnChange = this.props.onChange
317316
? debounce(
318317
((value, viewUpdate) => {
319-
this.latestDispatchedValue = value;
320318
this.props.onChange(value, viewUpdate);
321319
}) satisfies CypherEditorProps['onChange'],
322-
200,
320+
DEBOUNCE_TIME,
323321
)
324322
: undefined;
325323

@@ -374,8 +372,6 @@ export class CypherEditor extends Component<
374372
]
375373
: [];
376374

377-
this.latestDispatchedValue = this.props.value;
378-
379375
this.editorState.current = EditorState.create({
380376
extensions: [
381377
keyBindingCompartment.of(
@@ -440,10 +436,10 @@ export class CypherEditor extends Component<
440436
const currentCmValue = this.editorView.current.state?.doc.toString() ?? '';
441437

442438
if (
443-
this.props.value !== undefined &&
444-
this.props.value !== this.latestDispatchedValue
439+
this.props.value !== undefined && // If the component becomes uncontolled, we just leave the value as is
440+
this.props.value !== prevProps.value && // The value prop has changed, we need to update the editor
441+
this.props.value !== currentCmValue // No need to dispatch an update if the value is the same
445442
) {
446-
this.debouncedOnChange?.cancel();
447443
this.editorView.current.dispatch({
448444
changes: {
449445
from: 0,
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export const DEBOUNCE_TIME = 200;

packages/react-codemirror/src/e2e_tests/autoCompletion.spec.tsx

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,38 @@ test('get completions when typing and can accept completions with tab', async ({
6262
await expect(component).toContainText('RETURN');
6363
});
6464

65+
test('get completions when typing in controlled component', async ({
66+
mount,
67+
page,
68+
}) => {
69+
let value = '';
70+
const onChange = (val: string) => {
71+
value = val;
72+
void component.update(<CypherEditor value={val} onChange={onChange} />);
73+
};
74+
75+
const component = await mount(
76+
<CypherEditor value={value} onChange={onChange} />,
77+
);
78+
const textField = page.getByRole('textbox');
79+
80+
await textField.fill('RETU');
81+
await page.waitForTimeout(500); // wait for debounce
82+
83+
await expect(
84+
page.locator('.cm-tooltip-autocomplete').getByText('RETURN'),
85+
).toBeVisible();
86+
87+
// We need to wait for the editor to realise there is a completion open
88+
// so that it does not just indent with tab key
89+
await page.waitForTimeout(500);
90+
await textField.press('Tab');
91+
92+
await expect(page.locator('.cm-tooltip-autocomplete')).not.toBeVisible();
93+
94+
await expect(component).toContainText('RETURN');
95+
});
96+
6597
test('can complete labels', async ({ mount, page }) => {
6698
const component = await mount(
6799
<CypherEditor

packages/react-codemirror/src/e2e_tests/debounce.spec.tsx

Lines changed: 23 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,30 @@
11
import { expect, test } from '@playwright/experimental-ct-react';
2+
import { DEBOUNCE_TIME } from '../constants';
23
import { CypherEditor } from '../CypherEditor';
34
import { CypherEditorPage } from './e2eUtils';
45

5-
const DEBOUNCE_TIMER = 200;
6+
// value updates from outside onExecute are overwritten by pending updates
7+
test.fail(
8+
'external updates should override debounced updates',
9+
async ({ mount, page }) => {
10+
const editorPage = new CypherEditorPage(page);
11+
let value = '';
612

7-
test('external updates should override debounced updates', async ({
8-
mount,
9-
page,
10-
}) => {
11-
const editorPage = new CypherEditorPage(page);
12-
let value = '';
13-
14-
const onChange = (val: string) => {
15-
value = val;
16-
void component.update(<CypherEditor value={val} onChange={onChange} />);
17-
};
13+
const onChange = (val: string) => {
14+
value = val;
15+
void component.update(<CypherEditor value={val} onChange={onChange} />);
16+
};
1817

19-
const component = await mount(
20-
<CypherEditor value={value} onChange={onChange} />,
21-
);
18+
const component = await mount(
19+
<CypherEditor value={value} onChange={onChange} />,
20+
);
2221

23-
await editorPage.getEditor().pressSequentially('RETURN 1');
24-
onChange('foo');
25-
await page.waitForTimeout(DEBOUNCE_TIMER);
26-
await expect(component).toContainText('foo');
27-
});
22+
await editorPage.getEditor().pressSequentially('RETURN 1');
23+
onChange('foo');
24+
await page.waitForTimeout(DEBOUNCE_TIME);
25+
await expect(component).toContainText('foo');
26+
},
27+
);
2828

2929
test('onExecute updates should override debounce updates', async ({
3030
mount,
@@ -53,14 +53,14 @@ test('onExecute updates should override debounce updates', async ({
5353

5454
await editorPage.getEditor().pressSequentially('RETURN 1');
5555
await editorPage.getEditor().press('Control+Enter');
56-
await page.waitForTimeout(DEBOUNCE_TIMER);
56+
await page.waitForTimeout(DEBOUNCE_TIME);
5757
await expect(component).not.toContainText('RETURN 1');
5858

5959
await editorPage.getEditor().pressSequentially('RETURN 1');
6060
await editorPage.getEditor().pressSequentially('');
6161
await editorPage.getEditor().pressSequentially('RETURN 1');
6262
await editorPage.getEditor().press('Control+Enter');
63-
await page.waitForTimeout(DEBOUNCE_TIMER);
63+
await page.waitForTimeout(DEBOUNCE_TIME);
6464
await expect(component).not.toContainText('RETURN 1');
6565
});
6666

@@ -94,7 +94,7 @@ test('onExecute should fire after debounced updates', async ({
9494
await editorPage.getEditor().press('Control+Enter');
9595
await editorPage.getEditor().fill('RETURN 2');
9696
await editorPage.getEditor().press('Control+Enter');
97-
await page.waitForTimeout(DEBOUNCE_TIMER);
97+
await page.waitForTimeout(DEBOUNCE_TIME);
9898
await expect(component).toContainText('RETURN 2');
9999
expect(executedCommand).toBe('RETURN 2');
100100
});

packages/react-codemirror/vite.config.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,5 +13,8 @@ export default defineConfig({
1313
'**/.{idea,git,cache,output,temp}/**',
1414
'**/e2e_tests/**',
1515
],
16+
// Fix for error in pipeline, see https://github.com/vitest-dev/vitest/discussions/6131
17+
minWorkers: 1,
18+
maxWorkers: 1,
1619
},
1720
});

0 commit comments

Comments
 (0)