mirror of
https://github.com/tuono-labs/tuono
synced 2026-07-25 12:52:47 -07:00
feat(packages/tuono-router): replace zustand with React.Context (#256)
Co-authored-by: Marco Pasqualetti <marco.pasqualetti@live.com>
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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]' },
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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)
|
||||
|
||||
Generated
+3
-6
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user