Skip to content

Commit 5892833

Browse files
committed
feat: Merge plugin branch 'with/react-query'
2 parents b05b183 + 08e3201 commit 5892833

File tree

16 files changed

+559
-149
lines changed

16 files changed

+559
-149
lines changed

apps/expo/app/(main)/index.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
1-
import HomeScreen from '@app/core/screens/HomeScreen'
1+
import HomeScreen from '@app/core/routes/index'
22

33
export default HomeScreen

apps/next/app/(main)/page.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
'use client'
2-
import HomeScreen from '@app/core/screens/HomeScreen'
2+
import HomeScreen from '@app/core/routes/index'
33

44
export default HomeScreen

apps/next/middleware.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { NextRequest, NextResponse } from 'next/server'
2+
3+
/* --- Middleware ------------------------------------------------------------------------------ */
4+
5+
// -i- https://nextjs.org/docs/app/api-reference/functions/next-request
6+
export async function middleware(req: NextRequest) {
7+
// Execute the request handler (and pass the request context header)
8+
const res = NextResponse.next()
9+
10+
// Allow CORS for /api routes
11+
if (req.nextUrl.pathname.startsWith('/api')) {
12+
res.headers.append('Access-Control-Allow-Origin', '*')
13+
res.headers.append('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS')
14+
res.headers.append('Access-Control-Allow-Headers', 'Content-Type')
15+
}
16+
17+
return res
18+
}
19+
20+
export const config = {
21+
matcher: ['/((?!.+\\.[\\w]+$|_next).*)', '/', '/(api|trpc)(.*)'],
22+
}

features/app-core/appConfig.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,19 @@
11
import Constants from 'expo-constants'
22
import { Platform } from 'react-native'
33

4+
export const isLocalhost = Platform.OS === 'web' && globalThis?.location?.hostname === 'localhost'
5+
export const isExpoWebLocal = isLocalhost && globalThis?.location?.port === '8081'
6+
export const fallbackExpoWebHost = isExpoWebLocal ? "localhost" : ''
7+
48
export const expoDebuggerHost = Constants?.expoGoConfig?.debuggerHost || Constants.manifest2?.extra?.expoGo?.debuggerHost // prettier-ignore
5-
export const localURL = expoDebuggerHost?.split?.(':').shift()
9+
export const localURL = expoDebuggerHost?.split?.(':').shift() || fallbackExpoWebHost
610

711
export const fallbackBaseURL = localURL ? `http://${localURL}:3000` : ''
812

