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`.
|
||||
128
openspec/archive/2026-05-19-add-react-packer/proposal.md
Normal file
128
openspec/archive/2026-05-19-add-react-packer/proposal.md
Normal file
@@ -0,0 +1,128 @@
|
||||
# Add `@suaveplan/react-packer` — React 19 companion for `@suaveplan/packer-browser`
|
||||
|
||||
**Package**: `@suaveplan/react-packer`
|
||||
**Status**: PROPOSED
|
||||
**Type**: new-package
|
||||
|
||||
- **Design**: ./design.md
|
||||
- **Spec delta**: ./specs/react-packer/spec.md
|
||||
|
||||
## Why
|
||||
|
||||
`@suaveplan/packer-browser` (genesis Cluster A) ships the manager, cache,
|
||||
loader, signature verification, and Service-Worker interceptor with **no
|
||||
React runtime dep**. Upstream `packer-browser@1.0.17` carried the React
|
||||
hooks (`usePackedResource`, `usePackedImage`, `usePackedAudio`,
|
||||
`usePackedVideo`, `usePackedFont`) inside the same package, forcing every
|
||||
non-React consumer (server bundles, R3F apps that do their own loading,
|
||||
Web-Worker scripts) to ship React via transitive dependency. Upstream
|
||||
`enterprise/packer-browser.md` §3.3 specifically calls for splitting the
|
||||
hooks into a React companion package; that split has been deferred to
|
||||
`@suaveplan/react-packer`, this change.
|
||||
|
||||
This package provides the hooks at genesis quality — `AbortSignal` on
|
||||
every action, `MessageResolver` plumbed through, `PackedResourceProvider`
|
||||
React context for explicit dependency injection of a
|
||||
`PackedResourceManager`, sensible fallback to the
|
||||
`@suaveplan/packer-browser/singleton` for adopters that want zero-config,
|
||||
and 100/100/100/100 coverage against a real (non-mock) manager backed by
|
||||
real `Response` + `Blob` fixtures.
|
||||
|
||||
## What changes
|
||||
|
||||
This is a brand-new package — every export is additive. No changes to
|
||||
`@suaveplan/packer-browser`'s surface.
|
||||
|
||||
- ADDED: `usePackedResource(packedUrl, opts?)` — generic hook returning
|
||||
`{ url, blob, loading, error, status, reload }`.
|
||||
- ADDED: `usePackedImage(packedUrl, opts?)` — image-tuned defaults (cache
|
||||
on, `allowedMimePrefixes = ["image/"]`).
|
||||
- ADDED: `usePackedAudio(packedUrl, opts?)` — audio-tuned defaults.
|
||||
- ADDED: `usePackedVideo(packedUrl, opts?)` — video-tuned defaults (cache
|
||||
off — videos are large).
|
||||
- ADDED: `usePackedFont(packedUrl, fontFamily?, opts?)` — injects a
|
||||
`@font-face` rule and waits for `document.fonts.load`. Returns the
|
||||
generic shape plus `fontLoaded: boolean`.
|
||||
- ADDED: `PackedResourceProvider` — React context that supplies a
|
||||
`PackedResourceManager` and (optionally) a `MessageResolver` to every
|
||||
hook in its subtree.
|
||||
- ADDED: `usePackedResourceManager()` — internal hook that resolves the
|
||||
manager from context with a documented fallback to
|
||||
`@suaveplan/packer-browser/singleton`.
|
||||
- ADDED: `REACT_PACKER_CODES` + `ReactPackerError` — stable error code
|
||||
registry (`LOAD_FAILED`, `FONT_LOAD_FAILED`, `ABORTED`).
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Service-Worker registration UI — adopters use
|
||||
`@suaveplan/packer-browser`'s lifecycle helpers directly.
|
||||
- A `<PackedImage>` / `<PackedVideo>` / `<PackedAudio>` React component
|
||||
wrapper. The hooks are the public surface; consumers compose their own
|
||||
presentational components. This keeps the package focused on the carve
|
||||
upstream `enterprise/packer-browser.md` §3.3 explicitly asked for.
|
||||
|
||||
## Source mapping
|
||||
|
||||
This change derives from upstream
|
||||
`../suaveplan/packages/browser/packer-browser/src/hooks/usePackedResource.ts`
|
||||
and `../suaveplan/enterprise/packer-browser.md` §3.3 ("Move React hooks
|
||||
into a new React companion package"). The genesis rewrite preserves the
|
||||
upstream public symbol names and per-variant defaults while bringing
|
||||
every requirement to enterprise quality (no module singleton, real
|
||||
`AbortSignal`, real `MessageResolver`, real error hierarchy).
|
||||
|
||||
| Requirement | Source lines | Notes |
|
||||
|---|---|---|
|
||||
| REACT-PACKER-1 (`usePackedResource`) | upstream `usePackedResource.ts` L22-L87 | Rewritten to consume injected manager; adds AbortSignal + error-code wrapping. |
|
||||
| REACT-PACKER-2 (`usePackedImage`) | upstream L89-L101 | Same defaults (cache on); adds `allowedMimePrefixes`. |
|
||||
| REACT-PACKER-3 (`usePackedAudio`) | upstream L103-L115 | Same defaults (cache on). |
|
||||
| REACT-PACKER-4 (`usePackedVideo`) | upstream L117-L129 | Same defaults (cache off). |
|
||||
| REACT-PACKER-5 (`usePackedFont`) | upstream L131-L198 | Preserves `@font-face` injection + cleanup. |
|
||||
| REACT-PACKER-6 (`PackedResourceProvider`) | enterprise/packer-browser.md L42-L51 ("Move React hooks…") | Adds explicit DI surface that upstream did not have (module singleton only). |
|
||||
| REACT-PACKER-7 (error hierarchy) | enterprise/packer-browser.md §63 | Standard genesis bar; upstream raised plain `Error`. |
|
||||
| REACT-PACKER-8 (singleton fallback) | enterprise/packer-browser.md L51 | Adopters who omit `<PackedResourceProvider>` fall back to `@suaveplan/packer-browser/singleton`. |
|
||||
|
||||
### Requirements with no source line
|
||||
|
||||
None — every requirement either ports an upstream behaviour or implements
|
||||
an enterprise gap explicitly named in `enterprise/packer-browser.md`.
|
||||
|
||||
## Verification approach
|
||||
|
||||
Every requirement carries a `**Verified by:**` line pointing at a concrete
|
||||
`it(...)` title in the planned test files.
|
||||
|
||||
- `src/hooks/use-packed-resource/use-packed-resource.test.tsx` — covers
|
||||
REACT-PACKER-1 (loading, success, error, reload, abort on unmount,
|
||||
abort on URL change, signal propagation).
|
||||
- `src/hooks/use-packed-image/use-packed-image.test.tsx` — covers
|
||||
REACT-PACKER-2 (`allowedMimePrefixes` enforcement, image defaults).
|
||||
- `src/hooks/use-packed-audio/use-packed-audio.test.tsx` — covers
|
||||
REACT-PACKER-3 (audio defaults).
|
||||
- `src/hooks/use-packed-video/use-packed-video.test.tsx` — covers
|
||||
REACT-PACKER-4 (video defaults including cache off).
|
||||
- `src/hooks/use-packed-font/use-packed-font.test.tsx` — covers
|
||||
REACT-PACKER-5 (`@font-face` injection, cleanup, `fontLoaded` flag,
|
||||
`document.fonts.load` failure path).
|
||||
- `src/provider/packed-resource-provider/packed-resource-provider.test.tsx`
|
||||
— covers REACT-PACKER-6 + REACT-PACKER-8 (context wiring, singleton
|
||||
fallback when no provider, `MessageResolver` propagation).
|
||||
- `src/error-codes/error-codes.test.ts` — covers REACT-PACKER-7 (code
|
||||
registry, `ReactPackerError.is(...)` brand check).
|
||||
|
||||
## Migration impact
|
||||
|
||||
None — additive only. `@suaveplan/packer-browser` is not modified.
|
||||
Consumers that previously imported hooks from upstream `packer-browser`
|
||||
(none on genesis — packer-browser shipped without hooks per its own
|
||||
proposal) migrate by adding `@suaveplan/react-packer` as a dep and
|
||||
importing from there.
|
||||
|
||||
## Impact
|
||||
|
||||
### Affected dependents
|
||||
|
||||
Run `bunx turbo ls --filter=...@suaveplan/react-packer` to enumerate.
|
||||
This is a new package with zero existing dependents on genesis:
|
||||
|
||||
- No dependents (verified by `grep -rln "@suaveplan/react-packer" packages/ apps/ 2>/dev/null` returning no matches at proposal time).
|
||||
@@ -0,0 +1,159 @@
|
||||
<!-- 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 -->
|
||||
75
openspec/archive/2026-05-19-add-react-packer/tasks.md
Normal file
75
openspec/archive/2026-05-19-add-react-packer/tasks.md
Normal file
@@ -0,0 +1,75 @@
|
||||
# Tasks — Add `@suaveplan/react-packer`
|
||||
|
||||
> Every checkbox MUST reflect work actually done in this change.
|
||||
|
||||
## 1. Spec authoring
|
||||
|
||||
- [x] 1.1 Read `../suaveplan/packages/browser/packer-browser/src/hooks/usePackedResource.ts`
|
||||
- [x] 1.2 Read `../suaveplan/enterprise/packer-browser.md` §3.3
|
||||
- [x] 1.3 Read `openspec/AGENTS.md` §§0c, 2b, 6a, 7, 13, 14
|
||||
- [x] 1.4 Draft `proposal.md` from `openspec/templates/proposal.md`
|
||||
- [x] 1.5 Source-mapping table filled in (every requirement cites upstream lines OR an enterprise-md gap)
|
||||
- [x] 1.6 Draft `specs/react-packer/spec.md` from `openspec/templates/spec-delta.md`
|
||||
- [x] 1.7 Every requirement carries a `**Verified by:**` line with concrete planned `it(...)` titles
|
||||
|
||||
## 2. Implementation
|
||||
|
||||
- [x] 2.1 Scaffold `packages/data/react-packer/` (package.json, tsconfig, vite, vitest, playwright)
|
||||
- [x] 2.2 Add `react-packer` to `scripts/folder-reorg/assignment.yml` (data block, alphabetical between `pagination` and `serialize`)
|
||||
- [x] 2.3 `src/error-codes/error-codes.ts` — `REACT_PACKER_CODES` registry + `ReactPackerError`
|
||||
- [x] 2.4 `src/types/react-packer.types.ts` — `UsePackedResourceOptions`, `UsePackedResourceReturn`, `UsePackedFontReturn`, `PackedResourceProviderValue`
|
||||
- [x] 2.5 `src/provider/packed-resource-provider/packed-resource-provider.tsx` — context + `usePackedResourceManager`
|
||||
- [x] 2.6 `src/hooks/use-packed-resource/use-packed-resource.ts` — generic hook with AbortController, mounted-ref, reload counter
|
||||
- [x] 2.7 `src/hooks/use-packed-image/use-packed-image.ts`
|
||||
- [x] 2.8 `src/hooks/use-packed-audio/use-packed-audio.ts`
|
||||
- [x] 2.9 `src/hooks/use-packed-video/use-packed-video.ts`
|
||||
- [x] 2.10 `src/hooks/use-packed-font/use-packed-font.ts` — adds `@font-face` injection + `fontLoaded`
|
||||
- [x] 2.11 `src/index.ts` barrel — explicit named re-exports only
|
||||
- [x] 2.12 `src/<feature>/index.ts` feature barrels — explicit named re-exports (§6a)
|
||||
|
||||
## 3. Tests
|
||||
|
||||
- [x] 3.1 `src/error-codes/error-codes.test.ts`
|
||||
- [x] 3.2 `src/provider/packed-resource-provider/packed-resource-provider.test.tsx`
|
||||
- [x] 3.3 `src/hooks/use-packed-resource/use-packed-resource.test.tsx`
|
||||
- [x] 3.4 `src/hooks/use-packed-image/use-packed-image.test.tsx`
|
||||
- [x] 3.5 `src/hooks/use-packed-audio/use-packed-audio.test.tsx`
|
||||
- [x] 3.6 `src/hooks/use-packed-video/use-packed-video.test.tsx`
|
||||
- [x] 3.7 `src/hooks/use-packed-font/use-packed-font.test.tsx`
|
||||
- [x] 3.8 Every `it(...)` carries `{ timeout: 5000 }` (30000 where genuinely async)
|
||||
- [x] 3.9 Test files import from `@suaveplan/testing/runner` only (rule 11 / §7)
|
||||
- [x] 3.10 No mocks — real `PackedResourceManager` + real `Response`+`Blob` fixtures
|
||||
|
||||
## 4. Documentation
|
||||
|
||||
- [x] 4.1 Sibling `.md` per implementation file (≥200 words, basic + advanced + API + impl notes)
|
||||
- [x] 4.2 Stories `.stories.tsx` per hook + provider
|
||||
- [x] 4.3 `README.md` at package root linking every co-located `.md`
|
||||
- [x] 4.4 Update `CLAUDE.md` package listing under data block alongside `packer-browser`
|
||||
- [x] 4.5 Update `STATUS.md` with new row
|
||||
|
||||
## 5. Pre-archive verification (the 5-gate triple)
|
||||
|
||||
- [ ] 5.1 `bun run check` — Biome 0 errors / 0 warnings
|
||||
- [ ] 5.2 `bunx turbo lint typecheck test --filter=@suaveplan/react-packer` — green
|
||||
- [ ] 5.3 `bunx turbo test:coverage --filter=@suaveplan/react-packer` — 100/100/100/100
|
||||
- [ ] 5.4 `bun scripts/openspec-conformance.ts @suaveplan/react-packer` — exit 0
|
||||
- [ ] 5.5 `bunx turbo build --filter=@suaveplan/react-packer` — green
|
||||
|
||||
## 6. Pre-archive 9-row checklist (AGENTS.md §13)
|
||||
|
||||
- [ ] 6.1 Generics threaded where appropriate; no inline shapes (rule 11)
|
||||
- [ ] 6.2 No Zod boundaries declared (no untrusted inputs cross the hook surface — manager handles its own boundaries)
|
||||
- [ ] 6.3 Every error code in `REACT_PACKER_CODES` thrown by at least one production path
|
||||
- [ ] 6.4 `AbortSignal` propagation tested (pre-aborted + abort-on-unmount + abort-on-url-change)
|
||||
- [ ] 6.5 OTel traces pass through unchanged (the manager owns them; hooks do not add counters per scope)
|
||||
- [ ] 6.6 Lifecycle dedup tested — mounted-ref guard prevents `setState` after unmount
|
||||
- [ ] 6.7 Singleton fallback resolves only when no provider is mounted
|
||||
- [ ] 6.8 No retroactive tick — every box above reflects work in this change
|
||||
- [ ] 6.9 Consumer sweep clean (no existing genesis consumers of `@suaveplan/react-packer`)
|
||||
|
||||
## 7. Archive
|
||||
|
||||
- [ ] 7.1 Fold spec delta into `openspec/specs/react-packer/spec.md` (NEW capability)
|
||||
- [ ] 7.2 Move `openspec/changes/add-react-packer/` → `openspec/archive/<yyyy-mm-dd>-add-react-packer/`
|
||||
- [ ] 7.3 Refresh `MANIFEST.md` via `bun scripts/folder-reorg/generate-manifest.ts`
|
||||
Reference in New Issue
Block a user