import { mergeDataIntoQueryString, type RequestPayload, type HttpResponse, type HttpRequestConfig } from '@inertiajs/core' import { router, usePage, progress, http } from '@inertiajs/react' import { createElement, useEffect, useLayoutEffect, useState, useRef, useReducer, ReactNode, ComponentType } from 'react' import { createContext, useContext } from 'react' import { ResponseCache } from './cache' import { getConfig } from './config' import { except, kebabCase, generateId, sameUrlPath, parseResponseData } from './helpers' import ModalRenderer from './ModalRenderer' import type { Modal, ModalConfig, ModalResponseData, ModalStackContextValue, VisitOptions, ReloadOptions, EventCallback, HttpMethod, PageProps, ModalRootProps, LocalModal, PrefetchOptions, } from './types' const ModalStackContext = createContext(null) ModalStackContext.displayName = 'ModalStackContext' let baseUrl: string | null = null let currentPageVersion: string | null = null // Track the URL we're closing to (prevents navigate handler from re-setting baseUrl) // Only suppresses if navigate event URL matches this URL let closingToBaseUrlTarget: string | null = null // Prefetch cache (#146) const prefetchCache = new ResponseCache() export function prefetch(href: string, options: PrefetchOptions = {}): Promise { if (href.startsWith('#')) { return Promise.resolve() } const method = options.method ?? 'get' const data = options.data ?? ({} as RequestPayload) const headers = options.headers ?? {} const queryStringArrayFormat = options.queryStringArrayFormat ?? 'brackets' const cacheFor = options.cacheFor ?? 30000 const [url, mergedData] = mergeDataIntoQueryString(method, href || '', data, queryStringArrayFormat) const cacheKey = ResponseCache.key(method, url, mergedData) // Check if already cached if (prefetchCache.get(cacheKey)) { return Promise.resolve() } // Check if already in flight const inFlight = prefetchCache.getInFlight(cacheKey) if (inFlight) { return inFlight.then(() => {}) } options.onPrefetching?.() const requestHeaders: Record = { ...headers, Accept: 'text/html, application/xhtml+xml', 'X-Requested-With': 'XMLHttpRequest', 'X-Inertia': 'true', 'X-Inertia-Version': currentPageVersion ?? '', 'X-InertiaUI-Modal': generateId(), 'X-InertiaUI-Modal-Base-Url': baseUrl ?? '', } const request = http .getClient() .request({ url, method, data: mergedData, headers: requestHeaders, }) .then((response) => { prefetchCache.set(cacheKey, response, cacheFor) options.onPrefetched?.() return response }) .finally(() => { prefetchCache.deleteInFlight(cacheKey) }) prefetchCache.setInFlight(cacheKey, request) return request.then(() => {}) } interface ModalStackProviderProps { children: ReactNode } export const ModalStackProvider = ({ children }: ModalStackProviderProps) => { // Use ref for synchronous access to stack, state only for triggering re-renders const stackRef = useRef([]) const [, forceUpdate] = useReducer((x) => x + 1, 0) const [localModals, setLocalModals] = useState>({}) const updateStack = (withStack: (prevStack: Modal[]) => Modal[]) => { const newStack = withStack([...stackRef.current]) const isOnTopOfStack = (modalId: string) => { if (newStack.length < 2) { return true } return ( newStack .map((modal) => ({ id: modal.id, shouldRender: modal.shouldRender })) .reverse() .find((modal) => modal.shouldRender)?.id === modalId ) } newStack.forEach((modal, index) => { newStack[index].onTopOfStack = isOnTopOfStack(modal.id) newStack[index].getParentModal = () => { if (index < 1) { // This is the first modal in the stack return null } // Find the first open modal before this one return ( stackRef.current .slice(0, index) .reverse() .find((m) => m.isOpen) ?? null ) } newStack[index].getChildModal = () => { if (index === stackRef.current.length - 1) { // This is the last modal in the stack return null } // Find the first open modal after this one return stackRef.current.slice(index + 1).find((m) => m.isOpen) ?? null } }) stackRef.current = newStack forceUpdate() } class ModalClass implements Modal { id: string isOpen: boolean shouldRender: boolean listeners: Record component: ComponentType | null props: Record response: ModalResponseData config: ModalConfig onCloseCallback: (() => void) | null afterLeaveCallback: (() => void) | null index: number onTopOfStack: boolean name?: string getParentModal: () => Modal | null getChildModal: () => Modal | null constructor( component: ComponentType | null, response: ModalResponseData, config?: ModalConfig | null, onClose?: (() => void) | null, afterLeave?: (() => void) | null, ) { this.id = response.id ?? generateId() this.isOpen = false this.shouldRender = false this.listeners = {} this.component = component this.props = response.props ?? {} this.response = response this.config = config ?? {} this.onCloseCallback = onClose ?? null this.afterLeaveCallback = afterLeave ?? null this.index = -1 // Will be set when added to the stack this.getParentModal = () => null // Will be set in push() this.getChildModal = () => null // Will be set in push() this.onTopOfStack = true // Will be updated in push() } show = () => { updateStack((prevStack) => prevStack.map((modal) => { if (modal.id === this.id && !modal.isOpen) { modal.isOpen = true modal.shouldRender = true } return modal }), ) } setOpen = (open: boolean) => { if (open) { this.show() } else { this.close() } } close = () => { updateStack((currentStack) => { let modalClosed = false const newStack = currentStack.map((modal) => { if (modal.id === this.id && modal.isOpen) { Object.keys(modal.listeners).forEach((event) => { modal.off(event) }) modal.isOpen = false modal.onCloseCallback?.() modalClosed = true } return modal }) return modalClosed ? newStack : currentStack }) } afterLeave = () => { if (this.isOpen) { return } updateStack((prevStack) => { const updatedStack = prevStack.map((modal) => { if (modal.id === this.id && !modal.isOpen) { modal.shouldRender = false modal.afterLeaveCallback?.() modal.afterLeaveCallback = null } return modal }) if (this.index === 0) { // Update browser URL back to base when all modals are closed // Clear baseUrl BEFORE router.push to prevent the navigate event // from setting it back (race condition with props callback) const savedBaseUrl = baseUrl baseUrl = null // Set target URL to prevent navigate handler from re-setting baseUrl // Only suppresses navigate events to this specific URL closingToBaseUrlTarget = savedBaseUrl // Only call router.push() when the URL actually changed (navigate mode). // In non-navigate mode (default), the URL never changes and _inertiaui_modal // is never in page props, so router.push() would be a no-op that triggers // an unnecessary full component re-render in Inertia v3. if (savedBaseUrl && typeof window !== 'undefined' && !sameUrlPath(savedBaseUrl, window.location.href)) { router.push({ url: savedBaseUrl, preserveScroll: true, preserveState: true, // Clear _inertiaui_modal prop to prevent modal from reopening props: (currentProps: Record) => { // eslint-disable-next-line @typescript-eslint/no-unused-vars const { _inertiaui_modal, ...rest } = currentProps return { ...rest, _inertiaui_modal: undefined } }, }) } return [] } return updatedStack }) } on = (event: string, callback: EventCallback) => { event = kebabCase(event) this.listeners[event] = this.listeners[event] ?? [] this.listeners[event].push(callback) } off = (event: string, callback?: EventCallback) => { event = kebabCase(event) if (callback) { this.listeners[event] = this.listeners[event]?.filter((cb) => cb !== callback) ?? [] } else { delete this.listeners[event] } } emit = (event: string, ...args: unknown[]) => { this.listeners[kebabCase(event)]?.forEach((callback) => callback(...args)) } registerEventListenersFromProps = (props: Record) => { const unsubscribers: (() => void)[] = [] Object.keys(props) .filter((key) => key.startsWith('on')) .forEach((key) => { // e.g. onRefreshKey -> refresh-key const eventName = kebabCase(key).replace(/^on-/, '') const callback = props[key] as EventCallback this.on(eventName, callback) unsubscribers.push(() => this.off(eventName, callback)) }) return () => unsubscribers.forEach((unsub) => unsub()) } reload = (options: ReloadOptions = {}) => { let keys = Object.keys(this.response.props) if (options.only) { keys = options.only } if (options.except) { keys = except(keys, options.except) as string[] } if (!this.response?.url) { return } const method = options.method ?? 'get' const data = options.data ?? {} options.onStart?.() http.getClient() .request({ url: this.response.url, method, data: method === 'get' ? undefined : data, params: method === 'get' ? data : undefined, headers: { ...options.headers, Accept: 'text/html, application/xhtml+xml', 'X-Inertia': 'true', 'X-Inertia-Partial-Component': this.response.component, 'X-Inertia-Version': this.response.version ?? '', 'X-Inertia-Partial-Data': keys.join(','), 'X-InertiaUI-Modal': generateId(), 'X-InertiaUI-Modal-Base-Url': baseUrl ?? '', }, }) .then((response) => { this.updateProps((parseResponseData(response.data) as ModalResponseData).props) options.onSuccess?.(response) }) .catch((error) => { options.onError?.(error) }) .finally(() => { options.onFinish?.() }) } updateProps = (props: Record) => { Object.assign(this.props, props) updateStack((prevStack) => prevStack) // Trigger re-render } } const isValidModalResponse = (data: unknown): data is ModalResponseData => { return typeof data === 'object' && data !== null && 'component' in data && typeof (data as ModalResponseData).component === 'string' } const pushFromResponseData = ( responseData: ModalResponseData, config: ModalConfig = {}, onClose: (() => void) | null = null, onAfterLeave: (() => void) | null = null, ): Promise => { if (!isValidModalResponse(responseData)) { return Promise.reject( new Error( 'Invalid modal response. This usually happens when the server returns a redirect (e.g., due to session expiration). ' + 'Check if the user is still authenticated.', ), ) } return router .resolveComponent(responseData.component) .then((component) => push(component as ComponentType, responseData, config, onClose, onAfterLeave)) } const loadDeferredProps = (modal: Modal) => { const deferred = modal.response?.meta?.deferredProps if (!deferred) { return } Object.keys(deferred).forEach((key) => { modal.reload({ only: deferred[key] }) }) } const push = ( component: ComponentType | null, response: ModalResponseData, config?: ModalConfig | null, onClose?: (() => void) | null, afterLeave?: (() => void) | null, ): Modal => { const newModal = new ModalClass(component, response, config, onClose, afterLeave) newModal.index = stackRef.current.length updateStack((prevStack) => [...prevStack, newModal]) loadDeferredProps(newModal) newModal.show() return newModal } function pushLocalModal( name: string, config?: ModalConfig | null, onClose?: (() => void) | null, afterLeave?: (() => void) | null, props?: Record | null, ): Modal { if (!localModals[name]) { throw new Error(`The local modal "${name}" has not been registered.`) } const responseData = { props: props ?? {} } as ModalResponseData const modal = push(null, responseData, config, onClose, afterLeave) modal.name = name localModals[name].callback(modal) return modal } const visitModal = (url: string, options: VisitOptions = {}): Promise => visit( url, options.method ?? 'get', options.data ?? ({} as RequestPayload), options.headers ?? {}, options.config ?? {}, options.onClose ?? null, options.onAfterLeave ?? null, options.queryStringArrayFormat ?? 'brackets', options.navigate ?? (getConfig('navigate') as boolean), options.onStart ?? null, options.onSuccess ?? null, options.onError ?? null, options.props ?? null, ).then((modal) => { const listeners = options.listeners ?? {} Object.keys(listeners).forEach((event) => { // e.g. refreshKey -> refresh-key const eventName = kebabCase(event) modal.on(eventName, listeners[event]) }) return modal }) const updateBrowserUrl = (url: string | undefined, useBrowserHistory: boolean, modalData?: ModalResponseData): void => { if (!url || !useBrowserHistory || typeof window === 'undefined') { return } router.push({ url, preserveScroll: true, preserveState: true, // Store modal data in page props for history navigation props: modalData ? (currentProps: Record) => ({ ...currentProps, _inertiaui_modal: { ...modalData, baseUrl, }, }) : undefined, }) } const visit = ( href: string, method: HttpMethod, payload: RequestPayload = {}, headers: Record = {}, config: ModalConfig = {}, onClose: (() => void) | null = null, onAfterLeave: (() => void) | null = null, queryStringArrayFormat: 'brackets' | 'indices' = 'brackets', useBrowserHistory = false, onStart: (() => void) | null = null, onSuccess: ((response?: HttpResponse) => void) | null = null, onError: ((...args: unknown[]) => void) | null = null, props: Record | null = null, ): Promise => { const modalId = generateId() return new Promise((resolve, reject) => { if (href.startsWith('#')) { resolve(pushLocalModal(href.substring(1), config, onClose, onAfterLeave, props)) return } const [url, data] = mergeDataIntoQueryString(method, href || '', payload, queryStringArrayFormat) // Check for cached prefetch response (#146) const cachedResponse = prefetchCache.get(ResponseCache.key(method, url, data)) if (cachedResponse) { const cachedData = parseResponseData(cachedResponse.data) as ModalResponseData onSuccess?.(cachedResponse) pushFromResponseData(cachedData, config, onClose, onAfterLeave) .then((modal) => { updateBrowserUrl(cachedData.url, useBrowserHistory, cachedData) resolve(modal) }) .catch(reject) return } if (stackRef.current.length === 0) { baseUrl = typeof window !== 'undefined' ? window.location.href : '' } const requestHeaders: Record = { ...headers, Accept: 'text/html, application/xhtml+xml', 'X-Requested-With': 'XMLHttpRequest', 'X-Inertia': 'true', 'X-Inertia-Version': currentPageVersion ?? '', 'X-InertiaUI-Modal': modalId, 'X-InertiaUI-Modal-Base-Url': baseUrl ?? '', } onStart?.() progress?.start() http.getClient() .request({ url, method, data, headers: requestHeaders, }) .then((response) => { const responseData = parseResponseData(response.data) as ModalResponseData onSuccess?.(response) pushFromResponseData(responseData, config, onClose, onAfterLeave) .then((modal) => { updateBrowserUrl(responseData.url, useBrowserHistory, responseData) resolve(modal) }) .catch(reject) }) .catch((...args: unknown[]) => { onError?.(...args) reject(args[0]) }) .finally(() => { progress?.finish() }) }) } const registerLocalModal = (name: string, callback: (modal: Modal) => void) => { setLocalModals((prevLocalModals) => ({ ...prevLocalModals, [name]: { name, callback }, })) } const removeLocalModal = (name: string) => { setLocalModals((prevLocalModals) => { const newLocalModals = { ...prevLocalModals } delete newLocalModals[name] return newLocalModals }) } // Create value object with getter for stack to ensure we always get current ref value const value: ModalStackContextValue = { get stack() { return stackRef.current }, localModals, push, pushFromResponseData, length: () => stackRef.current.length, closeAll: (force = false) => { if (force) { // Force close: immediately remove all modals without transition updateStack(() => []) } else { // Normal close: trigger leave transition for each modal ;[...stackRef.current].reverse().forEach((modal) => modal.close()) } }, reset: () => updateStack(() => []), visit, visitModal, registerLocalModal, removeLocalModal, } return {children} } export const useModalStack = (): ModalStackContextValue => { const context = useContext(ModalStackContext) if (context === null) { throw new Error('useModalStack must be used within a ModalStackProvider') } return context } export const modalPropNames = ['closeButton', 'closeExplicitly', 'closeOnClickOutside', 'maxWidth', 'paddingClasses', 'panelClasses', 'position', 'slideover'] export const initFromPageProps = (pageProps: PageProps) => { if (pageProps.initialPage) { currentPageVersion = pageProps.initialPage.version ?? null } } interface RenderInertiaAppProps { Component: ComponentType & { layout?: ((page: ReactNode) => ReactNode) | ComponentType[] } props: Record key: string } export const renderApp = (App: ComponentType<{ children: (props: RenderInertiaAppProps) => ReactNode }>, pageProps: PageProps) => { initFromPageProps(pageProps) const renderInertiaApp = ({ Component, props, key }: RenderInertiaAppProps) => { const renderComponent = () => { const child = createElement(Component, { key, ...props }) if (typeof Component.layout === 'function') { return Component.layout(child) } if (Array.isArray(Component.layout)) { return Component.layout .slice() .reverse() .reduce((acc, Layout) => createElement(Layout as ComponentType>, props, acc), child as ReactNode) } return child } return ( <> {renderComponent()} ) } return ( )}>{renderInertiaApp} ) } interface InertiaUIModalPageProps { _inertiaui_modal?: ModalResponseData & { baseUrl: string } [key: string]: unknown } export const ModalRoot = ({ children }: ModalRootProps) => { const context = useContext(ModalStackContext) const $page = usePage() const pendingModalKeysRef = useRef(new Set()) // Keep module-level pageVersion in sync for use by prefetch/visit functions. // Set during render (not useEffect) because prefetch() needs it synchronously on mount. currentPageVersion = $page.version ?? null // Generate a unique key for deduplication (handles case when modal has no id) const getModalKey = (modalData: ModalResponseData) => modalData.id || `${modalData.component}:${modalData.url}` const isNavigatingRef = useRef(false) // Use a ref so the interceptor always reads the latest page props const pageRef = useRef($page) pageRef.current = $page // Register interceptor in useLayoutEffect (fires during commit, before microtasks). // Inertia 3 loads deferred props during page.set() microtasks which fire after commit // but before useEffect — useLayoutEffect ensures the interceptor is registered in time. useLayoutEffect( () => http.onRequest((config: HttpRequestConfig) => { const baseUrlValue = baseUrl ?? pageRef.current.props._inertiaui_modal?.baseUrl ?? null if (baseUrlValue) { config.headers = config.headers ?? {} config.headers['X-InertiaUI-Modal-Base-Url'] = baseUrlValue } return config }), [], ) useEffect(() => router.on('start', () => (isNavigatingRef.current = true)), []) useEffect(() => router.on('finish', () => (isNavigatingRef.current = false)), []) useEffect( () => router.on('navigate', function ($event) { const modalOnBase = ($event as { detail: { page: { props: InertiaUIModalPageProps; url: string } } }).detail.page.props._inertiaui_modal const pageUrl = ($event as { detail: { page: { url: string } } }).detail.page.url // If we're closing to this specific URL, don't re-open the modal // This handles the race condition where router.push in afterLeave // fires a navigate event before the props callback clears _inertiaui_modal // Only suppresses when navigating to our closing target URL (not browser back to modal) if (closingToBaseUrlTarget) { const targetPath = new URL(closingToBaseUrlTarget, 'http://x').pathname const pagePath = new URL(pageUrl, 'http://x').pathname if (targetPath === pagePath) { closingToBaseUrlTarget = null context?.closeAll(true) baseUrl = null return } closingToBaseUrlTarget = null } if (!modalOnBase) { // No modal data - close any open modals (force close without transition) context?.closeAll(true) baseUrl = null return } // If the page URL doesn't match the modal URL, close all modals if (!sameUrlPath(pageUrl, modalOnBase.url)) { context?.closeAll(true) baseUrl = null return } // Skip if this modal is already being pushed (handles duplicate navigate events) const modalKey = getModalKey(modalOnBase) if (pendingModalKeysRef.current.has(modalKey)) { return } // Also skip if a modal with this id is already in the stack if (modalOnBase.id && context?.stack.some((m) => m.id === modalOnBase.id)) { return } // Skip if a modal with the same component and URL is already open if (context?.stack.some((m) => m.response?.component === modalOnBase.component && sameUrlPath(m.response?.url, modalOnBase.url))) { return } // Only set baseUrl when we're actually opening a new modal // (after deduplication checks pass) baseUrl = modalOnBase.baseUrl pendingModalKeysRef.current.add(modalKey) context ?.pushFromResponseData(modalOnBase, {}, () => { if (!modalOnBase.baseUrl) { console.error('No base url in modal response data so cannot navigate back') return } // Clear baseUrl before navigating so the interceptor doesn't add // the modal header to the base page request (deferred props should // load without the modal context after closing) baseUrl = null if (!isNavigatingRef.current && typeof window !== 'undefined' && window.location.href !== modalOnBase.baseUrl) { router.visit(modalOnBase.baseUrl, { preserveScroll: true, preserveState: true, }) } }) .finally(() => { pendingModalKeysRef.current.delete(modalKey) }) }), [], ) const previousModalRef = useRef<(ModalResponseData & { baseUrl: string }) | undefined>(undefined) useEffect(() => { const newModal = $page.props?._inertiaui_modal const previousModal = previousModalRef.current // Store the current value for the next render previousModalRef.current = newModal if (!newModal) { return } // If there's a previous modal with same component/URL, update its props if (previousModal && newModal.component === previousModal.component && sameUrlPath(newModal.url, previousModal.url)) { context?.stack[0]?.updateProps(newModal.props ?? {}) return } // If there's no previous modal but we have modals in the stack (opened via XHR), // check if the new modal matches any open modal and update its props if (!previousModal && context && context.stack.length > 0) { const existingModal = context.stack.find((m) => m.response?.component === newModal.component && sameUrlPath(m.response?.url, newModal.url)) if (existingModal) { existingModal.updateProps(newModal.props ?? {}) } } }, [$page.props?._inertiaui_modal]) return ( <> {children} {context && context.stack.length > 0 && } ) }