diff --git a/.changeset/settled-custom-dialogs.md b/.changeset/settled-custom-dialogs.md new file mode 100644 index 0000000..0bb1690 --- /dev/null +++ b/.changeset/settled-custom-dialogs.md @@ -0,0 +1,5 @@ +--- +"@okyrychenko-dev/react-action-guard-router": patch +--- + +Settle pending custom confirmation dialogs as false on effect teardown, including unmount and hiding a preserved React Activity subtree. Clear preserved dialog state so it cannot reappear after effects reconnect. Dialog-specific resolvers now close their own dialog exactly once and cannot affect a replacement dialog. Stable hook-level controls continue to act on the current dialog. diff --git a/packages/router/README.md b/packages/router/README.md index 5229c95..0064b26 100644 --- a/packages/router/README.md +++ b/packages/router/README.md @@ -159,11 +159,24 @@ Helper hook for managing custom confirmation dialogs. - `dialogState: DialogState | null` - Current dialog state - `message: TMessage` - The message passed to confirm - `isOpen: boolean` - Whether dialog is open - - `resolve: (value: boolean) => void` - Internal resolver + - `resolve: (value: boolean) => void` - Settle and close this specific dialog - `confirm: (message: TMessage) => Promise` - Show dialog, returns Promise - `onConfirm: () => void` - Resolve dialog with `true` - `onCancel: () => void` - Resolve dialog with `false` +Each request settles once: confirmation resolves `true`; cancellation, replacement by +another `confirm` call, and hook unmount resolve `false`. Calling `dialogState.resolve` +also closes that dialog. Repeated calls and resolvers captured from an older dialog +have no effect on a newer dialog. + +Effect teardown also cancels and clears the pending dialog when React preserves +hook state, such as when an `Activity` becomes hidden. Revealing the subtree starts +with no open dialog and allows new confirmation requests. + +`confirm`, `onConfirm`, and `onCancel` retain stable references across renders. +The hook-level `onConfirm` and `onCancel` always act on the current dialog; use its +`dialogState.resolve` when a captured callback must belong to a specific dialog. + **Example:** ```tsx diff --git a/packages/router/src/core/__tests__/useDialogState.test.ts b/packages/router/src/core/__tests__/useDialogState.test.ts index bb4387c..38330a7 100644 --- a/packages/router/src/core/__tests__/useDialogState.test.ts +++ b/packages/router/src/core/__tests__/useDialogState.test.ts @@ -1,8 +1,141 @@ import { act, renderHook } from "@testing-library/react"; +import { Activity, createElement, StrictMode } from "react"; import { describe, expect, it } from "vitest"; import { useDialogState } from "../useDialogState"; +import type { PropsWithChildren } from "react"; describe("useDialogState", () => { + it("should clear a pending dialog when Activity hides and support a new dialog after reveal", async () => { + let mode: "visible" | "hidden" = "visible"; + const { result, rerender } = renderHook(() => useDialogState(), { + wrapper: ({ children }: PropsWithChildren) => createElement(Activity, { mode, children }), + }); + const outcomes: Array = []; + + act(() => { + void result.current.confirm("First").then((value) => outcomes.push(value)); + }); + + const firstDialog = result.current.dialogState; + + if (!firstDialog) { + throw new Error("Expected an open dialog"); + } + + mode = "hidden"; + + rerender(); + + await Promise.resolve(); + + expect(outcomes).toEqual([false]); + + mode = "visible"; + + rerender(); + + expect(result.current.dialogState).toBeNull(); + + act(() => { + void result.current.confirm("Second").then((value) => outcomes.push(value)); + firstDialog.resolve(true); + }); + + expect(result.current.dialogState?.message).toBe("Second"); + + act(() => result.current.onConfirm()); + + await Promise.resolve(); + + expect(outcomes).toEqual([false, true]); + expect(result.current.dialogState).toBeNull(); + }); + + it.each([true, false])("should ignore a replaced resolver settling with %s", async (value) => { + const { result } = renderHook(() => useDialogState()); + const outcomes: Array = []; + + act(() => { + void result.current.confirm("First").then((outcome) => outcomes.push(outcome)); + }); + + const firstDialog = result.current.dialogState; + + if (!firstDialog) { + throw new Error("Expected an open dialog"); + } + act(() => { + void result.current.confirm("Second").then((outcome) => outcomes.push(outcome)); + firstDialog.resolve(value); + }); + await Promise.resolve(); + expect(outcomes).toEqual([false]); + expect(result.current.dialogState?.message).toBe("Second"); + + act(() => result.current.onConfirm()); + await Promise.resolve(); + expect(outcomes).toEqual([false, true]); + }); + + it("should support dialogs after Strict Mode effect cleanup and deny retained controls after unmount", async () => { + const { result, unmount } = renderHook(() => useDialogState(), { wrapper: StrictMode }); + const { confirm, onConfirm, onCancel } = result.current; + const outcomes: Array = []; + + act(() => { + void confirm("Leave?").then((value) => outcomes.push(value)); + }); + expect(result.current.dialogState?.message).toBe("Leave?"); + unmount(); + onConfirm(); + onCancel(); + await Promise.resolve(); + expect(outcomes).toEqual([false]); + await expect(confirm("After unmount")).resolves.toBe(false); + }); + + it("should deny the pending dialog on unmount", async () => { + const { result, unmount } = renderHook(() => useDialogState()); + const settled: Array = []; + + act(() => { + void result.current.confirm("Leave?").then((value) => settled.push(value)); + }); + unmount(); + await Promise.resolve(); + expect(settled).toEqual([false]); + }); + + it("should close its own dialog and ignore repeated or stale resolvers", async () => { + const { result } = renderHook(() => useDialogState()); + const outcomes: Array = []; + + act(() => { + void result.current.confirm("First").then((value) => outcomes.push(value)); + }); + + const firstDialog = result.current.dialogState; + + if (!firstDialog) { + throw new Error("Expected an open dialog"); + } + act(() => firstDialog.resolve(true)); + expect(result.current.dialogState).toBeNull(); + + act(() => { + void result.current.confirm("Second").then((value) => outcomes.push(value)); + firstDialog.resolve(false); + firstDialog.resolve(true); + }); + await Promise.resolve(); + expect(outcomes).toEqual([true]); + expect(result.current.dialogState?.message).toBe("Second"); + + act(() => result.current.onCancel()); + await Promise.resolve(); + expect(outcomes).toEqual([true, false]); + }); + describe("Initialization", () => { it("should initialize with null dialog state", () => { const { result } = renderHook(() => useDialogState()); diff --git a/packages/router/src/core/useDialogState.ts b/packages/router/src/core/useDialogState.ts index ca72c38..f146613 100644 --- a/packages/router/src/core/useDialogState.ts +++ b/packages/router/src/core/useDialogState.ts @@ -1,4 +1,4 @@ -import { useCallback, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import type { Nullable } from "@okyrychenko-dev/type-utils"; /** @@ -11,7 +11,7 @@ export interface DialogState { /** The data to display in the dialog (typically a message) */ message: T; - /** Resolver function to confirm/cancel navigation */ + /** Settle and close this dialog; repeated or stale calls have no effect */ resolve: (confirmed: boolean) => void; } @@ -24,7 +24,7 @@ export interface UseDialogStateReturn { /** * Function to use as onConfirm callback. - * Returns a Promise that resolves when user confirms/cancels. + * Resolves true on confirmation, false on cancellation, replacement or effect teardown. */ confirm: (message: T) => Promise; @@ -41,29 +41,50 @@ export interface UseDialogStateReturn { export function useDialogState(): UseDialogStateReturn { const [dialogState, setDialogState] = useState>>(null); const resolveRef = useRef["resolve"]>>(null); + const mountedRef = useRef(true); + + useEffect(() => { + mountedRef.current = true; + + return () => { + mountedRef.current = false; + resolveRef.current?.(false); + }; + }, []); const confirm = useCallback((message: T): Promise => { + if (!mountedRef.current) { + return Promise.resolve(false); + } + return new Promise((resolve) => { if (resolveRef.current) { resolveRef.current(false); } - resolveRef.current = resolve; + + const settle = (confirmed: boolean): void => { + if (resolveRef.current !== settle) { + return; + } + + resolveRef.current = null; + resolve(confirmed); + + setDialogState(null); + }; + + resolveRef.current = settle; + setDialogState({ isOpen: true, message, - resolve, + resolve: settle, }); }); }, []); const closeDialog = useCallback((confirmed: boolean) => { - const resolve = resolveRef.current; - - if (resolve) { - resolve(confirmed); - } - resolveRef.current = null; - setDialogState(null); + resolveRef.current?.(confirmed); }, []); const onConfirm = useCallback(() => {