@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.
Files changed (63) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +90 -0
  3. package/package.json +53 -0
  4. package/src/dom.ts +16 -0
  5. package/src/index.ts +267 -0
  6. package/src/internal.ts +37 -0
  7. package/src/lib/Await.tsrx +145 -0
  8. package/src/lib/Await.tsrx.d.ts +6 -0
  9. package/src/lib/DefaultErrorComponent.tsrx +47 -0
  10. package/src/lib/DefaultErrorComponent.tsrx.d.ts +2 -0
  11. package/src/lib/RenderErrorBoundary.tsrx +117 -0
  12. package/src/lib/RenderErrorBoundary.tsrx.d.ts +14 -0
  13. package/src/lib/actions.ts +90 -0
  14. package/src/lib/components/MemoryRouter.tsrx +42 -0
  15. package/src/lib/components/MemoryRouter.tsrx.d.ts +10 -0
  16. package/src/lib/components/Navigate.ts +60 -0
  17. package/src/lib/components/Router.tsrx +88 -0
  18. package/src/lib/components/Router.tsrx.d.ts +13 -0
  19. package/src/lib/components/RouterProvider.tsrx +320 -0
  20. package/src/lib/components/RouterProvider.tsrx.d.ts +21 -0
  21. package/src/lib/components/Routes.tsrx +53 -0
  22. package/src/lib/components/Routes.tsrx.d.ts +7 -0
  23. package/src/lib/components/routes-collector.ts +317 -0
  24. package/src/lib/components/utils.ts +171 -0
  25. package/src/lib/components/with-props.ts +72 -0
  26. package/src/lib/context.ts +129 -0
  27. package/src/lib/dom/Form.tsrx +76 -0
  28. package/src/lib/dom/Form.tsrx.d.ts +22 -0
  29. package/src/lib/dom/Link.tsrx +91 -0
  30. package/src/lib/dom/Link.tsrx.d.ts +22 -0
  31. package/src/lib/dom/NavLink.tsrx +118 -0
  32. package/src/lib/dom/NavLink.tsrx.d.ts +33 -0
  33. package/src/lib/dom/dom.ts +344 -0
  34. package/src/lib/dom/hooks.ts +93 -0
  35. package/src/lib/dom/lib.ts +822 -0
  36. package/src/lib/dom/routers.tsrx +104 -0
  37. package/src/lib/dom/routers.tsrx.d.ts +23 -0
  38. package/src/lib/dom/server.tsrx +350 -0
  39. package/src/lib/dom/server.tsrx.d.ts +25 -0
  40. package/src/lib/errors.ts +84 -0
  41. package/src/lib/framework-stubs.ts +103 -0
  42. package/src/lib/hooks.ts +1797 -0
  43. package/src/lib/href.ts +71 -0
  44. package/src/lib/react-types.ts +10 -0
  45. package/src/lib/router/history.ts +763 -0
  46. package/src/lib/router/instrumentation.ts +496 -0
  47. package/src/lib/router/links.ts +192 -0
  48. package/src/lib/router/router.ts +7165 -0
  49. package/src/lib/router/server-runtime-types.ts +12 -0
  50. package/src/lib/router/url.ts +8 -0
  51. package/src/lib/router/utils.ts +2179 -0
  52. package/src/lib/server-runtime/cookies.ts +240 -0
  53. package/src/lib/server-runtime/crypto.ts +55 -0
  54. package/src/lib/server-runtime/mode.ts +16 -0
  55. package/src/lib/server-runtime/sessions/cookieStorage.ts +54 -0
  56. package/src/lib/server-runtime/sessions/memoryStorage.ts +59 -0
  57. package/src/lib/server-runtime/sessions.ts +291 -0
  58. package/src/lib/server-runtime/warnings.ts +10 -0
  59. package/src/lib/types/future.ts +16 -0
  60. package/src/lib/types/params.ts +8 -0
  61. package/src/lib/types/register.ts +39 -0
  62. package/src/lib/types/route-module.ts +17 -0
  63. 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 };