From d12d8404ec418facc55aaccb097a0c2c08a8a3ef Mon Sep 17 00:00:00 2001 From: Valerio Ageno <51341197+Valerioageno@users.noreply.github.com> Date: Tue, 24 Dec 2024 11:41:45 +0100 Subject: [PATCH] feat(packages/tuono-router): replace `zustand` with `React.Context` (#256) Co-authored-by: Marco Pasqualetti --- packages/tuono-router/package.json | 5 +- .../tuono-router/src/components/Matches.tsx | 4 +- .../tuono-router/src/components/NotFound.tsx | 4 +- .../src/components/RouterContext.tsx | 113 ++++++++++++++++-- .../src/components/RouterProvider.tsx | 47 +++----- packages/tuono-router/src/globals.ts | 4 - .../src/hooks/useInternalRouter.tsx | 8 -- .../src/hooks/useListenBrowserUrlUpdates.tsx | 34 ------ .../tuono-router/src/hooks/useRoute.spec.tsx | 26 ++-- packages/tuono-router/src/hooks/useRoute.tsx | 6 +- packages/tuono-router/src/hooks/useRouter.tsx | 46 +++---- .../tuono-router/src/hooks/useRouterStore.tsx | 73 ----------- .../src/hooks/useServerSideProps.tsx | 7 +- packages/tuono-router/src/types.ts | 12 +- .../src/utils/from-url-to-parsed-location.ts | 2 +- pnpm-lock.yaml | 9 +- 16 files changed, 177 insertions(+), 223 deletions(-) delete mode 100644 packages/tuono-router/src/hooks/useInternalRouter.tsx delete mode 100644 packages/tuono-router/src/hooks/useListenBrowserUrlUpdates.tsx delete mode 100644 packages/tuono-router/src/hooks/useRouterStore.tsx diff --git a/packages/tuono-router/package.json b/packages/tuono-router/package.json index 0466eeac..6878cc06 100644 --- a/packages/tuono-router/package.json +++ b/packages/tuono-router/package.json @@ -47,9 +47,7 @@ "react": ">=16.3.0" }, "dependencies": { - "react-intersection-observer": "^9.13.0", - "vite": "^5.2.11", - "zustand": "4.4.7" + "react-intersection-observer": "^9.13.0" }, "devDependencies": { "@tanstack/config": "0.7.13", @@ -59,6 +57,7 @@ "@vitejs/plugin-react-swc": "3.7.2", "react": "18.3.1", "jsdom": "^25.0.0", + "vite": "5.4.11", "vitest": "^2.0.0" } } diff --git a/packages/tuono-router/src/components/Matches.tsx b/packages/tuono-router/src/components/Matches.tsx index 6f3d38bd..350fc5ce 100644 --- a/packages/tuono-router/src/components/Matches.tsx +++ b/packages/tuono-router/src/components/Matches.tsx @@ -1,10 +1,10 @@ import type * as React from 'react' -import { useRouterStore } from '../hooks/useRouterStore' import useRoute from '../hooks/useRoute' import { RouteMatch } from './RouteMatch' import NotFound from './NotFound' +import { useRouterContext } from './RouterContext' interface MatchesProps { // user defined props @@ -12,7 +12,7 @@ interface MatchesProps { } export function Matches({ serverSideProps }: MatchesProps): React.JSX.Element { - const location = useRouterStore((st) => st.location) + const { location } = useRouterContext() const route = useRoute(location.pathname) diff --git a/packages/tuono-router/src/components/NotFound.tsx b/packages/tuono-router/src/components/NotFound.tsx index d8f9aa4d..c1ab19b7 100644 --- a/packages/tuono-router/src/components/NotFound.tsx +++ b/packages/tuono-router/src/components/NotFound.tsx @@ -1,12 +1,12 @@ import type * as React from 'react' -import { useInternalRouter } from '../hooks/useInternalRouter' +import { useRouterContext } from '../components/RouterContext' import { RouteMatch } from './RouteMatch' import Link from './Link' export default function NotFound(): React.JSX.Element { - const router = useInternalRouter() + const { router } = useRouterContext() const custom404Route = router.routesById['/404'] diff --git a/packages/tuono-router/src/components/RouterContext.tsx b/packages/tuono-router/src/components/RouterContext.tsx index 7d5456dc..abdcffcf 100644 --- a/packages/tuono-router/src/components/RouterContext.tsx +++ b/packages/tuono-router/src/components/RouterContext.tsx @@ -1,22 +1,111 @@ -import React from 'react' +import { createContext, useState, useEffect, useContext, useMemo } from 'react' +import type { ReactNode } from 'react' import type { Router } from '../router' +import type { ServerRouterInfo } from '../types' + +export interface ParsedLocation { + href: string + pathname: string + search: Record + searchStr: string + hash: string +} + +interface RouterContextValue { + router: Router + location: ParsedLocation + updateLocation: (loc: ParsedLocation) => void +} // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -const routerContext = React.createContext(null!) +const RouterContext = createContext(null!) -const TUONO_CONTEXT_GLOBAL_NAME = '__TUONO_CONTEXT__' - -export function getRouterContext(): React.Context { +function getInitialLocation( + serverSideProps?: ServerRouterInfo, +): ParsedLocation { if (typeof document === 'undefined') { - return routerContext + return { + pathname: serverSideProps?.pathname || '', + hash: '', + href: serverSideProps?.href || '', + searchStr: serverSideProps?.searchStr || '', + // TODO: Polyfill URLSearchParams + search: {}, + } } - if (window[TUONO_CONTEXT_GLOBAL_NAME]) { - return window[TUONO_CONTEXT_GLOBAL_NAME] + const { location } = window + return { + pathname: location.pathname, + hash: location.hash, + href: location.href, + searchStr: location.search, + search: Object.fromEntries(new URLSearchParams(location.search)), } - - window[TUONO_CONTEXT_GLOBAL_NAME] = routerContext - - return routerContext +} + +interface RouterContextProviderProps { + router: Router + children: ReactNode + serverSideProps?: ServerRouterInfo +} + +export function RouterContextProvider({ + router, + children, + serverSideProps, +}: RouterContextProviderProps): ReactNode { + // Allow the router to update options on the router instance + router.update({ ...router.options } as Parameters[0]) + + const [location, setLocation] = useState(() => + getInitialLocation(serverSideProps), + ) + + /** + * Listen browser navigation events + */ + useEffect(() => { + const updateLocationOnPopStateChange = ({ + target, + }: PopStateEvent): void => { + const { location: targetLocation } = target as typeof window + const { pathname, hash, href, search } = targetLocation + + setLocation({ + pathname, + hash, + href, + searchStr: search, + search: Object.fromEntries(new URLSearchParams(search)), + }) + } + + window.addEventListener('popstate', updateLocationOnPopStateChange) + + return (): void => { + window.removeEventListener('popstate', updateLocationOnPopStateChange) + } + }, []) + + const contextValue: RouterContextValue = useMemo( + () => ({ + router, + location, + updateLocation: setLocation, + }), + [location, router], + ) + + return ( + + {children} + + ) +} + +/** @warning DO NOT EXPORT THIS TO USER LAND */ +export function useRouterContext(): RouterContextValue { + return useContext(RouterContext) } diff --git a/packages/tuono-router/src/components/RouterProvider.tsx b/packages/tuono-router/src/components/RouterProvider.tsx index 0e1469ab..92b62daa 100644 --- a/packages/tuono-router/src/components/RouterProvider.tsx +++ b/packages/tuono-router/src/components/RouterProvider.tsx @@ -1,51 +1,32 @@ -import React from 'react' -import type { ReactNode, JSX } from 'react' +import type { JSX } from 'react' +import { Suspense } from 'react' -import { useListenBrowserUrlUpdates } from '../hooks/useListenBrowserUrlUpdates' -import { useInitRouterStore } from '../hooks/useRouterStore' import type { ServerProps } from '../types' import type { Router } from '../router' -import { getRouterContext } from './RouterContext' +import { RouterContextProvider } from './RouterContext' import { Matches } from './Matches' -interface RouterContextProviderProps { - router: Router - children: ReactNode -} - -function RouterContextProvider({ - router, - children, -}: RouterContextProviderProps): JSX.Element { - // Allow the router to update options on the router instance - router.update({ ...router.options } as Parameters[0]) - - const routerContext = getRouterContext() - - return ( - - {children} - - ) -} - interface RouterProviderProps { router: Router serverProps?: ServerProps } +/** + * This component is used in the tuono app entry point + */ export function RouterProvider({ router, serverProps, }: RouterProviderProps): JSX.Element { - useInitRouterStore(serverProps) - - useListenBrowserUrlUpdates() - return ( - - - + + + + + ) } diff --git a/packages/tuono-router/src/globals.ts b/packages/tuono-router/src/globals.ts index 0de11a90..faf284fa 100644 --- a/packages/tuono-router/src/globals.ts +++ b/packages/tuono-router/src/globals.ts @@ -1,5 +1,3 @@ -import type React from 'react' - import type { Router } from './router' declare global { @@ -8,7 +6,5 @@ declare global { __TUONO_SSR_PROPS__?: { props?: unknown } - - __TUONO_CONTEXT__?: React.Context } } diff --git a/packages/tuono-router/src/hooks/useInternalRouter.tsx b/packages/tuono-router/src/hooks/useInternalRouter.tsx deleted file mode 100644 index 35852f5e..00000000 --- a/packages/tuono-router/src/hooks/useInternalRouter.tsx +++ /dev/null @@ -1,8 +0,0 @@ -import * as React from 'react' - -import { getRouterContext } from '../components/RouterContext' -import type { Router } from '../router' - -export function useInternalRouter(): Router { - return React.useContext(getRouterContext()) -} diff --git a/packages/tuono-router/src/hooks/useListenBrowserUrlUpdates.tsx b/packages/tuono-router/src/hooks/useListenBrowserUrlUpdates.tsx deleted file mode 100644 index 44a59d76..00000000 --- a/packages/tuono-router/src/hooks/useListenBrowserUrlUpdates.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { useEffect } from 'react' - -import { useRouterStore } from './useRouterStore' - -/** - * This hook is meant to handle just browser related location updates - * like the back and forward buttons. - */ -export const useListenBrowserUrlUpdates = (): void => { - const updateLocation = useRouterStore((st) => st.updateLocation) - - useEffect(() => { - const updateLocationOnPopStateChange = ({ - target, - }: PopStateEvent): void => { - const { location } = target as typeof window - const { pathname, hash, href, search } = location - - updateLocation({ - pathname, - hash, - href, - searchStr: search, - search: Object.fromEntries(new URLSearchParams(search)), - }) - } - - window.addEventListener('popstate', updateLocationOnPopStateChange) - - return (): void => { - window.removeEventListener('popstate', updateLocationOnPopStateChange) - } - }, [updateLocation]) -} diff --git a/packages/tuono-router/src/hooks/useRoute.spec.tsx b/packages/tuono-router/src/hooks/useRoute.spec.tsx index 400a676a..11868738 100644 --- a/packages/tuono-router/src/hooks/useRoute.spec.tsx +++ b/packages/tuono-router/src/hooks/useRoute.spec.tsx @@ -3,21 +3,25 @@ import { cleanup } from '@testing-library/react' import useRoute from './useRoute' -describe('Test useRoute fn', () => { +describe('useRoute', () => { afterEach(cleanup) test('match routes by ids', () => { - vi.mock('./useInternalRouter.tsx', () => ({ - useInternalRouter: (): { routesById: Record } => { + vi.mock('../components/RouterContext.tsx', () => ({ + useRouterContext: (): { + router: { routesById: Record } + } => { return { - routesById: { - '/': { id: '/' }, - '/about': { id: '/about' }, - '/posts': { id: '/posts' }, // posts/index - '/posts/[post]': { id: '/posts/[post]' }, - '/posts/defined-post': { id: '/posts/defined-post' }, - '/posts/[post]/[comment]': { id: '/posts/[post]/[comment]' }, - '/blog/[...catch_all]': { id: '/blog/[...catch_all]' }, + router: { + routesById: { + '/': { id: '/' }, + '/about': { id: '/about' }, + '/posts': { id: '/posts' }, // posts/index + '/posts/[post]': { id: '/posts/[post]' }, + '/posts/defined-post': { id: '/posts/defined-post' }, + '/posts/[post]/[comment]': { id: '/posts/[post]/[comment]' }, + '/blog/[...catch_all]': { id: '/blog/[...catch_all]' }, + }, }, } }, diff --git a/packages/tuono-router/src/hooks/useRoute.tsx b/packages/tuono-router/src/hooks/useRoute.tsx index 362f0d6e..226c3b0c 100644 --- a/packages/tuono-router/src/hooks/useRoute.tsx +++ b/packages/tuono-router/src/hooks/useRoute.tsx @@ -1,6 +1,6 @@ import type { Route } from '../route' -import { useInternalRouter } from './useInternalRouter' +import { useRouterContext } from '../components/RouterContext' const DYNAMIC_PATH_REGEX = /\[(.*?)\]/ @@ -25,7 +25,9 @@ export function sanitizePathname(pathname: string): string { * Optimizations should occour on both */ export default function useRoute(pathname?: string): Route | undefined { - const { routesById } = useInternalRouter() + const { + router: { routesById }, + } = useRouterContext() if (!pathname) return diff --git a/packages/tuono-router/src/hooks/useRouter.tsx b/packages/tuono-router/src/hooks/useRouter.tsx index 35b85500..f6cbc69b 100644 --- a/packages/tuono-router/src/hooks/useRouter.tsx +++ b/packages/tuono-router/src/hooks/useRouter.tsx @@ -1,4 +1,6 @@ -import { useRouterStore } from './useRouterStore' +import React from 'react' + +import { useRouterContext } from '../components/RouterContext' interface PushOptions { /** @@ -7,7 +9,7 @@ interface PushOptions { scroll?: boolean } -interface UseRouterHook { +interface UseRouterResult { /** * Redirects to the path passed as argument updating the browser history. */ @@ -24,29 +26,29 @@ interface UseRouterHook { pathname: string } -export const useRouter = (): UseRouterHook => { - const [location, updateLocation] = useRouterStore((st) => [ - st.location, - st.updateLocation, - ]) +export const useRouter = (): UseRouterResult => { + const { location, updateLocation } = useRouterContext() - const push = (path: string, opt?: PushOptions): void => { - const { scroll = true } = opt || {} - const url = new URL(path, window.location.origin) + const push = React.useCallback( + (path: string, opt?: PushOptions): void => { + const { scroll = true } = opt || {} + const url = new URL(path, window.location.origin) - updateLocation({ - href: url.href, - pathname: url.pathname, - search: Object.fromEntries(url.searchParams), - searchStr: url.search, - hash: url.hash, - }) - history.pushState(path, '', path) + updateLocation({ + href: url.href, + pathname: url.pathname, + search: Object.fromEntries(url.searchParams), + searchStr: url.search, + hash: url.hash, + }) + history.pushState(path, '', path) - if (scroll) { - window.scroll(0, 0) - } - } + if (scroll) { + window.scroll(0, 0) + } + }, + [updateLocation], + ) return { push, diff --git a/packages/tuono-router/src/hooks/useRouterStore.tsx b/packages/tuono-router/src/hooks/useRouterStore.tsx deleted file mode 100644 index 68a80ce5..00000000 --- a/packages/tuono-router/src/hooks/useRouterStore.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import { create } from 'zustand' -import { useLayoutEffect } from 'react' - -import type { ServerProps } from '../types' - -export interface ParsedLocation { - href: string - pathname: string - search: Record - searchStr: string - hash: string -} - -interface RouterState { - isLoading: boolean - isTransitioning: boolean - status: 'idle' - location: ParsedLocation - matches: Array - pendingMatches: Array - cachedMatches: Array - statusCode: 200 - updateLocation: (loc: ParsedLocation) => void -} - -export const useInitRouterStore = (props?: ServerProps): void => { - const updateLocation = useRouterStore((st) => st.updateLocation) - - // Init the store in the server in order to correctly - // SSR the components that depend on the router. - if (typeof window === 'undefined') { - updateLocation({ - pathname: props?.router.pathname || '', - hash: '', - href: props?.router.href || '', - searchStr: props?.router.searchStr || '', - search: {}, - }) - } - - // Update the store on the client side before the first - // rendering - useLayoutEffect(() => { - const { pathname, hash, href, search } = window.location - updateLocation({ - pathname, - hash, - href, - searchStr: search, - search: Object.fromEntries(new URLSearchParams(search)), - }) - }, [updateLocation]) -} - -export const useRouterStore = create()((set) => ({ - isLoading: false, - isTransitioning: false, - status: 'idle', - location: { - href: '', - pathname: '', - search: {}, - searchStr: '', - hash: '', - }, - matches: [], - pendingMatches: [], - cachedMatches: [], - statusCode: 200, - updateLocation: (location: ParsedLocation): void => { - set({ location }) - }, -})) diff --git a/packages/tuono-router/src/hooks/useServerSideProps.tsx b/packages/tuono-router/src/hooks/useServerSideProps.tsx index c06de958..d5acab64 100644 --- a/packages/tuono-router/src/hooks/useServerSideProps.tsx +++ b/packages/tuono-router/src/hooks/useServerSideProps.tsx @@ -3,7 +3,7 @@ import { useState, useEffect, useRef } from 'react' import type { Route } from '../route' import { fromUrlToParsedLocation } from '../utils/from-url-to-parsed-location' -import { useRouterStore } from './useRouterStore' +import { useRouterContext } from '../components/RouterContext' const isServer = typeof document === 'undefined' @@ -37,10 +37,7 @@ export function useServerSideProps( serverSideProps: T, ): UseServerSidePropsReturn { const isFirstRendering = useRef(true) - const [location, updateLocation] = useRouterStore((st) => [ - st.location, - st.updateLocation, - ]) + const { location, updateLocation } = useRouterContext() const [isLoading, setIsLoading] = useState( // Force loading if has handler !!route.options.hasHandler && diff --git a/packages/tuono-router/src/types.ts b/packages/tuono-router/src/types.ts index 5c6a1d5a..f6d0c2b7 100644 --- a/packages/tuono-router/src/types.ts +++ b/packages/tuono-router/src/types.ts @@ -5,12 +5,14 @@ export interface Segment { value: string } +export interface ServerRouterInfo { + href: string + pathname: string + searchStr: string +} + export interface ServerProps { - router: { - href: string - pathname: string - searchStr: string - } + router: ServerRouterInfo props: TProps } diff --git a/packages/tuono-router/src/utils/from-url-to-parsed-location.ts b/packages/tuono-router/src/utils/from-url-to-parsed-location.ts index 2b861dc2..f05c151b 100644 --- a/packages/tuono-router/src/utils/from-url-to-parsed-location.ts +++ b/packages/tuono-router/src/utils/from-url-to-parsed-location.ts @@ -1,4 +1,4 @@ -import type { ParsedLocation } from '../hooks/useRouterStore' +import type { ParsedLocation } from '../components/RouterContext' export function fromUrlToParsedLocation(href: string): ParsedLocation { const location = new URL(href, window.location.origin) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index de8f457c..6a5f1971 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -306,12 +306,6 @@ importers: react-intersection-observer: specifier: ^9.13.0 version: 9.13.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - vite: - specifier: ^5.2.11 - version: 5.4.11(@types/node@22.10.0)(sugarss@4.0.1(postcss@8.4.49)) - zustand: - specifier: 4.4.7 - version: 4.4.7(@types/react@18.3.13)(react@18.3.1) devDependencies: '@tanstack/config': specifier: 0.7.13 @@ -334,6 +328,9 @@ importers: react: specifier: 18.3.1 version: 18.3.1 + vite: + specifier: 5.4.11 + version: 5.4.11(@types/node@22.10.0)(sugarss@4.0.1(postcss@8.4.49)) vitest: specifier: ^2.0.0 version: 2.1.6(@types/node@22.10.0)(jsdom@25.0.1)(sugarss@4.0.1(postcss@8.4.49))