@couch-kit/client 0.3.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,81 @@
1
1
  # @couch-kit/client
2
2
 
3
+ ## 0.4.1
4
+
5
+ ### Patch Changes
6
+
7
+ - e763b56: Performance and dependency/bundle improvements
8
+
9
+ **Dependency cleanup:**
10
+ - Remove unused root-level dependencies (expo-\*, @react-native/assets-registry, js-sha1)
11
+ - Move `buffer` from root to @couch-kit/host where it's actually used
12
+ - Move `react-native-nitro-modules` to peerDependencies in @couch-kit/host
13
+ - Remove unused `chalk` dependency from @couch-kit/cli
14
+ - Replace `fs-extra`, `ora`, and `ws` with built-in alternatives in @couch-kit/cli
15
+
16
+ **Bundle & tree-shaking:**
17
+ - Add `sideEffects: false` to all library packages for better tree-shaking
18
+ - Add modern `exports` field to all package.json files
19
+ - Fix @couch-kit/core build target from `node` to `browser` (it's environment-agnostic)
20
+
21
+ **Runtime performance:**
22
+ - Throttle state broadcasts to ~30fps to reduce serialization overhead for fast-updating games
23
+ - Replace per-event `Buffer.concat` with a growing buffer strategy in WebSocket server to reduce GC pressure
24
+ - Replace deprecated `Buffer.slice()` with `Buffer.subarray()`
25
+
26
+ **CLI improvements:**
27
+ - Lazy-load CLI commands via dynamic `import()` for faster startup
28
+ - Replace `ws` with Bun's native WebSocket
29
+ - Replace `fs-extra` with `node:fs` built-in APIs
30
+ - Replace `ora` with simple console output
31
+
32
+ - Updated dependencies [e763b56]
33
+ - @couch-kit/core@0.3.1
34
+
35
+ ## 0.4.0
36
+
37
+ ### Minor Changes
38
+
39
+ - e31e980: Comprehensive code audit: security, correctness, performance, and type safety improvements
40
+
41
+ **Core:**
42
+ - Add shared constants module (ports, timeouts, frame limits, reconnection defaults)
43
+ - Add `generateId()` utility using cryptographic randomness instead of `Math.random()`
44
+ - Add `toErrorMessage()` for safe error extraction from unknown caught values
45
+ - Add `InternalAction` type union and `InternalActionTypes` constants for `__HYDRATE__`, `__PLAYER_JOINED__`, `__PLAYER_LEFT__`
46
+ - Update `createGameReducer()` to handle all internal actions with proper types (no more double-casts)
47
+ - Expand reducer tests from 2 to 7 cases
48
+
49
+ **Host:**
50
+ - Rewrite `EventEmitter` as a generic class with full type safety on `on`/`off`/`once`/`emit`
51
+ - Add WebSocket `maxFrameSize` enforcement to prevent OOM attacks
52
+ - Add server-side keepalive pings to detect dead connections
53
+ - Use `ManagedSocket` interface instead of mutating raw socket objects
54
+ - Add graceful `stop()` with WebSocket close frames
55
+ - Safe `broadcast()` and `send()` with per-socket error handling
56
+ - Add client message validation and internal action injection prevention in provider
57
+ - Fix WELCOME race condition using `queueMicrotask()`
58
+ - Memoize context value to prevent unnecessary consumer re-renders
59
+ - Add `loading` state to server hook
60
+
61
+ **Client:**
62
+ - Add configurable reconnection with `maxRetries`, `baseDelay`, `maxDelay` props
63
+ - Fix stale closures via `configRef` pattern
64
+ - Add `disconnect()` and `reconnect()` methods
65
+ - Respect WebSocket close codes (1008, 1011 skip reconnection)
66
+ - Add ping map TTL cleanup to prevent unbounded growth in time sync
67
+ - Fix `usePreload` error swallowing — track and report `failedAssets`
68
+ - Replace `JSON.stringify(assets)` dependency with stable ref comparison
69
+
70
+ **CLI:**
71
+ - Replace unsafe `(e as Error).message` casts with `toErrorMessage()`
72
+ - Fix interval leak in simulate command — clear intervals on SIGINT
73
+
74
+ ### Patch Changes
75
+
76
+ - Updated dependencies [e31e980]
77
+ - @couch-kit/core@0.3.0
78
+
3
79
  ## 0.3.0
4
80
 
5
81
  ### Minor Changes
package/README.md CHANGED
@@ -136,7 +136,7 @@ import { usePreload } from "@couch-kit/client";
136
136
  const ASSETS = ["/images/avatar_1.png", "/sounds/buzz.mp3"];
137
137
 
138
138
  function App() {
139
- const { loaded, progress } = usePreload(ASSETS);
139
+ const { loaded, progress, failedAssets } = usePreload(ASSETS);
140
140
 
141
141
  if (!loaded) {
142
142
  return <div>Loading... {Math.round(progress)}%</div>;
@@ -148,4 +148,4 @@ function App() {
148
148
 
149
149
  ## Notes / Limitations
150
150
 
151
- - `usePreload()` preloads images via `Image()` and uses `fetch()` for other URLs; it does not currently send the protocol `ASSETS_LOADED` message.
151
+ - `usePreload()` preloads images via `Image()` and uses `fetch()` for other URLs; it does not currently send the protocol `ASSETS_LOADED` message. Failed assets are available via `failedAssets`.
package/dist/index.js CHANGED
@@ -1831,14 +1831,51 @@ var require_react = __commonJS((exports, module) => {
1831
1831
  var import_react2 = __toESM(require_react(), 1);
1832
1832
 
1833
1833
  // ../core/src/types.ts
1834
+ var InternalActionTypes = {
1835
+ HYDRATE: "__HYDRATE__",
1836
+ PLAYER_JOINED: "__PLAYER_JOINED__",
1837
+ PLAYER_LEFT: "__PLAYER_LEFT__"
1838
+ };
1834
1839
  function createGameReducer(reducer) {
1835
1840
  return (state, action) => {
1836
- if (action.type === "HYDRATE" && action.payload) {
1837
- return action.payload;
1841
+ switch (action.type) {
1842
+ case InternalActionTypes.HYDRATE:
1843
+ return action.payload;
1844
+ case InternalActionTypes.PLAYER_JOINED: {
1845
+ const { id, name, avatar } = action.payload;
1846
+ return {
1847
+ ...state,
1848
+ players: {
1849
+ ...state.players,
1850
+ [id]: {
1851
+ id,
1852
+ name,
1853
+ avatar,
1854
+ isHost: false,
1855
+ connected: true
1856
+ }
1857
+ }
1858
+ };
1859
+ }
1860
+ case InternalActionTypes.PLAYER_LEFT: {
1861
+ const { playerId } = action.payload;
1862
+ const player = state.players[playerId];
1863
+ if (!player)
1864
+ return state;
1865
+ return {
1866
+ ...state,
1867
+ players: {
1868
+ ...state.players,
1869
+ [playerId]: { ...player, connected: false }
1870
+ }
1871
+ };
1872
+ }
1873
+ default:
1874
+ return reducer(state, action);
1838
1875
  }
1839
- return reducer(state, action);
1840
1876
  };
1841
1877
  }
1878
+
1842
1879
  // ../core/src/protocol.ts
1843
1880
  var MessageTypes = {
1844
1881
  JOIN: "JOIN",
@@ -1851,6 +1888,29 @@ var MessageTypes = {
1851
1888
  RECONNECTED: "RECONNECTED",
1852
1889
  ERROR: "ERROR"
1853
1890
  };
1891
+
1892
+ // ../core/src/constants.ts
1893
+ var DEFAULT_WS_PORT_OFFSET = 2;
1894
+ var MAX_FRAME_SIZE = 1024 * 1024;
1895
+ var DEFAULT_MAX_RETRIES = 5;
1896
+ var DEFAULT_BASE_DELAY = 1000;
1897
+ var DEFAULT_MAX_DELAY = 1e4;
1898
+ var DEFAULT_SYNC_INTERVAL = 5000;
1899
+ var MAX_PENDING_PINGS = 50;
1900
+ function generateId() {
1901
+ if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
1902
+ return crypto.randomUUID();
1903
+ }
1904
+ if (typeof crypto !== "undefined" && typeof crypto.getRandomValues === "function") {
1905
+ const bytes = new Uint8Array(16);
1906
+ crypto.getRandomValues(bytes);
1907
+ return Array.from(bytes).map((b2) => b2.toString(16).padStart(2, "0")).join("");
1908
+ }
1909
+ const a = Math.random().toString(36).substring(2, 15);
1910
+ const b = Math.random().toString(36).substring(2, 10);
1911
+ return a + b;
1912
+ }
1913
+
1854
1914
  // src/time-sync.ts
1855
1915
  var import_react = __toESM(require_react(), 1);
1856
1916
  function calculateTimeSync(clientSendTime, clientReceiveTime, serverTime) {
@@ -1882,7 +1942,12 @@ function useServerTime(socket) {
1882
1942
  if (!socket || socket.readyState !== WebSocket.OPEN)
1883
1943
  return;
1884
1944
  const sync = () => {
1885
- const id = Math.random().toString(36).substring(7);
1945
+ if (pings.current.size >= MAX_PENDING_PINGS) {
1946
+ const oldest = pings.current.keys().next().value;
1947
+ if (oldest !== undefined)
1948
+ pings.current.delete(oldest);
1949
+ }
1950
+ const id = generateId();
1886
1951
  const timestamp = Date.now();
1887
1952
  pings.current.set(id, timestamp);
1888
1953
  socket.send(JSON.stringify({
@@ -1891,15 +1956,13 @@ function useServerTime(socket) {
1891
1956
  }));
1892
1957
  };
1893
1958
  sync();
1894
- const interval = setInterval(sync, 5000);
1959
+ const interval = setInterval(sync, DEFAULT_SYNC_INTERVAL);
1895
1960
  return () => clearInterval(interval);
1896
1961
  }, [socket]);
1897
1962
  return { getServerTime, rtt: timeSync.rtt, handlePong };
1898
1963
  }
1899
1964
 
1900
1965
  // src/client.ts
1901
- var MAX_RETRIES = 5;
1902
- var BASE_DELAY = 1000;
1903
1966
  function useGameClient(config) {
1904
1967
  const [status, setStatus] = import_react2.useState("disconnected");
1905
1968
  const [playerId, setPlayerId] = import_react2.useState(null);
@@ -1907,45 +1970,61 @@ function useGameClient(config) {
1907
1970
  const socketRef = import_react2.useRef(null);
1908
1971
  const reconnectAttempts = import_react2.useRef(0);
1909
1972
  const reconnectTimer = import_react2.useRef(null);
1910
- const { getServerTime, handlePong } = useServerTime(socketRef.current);
1973
+ const intentionalClose = import_react2.useRef(false);
1974
+ const configRef = import_react2.useRef(config);
1975
+ import_react2.useEffect(() => {
1976
+ configRef.current = config;
1977
+ });
1978
+ const { getServerTime, rtt, handlePong } = useServerTime(socketRef.current);
1979
+ const maxRetries = config.maxRetries ?? DEFAULT_MAX_RETRIES;
1980
+ const baseDelay = config.baseDelay ?? DEFAULT_BASE_DELAY;
1981
+ const maxDelay = config.maxDelay ?? DEFAULT_MAX_DELAY;
1911
1982
  const connect = import_react2.useCallback(() => {
1912
- let wsUrl = config.url;
1983
+ const cfg = configRef.current;
1984
+ intentionalClose.current = false;
1985
+ let wsUrl = cfg.url;
1913
1986
  if (!wsUrl && typeof window !== "undefined") {
1914
- const protocol2 = window.location.protocol === "https:" ? "wss:" : "ws:";
1987
+ const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
1915
1988
  const host = window.location.hostname;
1916
1989
  const httpPort = parseInt(window.location.port, 10) || 80;
1917
- const wsPort = config.wsPort || httpPort + 2;
1918
- wsUrl = `${protocol2}//${host}:${wsPort}`;
1990
+ const wsPort = cfg.wsPort || httpPort + DEFAULT_WS_PORT_OFFSET;
1991
+ wsUrl = `${protocol}//${host}:${wsPort}`;
1919
1992
  }
1920
1993
  if (!wsUrl)
1921
1994
  return;
1922
- if (config.debug)
1995
+ if (cfg.debug)
1923
1996
  console.log(`[GameClient] Connecting to ${wsUrl}`);
1924
1997
  setStatus("connecting");
1925
1998
  const ws = new WebSocket(wsUrl);
1926
1999
  socketRef.current = ws;
1927
2000
  ws.onopen = () => {
2001
+ const currentCfg = configRef.current;
1928
2002
  setStatus("connected");
1929
2003
  reconnectAttempts.current = 0;
1930
- config.onConnect?.();
2004
+ currentCfg.onConnect?.();
1931
2005
  let secret = null;
1932
2006
  try {
1933
2007
  secret = localStorage.getItem("ck_secret");
1934
2008
  if (!secret) {
1935
- secret = Math.random().toString(36).substring(2, 15);
2009
+ secret = generateId();
1936
2010
  localStorage.setItem("ck_secret", secret);
1937
2011
  }
1938
2012
  } catch {
1939
- secret = Math.random().toString(36).substring(2, 15);
2013
+ secret = generateId();
2014
+ }
2015
+ try {
2016
+ ws.send(JSON.stringify({
2017
+ type: MessageTypes.JOIN,
2018
+ payload: {
2019
+ name: currentCfg.name || "Player",
2020
+ avatar: currentCfg.avatar || "\uD83D\uDE00",
2021
+ secret
2022
+ }
2023
+ }));
2024
+ } catch (e) {
2025
+ if (currentCfg.debug)
2026
+ console.error("[GameClient] Failed to send JOIN:", e);
1940
2027
  }
1941
- ws.send(JSON.stringify({
1942
- type: MessageTypes.JOIN,
1943
- payload: {
1944
- name: config.name || "Player",
1945
- avatar: config.avatar || "\uD83D\uDE00",
1946
- secret
1947
- }
1948
- }));
1949
2028
  };
1950
2029
  ws.onmessage = (event) => {
1951
2030
  try {
@@ -1953,10 +2032,16 @@ function useGameClient(config) {
1953
2032
  switch (msg.type) {
1954
2033
  case MessageTypes.WELCOME:
1955
2034
  setPlayerId(msg.payload.playerId);
1956
- dispatchLocal({ type: "HYDRATE", payload: msg.payload.state });
2035
+ dispatchLocal({
2036
+ type: InternalActionTypes.HYDRATE,
2037
+ payload: msg.payload.state
2038
+ });
1957
2039
  break;
1958
2040
  case MessageTypes.STATE_UPDATE:
1959
- dispatchLocal({ type: "HYDRATE", payload: msg.payload.newState });
2041
+ dispatchLocal({
2042
+ type: InternalActionTypes.HYDRATE,
2043
+ payload: msg.payload.newState
2044
+ });
1960
2045
  break;
1961
2046
  case MessageTypes.PONG:
1962
2047
  handlePong(msg.payload);
@@ -1966,13 +2051,17 @@ function useGameClient(config) {
1966
2051
  console.error("Failed to parse message", e);
1967
2052
  }
1968
2053
  };
1969
- ws.onclose = () => {
2054
+ ws.onclose = (event) => {
1970
2055
  setStatus("disconnected");
1971
- config.onDisconnect?.();
1972
- if (reconnectAttempts.current < MAX_RETRIES) {
1973
- const delay = Math.min(BASE_DELAY * Math.pow(2, reconnectAttempts.current), 1e4);
2056
+ configRef.current.onDisconnect?.();
2057
+ if (intentionalClose.current)
2058
+ return;
2059
+ if (event.code === 1008 || event.code === 1011)
2060
+ return;
2061
+ if (reconnectAttempts.current < maxRetries) {
2062
+ const delay = Math.min(baseDelay * Math.pow(2, reconnectAttempts.current), maxDelay);
1974
2063
  reconnectAttempts.current++;
1975
- if (config.debug)
2064
+ if (configRef.current.debug)
1976
2065
  console.log(`[GameClient] Reconnecting in ${delay}ms...`);
1977
2066
  reconnectTimer.current = setTimeout(() => {
1978
2067
  connect();
@@ -1980,20 +2069,38 @@ function useGameClient(config) {
1980
2069
  }
1981
2070
  };
1982
2071
  ws.onerror = (e) => {
1983
- if (config.debug)
2072
+ if (configRef.current.debug)
1984
2073
  console.error("[GameClient] Error", e);
1985
2074
  setStatus("error");
1986
2075
  };
1987
- }, [config.url, config.wsPort, config.debug]);
2076
+ }, [config.url, config.wsPort, maxRetries, baseDelay, maxDelay]);
1988
2077
  import_react2.useEffect(() => {
1989
2078
  connect();
1990
2079
  return () => {
2080
+ intentionalClose.current = true;
1991
2081
  if (socketRef.current)
1992
2082
  socketRef.current.close();
1993
2083
  if (reconnectTimer.current)
1994
2084
  clearTimeout(reconnectTimer.current);
1995
2085
  };
1996
2086
  }, [connect]);
2087
+ const disconnect = import_react2.useCallback(() => {
2088
+ intentionalClose.current = true;
2089
+ if (reconnectTimer.current) {
2090
+ clearTimeout(reconnectTimer.current);
2091
+ reconnectTimer.current = null;
2092
+ }
2093
+ if (socketRef.current) {
2094
+ socketRef.current.close();
2095
+ socketRef.current = null;
2096
+ }
2097
+ setStatus("disconnected");
2098
+ }, []);
2099
+ const reconnect = import_react2.useCallback(() => {
2100
+ disconnect();
2101
+ reconnectAttempts.current = 0;
2102
+ setTimeout(() => connect(), 50);
2103
+ }, [disconnect, connect]);
1997
2104
  const sendAction = import_react2.useCallback((action) => {
1998
2105
  dispatchLocal(action);
1999
2106
  if (socketRef.current?.readyState === WebSocket.OPEN) {
@@ -2008,41 +2115,77 @@ function useGameClient(config) {
2008
2115
  state,
2009
2116
  playerId,
2010
2117
  sendAction,
2011
- getServerTime
2118
+ getServerTime,
2119
+ rtt,
2120
+ disconnect,
2121
+ reconnect
2012
2122
  };
2013
2123
  }
2014
2124
  // src/assets.ts
2015
2125
  var import_react3 = __toESM(require_react(), 1);
2126
+ function arraysEqual(a, b) {
2127
+ if (a.length !== b.length)
2128
+ return false;
2129
+ for (let i = 0;i < a.length; i++) {
2130
+ if (a[i] !== b[i])
2131
+ return false;
2132
+ }
2133
+ return true;
2134
+ }
2016
2135
  function usePreload(assets) {
2017
2136
  const [loaded, setLoaded] = import_react3.useState(false);
2018
2137
  const [progress, setProgress] = import_react3.useState(0);
2138
+ const [failedAssets, setFailedAssets] = import_react3.useState([]);
2139
+ const prevAssets = import_react3.useRef(assets);
2140
+ if (!arraysEqual(prevAssets.current, assets)) {
2141
+ prevAssets.current = assets;
2142
+ }
2143
+ const stableAssets = prevAssets.current;
2019
2144
  import_react3.useEffect(() => {
2020
- if (assets.length === 0) {
2145
+ if (stableAssets.length === 0) {
2021
2146
  setLoaded(true);
2022
2147
  setProgress(100);
2148
+ setFailedAssets([]);
2023
2149
  return;
2024
2150
  }
2025
2151
  let loadedCount = 0;
2026
- const total = assets.length;
2027
- const onLoad = () => {
2152
+ const total = stableAssets.length;
2153
+ const failed = [];
2154
+ let cancelled = false;
2155
+ const tick = () => {
2156
+ if (cancelled)
2157
+ return;
2028
2158
  loadedCount++;
2029
2159
  setProgress(Math.round(loadedCount / total * 100));
2030
2160
  if (loadedCount === total) {
2161
+ setFailedAssets([...failed]);
2031
2162
  setLoaded(true);
2032
2163
  }
2033
2164
  };
2034
- assets.forEach((src) => {
2165
+ setLoaded(false);
2166
+ setProgress(0);
2167
+ setFailedAssets([]);
2168
+ stableAssets.forEach((src) => {
2035
2169
  if (src.match(/\.(jpeg|jpg|gif|png|webp)$/)) {
2036
2170
  const img = new Image;
2037
- img.onload = onLoad;
2038
- img.onerror = onLoad;
2171
+ img.onload = tick;
2172
+ img.onerror = () => {
2173
+ failed.push(src);
2174
+ tick();
2175
+ };
2039
2176
  img.src = src;
2040
2177
  } else {
2041
- fetch(src).then(onLoad).catch(onLoad);
2178
+ fetch(src).then(tick).catch(() => {
2179
+ failed.push(src);
2180
+ tick();
2181
+ });
2042
2182
  }
2043
2183
  });
2044
- }, [JSON.stringify(assets)]);
2045
- return { loaded, progress };
2184
+ return () => {
2185
+ cancelled = true;
2186
+ };
2187
+ }, [stableAssets]);
2188
+ return { loaded, progress, failedAssets };
2046
2189
  }
2047
2190
  export {
2048
2191
  useServerTime,
package/package.json CHANGED
@@ -1,9 +1,33 @@
1
1
  {
2
2
  "name": "@couch-kit/client",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
4
+ "description": "React hooks for phone controllers in Couch Kit party games — WebSocket client and action dispatch",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/faluciano/react-native-couch-kit.git",
9
+ "directory": "packages/client"
10
+ },
11
+ "homepage": "https://github.com/faluciano/react-native-couch-kit#readme",
12
+ "keywords": [
13
+ "react",
14
+ "couch-kit",
15
+ "party-game",
16
+ "websocket",
17
+ "game-client",
18
+ "controller"
19
+ ],
4
20
  "main": "./src/index.ts",
5
21
  "module": "./src/index.ts",
6
22
  "types": "./src/index.ts",
23
+ "exports": {
24
+ ".": {
25
+ "types": "./src/index.ts",
26
+ "import": "./src/index.ts",
27
+ "default": "./src/index.ts"
28
+ }
29
+ },
30
+ "sideEffects": false,
7
31
  "scripts": {
8
32
  "build": "bun build ./src/index.ts --outdir ./dist --target browser",
9
33
  "test": "bun test",
@@ -12,7 +36,7 @@
12
36
  "clean": "rm -rf dist"
13
37
  },
14
38
  "dependencies": {
15
- "@couch-kit/core": "0.2.0"
39
+ "@couch-kit/core": "0.3.1"
16
40
  },
17
41
  "devDependencies": {
18
42
  "react": "^18.2.0",
package/src/assets.ts CHANGED
@@ -1,43 +1,100 @@
1
- import { useState, useEffect } from 'react';
1
+ import { useState, useEffect, useRef } from "react";
2
2
 
3
- // Simplified hook for preloading assets (images, audio)
4
- // Returns progress (0-100) and loaded (boolean)
5
- export function usePreload(assets: string[]) {
3
+ export interface PreloadResult {
4
+ /** Whether all assets have finished loading (including failures). */
5
+ loaded: boolean;
6
+ /** Loading progress as a percentage (0-100). */
7
+ progress: number;
8
+ /** Asset URLs that failed to load. */
9
+ failedAssets: string[];
10
+ }
11
+
12
+ /**
13
+ * Shallow-compare two string arrays by length and element identity.
14
+ * Returns `true` when the arrays are considered equal.
15
+ */
16
+ function arraysEqual(a: string[], b: string[]): boolean {
17
+ if (a.length !== b.length) return false;
18
+ for (let i = 0; i < a.length; i++) {
19
+ if (a[i] !== b[i]) return false;
20
+ }
21
+ return true;
22
+ }
23
+
24
+ /**
25
+ * Preloads a list of asset URLs (images via `Image()`, others via `fetch()`).
26
+ *
27
+ * Returns `loaded` (all done), `progress` (0-100), and `failedAssets` (URLs
28
+ * that failed to load). Failed assets still count toward progress so the hook
29
+ * always reaches 100 % — check `failedAssets.length` to decide how to react.
30
+ */
31
+ export function usePreload(assets: string[]): PreloadResult {
6
32
  const [loaded, setLoaded] = useState(false);
7
33
  const [progress, setProgress] = useState(0);
34
+ const [failedAssets, setFailedAssets] = useState<string[]>([]);
35
+
36
+ // Stable reference to the last asset list so we don't depend on
37
+ // `JSON.stringify(assets)` — which creates a new string every render.
38
+ const prevAssets = useRef<string[]>(assets);
39
+
40
+ // Only update the ref (and re-trigger the effect) when the content changes.
41
+ if (!arraysEqual(prevAssets.current, assets)) {
42
+ prevAssets.current = assets;
43
+ }
44
+
45
+ const stableAssets = prevAssets.current;
8
46
 
9
47
  useEffect(() => {
10
- if (assets.length === 0) {
48
+ if (stableAssets.length === 0) {
11
49
  setLoaded(true);
12
50
  setProgress(100);
51
+ setFailedAssets([]);
13
52
  return;
14
53
  }
15
54
 
16
55
  let loadedCount = 0;
17
- const total = assets.length;
56
+ const total = stableAssets.length;
57
+ const failed: string[] = [];
58
+ let cancelled = false;
18
59
 
19
- const onLoad = () => {
60
+ const tick = () => {
61
+ if (cancelled) return;
20
62
  loadedCount++;
21
63
  setProgress(Math.round((loadedCount / total) * 100));
22
64
  if (loadedCount === total) {
65
+ setFailedAssets([...failed]);
23
66
  setLoaded(true);
24
67
  }
25
68
  };
26
69
 
27
- assets.forEach(src => {
28
- // Basic Image preloading
29
- // (For Audio, we would need to use Audio() object or fetch)
30
- if (src.match(/\.(jpeg|jpg|gif|png|webp)$/)) {
31
- const img = new Image();
32
- img.onload = onLoad;
33
- img.onerror = onLoad; // Count errors as loaded to not block game
34
- img.src = src;
35
- } else {
36
- // Assume generic fetch for other assets
37
- fetch(src).then(onLoad).catch(onLoad);
38
- }
70
+ // Reset state for a fresh load cycle
71
+ setLoaded(false);
72
+ setProgress(0);
73
+ setFailedAssets([]);
74
+
75
+ stableAssets.forEach((src) => {
76
+ if (src.match(/\.(jpeg|jpg|gif|png|webp)$/)) {
77
+ const img = new Image();
78
+ img.onload = tick;
79
+ img.onerror = () => {
80
+ failed.push(src);
81
+ tick();
82
+ };
83
+ img.src = src;
84
+ } else {
85
+ fetch(src)
86
+ .then(tick)
87
+ .catch(() => {
88
+ failed.push(src);
89
+ tick();
90
+ });
91
+ }
39
92
  });
40
- }, [JSON.stringify(assets)]);
41
93
 
42
- return { loaded, progress };
94
+ return () => {
95
+ cancelled = true;
96
+ };
97
+ }, [stableAssets]);
98
+
99
+ return { loaded, progress, failedAssets };
43
100
  }