Skip to content

Commit

Permalink
feat: Add UniversalRouteScreen data fetching with react-query
Browse files Browse the repository at this point in the history
  • Loading branch information
codinsonn committed May 1, 2024
1 parent 7144629 commit 08e3201
Show file tree
Hide file tree
Showing 14 changed files with 419 additions and 8 deletions.
2 changes: 1 addition & 1 deletion apps/expo/app/(main)/index.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
import HomeScreen from '@app/core/screens/HomeScreen'
import HomeScreen from '@app/core/routes/index'

export default HomeScreen
2 changes: 1 addition & 1 deletion apps/next/app/(main)/page.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
'use client'
import HomeScreen from '@app/core/screens/HomeScreen'
import HomeScreen from '@app/core/routes/index'

export default HomeScreen
46 changes: 46 additions & 0 deletions features/app-core/context/UniversalQueryClientProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
'use client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'

/* --- Constants ------------------------------------------------------------------------------- */

let clientSideQueryClient: QueryClient | undefined = undefined

/** --- makeQueryClient() ---------------------------------------------------------------------- */
/** -i- Build a queryclient to be used either client-side or server-side */
export const makeQueryClient = () => {
const oneMinute = 1000 * 60
const queryClient = new QueryClient({
defaultOptions: {
queries: {
// With SSR, we usually want to set some default staleTime
// above 0 to avoid refetching immediately on the client
staleTime: oneMinute,
},
},
})
return queryClient
}

/** --- getQueryClient() ----------------------------------------------------------------------- */
/** -i- Always makes a new query client on the server, but reuses an existing client if found in browser or mobile */
export const getQueryClient = () => {
// Always create a new query client on the server, so no caching is shared between requests
const isServer = typeof window === 'undefined'
if (isServer) return makeQueryClient()
// On the browser or mobile, make a new client if we don't already have one
// This is important so we don't re-make a new client if React suspends during initial render.
// Might not be needed if we have a suspense boundary below the creation of the query client though.
if (!clientSideQueryClient) clientSideQueryClient = makeQueryClient()
return clientSideQueryClient
}

/** --- <UniversalQueryClientProvider/> ----------------------------------------------------------------- */
/** -i- Provides a universal queryclient to be used either client-side or server-side */
export const UniversalQueryClientProvider = ({ children }: { children: React.ReactNode }) => {
const queryClient = getQueryClient()
return (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
)
}
59 changes: 59 additions & 0 deletions features/app-core/navigation/UniversalRouteScreen.helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
'use client'
import type { Query, QueryKey } from '@tanstack/react-query'
import { queryBridge } from '../screens/HomeScreen'

/* --- Types ----------------------------------------------------------------------------------- */

export type QueryFn = (args: Record<string, unknown>) => Promise<Record<string, unknown>>

export type QueryBridgeConfig<Fetcher extends QueryFn> = {
/** -i- Function to turn any route params into the query key for the `routeDataFetcher()` query */
routeParamsToQueryKey: (routeParams: Partial<Parameters<Fetcher>[0]>) => QueryKey
/** -i- Function to turn any route params into the input args for the `routeDataFetcher()` query */
routeParamsToQueryInput: (routeParams: Partial<Parameters<Fetcher>[0]>) => Parameters<Fetcher>[0]
/** -i- Fetcher to prefetch data for the Page and QueryClient during SSR, or fetch it clientside if browser / mobile */
routeDataFetcher: Fetcher
/** -i- Function transform fetcher data into props */
fetcherDataToProps?: (data: Awaited<ReturnType<Fetcher>>) => Record<string, unknown>
/** -i- Initial data provided to the QueryClient */
initialData?: ReturnType<Fetcher>
}

