Skip to content

Commit daa0b0e

Browse files
authored
Added test case for re-used lazy leases to isolate react strict mode failure
1 parent 8b353bf commit daa0b0e

15 files changed

+317
-105
lines changed

Diff for: examples/old-typescript/package-lock.json

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

Diff for: src/react/ScopeProvider.test.tsx

+79-44
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,20 @@
11
import { act, renderHook } from "@testing-library/react";
2-
import { atom, useAtom } from "jotai";
3-
import React, { ReactNode, useContext, useRef, useState } from "react";
2+
import {
3+
Atom,
4+
PrimitiveAtom,
5+
atom,
6+
getDefaultStore,
7+
useAtom,
8+
useAtomValue,
9+
useSetAtom,
10+
} from "jotai";
11+
import React, { ReactNode, useContext, useEffect } from "react";
412
import { createLifecycleUtils } from "../shared/testing/lifecycle";
5-
import { ComponentScope, createScope, molecule, use } from "../vanilla";
13+
import { createScope, molecule, resetDefaultInjector, use } from "../vanilla";
614
import { ScopeProvider } from "./ScopeProvider";
715
import { ScopeContext } from "./contexts/ScopeContext";
816
import { strictModeSuite } from "./testing/strictModeSuite";
917
import { useMolecule } from "./useMolecule";
10-
import { useScopes } from "./useScopes";
1118

