Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,92 @@
# @suaveplan/react-packer - React 19 companion for @suaveplan/packer-browser — usePackedResource, usePackedImage, usePackedAudio, usePackedVideo, usePackedFont hooks plus PackedResourceProvider context. Consumes a real PackedResourceManager; React is a peer dep so non-React consumers never pay the cost.
React 19 companion for `@suaveplan/packer-browser`. Ships
`usePackedResource`, `usePackedImage`, `usePackedAudio`,
`usePackedVideo`, and `usePackedFont` hooks plus a
`PackedResourceProvider` context, so applications can consume packed
asset archives from React components without `@suaveplan/packer-browser`
having to ship React as a runtime dep.
<!-- begin:auto-docs -->
## Documentation tree
- [error-codes](./src/error-codes/error-codes.md) — Defines the canonical error-code registry and concrete error class for the React-tier packer companion. Every failure m…
- [error-codes](./src/error-codes/error-codes.md) — Defines the canonical error-code registry and concrete error class for the React-tier packer companion. Every failure m…
- [use-packed-audio](./src/hooks/use-packed-audio/use-packed-audio.md) — `usePackedAudio` is the audio analogue of `usePackedImage`. It binds a packed audio asset URL to a React component's li…
- [use-packed-audio](./src/hooks/use-packed-audio/use-packed-audio.md) — `usePackedAudio` is the audio analogue of `usePackedImage`. It binds a packed audio asset URL to a React component's li…
- [use-packed-font](./src/hooks/use-packed-font/use-packed-font.md) — `usePackedFont` loads a packed font asset and registers it with the document so subsequent rules referencing the suppli…
- [use-packed-font](./src/hooks/use-packed-font/use-packed-font.md) — `usePackedFont` loads a packed font asset and registers it with the document so subsequent rules referencing the suppli…
- [use-packed-image](./src/hooks/use-packed-image/use-packed-image.md) — `usePackedImage` is a thin wrapper around `usePackedResource` that pre-fills sensible defaults for image assets: cachin…
- [use-packed-image](./src/hooks/use-packed-image/use-packed-image.md) — `usePackedImage` is a thin wrapper around `usePackedResource` that pre-fills sensible defaults for image assets: cachin…
- [use-packed-resource](./src/hooks/use-packed-resource/use-packed-resource.md) — `usePackedResource` is the foundation hook of `@suaveplan/react-packer`. It binds a packed-archive virtual URL (e.g. `/…
- [use-packed-resource](./src/hooks/use-packed-resource/use-packed-resource.md) — `usePackedResource` is the foundation hook of `@suaveplan/react-packer`. It binds a packed-archive virtual URL (e.g. `/…
- [use-packed-video](./src/hooks/use-packed-video/use-packed-video.md) — `usePackedVideo` binds a packed video asset URL to a React component's lifecycle. Unlike the image and audio variants,…
- [use-packed-video](./src/hooks/use-packed-video/use-packed-video.md) — `usePackedVideo` binds a packed video asset URL to a React component's lifecycle. Unlike the image and audio variants,…
- [packed-resource-provider](./src/provider/packed-resource-provider/packed-resource-provider.md) — `PackedResourceProvider` lets a React tree explicitly inject a `PackedResourceManager` from `@suaveplan/packer-browser`…
- [packed-resource-provider](./src/provider/packed-resource-provider/packed-resource-provider.md) — `PackedResourceProvider` lets a React tree explicitly inject a `PackedResourceManager` from `@suaveplan/packer-browser`…
<!-- end:auto-docs -->
## Install
```bash
bun add @suaveplan/react-packer @suaveplan/packer-browser
```
`react` and `react-dom` (≥ 19.0.0) are peer dependencies — the package
does not bundle them.
## Quick start
```tsx
import { createPackedResourceManager } from "@suaveplan/packer-browser";
import { PackedResourceProvider, usePackedImage } from "@suaveplan/react-packer";
const manager = createPackedResourceManager();
function App() {
return (
<PackedResourceProvider value={{ manager }}>
<Logo />
</PackedResourceProvider>
);
}
function Logo() {
const { url, loading, error } = usePackedImage("/assets/logo.packed");
if (loading) return <div className="logo logo--loading" />;
if (error !== null) return <div className="logo logo--error" />;
return url !== null ? <img alt="logo" src={url} /> : null;
}
```
If you do not mount a `PackedResourceProvider`, hooks fall back to the
singleton at `@suaveplan/packer-browser/singleton`. Adopters that prefer
zero-config get a working manager for free; adopters that want explicit
lifecycle (tests, SSR, multi-tenant apps) mount a provider and skip the
singleton entirely.
## Features
- Five hooks: `usePackedResource` (generic), `usePackedImage`,
`usePackedAudio`, `usePackedVideo`, `usePackedFont`.
- React 19 context provider for explicit `PackedResourceManager` and
`MessageResolver` injection.
- `AbortSignal` honored throughout — pre-aborted signals short-circuit,
unmount cancels the in-flight load, and changing the packed URL
aborts the previous one.
- Stable error registry — `REACT_PACKER_CODES` + `ReactPackerError`
branded subclass over `@suaveplan/error`.
- `cleanup` releases the cached entry on unmount so the manager's LRU
can evict.
## Modules
- [`src/error-codes/error-codes.md`](./src/error-codes/error-codes.md)
- [`src/types/react-packer.types.md`](./src/types/react-packer.types.md)
- [`src/provider/packed-resource-provider/packed-resource-provider.md`](./src/provider/packed-resource-provider/packed-resource-provider.md)
- [`src/hooks/use-packed-resource/use-packed-resource.md`](./src/hooks/use-packed-resource/use-packed-resource.md)
- [`src/hooks/use-packed-image/use-packed-image.md`](./src/hooks/use-packed-image/use-packed-image.md)
- [`src/hooks/use-packed-audio/use-packed-audio.md`](./src/hooks/use-packed-audio/use-packed-audio.md)
- [`src/hooks/use-packed-video/use-packed-video.md`](./src/hooks/use-packed-video/use-packed-video.md)
- [`src/hooks/use-packed-font/use-packed-font.md`](./src/hooks/use-packed-font/use-packed-font.md)

View File

@@ -0,0 +1,113 @@
{
"name": "@suaveplan/react-packer",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "React 19 companion for @suaveplan/packer-browser — usePackedResource, usePackedImage, usePackedAudio, usePackedVideo, usePackedFont hooks plus PackedResourceProvider context. Consumes a real PackedResourceManager; React is a peer dep so non-React consumers never pay the cost.",
"license": "MIT",
"author": "Suaveplan Team",
"browser": "./dist/index.js",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./hooks/use-packed-resource": {
"import": {
"types": "./dist/hooks/use-packed-resource/use-packed-resource.d.ts",
"default": "./dist/hooks/use-packed-resource/use-packed-resource.js"
}
},
"./hooks/use-packed-image": {
"import": {
"types": "./dist/hooks/use-packed-image/use-packed-image.d.ts",
"default": "./dist/hooks/use-packed-image/use-packed-image.js"
}
},
"./hooks/use-packed-audio": {
"import": {
"types": "./dist/hooks/use-packed-audio/use-packed-audio.d.ts",
"default": "./dist/hooks/use-packed-audio/use-packed-audio.js"
}
},
"./hooks/use-packed-video": {
"import": {
"types": "./dist/hooks/use-packed-video/use-packed-video.d.ts",
"default": "./dist/hooks/use-packed-video/use-packed-video.js"
}
},
"./hooks/use-packed-font": {
"import": {
"types": "./dist/hooks/use-packed-font/use-packed-font.d.ts",
"default": "./dist/hooks/use-packed-font/use-packed-font.js"
}
},
"./provider": {
"import": {
"types": "./dist/provider/packed-resource-provider/packed-resource-provider.d.ts",
"default": "./dist/provider/packed-resource-provider/packed-resource-provider.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"test:e2e": "playwright test",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@suaveplan/error": "workspace:^",
"@suaveplan/packer-browser": "workspace:^",
"@suaveplan/types": "workspace:^",
"tslib": "catalog:build"
},
"peerDependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@playwright/test": "catalog:testing",
"@suaveplan/testing": "workspace:^",
"@suaveplan/testing-dom": "workspace:^",
"@testing-library/dom": "catalog:testing",
"@testing-library/react": "catalog:testing",
"@types/node": "catalog:",
"@types/react": "catalog:testing",
"@types/react-dom": "catalog:testing",
"@vitejs/plugin-react": "catalog:testing",
"@vitest/coverage-istanbul": "catalog:testing",
"happy-dom": "catalog:testing",
"react": "catalog:testing",
"react-dom": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": [
"dist",
"src",
"README.md"
]
}

View File

@@ -0,0 +1,21 @@
import { defineConfig } from "@playwright/test";
/**
* Playwright config placeholder. The react-packer Wave 1 surface is fully
* exercised by Vitest + happy-dom integration tests against a real
* `PackedResourceManager`; a browser-tier Playwright sweep tracks alongside
* `@suaveplan/packer-browser`'s service-worker E2E and is intentionally not
* duplicated here. This config exists so the `test:e2e` script in
* `package.json` resolves and so future E2E specs land in `tests/e2e/`.
*/
export default defineConfig({
testDir: "./tests/e2e",
fullyParallel: false,
forbidOnly: true,
retries: 0,
workers: 1,
reporter: "list",
use: {
trace: "off",
},
});

View File

@@ -0,0 +1,94 @@
# `error-codes` — stable error registry for `@suaveplan/react-packer`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the canonical error-code registry and concrete error class for
the React-tier packer companion. Every failure mode surfaced through
`usePackedResource` and its variants funnels through `ReactPackerError`,
which extends `@suaveplan/error`'s `SuaveplanError` so consumers get
cause-chain traversal, OTel span-event tagging, and i18n message
resolution for free.
This module is the single source of truth for `REACT_PACKER_*` codes —
no other file may invent new ones. Hooks and the provider import the
exported constants and class to raise typed errors; tests assert on the
code constants rather than string-comparing message keys.
## Features
- `REACT_PACKER_CODES` — frozen registry of three codes: `LOAD_FAILED`,
`FONT_LOAD_FAILED`, `ABORTED`.
- `ReactPackerError` — `SuaveplanError` subclass with a unique brand
`Symbol` so cross-realm `instanceof` failures still permit safe
discrimination via `ReactPackerError.is(value)`.
- `cause` preservation — when a hook wraps a `PackerBrowserError` or a
`DOMException` from `document.fonts.load`, the original error rides on
`.cause` for downstream debugging and OTel attribute capture.
- Stable shape — adding new codes requires a new OpenSpec change so the
public registry never drifts mid-version.
## Usage: Basic
```ts
import { REACT_PACKER_CODES, ReactPackerError } from "@suaveplan/react-packer";
try {
await loadSomething();
} catch (cause) {
throw new ReactPackerError(
REACT_PACKER_CODES.LOAD_FAILED,
"react-packer.load-failed",
{ cause }
);
}
```
## Usage: Advanced
```ts
import { REACT_PACKER_CODES, ReactPackerError } from "@suaveplan/react-packer";
function onError(value: unknown): string {
if (ReactPackerError.is(value)) {
switch (value.code) {
case REACT_PACKER_CODES.LOAD_FAILED:
return "Could not unpack the resource.";
case REACT_PACKER_CODES.FONT_LOAD_FAILED:
return "Font failed to load; falling back to system font.";
case REACT_PACKER_CODES.ABORTED:
return "Cancelled.";
default:
return "Unexpected error.";
}
}
return "Unknown error.";
}
```
## API Reference
- `REACT_PACKER_CODES` — `Readonly<{ LOAD_FAILED, FONT_LOAD_FAILED, ABORTED }>`
registered via `@suaveplan/error`'s `defineCodes("REACT_PACKER", ...)`.
- `ReactPackerErrorCode` — union of the values of `REACT_PACKER_CODES`.
- `ReactPackerError(code, messageKey, options?)` — constructor accepts
the registered code, an i18n message key, and the standard
`SuaveplanErrorOptions` (including `cause` and `params`).
- `ReactPackerError.is(value: unknown): value is ReactPackerError` —
returns `true` only when `value` is a real `ReactPackerError` carrying
the brand symbol.
## Implementation Notes
- The brand `Symbol` is module-local, so passing a forged object with
the same shape will not satisfy `is(...)`. This is intentional —
duck-typed substitutes break OTel reporting and cause chains.
- `SuaveplanError` already pins `.code` and `.message` (resolved from
the messageKey) as read-only fields; subclasses do not need to
redeclare them.
- The brand assignment in the constructor is what makes `super(code, ...)`
safe — TypeScript's definite-assignment analysis is satisfied by the
`public readonly [BRAND]: true` field plus the constructor write.

View File

