diff --git a/apps/expo/app/(main)/index.tsx b/apps/expo/app/(main)/index.tsx
index 03d5802..d9bf212 100644
--- a/apps/expo/app/(main)/index.tsx
+++ b/apps/expo/app/(main)/index.tsx
@@ -1,3 +1,3 @@
-import HomeScreen from '@app/core/screens/HomeScreen'
+import HomeScreen from '@app/core/routes/index'
export default HomeScreen
diff --git a/apps/next/app/(main)/page.tsx b/apps/next/app/(main)/page.tsx
index ee45166..12f90ec 100644
--- a/apps/next/app/(main)/page.tsx
+++ b/apps/next/app/(main)/page.tsx
@@ -1,4 +1,4 @@
'use client'
-import HomeScreen from '@app/core/screens/HomeScreen'
+import HomeScreen from '@app/core/routes/index'
export default HomeScreen
diff --git a/features/app-core/context/UniversalQueryClientProvider.tsx b/features/app-core/context/UniversalQueryClientProvider.tsx
new file mode 100644
index 0000000..b078be0
--- /dev/null
+++ b/features/app-core/context/UniversalQueryClientProvider.tsx
@@ -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
+}
+
+/** --- ----------------------------------------------------------------- */
+/** -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 (
+
+ {children}
+
+ )
+}
diff --git a/features/app-core/navigation/UniversalRouteScreen.helpers.ts b/features/app-core/navigation/UniversalRouteScreen.helpers.ts
new file mode 100644
index 0000000..2e86c22
--- /dev/null
+++ b/features/app-core/navigation/UniversalRouteScreen.helpers.ts
@@ -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) => Promise>
+
+export type QueryBridgeConfig = {
+ /** -i- Function to turn any route params into the query key for the `routeDataFetcher()` query */
+ routeParamsToQueryKey: (routeParams: Partial[0]>) => QueryKey
+ /** -i- Function to turn any route params into the input args for the `routeDataFetcher()` query */
+ routeParamsToQueryInput: (routeParams: Partial[0]>) => Parameters[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>) => Record
+ /** -i- Initial data provided to the QueryClient */
+ initialData?: ReturnType
+}
+
+export type UniversalRouteProps = {
+ /** -i- Optional params passed by the Next.js app router, in Expo we get these from `useRouteParams()` */
+ params?: Partial[0]>
+ /** -i- Optional search params passed by the Next.js app router, in Expo we get these from `useRouteParams()` */
+ searchParams?: Partial[0]>
+ /** -i- Configuration for the query bridge */
+ queryBridge: QueryBridgeConfig
+ /** -i- The screen to render for this route */
+ routeScreen: React.ComponentType
+}
+
+export type HydratedRouteProps<
+ QueryBridge extends QueryBridgeConfig
+> = ReturnType & {
+ /** -i- The route key for the query */
+ queryKey: QueryKey
+ /** -i- The input args for the query */
+ queryInput: Parameters[0]
+ /** -i- The route params passed by the Next.js app router, in Expo we get these from `useRouteParams()` */
+ params: Partial[0]>
+ /** -i- The search params passed by the Next.js app router, in Expo we get these from `useRouteParams()` */
+ searchParams: Partial[0]>
+}
+
+/** --- createQueryBridge() -------------------------------------------------------------------- */
+/** -i- Util to create a typed bridge between a fetcher and a route's props */
+export const createQueryBridge = >(
+ queryBridge: QueryBridge
+) => {
+ type FetcherData = Awaited>
+ type ReturnTypeOfFunction = F extends ((args: A) => infer R) ? R : FetcherData
+ type RoutePropsFromFetcher = ReturnTypeOfFunction
+ const fetcherDataToProps = queryBridge.fetcherDataToProps || ((data: FetcherData) => data)
+ return {
+ ...queryBridge,
+ fetcherDataToProps: fetcherDataToProps as ((data: FetcherData) => RoutePropsFromFetcher),
+ }
+}
diff --git a/features/app-core/navigation/UniversalRouteScreen.tsx b/features/app-core/navigation/UniversalRouteScreen.tsx
new file mode 100644
index 0000000..cf55b60
--- /dev/null
+++ b/features/app-core/navigation/UniversalRouteScreen.tsx
@@ -0,0 +1,45 @@
+'use client'
+import { useQuery } from '@tanstack/react-query'
+import type { UniversalRouteProps, QueryFn } from './UniversalRouteScreen.helpers'
+import { useRouteParams } from './useRouteParams'
+
+/** --- -------------------------------------------------------------------- */
+/** -i- Universal Route Wrapper to provide query data on mobile, the browser and during server rendering */
+export const UniversalRouteScreen = (props: UniversalRouteProps) => {
+ // Props
+ const { params: routeParams, searchParams, queryBridge, routeScreen: RouteScreen, ...screenProps } = props
+ const { routeParamsToQueryKey, routeParamsToQueryInput, routeDataFetcher } = queryBridge
+ const fetcherDataToProps = queryBridge.fetcherDataToProps || ((data: ReturnType) => 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
+
+ return (
+
+ )
+}
diff --git a/features/app-core/navigation/UniversalRouteScreen.web.tsx b/features/app-core/navigation/UniversalRouteScreen.web.tsx
new file mode 100644
index 0000000..551900e
--- /dev/null
+++ b/features/app-core/navigation/UniversalRouteScreen.web.tsx
@@ -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) : 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) : null
+ return ssrHydrationState
+}
+
+/** --- ---------------------------------------------------------------- */
+/** -i- Universal Route Wrapper to provide query data on mobile, the browser and during server rendering */
+export const UniversalRouteScreen = (props: UniversalRouteProps) => {
+ // Props
+ const { params: routeParams, searchParams, queryBridge, routeScreen: RouteScreen, ...screenProps } = props
+ const { routeParamsToQueryKey, routeParamsToQueryInput, routeDataFetcher } = queryBridge
+ const fetcherDataToProps = queryBridge.fetcherDataToProps || ((data: ReturnType) => data)
+
+ // Hooks
+ const nextRouterParams = useRouteParams(props)
+
+ // Context
+ const queryClient = useQueryClient()
+
+ // State
+ const [hydratedData, setHydratedData] = useState | null>(null)
+ const [hydratedQueries, setHydratedQueries] = useState | 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>) as Record // prettier-ignore
+
+ return (
+
+ {renderHydrationData && }
+ {renderHydrationData && }
+
+
+ )
+ }
+
+ // -- Server --
+
+ const fetcherData = use(queryClient.fetchQuery(queryConfig)) as Awaited>
+ const routeDataProps = fetcherDataToProps(fetcherData) as Record
+ const dehydratedState = dehydrate(queryClient)
+
+ return (
+
+ {!!fetcherData && }
+ {!!dehydratedState && }
+
+
+ )
+}
diff --git a/features/app-core/package.json b/features/app-core/package.json
index e04b339..7f8672e 100644
--- a/features/app-core/package.json
+++ b/features/app-core/package.json
@@ -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"
},
diff --git a/features/app-core/resolvers/healthCheck.fetcher.ts b/features/app-core/resolvers/healthCheck.fetcher.ts
new file mode 100644
index 0000000..60f6fae
--- /dev/null
+++ b/features/app-core/resolvers/healthCheck.fetcher.ts
@@ -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
+}
diff --git a/features/app-core/resolvers/healthCheck.fetcher.web.ts b/features/app-core/resolvers/healthCheck.fetcher.web.ts
new file mode 100644
index 0000000..ff29138
--- /dev/null
+++ b/features/app-core/resolvers/healthCheck.fetcher.web.ts
@@ -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
+}
diff --git a/features/app-core/resolvers/healthCheck.ts b/features/app-core/resolvers/healthCheck.ts
index 14a2dc8..56cd6a4 100644
--- a/features/app-core/resolvers/healthCheck.ts
+++ b/features/app-core/resolvers/healthCheck.ts
@@ -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) => {
diff --git a/features/app-core/routes/index.tsx b/features/app-core/routes/index.tsx
new file mode 100644
index 0000000..f5e7ded
--- /dev/null
+++ b/features/app-core/routes/index.tsx
@@ -0,0 +1,12 @@
+import { UniversalRouteScreen } from '../navigation/UniversalRouteScreen'
+import HomeScreen, { queryBridge } from '../screens/HomeScreen'
+
+/* --- / --------------------------------------------------------------------------------------- */
+
+export default (props: any) => (
+
+)
diff --git a/features/app-core/screens/HomeScreen.tsx b/features/app-core/screens/HomeScreen.tsx
index 7db3255..73502d4 100644
--- a/features/app-core/screens/HomeScreen.tsx
+++ b/features/app-core/screens/HomeScreen.tsx
@@ -2,10 +2,30 @@ import React from 'react'
import { StyleSheet, Text, View } from 'react-native'
import { Link } from '../navigation/Link'
import { Image } from '../components/Image'
+import { healthCheckFetcher } from '../resolvers/healthCheck.fetcher'
+import { HydratedRouteProps, createQueryBridge } from '../navigation/UniversalRouteScreen.helpers'
+
+/* --- Data Fetching --------------------------------------------------------------------------- */
+
+export const queryBridge = createQueryBridge({
+ routeParamsToQueryKey: (routeParams: { echo: string }) => ['healthCheck', routeParams.echo],
+ routeParamsToQueryInput: (routeParams: { echo: string }) => ({ echo: routeParams.echo }),
+ routeDataFetcher: healthCheckFetcher,
+ fetcherDataToProps: (fetcherData: Awaited>) => ({
+ serverHealth: fetcherData
+ }),
+})
+
+queryBridge['fetcherDataToProps']
/* --- --------------------------------------------------------------------------- */
-const HomeScreen = () => {
+const HomeScreen = (props: HydratedRouteProps) => {
+ // Props
+ const { serverHealth } = props
+
+ // -- Render --
+
return (
@@ -13,6 +33,11 @@ const HomeScreen = () => {
Open HomeScreen.tsx in features/app-core/screens to start working on your app
Test navigation
Test images
+ {serverHealth ? (
+ Test API
+ ) : (
+ {'Loading server health...'}
+ )}
Docs
)
diff --git a/features/app-core/screens/UniversalAppProviders.tsx b/features/app-core/screens/UniversalAppProviders.tsx
index 770f906..1054c5a 100644
--- a/features/app-core/screens/UniversalAppProviders.tsx
+++ b/features/app-core/screens/UniversalAppProviders.tsx
@@ -1,5 +1,6 @@
'use client'
import React from 'react'
+import { UniversalQueryClientProvider } from '../context/UniversalQueryClientProvider'
// -i- This is a regular react client component
// -i- Use this file for adding universal app providers
@@ -15,9 +16,9 @@ type UniversalAppProvidersProps = {
/* --- ---------------------------------------------------------------- */
const UniversalAppProviders = ({ children }: UniversalAppProvidersProps) => (
- <>
+
{children}
- >
+
)
/* --- Exports --------------------------------------------------------------------------------- */
diff --git a/package-lock.json b/package-lock.json
index 2f76ce4..0b904b5 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -52,6 +52,9 @@
"features/app-core": {
"name": "@app/core",
"version": "1.0.0",
+ "dependencies": {
+ "@tanstack/react-query": "^5.29.2"
+ },
"devDependencies": {
"typescript": "5.3.3"
}
@@ -6486,6 +6489,30 @@
"tslib": "^2.4.0"
}
},
+ "node_modules/@tanstack/query-core": {
+ "version": "5.29.0",
+ "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.29.0.tgz",
+ "integrity": "sha512-WgPTRs58hm9CMzEr5jpISe8HXa3qKQ8CxewdYZeVnA54JrPY9B1CZiwsCoLpLkf0dGRZq+LcX5OiJb0bEsOFww==",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ }
+ },
+ "node_modules/@tanstack/react-query": {
+ "version": "5.29.2",
+ "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.29.2.tgz",
+ "integrity": "sha512-nyuWILR4u7H5moLGSiifLh8kIqQDLNOHGuSz0rcp+J75fNc8aQLyr5+I2JCHU3n+nJrTTW1ssgAD8HiKD7IFBQ==",
+ "dependencies": {
+ "@tanstack/query-core": "5.29.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ },
+ "peerDependencies": {
+ "react": "^18.0.0"
+ }
+ },
"node_modules/@types/cookie": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",