161 lines
9.9 KiB
Markdown
161 lines
9.9 KiB
Markdown
<!-- markdownlint-disable MD024 -->
|
|
|
|
# Capability Spec: react-packer
|
|
|
|
**Package**: `@suaveplan/react-packer`
|
|
**Status**: ACTIVE
|
|
**Initial change**: 2026-05-19-add-react-packer
|
|
|
|
> Every requirement below carries a `**Verified by:**` line.
|
|
|
|
## 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 -->
|