@aminnairi/react-router 2.0.1 → 3.0.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/index.tsx CHANGED
@@ -1,381 +1,527 @@
1
- /* eslint-disable */
2
- import { useEffect, useState, FunctionComponent, useMemo, Component, PropsWithChildren, createContext, SetStateAction, Dispatch, ReactNode, useContext, useCallback, memo, MouseEvent } from "react";
3
-
4
- export type AbsolutePath<Path extends string> =
5
- Path extends `${infer Start}:${string}/${infer Rest}`
6
- ? `${Start}${string}/${AbsolutePath<Rest>}`
7
- : Path extends `${infer Start}:${string}`
8
- ? `${Start}${string}`
9
- : Path;
10
-
11
- export type Parameters<Path extends string> =
12
- Path extends `${string}/:${infer Segment}/${infer Rest}`
13
- ? { [K in Segment]: string } & Parameters<`/${Rest}`>
14
- : Path extends `${string}/:${infer Segment}`
15
- ? { [K in Segment]: string }
16
- : object;
17
-
18
- export type GoToPageFunction<Path extends string> = (path: AbsolutePath<Path>) => void;
19
-
20
- export interface PageComponentProps<Path extends string> {
21
- parameters: Parameters<Path>,
22
- }
1
+ import { Component, createContext, type Dispatch, type FunctionComponent, type ReactNode, type SetStateAction, useCallback, useContext, useEffect, useEffectEvent, useMemo, useState } from "react";
23
2
 
