Skip to content

Commit 1eaa637

Browse files
authored
Stabilise context value consumed by useDialog (#175)
Co-authored-by: Alex Nicholson <alex.n@clove.kitchen>
1 parent 155c155 commit 1eaa637

4 files changed

Lines changed: 141 additions & 90 deletions

File tree

packages/react-dialog-async/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"name": "react-dialog-async",
33
"description": "A promise-based way to show dialogs in React",
44
"type": "module",
5-
"version": "2.5.0",
5+
"version": "2.5.1",
66
"sideEffects": false,
77
"main": "index.js",
88
"module": "index.esm.js",
Lines changed: 89 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React, { useEffect, useRef } from 'react';
1+
import React, { useContext, useEffect, useMemo, useRef } from 'react';
22
import { PropsWithChildren, useCallback, useState } from 'react';
33
import DialogContext, { dialogContextState } from '../DialogContext';
44
import { DialogComponent } from '../types';
@@ -16,6 +16,7 @@ const DialogProvider = ({
1616
defaultUnmountDelayInMs,
1717
children,
1818
}: DialogProviderProps) => {
19+
// This ref tracks timers for unmount dialogs after they're closed
1920
const unmountDelayTimeoutRefs = useRef<{ [key: string]: any }>({});
2021

2122
const [dialogState, setDialogState] = useState<dialogsStateData>({});
@@ -24,39 +25,36 @@ const DialogProvider = ({
2425
/**
2526
* Force closes the dialog with the given id.
2627
*/
27-
const hide = useCallback(
28-
(id: string) => {
29-
setDialogState((state) => {
30-
if (!state[id]) return state;
28+
const hide = useCallback((id: string) => {
29+
setDialogState((state) => {
30+
if (!state[id]) return state;
3131

32-
if (!state[id].open) return state; // don't do anything if the dialog is already closed
32+
if (!state[id].open) return state; // don't do anything if the dialog is already closed
3333

34-
state[id].resolve?.(undefined);
34+
state[id].resolve?.(undefined);
3535

36-
if (state[id].unmountDelay) {
37-
if (unmountDelayTimeoutRefs.current[id] !== undefined) {
38-
clearTimeout(unmountDelayTimeoutRefs.current[id]);
39-
}
36+
if (state[id].unmountDelay) {
37+
if (unmountDelayTimeoutRefs.current[id] !== undefined) {
38+
clearTimeout(unmountDelayTimeoutRefs.current[id]);
39+
}
4040

41-
// start the delay
42-
unmountDelayTimeoutRefs.current[id] = setTimeout(() => {
43-
setDialogState((state) => removeKey(state, id));
44-
}, state[id].unmountDelay);
41+
// start the delay
42+
unmountDelayTimeoutRefs.current[id] = setTimeout(() => {
43+
setDialogState((state) => removeKey(state, id));
44+
}, state[id].unmountDelay);
4545

46-
return {
47-
...state,
48-
[id]: {
49-
open: false,
50-
dialog: state[id].dialog,
51-
data: state[id].data,
52-
},
53-
};
54-
}
55-
return removeKey(state, id);
56-
});
57-
},
58-
[setDialogState],
59-
);
46+
return {
47+
...state,
48+
[id]: {
49+
open: false,
50+
dialog: state[id].dialog,
51+
data: state[id].data,
52+
},
53+
};
54+
}
55+
return removeKey(state, id);
56+
});
57+
}, []);
6058

6159
const show = useCallback(
6260
(
@@ -66,9 +64,6 @@ const DialogProvider = ({
6664
data: unknown,
6765
unmountDelay?: number,
6866
): Promise<unknown> => {
69-
// if already open, do nothing
70-
if (dialogState[id]?.open) return Promise.resolve();
71-
7267
return new Promise((resolve) => {
7368
if (unmountDelayTimeoutRefs.current[id] !== undefined) {
7469
clearTimeout(unmountDelayTimeoutRefs.current[id]);
@@ -79,71 +74,70 @@ const DialogProvider = ({
7974
hide(id);
8075
};
8176

82-
setDialogState((state) => ({
83-
...state,
84-
[id]: {
85-
dialog: dialog,
86-
open: true,
87-
hash,
88-
data,
89-
resolve: resolveFn,
90-
unmountDelay: unmountDelay ?? defaultUnmountDelayInMs,
91-
},
92-
}));
93-
});
94-
},
95-
[setDialogState, hide, defaultUnmountDelayInMs],
96-
);
77+
setDialogState((state) => {
78+
if (state[id]?.open) {
79+
resolve(undefined);
80+
return state;
81+
}
9782

98-
/**
99-
* Updates the data of the given dialog
100-
*/
101-
const updateData = useCallback(
102-
(id: string, data: unknown): void => {
103-
setDialogState((state) => {
104-
if (state[id]) {
10583
return {
10684
...state,
107-
[id]: { ...state[id], data },
85+
[id]: {
86+
dialog: dialog,
87+
open: true,
88+
hash,
89+
data,
90+
resolve: resolveFn,
91+
unmountDelay: unmountDelay ?? defaultUnmountDelayInMs,
92+
},
10893
};
109-
}
110-
return state;
94+
});
11195
});
11296
},
113-
[setDialogState],
97+
[hide, defaultUnmountDelayInMs],
11498
);
11599

116-
const dialogComponents = useRenderDialogs(dialogState);
117-
118-
if (
119-
process.env.NODE_ENV !== 'production' &&
120-
!usingOutlet &&
121-
dialogComponents.length > 0
122-
) {
123-
console.warn(
124-
'Rendering a dialog without a <DialogOutlet/>. Please include a <DialogOutlet/> as a child of <DialogProvider/> to ensure dialogs are rendered within the correct contexts - this will be required in the next major version of react-dialog-async. See https://react-dialog-async.a16n.dev/API/dialog-outlet for more details. This warning is only present in development',
125-
);
126-
}
100+
/**
101+
* Updates the data of the given dialog
102+
*/
103+
const updateData = useCallback((id: string, data: unknown): void => {
104+
setDialogState((state) => {
105+
if (state[id]) {
106+
return {
107+
...state,
108+
[id]: { ...state[id], data },
109+
};
110+
}
111+
return state;
112+
});
113+
}, []);
127114

128115
useEffect(() => {
129116
return () => {
130117
Object.values(unmountDelayTimeoutRefs.current).forEach(clearTimeout);
131118
};
132119
}, []);
133120

134-
const ctx: dialogContextState = {
135-
show,
136-
hide,
137-
updateData,
138-
};
121+
/**
122+
* To prevent unnecessary re-renders, be careful to ensure that this state has
123+
* no transitive dependencies to `dialogState`
124+
*/
125+
const ctx: dialogContextState = useMemo(
126+
() => ({
127+
show,
128+
hide,
129+
updateData,
130+
}),
131+
[show, hide, updateData],
132+
);
139133

140134
return (
141135
<DialogStateContext.Provider
142136
value={{ dialogs: dialogState, setIsUsingOutlet: setUsingOutlet }}
143137
>
144138
<DialogContext.Provider value={ctx}>
145139
{children}
146-
{!usingOutlet && dialogComponents}
140+
{!usingOutlet && <InternalDialogOutlet />}
147141
</DialogContext.Provider>
148142
</DialogStateContext.Provider>
149143
);
@@ -158,3 +152,23 @@ function removeKey<
158152
const { [key]: _, ...rest } = data;
159153
return rest;
160154
}
155+
156+
const InternalDialogOutlet = () => {
157+
const dialogState = useContext(DialogStateContext);
158+
159+
if (!dialogState) {
160+
throw new Error(
161+
'Dialog context not found. You likely forgot to wrap your app in a <DialogProvider/> (https://react-dialog-async.a16n.dev/installation)',
162+
);
163+
}
164+
165+
const dialogComponents = useRenderDialogs(dialogState.dialogs);
166+
167+
if (process.env.NODE_ENV !== 'production' && dialogComponents.length > 0) {
168+
console.warn(
169+
'Rendering a dialog without a <DialogOutlet/>. Please include a <DialogOutlet/> as a child of <DialogProvider/> to ensure dialogs are rendered within the correct contexts - this will be required in the next major version of react-dialog-async. See https://react-dialog-async.a16n.dev/API/dialog-outlet for more details. This warning is only present in development',
170+
);
171+
}
172+
173+
return <>{dialogComponents}</>;
174+
};

packages/react-dialog-async/src/useDialog.test.tsx

Lines changed: 44 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,19 @@
1-
import React, { act } from 'react';
1+
import React, { act, PropsWithChildren } from 'react';
22
import { expect, test } from 'vitest';
33
import { render, renderHook, screen } from '@testing-library/react';
44
import DialogProvider from './DialogProvider/DialogProvider';
55

66
import useDialog from './useDialog';
77
import { AsyncDialogProps } from './types';
88
import { useEffect } from 'react';
9+
import DialogOutlet from './DialogOutlet';
10+
11+
const TestWrapper = ({ children }: PropsWithChildren) => (
12+
<DialogProvider>
13+
{children}
14+
<DialogOutlet />
15+
</DialogProvider>
16+
);
917

1018
const TestDialog = () => <div>Hello World!</div>;
1119
const TestDialogWithData = ({ data }: AsyncDialogProps<string>) => (
@@ -19,9 +27,9 @@ test('can be called without error', () => {
1927
return null;
2028
};
2129
const result = render(
22-
<DialogProvider>
30+
<TestWrapper>
2331
<TestComponent />
24-
</DialogProvider>,
32+
</TestWrapper>,
2533
);
2634

2735
expect(result.asFragment()).toMatchSnapshot();
@@ -40,9 +48,9 @@ test('data is used when default data is not provided', () => {
4048
};
4149

4250
render(
43-
<DialogProvider>
51+
<TestWrapper>
4452
<TestComponent />
45-
</DialogProvider>,
53+
</TestWrapper>,
4654
);
4755

4856
expect(screen.getByText(message)).toBeDefined();
@@ -63,9 +71,9 @@ test('default data is used when data is not provided', () => {
6371
};
6472

6573
render(
66-
<DialogProvider>
74+
<TestWrapper>
6775
<TestComponent />
68-
</DialogProvider>,
76+
</TestWrapper>,
6977
);
7078

7179
expect(screen.getByText(message)).toBeDefined();
@@ -86,9 +94,9 @@ test('default data is overridden when data is provided', () => {
8694
};
8795

8896
render(
89-
<DialogProvider>
97+
<TestWrapper>
9098
<TestComponent />
91-
</DialogProvider>,
99+
</TestWrapper>,
92100
);
93101

94102
expect(screen.queryByText(message)).toBeNull();
@@ -101,7 +109,7 @@ test('unmount delay does not delay the promise being resolved', async () => {
101109
unmountDelayInMs: 100,
102110
}),
103111
{
104-
wrapper: DialogProvider,
112+
wrapper: TestWrapper,
105113
},
106114
);
107115

@@ -120,3 +128,29 @@ test('unmount delay does not delay the promise being resolved', async () => {
120128

121129
expect(value).toBe(true);
122130
});
131+
132+
test('consumer does not rerender when dialog is opened', async () => {
133+
// Track the number of renders
134+
let renderCount = 0;
135+
136+
const TestComponent = () => {
137+
renderCount++;
138+
const testDialog = useDialog(TestDialog);
139+
140+
return <button onClick={() => testDialog.open()}>Open dialog</button>;
141+
};
142+
143+
render(
144+
<TestWrapper>
145+
<TestComponent />
146+
</TestWrapper>,
147+
);
148+
149+
// Press the button to open the dialog
150+
await act(async () => {
151+
screen.getByText('Open dialog').click();
152+
});
153+
154+
// Expect only the initial render
155+
expect(renderCount).toBe(1);
156+
});

packages/react-dialog-async/src/useRenderDialogs.tsx

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@ import { dialogsStateData } from './DialogStateContext';
22
import React, { useMemo } from 'react';
33
import IndividualDialogContext from './IndividualDialogContext';
44

5+
/**
6+
* Given the current dialog state, outputs an array of `Element`s to be rendered.
7+
*/
58
const useRenderDialogs = (state: dialogsStateData) => {
69
return useMemo(() => {
710
const entries = Object.entries(state);
@@ -28,11 +31,11 @@ const useRenderDialogs = (state: dialogsStateData) => {
2831
isInsideDialogContext: true,
2932
};
3033

34+
// This key will be unique for each open of the dialog, ensuring that the dialog always resets its internal state.
35+
const key = id + hash;
36+
3137
return (
32-
<IndividualDialogContext.Provider
33-
key={id + hash}
34-
value={contextValue}
35-
>
38+
<IndividualDialogContext.Provider key={key} value={contextValue}>
3639
<Component {...dialogProps} />
3740
</IndividualDialogContext.Provider>
3841
);

0 commit comments

Comments
 (0)