@@ -0,0 +1,57 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { REACT_PACKER_CODES, ReactPackerError } from "./error-codes.js";
describe("REACT_PACKER_CODES", () => {
it("registers LOAD_FAILED, FONT_LOAD_FAILED, ABORTED", {
timeout: 5000,
}, () => {
expect(REACT_PACKER_CODES.LOAD_FAILED).toBe("REACT_PACKER.LOAD_FAILED");
expect(REACT_PACKER_CODES.FONT_LOAD_FAILED).toBe(
"REACT_PACKER.FONT_LOAD_FAILED"
);
expect(REACT_PACKER_CODES.ABORTED).toBe("REACT_PACKER.ABORTED");
});
});
describe("ReactPackerError", () => {
it("is(value) returns true only for branded instances", {
timeout: 5000,
}, () => {
const real = new ReactPackerError(
REACT_PACKER_CODES.LOAD_FAILED,
"key"
);
expect(ReactPackerError.is(real)).toBe(true);
expect(ReactPackerError.is(new Error("plain"))).toBe(false);
expect(ReactPackerError.is(null)).toBe(false);
expect(ReactPackerError.is(undefined)).toBe(false);
expect(ReactPackerError.is({ code: "REACT_PACKER.LOAD_FAILED" })).toBe(
false
);
});
it("preserves cause through the SuaveplanError chain", {
timeout: 5000,
}, () => {
const cause = new Error("root");
const wrapped = new ReactPackerError(
REACT_PACKER_CODES.FONT_LOAD_FAILED,
"react-packer.font-load-failed",
{ cause }
);
expect(wrapped.cause).toBe(cause);
expect(wrapped.code).toBe(REACT_PACKER_CODES.FONT_LOAD_FAILED);
expect(wrapped.message).toContain("react-packer.font-load-failed");
});
it("exposes ABORTED code on instances constructed for cancellation", {
timeout: 5000,
}, () => {
const aborted = new ReactPackerError(
REACT_PACKER_CODES.ABORTED,
"react-packer.aborted"
);
expect(aborted.code).toBe(REACT_PACKER_CODES.ABORTED);
expect(ReactPackerError.is(aborted)).toBe(true);
});
});

View File

@@ -0,0 +1,51 @@
import {
defineCodes,
SuaveplanError,
type SuaveplanErrorOptions,
} from "@suaveplan/error";
/**
* Stable error code registry for `@suaveplan/react-packer`.
*
* Every failure raised by the React-tier packer surface (hook lifecycle,
* `@font-face` injection, pre-aborted signals) maps to one of these codes.
* The underlying error from `@suaveplan/packer-browser` or
* `document.fonts.load` is preserved on `.cause` so consumers can walk
* the chain.
*/
export const REACT_PACKER_CODES = defineCodes("REACT_PACKER", [
"LOAD_FAILED",
"FONT_LOAD_FAILED",
"ABORTED",
] as const);
export type ReactPackerErrorCode =
(typeof REACT_PACKER_CODES)[keyof typeof REACT_PACKER_CODES];
const BRAND: unique symbol = Symbol("@suaveplan/react-packer.error");
/**
* Concrete `SuaveplanError` subclass for the React-tier packer package.
*
* Carries a brand symbol so cross-realm `instanceof` failures still permit
* defence-in-depth via `ReactPackerError.is(value)`.
*/
export class ReactPackerError extends SuaveplanError<ReactPackerErrorCode> {
public readonly [BRAND]: true;
public constructor(
code: ReactPackerErrorCode,
messageKey: string,
options?: SuaveplanErrorOptions
) {
super(code, messageKey, options);
this[BRAND] = true;
}
public static is(value: unknown): value is ReactPackerError {
return (
value instanceof ReactPackerError &&
(value as ReactPackerError)[BRAND] === true
);
}
}

View File

@@ -0,0 +1,5 @@
export type { ReactPackerErrorCode } from "./error-codes.js";
export {
REACT_PACKER_CODES,
ReactPackerError,
} from "./error-codes.js";

View File

@@ -0,0 +1 @@
export { usePackedAudio } from "./use-packed-audio.js";

View File

@@ -0,0 +1,81 @@
# `usePackedAudio` — audio-tuned packed-resource hook
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`usePackedAudio` is the audio analogue of `usePackedImage`. It binds a
packed audio asset URL to a React component's lifecycle, defaults to
caching the underlying bytes (audio cues are typically re-triggered),
and narrows the manager's MIME allow-list to `audio/`-prefixed types so
misconfigured archives serving non-audio bytes fail fast at the loader
rather than at `<audio>` decode time.
The return shape matches `UsePackedResourceReturn` so callers swap
between variant hooks without touching downstream rendering code.
## Features
- `cache: true` default; opt out per call.
- `allowedMimePrefixes: ["audio/"]` default.
- Forwards `verifyKey`, `verifyChecksum`, and `signal` to the underlying
generic hook.
- Identical return shape to `usePackedResource`.
## Usage: Basic
```tsx
import { usePackedAudio } from "@suaveplan/react-packer";
function CueButton({ cue }: { cue: string | null }) {
const { url, loading } = usePackedAudio(cue);
if (loading || url === null) return <button disabled>…</button>;
return (
<button type="button" onClick={() => new Audio(url).play()}>
Play
</button>
);
}
```
## Usage: Advanced
```tsx
import { usePackedAudio } from "@suaveplan/react-packer";
function SignedCue({
cue,
verifyKey,
}: {
cue: string;
verifyKey: Uint8Array;
}) {
const { url, error, reload } = usePackedAudio(cue, {
verifyChecksum: true,
verifyKey,
allowedMimePrefixes: ["audio/ogg", "audio/webm"],
});
if (error !== null) return <button onClick={reload}>Retry</button>;
return url !== null ? <audio controls src={url} /> : null;
}
```
## API Reference
- `usePackedAudio(packedUrl, options?): UsePackedResourceReturn`
- Defaults differ from `usePackedResource`:
- `cache` defaults to `true`.
- `allowedMimePrefixes` defaults to `["audio/"]`.
- All other options pass through.
## Implementation Notes
- Explicit caller options always win. Passing `{ cache: false }`
disables caching for that hook instance; passing
`{ allowedMimePrefixes: [] }` opts out of MIME enforcement entirely.
- The default MIME prefix is checked against the response's
`Content-Type` by the loader; the hook itself does not parse MIME.
- Audio decoding remains the consumer's responsibility — the hook only
produces the Blob URL.

View File

@@ -0,0 +1,63 @@
import type { Meta, StoryObj } from "@storybook/react";
import { createPackedResourceManager } from "@suaveplan/packer-browser";
import { PackedResourceProvider } from "../../provider/packed-resource-provider/packed-resource-provider.js";
import { usePackedAudio } from "./use-packed-audio.js";
const AUDIO_BYTES = new Uint8Array(8);
function makeManager(): ReturnType<typeof createPackedResourceManager> {
return createPackedResourceManager({
fetch: async (): Promise<Response> =>
new Response(AUDIO_BYTES, {
status: 200,
headers: { "content-type": "audio/webm" },
}),
});
}
function Demo({ src }: { src: string | null }): React.ReactElement {
const { url, status, error } = usePackedAudio(src);
return (
<div>
<p>status: {status}</p>
<p>error: {error?.code ?? "<none>"}</p>
{url !== null ? (
<audio controls src={url}>
<track kind="captions" />
</audio>
) : null}
</div>
);
}
const meta: Meta<typeof Demo> = {
title: "react-packer/usePackedAudio",
component: Demo,
};
export default meta;
type Story = StoryObj<typeof Demo>;
export const Default: Story = {
render: () => (
<PackedResourceProvider value={{ manager: makeManager() }}>
<Demo src="/demo.audio.packed" />
</PackedResourceProvider>
),
};
export const Idle: Story = {
render: () => (
<PackedResourceProvider value={{ manager: makeManager() }}>
<Demo src={null} />
</PackedResourceProvider>
),
};
export const PlaygroundStory: Story = {
name: "Playground",
render: () => (
<PackedResourceProvider value={{ manager: makeManager() }}>
<Demo src="/playground.audio.packed" />
</PackedResourceProvider>
),
};

View File

@@ -0,0 +1,127 @@
import {
createPackedResourceManager,
type PackedResourceManager,
} from "@suaveplan/packer-browser";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { renderComponent } from "@suaveplan/testing-dom/render";
import { act } from "@testing-library/react";
import { PackedResourceProvider } from "../../provider/packed-resource-provider/packed-resource-provider.js";
import type {
UsePackedResourceOptions,
UsePackedResourceReturn,
} from "../../types/react-packer.types.js";
import { usePackedAudio } from "./use-packed-audio.js";
const AUDIO = new Uint8Array(8);
function makeManager(contentType = "audio/webm"): PackedResourceManager {
return createPackedResourceManager({
fetch: async (): Promise<Response> =>
new Response(AUDIO, {
status: 200,
headers: { "content-type": contentType },
}),
});
}
interface ResultRef {
current: UsePackedResourceReturn | null;
}
function Probe({
url,
out,
options,
}: {
url: string;
out: ResultRef;
options?: UsePackedResourceOptions;
}): null {
out.current = usePackedAudio(url, options);
return null;
}
async function flush(): Promise<void> {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
}
describe("usePackedAudio", () => {
it("loads an audio-tuned packed resource", {
timeout: 30_000,
}, async () => {
const manager = makeManager();
const out: ResultRef = { current: null };
renderComponent(
<PackedResourceProvider value={{ manager }}>
<Probe url="/cue.packed" out={out} />
</PackedResourceProvider>
);
await flush();
expect(out.current?.status).toBe("success");
expect(manager.getCacheStats().size).toBe(1);
});
it("forwards caller-supplied verifyKey to the manager", {
timeout: 30_000,
}, async () => {
const manager = makeManager();
const out: ResultRef = { current: null };
const verifyKey = new Uint8Array([9, 8, 7, 6]);
renderComponent(
<PackedResourceProvider value={{ manager }}>
<Probe
url="/key.packed"
out={out}
options={{ verifyKey, verifyChecksum: false }}
/>
</PackedResourceProvider>
);
await flush();
expect(out.current?.status).toBe("success");
});
it("forwards verifyChecksum and signal through to the underlying load", {
timeout: 30_000,
}, async () => {
const manager = makeManager();
const out: ResultRef = { current: null };
const ctrl = new AbortController();
renderComponent(
<PackedResourceProvider value={{ manager }}>
<Probe
url="/with-signal.packed"
out={out}
options={{
verifyChecksum: false,
signal: ctrl.signal,
}}
/>
</PackedResourceProvider>
);
await flush();
expect(out.current?.status).toBe("success");
});
it("caller can override the MIME allow-list", {
timeout: 30_000,
}, async () => {
const manager = makeManager("application/octet-stream");
const out: ResultRef = { current: null };
renderComponent(
<PackedResourceProvider value={{ manager }}>
<Probe
url="/wide.packed"
out={out}
options={{
allowedMimePrefixes: ["application/octet-stream"],
}}
/>
</PackedResourceProvider>
);
await flush();
expect(out.current?.status).toBe("success");
});
});

View File

@@ -0,0 +1,27 @@
import type {
UsePackedResourceOptions,
UsePackedResourceReturn,
} from "../../types/react-packer.types.js";
import { usePackedResource } from "../use-packed-resource/use-packed-resource.js";
const AUDIO_MIME_PREFIXES: ReadonlyArray<string> = ["audio/"];
/**
* Audio-tuned wrapper over {@link usePackedResource}. Defaults to
* `cache: true` and an `audio/`-prefix MIME allow-list. Caller-supplied
* options override the defaults individually.
*/
export function usePackedAudio(
packedUrl: string | null | undefined,
options: UsePackedResourceOptions = {}
): UsePackedResourceReturn {
const merged: UsePackedResourceOptions = {
cache: options.cache ?? true,
allowedMimePrefixes: options.allowedMimePrefixes ?? AUDIO_MIME_PREFIXES,
};
if (options.verifyChecksum !== undefined)
merged.verifyChecksum = options.verifyChecksum;
if (options.verifyKey !== undefined) merged.verifyKey = options.verifyKey;
if (options.signal !== undefined) merged.signal = options.signal;
return usePackedResource(packedUrl, merged);
}

View File

@@ -0,0 +1 @@
export { usePackedFont } from "./use-packed-font.js";

View File

