@inertiaui/modal-react 1.0.0-beta-5 → 2.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/dist/CloseButton.d.ts +5 -0
  2. package/dist/Deferred.d.ts +11 -0
  3. package/dist/HeadlessModal.d.ts +64 -0
  4. package/dist/Modal.d.ts +48 -0
  5. package/dist/ModalContent.d.ts +24 -0
  6. package/dist/ModalLink.d.ts +29 -0
  7. package/dist/ModalRenderer.d.ts +4 -0
  8. package/dist/ModalRoot.d.ts +22 -0
  9. package/dist/SlideoverContent.d.ts +24 -0
  10. package/dist/WhenVisible.d.ts +16 -0
  11. package/dist/config.d.ts +21 -0
  12. package/dist/constants.d.ts +7 -0
  13. package/dist/helpers.d.ts +3 -0
  14. package/dist/inertiaui-modal.d.ts +2 -0
  15. package/dist/inertiaui-modal.js +1680 -2167
  16. package/dist/inertiaui-modal.js.map +1 -0
  17. package/dist/inertiaui-modal.umd.cjs +1854 -21
  18. package/dist/inertiaui-modal.umd.cjs.map +1 -0
  19. package/dist/inertiauiModal.d.ts +21 -0
  20. package/dist/types.d.ts +111 -0
  21. package/dist/useModal.d.ts +2 -0
  22. package/package.json +37 -22
  23. package/src/{CloseButton.jsx → CloseButton.tsx} +5 -1
  24. package/src/{Deferred.jsx → Deferred.tsx} +10 -3
  25. package/src/HeadlessModal.tsx +238 -0
  26. package/src/Modal.tsx +292 -0
  27. package/src/ModalContent.tsx +311 -0
  28. package/src/ModalLink.tsx +224 -0
  29. package/src/ModalRenderer.tsx +33 -0
  30. package/src/ModalRoot.tsx +880 -0
  31. package/src/SlideoverContent.tsx +319 -0
  32. package/src/{WhenVisible.jsx → WhenVisible.tsx} +20 -9
  33. package/src/config.ts +99 -0
  34. package/src/constants.ts +22 -0
  35. package/src/helpers.ts +17 -0
  36. package/src/inertiauiModal.ts +65 -0
  37. package/src/types.ts +150 -0
  38. package/src/useModal.ts +7 -0
  39. package/src/HeadlessModal.jsx +0 -143
  40. package/src/Modal.jsx +0 -136
  41. package/src/ModalContent.jsx +0 -56
  42. package/src/ModalLink.jsx +0 -123
  43. package/src/ModalRenderer.jsx +0 -34
  44. package/src/ModalRoot.jsx +0 -623
  45. package/src/SlideoverContent.jsx +0 -55
  46. package/src/config.js +0 -3
  47. package/src/helpers.js +0 -2
  48. package/src/inertiauiModal.js +0 -34
  49. package/src/useModal.js +0 -6