export type UniversalRouteProps<Fetcher extends QueryFn> = {
/** -i- Optional params passed by the Next.js app router, in Expo we get these from `useRouteParams()` */
params?: Partial<Parameters<Fetcher>[0]>
/** -i- Optional search params passed by the Next.js app router, in Expo we get these from `useRouteParams()` */
searchParams?: Partial<Parameters<Fetcher>[0]>
/** -i- Configuration for the query bridge */
queryBridge: QueryBridgeConfig<Fetcher>
/** -i- The screen to render for this route */
routeScreen: React.ComponentType
}

export type HydratedRouteProps<
QueryBridge extends QueryBridgeConfig<QueryFn>
> = ReturnType<QueryBridge['fetcherDataToProps']> & {
/** -i- The route key for the query */
queryKey: QueryKey
/** -i- The input args for the query */
queryInput: Parameters<QueryBridge['routeDataFetcher']>[0]
/** -i- The route params passed by the Next.js app router, in Expo we get these from `useRouteParams()` */
params: Partial<Parameters<QueryBridge['routeDataFetcher']>[0]>
/** -i- The search params passed by the Next.js app router, in Expo we get these from `useRouteParams()` */
searchParams: Partial<Parameters<QueryBridge['routeDataFetcher']>[0]>
}

/** --- createQueryBridge() -------------------------------------------------------------------- */
/** -i- Util to create a typed bridge between a fetcher and a route's props */
export const createQueryBridge = <QueryBridge extends QueryBridgeConfig<QueryFn>>(
queryBridge: QueryBridge
) => {
type FetcherData = Awaited<ReturnType<QueryBridge['routeDataFetcher']>>
type ReturnTypeOfFunction<F, A> = F extends ((args: A) => infer R) ? R : FetcherData
type RoutePropsFromFetcher = ReturnTypeOfFunction<QueryBridge['fetcherDataToProps'], FetcherData>
const fetcherDataToProps = queryBridge.fetcherDataToProps || ((data: FetcherData) => data)
return {
...queryBridge,
fetcherDataToProps: fetcherDataToProps as ((data: FetcherData) => RoutePropsFromFetcher),
}
}
45 changes: 45 additions & 0 deletions features/app-core/navigation/UniversalRouteScreen.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
'use client'
import { useQuery } from '@tanstack/react-query'
import type { UniversalRouteProps, QueryFn } from './UniversalRouteScreen.helpers'
import { useRouteParams } from './useRouteParams'

/** --- <UniversalRouteScreen/> -------------------------------------------------------------------- */
/** -i- Universal Route Wrapper to provide query data on mobile, the browser and during server rendering */
export const UniversalRouteScreen = <Fetcher extends QueryFn>(props: UniversalRouteProps<Fetcher>) => {
// Props
const { params: routeParams, searchParams, queryBridge, routeScreen: RouteScreen, ...screenProps } = props
const { routeParamsToQueryKey, routeParamsToQueryInput, routeDataFetcher } = queryBridge
const fetcherDataToProps = queryBridge.fetcherDataToProps || ((data: ReturnType<Fetcher>) => data)

// Hooks
const expoRouterParams = useRouteParams(props)

// Vars
const queryParams = { ...routeParams, ...searchParams, ...expoRouterParams }
const queryKey = routeParamsToQueryKey(queryParams)
const queryInput = routeParamsToQueryInput(queryParams)

// -- Query --

const queryConfig = {
queryKey,
queryFn: async () => await routeDataFetcher(queryInput),
initialData: queryBridge.initialData,
}

// -- Mobile --

const { data: fetcherData } = useQuery(queryConfig)
const routeDataProps = fetcherDataToProps(fetcherData) as Record<string, unknown>

return (
<RouteScreen
{...routeDataProps}
queryKey={queryKey}
queryInput={queryInput}
{...screenProps} // @ts-ignore
params={routeParams}
searchParams={searchParams}
/>
)
}
116 changes: 116 additions & 0 deletions features/app-core/navigation/UniversalRouteScreen.web.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
'use client'
import { use, useState, useEffect } from 'react'
import { useQueryClient, useQuery, dehydrate, HydrationBoundary } from '@tanstack/react-query'
import type { UniversalRouteProps, QueryFn } from './UniversalRouteScreen.helpers'
import { useRouteParams } from './useRouteParams'