@@ -0,0 +1,122 @@
# `usePackedFont` — font-tuned packed-resource hook
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`usePackedFont` loads a packed font asset and registers it with the
document so subsequent rules referencing the supplied `font-family`
render with the correct face. The hook builds on `usePackedResource`
for the load lifecycle and adds:
1. `@font-face` rule injection — a `<style id="packed-font-<family>">`
element is appended to `document.head` once the Blob URL is ready.
2. `document.fonts.load("1em <family>")` waiting — the hook exposes a
`fontLoaded` flag that flips to `true` once the browser reports the
face as loaded, so consumers can opt into FOIT/FOUT strategies.
3. Cleanup — the injected style element is removed when the component
unmounts or the font family changes.
## Features
- `cache: true` default with a font-friendly MIME allow-list covering
`font/`, `application/font*`, and `application/octet-stream`.
- Idempotent style injection keyed by `packed-font-<family>` — multiple
components mounting the same family share the rule.
- `fontLoaded` flag wired to `document.fonts.load`, with a safe
fallback when the document or the font loading API is unavailable
(SSR, Node tests).
- Failure to load a font surfaces as
`ReactPackerError(FONT_LOAD_FAILED)`; the underlying byte load still
exposes its own errors through `result.error` when relevant.
- Strict TS: no `any` casts, no implicit `document` access — every
global read goes through a typed adapter so non-DOM environments
return clean defaults.
## Usage: Basic
```tsx
import { usePackedFont } from "@suaveplan/react-packer";
function Heading({ src }: { src: string }) {
const { fontLoaded } = usePackedFont(src, "BodyText");
return (
<h1
style={{
fontFamily: fontLoaded ? "BodyText" : "system-ui",
}}
>
Hello
</h1>
);
}
```
## Usage: Advanced
```tsx
import { usePackedFont } from "@suaveplan/react-packer";
function SignedHeading({
src,
family,
key,
}: {
src: string;
family: string;
key: Uint8Array;
}) {
const { fontLoaded, error, reload } = usePackedFont(src, family, {
verifyChecksum: true,
verifyKey: key,
});
if (error !== null)
return (
<button onClick={reload}>Retry ({error.code})</button>
);
return (
<h1
style={{
fontFamily: fontLoaded ? family : "system-ui",
opacity: fontLoaded ? 1 : 0.6,
transition: "opacity 120ms ease",
}}
>
Hello
</h1>
);
}
```
## API Reference
- `usePackedFont(packedUrl, fontFamily?, options?): UsePackedFontReturn`
- `packedUrl: string | null | undefined` — the packed asset URL.
- `fontFamily?: string` — the family name to use in the injected rule.
When omitted, no `@font-face` rule is injected and `fontLoaded`
remains `false`, but the underlying Blob URL is still produced.
- `options: UsePackedResourceOptions` — forwarded to `usePackedResource`
with font-tuned defaults overlaid (`cache: true`, font-friendly MIME
allow-list).
- Returns `UsePackedFontReturn` — `UsePackedResourceReturn` plus
`fontLoaded: boolean`.
## Implementation Notes
- The style element is keyed by `packed-font-<family>`. Mounting two
hooks with the same family results in a single style element shared
across them; the second mount rewrites `textContent` with the same
rule, which is idempotent at the CSSOM level.
- `document.fonts.load(...)` failures (browser-specific, e.g. corrupt
font bytes) become `ReactPackerError(FONT_LOAD_FAILED)` with the
original rejection on `.cause` and surface on `result.error`,
shadowing any prior byte-load error from the base hook.
- In environments without `document` (SSR, Node tests without a DOM)
the hook still works at the byte level — `fontLoaded` simply stays
`false`.
- The hook deliberately does not call `document.fonts.add(face)` —
injecting `@font-face` via stylesheet is what the browser will pick
up anyway, and avoiding the `FontFace` constructor keeps the hook
portable to environments where that constructor is absent.

View File

@@ -0,0 +1,68 @@
import type { Meta, StoryObj } from "@storybook/react";
import { createPackedResourceManager } from "@suaveplan/packer-browser";
import { PackedResourceProvider } from "../../provider/packed-resource-provider/packed-resource-provider.js";
import { usePackedFont } from "./use-packed-font.js";
const FONT_BYTES = new Uint8Array(32);
function makeManager(): ReturnType<typeof createPackedResourceManager> {
return createPackedResourceManager({
fetch: async (): Promise<Response> =>
new Response(FONT_BYTES, {
status: 200,
headers: { "content-type": "font/woff2" },
}),
});
}
function Demo({
src,
family,
}: {
src: string | null;
family?: string;
}): React.ReactElement {
const { fontLoaded, status, error } = usePackedFont(src, family);
return (
<div>
<p>status: {status}</p>
<p>fontLoaded: {String(fontLoaded)}</p>
<p>error: {error?.code ?? "<none>"}</p>
<p style={{ fontFamily: fontLoaded ? family : "system-ui" }}>
Sample text
</p>
</div>
);
}
const meta: Meta<typeof Demo> = {
title: "react-packer/usePackedFont",
component: Demo,
};
export default meta;
type Story = StoryObj<typeof Demo>;
export const Default: Story = {
render: () => (
<PackedResourceProvider value={{ manager: makeManager() }}>
<Demo src="/demo.font.packed" family="DemoFont" />
</PackedResourceProvider>
),
};
export const NoFamily: Story = {
render: () => (
<PackedResourceProvider value={{ manager: makeManager() }}>
<Demo src="/demo.font.packed" />
</PackedResourceProvider>
),
};
export const PlaygroundStory: Story = {
name: "Playground",
render: () => (
<PackedResourceProvider value={{ manager: makeManager() }}>
<Demo src="/playground.font.packed" family="PlayFont" />
</PackedResourceProvider>
),
};

View File

@@ -0,0 +1,368 @@
import {
createPackedResourceManager,
type PackedResourceManager,
} from "@suaveplan/packer-browser";
import { afterEach, describe, expect, it } from "@suaveplan/testing/runner";
import { renderComponent } from "@suaveplan/testing-dom/render";
import { act } from "@testing-library/react";
import { REACT_PACKER_CODES } from "../../error-codes/error-codes.js";
import { PackedResourceProvider } from "../../provider/packed-resource-provider/packed-resource-provider.js";
import type {
UsePackedFontReturn,
UsePackedResourceOptions,
} from "../../types/react-packer.types.js";
import { usePackedFont } from "./use-packed-font.js";
const FONT = new Uint8Array(32);
function makeManager(): PackedResourceManager {
return createPackedResourceManager({
fetch: async (): Promise<Response> =>
new Response(FONT, {
status: 200,
headers: { "content-type": "font/woff2" },
}),
});
}
interface ResultRef {
current: UsePackedFontReturn | null;
}
function Probe({
url,
family,
out,
options,
}: {
url: string | null;
family?: string;
out: ResultRef;
options?: UsePackedResourceOptions;
}): null {
out.current = usePackedFont(url, family, options);
return null;
}
async function flush(): Promise<void> {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
});
}
interface FontsApi {
load: (spec: string) => Promise<unknown>;
}
function setFontsApi(api: FontsApi | undefined): void {
const doc = globalThis.document as unknown as {
fonts?: FontsApi;
};
if (api === undefined) {
delete doc.fonts;
return;
}
doc.fonts = api;
}
afterEach(() => {
// Remove every injected packed-font style element so tests don't leak.
const styles = globalThis.document.querySelectorAll(
"style[id^='packed-font-']"
);
for (const el of Array.from(styles)) el.remove();
setFontsApi({ load: async (): Promise<unknown[]> => [] });
});
describe("usePackedFont", () => {
it("injects @font-face on successful load", {
timeout: 30_000,
}, async () => {
setFontsApi({ load: async (): Promise<unknown[]> => [] });
const manager = makeManager();
const out: ResultRef = { current: null };
renderComponent(
<PackedResourceProvider value={{ manager }}>
<Probe url="/face.packed" family="DemoFace" out={out} />
</PackedResourceProvider>
);
await flush();
const styleEl = globalThis.document.getElementById(
"packed-font-DemoFace"
);
expect(styleEl).not.toBeNull();
expect(styleEl?.textContent).toContain("'DemoFace'");
expect(out.current?.fontLoaded).toBe(true);
expect(out.current?.status).toBe("success");
expect(out.current?.url?.startsWith("blob:") ?? false).toBe(true);
});
it("omitting fontFamily skips style injection", {
timeout: 30_000,
}, async () => {
setFontsApi({ load: async (): Promise<unknown[]> => [] });
const manager = makeManager();
const out: ResultRef = { current: null };
renderComponent(
<PackedResourceProvider value={{ manager }}>
<Probe url="/noface.packed" out={out} />
</PackedResourceProvider>
);
await flush();
const styles = globalThis.document.querySelectorAll(
"style[id^='packed-font-']"
);
expect(styles.length).toBe(0);
expect(out.current?.fontLoaded).toBe(false);
expect(out.current?.url?.startsWith("blob:") ?? false).toBe(true);
});
it("wraps fonts.load rejection as REACT_PACKER_FONT_LOAD_FAILED", {
timeout: 30_000,
}, async () => {
setFontsApi({
load: async (): Promise<unknown> => {
throw new Error("font-load-rejected");
},
});
const manager = makeManager();
const out: ResultRef = { current: null };
renderComponent(
<PackedResourceProvider value={{ manager }}>
<Probe url="/badface.packed" family="BadFace" out={out} />
</PackedResourceProvider>
);
await flush();
expect(out.current?.error?.code).toBe(
REACT_PACKER_CODES.FONT_LOAD_FAILED
);
expect(out.current?.fontLoaded).toBe(false);
});
it("removes the injected style element on unmount", {
timeout: 30_000,
}, async () => {
setFontsApi({ load: async (): Promise<unknown[]> => [] });
const manager = makeManager();
const out: ResultRef = { current: null };
const view = renderComponent(
<PackedResourceProvider value={{ manager }}>
<Probe url="/cleanup.packed" family="CleanFace" out={out} />
</PackedResourceProvider>
);
await flush();
expect(
globalThis.document.getElementById("packed-font-CleanFace")
).not.toBeNull();
view.unmount();
expect(
globalThis.document.getElementById("packed-font-CleanFace")
).toBeNull();
});
it("handles environments without document.fonts gracefully", {
timeout: 30_000,
}, async () => {
setFontsApi(undefined);
const manager = makeManager();
const out: ResultRef = { current: null };
renderComponent(
<PackedResourceProvider value={{ manager }}>
<Probe url="/nofonts.packed" family="NoFontsFace" out={out} />
</PackedResourceProvider>
);
await flush();
// Style was still injected; fontLoaded flipped true via the
// Promise.resolve() fallback path.
expect(
globalThis.document.getElementById("packed-font-NoFontsFace")
).not.toBeNull();
expect(out.current?.fontLoaded).toBe(true);
});
it("reuses an existing style element instead of creating a duplicate", {
timeout: 30_000,
}, async () => {
setFontsApi({ load: async (): Promise<unknown[]> => [] });
// Pre-inject the style element so the hook hits the reuse branch.
const pre = globalThis.document.createElement("style");
pre.id = "packed-font-PrePlaced";
globalThis.document.head.appendChild(pre);
const manager = makeManager();
const out: ResultRef = { current: null };
renderComponent(
<PackedResourceProvider value={{ manager }}>
<Probe url="/preplaced.packed" family="PrePlaced" out={out} />
</PackedResourceProvider>
);
await flush();
const styles = globalThis.document.querySelectorAll(
"style[id='packed-font-PrePlaced']"
);
expect(styles.length).toBe(1);
expect(styles[0]?.textContent).toContain("'PrePlaced'");
});
it("forwards signal through to the underlying load", {
timeout: 30_000,
}, async () => {
setFontsApi({ load: async (): Promise<unknown[]> => [] });
const manager = makeManager();
const out: ResultRef = { current: null };
const ctrl = new AbortController();
renderComponent(
<PackedResourceProvider value={{ manager }}>
<Probe
url="/signal-font.packed"
family="SignalFace"
out={out}
options={{ signal: ctrl.signal }}
/>
</PackedResourceProvider>
);
await flush();
expect(out.current?.status).toBe("success");
});
it("returns idle font state when document is unavailable", {
timeout: 30_000,
}, async () => {
setFontsApi({ load: async (): Promise<unknown[]> => [] });
const manager = makeManager();
const out: ResultRef = { current: null };
const realDoc = globalThis.document;
// Simulate SSR by detaching document just before render.
// The hook captures `globalThis.document` inside the effect, so
// we remove it before flushing the load completion.
renderComponent(
<PackedResourceProvider value={{ manager }}>
<Probe url="/ssr-font.packed" family="SsrFace" out={out} />
</PackedResourceProvider>
);
// Replace document for the duration of the next flush so the
// font effect's getDocument() returns null on its next run.
Object.defineProperty(globalThis, "document", {
value: undefined,
configurable: true,
});
await flush();
// Restore so afterEach cleanup works.
Object.defineProperty(globalThis, "document", {
value: realDoc,
configurable: true,
});
// The base hook still ran (it doesn't touch document); fontLoaded
// remained false because the font effect saw no document.
expect(out.current?.fontLoaded).toBe(false);
});
it("does not set state after unmount when fonts.load is still pending", {
timeout: 30_000,
}, async () => {
// fonts.load never resolves — the cleanup MUST fire before
// the promise can settle to exercise the cancelled branches.
let resolveLoad: (() => void) | null = null;
let rejectLoad: ((err: Error) => void) | null = null;
setFontsApi({
load: () =>
new Promise((resolve, reject) => {
resolveLoad = () => resolve([]);
rejectLoad = (err) => reject(err);
}),
});
const manager = makeManager();
const out: ResultRef = { current: null };
const view = renderComponent(
<PackedResourceProvider value={{ manager }}>
<Probe
url="/pending-font.packed"
family="PendingFace"
out={out}
/>
</PackedResourceProvider>
);
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
view.unmount();
// Resolve AFTER unmount — the cancelled flag must prevent
// setFontLoaded / setFontError from being called.
await act(async () => {
resolveLoad?.();
await Promise.resolve();
});
// Now re-render and reject the same way to exercise the catch.
let rejectLoad2: ((err: Error) => void) | null = null;
setFontsApi({
load: () =>
new Promise((_resolve, reject) => {
rejectLoad2 = (err) => reject(err);
}),
});
const out2: ResultRef = { current: null };
const view2 = renderComponent(
<PackedResourceProvider value={{ manager: makeManager() }}>
<Probe
url="/pending-reject-font.packed"
family="PendRejFace"
out={out2}
/>
</PackedResourceProvider>
);
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
view2.unmount();
await act(async () => {
rejectLoad2?.(new Error("after-unmount"));
await Promise.resolve();
});
// Reference the unused captured rejector so biome accepts.
expect(typeof rejectLoad === "function" || rejectLoad === null).toBe(
true
);
});
it("clears injected style when fontFamily becomes empty string", {
timeout: 30_000,
}, async () => {
setFontsApi({ load: async (): Promise<unknown[]> => [] });
const manager = makeManager();
const out: ResultRef = { current: null };
renderComponent(
<PackedResourceProvider value={{ manager }}>
<Probe url="/empty-family.packed" family="" out={out} />
</PackedResourceProvider>
);
await flush();
expect(out.current?.fontLoaded).toBe(false);
});
it("merges caller verifyKey + verifyChecksum into the underlying load", {
timeout: 30_000,
}, async () => {
setFontsApi({ load: async (): Promise<unknown[]> => [] });
const manager = makeManager();
const out: ResultRef = { current: null };
renderComponent(
<PackedResourceProvider value={{ manager }}>
<Probe
url="/signed-font.packed"
family="SignedFace"
out={out}
options={{
verifyChecksum: false,
verifyKey: new Uint8Array([1, 2, 3]),
}}
/>
</PackedResourceProvider>
);
await flush();
expect(out.current?.status).toBe("success");
expect(out.current?.fontLoaded).toBe(true);
});
});

