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
29 changes: 29 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,35 @@ concurrency:
cancel-in-progress: true

jobs:
react-compatibility:
name: React ${{ matrix.react }} compatibility
runs-on: ubuntu-latest
strategy:
matrix:
include:
- react: 18.0.0
react-dom: 18.0.0
- react: 19.2.0
react-dom: 19.2.0
steps:
- uses: actions/checkout@v4

- uses: pnpm/action-setup@v4

- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
cache-dependency-path: pnpm-lock.yaml

- run: pnpm install --frozen-lockfile

- name: Install React compatibility target
run: pnpm add --save-dev --save-exact --lockfile=false react@${{ matrix.react }} react-dom@${{ matrix.react-dom }}

- name: Verify provider ownership across React roots
run: pnpm exec vitest run src/provider/__tests__/ModalProvider.roots.test.tsx src/provider/__tests__/ModalProvider.test.tsx src/provider/__tests__/useModalLifecycleOwner.test.tsx

verify:
name: Verify (Node ${{ matrix.node }})
runs-on: ubuntu-latest
Expand Down
230 changes: 230 additions & 0 deletions src/provider/__tests__/ModalProvider.roots.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
import { act, fireEvent, render, screen, within } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import { useModalManager } from "../../hooks";
import { createModal } from "../../registry/createModal";
import { ModalProvider } from "../ModalProvider";
import type { Optional } from "@okyrychenko-dev/type-utils";
import type { ReactNode } from "react";
import type { ModalHandle } from "../../hooks";
import type { ModalComponentProps } from "../../types";

interface RootModalInput {
rootName: string;
}

function RootModal({
close,
input,
}: ModalComponentProps<RootModalInput, string>) {
return (
<section aria-label={`${input.rootName} modal`} role="dialog">
<button type="button" onClick={() => close(input.rootName)}>
Resolve {input.rootName}
</button>
</section>
);
}

const rootModal = createModal<RootModalInput, string>({ component: RootModal });

function RootModalOpener({
onOpen,
rootName,
}: {
onOpen: (handle: ModalHandle<string>) => void;
rootName: string;
}): ReactNode {
const manager = useModalManager();

return (
<button
type="button"
onClick={() => {
const handle = manager.open(rootModal, { rootName });
void handle.catch(() => undefined);
onOpen(handle);
}}
>
Open {rootName}
</button>
);
}

describe("ModalProvider roots", () => {
const pendingHandles: Array<ModalHandle<string>> = [];

afterEach(() => {
for (const handle of pendingHandles) {
handle.dismiss();
}
pendingHandles.length = 0;
});

it("should isolate modal work between independently rendered roots", async () => {
let firstHandle: Optional<ModalHandle<string>>;
let secondHandle: Optional<ModalHandle<string>>;

const firstRoot = render(
<ModalProvider>
<RootModalOpener
rootName="first root"
onOpen={(handle) => {
firstHandle = handle;
pendingHandles.push(handle);
}}
/>
</ModalProvider>,
);
const secondRoot = render(
<ModalProvider>
<RootModalOpener
rootName="second root"
onOpen={(handle) => {
secondHandle = handle;
pendingHandles.push(handle);
}}
/>
</ModalProvider>,
);

fireEvent.click(
within(firstRoot.container).getByRole("button", {
name: "Open first root",
}),
);
fireEvent.click(
within(secondRoot.container).getByRole("button", {
name: "Open second root",
}),
);

expect(firstHandle?.instanceId).toBe("modal-0");
expect(secondHandle?.instanceId).toBe("modal-0");

act(() => {
firstHandle?.dismiss();
});

await expect(firstHandle).rejects.toMatchObject({ reason: "dismiss" });
expect(
within(firstRoot.container).queryByRole("dialog"),
).not.toBeInTheDocument();
expect(
within(secondRoot.container).getByRole("dialog", {
name: "second root modal",
}),
).toBeInTheDocument();
});

it("should route nested and adjacent consumers to their nearest provider", () => {
render(
<ModalProvider>
<RootModalOpener
rootName="outer"
onOpen={(handle) => {
pendingHandles.push(handle);
}}
/>
<ModalProvider>
<RootModalOpener
rootName="nested"
onOpen={(handle) => {
pendingHandles.push(handle);
}}
/>
</ModalProvider>
</ModalProvider>,
);
render(
<ModalProvider>
<RootModalOpener
rootName="adjacent"
onOpen={(handle) => {
pendingHandles.push(handle);
}}
/>
</ModalProvider>,
);

fireEvent.click(screen.getByRole("button", { name: "Open outer" }));
fireEvent.click(screen.getByRole("button", { name: "Open nested" }));
fireEvent.click(screen.getByRole("button", { name: "Open adjacent" }));

expect(
screen.getByRole("dialog", { name: "outer modal" }),
).toHaveTextContent("Resolve outer");
expect(
screen.getByRole("dialog", { name: "nested modal" }),
).toHaveTextContent("Resolve nested");
expect(
screen.getByRole("dialog", { name: "adjacent modal" }),
).toHaveTextContent("Resolve adjacent");
expect(pendingHandles.map((handle) => handle.instanceId)).toEqual([
"modal-0",
"modal-0",
"modal-0",
]);
});

it("should keep one root live when another root is disposed", async () => {
let disposedHandle: Optional<ModalHandle<string>>;
let liveHandle: Optional<ModalHandle<string>>;

const disposedRoot = render(
<ModalProvider>
<RootModalOpener
rootName="disposed root"
onOpen={(handle) => {
disposedHandle = handle;
pendingHandles.push(handle);
}}
/>
</ModalProvider>,
);

const liveRoot = render(
<ModalProvider>
<RootModalOpener
rootName="live root"
onOpen={(handle) => {
liveHandle = handle;
pendingHandles.push(handle);
}}
/>
</ModalProvider>,
);

fireEvent.click(
within(disposedRoot.container).getByRole("button", {
name: "Open disposed root",
}),
);
fireEvent.click(
within(liveRoot.container).getByRole("button", {
name: "Open live root",
}),
);

const disposedResult = expect(disposedHandle).rejects.toMatchObject({
reason: "provider-unmount",
});

disposedRoot.unmount();

await act(async () => undefined);

await disposedResult;
expect(
within(liveRoot.container).getByRole("dialog", {
name: "live root modal",
}),
).toBeInTheDocument();

fireEvent.click(
within(liveRoot.container).getByRole("button", {
name: "Resolve live root",
}),
);
await expect(liveHandle).resolves.toBe("live root");
});
});
Loading