/* --- Helpers --------------------------------------------------------------------------------- */

const getSSRData = () => {
const $ssrData = document.getElementById('ssr-data')
const ssrDataText = $ssrData?.getAttribute('data-ssr')
const ssrData = ssrDataText ? (JSON.parse(ssrDataText) as Record<string, any>) : null
return ssrData
}

const getDehydratedSSRState = () => {
const $ssrHydrationState = document.getElementById('ssr-hydration-state')
const ssrHydrationStateText = $ssrHydrationState?.getAttribute('data-ssr')
const ssrHydrationState = ssrHydrationStateText ? (JSON.parse(ssrHydrationStateText) as Record<string, any>) : null
return ssrHydrationState
}

/** --- <UniversalRouteScreen/> ---------------------------------------------------------------- */
/** -i- Universal Route Wrapper to provide query data on mobile, the browser and during server rendering */
export const UniversalRouteScreen = <Fetcher extends QueryFn>(props: UniversalRouteProps<Fetcher>) => {
// Props
const { params: routeParams, searchParams, queryBridge, routeScreen: RouteScreen, ...screenProps } = props
const { routeParamsToQueryKey, routeParamsToQueryInput, routeDataFetcher } = queryBridge
const fetcherDataToProps = queryBridge.fetcherDataToProps || ((data: ReturnType<Fetcher>) => data)

// Hooks
const nextRouterParams = useRouteParams(props)

// Context
const queryClient = useQueryClient()

// State
const [hydratedData, setHydratedData] = useState<Record<string, any> | null>(null)
const [hydratedQueries, setHydratedQueries] = useState<Record<string, any> | null>(null)

// Vars
const isBrowser = typeof window !== 'undefined'
const queryParams = { ...routeParams, ...searchParams, ...nextRouterParams }
const queryKey = routeParamsToQueryKey(queryParams)
const queryInput = routeParamsToQueryInput(queryParams)

// -- Effects --

useEffect(() => {
const ssrData = getSSRData()
if (ssrData) setHydratedData(ssrData) // Save the SSR data to state, removing the SSR data from the DOM
const hydratedQueyClientState = getDehydratedSSRState()
if (hydratedQueyClientState) setHydratedQueries(hydratedQueyClientState) // Save the hydrated state to state, removing the hydrated state from the DOM
}, [])

// -- Query --

const queryConfig = {
queryKey,
queryFn: async () => await routeDataFetcher(queryInput),
initialData: queryBridge.initialData,
}

// -- Browser --

if (isBrowser) {
const hydrationData = hydratedData || getSSRData()
const hydrationState = hydratedQueries || getDehydratedSSRState()
const renderHydrationData = !!hydrationData && !hydratedData // Only render the hydration data if it's not already in state

const { data: fetcherData } = useQuery({
...queryConfig,
initialData: {
...queryConfig.initialData,
...hydrationData,
},
})
const routeDataProps = fetcherDataToProps(fetcherData as Awaited<ReturnType<Fetcher>>) as Record<string, unknown> // prettier-ignore

return (
<HydrationBoundary state={hydrationState}>
{renderHydrationData && <div id="ssr-data" data-ssr={JSON.stringify(fetcherData)} />}
{renderHydrationData && <div id="ssr-hydration-state" data-ssr={JSON.stringify(hydrationState)} />}
<RouteScreen
{...routeDataProps}
queryKey={queryKey}
queryInput={queryInput}
{...screenProps} // @ts-ignore
params={routeParams}
searchParams={searchParams}
/>
</HydrationBoundary>
)
}

// -- Server --

const fetcherData = use(queryClient.fetchQuery(queryConfig)) as Awaited<ReturnType<Fetcher>>
const routeDataProps = fetcherDataToProps(fetcherData) as Record<string, unknown>
const dehydratedState = dehydrate(queryClient)

return (
<HydrationBoundary state={dehydratedState}>
{!!fetcherData && <div id="ssr-data" data-ssr={JSON.stringify(fetcherData)} />}
{!!dehydratedState && <div id="ssr-hydration-state" data-ssr={JSON.stringify(dehydratedState)} />}
<RouteScreen
{...routeDataProps}
queryKey={queryKey}
queryInput={queryInput}
{...screenProps} // @ts-ignore
params={routeParams}
searchParams={searchParams}
/>
</HydrationBoundary>
)
}
4 changes: 3 additions & 1 deletion features/app-core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@
"name": "@app/core",
"version": "1.0.0",
"private": true,
"dependencies": {},
"dependencies": {
"@tanstack/react-query": "^5.29.2"
},
"devDependencies": {
"typescript": "5.3.3"
},
Expand Down
15 changes: 15 additions & 0 deletions features/app-core/resolvers/healthCheck.fetcher.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import type { HealthCheckArgs, HealthCheckResponse } from './healthCheck'
import { appConfig } from '../appConfig'