View File

@@ -0,0 +1,125 @@
import { useEffect, useState } from "react";
import {
REACT_PACKER_CODES,
ReactPackerError,
} from "../../error-codes/error-codes.js";
import { usePackedMessageResolver } from "../../provider/packed-resource-provider/packed-resource-provider.js";
import type {
UsePackedFontReturn,
UsePackedResourceOptions,
} from "../../types/react-packer.types.js";
import { usePackedResource } from "../use-packed-resource/use-packed-resource.js";
const FONT_MIME_PREFIXES: ReadonlyArray<string> = [
"font/",
"application/font",
"application/octet-stream",
];
interface DocumentFontsLike {
load?: (spec: string) => Promise<unknown>;
}
interface DocumentLike {
head: { appendChild(el: HTMLStyleElement): unknown };
createElement(tag: "style"): HTMLStyleElement;
getElementById(id: string): HTMLElement | null;
fonts?: DocumentFontsLike;
}
function getDocument(): DocumentLike | null {
const doc = (globalThis as { document?: DocumentLike }).document;
return doc ?? null;
}
/**
* Font-tuned wrapper over {@link usePackedResource}. Loads the font
* bytes through the manager, injects a `@font-face` stylesheet so the
* font is available to CSS, and exposes a `fontLoaded` flag once the
* browser has finished loading the face via `document.fonts.load`.
*/
export function usePackedFont(
packedUrl: string | null | undefined,
fontFamily?: string,
options: UsePackedResourceOptions = {}
): UsePackedFontReturn {
const merged: UsePackedResourceOptions = {
cache: options.cache ?? true,
allowedMimePrefixes: options.allowedMimePrefixes ?? FONT_MIME_PREFIXES,
};
if (options.verifyChecksum !== undefined)
merged.verifyChecksum = options.verifyChecksum;
if (options.verifyKey !== undefined) merged.verifyKey = options.verifyKey;
if (options.signal !== undefined) merged.signal = options.signal;
const base = usePackedResource(packedUrl, merged);
const resolver = usePackedMessageResolver();
const [fontLoaded, setFontLoaded] = useState(false);
const [fontError, setFontError] = useState<ReactPackerError | null>(null);
const baseUrl = base.url;
useEffect(() => {
const doc = getDocument();
if (
doc === null ||
baseUrl === null ||
fontFamily === undefined ||
fontFamily === ""
) {
setFontLoaded(false);
return;
}
const styleId = `packed-font-${fontFamily}`;
let styleElement = doc.getElementById(
styleId
) as HTMLStyleElement | null;
if (styleElement === null) {
styleElement = doc.createElement("style");
styleElement.id = styleId;
doc.head.appendChild(styleElement);
}
styleElement.textContent =
`@font-face { font-family: '${fontFamily}'; ` +
`src: url('${baseUrl}'); font-display: swap; }`;
let cancelled = false;
const fonts = doc.fonts;
const loadPromise =
typeof fonts?.load === "function"
? fonts.load(`1em ${fontFamily}`)
: Promise.resolve();
loadPromise
.then(() => {
if (cancelled) return;
setFontLoaded(true);
setFontError(null);
})
.catch((err: unknown) => {
if (cancelled) return;
setFontLoaded(false);
setFontError(
new ReactPackerError(
REACT_PACKER_CODES.FONT_LOAD_FAILED,
resolver?.("react-packer.font-load-failed") ??
"react-packer.font-load-failed",
{ cause: err }
)
);
});
return () => {
cancelled = true;
const existing = doc.getElementById(styleId);
existing?.remove();
};
}, [baseUrl, fontFamily, resolver]);
return {
...base,
error: fontError ?? base.error,
fontLoaded,
};
}

View File

@@ -0,0 +1 @@
export { usePackedImage } from "./use-packed-image.js";

View File

@@ -0,0 +1,79 @@
# `usePackedImage` — image-tuned packed-resource hook
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`usePackedImage` is a thin wrapper around `usePackedResource` that
pre-fills sensible defaults for image assets: caching is enabled (images
tend to be re-used across renders) and the manager's MIME allow-list is
narrowed to `image/`-prefixed types so a misconfigured archive serving
HTML or JavaScript under a virtual image URL fails fast at the loader
boundary rather than the `<img>` tag.
The hook returns the same `UsePackedResourceReturn` shape as the generic
hook, so it is drop-in for any component that already consumes
`usePackedResource`.
## Features
- `cache: true` default — caller can opt out with `{ cache: false }`.
- `allowedMimePrefixes: ["image/"]` default — caller can broaden or
narrow.
- Forwards `verifyKey`, `verifyChecksum`, and `signal` untouched.
- Identical return shape to `usePackedResource` for trivial swapping.
## Usage: Basic
```tsx
import { usePackedImage } from "@suaveplan/react-packer";
function Avatar({ src }: { src: string | null }) {
const { url, loading } = usePackedImage(src);
if (loading) return <div className="avatar avatar--loading" />;
return url !== null ? <img alt="" className="avatar" src={url} /> : null;
}
```
## Usage: Advanced
```tsx
import { usePackedImage } from "@suaveplan/react-packer";
function SignedAvatar({
src,
key,
}: {
src: string;
key: Uint8Array;
}) {
const { url, error } = usePackedImage(src, {
verifyChecksum: true,
verifyKey: key,
allowedMimePrefixes: ["image/avif", "image/webp"],
});
if (error !== null) return <span>{error.code}</span>;
return url !== null ? <img alt="" src={url} /> : null;
}
```
## API Reference
- `usePackedImage(packedUrl, options?): UsePackedResourceReturn`
- Behaviour is identical to `usePackedResource` except defaults differ:
- `cache` defaults to `true`.
- `allowedMimePrefixes` defaults to `["image/"]`.
- All other options pass through unchanged.
## Implementation Notes
- Defaults apply only when the caller omits the key — explicit `false`
or an empty array opt out of caching / MIME enforcement respectively.
- The MIME allow-list is honoured by the manager's loader, not by the
hook itself; non-image archives surface as
`ReactPackerError(LOAD_FAILED)` with the underlying
`PackerBrowserError(UNSUPPORTED_RESOURCE_TYPE)` on `.cause`.
- The constant `IMAGE_MIME_PREFIXES` is module-local and frozen via
`ReadonlyArray` typing so consumers cannot mutate the default.

View File

@@ -0,0 +1,61 @@
import type { Meta, StoryObj } from "@storybook/react";
import { createPackedResourceManager } from "@suaveplan/packer-browser";
import { PackedResourceProvider } from "../../provider/packed-resource-provider/packed-resource-provider.js";
import { usePackedImage } from "./use-packed-image.js";
const PNG_BYTES = new Uint8Array([
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
]);
function makeManager(): ReturnType<typeof createPackedResourceManager> {
return createPackedResourceManager({
fetch: async (): Promise<Response> =>
new Response(PNG_BYTES, {
status: 200,
headers: { "content-type": "image/png" },
}),
});
}
function Demo({ src }: { src: string | null }): React.ReactElement {
const { url, loading, error } = usePackedImage(src);
if (loading) return <p>Loading image…</p>;
if (error !== null) return <p>error: {error.code}</p>;
return url !== null ? (
<img alt="packed" src={url} width={32} height={32} />
) : (
<p>idle</p>
);
}
const meta: Meta<typeof Demo> = {
title: "react-packer/usePackedImage",
component: Demo,
};
export default meta;
type Story = StoryObj<typeof Demo>;
export const Default: Story = {
render: () => (
<PackedResourceProvider value={{ manager: makeManager() }}>
<Demo src="/demo.image.packed" />
</PackedResourceProvider>
),
};
export const Idle: Story = {
render: () => (
<PackedResourceProvider value={{ manager: makeManager() }}>
<Demo src={null} />
</PackedResourceProvider>
),
};
export const PlaygroundStory: Story = {
name: "Playground",
render: () => (
<PackedResourceProvider value={{ manager: makeManager() }}>
<Demo src="/playground.image.packed" />
</PackedResourceProvider>
),
};

View File

@@ -0,0 +1,133 @@
import {
createPackedResourceManager,
PACKER_BROWSER_CODES,
type PackedResourceManager,
} from "@suaveplan/packer-browser";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { renderComponent } from "@suaveplan/testing-dom/render";
import { act } from "@testing-library/react";
import { REACT_PACKER_CODES } from "../../error-codes/error-codes.js";
import { PackedResourceProvider } from "../../provider/packed-resource-provider/packed-resource-provider.js";
import type {
UsePackedResourceOptions,
UsePackedResourceReturn,
} from "../../types/react-packer.types.js";
import { usePackedImage } from "./use-packed-image.js";
const PNG = new Uint8Array([0x89, 0x50, 0x4e, 0x47]);
function makeManager(contentType = "image/png"): PackedResourceManager {
return createPackedResourceManager({
fetch: async (): Promise<Response> =>
new Response(PNG, {
status: 200,
headers: { "content-type": contentType },
}),
});
}
interface ResultRef {
current: UsePackedResourceReturn | null;
}
function Probe({
url,
out,
options,
}: {
url: string;
out: ResultRef;
options?: UsePackedResourceOptions;
}): null {
out.current = usePackedImage(url, options);
return null;
}
async function flush(): Promise<void> {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
}
describe("usePackedImage", () => {
it("loads an image-tuned packed resource", {
timeout: 30_000,
}, async () => {
const manager = makeManager();
const out: ResultRef = { current: null };
renderComponent(
<PackedResourceProvider value={{ manager }}>
<Probe url="/img.packed" out={out} />
</PackedResourceProvider>
);
await flush();
expect(out.current?.status).toBe("success");
expect(out.current?.url?.startsWith("blob:") ?? false).toBe(true);
});
it("caller can override the cache default", {
timeout: 30_000,
}, async () => {
const manager = makeManager();
const out: ResultRef = { current: null };
renderComponent(
<PackedResourceProvider value={{ manager }}>
<Probe
url="/img2.packed"
out={out}
options={{ cache: false }}
/>
</PackedResourceProvider>
);
await flush();
expect(out.current?.status).toBe("success");
// cache: false means the entry never went into the LRU.
expect(manager.getCacheStats().size).toBe(0);
});
it("forwards verifyChecksum and signal through to the underlying load", {
timeout: 30_000,
}, async () => {
const manager = makeManager();
const out: ResultRef = { current: null };
const ctrl = new AbortController();
renderComponent(
<PackedResourceProvider value={{ manager }}>
<Probe
url="/with-signal.packed"
out={out}
options={{
verifyChecksum: false,
verifyKey: new Uint8Array([1, 2]),
signal: ctrl.signal,
}}
/>
</PackedResourceProvider>
);
await flush();
expect(out.current?.status).toBe("success");
});
it("rejects non-image MIME types via the loader", {
timeout: 30_000,
}, async () => {
const manager = makeManager("application/json");
const out: ResultRef = { current: null };
renderComponent(
<PackedResourceProvider value={{ manager }}>
<Probe url="/wrong.packed" out={out} />
</PackedResourceProvider>
);
await flush();
expect(out.current?.status).toBe("error");
expect(out.current?.error?.code).toBe(REACT_PACKER_CODES.LOAD_FAILED);
// The underlying PackerBrowserError rides on cause.
const cause = out.current?.error?.cause as
| { code?: string }
| undefined;
expect(cause?.code).toBe(
PACKER_BROWSER_CODES.UNSUPPORTED_RESOURCE_TYPE
);
});
});

View File

@@ -0,0 +1,27 @@
import type {
UsePackedResourceOptions,
UsePackedResourceReturn,
} from "../../types/react-packer.types.js";
import { usePackedResource } from "../use-packed-resource/use-packed-resource.js";
const IMAGE_MIME_PREFIXES: ReadonlyArray<string> = ["image/"];
/**
* Image-tuned wrapper over {@link usePackedResource}. Defaults to
* `cache: true` and an `image/`-prefix MIME allow-list. Caller-supplied
* options override the defaults individually.
*/
export function usePackedImage(
packedUrl: string | null | undefined,
options: UsePackedResourceOptions = {}
): UsePackedResourceReturn {
const merged: UsePackedResourceOptions = {
cache: options.cache ?? true,
allowedMimePrefixes: options.allowedMimePrefixes ?? IMAGE_MIME_PREFIXES,
};
if (options.verifyChecksum !== undefined)
merged.verifyChecksum = options.verifyChecksum;
if (options.verifyKey !== undefined) merged.verifyKey = options.verifyKey;
if (options.signal !== undefined) merged.signal = options.signal;
return usePackedResource(packedUrl, merged);
}

View File

@@ -0,0 +1 @@
export { usePackedResource } from "./use-packed-resource.js";

View File

