diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 2b3dafa..8e217f3 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -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
diff --git a/README.md b/README.md
index 883ef87..daea77a 100644
--- a/README.md
+++ b/README.md
@@ -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.
diff --git a/examples/next-app-router-provider.typecheck.tsx b/examples/next-app-router-provider.typecheck.tsx
new file mode 100644
index 0000000..e757aec
--- /dev/null
+++ b/examples/next-app-router-provider.typecheck.tsx
@@ -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 {children};
+}
diff --git a/package.json b/package.json
index 28b8e54..ea67575 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/src/provider/__tests__/ModalProvider.ssr.test.tsx b/src/provider/__tests__/ModalProvider.ssr.test.tsx
new file mode 100644
index 0000000..1132de3
--- /dev/null
+++ b/src/provider/__tests__/ModalProvider.ssr.test.tsx
@@ -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,
+ ): string =>
+ renderToString(
+
+ Application content
+ ,
+ );
+
+ const firstMarkup = renderRequest(firstRegistry);
+ const secondMarkup = renderRequest(secondRegistry);
+
+ expect(firstMarkup).toBe("Application content");
+ 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 = (
+
+ Application content
+
+ );
+ 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);
+ });
+});
diff --git a/src/provider/__tests__/ModalProvider.test.tsx b/src/provider/__tests__/ModalProvider.test.tsx
index 3b31514..9ca512b 100644
--- a/src/provider/__tests__/ModalProvider.test.tsx
+++ b/src/provider/__tests__/ModalProvider.test.tsx
@@ -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 {
@@ -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", () => {
@@ -48,45 +46,6 @@ describe("ModalProvider", () => {
vi.useRealTimers();
});
- it("should hydrate from a deterministic empty server snapshot", async () => {
- const serverMarkup = renderToString(
-
- Application content
- ,
- );
- const container = document.createElement("div");
- container.innerHTML = serverMarkup;
- const consoleError = vi
- .spyOn(console, "error")
- .mockImplementation(() => undefined);
-
- const root = hydrateRoot(
- container,
-
- Application content
- ,
- );
- 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(
-
- Application content
- ,
- );
-
- expect(registry.isReady()).toBe(false);
- });
-
it("should open a typed modal and resolve its result", async () => {
render(
diff --git a/tsconfig.examples.json b/tsconfig.examples.json
new file mode 100644
index 0000000..c6c2cdf
--- /dev/null
+++ b/tsconfig.examples.json
@@ -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"]
+}