mirror of
https://github.com/duckietm/Nitro-V3.git
synced 2026-06-19 06:56:20 +00:00
a029ee63cb
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.
109 lines
4.0 KiB
TypeScript
109 lines
4.0 KiB
TypeScript
/* @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 = () =>
|
|
{
|
|
// eslint-disable-next-line react-hooks/rules-of-hooks -- intentional: this test asserts the runtime crash
|
|
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 });
|
|
|
|
// Lowercase intentionally — this is a custom hook named like a
|
|
// regular function so the test reproduces the exact call shape
|
|
// a refactor might land on. The eslint disable below silences
|
|
// the "hooks must start with use" lint that flags the body.
|
|
const safeHook = () =>
|
|
{
|
|
// eslint-disable-next-line react-hooks/rules-of-hooks -- intentional: function named like a hook to mirror real call sites
|
|
const shared = useBetween(sharedState);
|
|
// eslint-disable-next-line react-hooks/rules-of-hooks -- intentional: same reason as above
|
|
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);
|
|
});
|
|
});
|