feat(packages/tuono-router): replace zustand with React.Context (#256)

Co-authored-by: Marco Pasqualetti <marco.pasqualetti@live.com>
This commit is contained in:
Valerio Ageno
2024-12-24 11:41:45 +01:00
committed by GitHub
parent d1b12173d5
commit d12d8404ec
16 changed files with 177 additions and 223 deletions
+2 -3
View File
@@ -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"
}
}
@@ -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<TServerSideProps = unknown> {
// user defined props
@@ -12,7 +12,7 @@ interface MatchesProps<TServerSideProps = unknown> {
}
export function Matches({ serverSideProps }: MatchesProps): React.JSX.Element {
const location = useRouterStore((st) => st.location)
const { location } = useRouterContext()
const route = useRoute(location.pathname)
@@ -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']
@@ -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<string, string>
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<Router>(null!)
const RouterContext = createContext<RouterContextValue>(null!)
const TUONO_CONTEXT_GLOBAL_NAME = '__TUONO_CONTEXT__'
export function getRouterContext(): React.Context<Router> {
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<typeof router.update>[0])
const [location, setLocation] = useState<ParsedLocation>(() =>
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 (
<RouterContext.Provider value={contextValue}>
{children}
</RouterContext.Provider>
)
}
/** @warning DO NOT EXPORT THIS TO USER LAND */
export function useRouterContext(): RouterContextValue {
return useContext(RouterContext)
}
@@ -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<typeof router.update>[0])
const routerContext = getRouterContext()
return (
<React.Suspense>
<routerContext.Provider value={router}>{children}</routerContext.Provider>
</React.Suspense>
)
}
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 (
<RouterContextProvider router={router}>
<Matches serverSideProps={serverProps?.props} />
</RouterContextProvider>
<Suspense>
<RouterContextProvider
router={router}
serverSideProps={serverProps?.router}
>
<Matches serverSideProps={serverProps?.props} />
</RouterContextProvider>
</Suspense>
)
}
-4
View File
@@ -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<Router>
}
}
@@ -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())
}
@@ -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])
}
@@ -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<string, { id: string }> } => {
vi.mock('../components/RouterContext.tsx', () => ({
useRouterContext: (): {
router: { routesById: Record<string, { id: string }> }
} => {
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]' },
},
},
}
},
+4 -2
View File
@@ -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
+24 -22
View File
@@ -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,
@@ -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<string, string>
searchStr: string
hash: string
}
interface RouterState {
isLoading: boolean
isTransitioning: boolean
status: 'idle'
location: ParsedLocation
matches: Array<string>
pendingMatches: Array<string>
cachedMatches: Array<string>
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<RouterState>()((set) => ({
isLoading: false,
isTransitioning: false,
status: 'idle',
location: {
href: '',
pathname: '',
search: {},
searchStr: '',
hash: '',
},
matches: [],
pendingMatches: [],
cachedMatches: [],
statusCode: 200,
updateLocation: (location: ParsedLocation): void => {
set({ location })
},
}))
@@ -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<T>(
serverSideProps: T,
): UseServerSidePropsReturn<T> {
const isFirstRendering = useRef<boolean>(true)
const [location, updateLocation] = useRouterStore((st) => [
st.location,
st.updateLocation,
])
const { location, updateLocation } = useRouterContext()
const [isLoading, setIsLoading] = useState<boolean>(
// Force loading if has handler
!!route.options.hasHandler &&
+7 -5
View File
@@ -5,12 +5,14 @@ export interface Segment {
value: string
}
export interface ServerRouterInfo {
href: string
pathname: string
searchStr: string
}
export interface ServerProps<TProps = unknown> {
router: {
href: string
pathname: string
searchStr: string
}
router: ServerRouterInfo
props: TProps
}
@@ -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)
+3 -6
View File
@@ -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))