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",