Skip to content

Commit d0db2af

Browse files
committed
fix: initial state was reset in single subscription hooks in strict mode in dev
With strict mode in development, each component is mounted, unmounted and remounted. Previously, `initialState` was set to `undefined` after the first mount because a change in the passed reference was assumed.
1 parent 83d1bc6 commit d0db2af

File tree

3 files changed

+59
-34
lines changed

3 files changed

+59
-34
lines changed

src/internal/useListen.spec.ts

Lines changed: 31 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
import { act, renderHook } from "@testing-library/react";
1+
import { act, configure, renderHook } from "@testing-library/react";
22
import { newSymbol } from "../__testfixtures__/index.js";
33
import { useListen } from "./useListen.js";
44
import { LoadingState } from "./useLoadingValue.js";
5-
import { it, expect, beforeEach, describe, vi } from "vitest";
5+
import { it, expect, beforeEach, describe, vi, afterEach } from "vitest";
66

77
const result1 = newSymbol("Result 1");
88
const result2 = newSymbol("Result 2");
@@ -25,21 +25,37 @@ beforeEach(() => {
2525
onChange.mockReturnValue(onChangeUnsubscribe);
2626
});
2727

28-
describe("initial state", () => {
29-
// reference, initialState, expectedValue, expectedLoading
30-
it.each([
31-
[undefined, result1, undefined, false],
32-
[undefined, undefined, undefined, false],
33-
[undefined, LoadingState, undefined, false],
34-
[refA1, result1, result1, false],
35-
[refA1, undefined, undefined, false],
36-
[refA1, LoadingState, undefined, true],
37-
])("reference=%s initialState=%s", (reference, initialState, expectedValue, expectedLoading) => {
38-
const { result } = renderHook(() => useListen(reference, onChange, isEqual, initialState));
39-
expect(result.current).toStrictEqual([expectedValue, expectedLoading, undefined]);
40-
});
28+
afterEach(() => {
29+
configure({ reactStrictMode: false });
4130
});
4231

32+
describe.each([{ reactStrictMode: true }, { reactStrictMode: false }])(
33+
`strictMode=$reactStrictMode`,
34+
({ reactStrictMode }) => {
35+
beforeEach(() => {
36+
configure({ reactStrictMode });
37+
});
38+
39+
describe("initial state", () => {
40+
it.each`
41+
reference | initialState | expectedValue | expectedLoading
42+
${undefined} | ${result1} | ${undefined} | ${false}
43+
${undefined} | ${undefined} | ${undefined} | ${false}
44+
${undefined} | ${LoadingState} | ${undefined} | ${false}
45+
${refA1} | ${result1} | ${result1} | ${false}
46+
${refA1} | ${undefined} | ${undefined} | ${false}
47+
${refA1} | ${LoadingState} | ${undefined} | ${true}
48+
`(
49+
"reference=$reference initialState=$initialState",
50+
({ reference, initialState, expectedValue, expectedLoading }) => {
51+
const { result } = renderHook(() => useListen(reference, onChange, isEqual, initialState));
52+
expect(result.current).toStrictEqual([expectedValue, expectedLoading, undefined]);
53+
},
54+
);
55+
});
56+
},
57+
);
58+
4359
describe("when changing ref", () => {
4460
it("should not resubscribe for equal ref", async () => {
4561
// first ref

src/internal/useListen.ts

Lines changed: 19 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1-
import { useEffect, useMemo, useRef } from "react";
1+
import { useEffect, useMemo } from "react";
22
import { ValueHookResult } from "../common/index.js";
33
import { useLoadingValue, LoadingState } from "./useLoadingValue.js";
44
import { useStableValue } from "./useStableValue.js";
5+
import { usePrevious } from "./usePrevious.js";
56

67
/**
78
* @internal
@@ -26,30 +27,29 @@ export function useListen<Value, Error, Reference>(
2627
);
2728

2829
const stableRef = useStableValue(reference ?? undefined, isEqual);
29-
const firstRender = useRef<boolean>(true);
30+
const previousRef = usePrevious(stableRef);
3031

32+
// set state when ref changes
3133
useEffect(() => {
34+
if (stableRef === previousRef) {
35+
return;
36+
}
37+
3238
if (stableRef === undefined) {
33-
// value doesn't change on first render with undefined ref
34-
if (firstRender.current) {
35-
firstRender.current = false;
36-
} else {
37-
setValue();
38-
}
39+
setValue();
3940
} else {
40-
// do not set loading state on first render
41-
// otherwise, the defaultValue gets overwritten
42-
if (firstRender.current) {
43-
firstRender.current = false;
44-
} else {
45-
setLoading();
46-
}
41+
setLoading();
42+
}
43+
}, [previousRef, setLoading, setValue, stableRef]);
4744

48-
const unsubscribe = onChange(stableRef, setValue, setError);
49-
return () => unsubscribe();
45+
// subscribe to changes
46+
useEffect(() => {
47+
if (stableRef === undefined) {
48+
return;
5049
}
51-
return undefined;
52-
}, [stableRef, onChange, setError, setLoading, setValue]);
50+
const unsubscribe = onChange(stableRef, setValue, setError);
51+
return () => unsubscribe();
52+
}, [onChange, setError, setValue, stableRef]);
5353

5454
return useMemo(() => [value, loading, error], [value, loading, error]);
5555
}

src/internal/usePrevious.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { useEffect, useRef } from "react";
2+
3+
export function usePrevious<T>(value: T) {
4+
const ref = useRef<T>(value);
5+
useEffect(() => {
6+
ref.current = value;
7+
});
8+
return ref.current;
9+
}

0 commit comments

Comments
 (0)