Skip to content

Commit 565c2d7

Browse files
committed
Add failing tests for #82
1 parent 4389760 commit 565c2d7

1 file changed

Lines changed: 260 additions & 1 deletion

File tree

src/react/useMolecule.test.tsx

Lines changed: 260 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { renderHook } from "@testing-library/react";
1+
import { render, renderHook } from "@testing-library/react";
22
import React, { StrictMode, useContext } from "react";
33
import { createScope, molecule, use } from ".";
44
import { createLifecycleUtils } from "../shared/testing/lifecycle";
@@ -691,3 +691,262 @@ test("Strict mode", () => {
691691
// Unmounts are called
692692
expect(lifecycle.unmounts).toBeCalledTimes(2);
693693
});
694+
695+
strictModeSuite(({ wrapper, isStrict }) => {
696+
describe("Issue #82 - Implicit and explicit scoped molecule instantiations", () => {
697+
// NOTE: This issue was reported with React 18 strict mode behavior
698+
// where components mount -> unmount -> remount, causing cache cleanup issues.
699+
// React 19 changed strict mode behavior (useMemo is called twice but keeps first result)
700+
// which has partially resolved this issue. These tests verify the expected behavior.
701+
702+
// Shared test setup for Issue #82 tests
703+
const TestScope = createScope("test-id");
704+
705+
function createTestMolecules() {
706+
// Source molecule with unique random value
707+
const sourceLifecycle = createLifecycleUtils();
708+
let sourceInstanceCount = 0;
709+
710+
const SourceMolecule = molecule(() => {
711+
sourceInstanceCount++;
712+
const value = {
713+
count: Math.random(),
714+
id: sourceInstanceCount,
715+
value: `source-${sourceInstanceCount}`,
716+
};
717+
sourceLifecycle.connect(value);
718+
return value;
719+
});
720+
721+
// Other molecule that depends on Source
722+
const otherLifecycle = createLifecycleUtils();
723+
const OtherMolecule = molecule(() => {
724+
const source = use(SourceMolecule);
725+
const value = { source, other: Math.random() };
726+
otherLifecycle.connect(value);
727+
return value;
728+
});
729+
730+
return {
731+
SourceMolecule,
732+
OtherMolecule,
733+
sourceLifecycle,
734+
otherLifecycle,
735+
getSourceInstanceCount: () => sourceInstanceCount,
736+
};
737+
}
738+
739+
const StrictWrapper = wrapper;
740+
741+
function createTestWrapper() {
742+
return ({ children }: { children?: React.ReactNode }) => (
743+
<StrictWrapper>
744+
<ScopeProvider scope={TestScope} value="foo">
745+
{children}
746+
</ScopeProvider>
747+
</StrictWrapper>
748+
);
749+
}
750+
751+
describe.each([
752+
{ first: "explicit", second: "implicit" },
753+
{ first: "implicit", second: "explicit" },
754+
])("When molecule is first $first then $second", ({ first }) => {
755+
// FIXME: Failing issue #82 shows that the "implicit then explicit" test fails in strict mode
756+
const TEST_SHOULD_FAIL_FOR_NOW = isStrict && first === "implicit";
757+
const testFn = TEST_SHOULD_FAIL_FOR_NOW ? test.fails : test;
758+
759+
testFn("Should share the same instance", () => {
760+
const {
761+
SourceMolecule,
762+
OtherMolecule,
763+
sourceLifecycle,
764+
otherLifecycle,
765+
} = createTestMolecules();
766+
const Wrapper = createTestWrapper();
767+
768+
sourceLifecycle.expectUncalled();
769+
otherLifecycle.expectUncalled();
770+
771+
let explicitSource: { count: number };
772+
let implicitSource: { count: number };
773+
let sourceCountAfterFirst: number;
774+
775+
if (first === "explicit") {
776+
// Step 1: Mount Source molecule EXPLICITLY first
777+
const { result: sourceResult } = renderHook(
778+
() => useMolecule(SourceMolecule),
779+
{ wrapper: Wrapper },
780+
);
781+
782+
explicitSource = sourceResult.current;
783+
sourceCountAfterFirst = sourceLifecycle.executions.mock.calls.length;
784+
785+
// Step 2: Mount Other molecule which implicitly uses Source
786+
const { result: otherResult } = renderHook(
787+
() => useMolecule(OtherMolecule),
788+
{ wrapper: Wrapper },
789+
);
790+
791+
implicitSource = otherResult.current.source;
792+
} else {
793+
// Step 1: Mount Other molecule (which implicitly creates Source)
794+
const { result: otherResult } = renderHook(
795+
() => useMolecule(OtherMolecule),
796+
{ wrapper: Wrapper },
797+
);
798+
799+
implicitSource = otherResult.current.source;
800+
sourceCountAfterFirst = sourceLifecycle.executions.mock.calls.length;
801+
802+
// Step 2: Now explicitly mount Source molecule in the same scope
803+
const { result: sourceResult } = renderHook(
804+
() => useMolecule(SourceMolecule),
805+
{ wrapper: Wrapper },
806+
);
807+
808+
explicitSource = sourceResult.current;
809+
}
810+
811+
// CRITICAL: The explicit and implicit instances should be THE SAME
812+
expect(explicitSource).toBe(implicitSource);
813+
expect(explicitSource.count).toBe(implicitSource.count);
814+
815+
// Source should NOT be executed again (already exists in cache)
816+
expect(sourceLifecycle.executions.mock.calls.length).toBe(
817+
sourceCountAfterFirst,
818+
);
819+
});
820+
821+
testFn("Should propagate changes between instances", () => {
822+
const { SourceMolecule, OtherMolecule } = createTestMolecules();
823+
const Wrapper = createTestWrapper();
824+
825+
let sourceValue: { count: number };
826+
let otherValue: { source: { count: number }; other: number };
827+
828+
if (first === "explicit") {
829+
// Mount Source first (explicit)
830+
const { result: sourceResult } = renderHook(
831+
() => useMolecule(SourceMolecule),
832+
{ wrapper: Wrapper },
833+
);
834+
sourceValue = sourceResult.current;
835+
836+
// Mount Other second (implicit Source)
837+
const { result: otherResult } = renderHook(
838+
() => useMolecule(OtherMolecule),
839+
{ wrapper: Wrapper },
840+
);
841+
otherValue = otherResult.current;
842+
} else {
843+
// Mount Other first (implicit Source)
844+
const { result: otherResult } = renderHook(
845+
() => useMolecule(OtherMolecule),
846+
{ wrapper: Wrapper },
847+
);
848+
otherValue = otherResult.current;
849+
850+
// Mount Source second (explicit)
851+
const { result: sourceResult } = renderHook(
852+
() => useMolecule(SourceMolecule),
853+
{ wrapper: Wrapper },
854+
);
855+
sourceValue = sourceResult.current;
856+
}
857+
858+
// They should be the same instance
859+
expect(sourceValue).toBe(otherValue.source);
860+
861+
// Modify via explicit reference
862+
sourceValue.count = 42;
863+
864+
// Changes should be visible in implicit reference
865+
expect(otherValue.source.count).toBe(42);
866+
});
867+
868+
testFn("Should handle toggle pattern with real components", () => {
869+
const { SourceMolecule, OtherMolecule, getSourceInstanceCount } =
870+
createTestMolecules();
871+
872+
// Component that explicitly uses Source
873+
function ComponentA() {
874+
const source = useMolecule(SourceMolecule);
875+
return <div data-testid="component-a">{source.value}</div>;
876+
}
877+
878+
// Component that uses Other (which implicitly uses Source)
879+
function ComponentB() {
880+
const other = useMolecule(OtherMolecule);
881+
return <div data-testid="component-b">{other.source.value}</div>;
882+
}
883+
884+
let initialValue: string | null;
885+
let instancesAfterFirst: number;
886+
let aValue: string | null;
887+
let bValue: string | null;
888+
889+
if (first === "explicit") {
890+
// Main component with toggle - ComponentA (explicit) first
891+
const TestComponent = ({ showB }: { showB: boolean }) => (
892+
<ScopeProvider scope={TestScope} value="test">
893+
<ComponentA />
894+
{showB && <ComponentB />}
895+
</ScopeProvider>
896+
);
897+
898+
const { rerender, getByTestId } = render(
899+
<TestComponent showB={false} />,
900+
{ wrapper },
901+
);
902+
903+
// Initially, only ComponentA is mounted (explicit Source)
904+
initialValue = getByTestId("component-a").textContent;
905+
instancesAfterFirst = getSourceInstanceCount();
906+
907+
// Now toggle on ComponentB (implicit Source via Other)
908+
rerender(<TestComponent showB={true} />);
909+
910+
aValue = getByTestId("component-a").textContent;
911+
bValue = getByTestId("component-b").textContent;
912+
} else {
913+
// Main component with toggle - ComponentB (implicit) first
914+
const TestComponent = ({ showA }: { showA: boolean }) => (
915+
<ScopeProvider scope={TestScope} value="test">
916+
<ComponentB />
917+
{showA && <ComponentA />}
918+
</ScopeProvider>
919+
);
920+
921+
const { rerender, getByTestId } = render(
922+
<TestComponent showA={false} />,
923+
{ wrapper },
924+
);
925+
926+
// Initially, only ComponentB is mounted (implicit Source)
927+
initialValue = getByTestId("component-b").textContent;
928+
instancesAfterFirst = getSourceInstanceCount();
929+
930+
// Now toggle on ComponentA (explicit Source)
931+
rerender(<TestComponent showA={true} />);
932+
933+
bValue = getByTestId("component-b").textContent;
934+
aValue = getByTestId("component-a").textContent;
935+
}
936+
937+
// CRITICAL TEST: Both components should show the SAME source value
938+
// This is the bug from issue #82 - they were getting different instances
939+
expect(aValue).toBe(bValue);
940+
expect(aValue).toBe(initialValue);
941+
942+
// Source should NOT be created again when second component mounts
943+
// (it should reuse the instance from first component)
944+
expect(getSourceInstanceCount()).toBe(instancesAfterFirst);
945+
946+
// Both components should reference the same instance ID
947+
expect(aValue).toContain("source-1");
948+
expect(bValue).toContain("source-1");
949+
});
950+
});
951+
});
952+
});

0 commit comments

Comments
 (0)