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

6.1 KiB

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.

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

// 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

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.