fix(snapshots): re-apply the 3 snapshot-consumer migrations with the use-between/useSyncExternalStore incompatibility resolved

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.
This commit is contained in:
simoleo89
2026-05-19 17:30:03 +02:00
parent 06f9b66073
commit d28819db89
4 changed files with 149 additions and 39 deletions
@@ -0,0 +1,101 @@
/* @vitest-environment jsdom */
import { cleanup, render, renderHook } from '@testing-library/react';
import { Component, ReactNode, useSyncExternalStore } from 'react';
import { useBetween } from 'use-between';
import { afterEach, describe, expect, it, vi } from 'vitest';
// Regression guard for the rolled-back snapshot-consumer migration.
//
// `use-between` (v1.x) ships its own dispatcher that proxies a subset of
// React hooks (useState, useReducer, useEffect, useLayoutEffect,
// useCallback, useMemo, useRef, useImperativeHandle). It does NOT
// implement `useSyncExternalStore`. When a state function runs inside
// `useBetween(stateFn)` and that state function calls
// `useSyncExternalStore` (directly or via a wrapper like
// `useExternalSnapshot` / `useUserDataSnapshot`), React resolves the
// dispatcher to use-between's proxy, finds `useSyncExternalStore`
// missing, and throws "(intermediate value)() is undefined" on the
// first render — that's the exact production error reported at
// ToolbarView.tsx:46 last session.
//
// The fix is structural: snapshot hooks must run OUTSIDE the useBetween
// scope (i.e. in the exported wrapper, not in the inner state
// function). These tests pin the constraint so a future migration
// doesn't reintroduce the broken pattern.
class CaptureBoundary extends Component<{ children: ReactNode }, { error: Error | null }>
{
state = { error: null as Error | null };
static getDerivedStateFromError(error: Error)
{
return { error };
}
componentDidCatch()
{
}
render()
{
return this.state.error ? null : this.props.children;
}
}
describe('use-between + useSyncExternalStore incompatibility', () =>
{
afterEach(() =>
{
cleanup();
});
it('crashes when useSyncExternalStore is called inside a useBetween scope', () =>
{
// React 19 logs every render-time error to console.error before
// forwarding to the error boundary. Suppress the noise to keep
// the test output readable, then assert the error fingerprint.
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined);
const Broken = () =>
{
useBetween(() => useSyncExternalStore(() => () => undefined, () => 'v', () => 'v'));
return null;
};
let captured: Error | null = null;
const boundaryRef = (instance: CaptureBoundary | null) =>
{
if(instance) captured = instance.state.error;
};
render(
<CaptureBoundary ref={boundaryRef as any}>
<Broken />
</CaptureBoundary>
);
expect(captured).not.toBeNull();
expect(captured!.message).toMatch(/useSyncExternalStore is not a function|intermediate value/);
consoleError.mockRestore();
});
it('works when useSyncExternalStore is called OUTSIDE the useBetween scope', () =>
{
const sharedState = () => ({ count: 0 });
const safeHook = () =>
{
const shared = useBetween(sharedState);
const external = useSyncExternalStore(() => () => undefined, () => 'value', () => 'value');
return { ...shared, external };
};
const { result } = renderHook(() => safeHook());
expect(result.current.external).toBe('value');
expect(result.current.count).toBe(0);
});
});