NGU-Web/src/lib/useRecord.ts
Zaldimmar 2428f4412a Type src/ against API and database shapes, fixing implicit anys
Adds .d.ts declarations beside the untyped JS modules imported from
TS (api.js, navConfig.js, adminSchema.js, adminNav.js, src/data/*),
with shapes taken from the server routes and migrations. get/post/
patch/del now return unknown unless the caller names the response.

Fixes found along the way:
- website/email/instagram are bare strings from splitLinks, not Link
  objects; OrganizationDetail's website and email pills rendered with
  no href or label and now link correctly.
- The chapter map falls back to FALLBACK_COLOR for a region with no
  colour instead of painting its tiles black.

Adds defineSection() so each page manifest entry's props are checked
against its own Component.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 04:13:46 -05:00

127 lines
4.3 KiB
TypeScript

/**
* useRecord — one record from one endpoint, on the useHistory
* pattern.
*
* Named for what it returns, and deliberately not useResource:
* src/lib/useResource.js is a different hook — it hands back the
* whole response body and takes an options object — and a .ts file
* of the same name would sit one extension away from it. An import
* whose target goes missing then resolves to the other file without
* a word, and every detail page renders the wrapper object instead
* of the record. That is exactly how this file came to exist.
*
* Every detail route in content.js answers the same shape: 200 with
* a single top-level key, or 404 with `{ error }`. So the hook takes
* the path and the key, and the four callers in useContent.ts are
* one line each rather than four copies of this file.
*
* `key` is a string rather than a selector function on purpose. A
* selector passed inline would be a new identity every render, and
* putting it in the effect's deps would refetch forever; leaving it
* out would silently use a stale closure. A string has neither
* problem.
*
* Carried over from useHistory, and worth restating:
*
* · `reload` invalidates before it refetches. Without that, the
* retry button inside the 60s TTL hands back the same settled
* promise and looks like it did nothing. A *failed* request is
* already evicted by api.js, so this matters for the refresh
* case rather than the error case.
*
* · no AbortController. `get` shares one promise between callers,
* so aborting on unmount would cancel someone else's request.
* The `live` flag drops the result instead.
*
* · no `fallback`. Rendering plausible-looking content with no
* sign the server is down is worse than a visible error.
*
* `notFound` is separated from `error` because they are different
* pages: a 404 is a slug that doesn't exist and retrying won't help,
* anything else is worth a Try again button.
*
* A null `path` means there is nothing to ask for, and the hook
* reports notFound rather than loading forever. Callers build the
* path with detailPath() from hrefs.ts, which returns null for an
* id that could never be real — "undefined" chief among them.
*/
import { useCallback, useEffect, useState } from 'react'
import { get, invalidate, ApiError } from './api.js'
export type Resource<T> = {
data: T | null
loading: boolean
error: string | null
notFound: boolean
reload: () => void
}
export function useRecord<T>(path: string | null, key: string): Resource<T> {
const [data, setData] = useState<T | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [notFound, setNotFound] = useState(false)
const [attempt, setAttempt] = useState(0)
const reload = useCallback(() => {
if (path) invalidate(path)
setAttempt((n) => n + 1)
}, [path])
useEffect(() => {
// Nothing to ask for. Distinguished from "not asked yet" by the
// caller: useContent passes null only when the id is unusable,
// and an unusable id is a 404 as far as the visitor is
// concerned.
if (!path) {
setData(null)
setError(null)
setNotFound(true)
setLoading(false)
return undefined
}
let live = true
async function load() {
setLoading(true)
setError(null)
setNotFound(false)
try {
const body = await get<Record<string, unknown> | null>(path as string)
if (!live) return
// A 200 with the key absent is a server-side shaping bug,
// not an empty record. Say so rather than rendering a page
// full of blanks.
const record = body?.[key]
if (record === undefined) {
setError(`The server sent no "${key}".`)
setData(null)
return
}
setData(record as T)
} catch (err) {
if (!live) return
if (err instanceof ApiError && err.status === 404) {
setNotFound(true)
setData(null)
return
}
setError(
err instanceof ApiError ? err.message : "Couldn't reach the server.",
)
setData(null)
} finally {
if (live) setLoading(false)
}
}
load()
return () => {
live = false
}
}, [path, key, attempt])
return { data, loading, error, notFound, reload }
}