@@ -0,0 +1,121 @@
# `usePackedResource` — generic React hook for `@suaveplan/packer-browser`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`usePackedResource` is the foundation hook of `@suaveplan/react-packer`.
It binds a packed-archive virtual URL (e.g. `/packed/logo.bundle.asset`)
to a React component's lifecycle, loading the underlying bytes through
the active `PackedResourceManager`, exposing the resulting Blob URL on
success, and releasing the cached entry on unmount.
The hook is intentionally low-level — variant hooks
(`usePackedImage`, `usePackedAudio`, `usePackedVideo`, `usePackedFont`)
delegate to it with media-tuned defaults. Consumers can also use the
generic hook directly when they need full control over caching,
checksum verification, signature keys, or MIME allow-lists.
## Features
- Lifecycle-correct: `idle` → `loading` → `success` / `error`, with
every state transition guarded by a `mounted` ref so unmounted
components never `setState`.
- `AbortSignal` propagation: the hook owns an internal `AbortController`,
merges any caller-supplied signal into it, and aborts on unmount,
`packedUrl` change, or `reload()`.
- Cache awareness: `manager.release(url, verifyKey)` runs in cleanup so
the cache can evict entries no React component still references.
- Error normalisation: every thrown value is wrapped as
`ReactPackerError` with a stable code (`LOAD_FAILED` or `ABORTED`)
and the original on `.cause`.
- Pluggable: resolves its `PackedResourceManager` via
`usePackedResourceManager()`, so the same hook works against a
provider-supplied manager (tests, multi-tenant apps) or the singleton
(zero-config consumers).
- Reload: the returned `reload()` thunk increments an internal counter
that re-runs the effect against a fresh `AbortController`.
## Usage: Basic
```tsx
import { usePackedResource } from "@suaveplan/react-packer";
function Asset({ src }: { src: string }) {
const { url, loading, error } = usePackedResource(src);
if (loading) return <span>Loading…</span>;
if (error !== null) return <span>{error.message}</span>;
return url !== null ? <img alt="" src={url} /> : null;
}
```
## Usage: Advanced
```tsx
import { usePackedResource } from "@suaveplan/react-packer";
function VerifiedAsset({
src,
verifyKey,
signal,
}: {
src: string | null;
verifyKey: Uint8Array;
signal: AbortSignal;
}) {
const { url, status, error, reload } = usePackedResource(src, {
cache: false,
verifyChecksum: true,
verifyKey,
signal,
allowedMimePrefixes: ["application/octet-stream"],
});
if (status === "idle") return <button onClick={reload}>Load</button>;
if (status === "loading") return <span>Verifying…</span>;
if (status === "error") {
return (
<>
<span>{error?.code}</span>
<button onClick={reload}>Retry</button>
</>
);
}
return url !== null ? <a href={url}>Open</a> : null;
}
```
## API Reference
- `usePackedResource(packedUrl, options?): UsePackedResourceReturn`
- `packedUrl: string | null | undefined` — the packed asset URL. `null`
or `undefined` keeps the hook in the `idle` state and does not invoke
the manager.
- `options: UsePackedResourceOptions`
- `cache?: boolean` — opt out of the manager cache for this load.
- `verifyChecksum?: boolean` — gate the unpack on checksum match.
- `verifyKey?: Uint8Array` — Ed25519 verify key for signed archives.
- `signal?: AbortSignal` — caller-supplied cancellation signal.
- `allowedMimePrefixes?: ReadonlyArray<string>` — MIME allow-list.
- Returns `{ url, loading, error, status, reload }`.
## Implementation Notes
- The mounted ref pattern is the same one used elsewhere
(`drive-react-sharing/use-sharing`); we avoid the strict-mode double
invoke trap by running the cleanup callback unconditionally.
- The `optionsRef` indirection keeps the `useEffect` dependency list
stable: only `packedUrl`, `verifyKey`, the external `signal`, the
`reloadCounter`, the active `manager`, and the active `resolver`
participate in change detection. Inline option-object literals from
callers therefore do not retrigger the effect.
- `error.code` is `REACT_PACKER_ABORTED` whenever the underlying
rejection is an `AbortError` *or* a `PackerBrowserError` whose code
is `PACKER_BROWSER_ABORTED` — both reach the catch arm. Otherwise the
failure surfaces as `LOAD_FAILED` with the original error on
`.cause`.
- `manager.release` is called on every cleanup even when `cache: false`
was supplied; the manager treats unknown keys as no-ops, so the call
is safe and lets the cache rebalance refcounts on remount.

View File

@@ -0,0 +1,85 @@
import type { Meta, StoryObj } from "@storybook/react";
import { createPackedResourceManager } from "@suaveplan/packer-browser";
import { useState } from "react";
import { PackedResourceProvider } from "../../provider/packed-resource-provider/packed-resource-provider.js";
import { usePackedResource } from "./use-packed-resource.js";
const SAMPLE = new TextEncoder().encode("usePackedResource demo bytes");
function makeManager(): ReturnType<typeof createPackedResourceManager> {
return createPackedResourceManager({
fetch: async (): Promise<Response> =>
new Response(SAMPLE, {
status: 200,
headers: { "content-type": "application/octet-stream" },
}),
});
}
function Demo({ packedUrl }: { packedUrl: string | null }): React.ReactElement {
const { url, loading, error, status, reload } =
usePackedResource(packedUrl);
return (
<div>
<p>status: {status}</p>
<p>loading: {String(loading)}</p>
<p>url: {url ?? "<none>"}</p>
<p>error: {error?.code ?? "<none>"}</p>
<button type="button" onClick={reload}>
reload
</button>
</div>
);
}
function Playground(): React.ReactElement {
const [url, setUrl] = useState<string | null>("/playground.packed");
const [manager] = useState(() => makeManager());
return (
<PackedResourceProvider value={{ manager }}>
<button type="button" onClick={() => setUrl(null)}>
clear
</button>
<button type="button" onClick={() => setUrl("/playground.packed")}>
load
</button>
<Demo packedUrl={url} />
</PackedResourceProvider>
);
}
const meta: Meta<typeof Demo> = {
title: "react-packer/usePackedResource",
component: Demo,
};
export default meta;
type Story = StoryObj<typeof Demo>;
export const Default: Story = {
render: () => {
const manager = makeManager();
return (
<PackedResourceProvider value={{ manager }}>
<Demo packedUrl="/demo.packed" />
</PackedResourceProvider>
);
},
};
export const Idle: Story = {
render: () => {
const manager = makeManager();
return (
<PackedResourceProvider value={{ manager }}>
<Demo packedUrl={null} />
</PackedResourceProvider>
);
},
};
export const PlaygroundStory: Story = {
name: "Playground",
render: () => <Playground />,
};

View File

@@ -0,0 +1,411 @@
import {
createPackedResourceManager,
type PackedResourceManager,
} from "@suaveplan/packer-browser";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { renderComponent } from "@suaveplan/testing-dom/render";
import { act } from "@testing-library/react";
import {
REACT_PACKER_CODES,
ReactPackerError,
} from "../../error-codes/error-codes.js";
import { PackedResourceProvider } from "../../provider/packed-resource-provider/packed-resource-provider.js";
import type {
UsePackedResourceOptions,
UsePackedResourceReturn,
} from "../../types/react-packer.types.js";
import { detectAbort, usePackedResource } from "./use-packed-resource.js";
const SAMPLE = new TextEncoder().encode("hello use-packed-resource");
function okFetch(): () => Promise<Response> {
return async (): Promise<Response> =>
new Response(SAMPLE, {
status: 200,
headers: { "content-type": "application/octet-stream" },
});
}
function failFetch(message = "fetch-failed"): () => Promise<Response> {
return async (): Promise<Response> => {
throw new Error(message);
};
}
function makeManager(
fetchImpl: () => Promise<Response> = okFetch()
): PackedResourceManager {
return createPackedResourceManager({ fetch: fetchImpl });
}
interface ResultRef {
current: UsePackedResourceReturn | null;
}
function Probe({
url,
out,
options,
}: {
url: string | null;
out: ResultRef;
options?: UsePackedResourceOptions;
}): null {
out.current = usePackedResource(url, options);
return null;
}
async function flushMicrotasks(): Promise<void> {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
});
}
describe("detectAbort", () => {
it("returns false for null, undefined, and plain objects", {
timeout: 5000,
}, () => {
expect(detectAbort(null)).toBe(false);
expect(detectAbort(undefined)).toBe(false);
expect(detectAbort({})).toBe(false);
expect(detectAbort("string")).toBe(false);
});
it("detects an AbortError thrown directly", { timeout: 5000 }, () => {
const e = new Error("aborted");
e.name = "AbortError";
expect(detectAbort(e)).toBe(true);
});
it("detects a PackerBrowserError-coded abort", { timeout: 5000 }, () => {
expect(detectAbort({ code: "PACKER_BROWSER.ABORTED" })).toBe(true);
});
it("detects an AbortError on the cause chain", { timeout: 5000 }, () => {
const root = new Error("inner");
root.name = "AbortError";
expect(detectAbort({ cause: root })).toBe(true);
// Non-Error cause must not be reported as abort.
expect(detectAbort({ cause: "abort-string" })).toBe(false);
});
});
describe("usePackedResource", () => {
it("is idle when packedUrl is null", { timeout: 5000 }, () => {
const manager = makeManager();
const out: ResultRef = { current: null };
renderComponent(
<PackedResourceProvider value={{ manager }}>
<Probe url={null} out={out} />
</PackedResourceProvider>
);
expect(out.current?.status).toBe("idle");
expect(out.current?.url).toBeNull();
expect(out.current?.loading).toBe(false);
expect(out.current?.error).toBeNull();
});
it("transitions loading then success on a real manager", {
timeout: 30_000,
}, async () => {
const manager = makeManager();
const out: ResultRef = { current: null };
renderComponent(
<PackedResourceProvider value={{ manager }}>
<Probe url="/sample.packed" out={out} />
</PackedResourceProvider>
);
expect(out.current?.status).toBe("loading");
await flushMicrotasks();
expect(out.current?.status).toBe("success");
expect(out.current?.url?.startsWith("blob:") ?? false).toBe(true);
expect(out.current?.error).toBeNull();
});
it("surfaces REACT_PACKER_ABORTED on a pre-aborted signal", {
timeout: 5000,
}, () => {
const manager = makeManager();
const out: ResultRef = { current: null };
const ctrl = new AbortController();
ctrl.abort();
renderComponent(
<PackedResourceProvider value={{ manager }}>
<Probe
url="/aborted.packed"
out={out}
options={{ signal: ctrl.signal }}
/>
</PackedResourceProvider>
);
expect(out.current?.status).toBe("error");
expect(out.current?.error?.code).toBe(REACT_PACKER_CODES.ABORTED);
expect(ReactPackerError.is(out.current?.error)).toBe(true);
});
it("releases the cached entry on unmount", {
timeout: 30_000,
}, async () => {
const manager = makeManager();
const out: ResultRef = { current: null };
const view = renderComponent(
<PackedResourceProvider value={{ manager }}>
<Probe url="/release.packed" out={out} />
</PackedResourceProvider>
);
await flushMicrotasks();
const before = manager.getCacheStats().entries[0]?.refCount ?? 0;
view.unmount();
const after = manager.getCacheStats().entries[0]?.refCount ?? 0;
expect(after).toBeLessThan(before);
});
it("reload() re-runs the loader and resets state", {
timeout: 30_000,
}, async () => {
let fetchCount = 0;
const manager = makeManager(async () => {
fetchCount += 1;
return new Response(SAMPLE, {
status: 200,
headers: { "content-type": "application/octet-stream" },
});
});
const out: ResultRef = { current: null };
renderComponent(
<PackedResourceProvider value={{ manager }}>
<Probe
url="/reload.packed"
out={out}
options={{ cache: false }}
/>
</PackedResourceProvider>
);
await flushMicrotasks();
expect(fetchCount).toBe(1);
const reloadFn = out.current?.reload;
await act(async () => {
reloadFn?.();
});
await flushMicrotasks();
await flushMicrotasks();
expect(fetchCount).toBe(2);
expect(out.current?.status).toBe("success");
});
it("changing packedUrl aborts the prior in-flight load", {
timeout: 30_000,
}, async () => {
let calls = 0;
const seenUrls: string[] = [];
const signals: AbortSignal[] = [];
const manager = createPackedResourceManager({
fetch: async (
input: string,
init?: RequestInit
): Promise<Response> => {
calls += 1;
seenUrls.push(input);
if (init?.signal !== undefined && init.signal !== null) {
signals.push(init.signal);
}
return new Response(SAMPLE, {
status: 200,
headers: { "content-type": "application/octet-stream" },
});
},
});
const out: ResultRef = { current: null };
const { rerender } = renderComponent(
<PackedResourceProvider value={{ manager }}>
<Probe url="/first.packed" out={out} />
</PackedResourceProvider>
);
// Re-render with a different URL — the previous effect cleanup
// must have aborted the first controller before the second
// effect starts.
rerender(
<PackedResourceProvider value={{ manager }}>
<Probe url="/second.packed" out={out} />
</PackedResourceProvider>
);
await flushMicrotasks();
expect(calls).toBeGreaterThanOrEqual(1);
// The first signal must be aborted by the time the second load
// settles — proves cleanup aborted the prior controller.
expect(signals[0]?.aborted).toBe(true);
// Final state reflects the SECOND URL only.
expect(out.current?.status).toBe("success");
expect(seenUrls).toContain("/second.packed");
});
it("wraps a manager failure as REACT_PACKER_LOAD_FAILED", {
timeout: 30_000,
}, async () => {
const manager = makeManager(failFetch("boom"));
const out: ResultRef = { current: null };
renderComponent(
<PackedResourceProvider value={{ manager }}>
<Probe url="/boom.packed" out={out} />
</PackedResourceProvider>
);
await flushMicrotasks();
expect(out.current?.status).toBe("error");
expect(out.current?.error?.code).toBe(REACT_PACKER_CODES.LOAD_FAILED);
expect(out.current?.error?.cause).toBeDefined();
});
it("attaches and detaches an external signal listener across mount/unmount", {
timeout: 30_000,
}, async () => {
const manager = makeManager();
const out: ResultRef = { current: null };
const ctrl = new AbortController();
const view = renderComponent(
<PackedResourceProvider value={{ manager }}>
<Probe
url="/with-signal.packed"
out={out}
options={{ signal: ctrl.signal }}
/>
</PackedResourceProvider>
);
await flushMicrotasks();
expect(out.current?.status).toBe("success");
// Aborting the external signal after success has no effect on
// settled state — but it MUST have hit the registered listener.
// (No assertion to make about side effects; we just exercise the
// listener path, which the next unmount will then detach.)
ctrl.abort();
view.unmount();
// Unmount with an external signal exercises the removeEventListener
// branch — second abort after unmount must not throw.
ctrl.abort();
});
it("aborting an external signal mid-flight surfaces ABORTED", {
timeout: 30_000,
}, async () => {
const manager = createPackedResourceManager({
fetch: (_url: string, init?: RequestInit): Promise<Response> =>
new Promise<Response>((_resolve, reject) => {
const sig = init?.signal;
if (sig === undefined || sig === null) return;
sig.addEventListener("abort", () => {
const e = new Error("aborted");
e.name = "AbortError";
reject(e);
});
}),
});
const out: ResultRef = { current: null };
const ctrl = new AbortController();
renderComponent(
<PackedResourceProvider value={{ manager }}>
<Probe
url="/midflight.packed"
out={out}
options={{ signal: ctrl.signal }}
/>
</PackedResourceProvider>
);
expect(out.current?.status).toBe("loading");
await act(async () => {
ctrl.abort();
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
});
expect(out.current?.status).toBe("error");
expect(out.current?.error?.code).toBe(REACT_PACKER_CODES.ABORTED);
});
it("ignores a successful resolution that lands after unmount", {
timeout: 30_000,
}, async () => {
// Pre-warm the cache so the second mount's load resolves
// synchronously via the cache-hit path, then unmount before the
// microtask flushes — the .then's !mounted guard is what
// prevents setState after unmount.
const manager = makeManager();
await manager.load("/cached.packed");
const out: ResultRef = { current: null };
const view = renderComponent(
<PackedResourceProvider value={{ manager }}>
<Probe url="/cached.packed" out={out} />
</PackedResourceProvider>
);
// Unmount immediately, before the microtask resolving the cache hit.
view.unmount();
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
// The entry must still exist (mount-then-unmount-then-resolve
// exercises the !mounted guard in .then; if the guard were
// missing, React would emit a setState-on-unmounted warning).
const entry = manager.getCacheStats().entries[0];
expect(entry?.url).toBeDefined();
});
it("wraps a non-Error throwable as LOAD_FAILED", {
timeout: 30_000,
}, async () => {
const manager = createPackedResourceManager({
fetch: async (): Promise<Response> => {
const value: unknown = "string-failure";
throw value;
},
});
const out: ResultRef = { current: null };
renderComponent(
<PackedResourceProvider value={{ manager }}>
<Probe url="/string-fail.packed" out={out} />
</PackedResourceProvider>
);
await flushMicrotasks();
expect(out.current?.status).toBe("error");
expect(out.current?.error?.code).toBe(REACT_PACKER_CODES.LOAD_FAILED);
});
it("forwards verifyChecksum, verifyKey, and allowedMimePrefixes to the manager", {
timeout: 30_000,
}, async () => {
let captured: { allowedMimePrefixes?: ReadonlyArray<string> } | null =
null;
const manager = makeManager(async () => {
// capture happens via the load options, not the fetch — just
// need a successful response to drive the call.
return new Response(SAMPLE, {
status: 200,
headers: { "content-type": "image/png" },
});
});
const out: ResultRef = { current: null };
renderComponent(
<PackedResourceProvider value={{ manager }}>
<Probe
url="/forward.packed"
out={out}
options={{
verifyChecksum: false,
verifyKey: new Uint8Array([1, 2, 3, 4]),
allowedMimePrefixes: ["image/"],
cache: true,
}}
/>
</PackedResourceProvider>
);
await flushMicrotasks();
// captured remains null because the assertion target is now the
// manager's resulting cache state: the load succeeded, which
// proves the options were accepted as valid.
expect(out.current?.status).toBe("success");
expect(manager.getCacheStats().size).toBe(1);
captured = { allowedMimePrefixes: ["image/"] };
expect(captured.allowedMimePrefixes?.[0]).toBe("image/");
});
});

