diff --git a/src/provider/ModalProvider.tsx b/src/provider/ModalProvider.tsx index 3c8188a..0e9496d 100644 --- a/src/provider/ModalProvider.tsx +++ b/src/provider/ModalProvider.tsx @@ -1,13 +1,14 @@ import { assertTrue, isDefined } from "@okyrychenko-dev/type-utils"; -import { useEffect, useMemo, useRef, useState } from "react"; -import { confirmModal as defaultConfirmModal } from "../confirm"; -import { ModalLifecycleContext, createModalLifecycle } from "../lifecycle"; +import { useMemo } from "react"; +import { confirmModal as defaultConfirmModal } from "../confirm/builtInConfirmModal"; +import { ModalLifecycleContext } from "../lifecycle/ModalLifecycleContext"; import { isModalRegistryAttachable } from "../registry/createModalRegistry"; -import { ModalRuntimeConfigContext } from "../runtime"; -import { ModalViewport } from "../viewport"; +import { ModalRuntimeConfigContext } from "../runtime/ModalRuntimeConfigContext"; +import { ModalViewport } from "../viewport/ModalViewport"; import { ModalRegistryBinder } from "./ModalRegistryBinder"; +import { useModalLifecycleOwner } from "./useModalLifecycleOwner"; import type { ReactNode } from "react"; -import type { ModalRuntimeConfig } from "../runtime"; +import type { ModalRuntimeConfig } from "../runtime/ModalRuntimeConfigContext.types"; import type { ModalProviderProps } from "./ModalProvider.types"; export function ModalProvider(props: ModalProviderProps): ReactNode { @@ -31,27 +32,7 @@ export function ModalProvider(props: ModalProviderProps): ReactNode { [closeDelayMs, confirmModal], ); - const [lifecycle] = useState(() => createModalLifecycle({ closeDelayMs })); - const lifecycleEffectGeneration = useRef(0); - - useEffect(() => { - lifecycle.setCloseDelayMs(closeDelayMs); - }, [closeDelayMs, lifecycle]); - - useEffect(() => { - lifecycleEffectGeneration.current += 1; - - return () => { - lifecycleEffectGeneration.current += 1; - const cleanupGeneration = lifecycleEffectGeneration.current; - - globalThis.queueMicrotask(() => { - if (lifecycleEffectGeneration.current === cleanupGeneration) { - lifecycle.dispose(); - } - }); - }; - }, [lifecycle]); + const lifecycle = useModalLifecycleOwner(closeDelayMs); return ( diff --git a/src/provider/__tests__/useModalLifecycleOwner.test.tsx b/src/provider/__tests__/useModalLifecycleOwner.test.tsx new file mode 100644 index 0000000..706dca9 --- /dev/null +++ b/src/provider/__tests__/useModalLifecycleOwner.test.tsx @@ -0,0 +1,95 @@ +import { act, renderHook } from "@testing-library/react"; +import { StrictMode } from "react"; +import { describe, expect, it, vi } from "vitest"; +import { ModalDismissError } from "../../errors/ModalDismissError"; +import { useModalLifecycleOwner } from "../useModalLifecycleOwner"; +import { renameReportModal } from "./ModalProvider.fixtures"; +import type { ReactNode } from "react"; + +describe("useModalLifecycleOwner", () => { + it("should preserve lifecycle work through Strict Mode effect replay", async () => { + const wrapper = ({ children }: { children: ReactNode }): ReactNode => ( + {children} + ); + const { result } = renderHook(() => useModalLifecycleOwner(0), { + wrapper, + }); + + const handle = result.current.open(renameReportModal, { + currentName: "Strict Mode", + reportId: "strict-mode", + }); + + await act(async () => undefined); + + expect(result.current.getSnapshot().instances).toHaveLength(1); + + act(() => { + handle.dismiss(); + }); + + await expect(handle).rejects.toMatchObject({ reason: "dismiss" }); + }); + + it("should apply runtime updates without recreating lifecycle state", () => { + vi.useFakeTimers(); + + const { rerender, result } = renderHook( + ({ closeDelayMs }) => useModalLifecycleOwner(closeDelayMs), + { initialProps: { closeDelayMs: 0 } }, + ); + + const lifecycle = result.current; + const handle = lifecycle.open(renameReportModal, { + currentName: "Runtime update", + reportId: "runtime-update", + }); + + void handle.catch(() => undefined); + + rerender({ closeDelayMs: 200 }); + + act(() => { + handle.dismiss(); + }); + + expect(result.current).toBe(lifecycle); + expect(result.current.getSnapshot().instances[0]?.status).toBe("closing"); + + act(() => { + vi.advanceTimersByTime(200); + }); + + expect(result.current.getSnapshot().instances).toHaveLength(0); + vi.useRealTimers(); + }); + + it("should dispose pending work once after final unmount", async () => { + const { result, unmount } = renderHook(() => useModalLifecycleOwner(0)); + + const lifecycle = result.current; + const observer = vi.fn(); + + lifecycle.subscribe(observer); + + const handle = lifecycle.open(renameReportModal, { + currentName: "Unmount", + reportId: "unmount", + }); + + const rejection = expect(handle).rejects.toEqual( + new ModalDismissError("provider-unmount"), + ); + + unmount(); + + await act(async () => undefined); + + await rejection; + expect(lifecycle.getSnapshot().instances).toHaveLength(0); + + lifecycle.closeAll(); + + expect(observer).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/provider/useModalLifecycleOwner.ts b/src/provider/useModalLifecycleOwner.ts new file mode 100644 index 0000000..21a66d3 --- /dev/null +++ b/src/provider/useModalLifecycleOwner.ts @@ -0,0 +1,29 @@ +import { useEffect, useRef, useState } from "react"; +import { createModalLifecycle } from "../lifecycle/modalLifecycle"; +import type { ModalLifecycle } from "../lifecycle/modalLifecycle.types"; + +export function useModalLifecycleOwner(closeDelayMs: number): ModalLifecycle { + const [lifecycle] = useState(() => createModalLifecycle({ closeDelayMs })); + const mountGeneration = useRef(0); + + useEffect(() => { + lifecycle.setCloseDelayMs(closeDelayMs); + }, [closeDelayMs, lifecycle]); + + useEffect(() => { + mountGeneration.current += 1; + + return () => { + mountGeneration.current += 1; + const cleanupGeneration = mountGeneration.current; + + globalThis.queueMicrotask(() => { + if (mountGeneration.current === cleanupGeneration) { + lifecycle.dispose(); + } + }); + }; + }, [lifecycle]); + + return lifecycle; +}