1219
const ExampleMolecule = molecule(() => {
1320
return {
@@ -149,41 +156,63 @@ strictModeSuite(({ wrapper: Outer }) => {
149156
});
150157

151158
describe("Separate ScopeProviders", () => {
159+
beforeEach(() => {
160+
// Turn on logging for this test
161+
resetDefaultInjector({});
162+
});
152163
test("String scope values are cleaned up at the right time (not too soon, not too late)", async () => {
153164
const TestHookContext = React.createContext<
154165
ReturnType<typeof useTestHook>
155166
>(undefined as any);
156167

168+
const mountA = atom(true);
169+
const valueA = atom(undefined as unknown);
170+
const mountB = atom(true);
171+
const valueB = atom(undefined as unknown);
172+
157173
const useTestHook = () => {
158-
const [mountA, setMountA] = useState(true);
159-
const [mountB, setMountB] = useState(true);
160-
const insideValue = useRef(null as any);
161-
const props = { mountA, mountB, setMountA, setMountB, insideValue };
162-
return props;
174+
const setMountA = useSetAtom(mountA);
175+
const setMountB = useSetAtom(mountB);
176+
return { setMountA, setMountB };
163177
};
164178

165179
const sharedAtExample = "[email protected]";
166180

167-
const Child = () => {
168-
// const scopes = useScopes().filter(
169-
// ([scope]) => scope !== ComponentScope,
170-
// );
181+
const Child = (props: {
182+
name: string;
183+
value: PrimitiveAtom<unknown>;
184+
}) => {
171185
const context = useContext(TestHookContext);
172186
const value = useMolecule(UserMolecule);
173-
context.insideValue.current = useContext(ScopeContext);
187+
const setValue = useSetAtom(props.value);
188+
189+
useEffect(() => {
190+
return () => {
191+
setValue(undefined);
192+
};
193+
});
194+
195+
setValue(value);
174196
return <div>Bad</div>;
175197
};
176-
const ProviderWithChild = () => (
177-
<ScopeProvider scope={UserScope} value={sharedAtExample}>
178-
<Child />
179-
</ScopeProvider>
180-
);
198+
const ProviderWithChild = (props: {
199+
show: Atom<boolean>;
200+
value: PrimitiveAtom<unknown>;
201+
name: string;
202+
}) => {
203+
const isShown = useAtomValue(props.show);
204+
return (
205+
<ScopeProvider scope={UserScope} value={sharedAtExample}>
206+
{isShown && <Child name={props.name} value={props.value} />}
207+
</ScopeProvider>
208+
);
209+
};
181210

182-
const Controller = (props: any) => {
211+
const Controller = (props: ReturnType<typeof useTestHook>) => {
183212
return (
184213
<>
185-
{props.mountA && <ProviderWithChild />}
186-
{props.mountB && <ProviderWithChild />}
214+
<ProviderWithChild key="a" name="a" show={mountA} value={valueA} />
215+
<ProviderWithChild key="b" name="b" show={mountB} value={valueB} />
187216
</>
188217
);
189218
};
@@ -212,57 +241,63 @@ strictModeSuite(({ wrapper: Outer }) => {
212241
},
213242
);
214243

215-
const { insideValue } = result.current;
244+
const initialValue = getDefaultStore().get(valueA);
245+
let aValue = getDefaultStore().get(valueA);
246+
let bValue = getDefaultStore().get(valueB);
247+
216248
// Then the molecule is mounted
217249
userLifecycle.expectActivelyMounted();
218250
// And the lifecycle events are called
219251
expect(userLifecycle.mounts).toHaveBeenCalledWith(sharedAtExample);
220-
// And the scopes matches the initial value
221-
expect(insideValue.current).toStrictEqual([[UserScope, sharedAtExample]]);
222-
223-
const userScopeTuple = insideValue.current[0];
252+
// And both trees have the same value
253+
expect(aValue).toBe(bValue);
224254

225255
act(() => {
226256
// When A is unmounted
227257
result.current.setMountA(false);
228258
});
259+
229260
// Then the molecule is still mounted
230261
// Because it's still being used by B
231262
userLifecycle.expectActivelyMounted();
232263

233-
const afterUnmountCache = insideValue.current;
264+
aValue = getDefaultStore().get(valueA);
265+
bValue = getDefaultStore().get(valueB);
234266

235-
// Then the scope tuple is unchanged
236-
expect(afterUnmountCache[0]).toBe(userScopeTuple);
267+
// Then A has been unmounted
268+
expect(aValue).toBe(undefined);
269+
// Then B still has the original value
270+
expect(bValue).toBe(initialValue);
237271

238272
// When B is unmounted
239273
act(() => {
274+
// When A is unmounted
240275
result.current.setMountB(false);
241276
});
242277

243278
// Then the molecule is unmounted
244279
userLifecycle.expectToMatchCalls([sharedAtExample]);
245280

246-
// Then the scope tuple is unchanged
247-
const finalTuples = insideValue.current;
248-
expect(finalTuples[0]).toBe(userScopeTuple);
281+
aValue = getDefaultStore().get(valueA);
282+
bValue = getDefaultStore().get(valueB);
283+
284+
// Then both values are cleaned up
285+
expect(aValue).not.toBe(initialValue);
286+
expect(bValue).not.toBe(initialValue);
287+
expect(aValue).toBeUndefined();
288+
expect(bValue).toBeUndefined();
249289

250290
// When B is re-mounted
251291
act(() => {
252292
result.current.setMountB(true);
253293
});
254294

255-
// Then a fresh tuple is created
256-
const freshTuples = insideValue.current;
257-
const [freshTuple] = freshTuples;
258-
259-
// And it does not match the original
260-
expect(freshTuple).not.toBe(userScopeTuple);
261-
if (true) {
262-
const [scopeKey, scopeValue] = freshTuple;
263-
expect(scopeKey).toBe(UserScope);
264-
expect(scopeValue).toBe(sharedAtExample);
265-
}
295+
bValue = getDefaultStore().get(valueB);
296+
// Then a new molecule value is created
297+
expect(bValue).not.toBeUndefined();
298+
// And it doesn't match the original value
299+
expect(bValue).not.toBe(initialValue);
300+
expect(bValue).not.toStrictEqual(initialValue);
266301

267302
// When the component is unmounted
268303
rest.unmount();

Diff for: src/react/contexts/InjectorContext.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import React from "react";
22
import { getDefaultInjector } from "../../vanilla";
33

4-
export const InjectorContext = React.createContext(getDefaultInjector());
4+
export const InjectorContext = React.createContext(() => getDefaultInjector());
55
InjectorContext.displayName = "BunshiMoleculeInjectorContext";

Diff for: src/react/index.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
export { MoleculeScopeOptions } from "../shared/MoleculeScopeOptions";
1+
export { type MoleculeScopeOptions } from "../shared/MoleculeScopeOptions";
22

33
export * from "../vanilla";
44

Diff for: src/react/testing/TestInjectorProvider.tsx

+4-4
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,23 @@
1-
import React from "react";
1+
import React, { useCallback } from "react";
22
import { createInjector } from "..";
33
import { InjectorProvider } from "../InjectorProvider";
44

55
export function createTestInjectorProvider(
66
Wrapper?: React.FC<{ children: React.ReactNode }>,
77
) {
88
const injector = createInjector();
9-
9+
const getInjector = () => injector;
1010
const TestInjectorProvider: React.FC<{ children: React.ReactNode }> = ({
1111
children,
1212
}) => {
1313
if (Wrapper) {
1414
return (
1515
<Wrapper>
16-
<InjectorProvider value={injector}>{children}</InjectorProvider>
16+
<InjectorProvider value={getInjector}>{children}</InjectorProvider>
1717
</Wrapper>
1818
);
1919
}
20-
return <InjectorProvider value={injector}>{children}</InjectorProvider>;
20+
return <InjectorProvider value={getInjector}>{children}</InjectorProvider>;
2121
};
2222

2323
return TestInjectorProvider;

Diff for: src/react/useInjector.test.tsx

+3-2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { act, renderHook } from "@testing-library/react";
22
import { atom, getDefaultStore, useAtomValue } from "jotai";
3-
import React from "react";
3+
import React, { useCallback } from "react";
44
import {
55
MoleculeInjector,
66
MoleculeOrInterface,
@@ -41,10 +41,11 @@ strictModeSuite(({ wrapper }) => {
4141

4242
const Wrapper = ({ children }: { children?: React.ReactNode }) => {
4343
const injector = useAtomValue(injectorAtom);
44+
const getInjector = useCallback(() => injector, [injector]);
4445
const Outer = wrapper;
4546
return (
4647
<Outer>
47-
<InjectorProvider value={injector}>{children}</InjectorProvider>
48+
<InjectorProvider value={getInjector}>{children}</InjectorProvider>
4849
</Outer>
4950
);
5051
};

Diff for: src/react/useInjector.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -7,5 +7,5 @@ import { InjectorContext } from "./contexts/InjectorContext";
77
* @returns
88
*/
99
export function useInjector() {
10-
return useContext(InjectorContext);
10+
return useContext(InjectorContext)();
1111
}

Diff for: src/react/useMolecule.test.tsx

+29-15
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,19 @@
11
import { renderHook } from "@testing-library/react";
22
import React, { StrictMode, useContext } from "react";
3-
import { createScope, molecule, onMount, onUnmount, use } from ".";
3+
import {
4+
createScope,
5+
molecule,
6+
onMount,
7+
onUnmount,
8+
resetDefaultInjector,
9+
use,
10+
} from ".";
411
import { ScopeProvider } from "./ScopeProvider";
512
import { ScopeContext } from "./contexts/ScopeContext";
613
import { strictModeSuite } from "./testing/strictModeSuite";
714
import { useMolecule } from "./useMolecule";
815
import { createLifecycleUtils } from "../shared/testing/lifecycle";
16+
import { LoggingInstrumentation } from "../vanilla/internal/instrumentation";
917

1018
export const UserScope = createScope("[email protected]", {
1119
debugLabel: "User Scope",
@@ -212,8 +220,9 @@ strictModeSuite(({ wrapper }) => {
212220
});
213221
});
214222

215-
test.todo("Strict mode", () => {
216-
const expectedUser = "johan";
223+
test.only("Strict mode", () => {
224+
resetDefaultInjector({ instrumentation: new LoggingInstrumentation() });
225+
const expectedUser = "[email protected]";
217226

218227
const lifecycle = createLifecycleUtils();
219228
const UseLifecycleMolecule = molecule(() => {
@@ -224,35 +233,38 @@ test.todo("Strict mode", () => {
224233
const testHook = () => {
225234
return {
226235
molecule: useMolecule(UseLifecycleMolecule, {
227-
// exclusiveScope: [UserScope, expectedUser],
236+
exclusiveScope: [UserScope, expectedUser],
228237
}),
229238
};
230239
};
231240

232241
lifecycle.expectUncalled();
233242

243+
console.log("Render 1");
234244
const run1 = renderHook(testHook, {
235245
wrapper: StrictMode,
236246
});
247+
console.log("==========Render 1 done");
237248

238-
expect.soft(lifecycle.executions).toBeCalledWith(expectedUser);
239-
expect.soft(lifecycle.mounts).toBeCalledWith(expectedUser);
240-
expect.soft(lifecycle.executions).toBeCalledTimes(2);
241-
expect.soft(lifecycle.mounts).toBeCalledTimes(2);
242-
expect.soft(lifecycle.unmounts).toBeCalledTimes(1);
243-
expect.soft(run1.result.current.molecule).toBe(expectedUser);
249+
expect(lifecycle.executions).toBeCalledTimes(2);
250+
expect(lifecycle.mounts).toBeCalledTimes(1);
251+
expect(lifecycle.unmounts).toBeCalledTimes(1);
252+
expect(run1.result.current.molecule).toBe(expectedUser);
244253

254+
console.log("==========Render 2");
245255
const run2 = renderHook(testHook, {
246256
wrapper: StrictMode,
247257
});
258+
console.log("Render 2 done");
248259

249-
// expect(runs).toBeCalledTimes(2);
250-
// expect(unmounts).toBeCalledTimes(4);
251-
// expect(mounts).toBeCalledTimes(1);
252-
// expect(unmounts).not.toBeCalled();
253-
// expect(run2.result.current.molecule).toBe(expectedUser);
260+
expect(lifecycle.executions).toBeCalledTimes(3);
261+
expect(lifecycle.mounts).toBeCalledTimes(2);
262+
expect(lifecycle.unmounts).toBeCalledTimes(1);
263+
expect(run2.result.current.molecule).toBe(expectedUser);
254264

265+
console.log("Unmount 1");
255266
run1.unmount();
267+
console.log("Unmount 1 done");
256268

257269
expect(lifecycle.executions).toBeCalledWith(expectedUser);
258270
expect(lifecycle.mounts).toBeCalledWith(expectedUser);
@@ -261,7 +273,9 @@ test.todo("Strict mode", () => {
261273
expect(lifecycle.unmounts).toBeCalledTimes(1);
262274
expect(run1.result.current.molecule).toBe(expectedUser);
263275

276+
console.log("Unmount 2");
264277
run2.unmount();
278+
console.log("Unmount 2 done");
265279

266280
expect(lifecycle.executions).toBeCalledTimes(3);
267281
expect(lifecycle.mounts).toBeCalledTimes(2);

0 commit comments

Comments
 (0)