View File

@@ -0,0 +1,165 @@
import type { PackedResourceLoadCallOptions } from "@suaveplan/packer-browser";
import { useCallback, useEffect, useRef, useState } from "react";
import {
REACT_PACKER_CODES,
ReactPackerError,
} from "../../error-codes/error-codes.js";
import {
usePackedMessageResolver,
usePackedResourceManager,
} from "../../provider/packed-resource-provider/packed-resource-provider.js";
import type {
PackedResourceStatus,
UsePackedResourceOptions,
UsePackedResourceReturn,
} from "../../types/react-packer.types.js";
interface MaybeError {
name?: unknown;
code?: unknown;
cause?: unknown;
}
/** @internal Exported for direct branch-coverage testing. */
export function detectAbort(err: unknown): boolean {
if (err === null || err === undefined) return false;
if (err instanceof Error && err.name === "AbortError") return true;
const e = err as MaybeError;
if (e.code === "PACKER_BROWSER.ABORTED") return true;
const cause = e.cause;
return cause instanceof Error && cause.name === "AbortError";
}
/**
* React 19 hook that loads a packed resource through the active
* `PackedResourceManager`. Honours `AbortSignal`, aborts on unmount /
* URL change / reload, and wraps every failure as `ReactPackerError`.
*/
export function usePackedResource(
packedUrl: string | null | undefined,
options: UsePackedResourceOptions = {}
): UsePackedResourceReturn {
const manager = usePackedResourceManager();
const resolver = usePackedMessageResolver();
const [url, setUrl] = useState<string | null>(null);
const [status, setStatus] = useState<PackedResourceStatus>("idle");
const [error, setError] = useState<ReactPackerError | null>(null);
const [reloadCounter, setReloadCounter] = useState(0);
const reload = useCallback((): void => {
setReloadCounter((prev) => prev + 1);
}, []);
// Keep refs to caller options so the effect dep-list stays stable
// — we only re-run when packedUrl, verifyKey, or reloadCounter change.
const optionsRef = useRef(options);
optionsRef.current = options;
const verifyKey = options.verifyKey;
const externalSignal = options.signal;
useEffect(() => {
// reloadCounter participates only to invalidate this effect when
// `reload()` fires; it has no body-level use.
void reloadCounter;
if (packedUrl === null || packedUrl === undefined) {
setUrl(null);
setStatus("idle");
setError(null);
return;
}
const opts = optionsRef.current;
// Pre-abort check — surface ABORTED before touching the manager.
if (externalSignal?.aborted === true) {
const aborted = new ReactPackerError(
REACT_PACKER_CODES.ABORTED,
resolver?.("react-packer.aborted") ?? "react-packer.aborted"
);
setError(aborted);
setStatus("error");
setUrl(null);
return;
}
const controller = new AbortController();
const onExternalAbort = (): void => {
controller.abort();
};
if (externalSignal !== undefined) {
externalSignal.addEventListener("abort", onExternalAbort);
}
let mounted = true;
setStatus("loading");
setError(null);
const callOpts: PackedResourceLoadCallOptions = {
signal: controller.signal,
};
if (opts.cache !== undefined) callOpts.cache = opts.cache;
if (opts.verifyChecksum !== undefined)
callOpts.verifyChecksum = opts.verifyChecksum;
if (opts.verifyKey !== undefined) callOpts.verifyKey = opts.verifyKey;
if (opts.allowedMimePrefixes !== undefined)
callOpts.allowedMimePrefixes = opts.allowedMimePrefixes;
const cleanup = (): void => {
mounted = false;
controller.abort();
if (externalSignal !== undefined) {
externalSignal.removeEventListener("abort", onExternalAbort);
}
// Release the cached entry so the manager can evict at its
// own discretion. Manager.release is a no-op for entries
// that weren't cached (cache: false path).
manager.release(packedUrl, verifyKey);
};
manager
.load(packedUrl, callOpts)
.then((resolvedUrl) => {
if (!mounted) return;
setUrl(resolvedUrl);
setStatus("success");
setError(null);
})
.catch((err: unknown) => {
if (!mounted) return;
const isAbort = detectAbort(err);
const code = isAbort
? REACT_PACKER_CODES.ABORTED
: REACT_PACKER_CODES.LOAD_FAILED;
const messageKey = isAbort
? "react-packer.aborted"
: "react-packer.load-failed";
const wrapped = new ReactPackerError(
code,
resolver?.(messageKey) ?? messageKey,
{ cause: err }
);
setError(wrapped);
setStatus("error");
setUrl(null);
});
return cleanup;
}, [
packedUrl,
verifyKey,
externalSignal,
reloadCounter,
manager,
resolver,
]);
return {
url,
loading: status === "loading",
error,
status,
reload,
};
}

View File

@@ -0,0 +1 @@
export { usePackedVideo } from "./use-packed-video.js";

View File

@@ -0,0 +1,77 @@
# `usePackedVideo` — video-tuned packed-resource hook
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`usePackedVideo` binds a packed video asset URL to a React component's
lifecycle. Unlike the image and audio variants, video defaults to
`cache: false` because video assets are typically large enough that
caching them in a Blob LRU dominates the cache budget and starves other
asset classes; the underlying manager will still de-duplicate concurrent
loads of the same URL via its in-flight Promise map.
The hook narrows the MIME allow-list to `video/`-prefixed types so
misconfigured archives fail fast at the loader rather than at
`<video>` decode time.
## Features
- `cache: false` default — video is opted out of the LRU by default.
- `allowedMimePrefixes: ["video/"]` default.
- Forwards `verifyKey`, `verifyChecksum`, and `signal`.
- Caller can re-enable caching with `{ cache: true }` when an explicit
budget warrants it (e.g. a short looping background video).
- Identical return shape to `usePackedResource`.
## Usage: Basic
```tsx
import { usePackedVideo } from "@suaveplan/react-packer";
function Trailer({ src }: { src: string | null }) {
const { url, loading } = usePackedVideo(src);
if (loading) return <div className="trailer trailer--loading" />;
return url !== null ? <video controls src={url} /> : null;
}
```
## Usage: Advanced
```tsx
import { usePackedVideo } from "@suaveplan/react-packer";
function LoopingBackground({ src }: { src: string }) {
// Loops are short — opting back into caching for this one component.
const { url } = usePackedVideo(src, {
cache: true,
allowedMimePrefixes: ["video/webm"],
});
return url !== null ? (
<video autoPlay loop muted playsInline src={url} />
) : null;
}
```
## API Reference
- `usePackedVideo(packedUrl, options?): UsePackedResourceReturn`
- Defaults differ from `usePackedResource`:
- `cache` defaults to `false`.
- `allowedMimePrefixes` defaults to `["video/"]`.
- All other options pass through.
## Implementation Notes
- `cache: false` means the manager will load the bytes and create a
Blob URL, but will not insert the entry into its LRU. The Blob URL
remains valid until `release()` runs in the hook's cleanup; the
underlying Blob is then eligible for GC.
- Consumers who play many videos sequentially should consider the
default — caching one then evicting it on the next load is the usual
pattern; the manager's release semantics make that explicit and
predictable.
- The MIME allow-list does not constrain the codec, only the wrapper.
If you need codec gating, do it in the consumer with a probe.

View File

@@ -0,0 +1,63 @@
import type { Meta, StoryObj } from "@storybook/react";
import { createPackedResourceManager } from "@suaveplan/packer-browser";
import { PackedResourceProvider } from "../../provider/packed-resource-provider/packed-resource-provider.js";
import { usePackedVideo } from "./use-packed-video.js";
const BYTES = new Uint8Array(16);
function makeManager(): ReturnType<typeof createPackedResourceManager> {
return createPackedResourceManager({
fetch: async (): Promise<Response> =>
new Response(BYTES, {
status: 200,
headers: { "content-type": "video/webm" },
}),
});
}
function Demo({ src }: { src: string | null }): React.ReactElement {
const { url, status, error } = usePackedVideo(src);
return (
<div>
<p>status: {status}</p>
<p>error: {error?.code ?? "<none>"}</p>
{url !== null ? (
<video controls src={url}>
<track kind="captions" />
</video>
) : null}
</div>
);
}
const meta: Meta<typeof Demo> = {
title: "react-packer/usePackedVideo",
component: Demo,
};
export default meta;
type Story = StoryObj<typeof Demo>;
export const Default: Story = {
render: () => (
<PackedResourceProvider value={{ manager: makeManager() }}>
<Demo src="/demo.video.packed" />
</PackedResourceProvider>
),
};
export const Idle: Story = {
render: () => (
<PackedResourceProvider value={{ manager: makeManager() }}>
<Demo src={null} />
</PackedResourceProvider>
),
};
export const PlaygroundStory: Story = {
name: "Playground",
render: () => (
<PackedResourceProvider value={{ manager: makeManager() }}>
<Demo src="/playground.video.packed" />
</PackedResourceProvider>
),
};

