187 lines
6.1 KiB
Markdown
187 lines
6.1 KiB
Markdown
# Design — `@suaveplan/react-packer`
|
|
|
|
## Architecture
|
|
|
|
`@suaveplan/react-packer` is a React 19 companion that wraps a
|
|
`PackedResourceManager` from `@suaveplan/packer-browser` in a small set of
|
|
hooks plus a React context.
|
|
|
|
```text
|
|
Consumer app
|
|
│
|
|
▼
|
|
<PackedResourceProvider value={manager} resolver={resolver}>
|
|
│
|
|
▼
|
|
usePackedResource(packedUrl) ─┐
|
|
usePackedImage(packedUrl) │
|
|
usePackedAudio(packedUrl) ├─► manager.load(url, opts)
|
|
usePackedVideo(packedUrl) │ │
|
|
usePackedFont(packedUrl, family) ─┘ ▼
|
|
PackedResourceLoader (fetch + verify + unpack)
|
|
PackedResourceCache (LRU + Blob URLs)
|
|
```
|
|
|
|
The provider is optional. If no provider is mounted in the tree, the
|
|
hooks resolve the singleton via dynamic import of
|
|
`@suaveplan/packer-browser/singleton`. This keeps the default zero-config
|
|
import cost zero — singleton construction only runs in trees that don't
|
|
inject a manager. Adopters that care about tree-shaking, multiple
|
|
managers, or explicit lifecycle (test rigs, SSR) mount a provider and
|
|
never touch the singleton.
|
|
|
|
## Public surface
|
|
|
|
```ts
|
|
// hooks/use-packed-resource
|
|
export interface UsePackedResourceOptions {
|
|
cache?: boolean;
|
|
verifyChecksum?: boolean;
|
|
verifyKey?: Uint8Array;
|
|
signal?: AbortSignal;
|
|
allowedMimePrefixes?: ReadonlyArray<string>;
|
|
}
|
|
|
|
export interface UsePackedResourceReturn {
|
|
url: string | null;
|
|
blob: Blob | null;
|
|
loading: boolean;
|
|
error: ReactPackerError | null;
|
|
status: "idle" | "loading" | "success" | "error";
|
|
reload: () => void;
|
|
}
|
|
|
|
export function usePackedResource(
|
|
packedUrl: string | null | undefined,
|
|
options?: UsePackedResourceOptions
|
|
): UsePackedResourceReturn;
|
|
|
|
// hooks/use-packed-image — default { cache: true, allowedMimePrefixes: ["image/"] }
|
|
export function usePackedImage(
|
|
packedUrl: string | null | undefined,
|
|
options?: UsePackedResourceOptions
|
|
): UsePackedResourceReturn;
|
|
|
|
// hooks/use-packed-audio — default { cache: true, allowedMimePrefixes: ["audio/"] }
|
|
// hooks/use-packed-video — default { cache: false, allowedMimePrefixes: ["video/"] }
|
|
// hooks/use-packed-font — default { cache: true, allowedMimePrefixes: ["font/", "application/font", "application/octet-stream"] }
|
|
|
|
export interface UsePackedFontReturn extends UsePackedResourceReturn {
|
|
fontLoaded: boolean;
|
|
}
|
|
|
|
export function usePackedFont(
|
|
packedUrl: string | null | undefined,
|
|
fontFamily?: string,
|
|
options?: UsePackedResourceOptions
|
|
): UsePackedFontReturn;
|
|
|
|
// provider
|
|
export interface PackedResourceProviderValue {
|
|
manager: PackedResourceManager;
|
|
messageResolver?: MessageResolver;
|
|
}
|
|
|
|
export function PackedResourceProvider(props: {
|
|
value: PackedResourceProviderValue;
|
|
children?: ReactNode;
|
|
}): ReactElement;
|
|
|
|
export function usePackedResourceManager(): PackedResourceManager;
|
|
```
|
|
|
|
## Error model
|
|
|
|
A single error code registry is registered with `@suaveplan/error`:
|
|
|
|
| Code | Raised when |
|
|
|---|---|
|
|
| `REACT_PACKER_LOAD_FAILED` | `manager.load(...)` threw and the hook is reporting it. The original error is preserved on `.cause`. |
|
|
| `REACT_PACKER_FONT_LOAD_FAILED` | `document.fonts.load(...)` rejected. Hook surfaces `fontLoaded = false` and the wrapped error on `result.error`. |
|
|
| `REACT_PACKER_ABORTED` | A pre-aborted signal was passed to a load; rejected before fetching. |
|
|
|
|
`ReactPackerError extends SuaveplanError` and carries a brand symbol;
|
|
`ReactPackerError.is(value)` discriminates safely across realms.
|
|
|
|
## File layout
|
|
|
|
```text
|
|
src/
|
|
index.ts # barrel — explicit named re-exports
|
|
error-codes/
|
|
error-codes.ts
|
|
error-codes.md
|
|
error-codes.test.ts
|
|
index.ts
|
|
types/
|
|
react-packer.types.ts
|
|
react-packer.types.md
|
|
index.ts
|
|
provider/
|
|
packed-resource-provider/
|
|
packed-resource-provider.tsx
|
|
packed-resource-provider.md
|
|
packed-resource-provider.test.tsx
|
|
packed-resource-provider.stories.tsx
|
|
index.ts
|
|
hooks/
|
|
use-packed-resource/
|
|
use-packed-resource.ts
|
|
use-packed-resource.md
|
|
use-packed-resource.test.tsx
|
|
use-packed-resource.stories.tsx
|
|
index.ts
|
|
use-packed-image/
|
|
use-packed-image.ts
|
|
use-packed-image.md
|
|
use-packed-image.test.tsx
|
|
use-packed-image.stories.tsx
|
|
index.ts
|
|
use-packed-audio/
|
|
use-packed-audio.ts
|
|
use-packed-audio.md
|
|
use-packed-audio.test.tsx
|
|
use-packed-audio.stories.tsx
|
|
index.ts
|
|
use-packed-video/
|
|
use-packed-video.ts
|
|
use-packed-video.md
|
|
use-packed-video.test.tsx
|
|
use-packed-video.stories.tsx
|
|
index.ts
|
|
use-packed-font/
|
|
use-packed-font.ts
|
|
use-packed-font.md
|
|
use-packed-font.test.tsx
|
|
use-packed-font.stories.tsx
|
|
index.ts
|
|
```
|
|
|
|
## Lifecycle semantics
|
|
|
|
1. Mount with `packedUrl !== null` → `status = "loading"`, hook calls
|
|
`manager.load(url, opts)` with an internal `AbortController` whose
|
|
signal merges with any externally-passed `opts.signal`.
|
|
2. Success → `status = "success"`, `url` + `blob` populated, manager
|
|
`release` is registered as cleanup so unmount drops the cache ref.
|
|
3. Failure → `status = "error"`, `error` is a `ReactPackerError` wrapping
|
|
the underlying error on `.cause`.
|
|
4. Re-render with different `packedUrl` → previous controller aborts,
|
|
previous URL is released, new load starts.
|
|
5. Unmount → controller aborts, the URL is released, no `setState` runs
|
|
after unmount (mounted-ref guard).
|
|
6. `reload()` increments an internal counter that re-runs the effect.
|
|
|
|
## Testing strategy
|
|
|
|
- Real `PackedResourceManager` constructed per-test via
|
|
`createPackedResourceManager({ fetch })`.
|
|
- `fetch` is a tiny closure returning `new Response(Uint8Array, { headers })`.
|
|
- No `vi.mock`/`vi.spyOn` against `@suaveplan/packer-browser` — the
|
|
manager runs its real loader + cache pipeline against the injected
|
|
`fetch`. This is exactly the pattern used by
|
|
`packer-browser/src/loader/loader.test.ts` and is what CLAUDE.md
|
|
rule 2 mandates.
|
|
- Coverage runs via `provider: "istanbul"` with 100/100/100/100
|
|
thresholds set in `vitest.config.ts`.
|