Initial Commit
This commit is contained in:
186
openspec/archive/2026-05-19-add-react-packer/design.md
Normal file
186
openspec/archive/2026-05-19-add-react-packer/design.md
Normal file
@@ -0,0 +1,186 @@
|
||||
# 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`.
|
||||
Reference in New Issue
Block a user