913
/** --- appConfig ------------------------------------------------------------------------------ */
1014
/** -i- App config variables powered by env vars universally, and including some expo contants config on mobile */
1115
export const appConfig = {
16+
isExpoWebLocal: isExpoWebLocal,
1217
baseURL: process.env.NEXT_PUBLIC_BASE_URL || process.env.EXPO_PUBLIC_BASE_URL || `${fallbackBaseURL}`, // prettier-ignore
1318
backendURL: process.env.NEXT_PUBLIC_BACKEND_URL || process.env.EXPO_PUBLIC_BACKEND_URL || `${fallbackBaseURL}`, // prettier-ignore
1419
apiURL: process.env.NEXT_PUBLIC_API_URL || process.env.EXPO_PUBLIC_API_URL || `${fallbackBaseURL}/api`, // prettier-ignore
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
'use client'
2+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
3+
4+
/* --- Constants ------------------------------------------------------------------------------- */
5+
6+
let clientSideQueryClient: QueryClient | undefined = undefined
7+
8+
/** --- makeQueryClient() ---------------------------------------------------------------------- */
9+
/** -i- Build a queryclient to be used either client-side or server-side */
10+
export const makeQueryClient = () => {
11+
const oneMinute = 1000 * 60
12+
const queryClient = new QueryClient({
13+
defaultOptions: {
14+
queries: {
15+
// With SSR, we usually want to set some default staleTime
16+
// above 0 to avoid refetching immediately on the client
17+
staleTime: oneMinute,
18+
},
19+
},
20+
})
21+
return queryClient
22+
}
23+
24+
/** --- getQueryClient() ----------------------------------------------------------------------- */
25+
/** -i- Always makes a new query client on the server, but reuses an existing client if found in browser or mobile */
26+
export const getQueryClient = () => {
27+
// Always create a new query client on the server, so no caching is shared between requests
28+
const isServer = typeof window === 'undefined'
29+
if (isServer) return makeQueryClient()
30+
// On the browser or mobile, make a new client if we don't already have one
31+
// This is important so we don't re-make a new client if React suspends during initial render.
32+
// Might not be needed if we have a suspense boundary below the creation of the query client though.
33+
if (!clientSideQueryClient) clientSideQueryClient = makeQueryClient()
34+
return clientSideQueryClient
35+
}
36+
37+
/** --- <UniversalQueryClientProvider/> ----------------------------------------------------------------- */
38+
/** -i- Provides a universal queryclient to be used either client-side or server-side */
39+
export const UniversalQueryClientProvider = ({ children }: { children: React.ReactNode }) => {
40+
const queryClient = getQueryClient()
41+
return (
42+
<QueryClientProvider client={queryClient}>
43+
{children}
44+
</QueryClientProvider>
45+
)
46+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
'use client'
2+
import type { Query, QueryKey } from '@tanstack/react-query'
3+
import { queryBridge } from '../screens/HomeScreen'
4+
5+
/* --- Types ----------------------------------------------------------------------------------- */
6+
7+
export type QueryFn = (args: Record<string, unknown>) => Promise<Record<string, unknown>>
8+
9+
export type QueryBridgeConfig<Fetcher extends QueryFn> = {
10+
/** -i- Function to turn any route params into the query key for the `routeDataFetcher()` query */
11+
routeParamsToQueryKey: (routeParams: Partial<Parameters<Fetcher>[0]>) => QueryKey
12+
/** -i- Function to turn any route params into the input args for the `routeDataFetcher()` query */
13+
routeParamsToQueryInput: (routeParams: Partial<Parameters<Fetcher>[0]>) => Parameters<Fetcher>[0]
14+
/** -i- Fetcher to prefetch data for the Page and QueryClient during SSR, or fetch it clientside if browser / mobile */
15+
routeDataFetcher: Fetcher
16+
/** -i- Function transform fetcher data into props */
17+
fetcherDataToProps?: (data: Awaited<ReturnType<Fetcher>>) => Record<string, unknown>
18+
/** -i- Initial data provided to the QueryClient */
19+
initialData?: ReturnType<Fetcher>
20+
}
21+
22+
export type UniversalRouteProps<Fetcher extends QueryFn> = {
23+
/** -i- Optional params passed by the Next.js app router, in Expo we get these from `useRouteParams()` */
24+
params?: Partial<Parameters<Fetcher>[0]>
25+
/** -i- Optional search params passed by the Next.js app router, in Expo we get these from `useRouteParams()` */
26+
searchParams?: Partial<Parameters<Fetcher>[0]>
27+
/** -i- Configuration for the query bridge */
28+
queryBridge: QueryBridgeConfig<Fetcher>
29+
/** -i- The screen to render for this route */
30+
routeScreen: React.ComponentType
31+
}
32+
33+
export type HydratedRouteProps<
34+
QueryBridge extends QueryBridgeConfig<QueryFn>
35+
> = ReturnType<QueryBridge['fetcherDataToProps']> & {
36+
/** -i- The route key for the query */
37+
queryKey: QueryKey
38+
/** -i- The input args for the query */
39+
queryInput: Parameters<QueryBridge['routeDataFetcher']>[0]
40+
/** -i- The route params passed by the Next.js app router, in Expo we get these from `useRouteParams()` */
41+
params: Partial<Parameters<QueryBridge['routeDataFetcher']>[0]>
42+
/** -i- The search params passed by the Next.js app router, in Expo we get these from `useRouteParams()` */
43+
searchParams: Partial<Parameters<QueryBridge['routeDataFetcher']>[0]>
44+
}
45+
46+
/** --- createQueryBridge() -------------------------------------------------------------------- */
47+
/** -i- Util to create a typed bridge between a fetcher and a route's props */
48+
export const createQueryBridge = <QueryBridge extends QueryBridgeConfig<QueryFn>>(
49+
queryBridge: QueryBridge
50+
) => {
51+
type FetcherData = Awaited<ReturnType<QueryBridge['routeDataFetcher']>>
52+
type ReturnTypeOfFunction<F, A> = F extends ((args: A) => infer R) ? R : FetcherData
53+
type RoutePropsFromFetcher = ReturnTypeOfFunction<QueryBridge['fetcherDataToProps'], FetcherData>
54+
const fetcherDataToProps = queryBridge.fetcherDataToProps || ((data: FetcherData) => data)
55+
return {
56+
...queryBridge,
57+
fetcherDataToProps: fetcherDataToProps as ((data: FetcherData) => RoutePropsFromFetcher),
58+
}
59+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
'use client'
2+
import { useQuery } from '@tanstack/react-query'
3+
import type { UniversalRouteProps, QueryFn } from './UniversalRouteScreen.helpers'
4+
import { useRouteParams } from './useRouteParams'
5+
6+
/** --- <UniversalRouteScreen/> -------------------------------------------------------------------- */
7+
/** -i- Universal Route Wrapper to provide query data on mobile, the browser and during server rendering */
8+
export const UniversalRouteScreen = <Fetcher extends QueryFn>(props: UniversalRouteProps<Fetcher>) => {
9+
// Props
10+
const { params: routeParams, searchParams, queryBridge, routeScreen: RouteScreen, ...screenProps } = props
11+
const { routeParamsToQueryKey, routeParamsToQueryInput, routeDataFetcher } = queryBridge
12+
const fetcherDataToProps = queryBridge.fetcherDataToProps || ((data: ReturnType<Fetcher>) => data)
13+
14+
// Hooks
15+
const expoRouterParams = useRouteParams(props)
16+
17+
// Vars
18+
const queryParams = { ...routeParams, ...searchParams, ...expoRouterParams }
19+
const queryKey = routeParamsToQueryKey(queryParams)
20+
const queryInput = routeParamsToQueryInput(queryParams)
21+
22+
// -- Query --
23+
24+
const queryConfig = {
25+
queryKey,
26+
queryFn: async () => await routeDataFetcher(queryInput),
27+
initialData: queryBridge.initialData,
28+
}
29+
30+
// -- Mobile --
31+
32+
const { data: fetcherData } = useQuery(queryConfig)
33+
const routeDataProps = fetcherDataToProps(fetcherData) as Record<string, unknown>
34+
35+
return (
36+
<RouteScreen
37+
{...routeDataProps}
38+
queryKey={queryKey}
39+
queryInput={queryInput}
40+
{...screenProps} // @ts-ignore
41+
params={routeParams}
42+
searchParams={searchParams}
43+
/>
44+
)
45+
}
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
'use client'
2+
import { use, useState, useEffect } from 'react'
3+
import { useQueryClient, useQuery, dehydrate, HydrationBoundary } from '@tanstack/react-query'
4+
import type { UniversalRouteProps, QueryFn } from './UniversalRouteScreen.helpers'
5+
import { useRouteParams } from './useRouteParams'
6+
import { isExpoWebLocal } from '../appConfig'
7+
8+
/* --- Helpers --------------------------------------------------------------------------------- */
9+
10+
const getSSRData = () => {
11+
const $ssrData = document.getElementById('ssr-data')
12+
const ssrDataText = $ssrData?.getAttribute('data-ssr')
13+
const ssrData = ssrDataText ? (JSON.parse(ssrDataText) as Record<string, any>) : null
14+
return ssrData
15+
}
16+
17+
const getDehydratedSSRState = () => {
18+
const $ssrHydrationState = document.getElementById('ssr-hydration-state')
19+
const ssrHydrationStateText = $ssrHydrationState?.getAttribute('data-ssr')
20+
const ssrHydrationState = ssrHydrationStateText ? (JSON.parse(ssrHydrationStateText) as Record<string, any>) : null
21+
return ssrHydrationState
22+
}
23+
24+
/** --- <UniversalRouteScreen/> ---------------------------------------------------------------- */
25+
/** -i- Universal Route Wrapper to provide query data on mobile, the browser and during server rendering */
26+
export const UniversalRouteScreen = <Fetcher extends QueryFn>(props: UniversalRouteProps<Fetcher>) => {
27+
// Props
28+
const { params: routeParams, searchParams, queryBridge, routeScreen: RouteScreen, ...screenProps } = props
29+
const { routeParamsToQueryKey, routeParamsToQueryInput, routeDataFetcher } = queryBridge
30+
const fetcherDataToProps = queryBridge.fetcherDataToProps || ((data: ReturnType<Fetcher>) => data)
31+
32+
// Hooks
33+
const nextRouterParams = useRouteParams(props)
34+
35+
// Context
36+
const queryClient = useQueryClient()
37+
38+
// State
39+
const [hydratedData, setHydratedData] = useState<Record<string, any> | null>(null)
40+
const [hydratedQueries, setHydratedQueries] = useState<Record<string, any> | null>(null)
41+
42+
// Vars
43+
const isBrowser = typeof window !== 'undefined'
44+
const queryParams = { ...routeParams, ...searchParams, ...nextRouterParams }
45+
const queryKey = routeParamsToQueryKey(queryParams)
46+
const queryInput = routeParamsToQueryInput(queryParams)
47+
48+
// -- Effects --
49+
50+
useEffect(() => {
51+
const ssrData = getSSRData()
52+
if (ssrData) setHydratedData(ssrData) // Save the SSR data to state, removing the SSR data from the DOM
53+
const hydratedQueyClientState = getDehydratedSSRState()
54+
if (hydratedQueyClientState) setHydratedQueries(hydratedQueyClientState) // Save the hydrated state to state, removing the hydrated state from the DOM
55+
}, [])
56+
57+
// -- Query --
58+
59+
const queryConfig = {
60+
queryKey,
61+
queryFn: async () => await routeDataFetcher(queryInput),
62+
initialData: queryBridge.initialData,
63+
}
64+
65+
// -- Browser --
66+
67+
if (isBrowser) {
68+
const hydrationData = hydratedData || getSSRData()
69+
const hydrationState = hydratedQueries || getDehydratedSSRState()
70+
const renderHydrationData = !!hydrationData && !hydratedData // Only render the hydration data if it's not already in state
71+
72+
const { data: fetcherData } = useQuery({
73+
...queryConfig,
74+
initialData: isExpoWebLocal ? undefined : {
75+
...queryConfig.initialData,
76+
...hydrationData,
77+
},
78+
refetchOnMount: isExpoWebLocal,
79+
})
80+
const routeDataProps = fetcherDataToProps(fetcherData as Awaited<ReturnType<Fetcher>>) as Record<string, unknown> // prettier-ignore
81+
82+
return (
83+
<HydrationBoundary state={hydrationState}>
84+
{renderHydrationData && <div id="ssr-data" data-ssr={JSON.stringify(fetcherData)} />}
85+
{renderHydrationData && <div id="ssr-hydration-state" data-ssr={JSON.stringify(hydrationState)} />}
86+
<RouteScreen
87+
{...routeDataProps}
88+
queryKey={queryKey}
89+
queryInput={queryInput}
90+
{...screenProps} // @ts-ignore
91+
params={routeParams}
92+
searchParams={searchParams}
93+
/>
94+
</HydrationBoundary>
95+
)
96+
}
97+
98+
// -- Server --
99+
100+
const fetcherData = use(queryClient.fetchQuery(queryConfig)) as Awaited<ReturnType<Fetcher>>
101+
const routeDataProps = fetcherDataToProps(fetcherData) as Record<string, unknown>
102+
const dehydratedState = dehydrate(queryClient)
103+
104+
return (
105+
<HydrationBoundary state={dehydratedState}>
106+
{!!fetcherData && <div id="ssr-data" data-ssr={JSON.stringify(fetcherData)} />}
107+
{!!dehydratedState && <div id="ssr-hydration-state" data-ssr={JSON.stringify(dehydratedState)} />}
108+
<RouteScreen
109+
{...routeDataProps}
110+
queryKey={queryKey}
111+
queryInput={queryInput}
112+
{...screenProps} // @ts-ignore
113+
params={routeParams}
114+
searchParams={searchParams}
115+
/>
116+
</HydrationBoundary>
117+
)
118+
}

features/app-core/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
"dependencies": {
77
"@apollo/server": "^4.10.2",
88
"@as-integrations/next": "^3.0.0",
9+
"@tanstack/react-query": "^5.29.2",
910
"@graphql-tools/load-files": "^7.0.0",
1011
"gql.tada": "^1.4.3",
1112
"graphql-tag": "^2.12.6",
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import type { HealthCheckArgs, HealthCheckResponse } from './healthCheck'
2+
import { appConfig } from '../appConfig'
3+
4+
/** --- healthCheckFetcher() ------------------------------------------------------------------- */
5+
/** -i- Isomorphic fetcher for our healthCheck() resolver at '/api/health' */
6+
export const healthCheckFetcher = async (args: HealthCheckArgs) => {
7+
const response = await fetch(`${appConfig.backendURL}/api/health?echo=${args.echo}`, {
8+
method: 'GET',
9+
headers: {
10+
'Content-Type': 'application/json',
11+
},
12+
})
13+
const data = await response.json()
14+
return data as HealthCheckResponse
15+
}

0 commit comments

Comments
 (0)