From 255999e94844b9fc5ecc4fd3e7f8bbd1f73b8fe0 Mon Sep 17 00:00:00 2001 From: Olexii Kyrychenko Date: Sun, 13 Sep 2026 11:34:21 +0300 Subject: [PATCH] docs: complete SSR and RSC guidance --- .github/workflows/ci.yml | 3 + README.md | 2 + .../next-app-router-provider.typecheck.tsx | 8 +++ package.json | 3 +- .../__tests__/ModalProvider.ssr.test.tsx | 68 +++++++++++++++++++ src/provider/__tests__/ModalProvider.test.tsx | 45 +----------- tsconfig.examples.json | 11 +++ 7 files changed, 96 insertions(+), 44 deletions(-) create mode 100644 examples/next-app-router-provider.typecheck.tsx create mode 100644 src/provider/__tests__/ModalProvider.ssr.test.tsx create mode 100644 tsconfig.examples.json 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"] +}