diff --git a/.changeset/modal-imperative-api.md b/.changeset/modal-imperative-api.md new file mode 100644 index 000000000..4e670dea0 --- /dev/null +++ b/.changeset/modal-imperative-api.md @@ -0,0 +1,16 @@ +--- +'@tiny-design/react': minor +--- + +feat(modal): add imperative/registered API on top of the existing context + +- New exports: `Modal.Register`, `Modal.useModalActions`, `Modal.useModalSelf`, `Modal.store`, and a named `createModalStore` factory. +- `show(id, props)` returns a promise that resolves with the value passed to `hide(result)`, so dialogs can be `await`ed. +- `` now backs an outlet that renders registered components; the legacy `Modal.useModal(id)` per-id hook continues to work unchanged. +- New "Choosing a store" docs section warning that two providers sharing the singleton cause duplicate overlays — recommends `createModalStore()` for app-level providers. + +fix(transition): stop firing `onExited` from inside a `setState` updater so it no longer triggers "Cannot update X while rendering Y" warnings when the callback dispatches across components. + +fix(collapse-transition): keep `onHidden` in a ref so the animation effect depends only on `visible`. Inline `onHidden={() => …}` callers no longer cause unrelated parent re-renders to interrupt the running open/close animation. + +fix(collapse): always mount `` and gate only the body content. The first time a panel is opened from a closed start now plays the open animation instead of snapping to its full height. diff --git a/apps/docs/src/components/markdown-tag/index.jsx b/apps/docs/src/components/markdown-tag/index.jsx index 53c5f63fc..2cf1dcdd2 100755 --- a/apps/docs/src/components/markdown-tag/index.jsx +++ b/apps/docs/src/components/markdown-tag/index.jsx @@ -6,13 +6,17 @@ import './md-tag.scss'; import { DemoBlock } from '../demo-block'; import { HighlightedCode } from '../highlighted-code'; -const slugifyLink = (name) => { - if (name.includes(' ')) { - return name.toLowerCase().split(' ').join('-'); - } - return typeof name === 'string' ? name.toLowerCase() : name; +const extractText = (node) => { + if (node == null || typeof node === 'boolean') return ''; + if (typeof node === 'string' || typeof node === 'number') return String(node); + if (Array.isArray(node)) return node.map(extractText).join(''); + if (React.isValidElement(node)) return extractText(node.props.children); + return ''; }; +const slugifyLink = (children) => + extractText(children).toLowerCase().trim().split(/\s+/).filter(Boolean).join('-'); + export const components = { wrapper: (props) =>
, h1: (props) =>

, diff --git a/packages/react/src/collapse-transition/collapse-transition.tsx b/packages/react/src/collapse-transition/collapse-transition.tsx index 0469caae1..acc5a32cc 100644 --- a/packages/react/src/collapse-transition/collapse-transition.tsx +++ b/packages/react/src/collapse-transition/collapse-transition.tsx @@ -20,6 +20,12 @@ const CollapseTransition = ({ const isFirstRender = useRef(true); const visible = open ?? isShow ?? false; + // Stash the latest onHidden so the animation effect can depend on `visible` + // alone. Callers commonly pass an inline arrow (e.g. `() => setX(false)`), + // and re-running the effect on every parent render restarts the animation. + const onHiddenRef = useRef(onHidden); + onHiddenRef.current = onHidden; + useEffect(() => { const node = ref.current; if (!node) return; @@ -43,7 +49,7 @@ const CollapseTransition = ({ node.style.height = ''; } else { node.style.display = 'none'; - onHidden?.(); + onHiddenRef.current?.(); } }; @@ -76,7 +82,7 @@ const CollapseTransition = ({ if (frameB) window.cancelAnimationFrame(frameB); node.removeEventListener('transitionend', handleTransitionEnd); }; - }, [visible, onHidden]); + }, [visible]); return (
diff --git a/packages/react/src/collapse/collapse-panel.tsx b/packages/react/src/collapse/collapse-panel.tsx index 35e1c35dc..ea32bd863 100644 --- a/packages/react/src/collapse/collapse-panel.tsx +++ b/packages/react/src/collapse/collapse-panel.tsx @@ -149,12 +149,12 @@ const CollapsePanel = ({ {extraContent &&
{extraContent}
}
- {shouldRenderBody && ( - setBodyMounted(false) : undefined} - > + setBodyMounted(false) : undefined} + > + {shouldRenderBody ? (
{item.children}
-
- )} + ) : null} +

); }; diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index aaaa8525a..1e3a8509c 100755 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -45,7 +45,7 @@ export { default as Menu } from './menu'; export { default as Message } from './message'; export { default as NativeSelect } from './native-select'; export { default as Row } from './row'; -export { default as Modal } from './modal'; +export { default as Modal, createModalStore } from './modal'; export { default as Notification } from './notification'; export { default as Overlay } from './overlay'; export { default as Popover } from './popover'; diff --git a/packages/react/src/modal/__tests__/modal-context.test.tsx b/packages/react/src/modal/__tests__/modal-context.test.tsx new file mode 100644 index 000000000..e34ec5f2e --- /dev/null +++ b/packages/react/src/modal/__tests__/modal-context.test.tsx @@ -0,0 +1,191 @@ +import React from 'react'; +import { act, fireEvent, render, screen } from '@testing-library/react'; +import Modal from '../index'; +import { createModalStore } from '../modal-context'; + +describe('Modal context — legacy useModal(id)', () => { + function Toolbar() { + const confirm = Modal.useModal('confirm'); + return ; + } + function ConfirmModal() { + const { visible, close } = Modal.useModal('confirm'); + return ( + + confirm body + + ); + } + + it('shows and closes a modal via per-id hook', () => { + render( + + + + + ); + + expect(screen.queryByText('confirm body')).not.toBeInTheDocument(); + fireEvent.click(screen.getByText('open')); + expect(screen.getByText('confirm body')).toBeInTheDocument(); + }); +}); + +describe('Modal context — imperative API + outlet', () => { + function ConfirmContent(props: { message: string }) { + const { visible, hide, remove } = Modal.useModalSelf<{ message: string }, boolean>(); + return ( + hide(true)} + onCancel={() => hide(false)}> + {props.message} + + ); + } + + it('renders registered components and resolves with hide(result)', async () => { + const store = createModalStore(); + store.register('confirm', ConfirmContent); + + let resolved: boolean | undefined; + function Trigger() { + const modal = Modal.useModalActions(); + return ( + + ); + } + + render( + + + + ); + + fireEvent.click(screen.getByText('ask')); + expect(screen.getByTestId('message')).toHaveTextContent('delete this?'); + + await act(async () => { + fireEvent.click(screen.getByText('OK')); + }); + expect(resolved).toBe(true); + }); + + it('declarative works the same as store.register', () => { + const store = createModalStore(); + function Trigger() { + const modal = Modal.useModalActions(); + return ; + } + + render( + + + + + ); + + expect(store.isRegistered('confirm')).toBe(true); + fireEvent.click(screen.getByText('go')); + expect(screen.getByTestId('message')).toHaveTextContent('hi'); + }); + + it(' unregisters when unmounted', () => { + const store = createModalStore(); + function Host({ mounted }: { mounted: boolean }) { + return ( + + {mounted ? : null} + + ); + } + + const { rerender } = render(); + expect(store.isRegistered('confirm')).toBe(true); + + rerender(); + expect(store.isRegistered('confirm')).toBe(false); + }); + + it('hideAll resolves all open modals with undefined', async () => { + const store = createModalStore(); + store.register('a', () => { + const { visible, remove } = Modal.useModalSelf(); + return ( + + a body + + ); + }); + store.register('b', () => { + const { visible, remove } = Modal.useModalSelf(); + return ( + + b body + + ); + }); + + let aResult: unknown = 'untouched'; + let bResult: unknown = 'untouched'; + render({null}); + + await act(async () => { + store.show('a').then((v) => (aResult = v)); + store.show('b').then((v) => (bResult = v)); + }); + expect(screen.getByText('a body')).toBeInTheDocument(); + expect(screen.getByText('b body')).toBeInTheDocument(); + + await act(async () => { + store.hideAll(); + }); + expect(aResult).toBeUndefined(); + expect(bResult).toBeUndefined(); + }); + + it('remove() drains a pending resolver instead of leaking the promise', async () => { + const store = createModalStore(); + store.register('confirm', ConfirmContent); + + let resolved: unknown = 'untouched'; + await act(async () => { + store.show('confirm', { message: 'x' }).then((v) => { + resolved = v; + }); + }); + + await act(async () => { + store.remove('confirm'); + }); + // microtask flush + await act(async () => {}); + + expect(resolved).toBeUndefined(); + }); + + it('useModalSelf throws outside an outlet', () => { + function Bad() { + Modal.useModalSelf(); + return null; + } + const spy = jest.spyOn(console, 'error').mockImplementation(() => {}); + expect(() => + render( + + + + ) + ).toThrow(/useModalSelf must be used inside/); + spy.mockRestore(); + }); +}); diff --git a/packages/react/src/modal/demo/Context.tsx b/packages/react/src/modal/demo/Context.tsx index 9b499aab3..ad3d9e440 100644 --- a/packages/react/src/modal/demo/Context.tsx +++ b/packages/react/src/modal/demo/Context.tsx @@ -1,5 +1,5 @@ -import React from 'react'; -import { Modal, Button, Space } from '@tiny-design/react'; +import React, { useMemo } from 'react'; +import { Modal, Button, Space, createModalStore } from '@tiny-design/react'; function ConfirmModal() { const { visible, close } = Modal.useModal('confirm'); @@ -20,21 +20,21 @@ function SettingsModal() { } function Toolbar() { - const confirm = Modal.useModal('confirm'); - const settings = Modal.useModal('settings'); + const { show } = Modal.useModalActions(); return ( - - + ); } export default function ContextDemo() { + const store = useMemo(() => createModalStore(), []); return ( - + diff --git a/packages/react/src/modal/demo/ContextRegister.tsx b/packages/react/src/modal/demo/ContextRegister.tsx new file mode 100644 index 000000000..735364c96 --- /dev/null +++ b/packages/react/src/modal/demo/ContextRegister.tsx @@ -0,0 +1,56 @@ +import React, { useMemo, useState } from 'react'; +import { Modal, Button, Space, createModalStore } from '@tiny-design/react'; + +interface ConfirmDeleteProps { + itemName: string; +} + +function ConfirmDelete({ itemName }: ConfirmDeleteProps) { + const { visible, hide, remove } = Modal.useModalSelf(); + return ( + hide(true)} + onCancel={() => hide(false)} + confirmText="Delete" + cancelText="Keep"> +

+ Are you sure you want to delete {itemName}? This action cannot be undone. +

+
+ ); +} + +function Trigger() { + const { show } = Modal.useModalActions(); + const [last, setLast] = useState(''); + + return ( + + + {last ? Last action: {last} : null} + + ); +} + +export default function ContextRegisterDemo() { + const store = useMemo(() => createModalStore(), []); + return ( + + + + + ); +} diff --git a/packages/react/src/modal/index.md b/packages/react/src/modal/index.md index 465ad5b46..d442e1534 100755 --- a/packages/react/src/modal/index.md +++ b/packages/react/src/modal/index.md @@ -8,6 +8,8 @@ import AnimationDemo from './demo/Animation'; import AnimationSource from './demo/Animation.tsx?raw'; import ContextDemo from './demo/Context'; import ContextSource from './demo/Context.tsx?raw'; +import ContextRegisterDemo from './demo/ContextRegister'; +import ContextRegisterSource from './demo/ContextRegister.tsx?raw'; # Modal @@ -87,14 +89,74 @@ Use `animation` to set different popup animation. ### Context -Manage multiple modals by ID with `Modal.Provider` and `Modal.useModal`. +Manage multiple modals by ID with `Modal.Provider` and `Modal.useModal`. Trigger components subscribe with `Modal.useModalActions()` to avoid re-rendering on visibility changes. + + + +### Registered modals with awaitable result + +Register a modal once with `Modal.Register` (or `store.register`), then call `show(id, props)` from anywhere. `show` returns a promise that resolves with the value passed to `hide(result)`, so you can `await` the user's choice. Inside the registered component, `Modal.useModalSelf()` exposes `props`, `visible`, `hide`, and `remove`. + + + +## Context API + +`Modal.Provider` wires children to a `ModalStore`. Two patterns coexist: + +- **Manual mount** — render modals yourself and read state with `useModal(id)`. +- **Registered** — register a component with an id, then trigger it imperatively with `useModalActions().show(id, props)` and read its own state with `useModalSelf()` from inside the component. + +### Choosing a store + +`` accepts an optional `store` prop. If you don't pass one, it falls back to the package-level `Modal.store` singleton — convenient for trivial apps with a single provider, but every provider that omits `store` shares the same state. Two providers backed by the singleton both subscribe to the same registry, so any registered modal renders **once per provider** (you'll see duplicate overlays). They also see each other's `show()` calls. + +In practice, create your own store with `createModalStore()` and pass it explicitly. Use the singleton only when you specifically need to trigger modals from outside React. + +```jsx +import { Modal, createModalStore } from '@tiny-design/react'; + +function App() { + const store = useMemo(() => createModalStore(), []); + return ( + + {/* … */} + + ); +} +``` + +Reach for `createModalStore()` whenever you need an isolated store: app-level providers, unit tests, SSR per-request stores, or independent modal trees on the same page. + +### Reference + +| Export | Type | Description | +| ----------------------------- | ------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | +| `Modal.Provider` | `({ store?, children }) => Element` | Provides a store to descendants and renders the outlet for registered modals. Defaults to the `Modal.store` singleton — see above. | +| `Modal.Register` | `({ id, component }) => null` | Declarative registration. Registers on mount, unregisters on unmount. | +| `Modal.useModal(id)` | `(id) => { visible, show, close, toggle }` | Per-id state hook. Re-renders only when this id's visibility flips. Use with manually mounted modals. | +| `Modal.useModalActions()` | `() => { show, hide, hideAll, register }` | Imperative actions. Does not subscribe to state — components calling this never re-render on open/close. | +| `Modal.useModalSelf()` | `() => { id, visible, props, hide, reject, remove }` | Read this modal's own state from inside a registered component. Throws if used outside the outlet. | +| `Modal.store` | `ModalStore` | Process-wide singleton store. Use **only** to trigger modals from outside React (route guards, error handlers). Inside React, prefer `useModalActions`. | +| `createModalStore()` | `() => ModalStore` _(named import from `@tiny-design/react`)_ | Create an isolated store. Recommended for app-level providers, tests, SSR per-request stores, and independent modal trees. | + +### `ModalStore` + +| Method | Description | +| ------------------------------- | ----------------------------------------------------------------------------------------------------------------- | +| `register(id, component)` | Register a component. Returns an unregister function. | +| `show(id, props?)` | Open a modal. Returns a promise that resolves with the value passed to `hide(result)`. | +| `hide(id, result?)` | Close a modal and resolve its pending promise. Modal stays mounted until `afterClose` fires `remove`. | +| `remove(id)` | Hard-remove the record from the store. Drains a still-pending resolver with `undefined`. | +| `hideAll()` | Hide every visible modal; resolves their pending promises with `undefined`. | +| `getState()` / `subscribe(fn)` | Low-level state access for custom integrations. | + ## Props | Property | Description | Type | Default | diff --git a/packages/react/src/modal/index.tsx b/packages/react/src/modal/index.tsx index 1aec3f036..28423823e 100755 --- a/packages/react/src/modal/index.tsx +++ b/packages/react/src/modal/index.tsx @@ -1,6 +1,24 @@ import Modal from './modal'; -import { ModalProvider, useModal } from './modal-context'; -import type { ModalProviderProps, UseModalReturn } from './modal-context'; +import { + ModalProvider, + ModalRegister, + createModalStore, + modalStore, + useModal, + useModalActions, + useModalSelf, +} from './modal-context'; +import type { + ModalComponent, + ModalProviderProps, + ModalRecord, + ModalRegisterProps, + ModalState, + ModalStore, + UseModalActionsReturn, + UseModalReturn, + UseModalSelfReturn, +} from './modal-context'; import { confirmStaticModal, openStaticModal, @@ -8,19 +26,44 @@ import { StaticModalProps, } from './static-modal'; -type ModalComponent = typeof Modal & { +type ModalComponentDecorated = typeof Modal & { Provider: typeof ModalProvider; + Register: typeof ModalRegister; useModal: typeof useModal; + useModalActions: typeof useModalActions; + useModalSelf: typeof useModalSelf; + store: typeof modalStore; open: (config: StaticModalProps) => StaticModalInstance; confirm: (config: StaticModalProps) => StaticModalInstance; }; -const ModalWithContext = Modal as ModalComponent; +const ModalWithContext = Modal as ModalComponentDecorated; ModalWithContext.Provider = ModalProvider; +ModalWithContext.Register = ModalRegister; ModalWithContext.useModal = useModal; +ModalWithContext.useModalActions = useModalActions; +ModalWithContext.useModalSelf = useModalSelf; +ModalWithContext.store = modalStore; ModalWithContext.open = openStaticModal; ModalWithContext.confirm = confirmStaticModal; +// `createModalStore` is exposed as a named import only — it's an advanced +// escape hatch (tests, SSR, multi-tenant trees), not part of the default +// `Modal.*` ergonomics. +export { createModalStore }; + export default ModalWithContext; export type * from './types'; -export type { ModalProviderProps, UseModalReturn, StaticModalInstance, StaticModalProps }; +export type { + ModalComponent, + ModalProviderProps, + ModalRecord, + ModalRegisterProps, + ModalState, + ModalStore, + StaticModalInstance, + StaticModalProps, + UseModalActionsReturn, + UseModalReturn, + UseModalSelfReturn, +}; diff --git a/packages/react/src/modal/index.zh_CN.md b/packages/react/src/modal/index.zh_CN.md index 91f9b985a..f1b9b7a07 100644 --- a/packages/react/src/modal/index.zh_CN.md +++ b/packages/react/src/modal/index.zh_CN.md @@ -8,6 +8,8 @@ import AnimationDemo from './demo/Animation'; import AnimationSource from './demo/Animation.tsx?raw'; import ContextDemo from './demo/Context'; import ContextSource from './demo/Context.tsx?raw'; +import ContextRegisterDemo from './demo/ContextRegister'; +import ContextRegisterSource from './demo/ContextRegister.tsx?raw'; # Modal 模态对话框 @@ -87,14 +89,74 @@ instance.destroy(); ### 上下文 -使用 `Modal.Provider` 和 `Modal.useModal` 通过 ID 管理多个对话框。 +使用 `Modal.Provider` 和 `Modal.useModal` 通过 ID 管理多个对话框。触发组件可以使用 `Modal.useModalActions()` 来避免在可见性变化时重新渲染。 + + + +### 注册式对话框与可等待结果 + +通过 `Modal.Register`(或 `store.register`)注册一次对话框,之后即可在任意位置调用 `show(id, props)`。`show` 返回一个 Promise,会以传给 `hide(result)` 的值进行 resolve,因此可以 `await` 用户的选择。在已注册的组件内部,使用 `Modal.useModalSelf()` 即可拿到 `props`、`visible`、`hide` 与 `remove`。 + + + +## 上下文 API + +`Modal.Provider` 将子树绑定到一个 `ModalStore`,支持两种使用方式: + +- **手动挂载** —— 自己渲染对话框,使用 `useModal(id)` 读取可见状态。 +- **注册式** —— 通过 id 注册组件,使用 `useModalActions().show(id, props)` 触发,并在组件内部使用 `useModalSelf()` 读取自身状态。 + +### 选择 store + +`` 接受可选的 `store` 属性。如果不传,会回退到包级单例 `Modal.store` —— 对只有一个 Provider 的简单应用很方便,但所有省略 `store` 的 Provider 都会共享同一份状态。两个挂载到单例的 Provider 会订阅同一个注册表,任何注册过的对话框会被**每个 Provider 各渲染一次**(你会看到重复的浮层),同时它们也会互相看到对方的 `show()` 调用。 + +实际项目中建议使用 `createModalStore()` 创建自己的 store 并显式传入。只有在确实需要从 React 之外触发对话框时才使用单例。 + +```jsx +import { Modal, createModalStore } from '@tiny-design/react'; + +function App() { + const store = useMemo(() => createModalStore(), []); + return ( + + {/* … */} + + ); +} +``` + +任何需要独立 store 的场景都应使用 `createModalStore()`:应用级 Provider、单元测试、SSR 每请求 store,或同页面上互不相关的对话框子树。 + +### API 参考 + +| 导出 | 类型 | 说明 | +| ----------------------------- | ------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | +| `Modal.Provider` | `({ store?, children }) => Element` | 向后代提供 store,并为已注册的对话框渲染 outlet。默认使用单例 `Modal.store`,参见上文。 | +| `Modal.Register` | `({ id, component }) => null` | 声明式注册。挂载时注册,卸载时反注册。 | +| `Modal.useModal(id)` | `(id) => { visible, show, close, toggle }` | 按 id 订阅可见性的状态 hook,仅在该 id 的可见性变化时重新渲染。配合手动挂载使用。 | +| `Modal.useModalActions()` | `() => { show, hide, hideAll, register }` | 命令式 actions,不订阅 state —— 调用方不会因开/关而重新渲染。 | +| `Modal.useModalSelf()` | `() => { id, visible, props, hide, reject, remove }` | 在已注册的组件内部读取自身状态。在 outlet 之外使用会抛错。 | +| `Modal.store` | `ModalStore` | 进程级单例 store。**仅**当需要在 React 之外触发对话框(路由守卫、错误处理等)时使用。在 React 内部请优先使用 `useModalActions`。 | +| `createModalStore()` | `() => ModalStore` _(从 `@tiny-design/react` 具名导入)_ | 创建独立的 store。推荐用于应用级 Provider、单元测试、SSR 每请求 store,或互相独立的对话框子树。 | + +### `ModalStore` + +| 方法 | 说明 | +| ------------------------------- | ------------------------------------------------------------------------------------------------------------------- | +| `register(id, component)` | 注册组件,返回反注册函数。 | +| `show(id, props?)` | 打开对话框。返回的 Promise 会以 `hide(result)` 传入的值进行 resolve。 | +| `hide(id, result?)` | 关闭对话框并 resolve 其 Promise。对话框会保持挂载直到 `afterClose` 触发 `remove`。 | +| `remove(id)` | 从 store 中硬删除该记录。如果还有未完成的 resolver,会以 `undefined` 进行 resolve。 | +| `hideAll()` | 隐藏所有可见对话框;将它们的 Promise 以 `undefined` resolve。 | +| `getState()` / `subscribe(fn)` | 低层 state 访问,便于自定义集成。 | + ## Props | 属性 | 说明 | 类型 | 默认值 | diff --git a/packages/react/src/modal/modal-context.tsx b/packages/react/src/modal/modal-context.tsx index 40c4be187..5aa3c1366 100644 --- a/packages/react/src/modal/modal-context.tsx +++ b/packages/react/src/modal/modal-context.tsx @@ -1,82 +1,349 @@ -import React, { createContext, useCallback, useContext, useMemo, useReducer } from 'react'; - -// --- Reducer --- - -type ModalState = Record; - -type ModalAction = - | { type: 'SHOW'; id: string } - | { type: 'HIDE'; id: string } - | { type: 'TOGGLE'; id: string } - | { type: 'PURGE' }; - -function modalReducer(state: ModalState, action: ModalAction): ModalState { - switch (action.type) { - case 'SHOW': - return state[action.id] === true ? state : { ...state, [action.id]: true }; - case 'HIDE': - return state[action.id] === false ? state : { ...state, [action.id]: false }; - case 'TOGGLE': - return { ...state, [action.id]: !state[action.id] }; - case 'PURGE': - return {}; - default: - return state; - } +import React, { + createContext, + Suspense, + useCallback, + useContext, + useEffect, + useMemo, + useSyncExternalStore, +} from 'react'; + +// ============================================================================ +// Types +// ============================================================================ + +export interface ModalRecord

{ + readonly id: string; + readonly visible: boolean; + readonly props: P; + readonly resolver?: ModalResolver; +} + +interface ModalResolver { + resolve: (value: unknown) => void; + reject: (reason?: unknown) => void; +} + +export type ModalState = Readonly>; + +export type ModalComponent

= React.ComponentType

; + +type Listener = () => void; + +// ============================================================================ +// Store +// ============================================================================ + +export interface ModalStore { + getState: () => ModalState; + subscribe: (listener: Listener) => () => void; + register: (id: string, component: ModalComponent) => () => void; + isRegistered: (id: string) => boolean; + getRegisteredComponent: (id: string) => ModalComponent | undefined; + show: (id: string, props?: P) => Promise; + hide: (id: string, result?: unknown) => void; + remove: (id: string) => void; + hideAll: () => void; } -// --- Context --- +export function createModalStore(): ModalStore { + let state: ModalState = {}; + const registry = new Map(); + const listeners = new Set(); + + const emit = (): void => { + listeners.forEach((listener) => listener()); + }; -interface ModalContextValue { - modals: ModalState; - dispatch: React.Dispatch; + const setState = (next: ModalState): void => { + if (next === state) return; + state = next; + emit(); + }; + + return { + getState: () => state, + + subscribe(listener) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + + register(id, component) { + registry.set(id, component); + // Re-emit so any record stored before registration gets picked up by the outlet. + emit(); + return () => { + registry.delete(id); + emit(); + }; + }, + + isRegistered: (id) => registry.has(id), + getRegisteredComponent: (id) => registry.get(id), + + show(id: string, props?: P): Promise { + return new Promise((resolve, reject) => { + // If a previous show() is still pending, drop its resolver to avoid leaks. + state[id]?.resolver?.resolve(undefined); + const record: ModalRecord

= { + id, + visible: true, + props, + resolver: { resolve: resolve as (v: unknown) => void, reject }, + }; + setState({ ...state, [id]: record as ModalRecord }); + }); + }, + + hide(id, result) { + const record = state[id]; + if (!record || !record.visible) return; + record.resolver?.resolve(result); + const next: ModalRecord = { ...record, visible: false, resolver: undefined }; + setState({ ...state, [id]: next }); + }, + + remove(id) { + const record = state[id]; + if (!record) return; + // Drain a still-pending resolver so callers awaiting `show()` don't hang + // when `remove()` is invoked directly without going through `hide()`. + record.resolver?.resolve(undefined); + const next: Record = { ...state }; + delete next[id]; + setState(next); + }, + + hideAll() { + let changed = false; + const next: Record = { ...state }; + for (const id of Object.keys(next)) { + const record = next[id]; + if (record.visible) { + record.resolver?.resolve(undefined); + next[id] = { ...record, visible: false, resolver: undefined }; + changed = true; + } + } + if (changed) setState(next); + }, + }; } -const ModalContext = createContext(null); +/** + * Process-wide singleton store. Use this — and only this — when you need to + * trigger modals from **outside React** (event handlers attached to globals, + * route guards, error reporters, sagas). `` defaults to this + * store, so calls made via `modalStore.show()` are picked up by the outlet. + * + * Inside React, prefer `useModalActions()` so refactors that change which + * store backs a subtree (e.g. tests, multi-tenant) keep working. + * + * Use `createModalStore()` instead when you need an isolated store — + * unit tests, SSR per-request stores, or independent modal trees. + */ +export const modalStore: ModalStore = createModalStore(); + +// ============================================================================ +// Contexts +// ============================================================================ + +const StoreContext = createContext(modalStore); + +interface ModalSelfContextValue { + id: string; + store: ModalStore; +} -// --- Provider --- +const ModalSelfContext = createContext(null); -interface ModalProviderProps { - modals?: ModalState; - dispatch?: React.Dispatch; +// ============================================================================ +// Provider + Outlet +// ============================================================================ + +export interface ModalProviderProps { + /** + * Optional store override. Defaults to the package-level `modalStore` + * singleton, which also accepts imperative calls from outside React. + */ + store?: ModalStore; children: React.ReactNode; } -function ModalProvider({ modals: controlledModals, dispatch: controlledDispatch, children }: ModalProviderProps) { - const [internalModals, internalDispatch] = useReducer(modalReducer, {}); +export function ModalProvider({ + store = modalStore, + children, +}: ModalProviderProps): React.JSX.Element { + return ( + + {children} + + + ); +} + +function ModalOutlet(): React.JSX.Element { + const store = useContext(StoreContext); + const state = useSyncExternalStore(store.subscribe, store.getState, store.getState); + + return ( + <> + {Object.values(state).map((record) => { + const Component = store.getRegisteredComponent(record.id); + if (!Component) return null; + return ( + } + /> + ); + })} + + ); +} + +interface ModalMountProps { + id: string; + store: ModalStore; + component: ModalComponent; + props: Record | undefined; +} + +function ModalMount({ id, store, component: Component, props }: ModalMountProps): React.JSX.Element { + const ctx = useMemo(() => ({ id, store }), [id, store]); + return ( + + + + + + ); +} - const modals = controlledModals ?? internalModals; - const dispatch = controlledDispatch ?? internalDispatch; +// ============================================================================ +// Declarative registration +// ============================================================================ - const value = useMemo(() => ({ modals, dispatch }), [modals, dispatch]); +export interface ModalRegisterProps { + id: string; + component: ModalComponent; +} - return {children}; +export function ModalRegister({ id, component }: ModalRegisterProps): null { + const store = useContext(StoreContext); + useEffect(() => store.register(id, component), [id, component, store]); + return null; } -// --- Hook --- +// ============================================================================ +// Hooks +// ============================================================================ + +export interface UseModalActionsReturn { + show: (id: string, props?: P) => Promise; + hide: (id: string, result?: unknown) => void; + hideAll: () => void; + register: (id: string, component: ModalComponent) => () => void; +} -interface UseModalReturn { +export interface UseModalReturn { visible: boolean; show: () => void; close: () => void; toggle: () => void; } -function useModal(id: string): UseModalReturn { - const ctx = useContext(ModalContext); - if (!ctx) { - throw new Error('useModal must be used within a '); - } +/** + * Per-id state hook for modals you mount and control yourself + * (the legacy/manual pattern). Re-renders only when this id's visibility flips. + */ +export function useModal(id: string): UseModalReturn { + const store = useContext(StoreContext); + + const getSnapshot = useCallback((): boolean => !!store.getState()[id]?.visible, [id, store]); + const visible = useSyncExternalStore(store.subscribe, getSnapshot, getSnapshot); - const { modals, dispatch } = ctx; - const visible = !!modals[id]; + return useMemo( + () => ({ + visible, + show: () => { + store.show(id); + }, + close: () => store.hide(id), + toggle: () => { + if (store.getState()[id]?.visible) store.hide(id); + else store.show(id); + }, + }), + [id, store, visible] + ); +} - const show = useCallback(() => dispatch({ type: 'SHOW', id }), [dispatch, id]); - const close = useCallback(() => dispatch({ type: 'HIDE', id }), [dispatch, id]); - const toggle = useCallback(() => dispatch({ type: 'TOGGLE', id }), [dispatch, id]); +/** + * Imperative actions for the surrounding store. Does not subscribe to state — + * calling components do not re-render when modals open/close. Use this to + * trigger modals from inside React components (event handlers, mutations). + */ +export function useModalActions(): UseModalActionsReturn { + const store = useContext(StoreContext); + return useMemo( + () => ({ + show: store.show, + hide: store.hide, + hideAll: store.hideAll, + register: store.register, + }), + [store] + ); +} - return useMemo(() => ({ visible, show, close, toggle }), [visible, show, close, toggle]); +export interface UseModalSelfReturn

{ + /** Stable id of the surrounding registered modal. */ + id: string; + /** True between `show()` and `hide()`. Drive the underlying Modal's `visible` prop. */ + visible: boolean; + /** Runtime props passed to `show(id, props)`. */ + props: P; + /** + * Close the modal and resolve the awaiting `show()` promise with `result`. + * Modal stays mounted while the exit animation runs — call `remove()` in `afterClose`. + */ + hide: (result?: R) => void; + /** Reject the awaiting `show()` promise without resolving. Does not close. */ + reject: (reason?: unknown) => void; + /** Hard-remove the record from store; typically wired to `afterClose`. */ + remove: () => void; } -export { ModalProvider, useModal, modalReducer }; -export type { ModalState, ModalAction, ModalProviderProps, UseModalReturn }; +/** + * Read this modal's own state from inside a registered component. + * Throws if used outside an outlet-rendered modal. + */ +export function useModalSelf

(): UseModalSelfReturn { + const ctx = useContext(ModalSelfContext); + if (!ctx) { + throw new Error('useModalSelf must be used inside a registered modal'); + } + const { id, store } = ctx; + + const getSnapshot = useCallback(() => store.getState()[id], [id, store]); + const record = useSyncExternalStore(store.subscribe, getSnapshot, getSnapshot); + + return useMemo>( + () => ({ + id, + visible: !!record?.visible, + props: (record?.props ?? ({} as P)) as P, + hide: (result?: R) => store.hide(id, result), + reject: (reason?: unknown) => record?.resolver?.reject(reason), + remove: () => store.remove(id), + }), + [id, store, record] + ); +} diff --git a/packages/react/src/transition/use-transition.ts b/packages/react/src/transition/use-transition.ts index c7f1bc952..36671ddf0 100644 --- a/packages/react/src/transition/use-transition.ts +++ b/packages/react/src/transition/use-transition.ts @@ -67,6 +67,8 @@ function useTransition(options: UseTransitionOptions): UseTransitionResult { }; const [state, setState] = useState(getInitialState); + const stateRef = useRef(state); + stateRef.current = state; const rafRef = useRef(0); const timerRef = useRef(0); const transitionEndRef = useRef<(() => void) | null>(null); @@ -173,13 +175,12 @@ function useTransition(options: UseTransitionOptions): UseTransitionResult { callbacksRef.current.onExiting?.(); waitForTransition('exit', () => { - setState((prev) => { - if (prev === 'exiting') { - callbacksRef.current.onExited?.(); - return unmountOnExit ? 'unmounted' : 'exited'; - } - return prev; - }); + // Guard via ref so we skip a stale done-callback if state was raced + // (e.g. modal re-opened mid-exit). Side effect lives outside the + // setState updater to avoid "update X while rendering Y" warnings. + if (stateRef.current !== 'exiting') return; + setState(unmountOnExit ? 'unmounted' : 'exited'); + callbacksRef.current.onExited?.(); }); }); }