Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 14 additions & 2 deletions app/people/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<main>
<h1 className="text-4xl font-bold m-4">People</h1>
<PeopleTable />
<SwapiResourceHydrator entries={entries}>
<PeopleTable />
</SwapiResourceHydrator>
</main>
);
}
16 changes: 14 additions & 2 deletions app/planets/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<main>
<h1 className="text-4xl font-bold m-4">Planets</h1>
<PlanetsTable />
<SwapiResourceHydrator entries={entries}>
<PlanetsTable />
</SwapiResourceHydrator>
</main>
);
}
2 changes: 1 addition & 1 deletion components/table/PlanetsTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export default function PlanetsTable() {
<div className="flex items-center gap-2 px-2">
<button
className="font-bold rounded bg-foreground/10 px-2 py-1 hover:bg-foreground/20"
onClick={() => planets.refetch(true)}
onClick={() => planets.refetch()}
>
Refresh
</button>
Expand Down
32 changes: 30 additions & 2 deletions lib/fetch-store/createFetchStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,16 @@ export type FetchStore = {
...args: Args
): UseResourceResult<T>;
};

/**
* 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;
};

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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<void> => {
// 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);
Expand Down Expand Up @@ -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);
},
Expand All @@ -273,5 +301,5 @@ export const createFetchStore = (): FetchStore => {
return { ...(item ?? (STALE_ITEM as ResourceItem<T>)), refetch };
}

return { FetchProvider: Provider, useResource };
return { FetchProvider: Provider, useResource, preloadEntry };
};
41 changes: 41 additions & 0 deletions lib/swapi/SwapiResourceHydrator.tsx
Original file line number Diff line number Diff line change
@@ -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);
*
* <SwapiResourceHydrator entries={[result]}>
* <PlanetsTable />
* </SwapiResourceHydrator>
* ```
*/
export function SwapiResourceHydrator({
entries,
children,
}: {
entries: HydrationEntry[];
children: ReactNode;
}) {
const hydrated = useRef<true | null>(null);

if (hydrated.current == null) {
for (const { url, data } of entries) {
preloadSwapiEntry(url, data);
}
hydrated.current = true;
}

return <>{children}</>;
}
1 change: 1 addition & 0 deletions lib/swapi/createSwapiStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,5 @@ import { createFetchStore } from "@/lib/fetch-store/createFetchStore";
export const {
FetchProvider: SwapiFetchProvider,
useResource: useSwapiResource,
preloadEntry: preloadSwapiEntry,
} = createFetchStore();
30 changes: 30 additions & 0 deletions lib/swapi/fetchResourceData.ts
Original file line number Diff line number Diff line change
@@ -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<T>(
def: ResourceDefinition<T, []>,
): 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 };
}