fix(650): fix 3 tsc -b never-callable errors in gate helpers (project-mode typecheck)

npm run typecheck (tsc -b --pretty false, the real gate — npx tsc --noEmit
was silently checking nothing meaningful due to the root tsconfig.json's
empty `files: []`) flagged 3 errors: `let x: (() => void) | null = null`
gate-release helpers, reassigned only inside a Promise executor, narrowed a
later `x?.()` call to `never` under tsc -b's project-mode control-flow
analysis. Not reproducible under a bare `tsc --noEmit` invocation.

Fixed by adopting the no-op-initializer pattern already established
elsewhere in this repo (api/libraries.test.ts's `releasePost`): declare as
`let x = () => {};` instead of `(() => void) | null = null`, dropping the
now-unnecessary optional chain at each call site. Same runtime behavior,
no `as any`/`@ts-expect-error`, no change to what any test asserts.

3 call sites fixed: releaseStrandedAppend (F3 test), and releaseB0 in both
the inverse-settlement-order test and the round-4 hook-level single-flight
test.

Verified: `npm run typecheck` no longer reports these 3; `npm run lint`
clean; `npx vitest run` 110 files / 1078 tests still pass.

3 more typecheck errors remain (node:fs/node:path/node:url unresolvable in
src/api/pageSizeCallSites.guard.test.ts, the only file under src that needs
real Node fs access) — deliberately NOT fixed here. @types/node is already
a devDependency and physically installed, but tsconfig.app.json (covering
all of src) has no "node" in its `types` array, and a file-local
`/// <reference types="node" />` was tried and reverted: under tsc -b's
single-program compilation, it leaked Node's ambient `setTimeout` (returning
NodeJS.Timeout) into the whole app project, breaking 3 unrelated
window.setTimeout mocks expecting the DOM signature (confirmed by trying
it — TS2345 in ChannelsScreen.test.tsx/LibrariesScreen.test.tsx/
PlayoutsScreen.test.tsx). The clean fix is a real project-config change
(either widen tsconfig.app.json's types, accepting Node globals become
type-visible in browser app code too, or move this one file into
tsconfig.node.json's project alongside the e2e specs, which would also need
a "references" wire-up for its cross-project import of pageSizeScan.ts) —
left for the coordinator to choose per their explicit instruction not to
make this call unilaterally.
This commit is contained in:
2026-07-27 02:08:19 +02:00
parent 937ee92a3f
commit 5b46214774
+13 -6
View File
@@ -780,7 +780,12 @@ describe('Channel Builder (#89)', () => {
const page0ForA = [browseItem({ id: 1, mediaItemId: 1, title: 'Item A' })];
const page0ForZ = [browseItem({ id: 9, mediaItemId: 9, title: 'Item Z' })];
let releaseStrandedAppend: (() => void) | null = null;
// No-op initializer (not `null`) matching the established gate-helper pattern elsewhere in
// this repo (see `api/libraries.test.ts`'s `releasePost`) — avoids a `tsc -b` project-mode
// control-flow quirk where a `let x: (() => void) | null = null` reassigned only inside a
// Promise executor narrows a later `x?.()` call to `never` (not caught by a bare
// `tsc --noEmit`, only by the real `npm run typecheck` gate).
let releaseStrandedAppend = () => {};
const strandedAppendGate = new Promise<void>((resolve) => {
releaseStrandedAppend = resolve;
});
@@ -827,7 +832,7 @@ describe('Channel Builder (#89)', () => {
});
// Let the stranded append resolve too, after the fact — it must not resurrect stale rows.
releaseStrandedAppend?.();
releaseStrandedAppend();
await waitFor(() => expect(screen.getByText('Item Z')).toBeInTheDocument());
expect(screen.queryByText('Item A')).not.toBeInTheDocument();
}
@@ -852,7 +857,8 @@ describe('Channel Builder (#89)', () => {
const pageB1 = [browseItem({ id: 3, mediaItemId: 3, title: 'Item B2' })];
const pageB2 = [browseItem({ id: 4, mediaItemId: 4, title: 'Item B3' })];
let releaseB0: (() => void) | null = null;
// No-op initializer (not `null`) — see the note on `releaseStrandedAppend` above for why.
let releaseB0 = () => {};
const gateB0 = new Promise<void>((resolve) => {
releaseB0 = resolve;
});
@@ -914,7 +920,7 @@ describe('Channel Builder (#89)', () => {
expect(televisionShowPageNumsForB).toEqual([0]);
// Release B/page-0 — items replace the stale A rows.
releaseB0?.();
releaseB0();
await screen.findByText('Item B1');
await waitFor(() => {
expect((screen.getByRole('button', { name: 'Load more' }) as HTMLButtonElement).disabled).toBe(false);
@@ -1267,7 +1273,8 @@ describe('Channel Builder (#89)', () => {
const pageB0 = [browseItem({ id: 2, mediaItemId: 2, mediaType: 'TelevisionShow', title: 'Item B0' })];
const pageB1 = [browseItem({ id: 3, mediaItemId: 3, mediaType: 'TelevisionShow', title: 'Item B1' })];
let releaseB0: (() => void) | null = null;
// No-op initializer (not `null`) — see the note on `releaseStrandedAppend` above for why.
let releaseB0 = () => {};
const gateB0 = new Promise<void>((resolve) => {
releaseB0 = resolve;
});
@@ -1317,7 +1324,7 @@ describe('Channel Builder (#89)', () => {
// Let B/page-0 resolve; still no page-1 request should have appeared (the ignored call
// above must not have been queued for later, either).
releaseB0?.();
releaseB0();
await waitFor(() => expect(result.current.state.items.map((item) => item.title)).toContain('Item B0'));
expect(televisionShowPageNumsForB).toEqual([0]);