diff --git a/app/people/page.tsx b/app/people/page.tsx index bb21b27..9ca474a 100644 --- a/app/people/page.tsx +++ b/app/people/page.tsx @@ -1,10 +1,22 @@ import PeopleTable from "@/components/table/PeopleTable"; +import { fetchResourceData } from "@/lib/swapi/fetchResourceData"; +import { PeopleResource } from "@/lib/swapi/resources"; +import { SwapiResourceHydrator } from "@/lib/swapi/SwapiResourceHydrator"; + +export default async function People() { + const entries = []; + try { + entries.push(await fetchResourceData(PeopleResource)); + } catch (error) { + console.error("Failed to prefetch people for SSR:", error); + } -export default function People() { return (

People

- + + +
); } diff --git a/app/planets/page.tsx b/app/planets/page.tsx index cf2579a..783a225 100644 --- a/app/planets/page.tsx +++ b/app/planets/page.tsx @@ -1,10 +1,22 @@ import PlanetsTable from "@/components/table/PlanetsTable"; +import { fetchResourceData } from "@/lib/swapi/fetchResourceData"; +import { PlanetsResource } from "@/lib/swapi/resources"; +import { SwapiResourceHydrator } from "@/lib/swapi/SwapiResourceHydrator"; + +export default async function Planets() { + const entries = []; + try { + entries.push(await fetchResourceData(PlanetsResource)); + } catch (error) { + console.error("Failed to prefetch planets for SSR:", error); + } -export default function Planets() { return (

Planets

- + + +
); } diff --git a/components/table/PlanetsTable.tsx b/components/table/PlanetsTable.tsx index 34fa67b..a4840f2 100644 --- a/components/table/PlanetsTable.tsx +++ b/components/table/PlanetsTable.tsx @@ -15,7 +15,7 @@ export default function PlanetsTable() {
diff --git a/lib/fetch-store/createFetchStore.ts b/lib/fetch-store/createFetchStore.ts index e83baca..7f319de 100644 --- a/lib/fetch-store/createFetchStore.ts +++ b/lib/fetch-store/createFetchStore.ts @@ -38,6 +38,16 @@ export type FetchStore = { ...args: Args ): UseResourceResult; }; + + /** + * Seeds the store with already-parsed data for a given URL. + * Called during render (e.g. by a hydrator component) so data is available + * for `useSyncExternalStore` during SSR and client hydration. + * + * Only writes when the entry is absent or stale — never overwrites fresh data. + * Does **not** notify subscribers (safe to call in the render phase). + */ + preloadEntry: (url: string, data: unknown) => void; }; // --------------------------------------------------------------------------- @@ -165,12 +175,30 @@ export const createFetchStore = (): FetchStore => { ResourceAction >(reducer, {}); + /** + * Directly seeds the store with parsed data for a URL. + * Only writes when the entry is absent or stale — never overwrites fresh data. + * Safe to call during render (no subscribers exist yet during SSR/hydration). + */ + const preloadEntry = (url: string, data: unknown): void => { + const entry = getState()[url]; + if (!entry || entry.state === "stale") { + dispatch({ type: "FETCH_SUCCESS", url, data }); + } + }; + const startFetch = async ( url: string, parse: (raw: unknown) => unknown, dispatch: (action: ResourceAction) => void, force: boolean = false, ): Promise => { + // Skip when data was already loaded (e.g. preloaded from SSR) unless forced. + const current = getState()[url]; + if (!force && current?.state === "success") { + return; + } + dispatch({ type: "FETCH_REQUEST", url }); const entry = getState()[url] as InternalResourceItem; const meta = ensureMeta(entry); @@ -254,7 +282,7 @@ export const createFetchStore = (): FetchStore => { typeof def.url === "function" ? def.url(...args) : def.url; const refetch = useCallback( - (force: boolean = false) => { + (force: boolean = true) => { if (resolvedUrl === null) return; startFetch(resolvedUrl, def.parse, dispatch, force); }, @@ -273,5 +301,5 @@ export const createFetchStore = (): FetchStore => { return { ...(item ?? (STALE_ITEM as ResourceItem)), refetch }; } - return { FetchProvider: Provider, useResource }; + return { FetchProvider: Provider, useResource, preloadEntry }; }; diff --git a/lib/swapi/SwapiResourceHydrator.tsx b/lib/swapi/SwapiResourceHydrator.tsx new file mode 100644 index 0000000..e33be40 --- /dev/null +++ b/lib/swapi/SwapiResourceHydrator.tsx @@ -0,0 +1,41 @@ +"use client"; + +import { type ReactNode, useRef } from "react"; +import { preloadSwapiEntry } from "@/lib/swapi/createSwapiStore"; + +type HydrationEntry = { url: string; data: unknown }; + +/** + * Preloads server-fetched data into the SWAPI fetch store during render. + * + * Wrap table (or any resource-consuming) components with this so the store + * already contains the data when they first render — both during SSR and + * client hydration. + * + * @example + * ```tsx + * const result = await fetchResourceData(PlanetsResource); + * + * + * + * + * ``` + */ +export function SwapiResourceHydrator({ + entries, + children, +}: { + entries: HydrationEntry[]; + children: ReactNode; +}) { + const hydrated = useRef(null); + + if (hydrated.current == null) { + for (const { url, data } of entries) { + preloadSwapiEntry(url, data); + } + hydrated.current = true; + } + + return <>{children}; +} diff --git a/lib/swapi/createSwapiStore.ts b/lib/swapi/createSwapiStore.ts index 23b9df3..67c6c8c 100644 --- a/lib/swapi/createSwapiStore.ts +++ b/lib/swapi/createSwapiStore.ts @@ -5,4 +5,5 @@ import { createFetchStore } from "@/lib/fetch-store/createFetchStore"; export const { FetchProvider: SwapiFetchProvider, useResource: useSwapiResource, + preloadEntry: preloadSwapiEntry, } = createFetchStore(); diff --git a/lib/swapi/fetchResourceData.ts b/lib/swapi/fetchResourceData.ts new file mode 100644 index 0000000..bfa8217 --- /dev/null +++ b/lib/swapi/fetchResourceData.ts @@ -0,0 +1,30 @@ +import type { ResourceDefinition } from "@/lib/fetch-store/types"; + +/** + * Fetches and parses resource data on the server. + * Returns both the resolved URL and the parsed data so the caller can + * seed the client-side store for hydration. + * + * @example + * ```ts + * const { url, data } = await fetchResourceData(PlanetsResource); + * ``` + */ +export async function fetchResourceData( + def: ResourceDefinition, +): Promise<{ url: string; data: T }> { + const url = typeof def.url === "string" ? def.url : def.url(); + if (!url) throw new Error("Resource URL resolved to null"); + + const res = await fetch(url); + if (!res.ok) { + throw new Error( + `Failed to fetch ${url}: ${res.statusText} (HTTP ${res.status})`, + ); + } + + const raw = await res.json(); + const data = def.parse(raw); + + return { url, data }; +}