Skip to content

Commit 97c4916

Browse files
authored
Rework outside click detection (adazzle#3794)
1 parent 5d7b0fc commit 97c4916

File tree

4 files changed

+72
-21
lines changed

4 files changed

+72
-21
lines changed

src/EditCell.tsx

Lines changed: 62 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useEffect, useRef } from 'react';
1+
import { useLayoutEffect, useRef } from 'react';
22
import { css } from '@linaria/core';
33

44
import { useLatestFunc } from './hooks';
@@ -12,6 +12,21 @@ import type {
1212
RenderEditCellProps
1313
} from './types';
1414

15+
declare global {
16+
const scheduler: Scheduler | undefined;
17+
}
18+
19+
interface Scheduler {
20+
readonly postTask?: (
21+
callback: () => void,
22+
options?: {
23+
priority?: 'user-blocking' | 'user-visible' | 'background';
24+
signal?: AbortSignal;
25+
delay?: number;
26+
}
27+
) => Promise<unknown>;
28+
}
29+
1530
/*
1631
* To check for outside `mousedown` events, we listen to all `mousedown` events at their birth,
1732
* i.e. on the window during the capture phase, and at their death, i.e. on the window during the bubble phase.
@@ -21,13 +36,15 @@ import type {
2136
*
2237
* The event can be `stopPropagation()`ed halfway through, so they may not always bubble back up to the window,
2338
* so an alternative check must be used. The check must happen after the event can reach the "inside" container,
24-
* and not before it run to completion. `requestAnimationFrame` is the best way we know how to achieve this.
39+
* and not before it run to completion. `postTask`/`requestAnimationFrame` are the best way we know to achieve this.
2540
* Usually we want click event handlers from parent components to access the latest commited values,
2641
* so `mousedown` is used instead of `click`.
2742
*
2843
* We must also rely on React's event capturing/bubbling to handle elements rendered in a portal.
2944
*/
3045