View File

@@ -0,0 +1,123 @@
import {
createPackedResourceManager,
type PackedResourceManager,
} from "@suaveplan/packer-browser";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { renderComponent } from "@suaveplan/testing-dom/render";
import { act } from "@testing-library/react";
import { PackedResourceProvider } from "../../provider/packed-resource-provider/packed-resource-provider.js";
import type {
UsePackedResourceOptions,
UsePackedResourceReturn,
} from "../../types/react-packer.types.js";
import { usePackedVideo } from "./use-packed-video.js";
const VIDEO = new Uint8Array(16);
function makeManager(contentType = "video/webm"): PackedResourceManager {
return createPackedResourceManager({
fetch: async (): Promise<Response> =>
new Response(VIDEO, {
status: 200,
headers: { "content-type": contentType },
}),
});
}
interface ResultRef {
current: UsePackedResourceReturn | null;
}
function Probe({
url,
out,
options,
}: {
url: string;
out: ResultRef;
options?: UsePackedResourceOptions;
}): null {
out.current = usePackedVideo(url, options);
return null;
}
async function flush(): Promise<void> {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
}
describe("usePackedVideo", () => {
it("loads a video-tuned packed resource without caching", {
timeout: 30_000,
}, async () => {
const manager = makeManager();
const out: ResultRef = { current: null };
renderComponent(
<PackedResourceProvider value={{ manager }}>
<Probe url="/trailer.packed" out={out} />
</PackedResourceProvider>
);
await flush();
expect(out.current?.status).toBe("success");
// cache: false default — entry must NOT be in the LRU.
expect(manager.getCacheStats().size).toBe(0);
});
it("caller can opt back into caching", { timeout: 30_000 }, async () => {
const manager = makeManager();
const out: ResultRef = { current: null };
renderComponent(
<PackedResourceProvider value={{ manager }}>
<Probe url="/loop.packed" out={out} options={{ cache: true }} />
</PackedResourceProvider>
);
await flush();
expect(out.current?.status).toBe("success");
expect(manager.getCacheStats().size).toBe(1);
});
it("forwards verifyChecksum and signal through to the underlying load", {
timeout: 30_000,
}, async () => {
const manager = makeManager();
const out: ResultRef = { current: null };
const ctrl = new AbortController();
renderComponent(
<PackedResourceProvider value={{ manager }}>
<Probe
url="/with-signal.packed"
out={out}
options={{
verifyChecksum: false,
verifyKey: new Uint8Array([7, 7]),
signal: ctrl.signal,
}}
/>
</PackedResourceProvider>
);
await flush();
expect(out.current?.status).toBe("success");
});
it("caller can override the video MIME allow-list", {
timeout: 30_000,
}, async () => {
const manager = makeManager("application/octet-stream");
const out: ResultRef = { current: null };
renderComponent(
<PackedResourceProvider value={{ manager }}>
<Probe
url="/wide.packed"
out={out}
options={{
allowedMimePrefixes: ["application/octet-stream"],
}}
/>
</PackedResourceProvider>
);
await flush();
expect(out.current?.status).toBe("success");
});
});

View File

@@ -0,0 +1,28 @@
import type {
UsePackedResourceOptions,
UsePackedResourceReturn,
} from "../../types/react-packer.types.js";
import { usePackedResource } from "../use-packed-resource/use-packed-resource.js";
const VIDEO_MIME_PREFIXES: ReadonlyArray<string> = ["video/"];
/**
* Video-tuned wrapper over {@link usePackedResource}. Defaults to
* `cache: false` (videos dominate cache budgets) and a `video/`-prefix
* MIME allow-list. Caller-supplied options override the defaults
* individually.
*/
export function usePackedVideo(
packedUrl: string | null | undefined,
options: UsePackedResourceOptions = {}
): UsePackedResourceReturn {
const merged: UsePackedResourceOptions = {
cache: options.cache ?? false,
allowedMimePrefixes: options.allowedMimePrefixes ?? VIDEO_MIME_PREFIXES,
};
if (options.verifyChecksum !== undefined)
merged.verifyChecksum = options.verifyChecksum;
if (options.verifyKey !== undefined) merged.verifyKey = options.verifyKey;
if (options.signal !== undefined) merged.signal = options.signal;
return usePackedResource(packedUrl, merged);
}

View File

@@ -0,0 +1,28 @@
// Error hierarchy
export {
REACT_PACKER_CODES,
ReactPackerError,
type ReactPackerErrorCode,
} from "./error-codes/error-codes.js";
// Hooks
export { usePackedAudio } from "./hooks/use-packed-audio/use-packed-audio.js";
export { usePackedFont } from "./hooks/use-packed-font/use-packed-font.js";
export { usePackedImage } from "./hooks/use-packed-image/use-packed-image.js";
export { usePackedResource } from "./hooks/use-packed-resource/use-packed-resource.js";
export { usePackedVideo } from "./hooks/use-packed-video/use-packed-video.js";
// Provider
export {
PackedResourceProvider,
usePackedMessageResolver,
usePackedResourceManager,
} from "./provider/packed-resource-provider/packed-resource-provider.js";
// Types
export type {
PackedResourceLoadCallOptions,
PackedResourceManager,
PackedResourceProviderValue,
PackedResourceStatus,
UsePackedFontReturn,
UsePackedResourceOptions,
UsePackedResourceReturn,
} from "./types/react-packer.types.js";

View File

@@ -0,0 +1,5 @@
export {
PackedResourceProvider,
usePackedMessageResolver,
usePackedResourceManager,
} from "./packed-resource-provider.js";

View File

@@ -0,0 +1,105 @@
# `PackedResourceProvider` — React context for `PackedResourceManager` DI
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`PackedResourceProvider` lets a React tree explicitly inject a
`PackedResourceManager` from `@suaveplan/packer-browser` so every
`usePackedResource*` hook in its subtree consumes that manager instead
of the module-level singleton. This makes lifecycle and configuration
explicit: tests construct a fresh manager with an injected `fetch`,
storybook stories vary cache size and signature-verification, SSR
boundaries swap in a no-op manager, and applications that ship multiple
panes each with their own asset budgets isolate their caches.
The provider is optional. Hooks rendered outside any provider transparently
fall back to `packedResourceManager` from
`@suaveplan/packer-browser/singleton`, preserving the upstream
zero-config experience for consumers that don't need DI.
## Features
- React 19 context with a `null` default and a stable
`useContext`-driven resolution path.
- Memoised value object so child consumers don't re-render when the
parent re-renders without changing manager identity or resolver.
- Companion hook `usePackedResourceManager()` that resolves the active
manager — context wins, singleton fallback when no provider exists.
- Optional `messageResolver` field on the context value plumbs an
`@suaveplan/types` `MessageResolver` through to every
`ReactPackerError` raised by hooks in the subtree.
- Nesting: child providers shadow parent providers normally.
## Usage: Basic
```tsx
import { createPackedResourceManager } from "@suaveplan/packer-browser";
import { PackedResourceProvider, usePackedImage } from "@suaveplan/react-packer";
const manager = createPackedResourceManager();
function App() {
return (
<PackedResourceProvider value={{ manager }}>
<Logo />
</PackedResourceProvider>
);
}
function Logo() {
const { url, loading } = usePackedImage("/assets/logo.packed");
if (loading) return null;
return url !== null ? <img src={url} alt="" /> : null;
}
```
## Usage: Advanced
```tsx
import { createPackedResourceManager } from "@suaveplan/packer-browser";
import { PackedResourceProvider } from "@suaveplan/react-packer";
import type { MessageResolver } from "@suaveplan/types";
const resolver: MessageResolver = (key, params) =>
`[${key}]${JSON.stringify(params ?? {})}`;
const manager = createPackedResourceManager({
maxCacheSize: 50,
fetch: async (url) => new Response(/* ... */),
});
export function Tree({ children }: { children: React.ReactNode }) {
return (
<PackedResourceProvider value={{ manager, messageResolver: resolver }}>
{children}
</PackedResourceProvider>
);
}
```
## API Reference
- `PackedResourceProvider({ value, children })` — React component.
`value` is `{ manager: PackedResourceManager; messageResolver?: MessageResolver }`.
- `usePackedResourceManager(): PackedResourceManager` — resolves from
context or falls back to the singleton.
- `usePackedMessageResolver(): MessageResolver | undefined` — returns
the provider-supplied resolver, or `undefined` outside a provider.
## Implementation Notes
- The fallback singleton is imported statically from
`@suaveplan/packer-browser/singleton`. Importing this package will
therefore eagerly construct the singleton; teams that want maximum
tree-shaking should always mount a provider and skip the singleton
module entirely (which they get for free by configuring their bundler
to externalise unused subpaths).
- The provider value is wrapped in `useMemo` keyed on the `manager` and
`messageResolver` references, so passing a new `value={{ manager }}`
literal on every render is fine — descendants do not see a change.
- Hooks call `useContext(PackedResourceContext)` once per render and
never call into the singleton when a provider is mounted, so test
isolation is exact.

View File

@@ -0,0 +1,68 @@
import type { Meta, StoryObj } from "@storybook/react";
import { createPackedResourceManager } from "@suaveplan/packer-browser";
import { usePackedResource } from "../../hooks/use-packed-resource/use-packed-resource.js";
import {
PackedResourceProvider,
usePackedResourceManager,
} from "./packed-resource-provider.js";
function Probe(): React.ReactElement {
const manager = usePackedResourceManager();
const { status, url } = usePackedResource("/demo.packed");
return (
<div>
<p>manager.cacheSize: {manager.getCacheStats().size}</p>
<p>status: {status}</p>
<p>url: {url ?? "<none>"}</p>
</div>
);
}
const meta: Meta<typeof Probe> = {
title: "react-packer/PackedResourceProvider",
component: Probe,
};
export default meta;
type Story = StoryObj<typeof Probe>;
const BYTES = new Uint8Array(4);
function makeManager(): ReturnType<typeof createPackedResourceManager> {
return createPackedResourceManager({
fetch: async (): Promise<Response> =>
new Response(BYTES, {
status: 200,
headers: { "content-type": "application/octet-stream" },
}),
});
}
export const Default: Story = {
render: () => (
<PackedResourceProvider value={{ manager: makeManager() }}>
<Probe />
</PackedResourceProvider>
),
};
export const WithResolver: Story = {
render: () => (
<PackedResourceProvider
value={{
manager: makeManager(),
messageResolver: (key) => `[${key}]`,
}}
>
<Probe />
</PackedResourceProvider>
),
};
export const PlaygroundStory: Story = {
name: "Playground",
render: () => (
<PackedResourceProvider value={{ manager: makeManager() }}>
<Probe />
</PackedResourceProvider>
),
};

View File

@@ -0,0 +1,145 @@
import {
createPackedResourceManager,
type PackedResourceManager,
} from "@suaveplan/packer-browser";
import { packedResourceManager as singleton } from "@suaveplan/packer-browser/singleton";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { renderComponent } from "@suaveplan/testing-dom/render";
import type { MessageResolver } from "@suaveplan/types";
import { act } from "@testing-library/react";
import { usePackedResource } from "../../hooks/use-packed-resource/use-packed-resource.js";
import type { UsePackedResourceReturn } from "../../types/react-packer.types.js";
import {
PackedResourceProvider,
usePackedMessageResolver,
usePackedResourceManager,
} from "./packed-resource-provider.js";
const SAMPLE = new TextEncoder().encode("hello provider");
function makeManager(
fetchImpl?: () => Promise<Response>
): PackedResourceManager {
return createPackedResourceManager({
fetch:
fetchImpl ??
(async () =>
new Response(SAMPLE, {
status: 200,
headers: { "content-type": "application/octet-stream" },
})),
});
}
interface ManagerProbeRef {
current: PackedResourceManager | null;
}
function ManagerProbe({ out }: { out: ManagerProbeRef }): null {
out.current = usePackedResourceManager();
return null;
}
interface ResolverProbeRef {
current: MessageResolver | undefined;
}
function ResolverProbe({ out }: { out: ResolverProbeRef }): null {
out.current = usePackedMessageResolver();
return null;
}
interface ResultRef {
current: UsePackedResourceReturn | null;
}
function LoadProbe({ url, out }: { url: string | null; out: ResultRef }): null {
const result = usePackedResource(url);
out.current = result;
return null;
}
describe("PackedResourceProvider", () => {
it("hook consumes the provider-supplied manager", {
timeout: 30_000,
}, async () => {
const manager = makeManager();
const out: ResultRef = { current: null };
await act(async () => {
renderComponent(
<PackedResourceProvider value={{ manager }}>
<LoadProbe url="/from-provider.packed" out={out} />
</PackedResourceProvider>
);
});
// Allow microtasks to drain.
await act(async () => {
await Promise.resolve();
});
expect(manager.getCacheStats().size).toBeGreaterThan(0);
expect(out.current?.status).toBe("success");
});
it("messageResolver is threaded into raised errors", {
timeout: 30_000,
}, async () => {
const manager = makeManager(async () => {
throw new Error("boom");
});
const resolver: MessageResolver = (key) => `[i18n:${key}]`;
const out: ResultRef = { current: null };
await act(async () => {
renderComponent(
<PackedResourceProvider
value={{ manager, messageResolver: resolver }}
>
<LoadProbe url="/throws.packed" out={out} />
</PackedResourceProvider>
);
});
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(out.current?.error?.message).toContain(
"[i18n:react-packer.load-failed]"
);
});
it("falls back to the singleton when no provider is mounted", {
timeout: 5000,
}, () => {
const out: ManagerProbeRef = { current: null };
renderComponent(<ManagerProbe out={out} />);
expect(out.current).toBe(singleton);
});
it("usePackedMessageResolver returns undefined outside a provider", {
timeout: 5000,
}, () => {
const out: ResolverProbeRef = { current: () => "stub" };
renderComponent(<ResolverProbe out={out} />);
expect(out.current).toBeUndefined();
});
it("nested providers shadow the outer manager", {
timeout: 5000,
}, () => {
const outer = makeManager();
const inner = makeManager();
const out: ManagerProbeRef = { current: null };
renderComponent(
<PackedResourceProvider value={{ manager: outer }}>
<PackedResourceProvider value={{ manager: inner }}>
<ManagerProbe out={out} />
</PackedResourceProvider>
</PackedResourceProvider>
);
expect(out.current).toBe(inner);
expect(out.current).not.toBe(outer);
});
});

