@octanejs/remix-router 0.1.0
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/LICENSE +21 -0
- package/README.md +90 -0
- package/package.json +53 -0
- package/src/dom.ts +16 -0
- package/src/index.ts +267 -0
- package/src/internal.ts +37 -0
- package/src/lib/Await.tsrx +145 -0
- package/src/lib/Await.tsrx.d.ts +6 -0
- package/src/lib/DefaultErrorComponent.tsrx +47 -0
- package/src/lib/DefaultErrorComponent.tsrx.d.ts +2 -0
- package/src/lib/RenderErrorBoundary.tsrx +117 -0
- package/src/lib/RenderErrorBoundary.tsrx.d.ts +14 -0
- package/src/lib/actions.ts +90 -0
- package/src/lib/components/MemoryRouter.tsrx +42 -0
- package/src/lib/components/MemoryRouter.tsrx.d.ts +10 -0
- package/src/lib/components/Navigate.ts +60 -0
- package/src/lib/components/Router.tsrx +88 -0
- package/src/lib/components/Router.tsrx.d.ts +13 -0
- package/src/lib/components/RouterProvider.tsrx +320 -0
- package/src/lib/components/RouterProvider.tsrx.d.ts +21 -0
- package/src/lib/components/Routes.tsrx +53 -0
- package/src/lib/components/Routes.tsrx.d.ts +7 -0
- package/src/lib/components/routes-collector.ts +317 -0
- package/src/lib/components/utils.ts +171 -0
- package/src/lib/components/with-props.ts +72 -0
- package/src/lib/context.ts +129 -0
- package/src/lib/dom/Form.tsrx +76 -0
- package/src/lib/dom/Form.tsrx.d.ts +22 -0
- package/src/lib/dom/Link.tsrx +91 -0
- package/src/lib/dom/Link.tsrx.d.ts +22 -0
- package/src/lib/dom/NavLink.tsrx +118 -0
- package/src/lib/dom/NavLink.tsrx.d.ts +33 -0
- package/src/lib/dom/dom.ts +344 -0
- package/src/lib/dom/hooks.ts +93 -0
- package/src/lib/dom/lib.ts +822 -0
- package/src/lib/dom/routers.tsrx +104 -0
- package/src/lib/dom/routers.tsrx.d.ts +23 -0
- package/src/lib/dom/server.tsrx +350 -0
- package/src/lib/dom/server.tsrx.d.ts +25 -0
- package/src/lib/errors.ts +84 -0
- package/src/lib/framework-stubs.ts +103 -0
- package/src/lib/hooks.ts +1797 -0
- package/src/lib/href.ts +71 -0
- package/src/lib/react-types.ts +10 -0
- package/src/lib/router/history.ts +763 -0
- package/src/lib/router/instrumentation.ts +496 -0
- package/src/lib/router/links.ts +192 -0
- package/src/lib/router/router.ts +7165 -0
- package/src/lib/router/server-runtime-types.ts +12 -0
- package/src/lib/router/url.ts +8 -0
- package/src/lib/router/utils.ts +2179 -0
- package/src/lib/server-runtime/cookies.ts +240 -0
- package/src/lib/server-runtime/crypto.ts +55 -0
- package/src/lib/server-runtime/mode.ts +16 -0
- package/src/lib/server-runtime/sessions/cookieStorage.ts +54 -0
- package/src/lib/server-runtime/sessions/memoryStorage.ts +59 -0
- package/src/lib/server-runtime/sessions.ts +291 -0
- package/src/lib/server-runtime/warnings.ts +10 -0
- package/src/lib/types/future.ts +16 -0
- package/src/lib/types/params.ts +8 -0
- package/src/lib/types/register.ts +39 -0
- package/src/lib/types/route-module.ts +17 -0
- package/src/lib/types/utils.ts +37 -0
|
@@ -0,0 +1,822 @@
|
|
|
1
|
+
// DOM-mode entry points + hooks — transcribed from react-router@7.18.1
|
|
2
|
+
// lib/dom/lib.tsx onto octane: createBrowserRouter / createHashRouter (with
|
|
3
|
+
// upstream's __staticRouterHydrationData parsing + error deserialization),
|
|
4
|
+
// useSearchParams, and useViewTransitionState (needed internally by NavLink;
|
|
5
|
+
// its public export lands in Phase E with the rest of the view-transition
|
|
6
|
+
// surface).
|
|
7
|
+
import {
|
|
8
|
+
createElement,
|
|
9
|
+
useCallback,
|
|
10
|
+
useContext,
|
|
11
|
+
useEffect,
|
|
12
|
+
useId,
|
|
13
|
+
useLayoutEffect,
|
|
14
|
+
useMemo,
|
|
15
|
+
useRef,
|
|
16
|
+
useState,
|
|
17
|
+
} from 'octane';
|
|
18
|
+
import {
|
|
19
|
+
DataRouterContext,
|
|
20
|
+
DataRouterStateContext,
|
|
21
|
+
FetchersContext,
|
|
22
|
+
NavigationContext,
|
|
23
|
+
RouteContext,
|
|
24
|
+
ViewTransitionContext,
|
|
25
|
+
} from '../context';
|
|
26
|
+
import type { Location, To } from '../router/history';
|
|
27
|
+
import {
|
|
28
|
+
createBrowserHistory,
|
|
29
|
+
createHashHistory,
|
|
30
|
+
createPath,
|
|
31
|
+
invariant,
|
|
32
|
+
warning,
|
|
33
|
+
} from '../router/history';
|
|
34
|
+
import type { HydrationState, Router as DataRouter } from '../router/router';
|
|
35
|
+
import { IDLE_FETCHER, createRouter } from '../router/router';
|
|
36
|
+
import type { RelativeRoutingType } from '../router/router';
|
|
37
|
+
import type { RouteObject } from '../router/utils';
|
|
38
|
+
import {
|
|
39
|
+
ErrorResponseImpl,
|
|
40
|
+
SUPPORTED_ERROR_TYPES,
|
|
41
|
+
joinPaths,
|
|
42
|
+
matchPath,
|
|
43
|
+
stripBasename,
|
|
44
|
+
} from '../router/utils';
|
|
45
|
+
import { hydrationRouteProperties, mapRouteProperties } from '../components/utils';
|
|
46
|
+
import {
|
|
47
|
+
useBlocker,
|
|
48
|
+
useLocation,
|
|
49
|
+
useMatches,
|
|
50
|
+
useNavigate,
|
|
51
|
+
useNavigation,
|
|
52
|
+
useResolvedPath,
|
|
53
|
+
useRouteId,
|
|
54
|
+
} from '../hooks';
|
|
55
|
+
import type { BlockerFunction } from '../router/router';
|
|
56
|
+
import type { URLSearchParamsInit } from './dom';
|
|
57
|
+
import { createSearchParams, getFormSubmissionInfo, getSearchParamsForLocation } from './dom';
|
|
58
|
+
import { Form } from './Form.tsrx';
|
|
59
|
+
import { splitSlot, subSlot } from '../../internal';
|
|
60
|
+
|
|
61
|
+
export interface DOMRouterOpts {
|
|
62
|
+
basename?: string;
|
|
63
|
+
getContext?: any;
|
|
64
|
+
future?: any;
|
|
65
|
+
hydrationData?: HydrationState;
|
|
66
|
+
dataStrategy?: any;
|
|
67
|
+
patchRoutesOnNavigation?: any;
|
|
68
|
+
instrumentations?: any;
|
|
69
|
+
window?: Window;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Create a new data router that manages the application path via
|
|
74
|
+
* `history.pushState` / `history.replaceState`.
|
|
75
|
+
*/
|
|
76
|
+
export function createBrowserRouter(routes: RouteObject[], opts?: DOMRouterOpts): DataRouter {
|
|
77
|
+
return createRouter({
|
|
78
|
+
basename: opts?.basename,
|
|
79
|
+
getContext: opts?.getContext,
|
|
80
|
+
future: opts?.future,
|
|
81
|
+
history: createBrowserHistory({ window: opts?.window }),
|
|
82
|
+
hydrationData: opts?.hydrationData || parseHydrationData(),
|
|
83
|
+
routes,
|
|
84
|
+
mapRouteProperties,
|
|
85
|
+
hydrationRouteProperties,
|
|
86
|
+
dataStrategy: opts?.dataStrategy,
|
|
87
|
+
patchRoutesOnNavigation: opts?.patchRoutesOnNavigation,
|
|
88
|
+
window: opts?.window,
|
|
89
|
+
instrumentations: opts?.instrumentations,
|
|
90
|
+
}).initialize();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Create a new data router that manages the application path via the URL
|
|
95
|
+
* hash.
|
|
96
|
+
*/
|
|
97
|
+
export function createHashRouter(routes: RouteObject[], opts?: DOMRouterOpts): DataRouter {
|
|
98
|
+
return createRouter({
|
|
99
|
+
basename: opts?.basename,
|
|
100
|
+
getContext: opts?.getContext,
|
|
101
|
+
future: opts?.future,
|
|
102
|
+
history: createHashHistory({ window: opts?.window }),
|
|
103
|
+
hydrationData: opts?.hydrationData || parseHydrationData(),
|
|
104
|
+
routes,
|
|
105
|
+
mapRouteProperties,
|
|
106
|
+
hydrationRouteProperties,
|
|
107
|
+
dataStrategy: opts?.dataStrategy,
|
|
108
|
+
patchRoutesOnNavigation: opts?.patchRoutesOnNavigation,
|
|
109
|
+
window: opts?.window,
|
|
110
|
+
instrumentations: opts?.instrumentations,
|
|
111
|
+
}).initialize();
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
declare global {
|
|
115
|
+
interface Window {
|
|
116
|
+
__staticRouterHydrationData?: HydrationState;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function parseHydrationData(): HydrationState | undefined {
|
|
121
|
+
let state = typeof window !== 'undefined' ? window.__staticRouterHydrationData : undefined;
|
|
122
|
+
if (state && state.errors) {
|
|
123
|
+
state = {
|
|
124
|
+
...state,
|
|
125
|
+
errors: deserializeErrors(state.errors),
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
return state;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function deserializeErrors(errors: DataRouter['state']['errors']): DataRouter['state']['errors'] {
|
|
132
|
+
if (!errors) return null;
|
|
133
|
+
let entries = Object.entries(errors);
|
|
134
|
+
let serialized: DataRouter['state']['errors'] = {};
|
|
135
|
+
for (let [key, val] of entries) {
|
|
136
|
+
// Hey you! If you change this, please change the corresponding logic in
|
|
137
|
+
// serializeErrors in react-router-dom/server.tsx :)
|
|
138
|
+
if (val && val.__type === 'RouteErrorResponse') {
|
|
139
|
+
serialized[key] = new ErrorResponseImpl(
|
|
140
|
+
val.status,
|
|
141
|
+
val.statusText,
|
|
142
|
+
val.data,
|
|
143
|
+
val.internal === true,
|
|
144
|
+
);
|
|
145
|
+
} else if (val && val.__type === 'Error') {
|
|
146
|
+
// Attempt to reconstruct the right type of Error (i.e., ReferenceError)
|
|
147
|
+
if (typeof val.__subType === 'string' && SUPPORTED_ERROR_TYPES.includes(val.__subType)) {
|
|
148
|
+
let ErrorConstructor = (window as any)[val.__subType];
|
|
149
|
+
if (typeof ErrorConstructor === 'function') {
|
|
150
|
+
try {
|
|
151
|
+
let error = new ErrorConstructor(val.message);
|
|
152
|
+
// Wipe away the client-side stack trace. Nothing to fill it in with
|
|
153
|
+
// because we don't serialize SSR stack traces for security reasons
|
|
154
|
+
error.stack = '';
|
|
155
|
+
serialized[key] = error;
|
|
156
|
+
} catch (e) {
|
|
157
|
+
// no-op - fall through and create a normal Error
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (serialized[key] == null) {
|
|
163
|
+
let error = new Error(val.message);
|
|
164
|
+
// Wipe away the client-side stack trace. Nothing to fill it in with
|
|
165
|
+
// because we don't serialize SSR stack traces for security reasons
|
|
166
|
+
error.stack = '';
|
|
167
|
+
serialized[key] = error;
|
|
168
|
+
}
|
|
169
|
+
} else {
|
|
170
|
+
serialized[key] = val;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return serialized;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export type SetURLSearchParams = (
|
|
177
|
+
nextInit?: URLSearchParamsInit | ((prev: URLSearchParams) => URLSearchParamsInit),
|
|
178
|
+
navigateOpts?: any,
|
|
179
|
+
) => void;
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Returns a tuple of the current URL's `URLSearchParams` and a function to
|
|
183
|
+
* update them. Setting the search params causes a navigation.
|
|
184
|
+
*/
|
|
185
|
+
export function useSearchParams(...args: any[]): [URLSearchParams, SetURLSearchParams] {
|
|
186
|
+
const [user, slot] = splitSlot(args);
|
|
187
|
+
const defaultInit = user[0] as URLSearchParamsInit | undefined;
|
|
188
|
+
|
|
189
|
+
warning(
|
|
190
|
+
typeof URLSearchParams !== 'undefined',
|
|
191
|
+
`You cannot use the \`useSearchParams\` hook in a browser that does not ` +
|
|
192
|
+
`support the URLSearchParams API. If you need to support Internet ` +
|
|
193
|
+
`Explorer 11, we recommend you load a polyfill such as ` +
|
|
194
|
+
`https://github.com/ungap/url-search-params.`,
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
let defaultSearchParamsRef = useRef(
|
|
198
|
+
null as URLSearchParams | null,
|
|
199
|
+
subSlot(slot, 'usp:default') as any,
|
|
200
|
+
);
|
|
201
|
+
if (defaultSearchParamsRef.current === null) {
|
|
202
|
+
defaultSearchParamsRef.current = createSearchParams(defaultInit);
|
|
203
|
+
}
|
|
204
|
+
let hasSetSearchParamsRef = useRef(false, subSlot(slot, 'usp:hasSet') as any);
|
|
205
|
+
|
|
206
|
+
let location = useLocation();
|
|
207
|
+
let searchParams = useMemo(
|
|
208
|
+
() =>
|
|
209
|
+
// Only merge in the defaults if we haven't yet called setSearchParams.
|
|
210
|
+
// Once we call that we want those to take precedence, otherwise you can't
|
|
211
|
+
// remove a param with setSearchParams({}) if it has an initial value
|
|
212
|
+
getSearchParamsForLocation(
|
|
213
|
+
location.search,
|
|
214
|
+
hasSetSearchParamsRef.current ? null : defaultSearchParamsRef.current,
|
|
215
|
+
),
|
|
216
|
+
[location.search],
|
|
217
|
+
subSlot(slot, 'usp:memo') as any,
|
|
218
|
+
);
|
|
219
|
+
|
|
220
|
+
let navigate = useNavigate(subSlot(slot, 'usp:nav')) as (to: To, opts?: any) => void;
|
|
221
|
+
let setSearchParams = useCallback(
|
|
222
|
+
((nextInit, navigateOptions) => {
|
|
223
|
+
const newSearchParams = createSearchParams(
|
|
224
|
+
typeof nextInit === 'function' ? nextInit(new URLSearchParams(searchParams)) : nextInit,
|
|
225
|
+
);
|
|
226
|
+
hasSetSearchParamsRef.current = true;
|
|
227
|
+
navigate('?' + newSearchParams, navigateOptions);
|
|
228
|
+
}) as SetURLSearchParams,
|
|
229
|
+
[navigate, searchParams],
|
|
230
|
+
subSlot(slot, 'usp:cb') as any,
|
|
231
|
+
);
|
|
232
|
+
|
|
233
|
+
return [searchParams, setSearchParams];
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Returns `true` when there is an active view transition to the given
|
|
238
|
+
* location. NOTE: the public export lands in Phase E; NavLink consumes this
|
|
239
|
+
* internally for its `isTransitioning` render prop (always `false` while
|
|
240
|
+
* RouterProvider's view-transition paths are dormant).
|
|
241
|
+
*/
|
|
242
|
+
export function useViewTransitionState(to: To, ...args: any[]): boolean {
|
|
243
|
+
const [user, slot] = splitSlot(args);
|
|
244
|
+
const { relative } = (user[0] ?? {}) as { relative?: RelativeRoutingType };
|
|
245
|
+
let vtContext = useContext(ViewTransitionContext);
|
|
246
|
+
|
|
247
|
+
invariant(
|
|
248
|
+
vtContext != null,
|
|
249
|
+
"`useViewTransitionState` must be used within `react-router-dom`'s `RouterProvider`. " +
|
|
250
|
+
'Did you accidentally import `RouterProvider` from `react-router`?',
|
|
251
|
+
);
|
|
252
|
+
|
|
253
|
+
let ctx = useContext(DataRouterContext);
|
|
254
|
+
invariant(ctx, 'useViewTransitionState must be used within a data router.');
|
|
255
|
+
let { basename } = ctx;
|
|
256
|
+
let path = useResolvedPath(to, { relative }, subSlot(slot, 'uvts:path')) as {
|
|
257
|
+
pathname: string;
|
|
258
|
+
};
|
|
259
|
+
if (!vtContext.isTransitioning) {
|
|
260
|
+
return false;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
let currentPath =
|
|
264
|
+
stripBasename(vtContext.currentLocation.pathname, basename) ||
|
|
265
|
+
vtContext.currentLocation.pathname;
|
|
266
|
+
let nextPath =
|
|
267
|
+
stripBasename(vtContext.nextLocation.pathname, basename) || vtContext.nextLocation.pathname;
|
|
268
|
+
|
|
269
|
+
// Transition is active if we're going to or coming from the indicated
|
|
270
|
+
// destination. This ensures that other PUSH navigations that reverse
|
|
271
|
+
// an indicated transition apply.
|
|
272
|
+
return (
|
|
273
|
+
matchPath(path.pathname, nextPath) != null || matchPath(path.pathname, currentPath) != null
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// ── Phase D — mutations ─────────────────────────────────────────────────────
|
|
278
|
+
// useSubmit / useFormAction / useFetcher / useFetchers — transcribed from
|
|
279
|
+
// react-router@7.18.1 lib/dom/lib.tsx. The dom-side data-router guards mirror
|
|
280
|
+
// upstream's local DataRouterHook enum + console errors.
|
|
281
|
+
|
|
282
|
+
enum DataRouterHook {
|
|
283
|
+
UseScrollRestoration = 'useScrollRestoration',
|
|
284
|
+
UseSubmit = 'useSubmit',
|
|
285
|
+
UseFetcher = 'useFetcher',
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
enum DataRouterStateHook {
|
|
289
|
+
UseFetcher = 'useFetcher',
|
|
290
|
+
UseFetchers = 'useFetchers',
|
|
291
|
+
UseScrollRestoration = 'useScrollRestoration',
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function getDataRouterConsoleError(hookName: DataRouterHook | DataRouterStateHook) {
|
|
295
|
+
return `${hookName} must be used within a data router. See https://reactrouter.com/en/main/routers/picking-a-router.`;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function useDataRouterContext(hookName: DataRouterHook) {
|
|
299
|
+
let ctx = useContext(DataRouterContext);
|
|
300
|
+
invariant(ctx, getDataRouterConsoleError(hookName));
|
|
301
|
+
return ctx;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function useDataRouterState(hookName: DataRouterStateHook) {
|
|
305
|
+
let state = useContext(DataRouterStateContext);
|
|
306
|
+
invariant(state, getDataRouterConsoleError(hookName));
|
|
307
|
+
return state;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
let fetcherId = 0;
|
|
311
|
+
const getUniqueFetcherId = () => `__${String(++fetcherId)}__`;
|
|
312
|
+
|
|
313
|
+
export type SubmitFunction = (target: any, options?: any) => Promise<void>;
|
|
314
|
+
export type FetcherSubmitFunction = (target: any, options?: any) => Promise<void>;
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* The imperative version of `<Form>` — submits a form (element, `FormData`,
|
|
318
|
+
* plain object, or JSON body) to a route action or loader.
|
|
319
|
+
*/
|
|
320
|
+
export function useSubmit(...args: any[]): SubmitFunction {
|
|
321
|
+
const [, slot] = splitSlot(args);
|
|
322
|
+
let { router } = useDataRouterContext(DataRouterHook.UseSubmit);
|
|
323
|
+
let { basename } = useContext(NavigationContext);
|
|
324
|
+
let currentRouteId = useRouteId();
|
|
325
|
+
|
|
326
|
+
let routerFetch = router.fetch;
|
|
327
|
+
let routerNavigate = router.navigate;
|
|
328
|
+
|
|
329
|
+
return useCallback(
|
|
330
|
+
(async (target: any, options: any = {}) => {
|
|
331
|
+
let { action, method, encType, formData, body } = getFormSubmissionInfo(target, basename);
|
|
332
|
+
|
|
333
|
+
if (options.navigate === false) {
|
|
334
|
+
let key = options.fetcherKey || getUniqueFetcherId();
|
|
335
|
+
await routerFetch(key, currentRouteId!, options.action || action, {
|
|
336
|
+
defaultShouldRevalidate: options.defaultShouldRevalidate,
|
|
337
|
+
preventScrollReset: options.preventScrollReset,
|
|
338
|
+
formData,
|
|
339
|
+
body,
|
|
340
|
+
formMethod: options.method || method,
|
|
341
|
+
formEncType: options.encType || encType,
|
|
342
|
+
flushSync: options.flushSync,
|
|
343
|
+
});
|
|
344
|
+
} else {
|
|
345
|
+
await routerNavigate(options.action || action, {
|
|
346
|
+
defaultShouldRevalidate: options.defaultShouldRevalidate,
|
|
347
|
+
preventScrollReset: options.preventScrollReset,
|
|
348
|
+
formData,
|
|
349
|
+
body,
|
|
350
|
+
formMethod: options.method || method,
|
|
351
|
+
formEncType: options.encType || encType,
|
|
352
|
+
replace: options.replace,
|
|
353
|
+
state: options.state,
|
|
354
|
+
fromRouteId: currentRouteId,
|
|
355
|
+
flushSync: options.flushSync,
|
|
356
|
+
viewTransition: options.viewTransition,
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
}) as SubmitFunction,
|
|
360
|
+
[routerFetch, routerNavigate, basename, currentRouteId],
|
|
361
|
+
subSlot(slot, 'us:cb') as any,
|
|
362
|
+
);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Resolves the URL to the closest route in the component hierarchy instead of
|
|
367
|
+
* the current URL of the app. Used internally by `<Form>` to resolve the
|
|
368
|
+
* `action` to the closest route.
|
|
369
|
+
*/
|
|
370
|
+
export function useFormAction(...args: any[]): string {
|
|
371
|
+
const [user, slot] = splitSlot(args);
|
|
372
|
+
const action = user[0] as string | undefined;
|
|
373
|
+
const { relative } = (user[1] ?? {}) as { relative?: RelativeRoutingType };
|
|
374
|
+
|
|
375
|
+
let { basename } = useContext(NavigationContext);
|
|
376
|
+
let routeContext = useContext(RouteContext);
|
|
377
|
+
invariant(routeContext, 'useFormAction must be used inside a RouteContext');
|
|
378
|
+
|
|
379
|
+
let [match] = routeContext.matches.slice(-1);
|
|
380
|
+
// Shallow clone path so we can modify it below, otherwise we modify the
|
|
381
|
+
// object referenced by useMemo inside useResolvedPath
|
|
382
|
+
let path = {
|
|
383
|
+
...(useResolvedPath(action ? action : '.', { relative }, subSlot(slot, 'ufa:path')) as any),
|
|
384
|
+
};
|
|
385
|
+
|
|
386
|
+
// If no action was specified, browsers will persist current search params
|
|
387
|
+
// when determining the path, so match that behavior
|
|
388
|
+
// https://github.com/remix-run/remix/issues/927
|
|
389
|
+
let location = useLocation();
|
|
390
|
+
if (action == null) {
|
|
391
|
+
// Safe to write to this directly here since if action was undefined, we
|
|
392
|
+
// would have called useResolvedPath(".") which will never include a search
|
|
393
|
+
path.search = location.search;
|
|
394
|
+
|
|
395
|
+
// When grabbing search params from the URL, remove any included ?index param
|
|
396
|
+
// since it might not apply to our contextual route. We add it back based
|
|
397
|
+
// on match.route.index below
|
|
398
|
+
let params = new URLSearchParams(path.search);
|
|
399
|
+
let indexValues = params.getAll('index');
|
|
400
|
+
let hasNakedIndexParam = indexValues.some((v) => v === '');
|
|
401
|
+
if (hasNakedIndexParam) {
|
|
402
|
+
params.delete('index');
|
|
403
|
+
indexValues.filter((v) => v).forEach((v) => params.append('index', v));
|
|
404
|
+
let qs = params.toString();
|
|
405
|
+
path.search = qs ? `?${qs}` : '';
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
if ((!action || action === '.') && match.route.index) {
|
|
410
|
+
path.search = path.search ? path.search.replace(/^\?/, '?index&') : '?index';
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// If we're operating within a basename, prepend it to the pathname prior
|
|
414
|
+
// to creating the form action. If this is a root navigation, then just use
|
|
415
|
+
// the raw basename which allows the basename to have full control over the
|
|
416
|
+
// presence of a trailing slash on root actions
|
|
417
|
+
if (basename !== '/') {
|
|
418
|
+
path.pathname = path.pathname === '/' ? basename : joinPaths([basename, path.pathname]);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
return createPath(path);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
export type FetcherWithComponents<TData = any> = any;
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* A hook for interacting with route loaders/actions WITHOUT navigating —
|
|
428
|
+
* returns a fetcher with `Form`/`submit`/`load`/`reset` plus the live fetcher
|
|
429
|
+
* state/data.
|
|
430
|
+
*/
|
|
431
|
+
export function useFetcher(...args: any[]): FetcherWithComponents {
|
|
432
|
+
const [user, slot] = splitSlot(args);
|
|
433
|
+
const { key } = (user[0] ?? {}) as { key?: string };
|
|
434
|
+
|
|
435
|
+
let { router } = useDataRouterContext(DataRouterHook.UseFetcher);
|
|
436
|
+
let state = useDataRouterState(DataRouterStateHook.UseFetcher);
|
|
437
|
+
let fetcherData = useContext(FetchersContext);
|
|
438
|
+
let route = useContext(RouteContext);
|
|
439
|
+
let routeId = route.matches[route.matches.length - 1]?.route.id;
|
|
440
|
+
|
|
441
|
+
invariant(fetcherData, `useFetcher must be used inside a FetchersContext`);
|
|
442
|
+
invariant(route, `useFetcher must be used inside a RouteContext`);
|
|
443
|
+
invariant(routeId != null, `useFetcher can only be used on routes that contain a unique "id"`);
|
|
444
|
+
|
|
445
|
+
// Fetcher key handling
|
|
446
|
+
let defaultKey = useId(subSlot(slot, 'uf:id') as any);
|
|
447
|
+
let [fetcherKey, setFetcherKey] = useState(key || defaultKey, subSlot(slot, 'uf:key') as any);
|
|
448
|
+
if (key && key !== fetcherKey) {
|
|
449
|
+
setFetcherKey(key);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
let { deleteFetcher, getFetcher, resetFetcher, fetch: routerFetch } = router;
|
|
453
|
+
|
|
454
|
+
// Registration/cleanup
|
|
455
|
+
useEffect(
|
|
456
|
+
() => {
|
|
457
|
+
getFetcher(fetcherKey);
|
|
458
|
+
return () => deleteFetcher(fetcherKey);
|
|
459
|
+
},
|
|
460
|
+
[deleteFetcher, getFetcher, fetcherKey],
|
|
461
|
+
subSlot(slot, 'uf:reg') as any,
|
|
462
|
+
);
|
|
463
|
+
|
|
464
|
+
// Fetcher additions
|
|
465
|
+
let load = useCallback(
|
|
466
|
+
async (href: string, opts?: { flushSync?: boolean }) => {
|
|
467
|
+
invariant(routeId, 'No routeId available for fetcher.load()');
|
|
468
|
+
await routerFetch(fetcherKey, routeId, href, opts as any);
|
|
469
|
+
},
|
|
470
|
+
[fetcherKey, routeId, routerFetch],
|
|
471
|
+
subSlot(slot, 'uf:load') as any,
|
|
472
|
+
);
|
|
473
|
+
|
|
474
|
+
let submitImpl = useSubmit(subSlot(slot, 'uf:submitImpl'));
|
|
475
|
+
let submit = useCallback(
|
|
476
|
+
(async (target: any, opts: any) => {
|
|
477
|
+
await submitImpl(target, {
|
|
478
|
+
...opts,
|
|
479
|
+
navigate: false,
|
|
480
|
+
fetcherKey,
|
|
481
|
+
});
|
|
482
|
+
}) as FetcherSubmitFunction,
|
|
483
|
+
[fetcherKey, submitImpl],
|
|
484
|
+
subSlot(slot, 'uf:submit') as any,
|
|
485
|
+
);
|
|
486
|
+
|
|
487
|
+
let reset = useCallback(
|
|
488
|
+
(opts?: { reason?: unknown }) => resetFetcher(fetcherKey, opts),
|
|
489
|
+
[resetFetcher, fetcherKey],
|
|
490
|
+
subSlot(slot, 'uf:reset') as any,
|
|
491
|
+
);
|
|
492
|
+
|
|
493
|
+
// Bound `<fetcher.Form>` — a plain octane component closing over the key
|
|
494
|
+
// (upstream memoizes a forwardRef; octane refs are props and flow through
|
|
495
|
+
// the spread).
|
|
496
|
+
let FetcherForm = useMemo(
|
|
497
|
+
() => {
|
|
498
|
+
return function FetcherForm(props: any) {
|
|
499
|
+
return createElement(Form as any, { ...props, navigate: false, fetcherKey });
|
|
500
|
+
};
|
|
501
|
+
},
|
|
502
|
+
[fetcherKey],
|
|
503
|
+
subSlot(slot, 'uf:form') as any,
|
|
504
|
+
);
|
|
505
|
+
|
|
506
|
+
// Exposed FetcherWithComponents
|
|
507
|
+
let fetcher = state.fetchers.get(fetcherKey) || IDLE_FETCHER;
|
|
508
|
+
let data = fetcherData.get(fetcherKey);
|
|
509
|
+
let fetcherWithComponents = useMemo(
|
|
510
|
+
() => ({
|
|
511
|
+
Form: FetcherForm,
|
|
512
|
+
submit,
|
|
513
|
+
load,
|
|
514
|
+
reset,
|
|
515
|
+
...fetcher,
|
|
516
|
+
data,
|
|
517
|
+
}),
|
|
518
|
+
[FetcherForm, submit, load, reset, fetcher, data],
|
|
519
|
+
subSlot(slot, 'uf:combined') as any,
|
|
520
|
+
);
|
|
521
|
+
|
|
522
|
+
return fetcherWithComponents;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* Returns an array of all in-flight fetchers (each with its unique `key`) —
|
|
527
|
+
* useful for optimistic UI over submissions made elsewhere in the app.
|
|
528
|
+
*/
|
|
529
|
+
export function useFetchers(...args: any[]): any[] {
|
|
530
|
+
const [, slot] = splitSlot(args);
|
|
531
|
+
let state = useDataRouterState(DataRouterStateHook.UseFetchers);
|
|
532
|
+
return useMemo(
|
|
533
|
+
() =>
|
|
534
|
+
Array.from(state.fetchers.entries()).map(([key, fetcher]) => ({
|
|
535
|
+
...fetcher,
|
|
536
|
+
key,
|
|
537
|
+
})),
|
|
538
|
+
[state.fetchers],
|
|
539
|
+
subSlot(slot, 'ufs:memo') as any,
|
|
540
|
+
);
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// ── Phase E — guards / scroll ───────────────────────────────────────────────
|
|
544
|
+
|
|
545
|
+
export type GetScrollRestorationKeyFunction = (location: Location, matches: any[]) => string | null;
|
|
546
|
+
|
|
547
|
+
const SCROLL_RESTORATION_STORAGE_KEY = 'react-router-scroll-positions';
|
|
548
|
+
let savedScrollPositions: Record<string, number> = {};
|
|
549
|
+
|
|
550
|
+
function getScrollRestorationKey(
|
|
551
|
+
location: Location,
|
|
552
|
+
matches: any[],
|
|
553
|
+
basename: string,
|
|
554
|
+
getKey?: GetScrollRestorationKeyFunction,
|
|
555
|
+
) {
|
|
556
|
+
let key: string | null = null;
|
|
557
|
+
if (getKey) {
|
|
558
|
+
if (basename !== '/') {
|
|
559
|
+
key = getKey(
|
|
560
|
+
{
|
|
561
|
+
...location,
|
|
562
|
+
pathname: stripBasename(location.pathname, basename) || location.pathname,
|
|
563
|
+
},
|
|
564
|
+
matches,
|
|
565
|
+
);
|
|
566
|
+
} else {
|
|
567
|
+
key = getKey(location, matches);
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
if (key == null) {
|
|
571
|
+
key = location.key;
|
|
572
|
+
}
|
|
573
|
+
return key;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/**
|
|
577
|
+
* When rendered inside a RouterProvider, will restore scroll positions on
|
|
578
|
+
* navigations.
|
|
579
|
+
*/
|
|
580
|
+
export function useScrollRestoration(...args: any[]): void {
|
|
581
|
+
const [user, slot] = splitSlot(args);
|
|
582
|
+
const { getKey, storageKey } = (user[0] ?? {}) as {
|
|
583
|
+
getKey?: GetScrollRestorationKeyFunction;
|
|
584
|
+
storageKey?: string;
|
|
585
|
+
};
|
|
586
|
+
let { router } = useDataRouterContext(DataRouterHook.UseScrollRestoration);
|
|
587
|
+
let { restoreScrollPosition, preventScrollReset } = useDataRouterState(
|
|
588
|
+
DataRouterStateHook.UseScrollRestoration,
|
|
589
|
+
);
|
|
590
|
+
let { basename } = useContext(NavigationContext);
|
|
591
|
+
let location = useLocation();
|
|
592
|
+
let matches = useMatches();
|
|
593
|
+
let navigation = useNavigation();
|
|
594
|
+
|
|
595
|
+
// Trigger manual scroll restoration while we're active
|
|
596
|
+
useEffect(
|
|
597
|
+
() => {
|
|
598
|
+
window.history.scrollRestoration = 'manual';
|
|
599
|
+
return () => {
|
|
600
|
+
window.history.scrollRestoration = 'auto';
|
|
601
|
+
};
|
|
602
|
+
},
|
|
603
|
+
[],
|
|
604
|
+
subSlot(slot, 'usr:manual') as any,
|
|
605
|
+
);
|
|
606
|
+
|
|
607
|
+
// Save positions on pagehide
|
|
608
|
+
usePageHide(
|
|
609
|
+
useCallback(
|
|
610
|
+
() => {
|
|
611
|
+
if (navigation.state === 'idle') {
|
|
612
|
+
let key = getScrollRestorationKey(location, matches, basename, getKey);
|
|
613
|
+
savedScrollPositions[key] = window.scrollY;
|
|
614
|
+
}
|
|
615
|
+
try {
|
|
616
|
+
sessionStorage.setItem(
|
|
617
|
+
storageKey || SCROLL_RESTORATION_STORAGE_KEY,
|
|
618
|
+
JSON.stringify(savedScrollPositions),
|
|
619
|
+
);
|
|
620
|
+
} catch (error) {
|
|
621
|
+
warning(
|
|
622
|
+
false,
|
|
623
|
+
`Failed to save scroll positions in sessionStorage, <ScrollRestoration /> will not work properly (${error}).`,
|
|
624
|
+
);
|
|
625
|
+
}
|
|
626
|
+
window.history.scrollRestoration = 'auto';
|
|
627
|
+
},
|
|
628
|
+
[navigation.state, getKey, basename, location, matches, storageKey],
|
|
629
|
+
subSlot(slot, 'usr:saveCb') as any,
|
|
630
|
+
),
|
|
631
|
+
undefined,
|
|
632
|
+
subSlot(slot, 'usr:pagehide'),
|
|
633
|
+
);
|
|
634
|
+
|
|
635
|
+
// Read in any saved scroll locations
|
|
636
|
+
if (typeof document !== 'undefined') {
|
|
637
|
+
useLayoutEffect(
|
|
638
|
+
() => {
|
|
639
|
+
try {
|
|
640
|
+
let sessionPositions = sessionStorage.getItem(
|
|
641
|
+
storageKey || SCROLL_RESTORATION_STORAGE_KEY,
|
|
642
|
+
);
|
|
643
|
+
if (sessionPositions) {
|
|
644
|
+
savedScrollPositions = JSON.parse(sessionPositions);
|
|
645
|
+
}
|
|
646
|
+
} catch (e) {
|
|
647
|
+
// no-op, use default empty object
|
|
648
|
+
}
|
|
649
|
+
},
|
|
650
|
+
[storageKey],
|
|
651
|
+
subSlot(slot, 'usr:read') as any,
|
|
652
|
+
);
|
|
653
|
+
|
|
654
|
+
// Enable scroll restoration in the router
|
|
655
|
+
useLayoutEffect(
|
|
656
|
+
() => {
|
|
657
|
+
let disableScrollRestoration = router?.enableScrollRestoration(
|
|
658
|
+
savedScrollPositions,
|
|
659
|
+
() => window.scrollY,
|
|
660
|
+
getKey
|
|
661
|
+
? (location, matches) => getScrollRestorationKey(location, matches, basename, getKey)
|
|
662
|
+
: undefined,
|
|
663
|
+
);
|
|
664
|
+
return () => disableScrollRestoration && disableScrollRestoration();
|
|
665
|
+
},
|
|
666
|
+
[router, basename, getKey],
|
|
667
|
+
subSlot(slot, 'usr:enable') as any,
|
|
668
|
+
);
|
|
669
|
+
|
|
670
|
+
// Restore scrolling when state.restoreScrollPosition changes
|
|
671
|
+
useLayoutEffect(
|
|
672
|
+
() => {
|
|
673
|
+
// Explicit false means don't do anything (used for submissions or revalidations)
|
|
674
|
+
if (restoreScrollPosition === false) {
|
|
675
|
+
return;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
// been here before, scroll to it
|
|
679
|
+
if (typeof restoreScrollPosition === 'number') {
|
|
680
|
+
window.scrollTo(0, restoreScrollPosition);
|
|
681
|
+
return;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
// try to scroll to the hash
|
|
685
|
+
try {
|
|
686
|
+
if (location.hash) {
|
|
687
|
+
let el = document.getElementById(decodeURIComponent(location.hash.slice(1)));
|
|
688
|
+
if (el) {
|
|
689
|
+
el.scrollIntoView();
|
|
690
|
+
return;
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
} catch {
|
|
694
|
+
warning(
|
|
695
|
+
false,
|
|
696
|
+
`"${location.hash.slice(1)}" is not a decodable element ID. The view will not scroll to it.`,
|
|
697
|
+
);
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
// Don't reset if this navigation opted out
|
|
701
|
+
if (preventScrollReset === true) {
|
|
702
|
+
return;
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
// otherwise go to the top on new locations
|
|
706
|
+
window.scrollTo(0, 0);
|
|
707
|
+
},
|
|
708
|
+
[location, restoreScrollPosition, preventScrollReset],
|
|
709
|
+
subSlot(slot, 'usr:restore') as any,
|
|
710
|
+
);
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
/**
|
|
715
|
+
* Emulates the browser's scroll restoration on location changes. In data mode
|
|
716
|
+
* (this port's scope) upstream renders nothing — the SSR inline-script branch
|
|
717
|
+
* needs a FrameworkContext (framework mode, out of scope), so the component
|
|
718
|
+
* is the hook + `null`.
|
|
719
|
+
*/
|
|
720
|
+
export function ScrollRestoration(props: {
|
|
721
|
+
getKey?: GetScrollRestorationKeyFunction;
|
|
722
|
+
storageKey?: string;
|
|
723
|
+
}): null {
|
|
724
|
+
// Plain-.ts component: hand-passed stable slot (state is keyed per
|
|
725
|
+
// component-instance scope).
|
|
726
|
+
useScrollRestoration(
|
|
727
|
+
{ getKey: props.getKey, storageKey: props.storageKey },
|
|
728
|
+
Symbol.for('rr:scroll-restoration') as any,
|
|
729
|
+
);
|
|
730
|
+
return null;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
/**
|
|
734
|
+
* Set up a callback to be fired on Window's `beforeunload` event.
|
|
735
|
+
*/
|
|
736
|
+
export function useBeforeUnload(callback: (event: BeforeUnloadEvent) => any, ...args: any[]): void {
|
|
737
|
+
const [user, slot] = splitSlot(args);
|
|
738
|
+
const { capture } = (user[0] ?? {}) as { capture?: boolean };
|
|
739
|
+
useEffect(
|
|
740
|
+
() => {
|
|
741
|
+
let opts = capture != null ? { capture } : undefined;
|
|
742
|
+
window.addEventListener('beforeunload', callback, opts);
|
|
743
|
+
return () => {
|
|
744
|
+
window.removeEventListener('beforeunload', callback, opts);
|
|
745
|
+
};
|
|
746
|
+
},
|
|
747
|
+
[callback, capture],
|
|
748
|
+
subSlot(slot, 'ubu:eff') as any,
|
|
749
|
+
);
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
/*
|
|
753
|
+
* Setup a callback to be fired on the window's `pagehide` event. This is
|
|
754
|
+
* useful for saving some data to `window.localStorage` just before the page
|
|
755
|
+
* refreshes. This event is better supported than beforeunload across browsers.
|
|
756
|
+
*
|
|
757
|
+
* Note: The `callback` argument should be a function created with
|
|
758
|
+
* `useCallback()`.
|
|
759
|
+
*/
|
|
760
|
+
function usePageHide(
|
|
761
|
+
callback: (event: PageTransitionEvent) => any,
|
|
762
|
+
options?: { capture?: boolean },
|
|
763
|
+
slot?: symbol,
|
|
764
|
+
): void {
|
|
765
|
+
let { capture } = options || {};
|
|
766
|
+
useEffect(
|
|
767
|
+
() => {
|
|
768
|
+
let opts = capture != null ? { capture } : undefined;
|
|
769
|
+
window.addEventListener('pagehide', callback, opts);
|
|
770
|
+
return () => {
|
|
771
|
+
window.removeEventListener('pagehide', callback, opts);
|
|
772
|
+
};
|
|
773
|
+
},
|
|
774
|
+
[callback, capture],
|
|
775
|
+
subSlot(slot, 'uph:eff') as any,
|
|
776
|
+
);
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
/**
|
|
780
|
+
* Wrapper around useBlocker to show a `window.confirm` prompt to users
|
|
781
|
+
* instead of building a custom UI with useBlocker. Exported as
|
|
782
|
+
* `unstable_usePrompt`, as upstream (the flag will not be removed — the
|
|
783
|
+
* technique has rough edges across browsers).
|
|
784
|
+
*/
|
|
785
|
+
export function usePrompt(
|
|
786
|
+
{ when, message }: { when: boolean | BlockerFunction; message: string },
|
|
787
|
+
...args: any[]
|
|
788
|
+
): void {
|
|
789
|
+
const [, slot] = splitSlot(args);
|
|
790
|
+
let blocker = useBlocker(when, subSlot(slot, 'up:blocker'));
|
|
791
|
+
|
|
792
|
+
useEffect(
|
|
793
|
+
() => {
|
|
794
|
+
if (blocker.state === 'blocked') {
|
|
795
|
+
let proceed = window.confirm(message);
|
|
796
|
+
if (proceed) {
|
|
797
|
+
// This timeout is needed to avoid a weird "race" on POP navigations
|
|
798
|
+
// between the `window.history` revert navigation and the result of
|
|
799
|
+
// `window.confirm`
|
|
800
|
+
setTimeout(blocker.proceed, 0);
|
|
801
|
+
} else {
|
|
802
|
+
blocker.reset();
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
},
|
|
806
|
+
[blocker, message],
|
|
807
|
+
subSlot(slot, 'up:confirm') as any,
|
|
808
|
+
);
|
|
809
|
+
|
|
810
|
+
useEffect(
|
|
811
|
+
() => {
|
|
812
|
+
if (blocker.state === 'blocked' && !when) {
|
|
813
|
+
blocker.reset();
|
|
814
|
+
}
|
|
815
|
+
},
|
|
816
|
+
[blocker, when],
|
|
817
|
+
subSlot(slot, 'up:reset') as any,
|
|
818
|
+
);
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
// Keep Location imported for the docs types above.
|
|
822
|
+
export type { Location };
|