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 = | 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( queryKey: readonly unknown[], load: (queries: PublicContentQueries) => Promise, ): PublicContentView { 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( 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 ? ( void query.retry()} /> ) : ( ), }); } /** * 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 { return ( typeof value === "object" && value !== null && typeof (value as { kind?: unknown }).kind === "string" && typeof (value as { code?: unknown }).code === "string" ); }