`PublicContentQueries` returned arrays, not promises. That signature is only implementable by something already in memory, so the port could hold exactly one adapter — the bundled fixture — and no amount of configuration could put the public site on the backend. Turning it async is the change that makes a second adapter possible; the adapter itself follows. The markup is untouched. Every page reads a value and hands it to a presentational component, so the shape those components receive is mapped at the adapter boundary and nothing below the page changes. Screens load through one query, not one per read. Several pages read in a loop — the home timeline walks every project for its activity, the explore filter walks search results to resolve titles — and a hook per read would mean a variable number of hooks per render, which React forbids. `usePublicContent` takes the whole screen's reads as one loader, where a loop is a loop and `Promise.all` is available; the loops that used to be N sequential lookups now issue together. Two places deliberately do not show the loading surface. The explore filter sits inside a page that already renders one, so a second skeleton would move the layout under it — it keeps its structure and fills its options in when they arrive. The search dialog is a type-ahead: re-querying per keystroke would replace the results with a skeleton on every key, so it loads the catalog once and applies the same predicate locally. `usePublicContent` requires an object because `undefined` is how the query layer says "no result yet". A loader returning the record itself would make a missing slug indistinguishable from a request in flight, and the page would sit on a skeleton instead of rendering its not-found route. Studio's `resolvePublishedLabel` stays synchronous. It is called from inside the public renderer, so making it async would push awaits through the render tree; the shell loads the catalog once and the callback remains a lookup. The component tests now assemble the query providers the running app assembles. Without them the render throws "No QueryClient set" — not a harness quirk, but the same failure the app would produce if it were mounted without its query layer.
110 lines
4.3 KiB
TypeScript
110 lines
4.3 KiB
TypeScript
import { useCallback, useMemo, type ReactNode } from "react";
|
|
|
|
import { createFailure } from "../../../../contracts/errors.ts";
|
|
import {
|
|
LoadingSurface,
|
|
TerminalErrorSurface,
|
|
} from "../../../../presentation/components/async-surface.tsx";
|
|
import { useApplicationQuery } from "../../../../presentation/adapters/query/index.ts";
|
|
import { useApplication } from "../../../../presentation/providers/application-provider.tsx";
|
|
import type { PublicContentQueries } from "../../application/ports/public-content-queries.ts";
|
|
import { TECH_LOG_FEATURE_ID } from "../../application/tech-log-feature-input.ts";
|
|
|
|
/**
|
|
* One query per screen, not one per call.
|
|
*
|
|
* The public pages were written against a synchronous fixture, so they read
|
|
* whatever they needed inline — and several read in a loop: the home timeline
|
|
* walks every project for its activity, the explore filter walks search results
|
|
* to resolve project titles. Turning each of those into its own hook would mean
|
|
* a variable number of hooks per render, which React forbids outright.
|
|
*
|
|
* So a screen loads everything in one `execute`, where a loop is just a loop and
|
|
* `Promise.all` is available. The cost is that a screen waits for its slowest
|
|
* read; the benefit is that the page bodies keep computing from plain values and
|
|
* the markup is unchanged.
|
|
*
|
|
* The return is a discriminated union so a page can hand back `view.fallback`
|
|
* and have `view.data` narrow to present on the line after — without that, every
|
|
* page would need its own non-null assertion.
|
|
*/
|
|
export type PublicContentView<Value> =
|
|
| Readonly<{ ready: false; fallback: ReactNode; data?: undefined }>
|
|
| Readonly<{ ready: true; fallback: null; data: Value }>;
|
|
|
|
/**
|
|
* `Value extends object` is load-bearing, not decoration. `undefined` is how the
|
|
* query layer says "no result yet", so a loader that returned the record itself
|
|
* would make a genuinely missing slug — `getRecord` resolving to `undefined` —
|
|
* indistinguishable from a request still in flight, and the page would sit on a
|
|
* loading skeleton instead of rendering its not-found route. Wrapping the
|
|
* screen's reads in an object keeps the two apart.
|
|
*/
|
|
export function usePublicContent<Value extends object>(
|
|
queryKey: readonly unknown[],
|
|
load: (queries: PublicContentQueries) => Promise<Value>,
|
|
): PublicContentView<Value> {
|
|
const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID);
|
|
|
|
// `load` is a new closure every render, so depending on it would re-run the
|
|
// query forever. The key is the declared identity of the request — the same
|
|
// rule the rest of the query layer follows — so the key is what this closes
|
|
// over.
|
|
const execute = useCallback(
|
|
async () => {
|
|
try {
|
|
return { ok: true as const, value: await load(publicContent) };
|
|
} catch (cause) {
|
|
return { ok: false as const, error: failureFor(cause) };
|
|
}
|
|
},
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- keyed by queryKey, see above
|
|
[publicContent, ...queryKey],
|
|
);
|
|
|
|
const query = useApplicationQuery<Value>(
|
|
useMemo(() => ({ queryKey, execute }), [execute, queryKey]),
|
|
);
|
|
|
|
if (query.data !== undefined) {
|
|
return Object.freeze({ ready: true as const, fallback: null, data: query.data });
|
|
}
|
|
const failure = query.state.failure;
|
|
return Object.freeze({
|
|
ready: false as const,
|
|
fallback: failure ? (
|
|
<TerminalErrorSurface
|
|
userMessageKey={failure.userMessageKey}
|
|
action={failure.action}
|
|
onAction={() => void query.retry()}
|
|
/>
|
|
) : (
|
|
<LoadingSurface />
|
|
),
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Adapters throw. One that knows what went wrong attaches the classified
|
|
* failure to the error; anything else arriving here is a defect in this layer
|
|
* rather than a server condition, and is not reported as one.
|
|
*/
|
|
function failureFor(cause: unknown) {
|
|
const attached = (cause as { failure?: unknown } | null)?.failure;
|
|
if (isAppFailure(attached)) return attached;
|
|
return createFailure("UNKNOWN_CLIENT_FAILURE", "TECH_LOG_PUBLIC_CONTENT", 0, {
|
|
code: "PUBLIC_CONTENT_UNAVAILABLE",
|
|
});
|
|
}
|
|
|
|
function isAppFailure(
|
|
value: unknown,
|
|
): value is ReturnType<typeof createFailure> {
|
|
return (
|
|
typeof value === "object" &&
|
|
value !== null &&
|
|
typeof (value as { kind?: unknown }).kind === "string" &&
|
|
typeof (value as { code?: unknown }).code === "string"
|
|
);
|
|
}
|