Two follow-ups to the CatalogPurchaseWidgetView fix (6bf3366):
1. CatalogItemGridWidgetView had the same shape — four useCallback
declarations (handleDragStart / handleDragOver / handleDrop /
handleDragEnd) sat below an `if(!currentPage) return null` early
return. When currentPage flipped from null to a real page the hook
count jumped by 4 and React would have thrown "Rendered more hooks
than during the previous render" the moment any consumer rendered
the grid in admin mode. Moved the four useCallback declarations
above the early-return; their bodies are safe pre-load (only
currentPage?.offers is accessed inside handleDrop, optional-chained
already).
2. CI gate — the existing GitHub Actions workflow runs `yarn
typecheck` and `yarn test`, but NOT `yarn eslint`. That's why this
pattern slipped through twice in a row: ESLint flags it locally
but no PR check enforces it. Full `yarn eslint` emits ~900
pre-existing baseline errors (brace-style, indentation,
recommended TS rules — out of scope for this branch), so a blanket
step would always fail. Instead added a focused
`eslint.hooks.config.mjs` + `yarn lint:hooks` script that runs
ESLint with ONLY `react-hooks/rules-of-hooks: error`. Wired into
ci.yml between `typecheck` and `test`. The local repo now has
zero violations of the rule.
3. useSessionSnapshots.test.tsx — added eslint-disable-next-line
comments on the three lines that intentionally violate the rule
(they're the assertions that the broken pattern crashes). Without
the comments the new CI gate would fail on the regression-guard
suite.
Verification: yarn lint:hooks green, yarn typecheck clean, yarn test
209/209.
Root cause of last session's "(intermediate value)() is undefined" at
ToolbarView.tsx:46:
use-between 1.x ships its own React-dispatcher proxy (ownDispatcher
in node_modules/use-between/release/index.esm.js:54-169) that
re-implements only useState, useReducer, useEffect, useLayoutEffect,
useCallback, useMemo, useRef and useImperativeHandle. It does NOT
implement useSyncExternalStore. When the inner state function of
useBetween(stateFn) calls useSyncExternalStore (directly or via
useExternalSnapshot / useUserDataSnapshot), React resolves the
dispatcher to use-between's proxy, finds .useSyncExternalStore
missing, and calls undefined() — that's the exact production crash
in Firefox. Chrome reports the same as
"dispatcher.useSyncExternalStore is not a function".
Neither the vite alias (790ad2b) nor the defensive renderer-method
guards (c35a2d4) could fix it — both addressed downstream symptoms
(stale dist / missing manager methods) but the dispatcher is upstream
of both. That's why every retry kept reproducing the same error.
Fix is structural: snapshot hooks (useUserDataSnapshot,
useIsUserIgnored, etc.) MUST run outside any useBetween scope. Three
re-applied migrations:
- useSessionInfo: snapshot read moved into the outer wrapper. The
inner useSessionInfoState (useBetween-shared) now contains only
use-between-safe hooks: useState, useMessageEvent, plain actions.
userFigure / userRespectRemaining / petRespectRemaining come from
useUserDataSnapshot() OUTSIDE useBetween, so useSyncExternalStore
installs against the real React dispatcher.
- useChatWidget.ownUserId: direct snapshot read. useChatWidget is
exported as `useChatWidget = useChatWidgetState` (NOT wrapped in
useBetween), so this hook never sat inside the broken scope — the
precautionary rollback was unnecessary in retrospect. Gains
session-change reactivity (e.g. reconnect under a different user id).
- AvatarInfoWidgetAvatarView Ignore/Unignore: useIsUserIgnored(name)
read directly in the component body. Same reasoning as
useChatWidget — never inside useBetween. The menu auto-flips
Ignore <-> Unignore while the popup is open.
Added regression guard at src/hooks/session/useSessionSnapshots.test.tsx
with two cases: (1) useSyncExternalStore inside useBetween throws,
(2) useSyncExternalStore outside useBetween in the same render works.
Pins the constraint so future migrations cannot reintroduce the bad
shape silently.
Verification: yarn typecheck clean, yarn test 209/209 (207 baseline
+ 2 new regression cases), no consumer surface changes — every
destructured field (userFigure, userRespectRemaining, respectUser,
petRespectRemaining, respectPet, chatStyleId, updateChatStyleId) is
still returned with the same name and shape.