Skip to content

Commit e52eea7

Browse files
authored
feat(react-dom): whileElementsMounted callback (floating-ui#1683)
1 parent 7667713 commit e52eea7

File tree

6 files changed

+274
-53
lines changed

6 files changed

+274
-53
lines changed

package-lock.json

+33-4
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/react-dom-interactions/src/index.ts

+13-2
Original file line numberDiff line numberDiff line change
@@ -33,18 +33,24 @@ export type UseFloatingReturn<RT extends ReferenceType = ReferenceType> =
3333
};
3434
};
3535

36-
export interface Props {
36+
export interface Props<RT extends ReferenceType = ReferenceType> {
3737
open: boolean;
3838
onOpenChange: (open: boolean) => void;
3939
placement: Placement;
4040
middleware: Array<Middleware>;
4141
strategy: Strategy;
4242
nodeId: string;
43+
whileElementsMounted?: (
44+
reference: RT,
45+
floating: HTMLElement,
46+
update: () => void
47+
) => void | (() => void);
4348
}
4449

4550
export function useFloating<RT extends ReferenceType = ReferenceType>({
4651
open = false,
4752
onOpenChange = () => {},
53+
whileElementsMounted,
4854
placement,
4955
middleware,
5056
strategy,
@@ -55,7 +61,12 @@ export function useFloating<RT extends ReferenceType = ReferenceType>({
5561
const tree = useFloatingTree<RT>();
5662
const dataRef = React.useRef<ContextData>({});
5763
const events = React.useState(() => createPubSub())[0];
58-
const floating = usePositionalFloating<RT>({placement, middleware, strategy});
64+
const floating = usePositionalFloating<RT>({
65+
placement,
66+
middleware,
67+
strategy,
68+
whileElementsMounted,
69+
});
5970

6071
const context = React.useMemo<FloatingContext<RT>>(
6172
() => ({

packages/react-dom/package.json

+1-2
Original file line numberDiff line numberDiff line change
@@ -71,8 +71,7 @@
7171
"@rollup/plugin-commonjs": "^21.0.1",
7272
"@rollup/plugin-node-resolve": "^13.0.6",
7373
"@rollup/plugin-replace": "^3.0.0",
74-
"@testing-library/react": "^12.1.2",
75-
"@testing-library/react-hooks": "^7.0.2",
74+
"@testing-library/react": "^13.2.0",
7675
"@types/jest": "^27.0.3",
7776
"@types/react": "^18.0.1",
7877
"babel-plugin-annotate-pure-calls": "^0.4.0",

packages/react-dom/src/index.ts

+48-6
Original file line numberDiff line numberDiff line change
@@ -31,16 +31,37 @@ export type UseFloatingReturn<RT extends ReferenceType = ReferenceType> =
3131
};
3232
};
3333

34+
export type UseFloatingProps<RT extends ReferenceType = ReferenceType> = Omit<
35+
Partial<ComputePositionConfig>,
36+
'platform'
37+
> & {
38+
whileElementsMounted?: (
39+
reference: RT,
40+
floating: HTMLElement,
41+
update: () => void
42+
) => void | (() => void);
43+
};
44+
45+
function useLatestRef<T>(value: T) {
46+
const ref = useRef(value);
47+
useLayoutEffect(() => {
48+
ref.current = value;
49+
});
50+
return ref;
51+
}
52+
3453
export function useFloating<RT extends ReferenceType = ReferenceType>({
3554
middleware,
3655
placement = 'bottom',
3756
strategy = 'absolute',
38-
}: Omit<
39-
Partial<ComputePositionConfig>,
40-
'platform'
41-
> = {}): UseFloatingReturn<RT> {
57+
whileElementsMounted,
58+
}: UseFloatingProps = {}): UseFloatingReturn<RT> {
4259
const reference = useRef<RT | null>(null);
4360
const floating = useRef<HTMLElement | null>(null);
61+
62+
const whileElementsMountedRef = useLatestRef(whileElementsMounted);
63+
const cleanupRef = useRef<void | (() => void) | null>(null);
64+
4465
const [data, setData] = useState<Data>({
4566
// Setting these to `null` will allow the consumer to determine if
4667
// `computePosition()` has run yet
@@ -90,20 +111,41 @@ export function useFloating<RT extends ReferenceType = ReferenceType>({
90111

91112
useLayoutEffect(update, [update]);
92113

114+
const runElementMountCallback = useCallback(() => {
115+
if (typeof cleanupRef.current === 'function') {
116+
cleanupRef.current();
117+
cleanupRef.current = null;
118+
}
119+
120+
if (
121+
reference.current &&
122+
floating.current &&
123+
whileElementsMountedRef.current
124+
) {
125+
cleanupRef.current = whileElementsMountedRef.current(
126+
reference.current,
127+
floating.current,
128+
update
129+
);
130+
}
131+
}, [update, whileElementsMountedRef]);
132+
93133
const setReference: UseFloatingReturn<RT>['reference'] = useCallback(
94134
(node) => {
95135
reference.current = node;
136+
runElementMountCallback();
96137
update();
97138
},
98-
[update]
139+
[update, runElementMountCallback]
99140
);
100141

101142
const setFloating: UseFloatingReturn<RT>['floating'] = useCallback(
102143
(node) => {
103144
floating.current = node;
145+
runElementMountCallback();
104146
update();
105147
},
106-
[update]
148+
[update, runElementMountCallback]
107149
);
108150

109151
const refs = useMemo(() => ({reference, floating}), []);

packages/react-dom/test/index.test.tsx

+136-18
Original file line numberDiff line numberDiff line change
@@ -11,16 +11,8 @@ import {
1111
hide,
1212
limitShift,
1313
} from '../src';
14-
import {renderHook} from '@testing-library/react-hooks';
15-
import {render, waitFor, fireEvent} from '@testing-library/react';
16-
import {useRef, useState} from 'react';
17-
18-
test('`x` and `y` are initially `null`', async () => {
19-
const {result} = renderHook(() => useFloating());
20-
21-
expect(result.current.x).toBe(null);
22-
expect(result.current.y).toBe(null);
23-
});
14+
import {render, fireEvent, screen, cleanup, act} from '@testing-library/react';
15+
import {useRef, useState, useEffect} from 'react';
2416

2517
test('middleware is always fresh and does not cause an infinite loop', async () => {
2618
function InlineMiddleware() {
@@ -129,16 +121,142 @@ test('middleware is always fresh and does not cause an infinite loop', async ()
129121
);
130122
}
131123

132-
await waitFor(() => render(<InlineMiddleware />));
124+
render(<InlineMiddleware />);
125+
126+
const {getByTestId} = render(<StateMiddleware />);
127+
fireEvent.click(getByTestId('step1'));
128+
129+
await act(async () => {});
133130

134-
const {getByTestId} = await waitFor(() => render(<StateMiddleware />));
135-
await waitFor(() => fireEvent.click(getByTestId('step1')));
136-
await waitFor(() => expect(getByTestId('x').textContent).toBe('10'));
131+
expect(getByTestId('x').textContent).toBe('10');
137132

138-
await waitFor(() => fireEvent.click(getByTestId('step2')));
139-
await waitFor(() => expect(getByTestId('x').textContent).toBe('5'));
133+
fireEvent.click(getByTestId('step2'));
134+
135+
await act(async () => {});
136+
137+
expect(getByTestId('x').textContent).toBe('5');
140138

141139
// No `expect` as this test will fail if a render loop occurs
142-
await waitFor(() => fireEvent.click(getByTestId('step3')));
143-
await waitFor(() => fireEvent.click(getByTestId('step4')));
140+
fireEvent.click(getByTestId('step3'));
141+
fireEvent.click(getByTestId('step4'));
142+
143+
await act(async () => {});
144+
});
145+
146+
describe('whileElementsMounted', () => {
147+
test('is called a single time when both elements mount', () => {
148+
const spy = jest.fn();
149+
150+
function App() {
151+
const {reference, floating} = useFloating({whileElementsMounted: spy});
152+
return (
153+
<>
154+
<button ref={reference} />
155+
<div ref={floating} />
156+
</>
157+
);
158+
}
159+
160+
render(<App />);
161+
expect(spy).toHaveBeenCalledTimes(1);
162+
cleanup();
163+
});
164+
165+
test('is called a single time after floating mounts conditionally', () => {
166+
const spy = jest.fn();
167+
168+
function App() {
169+
const [open, setOpen] = useState(false);
170+
const {reference, floating} = useFloating({whileElementsMounted: spy});
171+
return (
172+
<>
173+
<button ref={reference} onClick={() => setOpen(true)} />
174+
{open && <div ref={floating} />}
175+
</>
176+
);
177+
}
178+
179+
render(<App />);
180+
expect(spy).toHaveBeenCalledTimes(0);
181+
fireEvent.click(screen.getByRole('button'));
182+
expect(spy).toHaveBeenCalledTimes(1);
183+
184+
cleanup();
185+
});
186+
187+
test('is called a single time after reference mounts conditionally', () => {
188+
const spy = jest.fn();
189+
190+
function App() {
191+
const [open, setOpen] = useState(false);
192+
const {reference, floating} = useFloating({whileElementsMounted: spy});
193+
return (
194+
<>
195+
{open && <button ref={reference} />}
196+
<div role="tooltip" ref={floating} onClick={() => setOpen(true)} />
197+
</>
198+
);
199+
}
200+
201+
render(<App />);
202+
expect(spy).toHaveBeenCalledTimes(0);
203+
fireEvent.click(screen.getByRole('tooltip'));
204+
expect(spy).toHaveBeenCalledTimes(1);
205+
206+
cleanup();
207+
});
208+
209+
test('is called a single time both elements mount conditionally', () => {
210+
const spy = jest.fn();
211+
212+
function App() {
213+
const [open, setOpen] = useState(false);
214+
const {reference, floating} = useFloating({whileElementsMounted: spy});
215+
216+
useEffect(() => {
217+
setOpen(true);
218+
}, []);
219+
220+
return (
221+
<>
222+
{open && <button ref={reference} />}
223+
{open && <div role="tooltip" ref={floating} />}
224+
</>
225+
);
226+
}
227+
228+
render(<App />);
229+
expect(spy).toHaveBeenCalledTimes(1);
230+
231+
cleanup();
232+
});
233+
234+
test('calls the cleanup function', () => {
235+
const cleanupSpy = jest.fn();
236+
const spy = jest.fn(() => cleanupSpy);
237+
238+
function App() {
239+
const [open, setOpen] = useState(true);
240+
const {reference, floating} = useFloating({whileElementsMounted: spy});
241+
242+
useEffect(() => {
243+
setOpen(false);
244+
}, []);
245+
246+
return (
247+
<>
248+
{open && <button ref={reference} />}
249+
{open && <div role="tooltip" ref={floating} />}
250+
</>
251+
);
252+
}
253+
254+
render(<App />);
255+
expect(cleanupSpy).toHaveBeenCalledTimes(1);
256+
257+
// Does not get called again post-cleanup
258+
expect(spy).toHaveBeenCalledTimes(1);
259+
260+
cleanup();
261+
});
144262
});

0 commit comments

Comments
 (0)