@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.
- package/dist/ModalLink.d.ts +2 -2
- package/dist/cache.d.ts +12 -0
- package/dist/helpers.d.ts +1 -0
- package/dist/inertiaui-modal.js +95 -78
- package/dist/inertiaui-modal.js.map +1 -1
- package/dist/inertiaui-modal.umd.cjs +95 -77
- package/dist/inertiaui-modal.umd.cjs.map +1 -1
- package/dist/inertiauiModal.d.ts +1 -1
- package/dist/types.d.ts +12 -12
- package/package.json +6 -8
- package/src/HeadlessModal.tsx +2 -2
- package/src/ModalLink.tsx +2 -2
- package/src/ModalRoot.tsx +97 -127
- package/src/cache.ts +64 -0
- package/src/helpers.ts +3 -0
- package/src/inertiauiModal.ts +1 -0
- package/src/types.ts +13 -13
package/dist/inertiauiModal.d.ts
CHANGED
|
@@ -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
|
|
16
|
-
|
|
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?:
|
|
22
|
+
method?: HttpMethod;
|
|
22
23
|
data?: Record<string, unknown>;
|
|
23
24
|
headers?: Record<string, string>;
|
|
24
25
|
onStart?: () => void;
|
|
25
|
-
onSuccess?: (response:
|
|
26
|
+
onSuccess?: (response: HttpResponse) => void;
|
|
26
27
|
onError?: (error: unknown) => void;
|
|
27
28
|
onFinish?: () => void;
|
|
28
29
|
}
|
|
29
30
|
export interface VisitOptions {
|
|
30
|
-
method?:
|
|
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?:
|
|
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?:
|
|
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:
|
|
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": "
|
|
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": "^
|
|
39
|
-
"@inertiaui/vanilla": "^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.
|
|
60
|
+
"@inertiaui/vanilla": "^0.3.0"
|
|
62
61
|
},
|
|
63
62
|
"peerDependencies": {
|
|
64
|
-
"@inertiajs/react": "^
|
|
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
|
+
}
|
package/src/HeadlessModal.tsx
CHANGED
|
@@ -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
|
|
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
|
|
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?:
|
|
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 {
|
|
3
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
|
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':
|
|
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 =
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
84
|
+
prefetchCache.set(cacheKey, response, cacheFor)
|
|
120
85
|
options.onPrefetched?.()
|
|
121
86
|
return response
|
|
122
87
|
})
|
|
123
88
|
.finally(() => {
|
|
124
|
-
|
|
89
|
+
prefetchCache.deleteInFlight(cacheKey)
|
|
125
90
|
})
|
|
126
91
|
|
|
127
|
-
|
|
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
|
-
|
|
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 =
|
|
337
|
+
const method = options.method ?? 'get'
|
|
369
338
|
const data = options.data ?? {}
|
|
370
339
|
|
|
371
340
|
options.onStart?.()
|
|
372
341
|
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
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:
|
|
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?:
|
|
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
|
|
527
|
+
const [url, data] = mergeDataIntoQueryString(method, href || '', payload, queryStringArrayFormat)
|
|
562
528
|
|
|
563
529
|
// Check for cached prefetch response (#146)
|
|
564
|
-
const cachedResponse =
|
|
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(
|
|
534
|
+
pushFromResponseData(cachedData, config, onClose, onAfterLeave)
|
|
568
535
|
.then((modal) => {
|
|
569
|
-
updateBrowserUrl(
|
|
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':
|
|
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
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
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(
|
|
571
|
+
pushFromResponseData(responseData, config, onClose, onAfterLeave)
|
|
603
572
|
.then((modal) => {
|
|
604
|
-
updateBrowserUrl(
|
|
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
|
-
|
|
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
|
-
|
|
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
|