46+
const canUsePostTask = typeof scheduler === 'object' && typeof scheduler.postTask === 'function';
47+
3148
const cellEditing = css`
3249
@layer rdg.EditCell {
3350
padding: 0;
@@ -56,33 +73,68 @@ export default function EditCell<R, SR>({
5673
onKeyDown,
5774
navigate
5875
}: EditCellProps<R, SR>) {
76+
const captureEventRef = useRef<MouseEvent | undefined>(undefined);
77+
const abortControllerRef = useRef<AbortController>(undefined);
5978
const frameRequestRef = useRef<number>(undefined);
6079
const commitOnOutsideClick = column.editorOptions?.commitOnOutsideClick ?? true;
6180

62-
// We need to prevent the `useEffect` from cleaning up between re-renders,
81+
// We need to prevent the `useLayoutEffect` from cleaning up between re-renders,
6382
// as `onWindowCaptureMouseDown` might otherwise miss valid mousedown events.
6483
// To that end we instead access the latest props via useLatestFunc.
6584
const commitOnOutsideMouseDown = useLatestFunc(() => {
6685
onClose(true, false);
6786
});
6887

69-
useEffect(() => {
88+
useLayoutEffect(() => {
7089
if (!commitOnOutsideClick) return;
7190

72-
function onWindowCaptureMouseDown() {
73-
frameRequestRef.current = requestAnimationFrame(commitOnOutsideMouseDown);
91+
function onWindowCaptureMouseDown(event: MouseEvent) {
92+
captureEventRef.current = event;
93+
94+
if (canUsePostTask) {
95+
const abortController = new AbortController();
96+
const { signal } = abortController;
97+
abortControllerRef.current = abortController;
98+
// Use postTask to ensure that the event is not called in the middle of a React render
99+
// and that it is called before the next paint.
100+
scheduler
101+
.postTask(commitOnOutsideMouseDown, {
102+
priority: 'user-blocking',
103+
signal
104+
})
105+
// ignore abort errors
106+
.catch(() => {});
107+
} else {
108+
frameRequestRef.current = requestAnimationFrame(commitOnOutsideMouseDown);
109+
}
110+
}
111+
112+
function onWindowMouseDown(event: MouseEvent) {
113+
if (captureEventRef.current === event) {
114+
commitOnOutsideMouseDown();
115+
}
74116
}
75117

76118
addEventListener('mousedown', onWindowCaptureMouseDown, { capture: true });
119+
addEventListener('mousedown', onWindowMouseDown);
77120

78121
return () => {
79122
removeEventListener('mousedown', onWindowCaptureMouseDown, { capture: true });
80-
cancelFrameRequest();
123+
removeEventListener('mousedown', onWindowMouseDown);
124+
cancelTask();
81125
};
82126
}, [commitOnOutsideClick, commitOnOutsideMouseDown]);
83127

84-
function cancelFrameRequest() {
85-
cancelAnimationFrame(frameRequestRef.current!);
128+
function cancelTask() {
129+
captureEventRef.current = undefined;
130+
if (abortControllerRef.current !== undefined) {
131+
abortControllerRef.current.abort();
132+
abortControllerRef.current = undefined;
133+
}
134+
if (frameRequestRef.current !== undefined) {
135+
cancelAnimationFrame(frameRequestRef.current);
136+
frameRequestRef.current = undefined;
137+
}
86138
}
87139

88140
function handleKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
@@ -143,7 +195,7 @@ export default function EditCell<R, SR>({
143195
className={className}
144196
style={getCellStyle(column, colSpan)}
145197
onKeyDown={handleKeyDown}
146-
onMouseDownCapture={cancelFrameRequest}
198+
onMouseDownCapture={cancelTask}
147199
>
148200
{column.renderEditCell != null && (
149201
<>

src/hooks/useLatestFunc.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useCallback, useEffect, useRef } from 'react';
1+
import { useCallback, useLayoutEffect, useRef } from 'react';
22

33
import type { Maybe } from '../types';
44

@@ -7,7 +7,7 @@ import type { Maybe } from '../types';
77
export function useLatestFunc<T extends Maybe<(...args: any[]) => any>>(fn: T): T {
88
const ref = useRef(fn);
99

10-
useEffect(() => {
10+
useLayoutEffect(() => {
1111
ref.current = fn;
1212
});
1313

test/browser/rowSelection.test.tsx

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ function RowSelectionTest({
4444
);
4545
}
4646

47-
function setupNew(initialRows = defaultRows) {
47+
function setup(initialRows = defaultRows) {
4848
page.render(<RowSelectionTest initialRows={initialRows} />);
4949
}
5050

@@ -60,7 +60,7 @@ async function toggleSelection(rowIdx: number, shift = false) {
6060
}
6161

6262
test('toggle selection when checkbox is clicked', async () => {
63-
setupNew();
63+
setup();
6464
await toggleSelection(0);
6565
testSelection(0, true);
6666
await toggleSelection(1);
@@ -73,7 +73,7 @@ test('toggle selection when checkbox is clicked', async () => {
7373
});
7474

7575
test('toggle selection using keyboard', async () => {
76-
setupNew();
76+
setup();
7777
testSelection(0, false);
7878
await userEvent.click(getCellsAtRowIndex(0)[0]);
7979
testSelection(0, true);
@@ -88,7 +88,7 @@ test('toggle selection using keyboard', async () => {
8888
});
8989

9090
test('should partially select header checkbox', async () => {
91-
setupNew();
91+
setup();
9292
const headerCheckbox = page.getByRole('checkbox', { name: 'Select All' }).element();
9393
expect(headerCheckbox).not.toBeChecked();
9494
expect(headerCheckbox).not.toBePartiallyChecked();
@@ -140,7 +140,7 @@ test('should not select row when isRowSelectionDisabled returns true', async ()
140140
});
141141

142142
test('select/deselect all rows when header checkbox is clicked', async () => {
143-
setupNew();
143+
setup();
144144
const headerCheckbox = page.getByRole('checkbox', { name: 'Select All' }).element();
145145
expect(headerCheckbox).not.toBeChecked();
146146
await userEvent.click(headerCheckbox);
@@ -161,7 +161,7 @@ test('select/deselect all rows when header checkbox is clicked', async () => {
161161
});
162162

163163
test('header checkbox is not checked when there are no rows', async () => {
164-
setupNew([]);
164+
setup([]);
165165
await expect.element(page.getByRole('checkbox', { name: 'Select All' })).not.toBeChecked();
166166
});
167167

@@ -238,7 +238,7 @@ test('extra keys are preserved when updating the selectedRows Set', async () =>
238238
});
239239

240240
test('select/deselect rows using shift click', async () => {
241-
setupNew();
241+
setup();
242242
await toggleSelection(0);
243243
await toggleSelection(2, true);
244244
testSelection(0, true);
@@ -252,7 +252,7 @@ test('select/deselect rows using shift click', async () => {
252252
});
253253

254254
test('select rows using shift space', async () => {
255-
setupNew();
255+
setup();
256256
await userEvent.click(getCellsAtRowIndex(0)[1]);
257257
await userEvent.keyboard('{Shift>} {/Shift}');
258258
testSelection(0, true);

tsconfig.js.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
"allowJs": true,
55
"module": "NodeNext",
66
"moduleResolution": "NodeNext",
7-
"resolveJsonModule": true,
87
"skipLibCheck": true
98
},
109
"include": ["**/*.js", ".github/**/*.js", "package.json"],

0 commit comments

Comments
 (0)