}
82 |
--------------------------------------------------------------------------------
/pkgs/froute/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | ### 1.0.8
2 |
3 | - [#155](https://github.com/fleur-js/froute/pull/155) Fix failed to route resolution on Chromium on Windows
4 |
5 | ### 1.0.6, 1.0.7
6 |
7 | `v1.0.6` is failed publish, `v1.0.7` is valid package for `v1.0.6`
8 |
9 | - [#154](https://github.com/fleur-js/froute/pull/154) Fix Invalid URL in Chromium browsers
10 |
11 | ### 1.0.5
12 |
13 | - [#152](https://github.com/fleur-js/froute/pull/152) [dev] Replace bili to vite
14 | - [#153](https://github.com/fleur-js/froute/pull/153) [dev] Replace `url` and `querystring` deps to WHATWG URL API
15 |
16 | ### 1.0.3
17 |
18 | - [#137](https://github.com/fleur-js/froute/pull/137) Support typing for React 18
19 |
20 | ### 1.0.2
21 |
22 | - [#](https://github.com/fleur-js/froute/pull/) `target` attribute is no longer ignored on Link component
23 |
24 | ### 1.0.1
25 |
26 | - [#32](https://github.com/fleur-js/froute/pull/32) Fix broken query fragment in `Link#href`
27 |
28 | ### 1.0.0
29 |
30 | #### Breaking changes
31 | - [#4](https://github.com/fleur-js/froute/pull/4) Accept parsed query and search string in your `action.preload`.
32 | ```ts
33 | // Before
34 | routeOf(...).action({
35 | preload: (context, params, query)
36 | })
37 |
38 | // After
39 | routeOf(...).action({
40 | preload: (context, params, /* Changed ⇢ */ { query, search })
41 | })
42 | ```
43 | - [#2](https://github.com/fleur-js/froute/pull/2) `RouteDefinition.match` now returns `null` instead of `false` when route is unmatched
44 |
45 | #### New features
46 |
47 | - [#2](https://github.com/fleur-js/froute/pull/2) Add Next.js partially compat `useRouter` hooks
48 | - [#3](https://github.com/fleur-js/froute/pull/3) Add `useFrouteRouter` hooks, it's superset of `useRouter`
49 | - [#1](https://github.com/fleur-js/froute/pull/1) Add `useBeforeRouteChange` hooks for preventing navigation
50 | - [#2](https://github.com/fleur-js/froute/pull/2) Add `query` and `search` in result of `RouteDefinition.match`.
51 | - [#7](https://github.com/fleur-js/froute/pull/7) Accept plain query string in 3rd argument of `buildPath`
52 | ```ts
53 | buildPath(routeDef, { param: '' }, '**Accept query string here** without `?` prefix')
54 | ```
55 |
56 |
57 | #### Deprecation
58 |
59 | - The following hooks have been deprecated
60 | Use `useFrouteRouter` or `useRouter` instead.
61 | - useLocation
62 | - useNavigation
63 | - useParams
64 | - useUrlBuilder
65 |
66 | ### 0.2.0
67 |
68 | - [#1](https://github.com/fleur-js/froute/pull/1) Add `routeOf` route define method
69 | - It's follows `Template Literal Type`
70 | ```ts
71 | routeOf('/user/:userId')
72 | // infered params to `{ userId: string }`
73 | ```
74 | - [#1](https://github.com/fleur-js/froute/pull/1) Support history.state from `useLocation().state`
75 | - Sorry, I forgot to expose `useHistoryState` hooks in this version... now readonly...
76 |
77 | ### 0.1.0
78 |
79 | - First release of `@fleur/froute`!
80 |
--------------------------------------------------------------------------------
/pkgs/froute/src/index.spec.tsx:
--------------------------------------------------------------------------------
1 | // @ts-ignore TS6133 This is usage example file. ignore unused vars
2 |
3 | import React, { useEffect } from "react";
4 | import { render } from "@testing-library/react";
5 | import {
6 | createRouter,
7 | routeOf,
8 | useRouteComponent,
9 | FrouteLink,
10 | FrouteContext,
11 | RouterOptions,
12 | } from ".";
13 | import { ResponseCode } from "./components/ResponseCode";
14 | import { useRouter } from "./react-bind";
15 |
16 | describe("Usage", () => {
17 | // Mock of external context likes fleur context or redux store
18 | const externalContext = {
19 | foo: async (message: string, word: string) => {
20 | // fetch API
21 | },
22 | };
23 |
24 | type PreloadContext = { store: typeof externalContext };
25 |
26 | // Define routes
27 | const routes = {
28 | usersShow: routeOf("/users/:id").action({
29 | // Expecting dynamic import
30 | component: async () => () => {
31 | return (
32 |
33 | Here is UserShow
34 | {/* 👇 froute's Link automatically use history navigation and fire preload */}
35 |
36 | A
37 |
38 |
39 | );
40 | },
41 |
42 | // Expecting API call
43 | preload: async ({ store }: PreloadContext, params, { query }) =>
44 | Promise.all([
45 | new Promise((resolve) => setTimeout(resolve, 100)),
46 | store.foo(params.id, query.word as string),
47 | ]),
48 | }),
49 | };
50 |
51 | const routerOptions: RouterOptions = {
52 | // Passing Fleur context or Redux store to preload function
53 | preloadContext: { store: externalContext },
54 | };
55 |
56 | it("Routing", async () => {
57 | const reqUrl = "/users/1";
58 | const router = createRouter(routes, routerOptions);
59 |
60 | await router.navigate(reqUrl);
61 | await router.preloadCurrent();
62 | });
63 |
64 | it("Routing", async () => {
65 | const reqUrl = "/users/1";
66 | const router = createRouter(routes, routerOptions);
67 |
68 | await router.navigate(reqUrl);
69 | await router.preloadCurrent();
70 | });
71 |
72 | it("In React", async () => {
73 | const router = createRouter(routes, routerOptions);
74 | await router.navigate("/users/1");
75 | await router.preloadCurrent();
76 |
77 | const App = () => {
78 | // Get preloaded Page component
79 | const { PageComponent } = useRouteComponent();
80 | const router = useRouter();
81 |
82 | useEffect(() => {
83 | router.events.on("routeChangeStart", () => {
84 | // Do it something (likes nprogress)
85 | });
86 | });
87 |
88 | return (
89 |
90 | {PageComponent ?
:
}
91 |
92 | );
93 | };
94 |
95 | const result = render(
96 |
97 |
98 |
99 | );
100 |
101 | expect(result.container.innerHTML).toMatchInlineSnapshot(
102 | `""`
103 | );
104 | });
105 |
106 | it("Building URL", async () => {
107 | const router = createRouter(routes, routerOptions);
108 | router.buildPath(routes.usersShow, { id: "1" });
109 | });
110 | });
111 |
--------------------------------------------------------------------------------
/pkgs/froute/src/routing.ts:
--------------------------------------------------------------------------------
1 | import { MatchResult } from "path-to-regexp";
2 | import { RouterContext } from "./RouterContext";
3 | import { parseUrl } from "./utils";
4 | import { RouteDefinition } from "./RouteDefiner";
5 |
6 | export interface FrouteMatch {
7 | route: RouteDefinition
;
8 | match: FrouteMatchResult
;
9 | }
10 |
11 | export type FrouteMatchResult
= MatchResult<{
12 | [K in P]: string;
13 | }> & {
14 | query: ParsedQuery;
15 | search: string;
16 | };
17 |
18 | export type ParsedQuery = { [K: string]: string | string[] | undefined };
19 |
20 | export interface RoutingOnlyRouterContext {
21 | statusCode: number;
22 | redirectTo: string | null;
23 | resolveRoute: RouterContext["resolveRoute"];
24 | }
25 |
26 | export interface RouteResolver {
27 | (
28 | pathname: string,
29 | match: FrouteMatch | null,
30 | context: RoutingOnlyRouterContext
31 | ): FrouteMatch | null;
32 | }
33 |
34 | type RoutesObject = {
35 | [key: string]: RouteDefinition;
36 | };
37 |
38 | const createRoutingOnlyContext = (
39 | context: RouterContext | RoutingOnlyRouterContext | undefined,
40 | routes: RoutesObject
41 | ): RoutingOnlyRouterContext => {
42 | if (context) {
43 | const ctx: RoutingOnlyRouterContext = {
44 | get statusCode() {
45 | return context!.statusCode;
46 | },
47 | set statusCode(status: number) {
48 | context!.statusCode = status;
49 | },
50 | get redirectTo() {
51 | return context!.redirectTo;
52 | },
53 | set redirectTo(url: string | null) {
54 | context!.redirectTo = url;
55 | },
56 | resolveRoute: (pathname) =>
57 | matchByRoutes(pathname, routes, { context: ctx }),
58 | };
59 |
60 | return ctx;
61 | }
62 |
63 | const stabCtx: RoutingOnlyRouterContext = {
64 | statusCode: 200,
65 | redirectTo: null,
66 | resolveRoute: (pathname) =>
67 | matchByRoutes(pathname, routes, {
68 | /* skip resolver: for guard from infinite loop */
69 | context: stabCtx,
70 | }),
71 | };
72 |
73 | return stabCtx;
74 | };
75 |
76 | export const matchByRoutes = (
77 | pathname: string,
78 | routes: RoutesObject,
79 | {
80 | resolver,
81 | context,
82 | }: {
83 | resolver?: RouteResolver;
84 | context?: RoutingOnlyRouterContext;
85 | } = {}
86 | ): FrouteMatch | null => {
87 | const usingContext = createRoutingOnlyContext(context, routes);
88 |
89 | const parsed = parseUrl(pathname);
90 | const afterHostUrl =
91 | (parsed.pathname ?? "") + (parsed.search ?? "") + (parsed.hash ?? "");
92 |
93 | let matched: FrouteMatch | null = null;
94 |
95 | for (const route of Object.values(routes)) {
96 | const match = route.match(afterHostUrl);
97 | if (!match) continue;
98 |
99 | matched = { route, match };
100 | break;
101 | }
102 |
103 | if (resolver) {
104 | return resolver(afterHostUrl, matched, {
105 | get redirectTo() {
106 | return usingContext.redirectTo;
107 | },
108 | set redirectTo(url: string | null) {
109 | usingContext.redirectTo = url;
110 | },
111 | get statusCode() {
112 | return usingContext.statusCode;
113 | },
114 | set statusCode(code: number) {
115 | usingContext.statusCode = code;
116 | },
117 | resolveRoute: usingContext.resolveRoute,
118 | });
119 | }
120 |
121 | return matched;
122 | };
123 |
124 | export const isMatchToRoute = (
125 | pathname: string,
126 | route: RouteDefinition,
127 | {
128 | resolver,
129 | context,
130 | }: { resolver?: RouteResolver; context?: RouterContext } = {}
131 | ) => {
132 | const usingContext = createRoutingOnlyContext(context, { route });
133 |
134 | const parsed = parseUrl(pathname);
135 | const afterHostUrl =
136 | (parsed.pathname ?? "") + (parsed.search ?? "") + (parsed.hash ?? "");
137 |
138 | let matched: FrouteMatch | null = null;
139 |
140 | const match = route.match(afterHostUrl);
141 |
142 | matched = match ? { route, match } : null;
143 |
144 | if (resolver) {
145 | return resolver(afterHostUrl, matched, usingContext);
146 | }
147 |
148 | return matched;
149 | };
150 |
--------------------------------------------------------------------------------
/pkgs/froute/src/RouteDefiner.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable */
2 |
3 | import { ComponentType } from "react";
4 | import { match } from "path-to-regexp";
5 | import { StateBase } from "./FrouteHistoryState";
6 | import { FrouteMatchResult } from "./routing";
7 | import { type DeepReadonly, parseUrl, parseQueryString } from "./utils";
8 |
9 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment
10 | export interface RouteDefinition {
11 | match(pathname: string): FrouteMatchResult | null;
12 | toPath(): string;
13 | createState(): S | null;
14 | getActor(): Actor | null;
15 | }
16 |
17 | export interface ActorDef> {
18 | component: () =>
19 | | Promise<{ default: ComponentType } | ComponentType>
20 | | ComponentType;
21 | preload?: (
22 | context: any,
23 | params: ParamsOfRoute,
24 | extra: {
25 | query: DeepReadonly<{ [K: string]: string | string[] | undefined }>;
26 | search: string;
27 | }
28 | ) => Promise;
29 | [key: string]: any;
30 | }
31 |
32 | type ParamsObject> = {
33 | [K in Extract>>]: string;
34 | } & {
35 | [K in OptionalParamStringToConst<
36 | Extract>
37 | >]?: string;
38 | };
39 |
40 | // prettier-ignore
41 | export type ParamsOfRoute> =
42 | T extends RouteDefiner ? ParamsObject
43 | : T extends Readonly> ? ParamsObject
44 | : T extends RouteDefinition ? ParamsObject
45 | : never;
46 |
47 | export type StateOfRoute> =
48 | R extends RouteDefinition ? S : never;
49 |
50 | type OptionalParam = S & { __OPTIONAL: true };
51 | type OptionalParamStringToConst> =
52 | P extends OptionalParam ? K : never;
53 |
54 | type ParamFragment = T extends `:${infer R}?`
55 | ? OptionalParam
56 | : T extends `:${infer R}`
57 | ? R
58 | : never;
59 | type ParamsInPath = string extends S
60 | ? string
61 | : S extends `${infer R}/${infer Rest}`
62 | ? ParamFragment | ParamsInPath
63 | : ParamFragment;
64 |
65 | /**
66 | * Define route by fragment chain
67 | * @deprecated use `routeOf` instead
68 | */
69 | export const routeBy = (path: string): RouteDefiner> => {
70 | return new RouteDefiner(path);
71 | };
72 |
73 | /**
74 | * Define route by pathname
75 | *
76 | * - `routeOf('/fragment')`
77 | * - `routeOf('/fragment/:paramName')`
78 | * - `routeOf('/fragment/:paramName?')`
79 | */
80 | export const routeOf = (
81 | path: S
82 | ): RouteDefiner> => {
83 | return new RouteDefiner(path);
84 | };
85 |
86 | class Actor> implements ActorDef {
87 | // _cache: ComponentType;
88 | private cache: ComponentType | null = null;
89 |
90 | constructor(
91 | public component: ActorDef["component"],
92 | public preload?: ActorDef["preload"]
93 | ) {}
94 |
95 | public async loadComponent() {
96 | if (this.cache) return this.cache;
97 |
98 | const module = await this.component();
99 | this.cache = (module as any).default ?? module;
100 | return this.cache;
101 | }
102 |
103 | public get cachedComponent() {
104 | return this.cache;
105 | }
106 | }
107 |
108 | export class RouteDefiner<
109 | Params extends string,
110 | State extends StateBase = never
111 | > implements RouteDefinition
112 | {
113 | private stack: string[] = [];
114 | private actor: Actor | null = null;
115 | private stateFactory: (() => State) | null = null;
116 |
117 | constructor(path: string) {
118 | this.stack.push(path.replace(/^\//, ""));
119 | }
120 |
121 | public param(
122 | this: RouteDefiner,
123 | paramName: P
124 | ): RouteDefiner {
125 | this.stack.push(`:${paramName}`);
126 | return this as any;
127 | }
128 |
129 | public path(path: string): RouteDefiner {
130 | this.stack.push(path.replace(/^\//, ""));
131 | return this as any;
132 | }
133 |
134 | public state(
135 | this: RouteDefiner,
136 | stateFactory: () => S
137 | ): RouteDefiner {
138 | this.stateFactory = stateFactory;
139 | return this as any;
140 | }
141 |
142 | public action({
143 | component,
144 | preload,
145 | ...rest
146 | }: ActorDef): Readonly> {
147 | this.actor = new Actor(component, preload);
148 | Object.assign(this.actor, rest);
149 | return this as any;
150 | }
151 |
152 | public match(pathname: string): FrouteMatchResult | null {
153 | const parsed = parseUrl(pathname);
154 | const result = match>(this.toPath(), {
155 | decode: decodeURIComponent,
156 | })(parsed.pathname!);
157 |
158 | return result
159 | ? {
160 | ...result,
161 | query: parseQueryString(parsed.query ?? ""),
162 | search: parsed.search ?? "",
163 | }
164 | : null;
165 | }
166 |
167 | public getActor>(this: R) {
168 | return this.actor;
169 | }
170 |
171 | public createState() {
172 | return this.stateFactory?.() ?? null;
173 | }
174 |
175 | public toPath() {
176 | return "/" + this.stack.join("/");
177 | }
178 | }
179 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | [npm-url]: https://www.npmjs.com/package/@fleur/froute
2 | [ci-image-url]: https://img.shields.io/github/workflow/status/fleur-js/froute/CI?logo=github&style=flat-square
3 | [version-image-url]: https://img.shields.io/npm/v/@fleur/froute?style=flat-square
4 | [license-url]: https://opensource.org/licenses/MIT
5 | [license-image]: https://img.shields.io/npm/l/@fleur/froute.svg?style=flat-square
6 | [downloads-image]: https://img.shields.io/npm/dw/@fleur/froute.svg?style=flat-square&logo=npm
7 | [bundlephobia-url]: https://bundlephobia.com/result?p=@fleur/froute@2.0.1
8 | [bundlephobia-image]: https://img.shields.io/bundlephobia/minzip/@fleur/froute?style=flat-square
9 |
10 | ![CI][ci-image-url] [![latest][version-image-url]][npm-url] [![BundleSize][bundlephobia-image]][bundlephobia-url] [![License][license-image]][license-url] [![npm][downloads-image]][npm-url]
11 |
12 | # Froute
13 |
14 | [CHANGELOG](./pkgs/froute/CHANGELOG.md)
15 |
16 | Framework independent Router for React.
17 | Can use with both Fleur / Redux (redux-thunk).
18 |
19 | With provides Next.js subset `useRouter`
20 |
21 | ```
22 | yarn add @fleur/froute
23 | ```
24 |
25 | - [Features](#features)
26 | - [API Overview](#api-overview)
27 | - [Hooks](#hooks)
28 | - [Components](#components)
29 | - [Example](#example)
30 | - [Next.js compat status](#nextjs-compat-status)
31 | - [How to type-safe useRoute](#how-to-type-safe-useroute)
32 |
33 |
34 | ## Features
35 |
36 | See all examples in [this spec](https://github.com/fleur-js/froute/blob/master/src/index.spec.tsx) or [examples](https://github.com/fleur-js/froute/tree/master/examples)
37 |
38 | - Library independent
39 | - Works with Redux and Fleur
40 | - Next.js's Router subset compatiblity (`useRouter`, `withRouter`)
41 | - Supports dynamic import without any code transformer
42 | - Supports Sever Side Rendering
43 | - Supports preload
44 | - `ResponseCode` and `Redirect` component
45 | - Custom route resolution (for i18n support)
46 | - URL Builder
47 |
48 | ## API Overview
49 |
50 | ### Hooks
51 |
52 | - `useRouter` - **Next.js subset compat hooks**
53 | - `withRouter` available
54 | - `useFrouteRouter` - `useRouter` superset (not compatible to Next.js's `useRouter`)
55 | - `useRouteComponent`
56 | - `useBeforeRouteChange(listener: () => Promise | boolean | void)`
57 | - It can prevent routing returns `Promise | false`
58 |
59 |
60 | Deprecated APIs
61 |
62 | The following hooks are deprecated. These features are available from `useFrouteRouter`.
63 |
64 | - `useParams`
65 | - `useLocation`
66 | - `useNavigation`
67 | - `useUrlBuilder`
68 |
69 |
70 | ### Components
71 |
72 | - ``
73 | - `` - Type-safe routing
74 | - ``
75 | - ` import('./pages/index'),
84 | }),
85 | user: routeOf('/users/:userId').action({
86 | component: () => import('./pages/user'),
87 | preload: (store: Store, params /* => inferred to { userId: string } */) =>
88 | Promise.all([ store.dispatch(fetchUser(param.userId)) ]),
89 | })
90 | }
91 | ```
92 |
93 | App:
94 | ```tsx
95 | import { useRouteComponent, ResponseCode } from '@fleur/froute'
96 |
97 | export const App = () => {
98 | const { PageComponent } = useRouteComponent()
99 |
100 | return (
101 |
102 | {PageComponent ? (
103 |
104 | ) : (
105 |
106 |
107 |
108 | )}
109 |
110 | )
111 | }
112 | ```
113 |
114 | User.tsx:
115 | ```tsx
116 | import { useRouter, buildPath } from '@fleur/froute'
117 | import { routes, ResponseCode, Redirect } from './routes'
118 |
119 | export default () => {
120 | const { query: { userId } } = useRouter()
121 | const user = useSelector(getUser(userId))
122 |
123 | if (!user) {
124 | return (
125 |
126 |
127 |
128 | )
129 | }
130 |
131 | if (user.suspended) {
132 | return (
133 |
134 | This account is suspended.
135 |
136 | )
137 | }
138 |
139 | return (
140 |
141 | Hello, {user.name}!
142 |
143 |
144 | Show latest update friend
145 |
146 |
147 | )
148 | }
149 | ```
150 |
151 |
152 | Server side:
153 | ```tsx
154 | import { createRouter } from '@fleur/froute'
155 | import { routes } from './routes'
156 |
157 | server.get("*", async (req, res, next) => {
158 | const router = createRouter(routes, {
159 | preloadContext: store
160 | })
161 |
162 | await router.navigate(req.url)
163 | await context.preloadCurrent();
164 |
165 | const content = ReactDOM.renderToString(
166 |
167 |
168 |
169 | )
170 |
171 | // Handling redirect
172 | if (router.redirectTo) {
173 | res.redirect(router.statusCode, router.redirectTo)
174 | } else{
175 | res.status(router.statusCode)
176 | }
177 |
178 | const stream = ReactDOM.renderToNodeStream(
179 |
180 | {content}
181 |
182 | ).pipe(res)
183 |
184 | router.dispose()
185 | })
186 | ```
187 |
188 | Client side:
189 | ```tsx
190 | import { createRouter, FrouteContext } from '@fleur/froute'
191 |
192 | domready(async () => {
193 | const router = createRouter(routes, {
194 | preloadContext: store,
195 | });
196 |
197 | await router.navigate(location.href)
198 | await router.preloadCurrent({ onlyComponentPreload: true })
199 |
200 | ReactDOM.render((
201 |
202 |
203 |
204 | ),
205 | document.getElementById('root')
206 | )
207 | })
208 | ```
209 |
210 | ## Next.js compat status
211 |
212 | - Compat API via `useRouter` or `withRouter`
213 | - Compatible features
214 | - `query`, `push()`, `replace()`, `prefetch()`, `back()`, `reload()`
215 | - `pathname` is provided, but Froute's pathname is not adjust to file system route.
216 | - Any type check not provided from Next.js (Froute is provided, it's compat breaking)
217 | - Next.js specific functions not supported likes `asPath`, `isFallback`, `basePath`, `locale`, `locales` and `defaultLocale`
218 | - `` only href props compatible but behaviour in-compatible.
219 | - Froute's Link has `` element. Next.js is not.
220 | - `as`, `passHref`, `prefetch`, `replace`, `scroll`, `shallow` is not supported currently.
221 | - `pathname` is return current `location.pathname`, not adjust to component file path base pathname.
222 | - `router.push()`, `router.replace()`
223 | - URL Object is does not support currentry
224 | - `as` argument is not supported
225 | - `router.beforePopState` is not supported
226 | - Use `useBeforeRouteChange()` hooks instead
227 | - `router.events`
228 | - Partially supported: `routeChangeStart`, `routeChangeComplete`, `routeChangeError`
229 | - Only `url` or `err` arguments.
230 | - Not implemented: `err.cancelled` and `{ shallow }` flag.
231 | - Not implemented: `beforeHistoryChange`, `hashChangeStart`, `hashChangeComplete`
232 |
233 |
234 | ### Why froute provides Next.js compat hooks?
235 |
236 | It aims to migrate to Next.js from react-router or another router.
237 |
238 | Froute's `useRouter` aims to provide a `useRouter` that is partially compatible with the Next.js `useRouter`, thereby guaranteeing an intermediate step in the migration of existing React Router-based applications to Next.js.
239 |
240 |
241 | ### How to type-safe useRoute
242 |
243 | Use this snippet in your app.
244 | (It's breaking to Type-level API compatibility from Next.js)
245 |
246 | ```tsx
247 | // Copy it in-your-app/useRouter.ts
248 | import { useRouter as useNextCompatRouter } from '@fleur/froute'
249 | export const useRouter: UseRouter = useNextCompatRouter
250 | ```
251 |
252 | Usage:
253 |
254 | ```tsx
255 | // Route definition
256 | const routes = {
257 | users: routeOf('/users/:id'),
258 | }
259 |
260 | // Typeing to `Routes`, it's free from circular dependency
261 | export type Routes = typeof routes
262 |
263 | // Component
264 | import { useRouter } from './useRouter'
265 | import { Routes } from './your-routes'
266 |
267 | const Users = () => {
268 | const router = useRouter()
269 | router.query.id // It infering to `string`.
270 | }
271 | ```
272 |
--------------------------------------------------------------------------------
/pkgs/froute/README.md:
--------------------------------------------------------------------------------
1 | [npm-url]: https://www.npmjs.com/package/@fleur/froute
2 | [ci-image-url]: https://img.shields.io/github/workflow/status/fleur-js/froute/CI?logo=github&style=flat-square
3 | [version-image-url]: https://img.shields.io/npm/v/@fleur/froute?style=flat-square
4 | [license-url]: https://opensource.org/licenses/MIT
5 | [license-image]: https://img.shields.io/npm/l/@fleur/froute.svg?style=flat-square
6 | [downloads-image]: https://img.shields.io/npm/dw/@fleur/froute.svg?style=flat-square&logo=npm
7 | [bundlephobia-url]: https://bundlephobia.com/result?p=@fleur/froute@2.0.1
8 | [bundlephobia-image]: https://img.shields.io/bundlephobia/minzip/@fleur/froute?style=flat-square
9 |
10 | ![CI][ci-image-url] [![latest][version-image-url]][npm-url] [![BundleSize][bundlephobia-image]][bundlephobia-url] [![License][license-image]][license-url] [![npm][downloads-image]][npm-url]
11 |
12 | # Froute
13 |
14 | [CHANGELOG](./pkgs/froute/CHANGELOG.md)
15 |
16 | Framework independent Router for React.
17 | Can use with both Fleur / Redux (redux-thunk).
18 |
19 | With provides Next.js subset `useRouter`
20 |
21 | ```
22 | yarn add @fleur/froute
23 | ```
24 |
25 | - [Features](#features)
26 | - [API Overview](#api-overview)
27 | - [Hooks](#hooks)
28 | - [Components](#components)
29 | - [Example](#example)
30 | - [Next.js compat status](#nextjs-compat-status)
31 | - [How to type-safe useRoute](#how-to-type-safe-useroute)
32 |
33 |
34 | ## Features
35 |
36 | See all examples in [this spec](https://github.com/fleur-js/froute/blob/master/src/index.spec.tsx) or [examples](https://github.com/fleur-js/froute/tree/master/examples)
37 |
38 | - Library independent
39 | - Works with Redux and Fleur
40 | - Next.js's Router subset compatiblity (`useRouter`, `withRouter`)
41 | - Supports dynamic import without any code transformer
42 | - Supports Sever Side Rendering
43 | - Supports preload
44 | - `ResponseCode` and `Redirect` component
45 | - Custom route resolution (for i18n support)
46 | - URL Builder
47 |
48 | ## API Overview
49 |
50 | ### Hooks
51 |
52 | - `useRouter` - **Next.js subset compat hooks**
53 | - `withRouter` available
54 | - `useFrouteRouter` - `useRouter` superset (not compatible to Next.js's `useRouter`)
55 | - `useRouteComponent`
56 | - `useBeforeRouteChange(listener: () => Promise | boolean | void)`
57 | - It can prevent routing returns `Promise | false`
58 |
59 |
60 | Deprecated APIs
61 |
62 | The following hooks are deprecated. These features are available from `useFrouteRouter`.
63 |
64 | - `useParams`
65 | - `useLocation`
66 | - `useNavigation`
67 | - `useUrlBuilder`
68 |
69 |
70 | ### Components
71 |
72 | - ``
73 | - `` - Type-safe routing
74 | - ``
75 | - ` import('./pages/index'),
84 | }),
85 | user: routeOf('/users/:userId').action({
86 | component: () => import('./pages/user'),
87 | preload: (store: Store, params /* => inferred to { userId: string } */) =>
88 | Promise.all([ store.dispatch(fetchUser(param.userId)) ]),
89 | })
90 | }
91 | ```
92 |
93 | App:
94 | ```tsx
95 | import { useRouteComponent, ResponseCode } from '@fleur/froute'
96 |
97 | export const App = () => {
98 | const { PageComponent } = useRouteComponent()
99 |
100 | return (
101 |
102 | {PageComponent ? (
103 |
104 | ) : (
105 |
106 |
107 |
108 | )}
109 |
110 | )
111 | }
112 | ```
113 |
114 | User.tsx:
115 | ```tsx
116 | import { useRouter, buildPath } from '@fleur/froute'
117 | import { routes, ResponseCode, Redirect } from './routes'
118 |
119 | export default () => {
120 | const { query: { userId } } = useRouter()
121 | const user = useSelector(getUser(userId))
122 |
123 | if (!user) {
124 | return (
125 |
126 |
127 |
128 | )
129 | }
130 |
131 | if (user.suspended) {
132 | return (
133 |
134 | This account is suspended.
135 |
136 | )
137 | }
138 |
139 | return (
140 |
141 | Hello, {user.name}!
142 |
143 |
144 | Show latest update friend
145 |
146 |
147 | )
148 | }
149 | ```
150 |
151 |
152 | Server side:
153 | ```tsx
154 | import { createRouter } from '@fleur/froute'
155 | import { routes } from './routes'
156 |
157 | server.get("*", async (req, res, next) => {
158 | const router = createRouter(routes, {
159 | preloadContext: store
160 | })
161 |
162 | await router.navigate(req.url)
163 | await context.preloadCurrent();
164 |
165 | const content = ReactDOM.renderToString(
166 |
167 |
168 |
169 | )
170 |
171 | // Handling redirect
172 | if (router.redirectTo) {
173 | res.redirect(router.statusCode, router.redirectTo)
174 | } else{
175 | res.status(router.statusCode)
176 | }
177 |
178 | const stream = ReactDOM.renderToNodeStream(
179 |
180 | {content}
181 |
182 | ).pipe(res)
183 |
184 | router.dispose()
185 | })
186 | ```
187 |
188 | Client side:
189 | ```tsx
190 | import { createRouter, FrouteContext } from '@fleur/froute'
191 |
192 | domready(async () => {
193 | const router = createRouter(routes, {
194 | preloadContext: store,
195 | });
196 |
197 | await router.navigate(location.href)
198 | await router.preloadCurrent({ onlyComponentPreload: true })
199 |
200 | ReactDOM.render((
201 |
202 |
203 |
204 | ),
205 | document.getElementById('root')
206 | )
207 | })
208 | ```
209 |
210 | ## Next.js compat status
211 |
212 | - Compat API via `useRouter` or `withRouter`
213 | - Compatible features
214 | - `query`, `push()`, `replace()`, `prefetch()`, `back()`, `reload()`
215 | - `pathname` is provided, but Froute's pathname is not adjust to file system route.
216 | - Any type check not provided from Next.js (Froute is provided, it's compat breaking)
217 | - Next.js specific functions not supported likes `asPath`, `isFallback`, `basePath`, `locale`, `locales` and `defaultLocale`
218 | - `` only href props compatible but behaviour in-compatible.
219 | - Froute's Link has `` element. Next.js is not.
220 | - `as`, `passHref`, `prefetch`, `replace`, `scroll`, `shallow` is not supported currently.
221 | - `pathname` is return current `location.pathname`, not adjust to component file path base pathname.
222 | - `router.push()`, `router.replace()`
223 | - URL Object is does not support currentry
224 | - `as` argument is not supported
225 | - `router.beforePopState` is not supported
226 | - Use `useBeforeRouteChange()` hooks instead
227 | - `router.events`
228 | - Partially supported: `routeChangeStart`, `routeChangeComplete`, `routeChangeError`
229 | - Only `url` or `err` arguments.
230 | - Not implemented: `err.cancelled` and `{ shallow }` flag.
231 | - Not implemented: `beforeHistoryChange`, `hashChangeStart`, `hashChangeComplete`
232 |
233 |
234 | ### Why froute provides Next.js compat hooks?
235 |
236 | It aims to migrate to Next.js from react-router or another router.
237 |
238 | Froute's `useRouter` aims to provide a `useRouter` that is partially compatible with the Next.js `useRouter`, thereby guaranteeing an intermediate step in the migration of existing React Router-based applications to Next.js.
239 |
240 |
241 | ### How to type-safe useRoute
242 |
243 | Use this snippet in your app.
244 | (It's breaking to Type-level API compatibility from Next.js)
245 |
246 | ```tsx
247 | // Copy it in-your-app/useRouter.ts
248 | import { useRouter as useNextCompatRouter } from '@fleur/froute'
249 | export const useRouter: UseRouter = useNextCompatRouter
250 | ```
251 |
252 | Usage:
253 |
254 | ```tsx
255 | // Route definition
256 | const routes = {
257 | users: routeOf('/users/:id'),
258 | }
259 |
260 | // Typeing to `Routes`, it's free from circular dependency
261 | export type Routes = typeof routes
262 |
263 | // Component
264 | import { useRouter } from './useRouter'
265 | import { Routes } from './your-routes'
266 |
267 | const Users = () => {
268 | const router = useRouter()
269 | router.query.id // It infering to `string`.
270 | }
271 | ```
272 |
--------------------------------------------------------------------------------
/pkgs/froute/src/RouterContext.spec.ts:
--------------------------------------------------------------------------------
1 | import { buildPath } from "./builder";
2 | import { routeOf } from "./RouteDefiner";
3 | import { createRouter, RouterContext, RouterOptions } from "./RouterContext";
4 | import { combineRouteResolver } from "./RouterUtils";
5 |
6 | describe("Router", () => {
7 | const routes = {
8 | usersShow: routeOf("/users/:id").state(() => ({ hist: "default" })),
9 | };
10 |
11 | describe("navigate", () => {
12 | it("Should route to usersShow by string URL", async () => {
13 | const router = new RouterContext(routes);
14 | await router.navigate("/users/1?a=1#1");
15 |
16 | const match = router.getCurrentMatch();
17 |
18 | expect(match).not.toBe(false);
19 | if (!match) return; // Type guard
20 |
21 | expect(match.route).toBe(routes.usersShow);
22 | expect(match.match).toMatchObject({
23 | params: { id: "1" },
24 | path: "/users/1",
25 | query: { a: "1" },
26 | search: "?a=1",
27 | });
28 |
29 | expect(router.getCurrentLocation()).toMatchObject({
30 | hash: "#1",
31 | pathname: "/users/1",
32 | search: "?a=1",
33 | });
34 | expect(router.getCurrentLocation().state.app).toMatchInlineSnapshot(`
35 | {
36 | "hist": "default",
37 | }
38 | `);
39 | });
40 |
41 | it("Should route to usersShow by location", async () => {
42 | const router = new RouterContext(routes);
43 |
44 | await router.navigate({
45 | pathname: "/users/1",
46 | search: "?a=1",
47 | hash: "#1",
48 | state: null,
49 | });
50 |
51 | const match = router.getCurrentMatch();
52 |
53 | expect(match).not.toBe(false);
54 | if (!match) return;
55 |
56 | expect(match.route).toBe(routes.usersShow);
57 | expect(router.getCurrentLocation()).toMatchObject({
58 | pathname: "/users/1",
59 | search: "?a=1",
60 | hash: "#1",
61 | });
62 | expect(router.getCurrentLocation().state.app).toMatchInlineSnapshot(`
63 | {
64 | "hist": "default",
65 | }
66 | `);
67 | });
68 |
69 | it.each([
70 | [
71 | "not",
72 | "after reloading",
73 | {
74 | sameSession: false,
75 | calledTimes: 2 /* if invalid implementation, it to be 1 */,
76 | },
77 | ],
78 | [
79 | "be",
80 | "on same session",
81 | {
82 | sameSession: true,
83 | calledTimes: 2 /* if invalid implementation, it to be 3 */,
84 | },
85 | ],
86 | ])(
87 | "Should %s preload on popstate %s",
88 | async (_, __, { sameSession, calledTimes }) => {
89 | const preloadSpy = vi.fn();
90 | const router = createRouter({
91 | users: routeOf("/users/:id").action({
92 | component: () => () => null,
93 | preload: preloadSpy,
94 | }),
95 | });
96 |
97 | if (sameSession) {
98 | await router.navigate("/users/2", { action: "PUSH" });
99 | } else {
100 | // Make non froute handled state emulates reload
101 | history.pushState(null, "", "/users/2");
102 | }
103 |
104 | await router.navigate("/users/1", { action: "PUSH" });
105 |
106 | router.history.back();
107 | await new Promise((r) => setTimeout(r, 100));
108 |
109 | expect(preloadSpy).toBeCalledTimes(calledTimes);
110 | }
111 | );
112 | });
113 |
114 | describe("Prevent routing", () => {
115 | it("on push(navigate)", async () => {
116 | const router = createRouter(routes);
117 | await router.navigate("/users/1");
118 |
119 | // Prevent
120 | const preventSpy = vi.fn(() => false);
121 | router.setBeforeRouteChangeListener(preventSpy);
122 | await router.navigate("/users/2", { action: "PUSH" });
123 |
124 | expect(preventSpy).toBeCalledTimes(1);
125 | expect(router.getCurrentLocation().pathname).toBe("/users/1");
126 | router.clearBeforeRouteChangeListener();
127 |
128 | // Navigate
129 | const allowSpy = vi.fn(() => true);
130 | router.setBeforeRouteChangeListener(allowSpy);
131 | await router.navigate("/users/2", { action: "PUSH" });
132 |
133 | expect(allowSpy).toBeCalledTimes(1);
134 | expect(router.getCurrentLocation().pathname).toBe("/users/2");
135 | });
136 |
137 | it("on popstate", async () => {
138 | const router = createRouter(routes);
139 |
140 | await router.navigate("/users/1", { action: "PUSH" });
141 | await router.navigate("/users/2", { action: "PUSH" });
142 |
143 | const preventSpy = vi.fn(() => false);
144 | router.setBeforeRouteChangeListener(preventSpy);
145 |
146 | history.back();
147 | await new Promise((r) => setTimeout(r, /* Allows under */ 20));
148 |
149 | expect(preventSpy).toBeCalledTimes(1);
150 | expect(router.getCurrentLocation().pathname).toBe("/users/2");
151 | router.clearBeforeRouteChangeListener();
152 |
153 | const allowSpy = vi.fn(() => /* allow transition is */ true);
154 | router.setBeforeRouteChangeListener(allowSpy);
155 |
156 | history.back();
157 | await new Promise((r) => setTimeout(r, /* Allows under */ 20));
158 | expect(allowSpy).toBeCalledTimes(1);
159 | expect(router.getCurrentLocation().pathname).toBe("/users/1");
160 |
161 | router.setBeforeRouteChangeListener(allowSpy);
162 | history.forward();
163 | await new Promise((r) => setTimeout(r, /* Allows under */ 20));
164 | expect(allowSpy).toBeCalledTimes(2);
165 | expect(router.getCurrentLocation().pathname).toBe("/users/2");
166 |
167 | router.dispose();
168 | });
169 | });
170 |
171 | describe("History State", () => {
172 | it("check", async () => {
173 | const router = createRouter(routes);
174 | await router.navigate("/users/1");
175 | router.setHistoryState({ user1: "ok" });
176 |
177 | expect(router.getHistoryState().user1).toBe("ok");
178 |
179 | await router.navigate("/users/2", { state: { user2: "ok" } });
180 | expect(router.getHistoryState().user2).toBe("ok");
181 | });
182 | });
183 |
184 | describe("resolveRoute", () => {
185 | describe("Edge cases", () => {
186 | it("# in fragment", () => {
187 | const router = new RouterContext(routes);
188 | const result = router.resolveRoute("/users/%23sharp");
189 |
190 | expect(result!.match.params.id).toBe("#sharp");
191 | expect(result!.route).toBe(routes.usersShow);
192 | });
193 | });
194 | });
195 |
196 | describe("preload", () => {
197 | it("should receive params correctly", async () => {
198 | const preloadSpy = vi.fn();
199 |
200 | const routes = {
201 | users: routeOf("/users/:id").action({
202 | component: () => () => null,
203 | preload: preloadSpy,
204 | }),
205 | };
206 |
207 | const router = new RouterContext(routes, { preloadContext: "hello" });
208 | await router.navigate("/users/1?q1=aaa", { action: "PUSH" });
209 |
210 | expect(preloadSpy).toBeCalledWith(
211 | "hello",
212 | expect.objectContaining({
213 | id: "1",
214 | }),
215 | expect.objectContaining({ query: { q1: "aaa" }, search: "?q1=aaa" })
216 | );
217 | });
218 | });
219 |
220 | describe("Custom route resolution", () => {
221 | const options: RouterOptions = {
222 | resolver: combineRouteResolver(
223 | function resolveI18nRoute(pathname, _, context) {
224 | // I18n path resolve
225 | const matches = /^\/(?\w+)(?\/?.*)/.exec(pathname);
226 | const { lang, path } = matches?.groups ?? {};
227 | // When langage not found, skip this resolve and run next resolver
228 | // (Return the `false` to skip to next resolver in combineRouteResolver)
229 | if (!["en", "ja"].includes(lang)) return false;
230 |
231 | const match = context.resolveRoute(path);
232 | return match;
233 | },
234 | function resolveUserAlias(pathname, match, context) {
235 | // Alias route (Redirect)
236 | const [, uid] = /^\/u\/(\d+)/.exec(pathname) ?? [];
237 |
238 | if (uid) {
239 | context.statusCode = 302;
240 | context.redirectTo = buildPath(routes.usersShow, {
241 | id: uid,
242 | });
243 |
244 | // Return the `null` to unmatch any route
245 | return null;
246 | }
247 |
248 | return match;
249 | }
250 | ),
251 | };
252 |
253 | describe("Redirection", () => {
254 | it("Should alias path to real route", async () => {
255 | const router = new RouterContext(routes, options);
256 |
257 | await router.navigate("/u/1");
258 | expect(router.statusCode).toBe(302);
259 | expect(router.redirectTo).toBe("/users/1");
260 | expect(router.getCurrentMatch()).toBe(null);
261 | });
262 | });
263 |
264 | describe("Language specified route", () => {
265 | it("Should resolve language specified pathname", async () => {
266 | const router = new RouterContext(routes, options);
267 |
268 | await router.navigate("/ja/users/1");
269 | expect(router.getCurrentMatch()?.match.path).toMatchInlineSnapshot(
270 | `"/users/1"`
271 | );
272 |
273 | await router.navigate("/en/users/2");
274 | expect(router.getCurrentMatch()?.match.path).toMatchInlineSnapshot(
275 | `"/users/2"`
276 | );
277 | });
278 |
279 | it("Should ignore no language specified pathname", async () => {
280 | const router = new RouterContext(routes, options);
281 |
282 | await router.navigate("/users/2");
283 | expect(router.getCurrentMatch()?.match.path).toMatchInlineSnapshot(
284 | `"/users/2"`
285 | );
286 | });
287 | });
288 | });
289 | });
290 |
--------------------------------------------------------------------------------
/pkgs/froute/src/react-bind.tsx:
--------------------------------------------------------------------------------
1 | import React, {
2 | ComponentType,
3 | createContext,
4 | DependencyList,
5 | forwardRef,
6 | ReactNode,
7 | useContext,
8 | useEffect,
9 | useLayoutEffect,
10 | useMemo,
11 | useReducer,
12 | } from "react";
13 | import {
14 | canUseDOM,
15 | DeepReadonly,
16 | isDevelopment,
17 | parseQueryString,
18 | } from "./utils";
19 | import { RouteDefinition, ParamsOfRoute, StateOfRoute } from "./RouteDefiner";
20 | import { RouterContext, BeforeRouteListener } from "./RouterContext";
21 | import { RouterEvents } from "./RouterEvents";
22 | import { Location } from "history";
23 | import { buildPath, BuildPath } from "./builder";
24 |
25 | const useIsomorphicLayoutEffect = canUseDOM() ? useLayoutEffect : useEffect;
26 |
27 | const Context = createContext(null);
28 | Context.displayName = "FrouteContext";
29 |
30 | const checkExpectedRoute = (
31 | router: RouterContext,
32 | expectRoute?: RouteDefinition,
33 | methodName?: string
34 | ) => {
35 | if (expectRoute && router.getCurrentMatch()?.route !== expectRoute) {
36 | console.warn(
37 | `Froute: Expected route and current route not matched in \`${methodName}\``
38 | );
39 | }
40 | };
41 |
42 | export const FrouteContext = ({
43 | router,
44 | children,
45 | }: {
46 | router: RouterContext;
47 | children: ReactNode;
48 | }) => {
49 | return {children};
50 | };
51 |
52 | /**
53 | * Do not expose to public API. It's Froute internal only hooks.
54 | * WHY: Protect direct router operating from Components.
55 | * If allow it, Router status can changed by anywhere and becomes unpredictable.
56 | */
57 | export const useRouterContext = () => {
58 | const router = useContext(Context);
59 | if (!router) {
60 | throw new Error("FrouteContext must be placed of top of useRouter");
61 | }
62 |
63 | return router;
64 | };
65 |
66 | export const useRouteComponent = () => {
67 | const router = useRouterContext();
68 | const match = router.getCurrentMatch();
69 | const PageComponent = match?.route.getActor()?.cachedComponent;
70 | const [, rerender] = useReducer((s) => s + 1, 0);
71 |
72 | useIsomorphicLayoutEffect(() => {
73 | router.observeRouteChanged(rerender);
74 | return () => router.unobserveRouteChanged(rerender);
75 | }, [router, rerender]);
76 |
77 | return useMemo(() => ({ PageComponent }), [match]);
78 | };
79 |
80 | export interface UseRouter {
81 | = any>(): NextCompatRouter;
82 | }
83 |
84 | interface NextCompatRouter> {
85 | pathname: string;
86 | query: ParamsOfRoute & { [key: string]: string | string[] };
87 | push: (url: string) => void;
88 | replace: (url: string) => void;
89 | prefetch: (url: string) => void;
90 | back: FrouteNavigator["back"];
91 | reload: () => void;
92 | events: RouterEvents;
93 | }
94 |
95 | /**
96 | * Next.js subset-compat router
97 | *
98 | * - URL Object is not supported in `push`, `replace` currentry
99 | * - `as` is not supported currentry
100 | * - `beforePopState` is not supported
101 | * - router.events
102 | */
103 | export const useRouter: UseRouter = () => {
104 | const router = useRouterContext();
105 | const location = router.getCurrentLocation();
106 | const match = router.getCurrentMatch();
107 | const nav = useNavigation();
108 |
109 | return useMemo(
110 | () => ({
111 | pathname: location.pathname,
112 | query: {
113 | ...(match?.match.query as any),
114 | ...(match?.match.params as any),
115 | },
116 | push: (url: string) => nav.push(url),
117 | replace: (url: string) => nav.replace(url),
118 | prefetch: (url: string) => {
119 | const match = router.resolveRoute(url);
120 |
121 | if (!match) return;
122 |
123 | router.preloadRoute(match, {
124 | onlyComponentPreload: true,
125 | });
126 | },
127 | back: nav.back,
128 | reload: () => window.location.reload(),
129 | events: router.events,
130 | }),
131 | [location, match]
132 | );
133 | };
134 |
135 | // use `any` as default type to compat Next.js
136 | export type RouterProps = any> = {
137 | router: NextCompatRouter;
138 | };
139 |
140 | export const withRouter = (
141 | Component: ComponentType
142 | ) => {
143 | const WithRouter = forwardRef((props, ref) => {
144 | const router = useRouter();
145 | return ;
146 | });
147 |
148 | WithRouter.displayName = `WithRouter(${
149 | Component.displayName ?? (Component as any).name
150 | })`;
151 |
152 | return WithRouter as ComponentType>;
153 | };
154 |
155 | export interface UseFrouteRouter {
156 | = any>(r?: R): FrouteRouter;
157 | }
158 |
159 | interface FrouteRouter>
160 | extends NextCompatRouter {
161 | searchQuery: Record;
162 | location: DeepReadonly>>;
163 | buildPath: BuildPath;
164 | historyState: {
165 | get: () => StateOfRoute;
166 | set: (state: StateOfRoute) => void;
167 | };
168 | }
169 |
170 | export const useFrouteRouter: UseFrouteRouter = <
171 | R extends RouteDefinition
172 | >(
173 | r?: R
174 | ): FrouteRouter => {
175 | const router = useRouterContext();
176 | const nextCompatRouter = useRouter();
177 | const location = router.getCurrentLocation();
178 |
179 | if (isDevelopment) {
180 | checkExpectedRoute(router, r, "useLocation");
181 | }
182 |
183 | return useMemo(
184 | () => ({
185 | ...nextCompatRouter,
186 | searchQuery: parseQueryString(location.search.slice(1) ?? ""),
187 | location: { ...location, state: location.state.app },
188 | buildPath,
189 | historyState: {
190 | get: router.getHistoryState,
191 | set: router.setHistoryState,
192 | },
193 | }),
194 | [location, nextCompatRouter.pathname, nextCompatRouter.query]
195 | );
196 | };
197 |
198 | /** @deprecated Use `useFrouteRouter().location` instead */
199 | export const useLocation = >(
200 | expectRoute?: R
201 | ) => {
202 | const router = useRouterContext();
203 | const location = router.getCurrentLocation();
204 |
205 | if (isDevelopment) {
206 | checkExpectedRoute(router, expectRoute, "useLocation");
207 | }
208 |
209 | return useMemo(
210 | () => ({
211 | key: location.key,
212 | pathname: location.pathname,
213 | search: location.search,
214 | query: parseQueryString(location.search.slice(1) ?? ""),
215 | hash: location.hash,
216 | state: location.state.app as StateOfRoute,
217 | }),
218 | [location.pathname, location.search, location.search]
219 | );
220 | };
221 |
222 | /** @deprecated Use `useFrouteRouter().historyState` instead */
223 | export const useHistoryState = <
224 | R extends RouteDefinition = RouteDefinition
225 | >(
226 | expectRoute?: R
227 | ): [
228 | getHistoryState: () => DeepReadonly>,
229 | setHistoryState: (state: StateOfRoute) => void
230 | ] => {
231 | const router = useRouterContext();
232 |
233 | if (isDevelopment) {
234 | checkExpectedRoute(router, expectRoute, "useHistoryState");
235 | }
236 |
237 | return useMemo(() => [router.getHistoryState, router.setHistoryState], []);
238 | };
239 |
240 | interface UseParams {
241 | (): { [param: string]: string | undefined };
242 | >(expectRoute?: T): ParamsOfRoute;
243 | }
244 |
245 | /** @deprecated Use `useFrouteRouter().query` instead */
246 | export const useParams: UseParams = <
247 | T extends RouteDefinition = RouteDefinition
248 | >(
249 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment
250 | expectRoute?: T
251 | ) => {
252 | const router = useRouterContext();
253 | const location = router.getCurrentLocation();
254 | const match = location ? router.resolveRoute(location.pathname) : null;
255 |
256 | if (isDevelopment) {
257 | checkExpectedRoute(router, expectRoute, "useParams");
258 | }
259 |
260 | return match ? (match.match.params as ParamsOfRoute) : {};
261 | };
262 |
263 | /** @deprecated Use `useFrouteRouter().{push,replace,...}` instead */
264 | export interface FrouteNavigator {
265 | push>(
266 | route: R,
267 | params: ParamsOfRoute,
268 | extra?: {
269 | query?: { [key: string]: string | string[] };
270 | hash?: string;
271 | state?: StateOfRoute;
272 | }
273 | ): void;
274 | push(pathname: string): void;
275 | replace>(
276 | route: R,
277 | params: ParamsOfRoute,
278 | extra?: {
279 | query?: { [key: string]: string | string[] };
280 | hash?: string;
281 | state?: StateOfRoute;
282 | }
283 | ): void;
284 | replace(pathname: string): void;
285 | back(): void;
286 | forward(): void;
287 | }
288 |
289 | /** @deprecated Use `useFrouteRouter().{push,replace,...}` and `buildPath()` instead */
290 | export const useNavigation = () => {
291 | const router = useRouterContext();
292 | const { buildPath } = useUrlBuilder();
293 |
294 | return useMemo(
295 | () => ({
296 | push: >(
297 | route: R | string,
298 | params: ParamsOfRoute = {} as any,
299 | {
300 | query,
301 | hash = "",
302 | state,
303 | }: {
304 | query?: { [key: string]: string | string[] };
305 | hash?: string;
306 | state?: StateOfRoute;
307 | } = {}
308 | ) => {
309 | const pathname =
310 | typeof route === "string" ? route : buildPath(route, params, query);
311 |
312 | const resolvedRoute =
313 | typeof route !== "string"
314 | ? route
315 | : router.resolveRoute(pathname + hash)?.route;
316 | if (!resolvedRoute) return;
317 |
318 | router.navigate(pathname + hash, {
319 | state,
320 | action: "PUSH",
321 | });
322 | },
323 | replace: >(
324 | route: R | string,
325 | params: ParamsOfRoute = {} as any,
326 | {
327 | query,
328 | hash = "",
329 | state,
330 | }: {
331 | query?: { [key: string]: string | string[] };
332 | hash?: string;
333 | state?: StateOfRoute;
334 | } = {}
335 | ) => {
336 | const pathname =
337 | typeof route === "string" ? route : buildPath(route, params, query);
338 |
339 | router.navigate(pathname + hash, {
340 | state,
341 | action: "REPLACE",
342 | });
343 | },
344 | back: () => router.history.back(),
345 | forward: () => router.history.forward(),
346 | }),
347 | [router, router.history]
348 | );
349 | };
350 |
351 | /** @deprecated Use `useFrouteRouter().buildPath` instead */
352 | export const useUrlBuilder = () => {
353 | const router = useRouterContext();
354 | return useMemo(
355 | () => ({
356 | buildPath: router.buildPath,
357 | }),
358 | [router]
359 | );
360 | };
361 |
362 | /** Handling route change */
363 | export const useBeforeRouteChange = (
364 | /** Return Promise<false> | false to prevent route changing. This listener only one can be set at a time */
365 | beforeRouteListener: BeforeRouteListener,
366 | deps: DependencyList
367 | ) => {
368 | const router = useRouterContext();
369 |
370 | useEffect(() => {
371 | router.setBeforeRouteChangeListener(beforeRouteListener);
372 | return () => router.clearBeforeRouteChangeListener();
373 | }, deps);
374 | };
375 |
--------------------------------------------------------------------------------
/pkgs/froute/src/RouterContext.ts:
--------------------------------------------------------------------------------
1 | import {
2 | Blocker,
3 | BrowserHistory,
4 | createBrowserHistory,
5 | createMemoryHistory,
6 | History,
7 | Listener,
8 | Location,
9 | MemoryHistory,
10 | } from "history";
11 | import { type DeepReadonly, canUseDOM, parseUrl } from "./utils";
12 | import { RouteDefinition, ParamsOfRoute } from "./RouteDefiner";
13 | import { buildPath } from "./builder";
14 | import { FrouteMatch, RouteResolver, matchByRoutes } from "./routing";
15 | import {
16 | createFrouteHistoryState,
17 | FrouteHistoryState,
18 | StateBase,
19 | } from "./FrouteHistoryState";
20 | import { RouterEventsInternal, routerEvents } from "./RouterEvents";
21 |
22 | export interface RouterOptions {
23 | resolver?: RouteResolver;
24 | preloadContext?: any;
25 | history?: History;
26 | }
27 |
28 | export const createRouter = (
29 | routes: { [key: string]: RouteDefinition },
30 | options: RouterOptions = {}
31 | ) => {
32 | return new RouterContext(routes, options);
33 | };
34 |
35 | interface Navigate {
36 | (
37 | location: Omit, "key">,
38 | options?: NavigateOption
39 | ): Promise;
40 | (pathname: string, options?: NavigateOption): Promise;
41 | }
42 |
43 | interface NavigateOption {
44 | state?: StateBase;
45 | /** undefined only used at client side rehydration */
46 | action?: "PUSH" | "POP" | "REPLACE" | undefined;
47 | __INTERNAL_STATE_DO_NOT_USE_OR_YOU_WILL_BE_FIRED?: FrouteHistoryState | null;
48 | }
49 |
50 | /** Return `false` to prevent routing */
51 | export interface BeforeRouteListener {
52 | (nextMatch: FrouteMatch | null):
53 | | Promise
54 | | boolean
55 | | void;
56 | }
57 |
58 | const createKey = () => Math.random().toString(36).substr(2, 8);
59 |
60 | export type NavigationListener = (
61 | location: DeepReadonly>
62 | ) => void;
63 |
64 | export class RouterContext {
65 | public statusCode = 200;
66 | public redirectTo: string | null = null;
67 | public readonly history: History;
68 | public events: RouterEventsInternal = routerEvents();
69 |
70 | /** Temporary session id for detect reloading */
71 | private sid = createKey();
72 | private latestNavKey: string | null = null;
73 | private location: Location | null = null;
74 | private currentMatch: FrouteMatch | null = null;
75 | private unlistenHistory: () => void;
76 | private releaseNavigationBlocker: (() => void) | null;
77 |
78 | private beforeRouteChangeListener: BeforeRouteListener | null = null;
79 |
80 | /** Preload finish listeners */
81 | private routeChangedListener: Set = new Set();
82 |
83 | constructor(
84 | public routes: { [key: string]: RouteDefinition },
85 | private options: RouterOptions = {}
86 | ) {
87 | this.history =
88 | options.history ?? canUseDOM()
89 | ? (createBrowserHistory({}) as BrowserHistory)
90 | : (createMemoryHistory({}) as MemoryHistory);
91 |
92 | this.unlistenHistory = this.history.listen(this.historyListener);
93 | }
94 |
95 | public dispose() {
96 | this.unlistenHistory();
97 | this.releaseNavigationBlocker?.();
98 |
99 | this.routeChangedListener.clear();
100 | (this as any).routeChangedListener = null;
101 | this.beforeRouteChangeListener = null;
102 | this.releaseNavigationBlocker = null;
103 | this.location = null;
104 | this.currentMatch = null;
105 | }
106 |
107 | private historyListener: Listener = async ({
108 | location,
109 | action,
110 | }) => {
111 | this.navigate(location, {
112 | action,
113 | __INTERNAL_STATE_DO_NOT_USE_OR_YOU_WILL_BE_FIRED: location.state,
114 | });
115 | };
116 |
117 | public navigate: Navigate = async (
118 | pathname: string | Omit, "key">,
119 | options: NavigateOption = {}
120 | ) => {
121 | const {
122 | state,
123 | action,
124 | __INTERNAL_STATE_DO_NOT_USE_OR_YOU_WILL_BE_FIRED: internalState,
125 | } = options;
126 | const currentNavKey = (this.latestNavKey = createKey());
127 | const isCancelled = () => this.latestNavKey !== currentNavKey;
128 | const loc = typeof pathname === "string" ? parseUrl(pathname) : pathname;
129 | const userState = typeof pathname !== "string" ? pathname.state : state;
130 |
131 | const nextSid =
132 | "__INTERNAL_STATE_DO_NOT_USE_OR_YOU_WILL_BE_FIRED" in options
133 | ? internalState?.__froute?.sid
134 | : this.sid;
135 |
136 | const nextMatch = this.resolveRoute(
137 | (loc.pathname ?? "") + (loc.search ?? "") + (loc.hash ?? "")
138 | );
139 |
140 | if (
141 | (action === "PUSH" || action === "POP") &&
142 | (await this.beforeRouteChangeListener?.(nextMatch)) === false
143 | )
144 | return;
145 |
146 | // Dispose listener for prevent duplicate route handling
147 | this.unlistenHistory();
148 |
149 | try {
150 | const nextLocation = {
151 | key: createKey(),
152 | pathname: loc.pathname ?? "/",
153 | search: loc.search ?? "",
154 | hash: loc.hash ?? "",
155 | state:
156 | internalState ??
157 | createFrouteHistoryState(
158 | nextSid,
159 | userState ?? nextMatch?.route.createState()
160 | ),
161 | };
162 |
163 | if (action === "REPLACE") {
164 | this.history.replace(nextLocation, nextLocation.state);
165 |
166 | this.currentMatch = nextMatch;
167 | this.location = nextLocation;
168 | return;
169 | }
170 |
171 | this.events.emit("routeChangeStart", [loc.pathname ?? "/"]);
172 |
173 | if (action === "PUSH" && nextMatch) {
174 | await this.preloadRoute(nextMatch);
175 |
176 | if (!isCancelled()) {
177 | this.history.push(nextLocation, nextLocation.state);
178 | }
179 | } else if (
180 | action === "POP" &&
181 | nextLocation.state.__froute?.sid !== this.sid &&
182 | nextMatch
183 | ) {
184 | await this.preloadRoute(nextMatch);
185 | } else {
186 | // on restore location
187 | if (!isCancelled()) {
188 | this.history.replace(nextLocation, nextLocation.state);
189 | }
190 | }
191 |
192 | // Skip update if next navigation is started
193 | if (!isCancelled()) {
194 | this.currentMatch = nextMatch;
195 | this.location = nextLocation;
196 | this.routeChangedListener.forEach((listener) => listener(nextLocation));
197 | }
198 |
199 | this.events.emit("routeChangeComplete", [loc.pathname ?? "/"]);
200 | } catch (e) {
201 | this.events.emit("routeChangeError", [e, loc.pathname ?? "/"]);
202 | throw e;
203 | } finally {
204 | // Restore listener
205 | this.unlistenHistory = this.history.listen(this.historyListener);
206 | }
207 | };
208 |
209 | public clearBeforeRouteChangeListener() {
210 | this.releaseNavigationBlocker?.();
211 | this.beforeRouteChangeListener = null;
212 | this.releaseNavigationBlocker = null;
213 | }
214 |
215 | public setBeforeRouteChangeListener(listener: BeforeRouteListener) {
216 | if (this.beforeRouteChangeListener) {
217 | throw new Error(
218 | "Froute: beforeRouteChangeListener already set, please set only current Page not in child components"
219 | );
220 | }
221 |
222 | const block: Blocker = ({ action, location, retry }) => {
223 | if (action === "REPLACE") {
224 | return;
225 | }
226 |
227 | if (action === "PUSH") {
228 | // When PUSH, navigatable condition are maybe checked in #navigate()
229 |
230 | this.releaseNavigationBlocker?.();
231 | retry();
232 |
233 | setTimeout(() => {
234 | this.releaseNavigationBlocker = this.history.block(block);
235 | });
236 |
237 | return;
238 | }
239 |
240 | const nextMatch = this.resolveRoute(
241 | (location.pathname ?? "") +
242 | (location.search ?? "") +
243 | (location.hash ?? "")
244 | );
245 |
246 | if (this.beforeRouteChangeListener?.(nextMatch) === false) {
247 | return;
248 | } else {
249 | // In route changed
250 | this.events.emit("routeChangeStart", [location.pathname ?? "/"]);
251 |
252 | try {
253 | this.clearBeforeRouteChangeListener();
254 | retry();
255 |
256 | setTimeout(() => {
257 | this.events.emit("routeChangeComplete", [location.pathname ?? "/"]);
258 | });
259 | } catch (e) {
260 | this.events.emit("routeChangeError", [e, location.pathname ?? "/"]);
261 | }
262 | }
263 | };
264 |
265 | this.beforeRouteChangeListener = listener;
266 | this.releaseNavigationBlocker = this.history.block(block);
267 | }
268 |
269 | public get internalHitoryState() {
270 | return this.getCurrentLocation()?.state.__froute;
271 | }
272 |
273 | public set internalHistoryState(state: FrouteHistoryState["__froute"]) {
274 | const nextState: FrouteHistoryState = {
275 | __froute: state,
276 | app: this.getCurrentLocation()?.state.app,
277 | };
278 |
279 | this.history.replace(this.history.location, nextState);
280 | }
281 |
282 | public getHistoryState = (): FrouteHistoryState["app"] => {
283 | return this.getCurrentLocation().state?.app;
284 | };
285 |
286 | public setHistoryState = (nextState: FrouteHistoryState["app"]) => {
287 | const location = this.getCurrentLocation();
288 |
289 | const nextRawState: FrouteHistoryState = {
290 | __froute: location.state.__froute,
291 | app: nextState,
292 | };
293 |
294 | this.history.replace(location, nextRawState);
295 | this.location = { ...location, state: nextRawState };
296 | };
297 |
298 | public buildPath = >(
299 | route: R,
300 | params: ParamsOfRoute,
301 | query?: { [key: string]: string | string[] }
302 | ) => {
303 | return buildPath(route, params, query);
304 | };
305 |
306 | public getCurrentMatch = (): DeepReadonly> | null => {
307 | return this.currentMatch;
308 | };
309 |
310 | public getCurrentLocation = (): DeepReadonly<
311 | Location
312 | > => {
313 | if (!this.location) {
314 | throw new Error(
315 | "Froute: location is empty. Please call `.navigate` before get current location."
316 | );
317 | }
318 |
319 | return this.location;
320 | };
321 |
322 | public observeRouteChanged = (listener: NavigationListener) => {
323 | this.routeChangedListener.add(listener);
324 | };
325 |
326 | public unobserveRouteChanged = (listener: NavigationListener) => {
327 | this.routeChangedListener.delete(listener);
328 | };
329 |
330 | public resolveRoute = (pathname: string): FrouteMatch | null => {
331 | return matchByRoutes(pathname, this.routes, {
332 | resolver: this.options.resolver,
333 | context: this,
334 | });
335 | };
336 |
337 | public async preloadRoute>(
338 | match: DeepReadonly & { route: R }>,
339 | { onlyComponentPreload = false }: { onlyComponentPreload?: boolean } = {}
340 | ) {
341 | const actor = match.route.getActor();
342 | if (!actor) return;
343 |
344 | const { query, search, params } = match.match;
345 | await Promise.all([
346 | actor.loadComponent(),
347 | onlyComponentPreload
348 | ? null
349 | : actor.preload?.(this.options.preloadContext, params, {
350 | query,
351 | search,
352 | }),
353 | ]);
354 | }
355 |
356 | public async preloadCurrent({
357 | onlyComponentPreload = false,
358 | }: {
359 | onlyComponentPreload?: boolean;
360 | } = {}) {
361 | const matchedRoute = this.getCurrentMatch();
362 | if (!matchedRoute) return;
363 |
364 | await this.preloadRoute(matchedRoute, { onlyComponentPreload });
365 | }
366 | }
367 |
--------------------------------------------------------------------------------
/pkgs/froute/src/react-bind.spec.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { expectType } from "tsd";
3 | import { renderHook } from "@testing-library/react-hooks";
4 | import { render, act } from "@testing-library/react";
5 | import {
6 | FrouteContext,
7 | useBeforeRouteChange,
8 | useFrouteRouter,
9 | useHistoryState,
10 | useLocation,
11 | useNavigation,
12 | useParams,
13 | useRouteComponent,
14 | useRouter,
15 | useUrlBuilder,
16 | } from "./react-bind";
17 | import { RouteDefinition, routeOf } from "./RouteDefiner";
18 | import { createRouter, RouterContext } from "./RouterContext";
19 | import { waitTick } from "../spec/utils";
20 | import { rescue } from "@hanakla/rescue";
21 |
22 | describe("react-bind", () => {
23 | const routes = {
24 | usersShow: routeOf("/users/:id")
25 | .state(() => ({ hist: 1 }))
26 | .action({
27 | component: () => {
28 | const Component = () => {
29 | const params = useParams(routes.usersShow);
30 | return I am user {params.id}
;
31 | };
32 | return new Promise((resolve) =>
33 | setTimeout(() => resolve(Component), 100)
34 | );
35 | },
36 | }),
37 | userArtworks: routeOf("/users/:id/artworks/:artworkId").action({
38 | component: () => {
39 | return () => {
40 | const params = useParams();
41 | return (
42 |
43 | Here is Artwork {params.artworkId} for user {params.id}
44 |
45 | );
46 | };
47 | },
48 | }),
49 | };
50 |
51 | const createWrapper = (router: RouterContext): React.FC => {
52 | return ({ children }) => (
53 | {children}
54 | );
55 | };
56 |
57 | const createAndRenderRouter = async >(
58 | url: string,
59 | expectRoute: R
60 | ) => {
61 | const router = createRouter(routes);
62 | await router.navigate(url);
63 |
64 | return renderHook(() => useFrouteRouter(expectRoute), {
65 | wrapper: createWrapper(router),
66 | });
67 | };
68 |
69 | describe("useRouter", () => {
70 | it("Should emit events", async () => {
71 | const router = createRouter({
72 | users: routeOf("/users/:id").action({
73 | component: () => () => null,
74 | preload: () => waitTick(500),
75 | }),
76 | });
77 |
78 | await router.navigate("/users/1");
79 |
80 | const { result } = renderHook(() => useRouter(), {
81 | wrapper: createWrapper(router),
82 | });
83 |
84 | const startSpy = vi.fn();
85 | result.current.events.on("routeChangeStart", startSpy);
86 |
87 | const completeSpy = vi.fn();
88 | result.current.events.on("routeChangeComplete", completeSpy);
89 |
90 | const promise = router.navigate("/users/2", { action: "PUSH" });
91 | await waitTick();
92 | expect(startSpy).toBeCalled();
93 | expect(completeSpy).not.toBeCalled();
94 |
95 | await promise;
96 | expect(completeSpy).toBeCalled();
97 | });
98 |
99 | it("Should capture error and emit event", async () => {
100 | const router = createRouter({
101 | error: routeOf("/error").action({
102 | component: () => () => null,
103 | preload: () => {
104 | throw new Error("ok");
105 | },
106 | }),
107 | });
108 |
109 | await router.navigate("/");
110 |
111 | const { result } = renderHook(() => useRouter(), {
112 | wrapper: createWrapper(router),
113 | });
114 |
115 | const errorSpy = vi.fn();
116 | result.current.events.on("routeChangeError", errorSpy);
117 |
118 | const [, error] = await rescue(() => {
119 | return router.navigate("/error", { action: "PUSH" });
120 | });
121 |
122 | expect(error).not.toEqual(null);
123 | expect(errorSpy).toBeCalled();
124 | expect(errorSpy.mock.calls[0][0]).toMatchObject({ message: "ok" });
125 | });
126 | });
127 |
128 | describe("useFrouteRouter", () => {
129 | it("location is passed correctly", async () => {
130 | const {
131 | result: { current },
132 | } = await createAndRenderRouter("/users/1?a=1#1", routes.usersShow);
133 |
134 | expect(current.location.pathname).toBe("/users/1");
135 | expect(current.location.search).toBe("?a=1");
136 | expect(current.location.hash).toBe("#1");
137 | });
138 |
139 | it("searchQuery passed correctly", async () => {
140 | const {
141 | result: { current },
142 | } = await createAndRenderRouter(
143 | "/users/1?a=1&b=2&b=3#1",
144 | routes.usersShow
145 | );
146 |
147 | expect(current.searchQuery).toMatchObject({ a: "1", b: ["2", "3"] });
148 | });
149 |
150 | it("should get / set history state correctly", async () => {
151 | const {
152 | result: { current },
153 | } = await createAndRenderRouter("/users/1", routes.usersShow);
154 |
155 | expect(current.historyState.get()).toMatchObject({ hist: 1 });
156 |
157 | const eventSpy = vi.fn();
158 | current.events.on("routeChangeStart", eventSpy);
159 | current.historyState.set({ hist: 2 });
160 |
161 | expect(current.historyState.get()).toMatchObject({ hist: 2 });
162 | expect(eventSpy).not.toBeCalled();
163 | });
164 |
165 | it("buildPath passed correctly", async () => {
166 | const {
167 | result: { current },
168 | } = await createAndRenderRouter("/users/1?a=1#1", routes.usersShow);
169 |
170 | expect(current.buildPath(routes.usersShow, { id: "1" })).toBe("/users/1");
171 | });
172 |
173 | it("Type inference check", async () => {
174 | const router = createRouter(routes);
175 | await router.navigate("/users/1");
176 |
177 | const {
178 | result: { current },
179 | } = renderHook(() => useFrouteRouter(routes.usersShow), {
180 | wrapper: createWrapper(router),
181 | });
182 |
183 | expectType<{ id: string }>(current.query);
184 | expectType(current.query.some_query);
185 | });
186 | });
187 |
188 | describe("useRouteComponent", () => {
189 | it("test", async () => {
190 | const router = createRouter(routes);
191 |
192 | await act(async () => {
193 | await router.navigate("/users/1");
194 | await router.preloadCurrent();
195 | });
196 |
197 | const App = () => {
198 | const { PageComponent } = useRouteComponent();
199 | return PageComponent ? : null;
200 | };
201 |
202 | const result = render(, { wrapper: createWrapper(router) });
203 | expect(result.container.innerHTML).toMatchInlineSnapshot(
204 | `"I am user 1
"`
205 | );
206 |
207 | await act(async () => {
208 | await router.navigate("/users/1/artworks/2", { action: "PUSH" });
209 | });
210 |
211 | result.rerender();
212 |
213 | expect(result.container.innerHTML).toMatchInlineSnapshot(
214 | `"Here is Artwork 2 for user 1
"`
215 | );
216 | });
217 | });
218 |
219 | describe("useBeforeRouteChange", () => {
220 | it("Should block and allow", async () => {
221 | const routable = { current: () => false };
222 |
223 | const routes = {
224 | unloadHook: routeOf("/unloadhook")
225 | .state(() => ({ a: "a" }))
226 | .action({
227 | component: () => () => {
228 | useBeforeRouteChange(() => routable.current(), []);
229 | return null;
230 | },
231 | }),
232 | };
233 |
234 | const router = createRouter(routes);
235 | await router.navigate("/unloadhook", { action: "PUSH" });
236 |
237 | const App = () => {
238 | const { PageComponent } = useRouteComponent();
239 | return PageComponent ? : null;
240 | };
241 |
242 | render(, { wrapper: createWrapper(router) });
243 |
244 | routable.current = () => false;
245 | await act(() => router.navigate("/", { action: "PUSH" }));
246 | expect(router.getCurrentLocation().pathname).toBe("/unloadhook");
247 |
248 | routable.current = () => true;
249 | await act(() => router.navigate("/", { action: "PUSH" }));
250 | expect(router.getCurrentLocation().pathname).toBe("/");
251 | });
252 |
253 | it("Should replace state on block", async () => {
254 | const routes = {
255 | unloadHook: routeOf("/unloadhook").action({
256 | component: () => () => {
257 | useBeforeRouteChange(() => false, []);
258 | return null;
259 | },
260 | }),
261 | };
262 |
263 | const router = createRouter(routes);
264 | router.navigate("/unloadhook");
265 |
266 | expect(router.getHistoryState()).toBe(null);
267 | await router.navigate("/unloadhook", {
268 | action: "REPLACE",
269 | state: { a: "ok" },
270 | });
271 | expect(router.getHistoryState()).toEqual({ a: "ok" });
272 | });
273 | });
274 |
275 | describe("deprecated hooks", () => {
276 | describe("useLocation", () => {
277 | it("Should correctry parsed complex url", async () => {
278 | const router = createRouter(routes);
279 | await router.navigate("/users/1?q=1#hash");
280 |
281 | const result = renderHook(() => useLocation(), {
282 | wrapper: createWrapper(router),
283 | });
284 |
285 | expect(result.result.current).toMatchObject({
286 | hash: "#hash",
287 | key: expect.not.stringMatching(/^$/),
288 | pathname: "/users/1",
289 | query: {
290 | q: "1",
291 | },
292 | search: "?q=1",
293 | state: {
294 | hist: 1,
295 | },
296 | });
297 | });
298 |
299 | it("in 404, returns location and empty query", async () => {
300 | const router = createRouter(routes);
301 | await router.navigate("/notfound");
302 |
303 | const result = renderHook(() => useLocation(), {
304 | wrapper: createWrapper(router),
305 | });
306 |
307 | expect(result.result.current).toMatchObject({
308 | hash: "",
309 | key: expect.not.stringMatching(/^$/),
310 | pathname: "/notfound",
311 | query: {},
312 | search: "",
313 | state: null,
314 | });
315 | });
316 | });
317 |
318 | describe("useHistoryState", () => {
319 | it("get / set", async () => {
320 | const router = createRouter(routes);
321 | await router.navigate("/users");
322 |
323 | const {
324 | result: {
325 | current: [get, set],
326 | },
327 | rerender,
328 | } = renderHook(() => useHistoryState(routes.usersShow), {
329 | wrapper: createWrapper(router),
330 | });
331 |
332 | expect(get()).toMatchInlineSnapshot(`null`);
333 |
334 | set({ hist: 1 });
335 | rerender();
336 |
337 | expect(get()).toMatchInlineSnapshot(`
338 | {
339 | "hist": 1,
340 | }
341 | `);
342 | });
343 |
344 | it("Logging if expected route isnt match", async () => {
345 | // vi.mock("./utils", () => ({
346 | // isDevelopment: true,
347 | // canUseDDOM: () => true,
348 | // }));
349 |
350 | const router = createRouter(routes);
351 | await router.navigate("/users");
352 |
353 | // eslint-disable-next-line @typescript-eslint/no-empty-function
354 | const spy = vi.spyOn(console, "warn").mockImplementation(() => {});
355 | renderHook(() => useHistoryState(routes.userArtworks), {
356 | wrapper: createWrapper(router),
357 | });
358 |
359 | expect(spy.mock.calls.length).toBe(1);
360 | });
361 | });
362 |
363 | describe("useParams", () => {
364 | it("test", async () => {
365 | const router = createRouter(routes);
366 |
367 | await router.navigate("/users/1");
368 | const result = renderHook(
369 | () => {
370 | return useParams(routes.usersShow);
371 | },
372 | {
373 | wrapper: createWrapper(router),
374 | }
375 | );
376 |
377 | expect(result.result.current).toMatchInlineSnapshot(`
378 | {
379 | "id": "1",
380 | }
381 | `);
382 |
383 | await router.navigate("/users/1/artworks/1");
384 | result.rerender();
385 |
386 | expect(result.result.current).toMatchInlineSnapshot(`
387 | {
388 | "artworkId": "1",
389 | "id": "1",
390 | }
391 | `);
392 | });
393 | });
394 |
395 | describe("useNavigation", () => {
396 | it("", async () => {
397 | const router = createRouter(routes);
398 | await router.navigate("/users/1");
399 |
400 | const { result } = renderHook(useNavigation, {
401 | wrapper: createWrapper(router),
402 | });
403 |
404 | result.current.push(routes.usersShow, { id: "2" });
405 | await waitTick(200);
406 |
407 | expect(router.getCurrentLocation()).toMatchObject({
408 | hash: "",
409 | pathname: "/users/2",
410 | search: "",
411 | state: {
412 | __froute: {
413 | scrollX: 0,
414 | scrollY: 0,
415 | },
416 | app: { hist: 1 },
417 | },
418 | });
419 |
420 | expect(location.href).toMatchInlineSnapshot(
421 | `"http://localhost/users/2"`
422 | );
423 |
424 | result.current.push(routes.userArtworks, { id: "1", artworkId: "2" });
425 | await waitTick(200);
426 |
427 | expect(router.getCurrentLocation()).toMatchObject({
428 | hash: "",
429 | pathname: "/users/1/artworks/2",
430 | search: "",
431 | state: {
432 | __froute: {
433 | scrollX: 0,
434 | scrollY: 0,
435 | },
436 | app: null,
437 | },
438 | });
439 |
440 | expect(location.href).toMatchInlineSnapshot(
441 | `"http://localhost/users/1/artworks/2"`
442 | );
443 | });
444 | });
445 |
446 | describe("useUrlBuilder", () => {
447 | it("works", () => {
448 | const router = createRouter(routes);
449 |
450 | const { result } = renderHook(useUrlBuilder, {
451 | wrapper: createWrapper(router),
452 | });
453 |
454 | expect(
455 | result.current.buildPath(routes.usersShow, { id: "1" })
456 | ).toMatchInlineSnapshot(`"/users/1"`);
457 | });
458 | });
459 | });
460 | });
461 |
--------------------------------------------------------------------------------