Initial Commit
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
# `hooks/use-engine-state`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`useEngineState()` reports the `<AudioProvider>`'s async-init
|
||||
lifecycle phase as one of `'pending' | 'ready' | 'error'`. It is the
|
||||
only engine-aware hook that does **not** throw on the pending phase —
|
||||
its purpose is to *report* that phase so consumers can render a
|
||||
loading state or gate child trees on engine readiness.
|
||||
|
||||
The hook still throws `ReactAudioError(REACT_AUDIO.NO_PROVIDER)` when
|
||||
rendered outside an `<AudioProvider>` ancestor, mirroring the
|
||||
contract every public hook honours.
|
||||
|
||||
## Features
|
||||
|
||||
- Returns `'pending'`, `'ready'`, or `'error'`.
|
||||
- Throws `REACT_AUDIO.NO_PROVIDER` outside the provider.
|
||||
- Triggers re-render when the engine resolves (via the provider's
|
||||
internal `setEngineState`).
|
||||
- Stable identity across re-renders for the same lifecycle phase.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```tsx
|
||||
import { useEngineState } from "@suaveplan/react-audio";
|
||||
|
||||
function App() {
|
||||
const engineState = useEngineState();
|
||||
if (engineState === "pending") return <p>Loading audio…</p>;
|
||||
if (engineState === "error") return <p>Audio failed to initialise.</p>;
|
||||
return <Workspace />;
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```tsx
|
||||
import { useEngineState } from "@suaveplan/react-audio";
|
||||
|
||||
function useIsReady(): boolean {
|
||||
return useEngineState() === "ready";
|
||||
}
|
||||
|
||||
function Gate({ children }: { children: React.ReactNode }) {
|
||||
const ready = useIsReady();
|
||||
return ready ? <>{children}</> : <Spinner />;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `useEngineState(): 'pending' | 'ready' | 'error'`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`useEngineState` reads the provider's React context directly rather
|
||||
than going through `useSyncExternalStore`. React already batches state
|
||||
updates inside the provider, so reading the context value is
|
||||
sufficient and avoids the indirection of a snapshot+subscribe pair.
|
||||
The hook returns the same `engineState` string instance across
|
||||
renders within the same phase, so a `useMemo` keyed on it is a
|
||||
constant-time identity check. The `'error'` state is reachable when
|
||||
the engine's async init rejects (e.g. the browser refuses to start
|
||||
an `AudioContext`). The provider's error object is exposed on the
|
||||
context value too — consumers needing the underlying failure can
|
||||
combine `useEngineState() === 'error'` with `useAudioEngine` or a
|
||||
direct context read. The hook is the ONE hook in this package that
|
||||
deliberately does NOT throw on the pending phase.
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import {
|
||||
REACT_AUDIO_CODES,
|
||||
ReactAudioError,
|
||||
} from "../../error-codes/error-codes.js";
|
||||
import { AudioProvider } from "../../provider/audio-provider.js";
|
||||
import { makeFakeAudioContext } from "../../test-utils.js";
|
||||
import { useEngineState } from "./use-engine-state.js";
|
||||
|
||||
describe("useEngineState", () => {
|
||||
it("hook outside provider throws REACT_AUDIO.NO_PROVIDER", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
let caught: unknown;
|
||||
try {
|
||||
renderHook(() => useEngineState());
|
||||
} catch (e) {
|
||||
caught = e;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(ReactAudioError);
|
||||
expect((caught as ReactAudioError).code).toBe(
|
||||
REACT_AUDIO_CODES.NO_PROVIDER
|
||||
);
|
||||
});
|
||||
|
||||
it("reports pending then ready across the init lifecycle", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const { result } = renderHook(() => useEngineState(), {
|
||||
wrapper: ({ children }) => (
|
||||
<AudioProvider
|
||||
options={{ adapter: { context: makeFakeAudioContext() } }}
|
||||
>
|
||||
{children}
|
||||
</AudioProvider>
|
||||
),
|
||||
});
|
||||
expect(result.current).toBe("pending");
|
||||
await waitFor(() => {
|
||||
expect(result.current).toBe("ready");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { EngineState } from "../../provider/audio-context.js";
|
||||
import { useAudioContextOrThrow } from "../use-provider-state.js";
|
||||
|
||||
/**
|
||||
* Returns the provider's current engine-init lifecycle phase. Useful
|
||||
* for rendering a loading state during the async window between
|
||||
* mount and adapter `init()` resolution.
|
||||
*
|
||||
* - Throws `REACT_AUDIO.NO_PROVIDER` if no `<AudioProvider>` is in
|
||||
* the React tree above.
|
||||
* - Never throws `REACT_AUDIO.PROVIDER_PENDING` — it *reports* the
|
||||
* pending phase via the return value.
|
||||
*/
|
||||
export function useEngineState(): EngineState {
|
||||
const ctx = useAudioContextOrThrow();
|
||||
return ctx.engineState;
|
||||
}
|
||||
Reference in New Issue
Block a user