Initial Commit
This commit is contained in:
92
packages/foundation/data/react-packer/README.md
Normal file
92
packages/foundation/data/react-packer/README.md
Normal 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)
|
||||
|
||||
113
packages/foundation/data/react-packer/package.json
Normal file
113
packages/foundation/data/react-packer/package.json
Normal 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"
|
||||
]
|
||||
}
|
||||
21
packages/foundation/data/react-packer/playwright.config.ts
Normal file
21
packages/foundation/data/react-packer/playwright.config.ts
Normal 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",
|
||||
},
|
||||
});
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export type { ReactPackerErrorCode } from "./error-codes.js";
|
||||
export {
|
||||
REACT_PACKER_CODES,
|
||||
ReactPackerError,
|
||||
} from "./error-codes.js";
|
||||
@@ -0,0 +1 @@
|
||||
export { usePackedAudio } from "./use-packed-audio.js";
|
||||
@@ -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.
|
||||
@@ -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>
|
||||
),
|
||||
};
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { usePackedFont } from "./use-packed-font.js";
|
||||
@@ -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.
|
||||
@@ -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>
|
||||
),
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { usePackedImage } from "./use-packed-image.js";
|
||||
@@ -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.
|
||||
@@ -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>
|
||||
),
|
||||
};
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { usePackedResource } from "./use-packed-resource.js";
|
||||
@@ -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.
|
||||
@@ -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 />,
|
||||
};
|
||||
@@ -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/");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { usePackedVideo } from "./use-packed-video.js";
|
||||
@@ -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.
|
||||
@@ -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>
|
||||
),
|
||||
};
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
28
packages/foundation/data/react-packer/src/index.ts
Normal file
28
packages/foundation/data/react-packer/src/index.ts
Normal 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";
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
PackedResourceProvider,
|
||||
usePackedMessageResolver,
|
||||
usePackedResourceManager,
|
||||
} from "./packed-resource-provider.js";
|
||||
@@ -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.
|
||||
@@ -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>
|
||||
),
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
9
packages/foundation/data/react-packer/src/types/index.ts
Normal file
9
packages/foundation/data/react-packer/src/types/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export type {
|
||||
PackedResourceLoadCallOptions,
|
||||
PackedResourceManager,
|
||||
PackedResourceProviderValue,
|
||||
PackedResourceStatus,
|
||||
UsePackedFontReturn,
|
||||
UsePackedResourceOptions,
|
||||
UsePackedResourceReturn,
|
||||
} from "./react-packer.types.js";
|
||||
@@ -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.
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
17
packages/foundation/data/react-packer/tsconfig.lib.json
Normal file
17
packages/foundation/data/react-packer/tsconfig.lib.json
Normal 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"
|
||||
]
|
||||
}
|
||||
59
packages/foundation/data/react-packer/vite.config.ts
Normal file
59
packages/foundation/data/react-packer/vite.config.ts
Normal 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:/,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
38
packages/foundation/data/react-packer/vitest.config.ts
Normal file
38
packages/foundation/data/react-packer/vitest.config.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user