├── .browserslistrc ├── .editorconfig ├── .vscode └── settings.json ├── .yarnrc.yml ├── .husky └── pre-commit ├── pkgs ├── froute │ ├── .prettierignore │ ├── tsconfig.test.json │ ├── .npmignore │ ├── spec │ │ ├── setup.ts │ │ ├── utils.tsx │ │ └── fixtures │ │ │ └── routes.tsx │ ├── src │ │ ├── components │ │ │ ├── ResponseCode.tsx │ │ │ ├── Redirect.tsx │ │ │ ├── FrouteLink.tsx │ │ │ ├── FrouteLink.spec.tsx │ │ │ ├── Link.tsx │ │ │ └── Link.spec.tsx │ │ ├── routing.spec.ts │ │ ├── FrouteHistoryState.ts │ │ ├── RouterUtils.spec.ts │ │ ├── index.ts │ │ ├── builder.ts │ │ ├── RouterUtils.ts │ │ ├── RouterEvents.ts │ │ ├── utils.spec.ts │ │ ├── RouteDefiner.spec.tsx │ │ ├── builder.spec.ts │ │ ├── utils.ts │ │ ├── index.spec.tsx │ │ ├── routing.ts │ │ ├── RouteDefiner.ts │ │ ├── RouterContext.spec.ts │ │ ├── react-bind.tsx │ │ ├── RouterContext.ts │ │ └── react-bind.spec.tsx │ ├── tsconfig.json │ ├── vite.config.ts │ ├── package.json │ ├── CHANGELOG.md │ └── README.md └── examples │ ├── README.md │ ├── tsconfig.json │ ├── src │ ├── index.html │ ├── components │ │ └── App.tsx │ ├── pages │ │ ├── index.tsx │ │ ├── beforeunload.tsx │ │ └── user.tsx │ ├── domains │ │ └── index.ts │ ├── routes.ts │ └── index.tsx │ └── package.json ├── .github ├── dependabot.yml └── workflows │ ├── publish.yml │ └── testing.yml ├── .eslintrc ├── LICENSE ├── package.json ├── .gitignore └── README.md /.browserslistrc: -------------------------------------------------------------------------------- 1 | Chrome >= 71 2 | ios_saf >= 13 3 | safari >= 13 4 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | [*.{ts,tsx,json}] 2 | charset = utf-8 3 | indent_size = 2 4 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib" 3 | } -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | nodeLinker: node-modules 2 | 3 | yarnPath: .yarn/releases/yarn-3.3.1.cjs 4 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | yarn lint-staged 5 | -------------------------------------------------------------------------------- /pkgs/froute/.prettierignore: -------------------------------------------------------------------------------- 1 | # prettier doesn't support Template Literal Type syntax 2 | src/RouteDefiner.ts 3 | -------------------------------------------------------------------------------- /pkgs/examples/README.md: -------------------------------------------------------------------------------- 1 | # @fleur/froute CSR example 2 | 3 | This is `@fleur/froute` example create with `create-react-app`. 4 | -------------------------------------------------------------------------------- /pkgs/examples/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "jsx": "react-jsx" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /pkgs/froute/tsconfig.test.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "module": "commonjs" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /pkgs/froute/.npmignore: -------------------------------------------------------------------------------- 1 | /src 2 | /spec 3 | yarn-error.log 4 | .browserslistrc 5 | .editorconfig 6 | .eslintrc 7 | .prettierignore 8 | /*.ts 9 | /*.js 10 | /*.json 11 | -------------------------------------------------------------------------------- /pkgs/froute/spec/setup.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line @typescript-eslint/no-empty-function 2 | const noop = () => {}; 3 | Object.defineProperty(window, "scrollTo", { value: noop, writable: true }); 4 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: npm 4 | directory: "/" 5 | schedule: 6 | interval: weekly 7 | open-pull-requests-limit: 10 8 | allow: 9 | - dependency-type: direct 10 | 11 | -------------------------------------------------------------------------------- /pkgs/examples/src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Froute testing 7 | 8 | 9 | 10 | 11 |
12 | 13 | 14 | -------------------------------------------------------------------------------- /pkgs/froute/src/components/ResponseCode.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactNode, useMemo } from "react"; 2 | import { useRouterContext } from "../react-bind"; 3 | 4 | type Props = { status: number; children?: ReactNode }; 5 | 6 | export const ResponseCode = ({ status, children }: Props) => { 7 | const router = useRouterContext(); 8 | 9 | useMemo(() => { 10 | router.statusCode = status; 11 | }, []); 12 | 13 | return <>{children}; 14 | }; 15 | -------------------------------------------------------------------------------- /pkgs/examples/src/components/App.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { useRouteComponent, ResponseCode } from "@fleur/froute"; 3 | import {} from "../../../src"; 4 | 5 | export const App = () => { 6 | const { PageComponent } = useRouteComponent(); 7 | return ( 8 |
9 | {PageComponent ? ( 10 | 11 | ) : ( 12 | 404 Not Found 13 | )} 14 |
15 | ); 16 | }; 17 | -------------------------------------------------------------------------------- /pkgs/froute/src/components/Redirect.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactNode, useMemo } from "react"; 2 | import { useRouterContext } from "../react-bind"; 3 | 4 | type Props = { url: string; status?: number; children?: ReactNode }; 5 | 6 | export const Redirect = ({ url, status = 302, children }: Props) => { 7 | const router = useRouterContext(); 8 | 9 | useMemo(() => { 10 | router.statusCode = status; 11 | router.redirectTo = url; 12 | }, []); 13 | 14 | return <>{children}; 15 | }; 16 | -------------------------------------------------------------------------------- /pkgs/froute/spec/utils.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactNode } from "react"; 2 | import { FrouteContext } from "../src/react-bind"; 3 | import { RouterContext } from "../src/RouterContext"; 4 | 5 | export const createComponentWrapper = ( 6 | router: RouterContext 7 | ): React.FC<{ children: ReactNode }> => { 8 | return ({ children }) => ( 9 | {children} 10 | ); 11 | }; 12 | 13 | export const waitTick = (ms?: number) => 14 | new Promise((resolve) => setTimeout(resolve, ms)); 15 | -------------------------------------------------------------------------------- /pkgs/froute/src/routing.spec.ts: -------------------------------------------------------------------------------- 1 | import { routeOf } from "./RouteDefiner"; 2 | import { matchByRoutes } from "./routing"; 3 | 4 | describe("routing", () => { 5 | const routes = { 6 | users: routeOf("/users/:userId"), 7 | }; 8 | 9 | describe("matchByRoutes", () => { 10 | it("Should match", () => { 11 | const match = matchByRoutes("/users/1", routes); 12 | expect(match).not.toBe(null); 13 | }); 14 | 15 | it("Should match complex url", () => { 16 | const match = matchByRoutes("/users/1?a=1#a", routes); 17 | expect(match).not.toBe(null); 18 | }); 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "extends": [ 4 | "eslint:recommended", 5 | "plugin:@typescript-eslint/recommended", 6 | "plugin:prettier/recommended" 7 | ], 8 | "plugins": ["@typescript-eslint"], 9 | "env": { "browser": true, "node": true, "es6": true }, 10 | "parser": "@typescript-eslint/parser", 11 | "parserOptions": { 12 | "sourceType": "module" 13 | }, 14 | "rules": { 15 | "@typescript-eslint/no-explicit-any": [0], 16 | "@typescript-eslint/explicit-module-boundary-types": [0], 17 | "@typescript-eslint/ban-ts-comment": [0], 18 | "@typescript-eslint/no-unused-vars": [0] 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /pkgs/examples/src/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { buildPath, Link } from "@fleur/froute"; 3 | import { routes } from "../routes"; 4 | 5 | export default () => { 6 | return ( 7 |
8 | Here is index 9 |
10 | Go to user 11 |
12 | 13 | Go to not found user 14 | 15 |
16 | 17 | Go to beforeunload check 18 | 19 |
20 | ); 21 | }; 22 | -------------------------------------------------------------------------------- /pkgs/examples/src/pages/beforeunload.tsx: -------------------------------------------------------------------------------- 1 | import { FrouteLink, useBeforeRouteChange } from "@fleur/froute"; 2 | import { useEffect } from "react"; 3 | import { routes } from "../routes"; 4 | 5 | export default () => { 6 | useBeforeRouteChange(() => { 7 | // console.trace("hi"); 8 | return confirm("Really back?"); 9 | }, []); 10 | 11 | useEffect(() => { 12 | console.log("mouted"); 13 | return () => console.log("unmounted"); 14 | }, []); 15 | 16 | return ( 17 |
18 |

beforeunload test

19 | 20 | Back to Home 21 | 22 |
23 | ); 24 | }; 25 | -------------------------------------------------------------------------------- /pkgs/examples/src/domains/index.ts: -------------------------------------------------------------------------------- 1 | import Fleur, { action, actions, operations, reducerStore } from "@fleur/fleur"; 2 | 3 | export const UserOps = operations({ 4 | fetchUser(context, id: string) { 5 | if (id === "404") return; 6 | 7 | context.dispatch(UserActions.usersFetched, [id]); 8 | }, 9 | }); 10 | 11 | const UserActions = actions("User", { 12 | usersFetched: action(), 13 | }); 14 | 15 | export const UserStore = reducerStore("User", () => ({ 16 | fetchedIds: [] as string[], 17 | })).listen(UserActions.usersFetched, (draft, fetchedIds) => { 18 | draft.fetchedIds = fetchedIds; 19 | }); 20 | 21 | export const fleurApp = new Fleur({ 22 | stores: [UserStore], 23 | }); 24 | -------------------------------------------------------------------------------- /pkgs/froute/src/FrouteHistoryState.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line @typescript-eslint/ban-types 2 | export type StateBase = object | null; 3 | 4 | export type FrouteHistoryState = { 5 | __froute?: { 6 | sid: string | undefined | null; 7 | scrollX: number; 8 | scrollY: number; 9 | }; 10 | app: S; 11 | }; 12 | 13 | export const isFrouteState = (state: any): state is FrouteHistoryState => { 14 | if (state && typeof state.__froute === "object") return true; 15 | return false; 16 | }; 17 | 18 | export const createFrouteHistoryState = ( 19 | sid: string | undefined | null, 20 | appState: StateBase = null 21 | ): FrouteHistoryState => ({ 22 | __froute: { sid, scrollX: 0, scrollY: 0 }, 23 | app: appState, 24 | }); 25 | -------------------------------------------------------------------------------- /pkgs/examples/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "name": "examples", 4 | "version": "0.0.0", 5 | "main": "index.js", 6 | "license": "MIT", 7 | "scripts": { 8 | "start": "parcel src/index.html --open", 9 | "watch": "yarn start" 10 | }, 11 | "workspaces": { 12 | "nohoist": [ 13 | "parcel", 14 | "parcel/**" 15 | ] 16 | }, 17 | "devDependencies": { 18 | "@types/domready": "^1.0.0", 19 | "@types/react": "^17.0.38", 20 | "@types/react-dom": "^17.0.0", 21 | "parcel": "^2.8.3" 22 | }, 23 | "dependencies": { 24 | "@fleur/fleur": "^3.0.1", 25 | "@fleur/froute": "1.0.0-beta.12", 26 | "@fleur/react": "^4.0.1", 27 | "domready": "^1.0.8", 28 | "react": "^17.0.2", 29 | "react-dom": "^17.0.2", 30 | "regenerator-runtime": "^0.13.9" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /pkgs/examples/src/pages/user.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { ResponseCode, useParams, Link, useUrlBuilder } from "@fleur/froute"; 3 | import { useStore } from "@fleur/react"; 4 | import { routes } from "../routes"; 5 | import { UserStore } from "../domains"; 6 | 7 | export default () => { 8 | const params = useParams(routes.userShow); 9 | const { buildPath } = useUrlBuilder(); 10 | const userIds = useStore((get) => get(UserStore).state.fetchedIds); 11 | 12 | if (!userIds.includes(params.id)) { 13 | return User not found; 14 | } 15 | 16 | return ( 17 |
18 | Here is user page for id:{params.id} 19 |
20 | Fetched user ids: {userIds.join(",")} 21 |
22 | Back to home 23 |
24 | ); 25 | }; 26 | -------------------------------------------------------------------------------- /pkgs/froute/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "types": ["vitest/globals"], 4 | "baseUrl": "./src", 5 | "target": "es2015", 6 | "module": "es2015", 7 | "moduleResolution": "node", 8 | "esModuleInterop": true, 9 | "allowSyntheticDefaultImports": true, 10 | "noUnusedLocals": true, 11 | "noUnusedParameters": true, 12 | "noImplicitAny": true, 13 | "noImplicitReturns": true, 14 | "strictNullChecks": true, 15 | "skipLibCheck": false, 16 | // "strict": true, 17 | "jsx": "react", 18 | "forceConsistentCasingInFileNames": true, 19 | "lib": ["es2015", "es2016", "es2017", "dom"], 20 | "importHelpers": false, 21 | "noEmitHelpers": false, 22 | "outDir": "dist" 23 | }, 24 | // "files": ["src/index.ts"], 25 | "include": ["src/"], 26 | "exclude": ["src/**/*.spec.ts", "test_lib/", "dist/"] 27 | } 28 | -------------------------------------------------------------------------------- /pkgs/examples/src/routes.ts: -------------------------------------------------------------------------------- 1 | import { routeBy, routeOf } from "@fleur/froute"; 2 | import { OperationContext } from "@fleur/fleur"; 3 | import { UserOps } from "./domains"; 4 | 5 | export const routes = { 6 | index: routeOf("/").action({ 7 | component: () => import("./pages/index"), 8 | preload: () => new Promise((r) => setTimeout(r, 1000)), 9 | }), 10 | userShow: routeOf("/users/:id").action({ 11 | component: () => import("./pages/user"), 12 | preload: (context: OperationContext, { id }) => 13 | Promise.all([ 14 | new Promise((r) => setTimeout(r, 1000)), 15 | context.executeOperation(UserOps.fetchUser, id), 16 | ]), 17 | }), 18 | beforeUnload: routeOf("/beforeunload").action({ 19 | component: () => import("./pages/beforeunload"), 20 | preload: () => new Promise((r) => setTimeout(r, 1000)), 21 | }), 22 | }; 23 | -------------------------------------------------------------------------------- /pkgs/froute/spec/fixtures/routes.tsx: -------------------------------------------------------------------------------- 1 | import { useParams } from "../../src/react-bind"; 2 | import { routeOf } from "../../src/RouteDefiner"; 3 | 4 | export const complexRoutes = { 5 | usersShow: routeOf("/users/:id").action({ 6 | component: () => { 7 | const Component = () => { 8 | const params = useParams(complexRoutes.usersShow); 9 | return
I am user {params.id}
; 10 | }; 11 | return new Promise((resolve) => 12 | setTimeout(() => resolve(Component), 100) 13 | ); 14 | }, 15 | }), 16 | userArtworks: routeOf("/users/:id/artworks/:artworkId").action({ 17 | component: () => { 18 | return () => { 19 | const params = useParams(); 20 | return ( 21 |
22 | Here is Artwork {params.artworkId} for user {params.id} 23 |
24 | ); 25 | }; 26 | }, 27 | }), 28 | }; 29 | -------------------------------------------------------------------------------- /pkgs/froute/vite.config.ts: -------------------------------------------------------------------------------- 1 | import {} from "vitest"; 2 | 3 | import { join } from "path"; 4 | import { defineConfig } from "vite"; 5 | import dts from "vite-plugin-dts"; 6 | 7 | export default defineConfig({ 8 | build: { 9 | outDir: "dist", 10 | lib: { 11 | entry: "src/index.ts", 12 | formats: ["cjs", "es"], 13 | fileName: "index", 14 | }, 15 | rollupOptions: { 16 | external: ["react", "history", "path-to-regexp"], 17 | }, 18 | }, 19 | plugins: [ 20 | dts({ 21 | tsConfigFilePath: join(__dirname, "tsconfig.json"), 22 | }), 23 | ], 24 | test: { 25 | globals: true, 26 | environment: "jsdom", 27 | environmentOptions: { 28 | jsdom: { 29 | url: "http://localhost/", 30 | }, 31 | }, 32 | typecheck: { 33 | tsconfig: "tsconfig.test.json", 34 | }, 35 | include: ["src/**.spec.{ts,tsx}"], 36 | }, 37 | }); 38 | -------------------------------------------------------------------------------- /pkgs/froute/src/RouterUtils.spec.ts: -------------------------------------------------------------------------------- 1 | import { routeOf } from "./RouteDefiner"; 2 | import { createRouter } from "./RouterContext"; 3 | import { combineRouteResolver } from "./RouterUtils"; 4 | import { FrouteMatch } from "./routing"; 5 | 6 | describe("RouterUtils", () => { 7 | const routes = { 8 | users: routeOf("/users/:userId"), 9 | }; 10 | 11 | describe(combineRouteResolver.name, () => { 12 | it("Should call to second resolver and reject routing", () => { 13 | const spy = vi.fn((): FrouteMatch | false | null => false); 14 | const spy2 = vi.fn((): FrouteMatch | false | null => null); 15 | 16 | const resolver = combineRouteResolver(spy, spy2); 17 | const router = createRouter(routes, { resolver }); 18 | 19 | expect(router.resolveRoute("/users/1")).toBe(null); 20 | expect(spy.mock.calls.length).toBe(1); 21 | expect(spy2.mock.calls.length).toBe(1); 22 | }); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /pkgs/examples/src/index.tsx: -------------------------------------------------------------------------------- 1 | import "regenerator-runtime"; 2 | import domready from "domready"; 3 | import React from "react"; 4 | import ReactDOM from "react-dom"; 5 | import { createRouter, FrouteContext } from "@fleur/froute"; 6 | import { FleurContext } from "@fleur/react"; 7 | import { App } from "./components/App"; 8 | import { routes } from "./routes"; 9 | import { fleurApp } from "./domains"; 10 | 11 | domready(async () => { 12 | const root = document.getElementById("root"); 13 | const context = fleurApp.createContext(); 14 | const router = createRouter(routes, { 15 | preloadContext: context, 16 | }); 17 | 18 | await router.navigate(location.href); 19 | await router.preloadCurrent(); 20 | 21 | ReactDOM.render( 22 | 23 | 24 | 25 | 26 | , 27 | root 28 | ); 29 | 30 | console.log({ responseCode: router.statusCode }); 31 | }); 32 | -------------------------------------------------------------------------------- /pkgs/froute/src/index.ts: -------------------------------------------------------------------------------- 1 | export { 2 | routeBy, 3 | routeOf, 4 | type ParamsOfRoute, 5 | type StateOfRoute, 6 | type RouteDefinition, 7 | } from "./RouteDefiner"; 8 | export { createRouter, type RouterOptions } from "./RouterContext"; 9 | export { combineRouteResolver } from "./RouterUtils"; 10 | export { 11 | useLocation, 12 | useNavigation, 13 | useParams, 14 | useRouteComponent, 15 | useUrlBuilder, 16 | useBeforeRouteChange, 17 | useFrouteRouter, 18 | FrouteContext, 19 | type FrouteNavigator, 20 | // Next.js compat 21 | useRouter, 22 | withRouter, 23 | type RouterProps, 24 | type UseRouter, 25 | } from "./react-bind"; 26 | export { Link } from "./components/Link"; 27 | export { FrouteLink } from "./components/FrouteLink"; 28 | export { ResponseCode } from "./components/ResponseCode"; 29 | export { Redirect } from "./components/Redirect"; 30 | export { matchByRoutes, isMatchToRoute, type FrouteMatch } from "./routing"; 31 | export { buildPath } from "./builder"; 32 | -------------------------------------------------------------------------------- /pkgs/froute/src/builder.ts: -------------------------------------------------------------------------------- 1 | import { compile } from "path-to-regexp"; 2 | import { isEmptyObject, stringifyQueryString } from "./utils"; 3 | import { RouteDefinition, ParamsOfRoute } from "./RouteDefiner"; 4 | 5 | export type BuildPath = >( 6 | def: T, 7 | params: ParamsOfRoute, 8 | /** Object (encode unnecessory) or query string without `?` prefix */ 9 | query?: { [key: string]: string | string[] } | string 10 | ) => string; 11 | 12 | export const buildPath: BuildPath = (def, params, query?) => { 13 | const pathname = compile(def.toPath(), { encode: encodeURIComponent })( 14 | params 15 | ); 16 | 17 | let queryPart: string; 18 | 19 | if (query == null) { 20 | queryPart = ""; 21 | } else if (typeof query === "string") { 22 | queryPart = query.length > 0 ? `?${query}` : ""; 23 | } else { 24 | queryPart = isEmptyObject(query) ? "" : "?" + stringifyQueryString(query); 25 | } 26 | 27 | return query ? `${pathname}${queryPart}` : pathname; 28 | }; 29 | -------------------------------------------------------------------------------- /pkgs/froute/src/RouterUtils.ts: -------------------------------------------------------------------------------- 1 | import { 2 | FrouteMatch, 3 | RouteResolver, 4 | RoutingOnlyRouterContext, 5 | } from "./routing"; 6 | 7 | /** 8 | * RouteResolver for combineRouteResolver. 9 | * It returns `false`, skip to next resolver. 10 | * If does not match any route expect to return `null`. 11 | */ 12 | export interface CombinableResolver { 13 | ( 14 | pathname: string, 15 | match: FrouteMatch | null, 16 | context: RoutingOnlyRouterContext 17 | ): FrouteMatch | null | false; 18 | } 19 | 20 | export const combineRouteResolver = ( 21 | ...resolvers: CombinableResolver[] 22 | ): RouteResolver => { 23 | return (pathname, match, context) => { 24 | let prevMatch: FrouteMatch | null = match; 25 | let prevPath: string = pathname; 26 | 27 | for (const resolver of resolvers) { 28 | const result = resolver(prevPath, prevMatch, context); 29 | 30 | if (result === false) continue; 31 | if (result === null) return null; 32 | prevMatch = result; 33 | prevPath = result.match.path!; 34 | } 35 | 36 | return prevMatch; 37 | }; 38 | }; 39 | -------------------------------------------------------------------------------- /pkgs/froute/src/RouterEvents.ts: -------------------------------------------------------------------------------- 1 | interface Events { 2 | routeChangeStart: [url: string]; 3 | routeChangeComplete: [url: string]; 4 | routeChangeError: [err: Error, url: string]; 5 | } 6 | 7 | export type RouterEventsInternal = ReturnType; 8 | export type RouterEvents = Omit, "dispose">; 9 | 10 | export const routerEvents = () => { 11 | const listeners: Record void)[]> = 12 | Object.create(null); 13 | 14 | return { 15 | on( 16 | event: K, 17 | callback: (...args: Events[K]) => void 18 | ) { 19 | (listeners[event] = listeners[event] ?? []).push(callback); 20 | }, 21 | off( 22 | event: K, 23 | callback: (...args: Events[K]) => void 24 | ) { 25 | listeners[event] = (listeners[event] ?? []).filter( 26 | (listener) => listener !== callback 27 | ); 28 | }, 29 | emit(event: K, args: Events[K]) { 30 | (listeners[event] ?? []).forEach((listener) => listener(...args)); 31 | }, 32 | }; 33 | }; 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 fleur.js 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /pkgs/froute/src/components/FrouteLink.tsx: -------------------------------------------------------------------------------- 1 | import React, { forwardRef, useMemo, Ref, ReactElement } from "react"; 2 | import { useUrlBuilder } from "../react-bind"; 3 | import { ParamsOfRoute, RouteDefinition } from "../RouteDefiner"; 4 | import { Link } from "./Link"; 5 | 6 | type NativeProps = Omit< 7 | React.DetailedHTMLProps< 8 | React.AnchorHTMLAttributes, 9 | HTMLAnchorElement 10 | >, 11 | "href" 12 | >; 13 | 14 | type OwnProps> = { 15 | ref?: Ref; 16 | to: R; 17 | params: ParamsOfRoute; 18 | query?: { [key: string]: string | string[] }; 19 | }; 20 | 21 | type Props> = NativeProps & OwnProps; 22 | 23 | type FrouteLink = >( 24 | props: Props 25 | ) => ReactElement | null; 26 | 27 | export const FrouteLink: FrouteLink = forwardRef( 28 | ({ to, params, query, ...props }, ref) => { 29 | const { buildPath } = useUrlBuilder(); 30 | 31 | const href = useMemo( 32 | () => buildPath(to, params, query), 33 | [buildPath, to, params, query] 34 | ); 35 | 36 | return ; 37 | } 38 | ); 39 | -------------------------------------------------------------------------------- /pkgs/froute/src/components/FrouteLink.spec.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { render } from "@testing-library/react"; 3 | import { routeOf } from "../RouteDefiner"; 4 | import { createRouter } from "../RouterContext"; 5 | import { createComponentWrapper, waitTick } from "../../spec/utils"; 6 | import { FrouteLink } from "./FrouteLink"; 7 | 8 | describe("FrouteLink", () => { 9 | const routes = { 10 | users: routeOf("/users/:id"), 11 | }; 12 | 13 | it("Click to move location", async () => { 14 | const router = createRouter(routes); 15 | await router.navigate("/"); 16 | 17 | const spy = vi.spyOn(router, "navigate"); 18 | 19 | const result = render( 20 | 21 | Link 22 | , 23 | { wrapper: createComponentWrapper(router) } 24 | ); 25 | 26 | expect(location.href).toMatchInlineSnapshot(`"http://localhost/"`); 27 | 28 | const link = await result.findByTestId("link"); 29 | link.click(); 30 | await waitTick(); 31 | 32 | expect(location.href).toMatchInlineSnapshot(`"http://localhost/users/1"`); 33 | expect(spy.mock.calls.length).toBe(1); 34 | 35 | link.click(); 36 | await waitTick(); 37 | expect(spy.mock.calls.length).toBe(2); 38 | }); 39 | }); 40 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "name": "froute", 4 | "version": "0.0.0", 5 | "main": "index.js", 6 | "author": "Hanakla ", 7 | "license": "MIT", 8 | "workspaces": [ 9 | "pkgs/*" 10 | ], 11 | "scripts": { 12 | "watch:build": "wsrun -p @fleur/froute -c watch", 13 | "watch:example": "wsrun -p examples -c watch", 14 | "build": "wsrun -m -c build", 15 | "test": "wsrun -m --no-prefix -c test", 16 | "sync:readme": "cp ./README.md ./pkgs/froute/ && :", 17 | "example:start": "wsrun -p examples -c start", 18 | "postinstall": "husky install" 19 | }, 20 | "devDependencies": { 21 | "@typescript-eslint/eslint-plugin": "^3.10.1", 22 | "@typescript-eslint/parser": "^3.10.1", 23 | "cross-env": "^7.0.2", 24 | "eslint": "^7.7.0", 25 | "eslint-config-prettier": "^6.11.0", 26 | "eslint-plugin-prettier": "^3.1.4", 27 | "husky": "^8.0.3", 28 | "lint-staged": "^10.2.11", 29 | "npm-run-all": "^4.1.5", 30 | "prettier": "^2.5.1", 31 | "wsrun": "^5.2.4" 32 | }, 33 | "lint-staged": { 34 | "README.md": [ 35 | "yarn sync:readme", 36 | "git add ./pkgs/froute/README.md" 37 | ], 38 | "*.{ts,tsx}": [ 39 | "eslint --fix", 40 | "prettier --write" 41 | ] 42 | }, 43 | "packageManager": "yarn@3.3.1" 44 | } 45 | -------------------------------------------------------------------------------- /pkgs/froute/src/utils.spec.ts: -------------------------------------------------------------------------------- 1 | import { parse } from "url"; 2 | import * as qs from "querystring"; 3 | import { parseQueryString, parseUrl, stringifyQueryString } from "./utils"; 4 | 5 | describe("utils", () => { 6 | describe("parseUrl", () => { 7 | it.each([ 8 | [""], 9 | ["/"], 10 | ["/aaa/aaa?b=c"], 11 | ["https://example.com:1000"], 12 | ["https://user:pass@example.com:1000/aaaa?a=1#aaa"], 13 | ])("should same result to url.parse", (url) => { 14 | const { slashes, ...original } = parse(url); 15 | expect(parseUrl(url)).toEqual(original); 16 | }); 17 | }); 18 | 19 | describe("parseQueryString", () => { 20 | it.each([ 21 | ["a=1&b[]=1&b[]=2"], 22 | ["a=1&b[]=1&b[]=22&c[][]=11&c[][]=22&c[][]=33"], 23 | ])("parse", (query) => { 24 | const parsed = qs.parse(query); 25 | // console.log(parsed); 26 | expect(parseQueryString(query)).toEqual(parsed); 27 | }); 28 | }); 29 | 30 | describe("stringifyQueryString", () => { 31 | it.each([ 32 | [{ a: "a", b: "b" }], 33 | [{ "a[]": ["1", "2", "3"] }], 34 | [{ a: ["1", "2", "3"], b: "1" }], 35 | ])("parse", (query) => { 36 | const parsed = qs.stringify(query); 37 | // console.log(parsed); 38 | expect(stringifyQueryString(query)).toEqual(parsed); 39 | }); 40 | }); 41 | }); 42 | -------------------------------------------------------------------------------- /pkgs/froute/src/components/Link.tsx: -------------------------------------------------------------------------------- 1 | import { parseUrl } from "../utils"; 2 | import React, { forwardRef, useCallback, MouseEvent } from "react"; 3 | import { useFrouteRouter } from "../react-bind"; 4 | 5 | const isRoutable = (href: string | undefined) => { 6 | const parsed = parseUrl(href ?? ""); 7 | const current = parseUrl(location.href); 8 | 9 | if (!href) return false; 10 | if (href[0] === "#") return false; 11 | if (parsed.protocol && parsed.protocol !== current.protocol) return false; 12 | if (parsed.host && parsed.host !== location.host) return false; 13 | return true; 14 | }; 15 | 16 | const isModifiedEvent = (event: MouseEvent) => 17 | !!(event.metaKey || event.altKey || event.ctrlKey || event.shiftKey); 18 | 19 | export const Link = forwardRef< 20 | HTMLAnchorElement, 21 | React.DetailedHTMLProps< 22 | React.AnchorHTMLAttributes, 23 | HTMLAnchorElement 24 | > 25 | >(({ href, onClick, ...props }, ref) => { 26 | const { push } = useFrouteRouter(); 27 | 28 | const handleClick = useCallback( 29 | (e: React.MouseEvent) => { 30 | if (onClick) onClick(e); 31 | if (e.isDefaultPrevented()) return; 32 | 33 | if (!href) return; 34 | if (isModifiedEvent(e)) return; 35 | if (!isRoutable(href)) return; 36 | if (e.currentTarget.target !== "") return; 37 | 38 | e.preventDefault(); 39 | 40 | const parsed = parseUrl(href); 41 | push( 42 | (parsed.pathname || "") + (parsed.search || "") + (parsed.hash || "") 43 | ); 44 | }, 45 | [onClick, href] 46 | ); 47 | 48 | return ; 49 | }); 50 | -------------------------------------------------------------------------------- /pkgs/froute/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@fleur/froute", 3 | "version": "1.0.8", 4 | "description": "Type safe and flexible router for React", 5 | "repository": { 6 | "url": "https://github.com/fleur-js/froute" 7 | }, 8 | "homepage": "https://github.com/fleur-js/froute/", 9 | "main": "dist/index.js", 10 | "module": "dist/index.esm.js", 11 | "types": "dist/index.d.ts", 12 | "author": "Hanakla ", 13 | "license": "MIT", 14 | "scripts": { 15 | "test": "vitest", 16 | "clean-dist": "rm -rf ./dist", 17 | "watch": "vite build --watch", 18 | "build": "vite build", 19 | "prepublishOnly": "yarn clean-dist && yarn build" 20 | }, 21 | "peerDependencies": { 22 | "react": ">= 16.8.x" 23 | }, 24 | "devDependencies": { 25 | "@hanakla/rescue": "^1.0.3", 26 | "@testing-library/react": "^11.2.2", 27 | "@testing-library/react-hooks": "^3.7.0", 28 | "@types/history": "^4.7.11", 29 | "@types/node": "^20.1.2", 30 | "@types/path-to-regexp": "^1.7.0", 31 | "@types/react": "^17.0.38", 32 | "@typescript-eslint/eslint-plugin": "^5.59.5", 33 | "@typescript-eslint/parser": "^5.59.5", 34 | "eslint": "^8.40.0", 35 | "eslint-config-prettier": "^8.8.0", 36 | "eslint-plugin-prettier": "^4.2.1", 37 | "node-stdlib-browser": "^1.2.0", 38 | "react": "^17.0.2", 39 | "react-dom": "^17.0.2", 40 | "react-test-renderer": "^17.0.2", 41 | "tsd": "^0.14.0", 42 | "typescript": "^5.0.4", 43 | "vite": "^4.3.5", 44 | "vite-plugin-dts": "^2.3.0", 45 | "vite-plugin-node-stdlib-browser": "^0.2.1", 46 | "vitest": "^0.31.0" 47 | }, 48 | "dependencies": { 49 | "history": "^5.0.0", 50 | "jsdom": "^22.0.0", 51 | "path-to-regexp": "^6.1.0" 52 | }, 53 | "volta": { 54 | "node": "18.16.0" 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /pkgs/froute/src/components/Link.spec.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { render } from "@testing-library/react"; 3 | import { routeOf } from "../RouteDefiner"; 4 | import { createRouter } from "../RouterContext"; 5 | import { Link } from "./Link"; 6 | import { createComponentWrapper, waitTick } from "../../spec/utils"; 7 | 8 | describe("Link", () => { 9 | const routes = { 10 | users: routeOf("/users/:id"), 11 | }; 12 | 13 | it("Click to move location", async () => { 14 | const router = createRouter(routes); 15 | await router.navigate("/"); 16 | 17 | const spy = vi.spyOn(router, "navigate"); 18 | const result = render( 19 | 20 | Link 21 | , 22 | { wrapper: createComponentWrapper(router) } 23 | ); 24 | 25 | expect(location.href).toMatchInlineSnapshot(`"http://localhost/"`); 26 | 27 | const link = await result.findByTestId("link"); 28 | link.click(); 29 | await waitTick(); 30 | 31 | expect(location.href).toMatchInlineSnapshot(`"http://localhost/users/1"`); 32 | expect(spy.mock.calls.length).toBe(1); 33 | 34 | link.click(); 35 | await waitTick(); 36 | expect(spy.mock.calls.length).toBe(2); 37 | }); 38 | 39 | it("Click target='_blank' to ignore routing", async () => { 40 | const router = createRouter(routes); 41 | await router.navigate("/"); 42 | 43 | const spy = vi.spyOn(router, "navigate"); 44 | const result = render( 45 | 46 | Link 47 | , 48 | { wrapper: createComponentWrapper(router) } 49 | ); 50 | 51 | expect(location.href).toMatchInlineSnapshot(`"http://localhost/"`); 52 | 53 | const link = await result.findByTestId("link"); 54 | link.click(); 55 | await waitTick(); 56 | 57 | expect(spy.mock.calls.length).toBe(0); 58 | }); 59 | }); 60 | -------------------------------------------------------------------------------- /pkgs/froute/src/RouteDefiner.spec.tsx: -------------------------------------------------------------------------------- 1 | import { buildPath } from "./builder"; 2 | import { routeBy, routeOf } from "./RouteDefiner"; 3 | 4 | describe("RouteDefiner", () => { 5 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 6 | // @ts-ignore 7 | type ExteranalContext = { hi: "hi" }; 8 | 9 | describe("routeBy", () => { 10 | const route = routeBy("/user").param("userId"); 11 | 12 | it("Should resolve path", () => { 13 | expect(route.match("/user/1")).not.toBe(false); 14 | }); 15 | 16 | it("Should build path", () => { 17 | expect(buildPath(route, { userId: "1" })).toMatchInlineSnapshot( 18 | `"/user/1"` 19 | ); 20 | }); 21 | }); 22 | 23 | describe("routeOf", () => { 24 | const route = routeOf("/user/:userId/artworks/:artworkId?").state(() => ({ 25 | userId: "", 26 | })); 27 | 28 | it("Should resolve path", () => { 29 | expect(route.match("/user/1/artworks")).not.toBe(false); 30 | expect(route.match("/user/1/artworks/1")).not.toBe(false); 31 | }); 32 | 33 | it("Should build path", () => { 34 | expect(buildPath(route, { userId: "1" })).toMatchInlineSnapshot( 35 | `"/user/1/artworks"` 36 | ); 37 | 38 | expect( 39 | buildPath(route, { userId: "1", artworkId: "1" }) 40 | ).toMatchInlineSnapshot(`"/user/1/artworks/1"`); 41 | }); 42 | }); 43 | 44 | describe("match", () => { 45 | it("should decode URI component", () => { 46 | const route = routeOf("/search/:tag/:frag").action({ 47 | component: () => () => null, 48 | }); 49 | 50 | const match = route.match( 51 | "/search/%E3%81%86%E3%81%A1%E3%81%AE%E5%AD%90/%E3%81%8B%E3%82%8F%E3%81%84%E3%81%84?ans=%E3%81%9D%E3%81%86%E3%81%A0%E3%81%9E" 52 | ); 53 | 54 | expect(match!.params.tag).toBe("うちの子"); 55 | expect(match!.params.frag).toBe("かわいい"); 56 | expect(match!.query.ans).toBe("そうだぞ"); 57 | }); 58 | }); 59 | }); 60 | -------------------------------------------------------------------------------- /pkgs/froute/src/builder.spec.ts: -------------------------------------------------------------------------------- 1 | // import { route } from "./index"; 2 | import { buildPath } from "./builder"; 3 | import { routeOf } from "./RouteDefiner"; 4 | 5 | describe("index", () => { 6 | describe("buildPath", () => { 7 | it("Should build path", () => { 8 | const def = routeOf("/users/:id").action({ component: () => () => null }); 9 | const def2 = routeOf("/users/:id/comments/:commentId").action({ 10 | component: () => () => null, 11 | }); 12 | 13 | expect(buildPath(def, { id: "1" })).toBe("/users/1"); 14 | expect(buildPath(def2, { id: "1", commentId: "2" })).toBe( 15 | "/users/1/comments/2" 16 | ); 17 | }); 18 | 19 | it("should encode URI cmponent", () => { 20 | const route = routeOf("/search/:tag/:frag").action({ 21 | component: () => () => null, 22 | }); 23 | 24 | const path = buildPath( 25 | route, 26 | { 27 | tag: "うちの子", 28 | frag: "かわいい", 29 | }, 30 | { ans: "そうだぞ" } 31 | ); 32 | 33 | expect(path).toMatchInlineSnapshot( 34 | `"/search/%E3%81%86%E3%81%A1%E3%81%AE%E5%AD%90/%E3%81%8B%E3%82%8F%E3%81%84%E3%81%84?ans=%E3%81%9D%E3%81%86%E3%81%A0%E3%81%9E"` 35 | ); 36 | expect(decodeURIComponent(path)).toMatchInlineSnapshot( 37 | `"/search/うちの子/かわいい?ans=そうだぞ"` 38 | ); 39 | }); 40 | 41 | it("Should serialize array query", () => { 42 | const def = routeOf("/users/:userId"); 43 | 44 | expect( 45 | buildPath(def, { userId: "1" }, { q: ["a", "b"] }) 46 | ).toMatchInlineSnapshot(`"/users/1?q=a&q=b"`); 47 | }); 48 | 49 | it("should accept raw query string", () => { 50 | const def = routeOf("/users/:userId"); 51 | 52 | expect( 53 | buildPath(def, { userId: "1" }, "hello-query-string") 54 | ).toMatchInlineSnapshot(`"/users/1?hello-query-string"`); 55 | 56 | expect(buildPath(def, { userId: "1" }, "")).toMatchInlineSnapshot( 57 | `"/users/1"` 58 | ); 59 | }); 60 | }); 61 | }); 62 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # TypeScript v1 declaration files 45 | typings/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # yarn 69 | .yarn/ 70 | !.yarn/release/* 71 | 72 | # dotenv environment variables file 73 | .env 74 | .env.test 75 | 76 | # parcel-bundler cache (https://parceljs.org/) 77 | .cache 78 | 79 | # Next.js build output 80 | .next 81 | 82 | # Nuxt.js build / generate output 83 | .nuxt 84 | dist 85 | 86 | # Gatsby files 87 | .cache/ 88 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 89 | # https://nextjs.org/blog/next-9-1#public-directory-support 90 | # public 91 | 92 | # vuepress build output 93 | .vuepress/dist 94 | 95 | # Serverless directories 96 | .serverless/ 97 | 98 | # FuseBox cache 99 | .fusebox/ 100 | 101 | # DynamoDB Local files 102 | .dynamodb/ 103 | 104 | # TernJS port file 105 | .tern-port 106 | 107 | .DS_Store 108 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Node.js Package 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - dev 8 | 9 | jobs: 10 | publish: 11 | runs-on: ubuntu-latest 12 | permissions: 13 | id-token: write 14 | contents: read 15 | packages: write # allow GITHUB_TOKEN to publish packages 16 | steps: 17 | - uses: actions/checkout@v2 18 | - uses: actions/setup-node@v1 19 | with: 20 | node-version: 20 21 | 22 | - uses: actions/cache@v3 23 | with: 24 | path: | 25 | .yarn/cache/ 26 | node_modules/ 27 | key: yarn-cache-${{ hashFiles(format('{0}{1}', github.workspace, '/yarn.lock')) }} 28 | restore-keys: | 29 | yarn-cache- 30 | 31 | - name: Install dependencies 32 | run: yarn 33 | 34 | - name: Testing 35 | working-directory: pkgs/froute 36 | run: yarn test 37 | 38 | - name: Building 39 | working-directory: pkgs/froute 40 | run: yarn prepublishOnly 41 | 42 | - name: package-version 43 | id: package-version 44 | working-directory: pkgs/froute 45 | run: echo "version=`node -p -e 'require(\"./package.json\").version'`" >> "$GITHUB_OUTPUT" 46 | 47 | - uses: JS-DevTools/npm-publish@v2 48 | with: 49 | access: public 50 | provenance: true 51 | package: pkgs/froute 52 | token: ${{ secrets.NPM_TOKEN }} 53 | tag: ${{ (github.ref == 'refs/heads/dev' && 'dev') || 'latest' }} 54 | 55 | - name: "Check: package version has corrosponding git tag" 56 | id: tagged 57 | working-directory: pkgs/froute 58 | run: | 59 | git show-ref --tags --verify --quiet -- "refs/tags/v${{ steps.package-version.outputs.version }}" && echo "tagged=0" >> "$GITHUB_OUTPUT" || echo "tagged=1" >> "$GITHUB_OUTPUT" 60 | 61 | - name: package-version-to-git-tag 62 | uses: pkgdeps/git-tag-action@v2 63 | if: ${{ steps.tagged.outputs.tagged == '0' }} 64 | with: 65 | github_token: ${{ secrets.GITHUB_TOKEN }} 66 | github_repo: ${{ github.repository }} 67 | version: ${{ steps.package-version.outputs.version }} 68 | git_commit_sha: ${{ github.sha }} 69 | git_tag_prefix: "v" 70 | -------------------------------------------------------------------------------- /.github/workflows/testing.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push] 4 | 5 | jobs: 6 | testing: 7 | strategy: 8 | matrix: 9 | node-version: [16.x, 18.x, 20.x] 10 | 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v2 15 | - name: Setup Node.js for use with actions 16 | uses: actions/setup-node@v1 17 | with: 18 | node-version: ${{ matrix.node-version }} 19 | 20 | - name: Cache .yarn/cache 21 | uses: actions/cache@v1.0.0 22 | with: 23 | path: .yarn/cache/ 24 | key: yarncache-${{ matrix.node-version }}-${{ hashFiles(format('{0}{1}', github.workspace, '/yarn.lock')) }} 25 | restore-keys: | 26 | yarncache-${{ matrix.node-version }}- 27 | 28 | - name: Cache node_modules 29 | uses: actions/cache@v1.0.0 30 | with: 31 | path: node_modules/ 32 | key: nodemodules-${{ matrix.node-version }}-${{ hashFiles(format('{0}{1}', github.workspace, '/yarn.lock')) }} 33 | restore-keys: | 34 | nodemodules-${{ matrix.node-version }}- 35 | 36 | - name: Install deps 37 | run: yarn install 38 | - name: Testing @fluer/froute 39 | working-directory: pkgs/froute 40 | run: yarn test 41 | 42 | build-lint: 43 | strategy: 44 | matrix: 45 | steps: [build, lint] 46 | 47 | runs-on: ubuntu-latest 48 | 49 | steps: 50 | - uses: actions/checkout@v2 51 | - name: Setup Node.js for use with actions 52 | uses: actions/setup-node@v1 53 | with: 54 | node-version: ${{ matrix.node-version }} 55 | 56 | - name: Cache .yarn/cache 57 | uses: actions/cache@v1.0.0 58 | with: 59 | path: .yarn/cache/ 60 | key: yarncache-${{ matrix.node-version }}-${{ hashFiles(format('{0}{1}', github.workspace, '/yarn.lock')) }} 61 | restore-keys: | 62 | yarncache-${{ matrix.node-version }}- 63 | 64 | - name: Cache node_modules 65 | uses: actions/cache@v1.0.0 66 | with: 67 | path: node_modules/ 68 | key: nodemodules-${{ matrix.node-version }}-${{ hashFiles(format('{0}{1}', github.workspace, '/yarn.lock')) }} 69 | restore-keys: | 70 | nodemodules-${{ matrix.node-version }}- 71 | 72 | - name: Install deps 73 | run: yarn install 74 | # - name: Linting 75 | # if: ${{ matrix.steps == 'lint' }} 76 | # run: yarn eslint . 77 | - name: Building 78 | if: ${{ matrix.steps == 'build' }} 79 | working-directory: pkgs/froute 80 | run: yarn prepublishOnly 81 | -------------------------------------------------------------------------------- /pkgs/froute/src/utils.ts: -------------------------------------------------------------------------------- 1 | export const canUseDOM = () => typeof window !== "undefined"; 2 | 3 | export const isDevelopment = 4 | typeof process !== "undefined" 5 | ? process.env?.NODE_ENV === "development" || 6 | process.env?.NODE_ENV === "test" 7 | : false; 8 | 9 | const hasOwnProperty = Object.prototype.hasOwnProperty; 10 | 11 | // eslint-disable-next-line @typescript-eslint/ban-types 12 | export const isEmptyObject = (t: object) => { 13 | for (const k in t) { 14 | if (hasOwnProperty.call(t, k)) return false; 15 | } 16 | 17 | return true; 18 | }; 19 | 20 | export const parseUrl = (url: string) => { 21 | const result = new URL(url, "pl:/___.com"); 22 | const path = result.pathname + result.search; 23 | 24 | return { 25 | protocol: result.protocol === "pl:" ? null : result.protocol, 26 | // slashes: null, 27 | auth: 28 | result.username !== "" || result.password !== "" 29 | ? `${result.username ?? ""}:${result.password ?? ""}` 30 | : null, 31 | host: result.host === "" ? null : result.host, 32 | port: result.port === "" ? null : result.port, 33 | hostname: result.hostname === "" ? null : result.hostname, 34 | hash: result.hash === "" ? null : result.hash, 35 | search: result.search === "" ? null : result.search, 36 | query: result.search === "" ? null : result.search.replace(/^\?/, ""), 37 | pathname: 38 | result.pathname === "" || result.pathname === "/___.com" 39 | ? null 40 | : result.pathname, 41 | path: path === "" || path === "/___.com" ? null : path, 42 | href: result.href.replace(/^(pl:)?(\/___.com)?/g, ""), 43 | }; 44 | }; 45 | 46 | export const parseQueryString = (query: string) => { 47 | return Array.from(new URLSearchParams(query)).reduce((accum, [k, v]) => { 48 | if (k in accum) { 49 | accum[k] = Array.isArray(accum[k]) 50 | ? [...(accum[k] as any[]), v] 51 | : [accum[k], v]; 52 | } else { 53 | accum[k] = v; 54 | } 55 | 56 | return accum; 57 | }, Object.create(null) as Record); 58 | }; 59 | 60 | export const stringifyQueryString = ( 61 | query: Record 62 | ) => { 63 | const params = new URLSearchParams(); 64 | Object.entries(query).forEach(([k, v]) => { 65 | if (Array.isArray(v)) { 66 | v.forEach((vv) => params.append(k, vv)); 67 | } else { 68 | params.append(k, v); 69 | } 70 | }); 71 | 72 | return params.toString(); 73 | }; 74 | 75 | // prettier-ignore 76 | export type DeepReadonly = 77 | T extends () => any | boolean | number | string | null | undefined ? T 78 | : T extends Array ? ReadonlyArray> 79 | : T extends Map ? ReadonlyMap 80 | : T extends Set ? ReadonlySet 81 | : { readonly [K in keyof T]: DeepReadonly } 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 | --------------------------------------------------------------------------------