24
- export interface Page<Path extends string> {
25
- path: Path,
26
- element: FunctionComponent<PageComponentProps<Path>>
3
+ export interface IssueProps {
4
+ error: Error | null,
5
+ resetError: () => void
27
6
  }
28
7
 
29
- export type Transition = (direction: NavigationDirection, next: () => void) => void;
30
-
31
- export interface CreateRouterOptions<Path extends string> {
32
- transition?: Transition,
33
- prefix?: string,
34
- pages: Array<Page<Path>>
8
+ export interface Router<Path extends string, Locale> {
9
+ prefix?: string
10
+ locales?: Locale[]
11
+ transition?: Transition
35
12
  fallback: FunctionComponent
36
13
  issue: FunctionComponent<IssueProps>
14
+ pages: Array<Page<Path>>
37
15
  }
38
16
 
39
- export interface FindPageOptions {
40
- pages: Array<Page<string>>
41
- path: string
17
+ export interface RouterProviderProps {
18
+ children: ReactNode
42
19
  }
43
20
 
44
- export const sanitizePath = (path: string): string => {
45
- const sanitizedPath = path.replace(/\/+/g, "/").replace(/^\/|\/$/g, "")
46
- return "/" + sanitizedPath;
21
+ export interface RouterContextInterface<Locale> {
22
+ locale: Locale | null
23
+ prefix: string | null
24
+ path: string
25
+ setLocale: Dispatch<SetStateAction<Locale | null>>
47
26
  }
48
27
 
28
+ export type NavigationDirection = "forward" | "backward";
49
29
 
50
- export const createPage = <Path extends string>(page: Page<Path>) => {
51
- return page
30
+ function normalize(uri: string) {
31
+ return uri
32
+ .trim()
33
+ .toLowerCase()
34
+ .replace(/\/+/g, "/")
35
+ .replace(/^\/+|\/$/g, "");
52
36
  }
53
37
 
54
- export const doesRouteMatchPath = (path: string, route: string, prefix?: string): boolean => {
55
- const pathParts = sanitizePath(`${prefix ?? ""}/${path}`).split("/").filter(Boolean);
56
- const routeParts = sanitizePath(route).split("/").filter(Boolean);
38
+ function matchPath(path: string, pathname: string) {
39
+ const pathParts = normalize(path).split("/").filter(Boolean);
40
+ const pathnameParts = normalize(pathname).split("/").filter(Boolean);
57
41
 
58
- return (
59
- pathParts.length === routeParts.length &&
60
- pathParts.every((part, index) => part.startsWith(":") || part === routeParts[index])
61
- );
42
+ return pathParts.length === pathnameParts.length && pathParts.every((pathPart, index) => {
43
+ return pathPart.startsWith(":") || pathPart === pathnameParts.at(index);
44
+ });
62
45
  }
63
46
 
64
- export const getParameters = <Path extends string>(path: Path, route: string, prefix?: string): Parameters<Path> => {
65
- if (!doesRouteMatchPath(path, route, prefix)) {
66
- return {} as Parameters<Path>;
67
- }
68
-
69
- const pathParts = sanitizePath(`${prefix ?? ""}/${path}`).split("/").filter(Boolean);
70
- const routeParts = sanitizePath(route).split("/").filter(Boolean);
47
+ function matchParameters(path: string, pathname: string): Record<string, string> {
48
+ const pathParts = normalize(path).split("/").filter(Boolean);
49
+ const pathnameParts = normalize(pathname).split("/").filter(Boolean);
71
50
 
72
- return pathParts.reduce((parameters, pathPart, pathPartIndex) => {
73
- const routePart = routeParts[pathPartIndex];
74
-
75
- if (!routePart) {
76
- return parameters;
77
- }
51
+ if (pathParts.length !== pathnameParts.length) {
52
+ return {};
53
+ }
78
54
 
55
+ return pathParts.reduce((parameters, pathPart, index) => {
79
56
  if (!pathPart.startsWith(":")) {
80
57
  return parameters;
81
58
  }
82
59
 
83
60
  return {
84
61
  ...parameters,
85
- [`${pathPart.slice(1)}`]: routePart
62
+ [pathPart.slice(1)]: pathnameParts.at(index) ?? ""
86
63
  }
87
- }, {} as Parameters<Path>);
64
+ }, {});
88
65
  }
89
66
 
90
- const findPage = (pages: Array<Page<string>>, path: string, prefix?: string) => {
91
- const foundPage = pages.find(route => {
92
- return doesRouteMatchPath(sanitizePath(`${prefix ?? ""}/${route.path}`), sanitizePath(path));
93
- });
67
+ export class Uri<Locale> {
68
+ private constructor(public readonly path: string, public readonly prefix: string | null, public readonly locale: Locale | null) { }
94
69
 
95
- return foundPage;
96
- };
70
+ public static from<Locale>(uri: string, expectedPrefix?: string, expectedLocales?: Locale[]): Uri<Locale> {
71
+ const [prefixOrLocale, localeOrNothing, ...parts] = normalize(uri).split("/");
72
+ const locales = expectedLocales ?? [];
97
73
 
98
- export interface IssueProps {
99
- error: Error,
100
- reset: () => void,
74
+ if (expectedPrefix && prefixOrLocale && prefixOrLocale === normalize(expectedPrefix)) {
75
+ const locale = locales.find(expectedLocale => expectedLocale === localeOrNothing);
76
+
77
+ if (locale) {
78
+ return new Uri<Locale>(
79
+ parts.join("/"),
80
+ prefixOrLocale,
81
+ locale,
82
+ );
83
+ }
84
+
85
+ return new Uri<Locale>(
86
+ [localeOrNothing, ...parts].join("/"),
87
+ prefixOrLocale,
88
+ null
89
+ );
90
+ }
91
+
92
+ const locale = locales.find(expectedLocale => expectedLocale === prefixOrLocale);
93
+
94
+ if (locale) {
95
+ return new Uri<Locale>(
96
+ [localeOrNothing, ...parts].join("/"),
97
+ null,
98
+ locale
99
+ );
100
+ }
101
+
102
+ return new Uri<Locale>(
103
+ [prefixOrLocale, localeOrNothing, ...parts].join("/"),
104
+ null,
105
+ null
106
+ );
107
+ }
101
108
  }
102
109
 
103
- export interface ErrorBoundaryProps {
104
- fallback: FunctionComponent<IssueProps>,
105
- transition?: Transition
110
+ export type ExtractParams<Path extends string> =
111
+ Path extends `${string}:${infer Param}/${infer Rest}`
112
+ ? Param | ExtractParams<Rest>
113
+ : Path extends `${string}:${infer Param}`
114
+ ? Param
115
+ : never
116
+
117
+ export type Params<Path extends string> = {
118
+ [Key in ExtractParams<Path>]: string
106
119
  }
107
120
 
108
- export interface ErrorBoundaryState {
109
- error: Error | null
121
+ export interface PageParams<Path extends string> {
122
+ parameters: Params<Path>
110
123
  }
111
124
 
112
- export class ErrorBoundary extends Component<PropsWithChildren<ErrorBoundaryProps>, ErrorBoundaryState> {
113
- constructor(props: ErrorBoundaryProps) {
125
+ export interface Page<Path extends string> {
126
+ path: Path
127
+ element: FunctionComponent<PageParams<Path>>
128
+ }
129
+
130
+ interface ErrorBoundaryProps {
131
+ children: ReactNode;
132
+ issue: FunctionComponent<IssueProps>;
133
+ }
134
+
135
+ class ErrorBoundary extends Component<ErrorBoundaryProps, IssueProps> {
136
+ public constructor(props: ErrorBoundaryProps) {
114
137
  super(props);
115
138
 
116
- this.state = { error: null };
139
+ this.state = {
140
+ error: null,
141
+ resetError: this.resetError.bind(this)
142
+ };
117
143
  }
118
144
 
119
- public static getDerivedStateFromError(error: unknown) {
120
- const normalizedError = error instanceof Error ? error : new Error(String(error));
145
+ private resetError() {
146
+ this.setState({ error: null });
147
+ }
121
148
 
122
- return { error: normalizedError };
149
+ public static getDerivedStateFromError(error: Error) {
150
+ return {
151
+ error
152
+ };
123
153
  }
124
154
 
125
- public override render() {
126
- const viewTransitionSupported = typeof document.startViewTransition === "function";
155
+ public override componentDidCatch(error: unknown) {
156
+ this.setState({
157
+ error: error instanceof Error ? error : new Error(String(error))
158
+ });
159
+ }
127
160
 
161
+ public override render() {
128
162
  if (this.state.error) {
129
- const reset = () => {
130
- if (this.props.transition && viewTransitionSupported) {
131
- document.startViewTransition(() => {
132
- this.setState({
133
- error: null
134
- });
135
- });
136
-
137
- return;
138
- }
139
-
140
- this.setState({
141
- error: null
142
- });
143
- }
163
+ const Issue = this.props.issue;
144
164
 
145
- return this.props.fallback({
146
- error: this.state.error,
147
- reset
148
- });
165
+ return <Issue error={this.state.error} resetError={this.state.resetError} />;
149
166
  }
150
167
 
151
168
  return this.props.children;
152
169
  }
153
170
  }
154
171
 
155
- export const createIssue = (issue: FunctionComponent<IssueProps>) => {
156
- return issue;
172
+ export function createPage<Path extends string>(page: Page<Path>): Page<Path> {
173
+ return {
174
+ ...page,
175
+ path: normalize(page.path) as Path
176
+ };
157
177
  }
158
178
 
159
- export interface ContextInterface {
160
- prefix: string,
161
- pathname: string,
162
- setPathname: Dispatch<SetStateAction<string>>,
163
- search: URLSearchParams,
164
- setSearch: Dispatch<SetStateAction<URLSearchParams>>,
165
- hash: string,
166
- setHash: Dispatch<SetStateAction<string>>
179
+ export type Transition = (direction: "forward" | "backward", next: () => void) => void;
180
+
181
+ export const scaleFadeTransition: Transition = async (direction: NavigationDirection, next) => {
182
+ try {
183
+ const transition = document.startViewTransition(() => {
184
+ next();
185
+ });
186
+
187
+ await transition.ready;
188
+
189
+ document.documentElement.animate(
190
+ [
191
+ {
192
+ transform: 'scale(1)',
193
+ opacity: 1
194
+ },
195
+ {
196
+ transform: direction === "forward" ? "scale(1.04)" : "scale(0.96)",
197
+ opacity: 0
198
+ }
199
+ ],
200
+ {
201
+ duration: 200,
202
+ easing: "ease-in-out",
203
+ fill: "both",
204
+ pseudoElement: `::view-transition-old(root)`,
205
+ }
206
+ );
207
+
208
+ document.documentElement.animate(
209
+ [
210
+ {
211
+ transform: direction === "forward" ? "scale(0.96)" : "scale(1.04)",
212
+ opacity: 0
213
+ },
214
+ {
215
+ transform: 'scale(1)',
216
+ opacity: 1
217
+ }
218
+ ],
219
+ {
220
+ duration: 200,
221
+ easing: "ease-in-out",
222
+ fill: "both",
223
+ pseudoElement: `::view-transition-new(root)`,
224
+ }
225
+ );
226
+ } catch (error) {
227
+ console.error(error);
228
+ }
167
229
  }
168
230
 
169
- export interface ProviderProps {
170
- children: ReactNode
231
+ export const crossFadeTransition: Transition = async (direction: NavigationDirection, next) => {
232
+ try {
233
+ const transition = document.startViewTransition(() => {
234
+ next();
235
+ });
236
+
237
+ await transition.ready;
238
+
239
+ document.documentElement.animate(
240
+ [
241
+ { opacity: 1 },
242
+ { opacity: 0 }
243
+ ],
244
+ {
245
+ duration: 200,
246
+ easing: "ease-in-out",
247
+ fill: "both",
248
+ pseudoElement: `::view-transition-old(root)`,
249
+ }
250
+ );
251
+
252
+ document.documentElement.animate(
253
+ [
254
+ { opacity: 0 },
255
+ { opacity: 1 }
256
+ ],
257
+ {
258
+ duration: 200,
259
+ easing: "ease-in-out",
260
+ fill: "both",
261
+ pseudoElement: `::view-transition-new(root)`,
262
+ }
263
+ );
264
+ } catch (error) {
265
+ console.error(error);
266
+ }
267
+ }
268
+
269
+ export const slideHorizontalTransition: Transition = async (direction: NavigationDirection, next) => {
270
+ try {
271
+ const transition = document.startViewTransition(() => {
272
+ next();
273
+ });
274
+
275
+ await transition.ready;
276
+
277
+ document.documentElement.animate(
278
+ [
279
+ { transform: 'translateX(0)' },
280
+ { transform: direction === "forward" ? 'translateX(-100%)' : 'translateX(100%)' }
281
+ ],
282
+ {
283
+ duration: 200,
284
+ easing: "ease-in-out",
285
+ fill: "both",
286
+ pseudoElement: `::view-transition-old(root)`,
287
+ }
288
+ );
289
+
290
+ document.documentElement.animate(
291
+ [
292
+ { transform: direction === "forward" ? 'translateX(100%)' : 'translateX(-100%)' },
293
+ { transform: 'translateX(0)' }
294
+ ],
295
+ {
296
+ duration: 200,
297
+ easing: "ease-in-out",
298
+ fill: "both",
299
+ pseudoElement: `::view-transition-new(root)`,
300
+ }
301
+ );
302
+ } catch (error) {
303
+ console.error(error);
304
+ }
171
305
  }
172
306
 
173
- const Context = createContext<ContextInterface>({
174
- prefix: "",
175
- pathname: sanitizePath(window.location.pathname),
176
- setPathname: () => { },
177
- search: new URLSearchParams(),
178
- setSearch: () => { },
179
- hash: window.location.hash,
180
- setHash: () => { }
181
- });
182
-
183
- enum NavigationDirection {
184
- Forward = "pushstate",
185
- Backward = "popstate"
307
+ export const slideVerticalTransition: Transition = async (direction: NavigationDirection, next) => {
308
+ try {
309
+ const transition = document.startViewTransition(() => {
310
+ next();
311
+ });
312
+
313
+ await transition.ready;
314
+
315
+ document.documentElement.animate(
316
+ [
317
+ { transform: 'translateY(0)' },
318
+ { transform: direction === "forward" ? 'translateY(-100%)' : 'translateY(100%)' }
319
+ ],
320
+ {
321
+ duration: 200,
322
+ easing: "ease-in-out",
323
+ fill: "both",
324
+ pseudoElement: `::view-transition-old(root)`,
325
+ }
326
+ );
327
+
328
+ document.documentElement.animate(
329
+ [
330
+ { transform: direction === "forward" ? 'translateY(100%)' : 'translateY(-100%)' },
331
+ { transform: 'translateY(0)' }
332
+ ],
333
+ {
334
+ duration: 200,
335
+ easing: "ease-in-out",
336
+ fill: "both",
337
+ pseudoElement: `::view-transition-new(root)`,
338
+ }
339
+ );
340
+ } catch (error) {
341
+ console.error(error);
342
+ }
186
343
  }
187
344
 
188
- export const useNavigateToPage = <Path extends string>(page: Page<Path>) => {
189
- const { prefix } = useContext(Context);
345
+ export function createRouter<Locale extends string = never, Path extends string = never>({ prefix: expectedPrefix, locales, pages, fallback: Fallback, issue: Issue, transition }: Router<Path, Locale>) {
346
+ const RouterContext = createContext<RouterContextInterface<Locale>>({
347
+ locale: null,
348
+ prefix: null,
349
+ path: "/",
350
+ setLocale: () => { },
351
+ });
352
+
190
353
 
191
- return useCallback((parameters: Parameters<Path>, replace: boolean = false) => {
192
- const initialPath = sanitizePath(`${prefix ?? ""}/${page.path}`);
354
+ function useIsActivePage<P extends Path>(page: Page<P>): boolean {
355
+ const { path } = usePath();
356
+ return matchPath(page.path, path);
357
+ }
193
358
 
194
- const pathWithParameters = Object.entries(parameters).reduce((path, [parameterName, parameterValue]) => {
195
- return path.replace(`:${parameterName}`, parameterValue);
196
- }, initialPath);
359
+ function useLocale() {
360
+ const context = useContext(RouterContext);
197
361
 
198
- if (replace) {
199
- window.history.replaceState(null, pathWithParameters, pathWithParameters);
200
- } else {
201
- window.history.pushState(null, pathWithParameters, pathWithParameters);
362
+ if (!context) {
363
+ throw new Error("component using the useLocale hook has not been wrapped inside RouterProvider.");
202
364
  }
203
365
 
204
- window.dispatchEvent(new CustomEvent(NavigationDirection.Forward));
205
- }, [page, prefix]);
206
- };
366
+ return {
367
+ locale: context.locale,
368
+ setLocale: (locale: Locale) => {
369
+ if (!locales?.includes(locale)) {
370
+ return;
371
+ }
207
372
 
208
- export const useNavigateBack = () => {
209
- return useCallback(() => {
210
- window.dispatchEvent(new CustomEvent(NavigationDirection.Backward));
211
- }, []);
212
- }
373
+ window.history.pushState(null, "", `/${normalize(`${context.prefix ?? ""}/${locale}/${context.path}`)}`);
374
+ window.dispatchEvent(new Event("pushstate"));
375
+ }
376
+ }
377
+ }
213
378
 
214
- export const useIsActivePage = (page: Page<string>) => {
215
- const { pathname, prefix } = useContext(Context);
379
+ function usePrefix() {
380
+ const context = useContext(RouterContext);
216
381
 
217
- return doesRouteMatchPath(sanitizePath(page.path), sanitizePath(pathname), prefix);
218
- };
382
+ if (!context) {
383
+ throw new Error("Component using the usePrefix hook has not been wrapped inside RouterProvider");
384
+ }
219
385
 
220
- export const useSearch = () => {
221
- const { search } = useContext(Context);
386
+ return {
387
+ prefix: context.prefix
388
+ };
389
+ }
222
390
 
223
- return search;
224
- };
391
+ function usePath() {
392
+ const context = useContext(RouterContext);
225
393
 
226
- export const useHash = () => {
227
- const { hash } = useContext(Context);
228
- return hash;
229
- };
394
+ if (!context) {
395
+ throw new Error("Component using the usePath hook has not been wrapped inside RouterProvider");
396
+ }
230
397
 
231
- export const useLink = <Path extends string>(page: Page<Path>) => {
232
- const Link = memo(({ children, parameters }: { children: ReactNode, parameters: Parameters<Path> }) => {
233
- const { prefix } = useContext(Context);
234
- const navigateToPage = useNavigateToPage(page);
398
+ return {
399
+ path: context.path,
400
+ };
401
+ }
235
402
 
236
- const pathWithParameters = useMemo(() => {
237
- return Object.entries(parameters).reduce((previousPath, [parameterName, parameterValue]) => {
238
- return previousPath.replace(`:${parameterName}`, parameterValue);
239
- }, sanitizePath(`${prefix ?? ""}/${page.path}`));
240
- }, [prefix, page, parameters]);
403
+ function useNavigateToPage<P extends Path>(page: Page<P>) {
404
+ const { locale } = useLocale();
405
+ const { prefix } = usePrefix();
241
406
 
242
- const navigate = useCallback((event: MouseEvent) => {
243
- event.preventDefault();
244
- navigateToPage(parameters);
245
- }, [navigateToPage, parameters]);
407
+ return useCallback((...[params]: ExtractParams<P> extends never ? [] : [Params<P>]) => {
408
+ const path = Object.entries(params ?? {}).reduce<string>((oldParams, [name, value]) => {
409
+ return oldParams.replace(`:${name}`, String(value));
410
+ }, page.path);
246
411
 
247
- return (
248
- <a
249
- href={pathWithParameters}
250
- onClick={navigate}>
251
- {children}
252
- </a>
253
- );
412
+ const pathname = `/${normalize(`${prefix ?? ""}/${locale ?? ""}/${path}`)}`
254
413
 
255
- });
414
+ console.log({ pathname });
256
415
 
257
- return Link;
258
- };
416
+ window.history.pushState(null, "", pathname);
417
+ window.dispatchEvent(new Event("pushstate"));
418
+ }, [page, locale, prefix]);
419
+ }
259
420
 
260
- export const slideFadeTransition: Transition = async (direction: NavigationDirection, next) => {
261
- const transition = document.startViewTransition(() => {
262
- next();
263
- });
421
+ function RouterProvider({ children }: RouterProviderProps) {
422
+ const uri = useMemo(() => Uri.from(window.location.pathname, expectedPrefix, locales), [expectedPrefix, locales]);
423
+ const [locale, setLocale] = useState(uri.locale);
424
+ const [path, setPath] = useState(uri.path);
425
+ const [prefix, setPrefix] = useState(uri.prefix);
264
426
 
265
- await transition.ready;
266
-
267
- document.documentElement.animate(
268
- [
269
- { transform: 'translateX(0)', opacity: 1 },
270
- { transform: `translateX(${direction === NavigationDirection.Forward ? '100%' : '-100%'})`, opacity: 0 }
271
- ],
272
- {
273
- duration: 250,
274
- easing: "ease-in-out",
275
- fill: "both",
276
- pseudoElement: "::view-transition-old(root)",
277
- }
278
- );
279
-
280
- document.documentElement.animate(
281
- [
282
- { transform: `translateX(${direction === NavigationDirection.Forward ? '-100%' : '100%'})`, opacity: 0 },
283
- { transform: 'translateX(0)', opacity: 1 }
284
- ],
285
- {
286
- duration: 250,
287
- easing: "ease-in-out",
288
- fill: "both",
289
- pseudoElement: "::view-transition-new(root)",
427
+ function setHash(newHash: string) {
428
+ window.location.hash = newHash;
290
429
  }
291
- );
292
- }
293
-
294
- export const createRouter = <Path extends string>({ pages, fallback, transition, issue, prefix }: CreateRouterOptions<Path>) => {
295
- const Provider = ({ children }: ProviderProps) => {
296
- const [pathname, setPathname] = useState(sanitizePath(window.location.pathname));
297
- const [search, setSearch] = useState(new URLSearchParams(sanitizePath(window.location.search)));
298
- const [hash, setHash] = useState(window.location.hash);
299
430
 
300
431
  const value = useMemo(() => {
301
432
  return {
302
- prefix: prefix ?? "",
303
- pathname,
304
- search,
305
- hash,
306
- setPathname,
307
- setSearch,
308
- setHash
433
+ locale,
434
+ path,
435
+ prefix: prefix ?? null,
436
+ setLocale,
437
+ setHash,
309
438
  };
310
- }, [prefix, pathname, search, hash]);
439
+ }, [locale, prefix, path]);
311
440
 
312
- useEffect(() => {
313
- const onNavigation = async (direction: NavigationDirection) => {
314
- if (transition) {
315
- transition(direction, () => {
316
- setPathname(sanitizePath(window.location.pathname));
317
- });
441
+ const onNavigation = useEffectEvent((direction: NavigationDirection) => {
442
+ const pathname = normalize(window.location.pathname);
443
+ const uri = Uri.from(pathname, expectedPrefix, locales);
318
444
 
319
- return;
320
- }
445
+ const defaultTransition: Transition = (_, next) => {
446
+ next();
447
+ }
448
+
449
+ const currentTransition: Transition = transition ?? defaultTransition;
450
+
451
+ currentTransition(direction, () => {
452
+ setLocale(uri.locale);
453
+ setPath(uri.path);
454
+ setPrefix(uri.prefix);
455
+ });
456
+ });
457
+
458
+ const onMount = useEffectEvent(() => {
459
+ const pathname = `/${normalize(window.location.pathname)}`;
460
+ const uri = Uri.from(pathname, expectedPrefix, [locale, ...locales ?? []]);
461
+ const newPathname = `/${normalize(`${uri.prefix ?? expectedPrefix ?? ""}/${uri.locale ?? locales?.at(0) ?? ""}/${uri.path}`)}`;
321
462
 
322
- setPathname(sanitizePath(window.location.pathname));
463
+ if (newPathname !== pathname) {
464
+ window.history.pushState(null, "", newPathname);
465
+ window.dispatchEvent(new Event("pushstate"));
323
466
  }
467
+ });
324
468
 
325
- const onNavigationForward = () => {
326
- onNavigation(NavigationDirection.Forward);
327
- };
469
+ useEffect(() => {
470
+ const abortController = new AbortController();
328
471
 
329
- const onNavigationBackward = () => {
330
- onNavigation(NavigationDirection.Backward);
331
- };
472
+ window.addEventListener("pushstate", () => {
473
+ onNavigation("forward");
474
+ }, abortController);
475
+
476
+ window.addEventListener("popstate", () => {
477
+ onNavigation("backward");
478
+ }, abortController);
332
479
 
333
- window.addEventListener(NavigationDirection.Forward, onNavigationForward);
334
- window.addEventListener(NavigationDirection.Backward, onNavigationBackward);
480
+ onMount();
335
481
 
336
482
  return () => {
337
- window.removeEventListener(NavigationDirection.Forward, onNavigationForward);
338
- window.removeEventListener(NavigationDirection.Backward, onNavigationBackward);
339
- }
483
+ abortController.abort();
484
+ };
340
485
  }, []);
341
486
 
342
487
  return (
343
- <Context.Provider value={value}>
344
- <ErrorBoundary fallback={issue}>
345
- {children}
346
- </ErrorBoundary>
347
- </Context.Provider>
488
+ <RouterContext.Provider value={value}>
489
+ {children}
490
+ </RouterContext.Provider>
348
491
  );
349
- };
350
-
351
- const View = () => {
352
- const Fallback = useMemo(() => fallback, []);
353
- const { pathname } = useContext(Context);
354
- const page = useMemo(() => findPage(pages, pathname, prefix), [pathname]);
492
+ }
355
493
 
356
- const parameters = useMemo(() => {
357
- if (page) {
358
- return getParameters(sanitizePath(page.path), sanitizePath(window.location.pathname), prefix);
359
- }
494
+ function RouterView() {
495
+ const { path } = usePath();
360
496
 
361
- return {};
362
- }, [page]);
497
+ const foundPage = useMemo(() => {
498
+ return pages.find(page => {
499
+ return matchPath(page.path, path);
500
+ });
501
+ }, [path, pages]);
363
502
 
364
- if (page) {
503
+ if (foundPage) {
365
504
  return (
366
- <div >
367
- <page.element parameters={parameters} />
368
- </div>
505
+ <ErrorBoundary issue={Issue}>
506
+ <foundPage.element parameters={matchParameters(foundPage.path, path) as Params<Path>} />
507
+ </ErrorBoundary>
369
508
  );
370
509
  }
371
510
 
372
511
  return (
373
- <Fallback />
512
+ <ErrorBoundary issue={Issue}>
513
+ <Fallback />
514
+ </ErrorBoundary>
374
515
  );
375
- };
516
+ }
376
517
 
377
518
  return {
378
- View,
379
- Provider,
519
+ RouterProvider,
520
+ RouterView,
521
+ useLocale,
522
+ usePrefix,
523
+ usePath,
524
+ useNavigateToPage,
525
+ useIsActivePage,
380
526
  };
381
527
  }