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
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,9 @@ jobs:
- name: Typecheck
run: pnpm run typecheck

- name: Typecheck public examples
run: pnpm run typecheck:examples

- name: Lint
run: pnpm run lint

Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -353,6 +353,8 @@ export default function RootLayout({ children }: { children: React.ReactNode })

Modal components and any component calling `useModalManager()` must also be Client Components (`"use client"`).

Opening modal work while React is rendering on the server is unsupported. The server snapshot is intentionally empty: open modals from event handlers, effects, command handlers, or other client-side code after hydration. A registry likewise remains unbound until its provider's client effect runs, so check `registry.isReady()` before dispatching startup commands from outside React.

### Tailwind CSS

Provide the overlay and centering through the `renderer`, and style modal components with Tailwind utilities.
Expand Down
8 changes: 8 additions & 0 deletions examples/next-app-router-provider.typecheck.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
"use client";

import { ModalProvider } from "@okyrychenko-dev/react-modal-manager";
import type { PropsWithChildren, ReactNode } from "react";

export function AppModalProvider({ children }: PropsWithChildren): ReactNode {
return <ModalProvider>{children}</ModalProvider>;
}
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,10 @@
"clean": "rm -rf dist",
"prepublishOnly": "pnpm run clean && pnpm run build",
"typecheck": "tsc --noEmit",
"typecheck:examples": "tsc --project tsconfig.examples.json",
"lint": "eslint src --ext .ts,.tsx",
"lint:fix": "eslint src --ext .ts,.tsx --fix",
"check": "pnpm run lint && pnpm run typecheck && pnpm run test:run && pnpm run build",
"check": "pnpm run lint && pnpm run typecheck && pnpm run typecheck:examples && pnpm run test:run && pnpm run build",
"format": "prettier --write \"src/**/*.{ts,tsx}\"",
"format:check": "prettier --check \"src/**/*.{ts,tsx}\"",
"test": "vitest",
Expand Down
68 changes: 68 additions & 0 deletions src/provider/__tests__/ModalProvider.ssr.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { act } from "@testing-library/react";
import { hydrateRoot } from "react-dom/client";
import { renderToString } from "react-dom/server";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createModalRegistry } from "../../registry/createModalRegistry";
import { ModalProvider } from "../ModalProvider";
import { renameReportModal } from "./ModalProvider.fixtures";

describe("ModalProvider SSR", () => {
afterEach(() => {
vi.restoreAllMocks();
});

it("should render deterministic empty modal state across server requests", () => {
const firstRegistry = createModalRegistry({
renameReport: renameReportModal,
});
const secondRegistry = createModalRegistry({
renameReport: renameReportModal,
});

const renderRequest = (
registry: ReturnType<typeof createModalRegistry>,
): string =>
renderToString(
<ModalProvider registry={registry}>
<span>Application content</span>
</ModalProvider>,
);

const firstMarkup = renderRequest(firstRegistry);
const secondMarkup = renderRequest(secondRegistry);

expect(firstMarkup).toBe("<span>Application content</span>");
expect(secondMarkup).toBe(firstMarkup);
expect(firstRegistry.isReady()).toBe(false);
expect(secondRegistry.isReady()).toBe(false);
});

it("should hydrate from matching state before binding the registry", async () => {
const registry = createModalRegistry({ renameReport: renameReportModal });
const application = (
<ModalProvider registry={registry}>
<span>Application content</span>
</ModalProvider>
);
const container = document.createElement("div");
container.innerHTML = renderToString(application);
const consoleError = vi
.spyOn(console, "error")
.mockImplementation(() => undefined);

expect(registry.isReady()).toBe(false);

const root = hydrateRoot(container, application);

expect(registry.isReady()).toBe(false);

await act(async () => undefined);

expect(container).toHaveTextContent("Application content");
expect(consoleError).not.toHaveBeenCalled();
expect(registry.isReady()).toBe(true);

root.unmount();
expect(registry.isReady()).toBe(false);
});
});
45 changes: 2 additions & 43 deletions src/provider/__tests__/ModalProvider.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,9 @@ import {
waitFor,
} from "@testing-library/react";
import { StrictMode } from "react";
import { hydrateRoot } from "react-dom/client";
import { renderToString } from "react-dom/server";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createModalRegistry, useModalManager } from "../../index";
import { type ModalHandle, useModalManager } from "../../hooks";
import { createModalRegistry } from "../../registry";
import { assertTypeUtilsAssertion } from "../../test/assertTypeUtilsAssertion";
import { ModalProvider } from "../ModalProvider";
import {
Expand All @@ -29,7 +28,6 @@ import {
renameReportModal,
} from "./ModalProvider.fixtures";
import type { Optional } from "@okyrychenko-dev/type-utils";
import type { ModalHandle } from "../../index";
import type { RenameReportResult } from "./ModalProvider.fixtures";

describe("ModalProvider", () => {
Expand All @@ -48,45 +46,6 @@ describe("ModalProvider", () => {
vi.useRealTimers();
});

it("should hydrate from a deterministic empty server snapshot", async () => {
const serverMarkup = renderToString(
<ModalProvider>
<span>Application content</span>
</ModalProvider>,
);
const container = document.createElement("div");
container.innerHTML = serverMarkup;
const consoleError = vi
.spyOn(console, "error")
.mockImplementation(() => undefined);

const root = hydrateRoot(
container,
<ModalProvider>
<span>Application content</span>
</ModalProvider>,
);
await act(async () => undefined);

expect(container).toHaveTextContent("Application content");
expect(consoleError).not.toHaveBeenCalled();

root.unmount();
consoleError.mockRestore();
});

it("should leave a provider registry unbound during server rendering", () => {
const registry = createModalRegistry({ renameReport: renameReportModal });

renderToString(
<ModalProvider registry={registry}>
<span>Application content</span>
</ModalProvider>,
);

expect(registry.isReady()).toBe(false);
});

it("should open a typed modal and resolve its result", async () => {
render(
<ModalProvider>
Expand Down
11 changes: 11 additions & 0 deletions tsconfig.examples.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": true,
"paths": {
"@okyrychenko-dev/react-modal-manager": ["./src/index.ts"]
},
"rootDir": "."
},
"include": ["examples/**/*.typecheck.ts", "examples/**/*.typecheck.tsx"]
}
Loading