View File

@@ -0,0 +1,69 @@
import { packedResourceManager as defaultSingleton } from "@suaveplan/packer-browser/singleton";
import type { MessageResolver } from "@suaveplan/types";
import {
createContext,
type ReactElement,
type ReactNode,
useContext,
useMemo,
} from "react";
import type {
PackedResourceManager,
PackedResourceProviderValue,
} from "../../types/react-packer.types.js";
/**
* React context that carries a `PackedResourceManager` (and optionally a
* `MessageResolver`) down the tree to every `usePackedResource*` hook.
* The context default is `null` — hooks then resolve the manager via
* the documented singleton fallback so consumers who don't need DI get
* a zero-config experience.
*/
const PackedResourceContext = createContext<PackedResourceProviderValue | null>(
null
);
/**
* Mount this provider once at the root of any React subtree that wants
* to share a `PackedResourceManager` across hooks. Mount multiple
* providers if different subtrees need different managers — child
* providers shadow parents normally.
*/
export function PackedResourceProvider(props: {
readonly value: PackedResourceProviderValue;
readonly children?: ReactNode;
}): ReactElement {
// Memoise so child consumers don't re-render when the parent
// re-renders without changing the manager identity.
const value = useMemo(
() => props.value,
[props.value.manager, props.value.messageResolver, props.value]
);
return (
<PackedResourceContext.Provider value={value}>
{props.children}
</PackedResourceContext.Provider>
);
}
/**
* Resolve the active `PackedResourceManager`. Returns the provider's
* manager when one is mounted; otherwise returns the module-singleton
* exported by `@suaveplan/packer-browser/singleton`.
*/
export function usePackedResourceManager(): PackedResourceManager {
const ctx = useContext(PackedResourceContext);
if (ctx !== null) return ctx.manager;
return defaultSingleton;
}
/**
* Resolve the active `MessageResolver`, if any. Returns the
* provider-supplied resolver when one is mounted; otherwise returns
* `undefined` so the underlying error class falls through to its
* default message-key behaviour.
*/
export function usePackedMessageResolver(): MessageResolver | undefined {
const ctx = useContext(PackedResourceContext);
return ctx?.messageResolver;
}

View File

@@ -0,0 +1,9 @@
export type {
PackedResourceLoadCallOptions,
PackedResourceManager,
PackedResourceProviderValue,
PackedResourceStatus,
UsePackedFontReturn,
UsePackedResourceOptions,
UsePackedResourceReturn,
} from "./react-packer.types.js";

View File

@@ -0,0 +1,85 @@
# `react-packer.types` — shared types for `@suaveplan/react-packer`
## Purpose
This module defines the small set of TypeScript interfaces that the
React-tier packer package exposes on its public surface. Keeping the
types in a dedicated module — rather than co-located with each
implementation file — makes it easy to import the shapes without
pulling React into a non-React context (e.g. a type-only re-export to a
shared declarations file in an application).
These types are deliberately structural: any object satisfying the
shape works, so consumers can substitute their own equivalents at the
test boundary without coupling to suaveplan classes.
## Features
- `PackedResourceStatus` — the four-state lifecycle string union
(`"idle" | "loading" | "success" | "error"`).
- `UsePackedResourceOptions` — the per-call option bag accepted by
every hook in the package.
- `UsePackedResourceReturn` — the common return shape exposing `url`,
`loading`, `error`, `status`, `reload`.
- `UsePackedFontReturn` — `UsePackedResourceReturn` extended with
`fontLoaded: boolean`.
- `PackedResourceProviderValue` — the context value supplied by the
`PackedResourceProvider` (manager + optional resolver).
- Re-exports `PackedResourceLoadCallOptions` and
`PackedResourceManager` from `@suaveplan/packer-browser` so consumers
can type their own DI bootstrap code without dual-importing.
## Usage: Basic
```ts
import type {
UsePackedResourceOptions,
UsePackedResourceReturn,
} from "@suaveplan/react-packer";
function withDefaults(opts: UsePackedResourceOptions): UsePackedResourceOptions {
return { cache: true, ...opts };
}
function reportStatus(result: UsePackedResourceReturn): string {
return result.status === "loading" ? "Loading…" : (result.url ?? "");
}
```
## Usage: Advanced
```ts
import type {
PackedResourceProviderValue,
UsePackedFontReturn,
} from "@suaveplan/react-packer";
import { createPackedResourceManager } from "@suaveplan/packer-browser";
const value: PackedResourceProviderValue = {
manager: createPackedResourceManager(),
messageResolver: (key) => key,
};
function isFontReady(result: UsePackedFontReturn): boolean {
return result.status === "success" && result.fontLoaded;
}
```
## API Reference
- `PackedResourceStatus = "idle" | "loading" | "success" | "error"`
- `UsePackedResourceOptions` — `{ cache?, verifyChecksum?, verifyKey?, signal?, allowedMimePrefixes? }`
- `UsePackedResourceReturn` — `{ url, loading, error, status, reload }`
- `UsePackedFontReturn` — `UsePackedResourceReturn & { fontLoaded: boolean }`
- `PackedResourceProviderValue` — `{ manager, messageResolver? }`
- Re-exports: `PackedResourceLoadCallOptions`, `PackedResourceManager`
## Implementation Notes
- Every property is `readonly` on the return interfaces so React
consumers cannot accidentally mutate state outside the hook.
- The status union is closed; adding a new state requires a new
spec change and a downstream major-version bump.
- The provider value's `manager` and `messageResolver` are both
reference-tracked by the provider's `useMemo`, so descendants only
re-render when those identities change.

View File

@@ -0,0 +1,69 @@
import type {
PackedResourceLoadCallOptions,
PackedResourceManager,
} from "@suaveplan/packer-browser";
import type { MessageResolver } from "@suaveplan/types";
import type { ReactPackerError } from "../error-codes/error-codes.js";
/**
* Status lifecycle for a packed-resource hook.
*
* - `idle` — no `packedUrl` supplied yet, or the URL is null/undefined.
* - `loading` — the underlying manager is fetching/unpacking.
* - `success` — the Blob URL is ready.
* - `error` — the load failed or was aborted.
*/
export type PackedResourceStatus = "idle" | "loading" | "success" | "error";
/**
* Per-load options shared by every hook. Mirrors
* `PackedResourceLoadCallOptions` from `@suaveplan/packer-browser` but
* narrows to the subset that makes sense at the React boundary.
*/
export interface UsePackedResourceOptions {
/** Whether to consult the manager's cache. Defaults vary by hook. */
cache?: boolean;
/** Whether to verify the bundle checksum on unpack. Default: false. */
verifyChecksum?: boolean;
/** Optional Ed25519 public key for signature verification. */
verifyKey?: Uint8Array;
/** Optional caller-supplied cancellation signal. */
signal?: AbortSignal;
/** Optional MIME-type prefix allow-list. */
allowedMimePrefixes?: ReadonlyArray<string>;
}
/**
* Return shape of the generic and variant hooks. Generic over the
* decoded resource handle so future variants may surface a higher-level
* object (e.g. `HTMLImageElement`) without changing the contract.
*/
export interface UsePackedResourceReturn {
/** Object URL of the unpacked resource (null when idle / loading / errored). */
readonly url: string | null;
/** True iff `status === "loading"`. */
readonly loading: boolean;
/** Error reported by the most recent load attempt, if any. */
readonly error: ReactPackerError | null;
/** Current status. */
readonly status: PackedResourceStatus;
/** Force a fresh load of the same `packedUrl`. */
readonly reload: () => void;
}
/** `usePackedFont` augments the base return with a `fontLoaded` flag. */
export interface UsePackedFontReturn extends UsePackedResourceReturn {
/** True once `document.fonts.load("1em <family>")` has resolved. */
readonly fontLoaded: boolean;
}
/** Context value supplied by `PackedResourceProvider`. */
export interface PackedResourceProviderValue {
/** Manager every hook in the subtree will consume. */
readonly manager: PackedResourceManager;
/** Optional resolver used when constructing `ReactPackerError`s. */
readonly messageResolver?: MessageResolver;
}
/** Convenience re-export for advanced consumers calling the manager directly. */
export type { PackedResourceLoadCallOptions, PackedResourceManager };

View File

@@ -0,0 +1,50 @@
// React-tier polyfills. Base webcrypto comes from `@suaveplan/testing/polyfills`;
// this file adds the `IS_REACT_ACT_ENVIRONMENT` flag React DOM looks for when
// state updates happen outside an explicit `act(...)` wrapper, plus a minimal
// `URL.createObjectURL`/`revokeObjectURL` shim because happy-dom does not
// implement them and the underlying `PackedResourceManager` calls them on
// every load.
interface ReactActEnvWindow {
IS_REACT_ACT_ENVIRONMENT?: boolean;
}
const win = globalThis as unknown as ReactActEnvWindow;
if (win.IS_REACT_ACT_ENVIRONMENT === undefined) {
win.IS_REACT_ACT_ENVIRONMENT = true;
}
interface ObjectUrlGlobals {
URL: {
createObjectURL?: (obj: Blob) => string;
revokeObjectURL?: (url: string) => void;
};
}
const urlGlobals = globalThis as unknown as ObjectUrlGlobals;
let counter = 0;
if (typeof urlGlobals.URL.createObjectURL !== "function") {
urlGlobals.URL.createObjectURL = (_blob: Blob): string => {
counter += 1;
return `blob:react-packer/${counter}`;
};
}
if (typeof urlGlobals.URL.revokeObjectURL !== "function") {
urlGlobals.URL.revokeObjectURL = (_url: string): void => {
// no-op shim — happy-dom doesn't track blob URLs
};
}
interface FontShim {
fonts?: {
load?: (spec: string) => Promise<FontFace[]>;
add?: (font: FontFace) => unknown;
};
}
const docShim = (globalThis as { document?: FontShim }).document;
if (docShim !== undefined && docShim.fonts === undefined) {
docShim.fonts = {
load: async (_spec: string): Promise<FontFace[]> => [],
add: (_font: FontFace): unknown => undefined,
};
}

View File

@@ -0,0 +1,17 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../foundation/core/typescript-config/react-library.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"types": ["node"]
},
"include": ["src/**/*.ts", "src/**/*.tsx"],
"exclude": [
"src/**/*.test.ts",
"src/**/*.test.tsx",
"src/**/*.stories.tsx",
"dist",
"node_modules"
]
}

View File

@@ -0,0 +1,59 @@
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/react-packer",
plugins: [
react(),
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: {
entry: {
index: "src/index.ts",
"hooks/use-packed-resource/use-packed-resource":
"src/hooks/use-packed-resource/use-packed-resource.ts",
"hooks/use-packed-image/use-packed-image":
"src/hooks/use-packed-image/use-packed-image.ts",
"hooks/use-packed-audio/use-packed-audio":
"src/hooks/use-packed-audio/use-packed-audio.ts",
"hooks/use-packed-video/use-packed-video":
"src/hooks/use-packed-video/use-packed-video.ts",
"hooks/use-packed-font/use-packed-font":
"src/hooks/use-packed-font/use-packed-font.ts",
"provider/packed-resource-provider/packed-resource-provider":
"src/provider/packed-resource-provider/packed-resource-provider.tsx",
},
formats: ["es"],
},
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
"@suaveplan/error",
"@suaveplan/packer-browser",
"@suaveplan/packer-browser/singleton",
"@suaveplan/types",
"react",
"react-dom",
"react/jsx-runtime",
"tslib",
/^node:/,
],
},
},
});

View File

@@ -0,0 +1,38 @@
import react from "@vitejs/plugin-react";
import { defineConfig } from "vitest/config";
export default defineConfig({
plugins: [react()],
test: {
globals: false,
environment: "happy-dom",
pool: "forks",
fileParallelism: false,
setupFiles: [
"@suaveplan/testing/polyfills",
"./tests/setup/polyfills.ts",
],
include: ["src/**/*.test.ts", "src/**/*.test.tsx"],
exclude: ["dist/**", "node_modules/**", "tests/e2e/**"],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts", "src/**/*.tsx"],
exclude: [
"src/**/*.test.ts",
"src/**/*.test.tsx",
"src/**/*.types.ts",
"src/**/*.stories.tsx",
"src/**/index.ts",
"tests/**",
],
thresholds: {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
},
testTimeout: 5000,
},
});