@inertiaui/modal-react 2.0.2 → 3.0.1

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.
@@ -8,7 +8,7 @@ import { default as Modal } from './Modal';
8
8
  import { default as ModalLink } from './ModalLink';
9
9
  import { default as WhenVisible } from './WhenVisible';
10
10
  import * as dialogUtils from '@inertiaui/vanilla';
11
- export type { Modal as ModalInstance, ModalConfig, ModalResponseData, ModalStackContextValue, VisitOptions, ReloadOptions, EventCallback, ComponentResolver, PageProps, ModalRootProps, ModalRendererProps, LocalModal, PrefetchOption, PrefetchOptions, } from './types';
11
+ export type { Modal as ModalInstance, ModalConfig, ModalResponseData, ModalStackContextValue, VisitOptions, ReloadOptions, EventCallback, ComponentResolver, HttpMethod, PageProps, ModalRootProps, ModalRendererProps, LocalModal, PrefetchOption, PrefetchOptions, } from './types';
12
12
  export type { ModalTypeConfig } from './config';
13
13
  export type { CleanupFunction, FocusTrapOptions, EscapeKeyOptions } from '@inertiaui/vanilla';
14
14
  declare const setPageLayout: <T extends {
package/dist/types.d.ts CHANGED
@@ -1,6 +1,7 @@
1
- import { AxiosResponse } from 'axios';
2
1
  import { ComponentType, ReactNode } from 'react';
3
- import { RequestPayload } from '@inertiajs/core';
2
+ import { RequestPayload, HttpResponse, Method } from '@inertiajs/core';
3
+ import { ModalTypeConfig } from './config';
4
+ export type HttpMethod = Method;
4
5
  export interface ModalResponseData {
5
6
  id?: string;
6
7
  component: string;
@@ -12,22 +13,22 @@ export interface ModalResponseData {
12
13
  };
13
14
  baseUrl?: string;
14
15
  }
15
- export interface ModalConfig {
16
- [key: string]: unknown;
17
- }
16
+ export type ModalConfig = Partial<ModalTypeConfig & {
17
+ slideover: boolean;
18
+ }>;
18
19
  export interface ReloadOptions {
19
20
  only?: string[];
20
21
  except?: string[];
21
- method?: string;
22
+ method?: HttpMethod;
22
23
  data?: Record<string, unknown>;
23
24
  headers?: Record<string, string>;
24
25
  onStart?: () => void;
25
- onSuccess?: (response: AxiosResponse) => void;
26
+ onSuccess?: (response: HttpResponse) => void;
26
27
  onError?: (error: unknown) => void;
27
28
  onFinish?: () => void;
28
29
  }
29
30
  export interface VisitOptions {
30
- method?: string;
31
+ method?: HttpMethod;
31
32
  data?: RequestPayload;
32
33
  headers?: Record<string, string>;
33
34
  config?: ModalConfig;
@@ -36,14 +37,14 @@ export interface VisitOptions {
36
37
  queryStringArrayFormat?: 'brackets' | 'indices';
37
38
  navigate?: boolean;
38
39
  onStart?: () => void;
39
- onSuccess?: (response?: AxiosResponse) => void;
40
+ onSuccess?: (response?: HttpResponse) => void;
40
41
  onError?: (...args: unknown[]) => void;
41
42
  listeners?: Record<string, (...args: unknown[]) => void>;
42
43
  props?: Record<string, unknown>;
43
44
  }
44
45
  export type PrefetchOption = boolean | 'hover' | 'click' | 'mount' | Array<'hover' | 'click' | 'mount'>;
45
46
  export interface PrefetchOptions {
46
- method?: string;
47
+ method?: HttpMethod;
47
48
  data?: RequestPayload;
48
49
  headers?: Record<string, string>;
49
50
  queryStringArrayFormat?: 'brackets' | 'indices';
@@ -92,7 +93,7 @@ export interface ModalStackContextValue {
92
93
  length: () => number;
93
94
  closeAll: (force?: boolean) => void;
94
95
  reset: () => void;
95
- visit: (href: string, method: string, payload?: RequestPayload, headers?: Record<string, string>, config?: ModalConfig, onClose?: (() => void) | null, onAfterLeave?: (() => void) | null, queryStringArrayFormat?: 'brackets' | 'indices', useBrowserHistory?: boolean, onStart?: (() => void) | null, onSuccess?: ((response?: AxiosResponse) => void) | null, onError?: ((...args: unknown[]) => void) | null) => Promise<Modal>;
96
+ visit: (href: string, method: HttpMethod, payload?: RequestPayload, headers?: Record<string, string>, config?: ModalConfig, onClose?: (() => void) | null, onAfterLeave?: (() => void) | null, queryStringArrayFormat?: 'brackets' | 'indices', useBrowserHistory?: boolean, onStart?: (() => void) | null, onSuccess?: ((response?: HttpResponse) => void) | null, onError?: ((...args: unknown[]) => void) | null) => Promise<Modal>;
96
97
  visitModal: (url: string, options?: VisitOptions) => Promise<Modal>;
97
98
  registerLocalModal: (name: string, callback: (modal: Modal) => void) => void;
98
99
  removeLocalModal: (name: string) => void;
@@ -101,7 +102,6 @@ export interface PageProps {
101
102
  initialPage?: {
102
103
  version?: string;
103
104
  };
104
- resolveComponent?: ComponentResolver;
105
105
  }
106
106
  export interface ModalRootProps {
107
107
  children?: ReactNode;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@inertiaui/modal-react",
3
3
  "author": "Pascal Baljet <pascal@protone.media>",
4
- "version": "2.0.2",
4
+ "version": "3.0.1",
5
5
  "private": false,
6
6
  "license": "MIT",
7
7
  "repository": {
@@ -35,8 +35,8 @@
35
35
  },
36
36
  "devDependencies": {
37
37
  "@heroicons/react": "^2.1.4",
38
- "@inertiajs/react": "^2.3.15",
39
- "@inertiaui/vanilla": "^0.2.0",
38
+ "@inertiajs/react": "^3.0.0",
39
+ "@inertiaui/vanilla": "^0.3.0",
40
40
  "@testing-library/react": "^16.0.0",
41
41
  "@types/react": "^19.0.0",
42
42
  "@types/react-dom": "^19.0.0",
@@ -45,7 +45,6 @@
45
45
  "@vitejs/plugin-react": "^4.3.1",
46
46
  "@vitest/coverage-v8": "^4.0.0",
47
47
  "@vitest/ui": "^4.0.0",
48
- "axios": "^1.6.0",
49
48
  "clsx": "^2.1.1",
50
49
  "eslint": "^9.0.0",
51
50
  "happy-dom": "^20.0.0",
@@ -58,12 +57,11 @@
58
57
  "vitest": "^4.0.0"
59
58
  },
60
59
  "dependencies": {
61
- "@inertiaui/vanilla": "^0.2.0"
60
+ "@inertiaui/vanilla": "^0.3.0"
62
61
  },
63
62
  "peerDependencies": {
64
- "@inertiajs/react": "^2.3.15",
65
- "axios": "^1.6.0",
63
+ "@inertiajs/react": "^3.0.0",
66
64
  "react": "^19.0.0",
67
65
  "react-dom": "^19.0.0"
68
66
  }
69
- }
67
+ }
@@ -138,7 +138,7 @@ const HeadlessModal = forwardRef<HeadlessModalRef, HeadlessModalProps>(
138
138
  const previousIsOpenRef = useRef<boolean | undefined>(undefined)
139
139
 
140
140
  useEffect(() => {
141
- if (modalContext !== null) {
141
+ if (modalContext != null) {
142
142
  if (modalContext.isOpen) {
143
143
  onSuccess?.()
144
144
  } else if (previousIsOpenRef.current === true) {
@@ -153,7 +153,7 @@ const HeadlessModal = forwardRef<HeadlessModalRef, HeadlessModalProps>(
153
153
  const [rendered, setRendered] = useState(false)
154
154
 
155
155
  useEffect(() => {
156
- if (rendered && modalContext !== null && modalContext.isOpen) {
156
+ if (rendered && modalContext != null && modalContext.isOpen) {
157
157
  if (modalContext.onTopOfStack) {
158
158
  onFocus?.()
159
159
  } else {
package/src/ModalLink.tsx CHANGED
@@ -2,12 +2,12 @@ import { useCallback, useState, useEffect, useMemo, useRef, ReactNode, ElementTy
2
2
  import { useModalStack, modalPropNames, prefetch as prefetchModal } from './ModalRoot'
3
3
  import { only, rejectNullValues, isStandardDomEvent } from './helpers'
4
4
  import { getConfig } from './config'
5
- import type { Modal, PrefetchOption } from './types'
5
+ import type { Modal, PrefetchOption, HttpMethod } from './types'
6
6
  import type { RequestPayload } from '@inertiajs/core'
7
7
 
8
8
  interface ModalLinkProps {
9
9
  href: string
10
- method?: string
10
+ method?: HttpMethod
11
11
  data?: RequestPayload
12
12
  as?: ElementType
13
13
  headers?: Record<string, string>
package/src/ModalRoot.tsx CHANGED
@@ -1,8 +1,8 @@
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'
1
+ import { createElement, useEffect, useLayoutEffect, useState, useRef, useReducer, ReactNode, ComponentType } from 'react'
2
+ import { except, kebabCase, generateId, sameUrlPath, parseResponseData } from './helpers'
3
+ import { ResponseCache } from './cache'
4
+ import { router, usePage, progress, http } from '@inertiajs/react'
5
+ import { mergeDataIntoQueryString, type RequestPayload, type HttpResponse, type HttpRequestConfig } from '@inertiajs/core'
6
6
  import { createContext, useContext } from 'react'
7
7
  import ModalRenderer from './ModalRenderer'
8
8
  import { getConfig } from './config'
@@ -14,7 +14,7 @@ import type {
14
14
  VisitOptions,
15
15
  ReloadOptions,
16
16
  EventCallback,
17
- ComponentResolver,
17
+ HttpMethod,
18
18
  PageProps,
19
19
  ModalRootProps,
20
20
  LocalModal,
@@ -24,75 +24,38 @@ import type {
24
24
  const ModalStackContext = createContext<ModalStackContextValue | null>(null)
25
25
  ModalStackContext.displayName = 'ModalStackContext'
26
26
 
27
- let pageVersion: string | null = null
28
- let resolveComponent: ComponentResolver | null = null
29
27
  let baseUrl: string | null = null
28
+ let currentPageVersion: string | null = null
30
29
 
31
30
  // Track the URL we're closing to (prevents navigate handler from re-setting baseUrl)
32
31
  // Only suppresses if navigate event URL matches this URL
33
32
  let closingToBaseUrlTarget: string | null = null
34
33
 
35
34
  // 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
- }
35
+ const prefetchCache = new ResponseCache<HttpResponse>()
73
36
 
74
37
  export function prefetch(href: string, options: PrefetchOptions = {}): Promise<void> {
75
38
  if (href.startsWith('#')) {
76
39
  return Promise.resolve()
77
40
  }
78
41
 
79
- const method = (options.method ?? 'get').toLowerCase()
42
+ const method = options.method ?? 'get'
80
43
  const data = options.data ?? ({} as RequestPayload)
81
44
  const headers = options.headers ?? {}
82
45
  const queryStringArrayFormat = options.queryStringArrayFormat ?? 'brackets'
83
46
  const cacheFor = options.cacheFor ?? 30000
84
47
 
85
- const [url, mergedData] = mergeDataIntoQueryString(method as 'get' | 'post' | 'put' | 'patch' | 'delete', href || '', data, queryStringArrayFormat)
48
+ const [url, mergedData] = mergeDataIntoQueryString(method, href || '', data, queryStringArrayFormat)
49
+
50
+ const cacheKey = ResponseCache.key(method, url, mergedData)
86
51
 
87
52
  // Check if already cached
88
- const cached = getCachedResponse(url, method, mergedData)
89
- if (cached) {
53
+ if (prefetchCache.get(cacheKey)) {
90
54
  return Promise.resolve()
91
55
  }
92
56
 
93
57
  // Check if already in flight
94
- const cacheKey = getPrefetchCacheKey(url, method, mergedData)
95
- const inFlight = prefetchInFlight.get(cacheKey)
58
+ const inFlight = prefetchCache.getInFlight(cacheKey)
96
59
  if (inFlight) {
97
60
  return inFlight.then(() => {})
98
61
  }
@@ -104,27 +67,29 @@ export function prefetch(href: string, options: PrefetchOptions = {}): Promise<v
104
67
  Accept: 'text/html, application/xhtml+xml',
105
68
  'X-Requested-With': 'XMLHttpRequest',
106
69
  'X-Inertia': 'true',
107
- 'X-Inertia-Version': pageVersion ?? '',
70
+ 'X-Inertia-Version': currentPageVersion ?? '',
108
71
  'X-InertiaUI-Modal': generateId(),
109
72
  'X-InertiaUI-Modal-Base-Url': baseUrl ?? '',
110
73
  }
111
74
 
112
- const request = Axios({
113
- url,
114
- method,
115
- data: mergedData,
116
- headers: requestHeaders,
117
- })
75
+ const request = http
76
+ .getClient()
77
+ .request({
78
+ url,
79
+ method,
80
+ data: mergedData,
81
+ headers: requestHeaders,
82
+ })
118
83
  .then((response) => {
119
- setCachedResponse(url, method, mergedData, response, cacheFor)
84
+ prefetchCache.set(cacheKey, response, cacheFor)
120
85
  options.onPrefetched?.()
121
86
  return response
122
87
  })
123
88
  .finally(() => {
124
- prefetchInFlight.delete(cacheKey)
89
+ prefetchCache.deleteInFlight(cacheKey)
125
90
  })
126
91
 
127
- prefetchInFlight.set(cacheKey, request)
92
+ prefetchCache.setInFlight(cacheKey, request)
128
93
 
129
94
  return request.then(() => {})
130
95
  }
@@ -295,7 +260,11 @@ export const ModalStackProvider = ({ children }: ModalStackProviderProps) => {
295
260
  // Only suppresses navigate events to this specific URL
296
261
  closingToBaseUrlTarget = savedBaseUrl
297
262
 
298
- if (savedBaseUrl && typeof window !== 'undefined') {
263
+ // Only call router.push() when the URL actually changed (navigate mode).
264
+ // In non-navigate mode (default), the URL never changes and _inertiaui_modal
265
+ // is never in page props, so router.push() would be a no-op that triggers
266
+ // an unnecessary full component re-render in Inertia v3.
267
+ if (savedBaseUrl && typeof window !== 'undefined' && !sameUrlPath(savedBaseUrl, window.location.href)) {
299
268
  router.push({
300
269
  url: savedBaseUrl,
301
270
  preserveScroll: true,
@@ -365,29 +334,30 @@ export const ModalStackProvider = ({ children }: ModalStackProviderProps) => {
365
334
  return
366
335
  }
367
336
 
368
- const method = (options.method ?? 'get').toLowerCase()
337
+ const method = options.method ?? 'get'
369
338
  const data = options.data ?? {}
370
339
 
371
340
  options.onStart?.()
372
341
 
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
- })
342
+ http.getClient()
343
+ .request({
344
+ url: this.response.url,
345
+ method,
346
+ data: method === 'get' ? undefined : data,
347
+ params: method === 'get' ? data : undefined,
348
+ headers: {
349
+ ...(options.headers ?? {}),
350
+ Accept: 'text/html, application/xhtml+xml',
351
+ 'X-Inertia': 'true',
352
+ 'X-Inertia-Partial-Component': this.response.component,
353
+ 'X-Inertia-Version': this.response.version ?? '',
354
+ 'X-Inertia-Partial-Data': keys.join(','),
355
+ 'X-InertiaUI-Modal': generateId(),
356
+ 'X-InertiaUI-Modal-Base-Url': baseUrl ?? '',
357
+ },
358
+ })
389
359
  .then((response) => {
390
- this.updateProps(response.data.props)
360
+ this.updateProps((parseResponseData(response.data) as ModalResponseData).props)
391
361
 
392
362
  options.onSuccess?.(response)
393
363
  })
@@ -420,10 +390,6 @@ export const ModalStackProvider = ({ children }: ModalStackProviderProps) => {
420
390
  onClose: (() => void) | null = null,
421
391
  onAfterLeave: (() => void) | null = null,
422
392
  ): Promise<Modal> => {
423
- if (!resolveComponent) {
424
- return Promise.reject(new Error('resolveComponent not set'))
425
- }
426
-
427
393
  if (!isValidModalResponse(responseData)) {
428
394
  return Promise.reject(
429
395
  new Error(
@@ -433,8 +399,8 @@ export const ModalStackProvider = ({ children }: ModalStackProviderProps) => {
433
399
  )
434
400
  }
435
401
 
436
- return resolveComponent(responseData.component).then((component) =>
437
- push(component, responseData, config, onClose, onAfterLeave),
402
+ return router.resolveComponent(responseData.component).then((component) =>
403
+ push(component as ComponentType, responseData, config, onClose, onAfterLeave),
438
404
  )
439
405
  }
440
406
 
@@ -537,7 +503,7 @@ export const ModalStackProvider = ({ children }: ModalStackProviderProps) => {
537
503
 
538
504
  const visit = (
539
505
  href: string,
540
- method: string,
506
+ method: HttpMethod,
541
507
  payload: RequestPayload = {},
542
508
  headers: Record<string, string> = {},
543
509
  config: ModalConfig = {},
@@ -546,7 +512,7 @@ export const ModalStackProvider = ({ children }: ModalStackProviderProps) => {
546
512
  queryStringArrayFormat: 'brackets' | 'indices' = 'brackets',
547
513
  useBrowserHistory = false,
548
514
  onStart: (() => void) | null = null,
549
- onSuccess: ((response?: AxiosResponse) => void) | null = null,
515
+ onSuccess: ((response?: HttpResponse) => void) | null = null,
550
516
  onError: ((...args: unknown[]) => void) | null = null,
551
517
  props: Record<string, unknown> | null = null,
552
518
  ): Promise<Modal> => {
@@ -558,15 +524,16 @@ export const ModalStackProvider = ({ children }: ModalStackProviderProps) => {
558
524
  return
559
525
  }
560
526
 
561
- const [url, data] = mergeDataIntoQueryString(method as 'get' | 'post' | 'put' | 'patch' | 'delete', href || '', payload, queryStringArrayFormat)
527
+ const [url, data] = mergeDataIntoQueryString(method, href || '', payload, queryStringArrayFormat)
562
528
 
563
529
  // Check for cached prefetch response (#146)
564
- const cachedResponse = getCachedResponse(url, method, data)
530
+ const cachedResponse = prefetchCache.get(ResponseCache.key(method, url, data))
565
531
  if (cachedResponse) {
532
+ const cachedData = parseResponseData(cachedResponse.data) as ModalResponseData
566
533
  onSuccess?.(cachedResponse)
567
- pushFromResponseData(cachedResponse.data, config, onClose, onAfterLeave)
534
+ pushFromResponseData(cachedData, config, onClose, onAfterLeave)
568
535
  .then((modal) => {
569
- updateBrowserUrl(cachedResponse.data.url, useBrowserHistory, cachedResponse.data)
536
+ updateBrowserUrl(cachedData.url, useBrowserHistory, cachedData)
570
537
  resolve(modal)
571
538
  })
572
539
  .catch(reject)
@@ -582,7 +549,7 @@ export const ModalStackProvider = ({ children }: ModalStackProviderProps) => {
582
549
  Accept: 'text/html, application/xhtml+xml',
583
550
  'X-Requested-With': 'XMLHttpRequest',
584
551
  'X-Inertia': 'true',
585
- 'X-Inertia-Version': pageVersion ?? '',
552
+ 'X-Inertia-Version': currentPageVersion ?? '',
586
553
  'X-InertiaUI-Modal': modalId,
587
554
  'X-InertiaUI-Modal-Base-Url': baseUrl ?? '',
588
555
  }
@@ -591,17 +558,19 @@ export const ModalStackProvider = ({ children }: ModalStackProviderProps) => {
591
558
 
592
559
  progress?.start()
593
560
 
594
- Axios({
595
- url,
596
- method,
597
- data,
598
- headers: requestHeaders,
599
- })
561
+ http.getClient()
562
+ .request({
563
+ url,
564
+ method,
565
+ data,
566
+ headers: requestHeaders,
567
+ })
600
568
  .then((response) => {
569
+ const responseData = parseResponseData(response.data) as ModalResponseData
601
570
  onSuccess?.(response)
602
- pushFromResponseData(response.data, config, onClose, onAfterLeave)
571
+ pushFromResponseData(responseData, config, onClose, onAfterLeave)
603
572
  .then((modal) => {
604
- updateBrowserUrl(response.data.url, useBrowserHistory, response.data)
573
+ updateBrowserUrl(responseData.url, useBrowserHistory, responseData)
605
574
  resolve(modal)
606
575
  })
607
576
  .catch(reject)
@@ -671,11 +640,7 @@ export const modalPropNames = ['closeButton', 'closeExplicitly', 'closeOnClickOu
671
640
 
672
641
  export const initFromPageProps = (pageProps: PageProps) => {
673
642
  if (pageProps.initialPage) {
674
- pageVersion = pageProps.initialPage.version ?? null
675
- }
676
-
677
- if (pageProps.resolveComponent) {
678
- resolveComponent = pageProps.resolveComponent
643
+ currentPageVersion = pageProps.initialPage.version ?? null
679
644
  }
680
645
  }
681
646
 
@@ -734,11 +699,30 @@ export const ModalRoot = ({ children }: ModalRootProps) => {
734
699
  const $page = usePage<InertiaUIModalPageProps>()
735
700
  const pendingModalKeysRef = useRef(new Set<string>())
736
701
 
702
+ // Keep module-level pageVersion in sync for use by prefetch/visit functions.
703
+ // Set during render (not useEffect) because prefetch() needs it synchronously on mount.
704
+ currentPageVersion = $page.version ?? null
705
+
737
706
  // Generate a unique key for deduplication (handles case when modal has no id)
738
707
  const getModalKey = (modalData: ModalResponseData) => modalData.id || `${modalData.component}:${modalData.url}`
739
708
 
740
709
  const isNavigatingRef = useRef(false)
741
- const initialModalStillOpenedRef = useRef(!!$page.props?._inertiaui_modal)
710
+
711
+ // Use a ref so the interceptor always reads the latest page props
712
+ const pageRef = useRef($page)
713
+ pageRef.current = $page
714
+
715
+ // Register interceptor in useLayoutEffect (fires during commit, before microtasks).
716
+ // Inertia 3 loads deferred props during page.set() microtasks which fire after commit
717
+ // but before useEffect — useLayoutEffect ensures the interceptor is registered in time.
718
+ useLayoutEffect(() => http.onRequest((config: HttpRequestConfig) => {
719
+ const baseUrlValue = baseUrl ?? pageRef.current.props._inertiaui_modal?.baseUrl ?? null
720
+ if (baseUrlValue) {
721
+ config.headers = config.headers ?? {}
722
+ config.headers['X-InertiaUI-Modal-Base-Url'] = baseUrlValue
723
+ }
724
+ return config
725
+ }), [])
742
726
 
743
727
  useEffect(() => router.on('start', () => (isNavigatingRef.current = true)), [])
744
728
  useEffect(() => router.on('finish', () => (isNavigatingRef.current = false)), [])
@@ -759,7 +743,6 @@ export const ModalRoot = ({ children }: ModalRootProps) => {
759
743
  closingToBaseUrlTarget = null
760
744
  context?.closeAll(true)
761
745
  baseUrl = null
762
- initialModalStillOpenedRef.current = false
763
746
  return
764
747
  }
765
748
  closingToBaseUrlTarget = null
@@ -769,7 +752,6 @@ export const ModalRoot = ({ children }: ModalRootProps) => {
769
752
  // No modal data - close any open modals (force close without transition)
770
753
  context?.closeAll(true)
771
754
  baseUrl = null
772
- initialModalStillOpenedRef.current = false
773
755
  return
774
756
  }
775
757
 
@@ -777,7 +759,6 @@ export const ModalRoot = ({ children }: ModalRootProps) => {
777
759
  if (!sameUrlPath(pageUrl, modalOnBase.url)) {
778
760
  context?.closeAll(true)
779
761
  baseUrl = null
780
- initialModalStillOpenedRef.current = false
781
762
  return
782
763
  }
783
764
 
@@ -809,6 +790,11 @@ export const ModalRoot = ({ children }: ModalRootProps) => {
809
790
  console.error('No base url in modal response data so cannot navigate back')
810
791
  return
811
792
  }
793
+ // Clear baseUrl before navigating so the interceptor doesn't add
794
+ // the modal header to the base page request (deferred props should
795
+ // load without the modal context after closing)
796
+ baseUrl = null
797
+
812
798
  if (!isNavigatingRef.current && typeof window !== 'undefined' && window.location.href !== modalOnBase.baseUrl) {
813
799
  router.visit(modalOnBase.baseUrl, {
814
800
  preserveScroll: true,
@@ -823,23 +809,6 @@ export const ModalRoot = ({ children }: ModalRootProps) => {
823
809
  [],
824
810
  )
825
811
 
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
812
  const previousModalRef = useRef<(ModalResponseData & { baseUrl: string }) | undefined>(undefined)
844
813
 
845
814
  useEffect(() => {
@@ -878,3 +847,4 @@ export const ModalRoot = ({ children }: ModalRootProps) => {
878
847
  </>
879
848
  )
880
849
  }
850
+
package/src/cache.ts ADDED
@@ -0,0 +1,64 @@
1
+ interface CacheEntry<T> {
2
+ response: T
3
+ expiresAt: number
4
+ }
5
+
6
+ export class ResponseCache<T> {
7
+ private cache = new Map<string, CacheEntry<T>>()
8
+ private timers = new Map<string, ReturnType<typeof setTimeout>>()
9
+ private inFlight = new Map<string, Promise<T>>()
10
+
11
+ static key(method: string, url: string, data: unknown): string {
12
+ return `${method}:${url}:${JSON.stringify(data)}`
13
+ }
14
+
15
+ get(key: string): T | null {
16
+ const cached = this.cache.get(key)
17
+
18
+ if (!cached) {
19
+ return null
20
+ }
21
+
22
+ if (Date.now() > cached.expiresAt) {
23
+ this.delete(key)
24
+ return null
25
+ }
26
+
27
+ return cached.response
28
+ }
29
+
30
+ set(key: string, response: T, cacheFor: number): void {
31
+ this.delete(key)
32
+
33
+ this.cache.set(key, {
34
+ response,
35
+ expiresAt: Date.now() + cacheFor,
36
+ })
37
+
38
+ if (cacheFor > 0) {
39
+ this.timers.set(key, setTimeout(() => this.delete(key), cacheFor))
40
+ }
41
+ }
42
+
43
+ delete(key: string): void {
44
+ this.cache.delete(key)
45
+
46
+ const timer = this.timers.get(key)
47
+ if (timer) {
48
+ clearTimeout(timer)
49
+ this.timers.delete(key)
50
+ }
51
+ }
52
+
53
+ getInFlight(key: string): Promise<T> | undefined {
54
+ return this.inFlight.get(key)
55
+ }
56
+
57
+ setInFlight(key: string, promise: Promise<T>): void {
58
+ this.inFlight.set(key, promise)
59
+ }
60
+
61
+ deleteInFlight(key: string): void {
62
+ this.inFlight.delete(key)
63
+ }
64
+ }
package/src/helpers.ts CHANGED
@@ -1,5 +1,8 @@
1
1
  // Re-export helper utilities from vanilla
2
2
  export { sameUrlPath, except, only, rejectNullValues, kebabCase, isStandardDomEvent } from '@inertiaui/vanilla'
3
+ export function parseResponseData(data: unknown): unknown {
4
+ return typeof data === 'string' ? JSON.parse(data) : data
5
+ }
3
6
  import { generateId as vanillaGenerateId } from '@inertiaui/vanilla'
4
7
 
5
8
  // Wrap generateId with custom callback support for testing
@@ -20,6 +20,7 @@ export type {
20
20
  ReloadOptions,
21
21
  EventCallback,
22
22
  ComponentResolver,
23
+ HttpMethod,
23
24
  PageProps,
24
25
  ModalRootProps,
25
26
  ModalRendererProps,