From d6bb6f019a755e879ff5c20104ae9eeece42d7b2 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 22:00:54 +0000 Subject: [PATCH 1/2] fix(utils): call the committed fn in useUnmount `useUnmount` wrote its `fn` into a ref during render. If React dropped that render, for example a suspended transition, the unmount called a `fn` from a render that never committed. The ref now comes from `useLiveRef`, which updates after commit. Claude-Session: https://claude.ai/code/session_01BvSDyJABLGit75HAjpnsqm --- src/lib/utils/react/useUnmount.test.tsx | 60 +++++++++++++++++++++++++ src/lib/utils/react/useUnmount.ts | 7 +-- 2 files changed, 62 insertions(+), 5 deletions(-) create mode 100644 src/lib/utils/react/useUnmount.test.tsx diff --git a/src/lib/utils/react/useUnmount.test.tsx b/src/lib/utils/react/useUnmount.test.tsx new file mode 100644 index 00000000..58ab3e07 --- /dev/null +++ b/src/lib/utils/react/useUnmount.test.tsx @@ -0,0 +1,60 @@ +import { render, renderHook } from "@testing-library/react" +import { act, Suspense, startTransition, use, useState } from "react" +import { describe, expect, it, vi } from "vitest" +import { useUnmount } from "./useUnmount" + +describe("useUnmount", () => { + it("should call the fn of the last render when the component unmounts", () => { + const first = vi.fn() + const latest = vi.fn() + const { rerender, unmount } = renderHook(({ fn }) => useUnmount(fn), { + initialProps: { fn: first }, + }) + + rerender({ fn: latest }) + unmount() + + expect(first).not.toHaveBeenCalled() + expect(latest).toHaveBeenCalledTimes(1) + }) + + it("should call the committed fn when React drops a render", async () => { + const never = new Promise(() => {}) + const Suspends = () => { + "use no memo" + + use(never) + + return null + } + const committed = vi.fn() + const dropped = vi.fn() + const rendered: Array<() => void> = [] + let setFn: (fn: () => void) => void = () => {} + + const Comp = () => { + "use no memo" + + const [fn, set] = useState<() => void>(() => committed) + setFn = set + rendered.push(fn) + useUnmount(fn) + + return ( + {fn === dropped && } + ) + } + + const { unmount } = render() + + await act(async () => { + startTransition(() => setFn(() => dropped)) + }) + + unmount() + + expect(rendered).toContain(dropped) + expect(dropped).not.toHaveBeenCalled() + expect(committed).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/lib/utils/react/useUnmount.ts b/src/lib/utils/react/useUnmount.ts index 137c5ac2..7b10c9c8 100644 --- a/src/lib/utils/react/useUnmount.ts +++ b/src/lib/utils/react/useUnmount.ts @@ -1,12 +1,9 @@ -import { useRef } from "react" import { useEffectOnce } from "./useEffectOnce" +import { useLiveRef } from "./useLiveRef" // biome-ignore lint/suspicious/noExplicitAny: Expected any export const useUnmount = (fn: () => any): void => { - const fnRef = useRef(fn) - - // update the ref each render so if it change the newest callback will be invoked - fnRef.current = fn + const fnRef = useLiveRef(fn) useEffectOnce(() => () => fnRef.current()) } From a65f7f6ee2c58f803d795c189cc19540da77548f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 22:00:55 +0000 Subject: [PATCH 2/2] docs(utils): warn that StrictMode runs useMount, useEffectOnce and useUnmount early In development, under StrictMode, React mounts each component twice. `useMount` and `useEffectOnce` then run twice, and `useUnmount` runs once while the component stays mounted. Their pages now say so, and `useUnmount` gets a page. Closes #224. Claude-Session: https://claude.ai/code/session_01BvSDyJABLGit75HAjpnsqm --- gitbook/reactjrx/SUMMARY.md | 1 + gitbook/reactjrx/utils/react/useeffectonce.md | 6 ++++- gitbook/reactjrx/utils/react/usemount.md | 4 ++++ gitbook/reactjrx/utils/react/useunmount.md | 24 +++++++++++++++++++ 4 files changed, 34 insertions(+), 1 deletion(-) create mode 100644 gitbook/reactjrx/utils/react/useunmount.md diff --git a/gitbook/reactjrx/SUMMARY.md b/gitbook/reactjrx/SUMMARY.md index 8f763715..e6d2c1a4 100644 --- a/gitbook/reactjrx/SUMMARY.md +++ b/gitbook/reactjrx/SUMMARY.md @@ -43,6 +43,7 @@ * [React](utils/react/README.md) * [useMount](utils/react/usemount.md) * [useEffectOnce](utils/react/useeffectonce.md) + * [useUnmount](utils/react/useunmount.md) * [useConstant](utils/react/useconstant.md) * [useLiveRef](utils/react/useliveref.md) * [useDebounced](utils/react/usedebounced.md) diff --git a/gitbook/reactjrx/utils/react/useeffectonce.md b/gitbook/reactjrx/utils/react/useeffectonce.md index ed59fd72..2a6955c3 100644 --- a/gitbook/reactjrx/utils/react/useeffectonce.md +++ b/gitbook/reactjrx/utils/react/useeffectonce.md @@ -1,6 +1,10 @@ # useEffectOnce -React lifecycle hook that runs an effect only once. +React lifecycle hook that runs an effect once, when the component mounts. + +{% hint style="warning" %} +In development, under ``, React mounts each component twice. So the effect runs twice, with its cleanup between the two runs. Make the cleanup undo the effect. +{% endhint %} ### Usage diff --git a/gitbook/reactjrx/utils/react/usemount.md b/gitbook/reactjrx/utils/react/usemount.md index 684e30ac..0946aa1c 100644 --- a/gitbook/reactjrx/utils/react/usemount.md +++ b/gitbook/reactjrx/utils/react/usemount.md @@ -2,6 +2,10 @@ React lifecycle hook that calls a function after the component is mounted. +{% hint style="warning" %} +In development, under ``, React mounts each component twice. So `useMount` calls `fn` twice. Make `fn` safe to call twice, or use `useEffectOnce` and undo the work in its cleanup. +{% endhint %} + ### Usage ```jsx diff --git a/gitbook/reactjrx/utils/react/useunmount.md b/gitbook/reactjrx/utils/react/useunmount.md new file mode 100644 index 00000000..a24c88a8 --- /dev/null +++ b/gitbook/reactjrx/utils/react/useunmount.md @@ -0,0 +1,24 @@ +# useUnmount + +React lifecycle hook that calls a function when the component unmounts. It calls the `fn` of the last committed render. + +{% hint style="warning" %} +In development, under ``, React mounts each component twice. So `useUnmount` also calls `fn` once after the first mount, while the component stays mounted. Do not end work there that the component still uses, such as a subject that it reads. +{% endhint %} + +### Usage + +```jsx +import {useUnmount} from 'reactjrx'; + +const Demo = ({ onClose }) => { + useUnmount(() => onClose()); + return null; +}; +``` + +### Reference + +```ts +useUnmount(fn: () => any); +```