fix(sound): stop component imports from dragging audio assets into consumer bundles
Importing any sound-aware component (e.g. @unom/ui/button) statically reached src/sound/defaults.ts via the provider, pulling ~6.7MB of wav/mp3 URL references into every consumer build even with no SoundProvider mounted. - new src/sound/silent.ts: asset-free silentSoundTheme, now the context default - SoundTheme.tokens is Partial<> so sparse themes typecheck; merge/resolve are null-safe over missing tokens - SoundProvider: theme prop now optional; new loadTheme prop resolves a theme chunk lazily after mount and merges it over theme - defaults.ts renamed to default-theme.ts, no longer re-exported from sound/index.ts; published as the @unom/ui/sound/default-theme subpath (the ONLY module referencing the audio assets) - Storybook preview passes defaultSoundTheme explicitly via the new subpath Verified: after build, grep -rl "assets/sounds" dist/ lists only dist/sound/default-theme.js. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,7 @@ import "../src/styles/theme.css";
|
||||
import { definePreview } from "@storybook/react-vite";
|
||||
import { AnimationProvider } from "@/animation/provider";
|
||||
import Section from "@/section";
|
||||
import { defaultSoundTheme } from "@/sound/default-theme";
|
||||
import { SoundProvider } from "@/sound/provider";
|
||||
|
||||
export default definePreview({
|
||||
@@ -9,7 +10,7 @@ export default definePreview({
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<AnimationProvider theme={{}}>
|
||||
<SoundProvider theme={{}}>
|
||||
<SoundProvider theme={defaultSoundTheme}>
|
||||
<Section maxWidth={false}>
|
||||
<Story />
|
||||
</Section>
|
||||
|
||||
+2
-1
@@ -68,6 +68,7 @@
|
||||
"./lib/utils": "./dist/lib/utils.js",
|
||||
"./preload-reload": "./dist/preload-reload.js",
|
||||
"./sound": "./dist/sound/index.js",
|
||||
"./sound/default-theme": "./dist/sound/default-theme.js",
|
||||
"./richtext": "./dist/richtext/index.js",
|
||||
"./richtext/converters": "./dist/richtext/converters/index.js",
|
||||
"./richtext/converters/headings": "./dist/richtext/converters/headings.js",
|
||||
@@ -98,4 +99,4 @@
|
||||
"motion-dom": "12.38.0",
|
||||
"motion-utils": "12.36.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
import type { SoundDef, SoundTheme } from "./types";
|
||||
|
||||
const buttonsSrc = new URL(
|
||||
"../assets/sounds/762132__ienba__ui-buttons.wav",
|
||||
import.meta.url,
|
||||
).href;
|
||||
const uiSetSrc = new URL(
|
||||
"../assets/sounds/842498__newlocknew__uimvmt_game-user-interface-sound-set.mp3",
|
||||
import.meta.url,
|
||||
).href;
|
||||
|
||||
const buttonsSpriteMap = {
|
||||
click1: [0, 1000],
|
||||
click2: [2750, 1000],
|
||||
click3: [5200, 1000],
|
||||
click4: [7700, 1000],
|
||||
} satisfies Record<string, [number, number]>;
|
||||
|
||||
const uiSetSpriteMap = {
|
||||
smooth1: [0, 1200],
|
||||
smooth2: [7800, 1400],
|
||||
lobbyCreated: [53800, 3500],
|
||||
gameStart: [62000, 4000],
|
||||
} satisfies Record<string, [number, number]>;
|
||||
|
||||
const fromButtons = (
|
||||
sprite: keyof typeof buttonsSpriteMap,
|
||||
extra?: Partial<SoundDef>,
|
||||
): SoundDef => ({
|
||||
src: buttonsSrc,
|
||||
sprite,
|
||||
spriteMap: buttonsSpriteMap,
|
||||
...extra,
|
||||
});
|
||||
|
||||
const fromUiSet = (
|
||||
sprite: keyof typeof uiSetSpriteMap,
|
||||
extra?: Partial<SoundDef>,
|
||||
): SoundDef => ({
|
||||
src: uiSetSrc,
|
||||
sprite,
|
||||
spriteMap: uiSetSpriteMap,
|
||||
...extra,
|
||||
});
|
||||
|
||||
export const defaultSoundTheme: SoundTheme = {
|
||||
volume: 1,
|
||||
muted: false,
|
||||
custom: {},
|
||||
tokens: {
|
||||
click: fromButtons("click1", { pool: 4, interrupt: true }),
|
||||
hover: fromButtons("click2", { pool: 4, interrupt: true, volume: 0.6 }),
|
||||
toggle: fromButtons("click2", { pool: 2 }),
|
||||
focus: fromButtons("click2", { pool: 1, volume: 0.5 }),
|
||||
selectOpen: fromButtons("click3"),
|
||||
selectClose: fromButtons("click4"),
|
||||
lobbyCreated: fromUiSet("lobbyCreated"),
|
||||
gameStart: fromUiSet("gameStart"),
|
||||
gameWon: fromUiSet("gameStart"),
|
||||
roundSuccess: fromUiSet("smooth1"),
|
||||
userJoined: fromUiSet("smooth1", { volume: 0.7 }),
|
||||
userLeft: fromUiSet("smooth2", { volume: 0.7 }),
|
||||
error: fromButtons("click4", { volume: 0.8 }),
|
||||
submit: fromButtons("click3"),
|
||||
vote: fromButtons("click2"),
|
||||
},
|
||||
};
|
||||
+4
-1
@@ -1,4 +1,6 @@
|
||||
export { defaultSoundTheme } from "./defaults";
|
||||
// NOTE: `defaultSoundTheme` is deliberately NOT re-exported here — it is the
|
||||
// only module referencing the (multi-MB) audio assets and must stay behind the
|
||||
// `@unom/ui/sound/default-theme` subpath so component imports stay asset-free.
|
||||
export { mergeTheme } from "./merge";
|
||||
export {
|
||||
SoundProvider,
|
||||
@@ -8,6 +10,7 @@ export {
|
||||
useSoundSettings,
|
||||
useSoundTheme,
|
||||
} from "./provider";
|
||||
export { silentSoundTheme } from "./silent";
|
||||
export {
|
||||
type AnySoundKey,
|
||||
type CoreSoundKey,
|
||||
|
||||
+4
-5
@@ -4,11 +4,10 @@ export function mergeTheme(
|
||||
base: SoundTheme,
|
||||
override: PartialSoundTheme,
|
||||
): SoundTheme {
|
||||
const tokens = { ...base.tokens };
|
||||
// Both sides may be sparse — missing keys simply stay silent.
|
||||
const tokens = { ...(base.tokens ?? {}) };
|
||||
if (override.tokens) {
|
||||
for (const key of Object.keys(
|
||||
override.tokens,
|
||||
) as (keyof typeof tokens)[]) {
|
||||
for (const key of Object.keys(override.tokens) as (keyof typeof tokens)[]) {
|
||||
const next = override.tokens[key];
|
||||
// `undefined` inherits from parent; explicit `null` silences this token.
|
||||
if (next !== undefined) tokens[key] = next;
|
||||
@@ -18,6 +17,6 @@ export function mergeTheme(
|
||||
tokens,
|
||||
volume: override.volume ?? base.volume,
|
||||
muted: override.muted ?? base.muted,
|
||||
custom: { ...base.custom, ...(override.custom ?? {}) },
|
||||
custom: { ...(base.custom ?? {}), ...(override.custom ?? {}) },
|
||||
};
|
||||
}
|
||||
|
||||
+60
-9
@@ -8,9 +8,14 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { defaultSoundTheme } from "./defaults";
|
||||
import { mergeTheme } from "./merge";
|
||||
import { getUrlHowl, playDef, stopAll as stopAllPlayers, stopDef } from "./player";
|
||||
import {
|
||||
getUrlHowl,
|
||||
playDef,
|
||||
stopAll as stopAllPlayers,
|
||||
stopDef,
|
||||
} from "./player";
|
||||
import { silentSoundTheme } from "./silent";
|
||||
import type {
|
||||
AnySoundKey,
|
||||
CoreSoundKey,
|
||||
@@ -22,7 +27,10 @@ import type {
|
||||
SoundTheme,
|
||||
} from "./types";
|
||||
|
||||
const SoundContext = createContext<SoundTheme>(defaultSoundTheme);
|
||||
// Silent by default: the default theme (and its audio assets) must only ever
|
||||
// be pulled in through the `@unom/ui/sound/default-theme` subpath, never by
|
||||
// merely importing a sound-aware component.
|
||||
const SoundContext = createContext<SoundTheme>(silentSoundTheme);
|
||||
const ControllerContext = createContext<SoundController | null>(null);
|
||||
|
||||
type PersistedSettings = { volume?: number; muted?: boolean };
|
||||
@@ -50,7 +58,22 @@ function writePersisted(key: string, value: PersistedSettings) {
|
||||
}
|
||||
|
||||
export type SoundProviderProps = {
|
||||
theme: PartialSoundTheme;
|
||||
theme?: PartialSoundTheme;
|
||||
/**
|
||||
* Lazily loaded theme, resolved once after mount and merged OVER `theme`.
|
||||
* Lets apps defer even the theme's JS (and its audio asset references):
|
||||
*
|
||||
* ```tsx
|
||||
* <SoundProvider
|
||||
* loadTheme={() =>
|
||||
* import("@unom/ui/sound/default-theme").then((m) => m.defaultSoundTheme)
|
||||
* }
|
||||
* >
|
||||
* ```
|
||||
*
|
||||
* Read once on mount, so an inline arrow function is fine.
|
||||
*/
|
||||
loadTheme?: () => Promise<PartialSoundTheme>;
|
||||
/** When set, volume/mute persist to localStorage under this key. */
|
||||
persistKey?: string;
|
||||
children: ReactNode;
|
||||
@@ -58,6 +81,7 @@ export type SoundProviderProps = {
|
||||
|
||||
export function SoundProvider({
|
||||
theme,
|
||||
loadTheme,
|
||||
persistKey,
|
||||
children,
|
||||
}: SoundProviderProps) {
|
||||
@@ -72,6 +96,30 @@ export function SoundProvider({
|
||||
Pick<PartialSoundTheme, "volume" | "muted">
|
||||
>({});
|
||||
const [hydrated, setHydrated] = useState(false);
|
||||
const [loadedTheme, setLoadedTheme] = useState<PartialSoundTheme | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
// `loadTheme` is intentionally read once on mount (via ref) so consumers can
|
||||
// pass an inline arrow without re-triggering the load on every render.
|
||||
const loadThemeRef = useRef(loadTheme);
|
||||
loadThemeRef.current = loadTheme;
|
||||
|
||||
useEffect(() => {
|
||||
const load = loadThemeRef.current;
|
||||
if (!load) return;
|
||||
let cancelled = false;
|
||||
load()
|
||||
.then((t) => {
|
||||
if (!cancelled) setLoadedTheme(t);
|
||||
})
|
||||
.catch(() => {
|
||||
// Failed/aborted chunk load — stay silent rather than crash.
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (persistKey) {
|
||||
@@ -90,10 +138,12 @@ export function SoundProvider({
|
||||
});
|
||||
}, [persistKey, hydrated, overrides.volume, overrides.muted]);
|
||||
|
||||
const merged = useMemo(
|
||||
() => mergeTheme(parent, { ...theme, ...overrides }),
|
||||
[parent, theme, overrides],
|
||||
);
|
||||
const merged = useMemo(() => {
|
||||
// parent ← theme ← loadTheme() result ← persisted volume/mute overrides
|
||||
let next = mergeTheme(parent, theme ?? {});
|
||||
if (loadedTheme) next = mergeTheme(next, loadedTheme);
|
||||
return mergeTheme(next, overrides);
|
||||
}, [parent, theme, loadedTheme, overrides]);
|
||||
|
||||
// Stable themeRef so controller callbacks never need to re-create.
|
||||
const themeRef = useRef(merged);
|
||||
@@ -102,7 +152,8 @@ export function SoundProvider({
|
||||
const controller = useMemo<SoundController>(() => {
|
||||
const resolve = (key: string): SoundDef | null => {
|
||||
const t = themeRef.current;
|
||||
if (key in t.tokens) return t.tokens[key as CoreSoundKey];
|
||||
// Sparse tokens: a missing key falls through to `custom`, then silence.
|
||||
if (key in t.tokens) return t.tokens[key as CoreSoundKey] ?? null;
|
||||
return t.custom[key] ?? null;
|
||||
};
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { SoundTheme } from "./types";
|
||||
|
||||
/**
|
||||
* The zero-asset theme. Used as the context default so that importing any
|
||||
* sound-aware component (e.g. Button) never drags audio assets into a
|
||||
* consumer's bundle. Mount a <SoundProvider> with a real theme (or
|
||||
* `loadTheme`) to opt into sound.
|
||||
*/
|
||||
export const silentSoundTheme: SoundTheme = {
|
||||
tokens: {},
|
||||
volume: 1,
|
||||
muted: false,
|
||||
custom: {},
|
||||
};
|
||||
+3
-1
@@ -33,11 +33,13 @@ export type CoreSoundKey =
|
||||
| "vote";
|
||||
|
||||
/** Games augment this via `declare module "@unom/ui/sound"` to register custom token names. */
|
||||
// biome-ignore lint/suspicious/noEmptyInterface: must stay an interface — consumers extend it via declaration merging.
|
||||
export interface SoundCustomTokens {}
|
||||
export type CustomSoundKey = Extract<keyof SoundCustomTokens, string>;
|
||||
|
||||
export type SoundTheme = {
|
||||
tokens: Record<CoreSoundKey, SoundDef | null>;
|
||||
/** Sparse: missing keys are simply silent. `null` explicitly silences a token. */
|
||||
tokens: Partial<Record<CoreSoundKey, SoundDef | null>>;
|
||||
volume: number;
|
||||
muted: boolean;
|
||||
custom: Record<string, SoundDef | null>;
|
||||
|
||||
Reference in New Issue
Block a user