Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/settled-custom-dialogs.md
Original file line number Diff line number Diff line change
@@ -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.
15 changes: 14 additions & 1 deletion packages/router/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,11 +159,24 @@ Helper hook for managing custom confirmation dialogs.
- `dialogState: DialogState<TMessage> | 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<boolean>` - 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
Expand Down
133 changes: 133 additions & 0 deletions packages/router/src/core/__tests__/useDialogState.test.ts
Original file line number Diff line number Diff line change
@@ -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<boolean> = [];

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<boolean> = [];

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<boolean> = [];

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<boolean> = [];

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<boolean> = [];

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());
Expand Down
45 changes: 33 additions & 12 deletions packages/router/src/core/useDialogState.ts
Original file line number Diff line number Diff line change
@@ -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";

/**
Expand All @@ -11,7 +11,7 @@ export interface DialogState<T = string> {
/** 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;
}

Expand All @@ -24,7 +24,7 @@ export interface UseDialogStateReturn<T = string> {

/**
* 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<boolean>;

Expand All @@ -41,29 +41,50 @@ export interface UseDialogStateReturn<T = string> {
export function useDialogState<T = string>(): UseDialogStateReturn<T> {
const [dialogState, setDialogState] = useState<Nullable<DialogState<T>>>(null);
const resolveRef = useRef<Nullable<DialogState<T>["resolve"]>>(null);
const mountedRef = useRef(true);

useEffect(() => {
mountedRef.current = true;

return () => {
mountedRef.current = false;
resolveRef.current?.(false);
Comment thread
okyrychenko-dev marked this conversation as resolved.
};
}, []);

const confirm = useCallback((message: T): Promise<boolean> => {
if (!mountedRef.current) {
return Promise.resolve(false);
}

return new Promise<boolean>((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(() => {
Expand Down
Loading