import { act, renderHook } from "@testing-library/react";
import {
Atom,
PrimitiveAtom,
atom,
getDefaultStore,
useAtom,
useAtomValue,
useSetAtom,
} from "jotai";
import React, { ReactNode, useContext, useEffect } from "react";
import { createLifecycleUtils } from "../shared/testing/lifecycle";
import { createScope, molecule, resetDefaultInjector, use } from "../vanilla";
import { ScopeProvider } from "./ScopeProvider";
import { ScopeContext } from "./contexts/ScopeContext";
import { strictModeSuite } from "./testing/strictModeSuite";
import { useMolecule } from "./useMolecule";
const ExampleMolecule = molecule(() => {
return {
example: Math.random(),
};
});
export const UserScope = createScope("user@example.com");
const AtomScope = createScope(atom("user@example.com"));
const userLifecycle = createLifecycleUtils();
export const UserMolecule = molecule(() => {
const userId = use(UserScope);
userLifecycle.connect(userId);
return {
example: Math.random(),
userId,
};
});
const atomLifecycle = createLifecycleUtils();
const AtomMolecule = molecule(() => {
const userAtom = use(AtomScope);
const userNameAtom = atom((get) => get(userAtom) + " name");
atomLifecycle.connect(userAtom);
return {
example: Math.random(),
userIdAtom: userAtom,
userNameAtom,
};
});
strictModeSuite(({ wrapper: Outer, isStrict }) => {
test("Use molecule should produce a single value across multiple uses", () => {
const { result: result1 } = renderHook(() => useMolecule(ExampleMolecule), {
wrapper: Outer,
});
const { result: result2 } = renderHook(() => useMolecule(ExampleMolecule), {
wrapper: Outer,
});
expect(result1.current).toBe(result2.current);
});
test("Alternating scopes", () => {
const ScopeA = createScope(undefined);
const ScopeB = createScope(undefined);
const ScopeC = createScope(undefined);
const ScopeAMolecule = molecule(
(mol, scope) => `${scope(ScopeA)}/${scope(ScopeB)}/${scope(ScopeC)}`,
);
const Wrapper = ({ children }: { children?: React.ReactNode }) => (
{children}
);
const useTestcase = () => {
return {
molecule: useMolecule(ScopeAMolecule),
context: useContext(ScopeContext),
};
};
const { result } = renderHook(useTestcase, {
wrapper: Wrapper,
});
expect(result.current.molecule).toStrictEqual("a1/b2/c1");
});
test("Use molecule should produce a different value in different providers", () => {
const Wrapper1 = ({ children }: { children?: React.ReactNode }) => (
{children}
);
const Wrapper2 = ({ children }: { children?: React.ReactNode }) => (
);
const useUserMolecule = () => {
return {
molecule: useMolecule(UserMolecule),
context: useContext(ScopeContext),
};
};
const { result: result1, ...rest1 } = renderHook(useUserMolecule, {
wrapper: Wrapper1,
});
expect(userLifecycle.mounts).toHaveBeenLastCalledWith("sam@example.com");
const { result: result2, ...rest2 } = renderHook(useUserMolecule, {
wrapper: Wrapper2,
});
expect(userLifecycle.mounts).toHaveBeenLastCalledWith(
"jeffrey@example.com",
);
expect(result1.current.context).toStrictEqual([
[UserScope, "sam@example.com"],
]);
expect(result1.current.molecule.userId).toBe("sam@example.com");
expect(result2.current.molecule.userId).toBe("jeffrey@example.com");
rest1.unmount();
expect(userLifecycle.unmounts).toHaveBeenLastCalledWith("sam@example.com");
rest2.unmount();
expect(userLifecycle.unmounts).toHaveBeenLastCalledWith(
"jeffrey@example.com",
);
if (isStrict) {
// userLifecycle.expectCalledTimesEach(4, 4, 4);
} else {
userLifecycle.expectToHaveBeenCalledTimes(2);
userLifecycle.expectToMatchCalls(
["sam@example.com"],
["jeffrey@example.com"],
);
}
});
describe("Separate ScopeProviders", () => {
beforeEach(() => {
// Turn on logging for this test
resetDefaultInjector({});
});
test("String scope values are cleaned up at the right time (not too soon, not too late)", async () => {
const TestHookContext = React.createContext<
ReturnType
>(undefined as any);
const mountA = atom(true);
const valueA = atom(undefined as unknown);
const mountB = atom(true);
const valueB = atom(undefined as unknown);
const useTestHook = () => {
const setMountA = useSetAtom(mountA);
const setMountB = useSetAtom(mountB);
return { setMountA, setMountB };
};
const sharedAtExample = "shared@example.com";
const Child = (props: {
name: string;
value: PrimitiveAtom;
}) => {
const value = useMolecule(UserMolecule);
const setValue = useSetAtom(props.value);
// console.log("Child render", props.name, value);
useEffect(() => {
// console.log("Child effect", props.name, value);
setValue(value);
return () => {
setValue(undefined);
};
}, []);
setValue(value);
return Bad
;
};
const ProviderWithChild = (props: {
show: Atom;
value: PrimitiveAtom;
name: string;
}) => {
const isShown = useAtomValue(props.show);
return (
{isShown && }
);
};
const Controller = () => {
return (
<>
>
);
};
const TestHookProvider: React.FC<{ children: ReactNode }> = ({
children,
}) => {
const props = useTestHook();
return (
{children}
);
};
userLifecycle.expectUncalled();
// When the component is initially mounted
const { result, ...rest } = renderHook(
() => useContext(TestHookContext),
{
wrapper: TestHookProvider,
},
);
const initialValue = getDefaultStore().get(valueA);
let aValue = getDefaultStore().get(valueA);
let bValue = getDefaultStore().get(valueB);
// Then the molecule is mounted
// and executed twice, since each call to `useMolecule` will call once
// and mounted once, because only one value will be used
// and never unmounted
if (isStrict) {
userLifecycle.expectCalledTimesEach(2, 2, 1);
} else {
userLifecycle.expectCalledTimesEach(2, 1, 0);
}
expect(aValue).toBe(bValue);
act(() => {
// When A is unmounted
result.current.setMountA(false);
});
// Then the molecule is still mounted
// Because it's still being used by B
if (isStrict) {
userLifecycle.expectCalledTimesEach(2, 2, 1);
} else {
userLifecycle.expectCalledTimesEach(2, 1, 0);
}
aValue = getDefaultStore().get(valueA);
bValue = getDefaultStore().get(valueB);
// Then A has been unmounted
expect(aValue).toBe(undefined);
// Then B still has the original value
expect(bValue).toBe(initialValue);
// When B is unmounted
act(() => {
// When A is unmounted
result.current.setMountB(false);
});
// Then the molecule is unmounted
if (isStrict) {
userLifecycle.expectCalledTimesEach(2, 2, 2);
} else {
userLifecycle.expectCalledTimesEach(2, 1, 1);
}
aValue = getDefaultStore().get(valueA);
bValue = getDefaultStore().get(valueB);
// Then both values are cleaned up
expect(aValue).not.toBe(initialValue);
expect(bValue).not.toBe(initialValue);
expect(aValue).toBeUndefined();
expect(bValue).toBeUndefined();
// When B is re-mounted
act(() => {
result.current.setMountB(true);
});
bValue = getDefaultStore().get(valueB);
// Then a new molecule value is created
if (isStrict) {
userLifecycle.expectCalledTimesEach(3, 4, 3);
} else {
userLifecycle.expectCalledTimesEach(3, 2, 1);
}
expect(bValue).not.toBeUndefined();
// And it doesn't match the original value
expect(bValue).not.toBe(initialValue);
expect(bValue).not.toStrictEqual(initialValue);
// When the component is unmounted
rest.unmount();
// Then the user molecule lifecycle has been completed twice
if (isStrict) {
userLifecycle.expectCalledTimesEach(3, 4, 4);
} else {
userLifecycle.expectCalledTimesEach(3, 2, 2);
}
});
});
test("Void scopes can be used to create unique molecules", () => {
const VoidScope = createScope(undefined);
const Wrapper1 = ({ children }: { children?: React.ReactNode }) => (
);
const Wrapper2 = ({ children }: { children?: React.ReactNode }) => (
);
const voidLifecycle = createLifecycleUtils();
const voidMolecule = molecule(() => {
use(VoidScope);
voidLifecycle.connect();
return {
example: Math.random(),
};
});
const useVoidMolecule = () => {
return {
molecule: useMolecule(voidMolecule),
context: useContext(ScopeContext),
};
};
voidLifecycle.expectUncalled();
const { result: result1, ...rest1 } = renderHook(useVoidMolecule, {
wrapper: Wrapper1,
});
const { result: result2, ...rest2 } = renderHook(useVoidMolecule, {
wrapper: Wrapper2,
});
expect(result1.current.molecule).not.toBe(result2.current.molecule);
if (isStrict) {
// Note: Since this is using a default scope, it is better memoized
voidLifecycle.expectCalledTimesEach(2, 4, 2);
} else {
voidLifecycle.expectCalledTimesEach(2, 2, 0);
}
rest1.unmount();
rest2.unmount();
if (isStrict) {
// Note: Since this is using a default scope, it is better memoized
voidLifecycle.expectCalledTimesEach(2, 4, 4);
} else {
voidLifecycle.expectToHaveBeenCalledTimes(2);
}
});
test("Object scope values are shared across providers", () => {
const childAtom = atom("sam@example.com");
const Wrapper1 = ({ children }: { children?: React.ReactNode }) => (
);
const Wrapper2 = ({ children }: { children?: React.ReactNode }) => (
);
const useUserMolecule = () => {
const molecule = useMolecule(AtomMolecule);
const context = useContext(ScopeContext);
const name = useAtom(molecule.userNameAtom)[0];
const userId = useAtom(molecule.userIdAtom)[0];
return {
molecule,
context,
name,
userId,
};
};
atomLifecycle.expectUncalled();
const { result: result1, ...rest1 } = renderHook(useUserMolecule, {
wrapper: Wrapper1,
});
atomLifecycle.expectActivelyMounted();
const { result: result2, ...rest2 } = renderHook(useUserMolecule, {
wrapper: Wrapper2,
});
atomLifecycle.expectActivelyMounted();
expect(result1.current.molecule).toBe(result2.current.molecule);
expect(result1.current.userId).toBe("sam@example.com");
expect(result2.current.userId).toBe("sam@example.com");
rest1.unmount();
atomLifecycle.expectActivelyMounted();
rest2.unmount();
atomLifecycle.expectToMatchCalls([childAtom]);
});
test("Use molecule should will use the nested scope", () => {
const Wrapper = ({ children }: { children?: React.ReactNode }) => (
{children}
);
userLifecycle.expectUncalled();
const useUserMolecule = () => {
return {
molecule: useMolecule(UserMolecule),
context: useContext(ScopeContext),
};
};
const { result, ...rest } = renderHook(useUserMolecule, {
wrapper: Wrapper,
});
if (isStrict) {
userLifecycle.expectCalledTimesEach(1, 2, 1);
} else {
userLifecycle.expectCalledTimesEach(1, 1, 0);
}
expect(result.current.context).toStrictEqual([
[UserScope, "jeffrey@example.com"],
]);
expect(result.current.molecule.userId).toBe("jeffrey@example.com");
rest.unmount();
if (isStrict) {
userLifecycle.expectCalledTimesEach(1, 2, 2);
} else {
userLifecycle.expectCalledTimesEach(1, 1, 1);
userLifecycle.expectToMatchCalls(["jeffrey@example.com"]);
}
});
});