v1.5 - history and timeline as well as many datastructure updates added, polished, fixes
This commit is contained in:
parent
1f0aa3078f
commit
1d84400aef
63 changed files with 7927 additions and 208 deletions
127
src/lib/useRecord.ts
Normal file
127
src/lib/useRecord.ts
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
/**
|
||||
* 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(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 }
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue