6b41340abe36f00a6c9d353ed1465d7060a9daed
2
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
1c86a1c1fc |
fix(650): enforce single-flight in useLibraryBrowse; close scanner false negatives
Third cold cross-family review (BLOCKED) found the append/page-0-refresh races were being fixed one interleaving at a time — round 1 fixed page-0-settles-first, round 2's compare-and-set rollback fixed the duplicate-append case but introduced a permanently-skipped page, and the reviewer found the exact mirror of round 1's fix (page-1-settles-first, erasing page 1 with no cursor reset). Direction from the review: stop enumerating orderings, make the overlap structurally impossible. SINGLE-FLIGHT (web/src/builder/libraryBrowse.ts): a new `busyRef` guard is true from the moment ANY fetch (a page-0 refresh OR an append) for the current query generation is issued until it settles. `loadMore` checks it SYNCHRONOUSLY and returns immediately (ignored, not queued) if a fetch is already in flight — including a page-0 refresh, not just a prior append, so a "Load more" click that lands while a query change is still resolving is a no-op rather than starting a second, overlapping request. With overlapping fetches eliminated by construction, the append-failure rollback no longer needs the round-2 compare-and-set: single-flight guarantees nothing else could have moved `pageRef` since a given fetch started, so it now always rolls back and retries the exact page that failed, unconditionally. Visual feedback (the button showing loading/disabled during a page-0 refresh, not just an append) is set via `queueMicrotask(() => setLoadingMore (true))` rather than a bare synchronous call in the generation-change effect — `react-hooks/set-state-in-effect` flags the latter; a microtask-deferred call resolves before any human-perceptible input, satisfies the lint rule (the same reason `.then()` callbacks elsewhwere in this hook aren't flagged), and keeps the actual correctness guarantee (the ref check) perfectly synchronous regardless. TESTS REWRITTEN, not just added — the round-2 "HIGH-2" hook test explicitly asserted the NEXT request after a failed page 1 (following an overlapping page 2 success) should be page 3, i.e. it blessed page 1's permanent loss. Replaced with two hook-level tests: single-flight ignores a synchronous double `loadMore()` call (only one fetch issued), and a failed page is retried as the SAME page number. Replaced the round-2 component-level "HIGH-1" test (which drove the now-impossible overlap through the DOM) with one asserting the click during a pending page-0 refresh is ignored, and that once free, the correct page-1-then-page-2 sequence completes with both pages' rows present. Verified all three new/rewritten tests against the prior committed hook (7b1ae48b0): the two single-flight-specific tests fail as expected (`[0, 1]` requested when only `[0]` should have been); the retry-semantics test happens to pass against 7b1ae48b0 too (compare-and-set and unconditional rollback coincide in the non-overlapping case) but is kept because it is the correct "retry as page 1, not page 3" pin the review asked for, replacing the one that asserted the wrong thing. SCANNER (pageSizeScan.ts) — closed three documented false-negative classes: - Transparent TS wrappers around the initializer (`pageSize: 100 as const`, `100 satisfies number`, parenthesized) are now unwrapped before the NumericLiteral/Identifier check. - Non-Identifier property names: a quoted string key (`'pageSize': 100`) or a statically-resolvable computed key (`['pageSize']: 100`) are now accepted; a computed key that isn't a literal correctly stays unresolved. - `.mts`/`.cts` are no longer silently excluded from the guard's file discovery glob (tsconfig.app.json's `include` covers all of `src`; no such files exist in the repo today, but the glob shouldn't hide one if it ever does). 10 new fixture tests in pageSizeScan.test.ts pin each case (plus a rejection test confirming a forwarded call wrapped in `as` still doesn't match, and one confirming an unresolvable computed key stays unmatched). TEST LABELLING: relabeled the URL/M-3 and `??`/M-4 fixtures as CONTRACT fixtures rather than regression pins — a round-3 review found round 1's plain literal regex already handled those two exact inputs correctly on its own; only the combined multi-case fixture (and the string-contains-text, template-interpolation, same-line-identity, JSX, and destructuring fixtures) actually fail against round 1. Labeled the guard test's 4 tests as BASELINE assertions (they all pass on clean b90f8a3b) rather than implying they prove this round's specific fixes — pageSizeScan.test.ts's fixtures are what actually regression-pin the scanner. No server-side/C# change. Full local gate: lint clean, tsc clean, full vitest run 110 files / 1063 tests passed (re-run twice, stable). |
||
|
|
ca99bedb1a |
fix(650): rewrite the pageSize guard on the TS compiler API; fix two append-ownership races
Second cold cross-family (Codex, BLOCKED) re-review of b90f8a3b found the
regex/bracket-tracking guard scanner still defeated in five ways, and two new
High-severity races introduced by the F3/F4 fixes. Addressed as a further
follow-up (b90f8a3b left untouched).
GUARD REWRITE (per the review's explicit direction — stop patching the regex,
use the compiler):
- New `web/src/api/pageSizeScan.ts`: `scanPageSizeSites` parses each file with
`ts.createSourceFile` and walks the real AST for `pageSize`
PropertyAssignment/ShorthandPropertyAssignment nodes inside an
ObjectLiteralExpression. This eliminates categorically (not case-by-case):
- M-3: comments and string/template CONTENTS are never revisited as code,
so a `'https://...'` string can't be misread as an unterminated string
that swallows the rest of the file.
- M-4: an object literal nested in a ternary, `??`, or JSX expression
container is still found — the walk visits every descendant node
regardless of the syntactic context above the ObjectLiteralExpression.
- M-5: template-literal interpolations are real AST children, not opaque
text.
- L-7: a type literal (`type P = { pageSize: 100 }`), an interface
PropertySignature, and a destructuring ObjectBindingPattern (parameter
or nested) are structurally different node kinds from
ObjectLiteralExpression — excluded by kind, not by a
preceding-character heuristic a stray `{`/`(`/`,` could fool.
`getLineAndCharacterOfPosition` gives exact line+column (fixes M-6 identity
granularity) instead of the prior line-only identity.
- `pageSizeCallSites.guard.test.ts` now imports the shared scanner; identity
is `file:line:column:kind:value`, compared as a MULTISET (count, not
membership) in both directions.
- Both directions (unregistered / stale) are computed and folded into ONE
thrown Error so a failure always shows the complete picture in one run,
addressing the line-churn "second direction never renders" concern.
- New `pageSizeScan.test.ts`: a FIXTURE test (inline source strings, no repo
scan) pinning the exact discovered set for every case the review named —
comment-in-string, string containing the literal text `pageSize: 100`,
template interpolation, ternary, `??`, JSX container, same-line duplicates,
parameter/nested destructuring, a type literal, an interface property, a
forwarded call expression, a React dependency array. This is what actually
protects the scanner going forward — the guard test alone only ever proved
today's snapshot of real call sites, never the scanner's handling of input
classes it hadn't happened to encounter yet.
- Re-verified both original plants (a duplicate at-cap call in an
already-registered file, and a new file with both a literal and a
shorthand site) against the rewritten scanner; both still fail with the
new combined-direction message. Also verified a run with BOTH directions
simultaneously non-empty renders both in one report.
HIGH-1 (ChannelBuilder.tsx useLibraryBrowse, now web/src/builder/libraryBrowse.ts):
`reqId` identifies a query GENERATION, not an individual fetch — a page-0
refresh and a "Load more" append can be outstanding simultaneously under the
same reqId (query changes while an append is in flight for the new
generation). Whichever settled first used to clear `loadingMore`, letting a
second click fire an out-of-order/duplicate page fetch. Fixed with a
per-fetch `fetchId` plus a `loadingFetchIdRef`/`loadingFetchReqIdRef` pair:
only the fetch that OWNS the currently-displayed spinner can clear it; a
same-generation page-0 refresh leaves a same-generation append's spinner
alone, while a page-0 refresh for a NEW generation still retires an
abandoned OLDER-generation append's spinner (preserving the original #650 F3
fix). Reproduced the exact interleaving from the review in a new test
(gate B's page-0 and page-1 fetches independently, click "Load more" while
B's page-0 is still in flight) and confirmed it fails without the fix
(button re-enables while the append is still pending).
HIGH-2 (same file): the append-failure rollback mutated whatever
`pageRef.current` currently held, rather than the specific page THIS fetch
requested — under an overlapping-append race, a later page's success
followed by an earlier page's failure could roll the cursor back past
already-appended progress, corrupting a retry into refetching a duplicate.
Fixed with a compare-and-set guard (`if (pageRef.current === pageNum)`) so
the rollback only fires when nothing has advanced the cursor since. Since
this overlap is UI-unreachable once HIGH-1's single-flight disabling is
wired up (verified empirically: two synchronous fireEvent.click calls in RTL
only produce one request, since act() flushes the disabling render between
them), the regression test drives `useLibraryBrowse` directly via
`renderHook` (now exported) to force the exact interleaving and confirms it
fails without the fix (page 2 gets duplicated, page 3 never requested).
Extracted `useLibraryBrowse` (plus `loadCollections`/`loadLibraryItems`/
`BrowseState`/the media-type const arrays) into a new non-JSX module
`web/src/builder/libraryBrowse.ts` — exporting a hook from a .tsx file
tripped `react-refresh/only-export-components`; this also makes the hook
importable by `renderHook` without pulling in the whole screen component.
No server-side/C# change. Full local gate: lint clean, tsc clean, full
vitest run 110 files / 1051 tests passed (one LibrariesScreen.test.tsx
flake reproduced under full-suite parallel load, confirmed pre-existing and
unrelated — passes in isolation, never touched that file).
|