From f8d0f2c8e9ca066707f2beb0c91bb7f6ffc2df3a Mon Sep 17 00:00:00 2001 From: Olexii Kyrychenko Date: Sun, 13 Sep 2026 10:30:34 +0300 Subject: [PATCH] test: verify React roots and versions --- .github/workflows/ci.yml | 29 +++ .../__tests__/ModalProvider.roots.test.tsx | 230 ++++++++++++++++++ 2 files changed, 259 insertions(+) create mode 100644 src/provider/__tests__/ModalProvider.roots.test.tsx diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e8ef775..2b3dafa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/src/provider/__tests__/ModalProvider.roots.test.tsx b/src/provider/__tests__/ModalProvider.roots.test.tsx new file mode 100644 index 0000000..d17e699 --- /dev/null +++ b/src/provider/__tests__/ModalProvider.roots.test.tsx @@ -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) { + return ( +
+ +
+ ); +} + +const rootModal = createModal({ component: RootModal }); + +function RootModalOpener({ + onOpen, + rootName, +}: { + onOpen: (handle: ModalHandle) => void; + rootName: string; +}): ReactNode { + const manager = useModalManager(); + + return ( + + ); +} + +describe("ModalProvider roots", () => { + const pendingHandles: Array> = []; + + afterEach(() => { + for (const handle of pendingHandles) { + handle.dismiss(); + } + pendingHandles.length = 0; + }); + + it("should isolate modal work between independently rendered roots", async () => { + let firstHandle: Optional>; + let secondHandle: Optional>; + + const firstRoot = render( + + { + firstHandle = handle; + pendingHandles.push(handle); + }} + /> + , + ); + const secondRoot = render( + + { + secondHandle = handle; + pendingHandles.push(handle); + }} + /> + , + ); + + 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( + + { + pendingHandles.push(handle); + }} + /> + + { + pendingHandles.push(handle); + }} + /> + + , + ); + render( + + { + pendingHandles.push(handle); + }} + /> + , + ); + + 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>; + let liveHandle: Optional>; + + const disposedRoot = render( + + { + disposedHandle = handle; + pendingHandles.push(handle); + }} + /> + , + ); + + const liveRoot = render( + + { + liveHandle = handle; + pendingHandles.push(handle); + }} + /> + , + ); + + 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"); + }); +});