@@ -0,0 +1,880 @@
1
+ import { createElement, useEffect, useState, useRef, useReducer, ReactNode, ComponentType } from 'react'
2
+ import { default as Axios, AxiosResponse, InternalAxiosRequestConfig } from 'axios'
3
+ import { except, kebabCase, generateId, sameUrlPath } from './helpers'
4
+ import { router, usePage, progress } from '@inertiajs/react'
5
+ import { mergeDataIntoQueryString, type RequestPayload } from '@inertiajs/core'
6
+ import { createContext, useContext } from 'react'
7
+ import ModalRenderer from './ModalRenderer'
8
+ import { getConfig } from './config'
9
+ import type {
10
+ Modal,
11
+ ModalConfig,
12
+ ModalResponseData,
13
+ ModalStackContextValue,
14
+ VisitOptions,
15
+ ReloadOptions,
16
+ EventCallback,
17
+ ComponentResolver,
18
+ PageProps,
19
+ ModalRootProps,
20
+ LocalModal,
21
+ PrefetchOptions,
22
+ } from './types'
23
+
24
+ const ModalStackContext = createContext<ModalStackContextValue | null>(null)
25
+ ModalStackContext.displayName = 'ModalStackContext'
26
+
27
+ let pageVersion: string | null = null
28
+ let resolveComponent: ComponentResolver | null = null
29
+ let baseUrl: string | null = null
30
+
31
+ // Track the URL we're closing to (prevents navigate handler from re-setting baseUrl)
32
+ // Only suppresses if navigate event URL matches this URL
33
+ let closingToBaseUrlTarget: string | null = null
34
+
35
+ // Prefetch cache (#146)
36
+ interface PrefetchCacheEntry {
37
+ response: AxiosResponse
38
+ timestamp: number
39
+ expiresAt: number
40
+ }
41
+
42
+ const prefetchCache = new Map<string, PrefetchCacheEntry>()
43
+ const prefetchInFlight = new Map<string, Promise<AxiosResponse>>()
44
+
45
+ function getPrefetchCacheKey(url: string, method: string, data: RequestPayload): string {
46
+ return `${method}:${url}:${JSON.stringify(data)}`
47
+ }
48
+
49
+ function getCachedResponse(url: string, method: string, data: RequestPayload): AxiosResponse | null {
50
+ const key = getPrefetchCacheKey(url, method, data)
51
+ const cached = prefetchCache.get(key)
52
+
53
+ if (!cached) {
54
+ return null
55
+ }
56
+
57
+ if (Date.now() > cached.expiresAt) {
58
+ prefetchCache.delete(key)
59
+ return null
60
+ }
61
+
62
+ return cached.response
63
+ }
64
+
65
+ function setCachedResponse(url: string, method: string, data: RequestPayload, response: AxiosResponse, cacheFor: number): void {
66
+ const key = getPrefetchCacheKey(url, method, data)
67
+ prefetchCache.set(key, {
68
+ response,
69
+ timestamp: Date.now(),
70
+ expiresAt: Date.now() + cacheFor,
71
+ })
72
+ }
73
+
74
+ export function prefetch(href: string, options: PrefetchOptions = {}): Promise<void> {
75
+ if (href.startsWith('#')) {
76
+ return Promise.resolve()
77
+ }
78
+
79
+ const method = (options.method ?? 'get').toLowerCase()
80
+ const data = options.data ?? ({} as RequestPayload)
81
+ const headers = options.headers ?? {}
82
+ const queryStringArrayFormat = options.queryStringArrayFormat ?? 'brackets'
83
+ const cacheFor = options.cacheFor ?? 30000
84
+
85
+ const [url, mergedData] = mergeDataIntoQueryString(method as 'get' | 'post' | 'put' | 'patch' | 'delete', href || '', data, queryStringArrayFormat)
86
+
87
+ // Check if already cached
88
+ const cached = getCachedResponse(url, method, mergedData)
89
+ if (cached) {
90
+ return Promise.resolve()
91
+ }
92
+
93
+ // Check if already in flight
94
+ const cacheKey = getPrefetchCacheKey(url, method, mergedData)
95
+ const inFlight = prefetchInFlight.get(cacheKey)
96
+ if (inFlight) {
97
+ return inFlight.then(() => {})
98
+ }
99
+
100
+ options.onPrefetching?.()
101
+
102
+ const requestHeaders: Record<string, string> = {
103
+ ...headers,
104
+ Accept: 'text/html, application/xhtml+xml',
105
+ 'X-Requested-With': 'XMLHttpRequest',
106
+ 'X-Inertia': 'true',
107
+ 'X-Inertia-Version': pageVersion ?? '',
108
+ 'X-InertiaUI-Modal': generateId(),
109
+ 'X-InertiaUI-Modal-Base-Url': baseUrl ?? '',
110
+ }
111
+
112
+ const request = Axios({
113
+ url,
114
+ method,
115
+ data: mergedData,
116
+ headers: requestHeaders,
117
+ })
118
+ .then((response) => {
119
+ setCachedResponse(url, method, mergedData, response, cacheFor)
120
+ options.onPrefetched?.()
121
+ return response
122
+ })
123
+ .finally(() => {
124
+ prefetchInFlight.delete(cacheKey)
125
+ })
126
+
127
+ prefetchInFlight.set(cacheKey, request)
128
+
129
+ return request.then(() => {})
130
+ }
131
+
132
+ interface ModalStackProviderProps {
133
+ children: ReactNode
134
+ }
135
+
136
+ export const ModalStackProvider = ({ children }: ModalStackProviderProps) => {
137
+ // Use ref for synchronous access to stack, state only for triggering re-renders
138
+ const stackRef = useRef<Modal[]>([])
139
+ const [, forceUpdate] = useReducer((x) => x + 1, 0)
140
+ const [localModals, setLocalModals] = useState<Record<string, LocalModal>>({})
141
+
142
+ const updateStack = (withStack: (prevStack: Modal[]) => Modal[]) => {
143
+ const newStack = withStack([...stackRef.current])
144
+
145
+ const isOnTopOfStack = (modalId: string) => {
146
+ if (newStack.length < 2) {
147
+ return true
148
+ }
149
+
150
+ return (
151
+ newStack
152
+ .map((modal) => ({ id: modal.id, shouldRender: modal.shouldRender }))
153
+ .reverse()
154
+ .find((modal) => modal.shouldRender)?.id === modalId
155
+ )
156
+ }
157
+
158
+ newStack.forEach((modal, index) => {
159
+ newStack[index].onTopOfStack = isOnTopOfStack(modal.id)
160
+ newStack[index].getParentModal = () => {
161
+ if (index < 1) {
162
+ // This is the first modal in the stack
163
+ return null
164
+ }
165
+
166
+ // Find the first open modal before this one
167
+ return (
168
+ stackRef.current
169
+ .slice(0, index)
170
+ .reverse()
171
+ .find((m) => m.isOpen) ?? null
172
+ )
173
+ }
174
+ newStack[index].getChildModal = () => {
175
+ if (index === stackRef.current.length - 1) {
176
+ // This is the last modal in the stack
177
+ return null
178
+ }
179
+
180
+ // Find the first open modal after this one
181
+ return stackRef.current.slice(index + 1).find((m) => m.isOpen) ?? null
182
+ }
183
+ })
184
+
185
+ stackRef.current = newStack
186
+ forceUpdate()
187
+ }
188
+
189
+ class ModalClass implements Modal {
190
+ id: string
191
+ isOpen: boolean
192
+ shouldRender: boolean
193
+ listeners: Record<string, EventCallback[]>
194
+ component: ComponentType | null
195
+ props: Record<string, unknown>
196
+ response: ModalResponseData
197
+ config: ModalConfig
198
+ onCloseCallback: (() => void) | null
199
+ afterLeaveCallback: (() => void) | null
200
+ index: number
201
+ onTopOfStack: boolean
202
+ name?: string
203
+ getParentModal: () => Modal | null
204
+ getChildModal: () => Modal | null
205
+
206
+ constructor(
207
+ component: ComponentType | null,
208
+ response: ModalResponseData,
209
+ config?: ModalConfig | null,
210
+ onClose?: (() => void) | null,
211
+ afterLeave?: (() => void) | null,
212
+ ) {
213
+ this.id = response.id ?? generateId()
214
+ this.isOpen = false
215
+ this.shouldRender = false
216
+ this.listeners = {}
217
+
218
+ this.component = component
219
+ this.props = response.props ?? {}
220
+ this.response = response
221
+ this.config = config ?? {}
222
+ this.onCloseCallback = onClose ?? null
223
+ this.afterLeaveCallback = afterLeave ?? null
224
+
225
+ this.index = -1 // Will be set when added to the stack
226
+ this.getParentModal = () => null // Will be set in push()
227
+ this.getChildModal = () => null // Will be set in push()
228
+ this.onTopOfStack = true // Will be updated in push()
229
+ }
230
+
231
+ show = () => {
232
+ updateStack((prevStack) =>
233
+ prevStack.map((modal) => {
234
+ if (modal.id === this.id && !modal.isOpen) {
235
+ modal.isOpen = true
236
+ modal.shouldRender = true
237
+ }
238
+ return modal
239
+ }),
240
+ )
241
+ }
242
+
243
+ setOpen = (open: boolean) => {
244
+ if (open) {
245
+ this.show()
246
+ } else {
247
+ this.close()
248
+ }
249
+ }
250
+
251
+ close = () => {
252
+ updateStack((currentStack) => {
253
+ let modalClosed = false
254
+
255
+ const newStack = currentStack.map((modal) => {
256
+ if (modal.id === this.id && modal.isOpen) {
257
+ Object.keys(modal.listeners).forEach((event) => {
258
+ modal.off(event)
259
+ })
260
+
261
+ modal.isOpen = false
262
+ modal.onCloseCallback?.()
263
+ modalClosed = true
264
+ }
265
+ return modal
266
+ })
267
+
268
+ return modalClosed ? newStack : currentStack
269
+ })
270
+ }
271
+
272
+ afterLeave = () => {
273
+ if (this.isOpen) {
274
+ return
275
+ }
276
+
277
+ updateStack((prevStack) => {
278
+ const updatedStack = prevStack.map((modal) => {
279
+ if (modal.id === this.id && !modal.isOpen) {
280
+ modal.shouldRender = false
281
+ modal.afterLeaveCallback?.()
282
+ modal.afterLeaveCallback = null
283
+ }
284
+ return modal
285
+ })
286
+
287
+ if (this.index === 0) {
288
+ // Update browser URL back to base when all modals are closed
289
+ // Clear baseUrl BEFORE router.push to prevent the navigate event
290
+ // from setting it back (race condition with props callback)
291
+ const savedBaseUrl = baseUrl
292
+ baseUrl = null
293
+
294
+ // Set target URL to prevent navigate handler from re-setting baseUrl
295
+ // Only suppresses navigate events to this specific URL
296
+ closingToBaseUrlTarget = savedBaseUrl
297
+
298
+ if (savedBaseUrl && typeof window !== 'undefined') {
299
+ router.push({
300
+ url: savedBaseUrl,
301
+ preserveScroll: true,
302
+ preserveState: true,
303
+ // Clear _inertiaui_modal prop to prevent modal from reopening
304
+ props: (currentProps: Record<string, unknown>) => {
305
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
306
+ const { _inertiaui_modal, ...rest } = currentProps
307
+ return { ...rest, _inertiaui_modal: undefined }
308
+ },
309
+ })
310
+ }
311
+ return []
312
+ }
313
+
314
+ return updatedStack
315
+ })
316
+ }
317
+
318
+ on = (event: string, callback: EventCallback) => {
319
+ event = kebabCase(event)
320
+ this.listeners[event] = this.listeners[event] ?? []
321
+ this.listeners[event].push(callback)
322
+ }
323
+
324
+ off = (event: string, callback?: EventCallback) => {
325
+ event = kebabCase(event)
326
+ if (callback) {
327
+ this.listeners[event] = this.listeners[event]?.filter((cb) => cb !== callback) ?? []
328
+ } else {
329
+ delete this.listeners[event]
330
+ }
331
+ }
332
+
333
+ emit = (event: string, ...args: unknown[]) => {
334
+ this.listeners[kebabCase(event)]?.forEach((callback) => callback(...args))
335
+ }
336
+
337
+ registerEventListenersFromProps = (props: Record<string, unknown>) => {
338
+ const unsubscribers: (() => void)[] = []
339
+
340
+ Object.keys(props)
341
+ .filter((key) => key.startsWith('on'))
342
+ .forEach((key) => {
343
+ // e.g. onRefreshKey -> refresh-key
344
+ const eventName = kebabCase(key).replace(/^on-/, '')
345
+ const callback = props[key] as EventCallback
346
+ this.on(eventName, callback)
347
+ unsubscribers.push(() => this.off(eventName, callback))
348
+ })
349
+
350
+ return () => unsubscribers.forEach((unsub) => unsub())
351
+ }
352
+
353
+ reload = (options: ReloadOptions = {}) => {
354
+ let keys = Object.keys(this.response.props)
355
+
356
+ if (options.only) {
357
+ keys = options.only
358
+ }
359
+
360
+ if (options.except) {
361
+ keys = except(keys, options.except) as string[]
362
+ }
363
+
364
+ if (!this.response?.url) {
365
+ return
366
+ }
367
+
368
+ const method = (options.method ?? 'get').toLowerCase()
369
+ const data = options.data ?? {}
370
+
371
+ options.onStart?.()
372
+
373
+ Axios({
374
+ url: this.response.url,
375
+ method,
376
+ data: method === 'get' ? {} : data,
377
+ params: method === 'get' ? data : {},
378
+ headers: {
379
+ ...(options.headers ?? {}),
380
+ Accept: 'text/html, application/xhtml+xml',
381
+ 'X-Inertia': 'true',
382
+ 'X-Inertia-Partial-Component': this.response.component,
383
+ 'X-Inertia-Version': this.response.version ?? '',
384
+ 'X-Inertia-Partial-Data': keys.join(','),
385
+ 'X-InertiaUI-Modal': generateId(),
386
+ 'X-InertiaUI-Modal-Base-Url': baseUrl ?? '',
387
+ },
388
+ })
389
+ .then((response) => {
390
+ this.updateProps(response.data.props)
391
+
392
+ options.onSuccess?.(response)
393
+ })
394
+ .catch((error) => {
395
+ options.onError?.(error)
396
+ })
397
+ .finally(() => {
398
+ options.onFinish?.()
399
+ })
400
+ }
401
+
402
+ updateProps = (props: Record<string, unknown>) => {
403
+ Object.assign(this.props, props)
404
+ updateStack((prevStack) => prevStack) // Trigger re-render
405
+ }
406
+ }
407
+
408
+ const isValidModalResponse = (data: unknown): data is ModalResponseData => {
409
+ return (
410
+ typeof data === 'object' &&
411
+ data !== null &&
412
+ 'component' in data &&
413
+ typeof (data as ModalResponseData).component === 'string'
414
+ )
415
+ }
416
+
417
+ const pushFromResponseData = (
418
+ responseData: ModalResponseData,
419
+ config: ModalConfig = {},
420
+ onClose: (() => void) | null = null,
421
+ onAfterLeave: (() => void) | null = null,
422
+ ): Promise<Modal> => {
423
+ if (!resolveComponent) {
424
+ return Promise.reject(new Error('resolveComponent not set'))
425
+ }
426
+
427
+ if (!isValidModalResponse(responseData)) {
428
+ return Promise.reject(
429
+ new Error(
430
+ 'Invalid modal response. This usually happens when the server returns a redirect (e.g., due to session expiration). ' +
431
+ 'Check if the user is still authenticated.',
432
+ ),
433
+ )
434
+ }
435
+
436
+ return resolveComponent(responseData.component).then((component) =>
437
+ push(component, responseData, config, onClose, onAfterLeave),
438
+ )
439
+ }
440
+
441
+ const loadDeferredProps = (modal: Modal) => {
442
+ const deferred = modal.response?.meta?.deferredProps
443
+
444
+ if (!deferred) {
445
+ return
446
+ }
447
+
448
+ Object.keys(deferred).forEach((key) => {
449
+ modal.reload({ only: deferred[key] })
450
+ })
451
+ }
452
+
453
+ const push = (
454
+ component: ComponentType | null,
455
+ response: ModalResponseData,
456
+ config?: ModalConfig | null,
457
+ onClose?: (() => void) | null,
458
+ afterLeave?: (() => void) | null,
459
+ ): Modal => {
460
+ const newModal = new ModalClass(component, response, config, onClose, afterLeave)
461
+ newModal.index = stackRef.current.length
462
+
463
+ updateStack((prevStack) => [...prevStack, newModal])
464
+ loadDeferredProps(newModal)
465
+
466
+ newModal.show()
467
+
468
+ return newModal
469
+ }
470
+
471
+ function pushLocalModal(
472
+ name: string,
473
+ config?: ModalConfig | null,
474
+ onClose?: (() => void) | null,
475
+ afterLeave?: (() => void) | null,
476
+ props?: Record<string, unknown> | null,
477
+ ): Modal {
478
+ if (!localModals[name]) {
479
+ throw new Error(`The local modal "${name}" has not been registered.`)
480
+ }
481
+
482
+ const responseData = { props: props ?? {} } as ModalResponseData
483
+ const modal = push(null, responseData, config, onClose, afterLeave)
484
+ modal.name = name
485
+ localModals[name].callback(modal)
486
+ return modal
487
+ }
488
+
489
+ const visitModal = (url: string, options: VisitOptions = {}): Promise<Modal> =>
490
+ visit(
491
+ url,
492
+ options.method ?? 'get',
493
+ options.data ?? ({} as RequestPayload),
494
+ options.headers ?? {},
495
+ options.config ?? {},
496
+ options.onClose ?? null,
497
+ options.onAfterLeave ?? null,
498
+ options.queryStringArrayFormat ?? 'brackets',
499
+ options.navigate ?? (getConfig('navigate') as boolean),
500
+ options.onStart ?? null,
501
+ options.onSuccess ?? null,
502
+ options.onError ?? null,
503
+ options.props ?? null,
504
+ ).then((modal) => {
505
+ const listeners = options.listeners ?? {}
506
+
507
+ Object.keys(listeners).forEach((event) => {
508
+ // e.g. refreshKey -> refresh-key
509
+ const eventName = kebabCase(event)
510
+ modal.on(eventName, listeners[event])
511
+ })
512
+
513
+ return modal
514
+ })
515
+
516
+ const updateBrowserUrl = (url: string | undefined, useBrowserHistory: boolean, modalData?: ModalResponseData): void => {
517
+ if (!url || !useBrowserHistory || typeof window === 'undefined') {
518
+ return
519
+ }
520
+
521
+ router.push({
522
+ url,
523
+ preserveScroll: true,
524
+ preserveState: true,
525
+ // Store modal data in page props for history navigation
526
+ props: modalData
527
+ ? (currentProps: Record<string, unknown>) => ({
528
+ ...currentProps,
529
+ _inertiaui_modal: {
530
+ ...modalData,
531
+ baseUrl,
532
+ },
533
+ })
534
+ : undefined,
535
+ })
536
+ }
537
+
538
+ const visit = (
539
+ href: string,
540
+ method: string,
541
+ payload: RequestPayload = {},
542
+ headers: Record<string, string> = {},
543
+ config: ModalConfig = {},
544
+ onClose: (() => void) | null = null,
545
+ onAfterLeave: (() => void) | null = null,
546
+ queryStringArrayFormat: 'brackets' | 'indices' = 'brackets',
547
+ useBrowserHistory = false,
548
+ onStart: (() => void) | null = null,
549
+ onSuccess: ((response?: AxiosResponse) => void) | null = null,
550
+ onError: ((...args: unknown[]) => void) | null = null,
551
+ props: Record<string, unknown> | null = null,
552
+ ): Promise<Modal> => {
553
+ const modalId = generateId()
554
+
555
+ return new Promise((resolve, reject) => {
556
+ if (href.startsWith('#')) {
557
+ resolve(pushLocalModal(href.substring(1), config, onClose, onAfterLeave, props))
558
+ return
559
+ }
560
+
561
+ const [url, data] = mergeDataIntoQueryString(method as 'get' | 'post' | 'put' | 'patch' | 'delete', href || '', payload, queryStringArrayFormat)
562
+
563
+ // Check for cached prefetch response (#146)
564
+ const cachedResponse = getCachedResponse(url, method, data)
565
+ if (cachedResponse) {
566
+ onSuccess?.(cachedResponse)
567
+ pushFromResponseData(cachedResponse.data, config, onClose, onAfterLeave)
568
+ .then((modal) => {
569
+ updateBrowserUrl(cachedResponse.data.url, useBrowserHistory, cachedResponse.data)
570
+ resolve(modal)
571
+ })
572
+ .catch(reject)
573
+ return
574
+ }
575
+
576
+ if (stackRef.current.length === 0) {
577
+ baseUrl = typeof window !== 'undefined' ? window.location.href : ''
578
+ }
579
+
580
+ const requestHeaders: Record<string, string> = {
581
+ ...headers,
582
+ Accept: 'text/html, application/xhtml+xml',
583
+ 'X-Requested-With': 'XMLHttpRequest',
584
+ 'X-Inertia': 'true',
585
+ 'X-Inertia-Version': pageVersion ?? '',
586
+ 'X-InertiaUI-Modal': modalId,
587
+ 'X-InertiaUI-Modal-Base-Url': baseUrl ?? '',
588
+ }
589
+
590
+ onStart?.()
591
+
592
+ progress?.start()
593
+
594
+ Axios({
595
+ url,
596
+ method,
597
+ data,
598
+ headers: requestHeaders,
599
+ })
600
+ .then((response) => {
601
+ onSuccess?.(response)
602
+ pushFromResponseData(response.data, config, onClose, onAfterLeave)
603
+ .then((modal) => {
604
+ updateBrowserUrl(response.data.url, useBrowserHistory, response.data)
605
+ resolve(modal)
606
+ })
607
+ .catch(reject)
608
+ })
609
+ .catch((...args: unknown[]) => {
610
+ onError?.(...args)
611
+ reject(args[0])
612
+ })
613
+ .finally(() => {
614
+ progress?.finish()
615
+ })
616
+ })
617
+ }
618
+
619
+ const registerLocalModal = (name: string, callback: (modal: Modal) => void) => {
620
+ setLocalModals((prevLocalModals) => ({
621
+ ...prevLocalModals,
622
+ [name]: { name, callback },
623
+ }))
624
+ }
625
+
626
+ const removeLocalModal = (name: string) => {
627
+ setLocalModals((prevLocalModals) => {
628
+ const newLocalModals = { ...prevLocalModals }
629
+ delete newLocalModals[name]
630
+ return newLocalModals
631
+ })
632
+ }
633
+
634
+ // Create value object with getter for stack to ensure we always get current ref value
635
+ const value: ModalStackContextValue = {
636
+ get stack() {
637
+ return stackRef.current
638
+ },
639
+ localModals,
640
+ push,
641
+ pushFromResponseData,
642
+ length: () => stackRef.current.length,
643
+ closeAll: (force = false) => {
644
+ if (force) {
645
+ // Force close: immediately remove all modals without transition
646
+ updateStack(() => [])
647
+ } else {
648
+ // Normal close: trigger leave transition for each modal
649
+ ;[...stackRef.current].reverse().forEach((modal) => modal.close())
650
+ }
651
+ },
652
+ reset: () => updateStack(() => []),
653
+ visit,
654
+ visitModal,
655
+ registerLocalModal,
656
+ removeLocalModal,
657
+ }
658
+
659
+ return <ModalStackContext.Provider value={value}>{children}</ModalStackContext.Provider>
660
+ }
661
+
662
+ export const useModalStack = (): ModalStackContextValue => {
663
+ const context = useContext(ModalStackContext)
664
+ if (context === null) {
665
+ throw new Error('useModalStack must be used within a ModalStackProvider')
666
+ }
667
+ return context
668
+ }
669
+
670
+ export const modalPropNames = ['closeButton', 'closeExplicitly', 'closeOnClickOutside', 'maxWidth', 'paddingClasses', 'panelClasses', 'position', 'slideover']
671
+
672
+ export const initFromPageProps = (pageProps: PageProps) => {
673
+ if (pageProps.initialPage) {
674
+ pageVersion = pageProps.initialPage.version ?? null
675
+ }
676
+
677
+ if (pageProps.resolveComponent) {
678
+ resolveComponent = pageProps.resolveComponent
679
+ }
680
+ }
681
+
682
+ interface RenderInertiaAppProps {
683
+ Component: ComponentType & { layout?: ((page: ReactNode) => ReactNode) | ComponentType[] }
684
+ props: Record<string, unknown>
685
+ key: string
686
+ }
687
+
688
+ export const renderApp = (App: ComponentType<{ children: (props: RenderInertiaAppProps) => ReactNode }>, pageProps: PageProps) => {
689
+ initFromPageProps(pageProps)
690
+
691
+ const renderInertiaApp = ({ Component, props, key }: RenderInertiaAppProps) => {
692
+ const renderComponent = () => {
693
+ const child = createElement(Component, { key, ...props })
694
+
695
+ if (typeof Component.layout === 'function') {
696
+ return Component.layout(child)
697
+ }
698
+
699
+ if (Array.isArray(Component.layout)) {
700
+ return Component.layout
701
+ .slice()
702
+ .reverse()
703
+ .reduce(
704
+ (acc, Layout) => createElement(Layout as ComponentType<Record<string, unknown>>, props, acc),
705
+ child as ReactNode,
706
+ )
707
+ }
708
+
709
+ return child
710
+ }
711
+
712
+ return (
713
+ <>
714
+ {renderComponent()}
715
+ <ModalRoot />
716
+ </>
717
+ )
718
+ }
719
+
720
+ return (
721
+ <ModalStackProvider>
722
+ <App {...(pageProps as Record<string, unknown>)}>{renderInertiaApp}</App>
723
+ </ModalStackProvider>
724
+ )
725
+ }
726
+
727
+ interface InertiaUIModalPageProps {
728
+ _inertiaui_modal?: ModalResponseData & { baseUrl: string }
729
+ [key: string]: unknown
730
+ }
731
+
732
+ export const ModalRoot = ({ children }: ModalRootProps) => {
733
+ const context = useContext(ModalStackContext)
734
+ const $page = usePage<InertiaUIModalPageProps>()
735
+ const pendingModalKeysRef = useRef(new Set<string>())
736
+
737
+ // Generate a unique key for deduplication (handles case when modal has no id)
738
+ const getModalKey = (modalData: ModalResponseData) => modalData.id || `${modalData.component}:${modalData.url}`
739
+
740
+ const isNavigatingRef = useRef(false)
741
+ const initialModalStillOpenedRef = useRef(!!$page.props?._inertiaui_modal)
742
+
743
+ useEffect(() => router.on('start', () => (isNavigatingRef.current = true)), [])
744
+ useEffect(() => router.on('finish', () => (isNavigatingRef.current = false)), [])
745
+ useEffect(
746
+ () =>
747
+ router.on('navigate', function ($event) {
748
+ const modalOnBase = ($event as { detail: { page: { props: InertiaUIModalPageProps; url: string } } }).detail.page.props._inertiaui_modal
749
+ const pageUrl = ($event as { detail: { page: { url: string } } }).detail.page.url
750
+
751
+ // If we're closing to this specific URL, don't re-open the modal
752
+ // This handles the race condition where router.push in afterLeave
753
+ // fires a navigate event before the props callback clears _inertiaui_modal
754
+ // Only suppresses when navigating to our closing target URL (not browser back to modal)
755
+ if (closingToBaseUrlTarget) {
756
+ const targetPath = new URL(closingToBaseUrlTarget, 'http://x').pathname
757
+ const pagePath = new URL(pageUrl, 'http://x').pathname
758
+ if (targetPath === pagePath) {
759
+ closingToBaseUrlTarget = null
760
+ context?.closeAll(true)
761
+ baseUrl = null
762
+ initialModalStillOpenedRef.current = false
763
+ return
764
+ }
765
+ closingToBaseUrlTarget = null
766
+ }
767
+
768
+ if (!modalOnBase) {
769
+ // No modal data - close any open modals (force close without transition)
770
+ context?.closeAll(true)
771
+ baseUrl = null
772
+ initialModalStillOpenedRef.current = false
773
+ return
774
+ }
775
+
776
+ // If the page URL doesn't match the modal URL, close all modals
777
+ if (!sameUrlPath(pageUrl, modalOnBase.url)) {
778
+ context?.closeAll(true)
779
+ baseUrl = null
780
+ initialModalStillOpenedRef.current = false
781
+ return
782
+ }
783
+
784
+ // Skip if this modal is already being pushed (handles duplicate navigate events)
785
+ const modalKey = getModalKey(modalOnBase)
786
+ if (pendingModalKeysRef.current.has(modalKey)) {
787
+ return
788
+ }
789
+
790
+ // Also skip if a modal with this id is already in the stack
791
+ if (modalOnBase.id && context?.stack.some((m) => m.id === modalOnBase.id)) {
792
+ return
793
+ }
794
+
795
+ // Skip if a modal with the same component and URL is already open
796
+ if (context?.stack.some((m) => m.response?.component === modalOnBase.component && sameUrlPath(m.response?.url, modalOnBase.url))) {
797
+ return
798
+ }
799
+
800
+ // Only set baseUrl when we're actually opening a new modal
801
+ // (after deduplication checks pass)
802
+ baseUrl = modalOnBase.baseUrl
803
+
804
+ pendingModalKeysRef.current.add(modalKey)
805
+
806
+ context
807
+ ?.pushFromResponseData(modalOnBase, {}, () => {
808
+ if (!modalOnBase.baseUrl) {
809
+ console.error('No base url in modal response data so cannot navigate back')
810
+ return
811
+ }
812
+ if (!isNavigatingRef.current && typeof window !== 'undefined' && window.location.href !== modalOnBase.baseUrl) {
813
+ router.visit(modalOnBase.baseUrl, {
814
+ preserveScroll: true,
815
+ preserveState: true,
816
+ })
817
+ }
818
+ })
819
+ .finally(() => {
820
+ pendingModalKeysRef.current.delete(modalKey)
821
+ })
822
+ }),
823
+ [],
824
+ )
825
+
826
+ const axiosRequestInterceptor = (config: InternalAxiosRequestConfig) => {
827
+ // A Modal is opened on top of a base route, so we need to pass this base route
828
+ // so it can redirect back with the back() helper method...
829
+ // Only send the header when we have an actual base URL value
830
+ const baseUrlValue = baseUrl ?? (initialModalStillOpenedRef.current ? $page.props._inertiaui_modal?.baseUrl : null)
831
+ if (baseUrlValue) {
832
+ config.headers['X-InertiaUI-Modal-Base-Url'] = baseUrlValue
833
+ }
834
+
835
+ return config
836
+ }
837
+
838
+ useEffect(() => {
839
+ const interceptorId = Axios.interceptors.request.use(axiosRequestInterceptor)
840
+ return () => Axios.interceptors.request.eject(interceptorId)
841
+ }, [])
842
+
843
+ const previousModalRef = useRef<(ModalResponseData & { baseUrl: string }) | undefined>(undefined)
844
+
845
+ useEffect(() => {
846
+ const newModal = $page.props?._inertiaui_modal
847
+ const previousModal = previousModalRef.current
848
+
849
+ // Store the current value for the next render
850
+ previousModalRef.current = newModal
851
+
852
+ if (!newModal) {
853
+ return
854
+ }
855
+
856
+ // If there's a previous modal with same component/URL, update its props
857
+ if (previousModal && newModal.component === previousModal.component && sameUrlPath(newModal.url, previousModal.url)) {
858
+ context?.stack[0]?.updateProps(newModal.props ?? {})
859
+ return
860
+ }
861
+
862
+ // If there's no previous modal but we have modals in the stack (opened via XHR),
863
+ // check if the new modal matches any open modal and update its props
864
+ if (!previousModal && context && context.stack.length > 0) {
865
+ const existingModal = context.stack.find(
866
+ (m) => m.response?.component === newModal.component && sameUrlPath(m.response?.url, newModal.url),
867
+ )
868
+ if (existingModal) {
869
+ existingModal.updateProps(newModal.props ?? {})
870
+ }
871
+ }
872
+ }, [$page.props?._inertiaui_modal])
873
+
874
+ return (
875
+ <>
876
+ {children}
877
+ {context && context.stack.length > 0 && <ModalRenderer index={0} />}
878
+ </>
879
+ )
880
+ }