Files
2026-05-27 23:29:53 +01:00

160 lines
9.9 KiB
Markdown

<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: react-packer (add-react-packer)
**Package**: `@suaveplan/react-packer`
**Status**: PROPOSED
> Every requirement below carries a `**Verified by:**` line at authoring time.
## ADDED Requirements
### Requirement: REACT-PACKER-1 — `usePackedResource` lifecycle
The package SHALL export `usePackedResource(packedUrl, options?)`, a React hook that resolves a `PackedResourceManager` from context (or the singleton fallback), invokes `manager.load(url, opts)`, and returns `{ url, loading, error, status, reload }`. The hook MUST honour `AbortSignal`: it merges any caller-supplied signal with its own internal `AbortController`, aborts on unmount, aborts on `packedUrl` change, and aborts on `reload()`. On a `null`/`undefined` `packedUrl` the hook MUST settle on `status = "idle"` without invoking the manager.
#### Scenario: nullable packed URL produces idle state
- **WHEN** the hook is mounted with `packedUrl = null`
- **THEN** `status === "idle"`, `url === null`, `loading === false`, and `manager.load` is never called
#### Scenario: success transitions loading → success
- **WHEN** the manager resolves a real Blob URL for the given packed URL
- **THEN** the hook transitions `loading` (true) → `success`, exposes the Blob `url`, and clears any prior `error`
#### Scenario: pre-aborted signal short-circuits with REACT_PACKER_ABORTED
- **WHEN** the caller passes a pre-aborted `AbortSignal`
- **THEN** the hook surfaces `status = "error"` with `error.code === "REACT_PACKER_ABORTED"` and never calls the manager
#### Scenario: unmount releases the cached resource
- **WHEN** a successful load completes and the component unmounts
- **THEN** `manager.release(url, opts.verifyKey)` runs exactly once and no `setState` warning fires
**Verified by:** `src/hooks/use-packed-resource/use-packed-resource.test.tsx::"is idle when packedUrl is null"` AND `src/hooks/use-packed-resource/use-packed-resource.test.tsx::"transitions loading then success on a real manager"` AND `src/hooks/use-packed-resource/use-packed-resource.test.tsx::"surfaces REACT_PACKER_ABORTED on a pre-aborted signal"` AND `src/hooks/use-packed-resource/use-packed-resource.test.tsx::"releases the cached entry on unmount"` AND `src/hooks/use-packed-resource/use-packed-resource.test.tsx::"reload() re-runs the loader and resets state"` AND `src/hooks/use-packed-resource/use-packed-resource.test.tsx::"changing packedUrl aborts the prior in-flight load"` AND `src/hooks/use-packed-resource/use-packed-resource.test.tsx::"wraps a manager failure as REACT_PACKER_LOAD_FAILED"`
---
### Requirement: REACT-PACKER-2 — `usePackedImage` with image-tuned defaults
The package SHALL export `usePackedImage(packedUrl, options?)` which delegates to `usePackedResource` with `cache: true` and `allowedMimePrefixes: ["image/"]` unless the caller overrides them. The hook MUST preserve the full `UsePackedResourceReturn` shape.
#### Scenario: image hook applies image MIME allow-list
- **WHEN** the hook is mounted and the underlying manager call inspects options
- **THEN** the call includes `allowedMimePrefixes: ["image/"]` and `cache: true`
#### Scenario: caller override wins over defaults
- **WHEN** the caller passes `{ cache: false }`
- **THEN** the manager call receives `cache: false`
**Verified by:** `src/hooks/use-packed-image/use-packed-image.test.tsx::"loads an image-tuned packed resource"` AND `src/hooks/use-packed-image/use-packed-image.test.tsx::"caller can override the cache default"`
---
### Requirement: REACT-PACKER-3 — `usePackedAudio` with audio-tuned defaults
The package SHALL export `usePackedAudio(packedUrl, options?)` which delegates to `usePackedResource` with `cache: true` and `allowedMimePrefixes: ["audio/"]` unless overridden.
#### Scenario: audio hook applies audio MIME allow-list
- **WHEN** the hook resolves a packed URL
- **THEN** the manager receives `allowedMimePrefixes: ["audio/"]` and `cache: true`
**Verified by:** `src/hooks/use-packed-audio/use-packed-audio.test.tsx::"loads an audio-tuned packed resource"` AND `src/hooks/use-packed-audio/use-packed-audio.test.tsx::"forwards caller-supplied verifyKey to the manager"`
---
### Requirement: REACT-PACKER-4 — `usePackedVideo` with video-tuned defaults
The package SHALL export `usePackedVideo(packedUrl, options?)` which delegates to `usePackedResource` with `cache: false` (videos are large and dominate cache budgets) and `allowedMimePrefixes: ["video/"]` unless overridden.
#### Scenario: video hook opts out of caching by default
- **WHEN** the hook resolves a packed URL with no caller options
- **THEN** the manager receives `cache: false` and `allowedMimePrefixes: ["video/"]`
**Verified by:** `src/hooks/use-packed-video/use-packed-video.test.tsx::"loads a video-tuned packed resource without caching"` AND `src/hooks/use-packed-video/use-packed-video.test.tsx::"caller can opt back into caching"`
---
### Requirement: REACT-PACKER-5 — `usePackedFont` injects @font-face
The package SHALL export `usePackedFont(packedUrl, fontFamily?, options?)` which delegates to `usePackedResource` with font-tuned defaults, injects a `@font-face` stylesheet (`<style id="packed-font-<family>">`) into `document.head`, waits for `document.fonts.load("1em <family>")`, and exposes `fontLoaded: boolean` alongside the standard return. Unmount MUST remove the injected `<style>` element. When `document.fonts.load` rejects, the hook MUST surface `error.code === "REACT_PACKER_FONT_LOAD_FAILED"` and leave `fontLoaded = false`.
#### Scenario: successful font load injects @font-face and flips fontLoaded
- **WHEN** the manager resolves the font Blob URL and `document.fonts.load` resolves
- **THEN** a `<style id="packed-font-<family>">` element exists in `document.head`, the rule's `src` URL matches the loaded Blob URL, and `fontLoaded === true`
#### Scenario: font-family omitted skips injection but still loads the bytes
- **WHEN** the hook is mounted with `fontFamily = undefined`
- **THEN** no `<style>` element is injected and `fontLoaded === false`, but the underlying Blob `url` is populated
#### Scenario: document.fonts.load rejection surfaces FONT_LOAD_FAILED
- **WHEN** `document.fonts.load` rejects with an `Error`
- **THEN** `error.code === "REACT_PACKER_FONT_LOAD_FAILED"` and `fontLoaded === false`
#### Scenario: unmount removes the injected style element
- **WHEN** a font hook unmounts after a successful injection
- **THEN** `document.getElementById("packed-font-<family>")` returns `null`
**Verified by:** `src/hooks/use-packed-font/use-packed-font.test.tsx::"injects @font-face on successful load"` AND `src/hooks/use-packed-font/use-packed-font.test.tsx::"omitting fontFamily skips style injection"` AND `src/hooks/use-packed-font/use-packed-font.test.tsx::"wraps fonts.load rejection as REACT_PACKER_FONT_LOAD_FAILED"` AND `src/hooks/use-packed-font/use-packed-font.test.tsx::"removes the injected style element on unmount"`
---
### Requirement: REACT-PACKER-6 — `PackedResourceProvider` context
The package SHALL export `PackedResourceProvider`, a React context provider that supplies a `PackedResourceManager` and optional `MessageResolver` to every hook in its subtree. Hooks MUST resolve their manager via `useContext(PackedResourceContext)` first and consult the optional resolver when constructing `ReactPackerError` instances.
#### Scenario: hook uses the provider's manager
- **WHEN** a hook is rendered inside `<PackedResourceProvider value={{ manager }}>`
- **THEN** that hook calls `manager.load(...)` rather than the singleton
#### Scenario: provider supplies a MessageResolver
- **WHEN** the provider's value supplies a custom `messageResolver` and a load fails
- **THEN** the resulting `ReactPackerError.messageKey` is the key the resolver returned
**Verified by:** `src/provider/packed-resource-provider/packed-resource-provider.test.tsx::"hook consumes the provider-supplied manager"` AND `src/provider/packed-resource-provider/packed-resource-provider.test.tsx::"messageResolver is threaded into raised errors"`
---
### Requirement: REACT-PACKER-7 — Stable error hierarchy
The package SHALL export `REACT_PACKER_CODES` (a `defineCodes(...)` registry) and `ReactPackerError extends SuaveplanError`. The registry MUST include `LOAD_FAILED`, `FONT_LOAD_FAILED`, and `ABORTED`. `ReactPackerError.is(value)` MUST return `true` only for instances carrying the brand symbol, even across realms.
#### Scenario: every registered code is reachable from a production path
- **WHEN** the consumer triggers a load failure, font load rejection, or pre-aborted signal
- **THEN** each respective `REACT_PACKER_*` code is raised by exactly one hook code path and surfaces on `result.error.code`
#### Scenario: `is(value)` is a safe brand check
- **WHEN** an arbitrary `unknown` value is passed to `ReactPackerError.is`
- **THEN** the result is `true` iff the value is a real `ReactPackerError` instance with the brand symbol set
**Verified by:** `src/error-codes/error-codes.test.ts::"registers LOAD_FAILED, FONT_LOAD_FAILED, ABORTED"` AND `src/error-codes/error-codes.test.ts::"is(value) returns true only for branded instances"` AND `src/error-codes/error-codes.test.ts::"preserves cause through the SuaveplanError chain"`
---
### Requirement: REACT-PACKER-8 — Singleton fallback when no provider is mounted
The package SHALL resolve a working `PackedResourceManager` even when no `<PackedResourceProvider>` is mounted. In that case it MUST defer to `@suaveplan/packer-browser/singleton` so adopters who do not need DI get a zero-config experience.
#### Scenario: hook outside a provider uses the singleton
- **WHEN** a hook is rendered with no surrounding provider
- **THEN** `usePackedResourceManager()` returns the module-singleton instance from `@suaveplan/packer-browser/singleton`
**Verified by:** `src/provider/packed-resource-provider/packed-resource-provider.test.tsx::"falls back to the singleton when no provider is mounted"`
<!-- markdownlint-enable MD024 -->