/** --- healthCheckFetcher() ------------------------------------------------------------------- */
/** -i- Isomorphic fetcher for our healthCheck() resolver at '/api/health' */
export const healthCheckFetcher = async (args: HealthCheckArgs) => {
const response = await fetch(`${appConfig.backendURL}/api/health?echo=${args.echo}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
})
const data = await response.json()
return data as HealthCheckResponse
}
35 changes: 35 additions & 0 deletions features/app-core/resolvers/healthCheck.fetcher.web.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import type { NextRequest, NextResponse } from 'next/server'
import type { HealthCheckArgs, HealthCheckResponse } from './healthCheck'
import { appConfig } from '../appConfig'

/** --- healthCheckFetcher() ------------------------------------------------------------------- */
/** -i- Isomorphic fetcher for our healthCheck() resolver at '/api/health' */
export const healthCheckFetcher = async (args: HealthCheckArgs) => {
// Vars
const isServer = typeof window === 'undefined'

// -- Browser --

if (!isServer) {
const response = await fetch(`${appConfig.backendURL}/api/health?echo=${args.echo}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
})
const data = await response.json()
return data as HealthCheckResponse
}

// -- Server --

const { healthCheck } = await import('./healthCheck')
const data = await healthCheck({
args,
context: {
req: {} as NextRequest,
res: {} as NextResponse,
},
})
return data as HealthCheckResponse
}
32 changes: 30 additions & 2 deletions features/app-core/resolvers/healthCheck.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,43 @@ const ALIVE_SINCE = new Date()

/* --- Types ----------------------------------------------------------------------------------- */

type HealthCheckArgs = {
export type HealthCheckArgs = {
echo?: string
}

type HealthCheckInputs = {
export type HealthCheckInputs = {
args: HealthCheckArgs,
context: RequestContext
}

export type HealthCheckResponse = {
echo?: string
status: 'OK'
alive: boolean
kicking: boolean
now: string
aliveTime: number
aliveSince: string
serverTimezone: string
requestHost: string
requestProtocol: string
requestURL: string
baseURL: string
backendURL: string
apiURL: string
graphURL: string
port: number | null
debugPort: number | null
nodeVersion: string
v8Version: string
systemArch: string
systemPlatform: string
systemRelease: string
systemFreeMemory: number
systemTotalMemory: number
systemLoadAverage: number[]
}

/** --- healthCheck() -------------------------------------------------------------------------- */
/** -i- Check the health status of the server. Includes relevant urls, server time(zone), versions and more */
export const healthCheck = async ({ args, context }: HealthCheckInputs) => {
Expand Down
Loading

0 comments on commit 08e3201

Please sign in to comment.