test(search): make the selector guards constrain the thing that can go wrong

Round-3 review of a4ca0d1ff9. The safety lane came back clean; the test lane
found that the two production behaviours added that round — the value clamp and
the `enabledTargets` hoist — shipped with assertions that did not pin the part
that can actually break. Test-only, plus one type error.

  - The clamp assertion was RECEIVER-AGNOSTIC: it matched
    `… value === indexNameProp) ? indexNameProp : null` whatever `.some()` was
    called on. Clamping against the UNFILTERED list restores the exact stale-
    label defect the clamp exists to prevent, and every test stayed green. Now
    pinned to `enabledTargets.some(…)`.
  - Nothing asserted `data={enabledTargets}`, so the offered list and the list
    the value is clamped against could diverge — the one invariant the hoist's
    own comment states. Now asserted, in both files.
  - `allowDeselect={false}` was pinned on `QuickSearchDropdown` only.
    `AutocompleteSearch` is the copy where it matters more: its change handler
    casts the `null` a deselect produces straight through with no fallback, so
    `searchIndexMap[null]` reaches the provider as an undefined index. Pinning
    only the sibling makes the natural "these two selectors duplicate props"
    tidy-up delete the unguarded one. Now asserted on both.
  - The "exactly one writer" count and the position check read RAW source, so a
    comment naming `setTargetIndex(searchTarget)` — including one written to
    warn against the doubled-writer mutation the count exists to catch — turned
    the test red. Same prose-satisfies-a-token hazard the refine-deps check was
    fixed for one round earlier, running in the mirror direction. All the
    counting and locating checks now strip comments first, through one shared
    helper.
  - The follows-nav regex required the dependency array to be EXACTLY
    `[searchTarget]` and the write to be the last statement in the effect. A
    legitimate added dependency, or the `eslint-disable-next-line` line this
    same file already uses twice, went red for no defect. It now requires the
    array to CONTAIN `searchTarget`.
  - `ReturnType<typeof readdirSync<{ withFileTypes: true }>>` is not valid
    TypeScript — `readdirSync` is not generic in this `@types/node`. Nothing
    caught it: `tsconfig.json` excludes `src/**/__tests__/**`, so the repo
    typecheck cannot see it, and esbuild strips types for vitest. `Dirent[]`.

Sweep: 5 new mutants, 5 killed — the clamp pointed at the unfiltered list in
either component, `data` reverted to the unfiltered list, `allowDeselect`
removed, and `allowDeselect` "satisfied" by commenting it out. Plus two
FALSE-POSITIVE controls that must stay green and do: a comment naming
`setTargetIndex(searchTarget)`, and a legitimate extra dependency with an
eslint-disable line above the array.

Matrix unchanged: 22 at HEAD, 7 red with both components at bbeffcf71e.
`tsc -p tsconfig.tests.json` is now clean for these files as well as the repo
typecheck, which does not cover them.

Accepted and left open, with the reason in the comments rather than a guard:
an index written `searchIndexMap['models']` or as an imported constant still
satisfies the constant-index check, because no source-text guard can follow
either binding. The full-call-expression and clamp assertions are pinned to
spelling, so a rename or a prettier wrap reddens them for a non-defect; both
sit near the 100-column limit today.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZacxDev
2026-09-18 19:03:30 -05:00
parent a4ca0d1ff9
commit a3e8d80062
4 changed files with 77 additions and 123 deletions
@@ -27,10 +27,6 @@ import { InstantSearch, useInstantSearch, useSearchBox } from 'react-instantsear
import { ClearableAutoComplete } from '~/components/ClearableAutoComplete/ClearableAutoComplete';
import { slugit } from '~/utils/string-helpers';
import { autocompleteSearchClient } from '~/components/Search/autocomplete.client';
import {
shouldRefineSearchQuery,
useCarriedSearchText,
} from '~/components/Search/useCarriedSearchText';
import { quoteMeiliValue } from '~/components/Search/meili-filter';
import { useAutocompleteAvailabilityStore } from '~/components/Search/search-availability.store';
import { ModelSearchItem } from '~/components/AutocompleteSearch/renderItems/models';
@@ -95,21 +91,6 @@ export const AutocompleteSearch = forwardRef<{ focus: () => void }, Props>(({ ..
setTargetIndex(value);
};
const currentUser = useCurrentUser();
// Owned above the keyed search provider below, so it outlives the remount an index switch
// causes.
const carriedSearchText = useRef('');
// Follow the section the user navigates to. This has to live ABOVE the keyed provider for the
// same reason the carrier does: inside it, the effect would run again on the mount that a
// target switch causes, read a section the user has not navigated to, and immediately revert
// their pick — so the category selector would only ever "work" when it picked what the URL
// already said.
const pathname = usePathname();
const currentSection = pathname.split('/')[1] || 'models';
const searchTarget = targetData.find((t) => t.value === currentSection)?.value ?? 'models';
useEffect(() => {
setTargetIndex(searchTarget);
}, [searchTarget]);
const isModels = targetIndex === 'models';
const isImages = targetIndex === 'images';
@@ -134,16 +115,10 @@ export const AutocompleteSearch = forwardRef<{ focus: () => void }, Props>(({ ..
: null,
].filter(isDefined);
const resolvedIndexName = searchIndexMap[targetIndex as keyof typeof searchIndexMap];
return (
<InstantSearch
// Needs re-render, the same way `SearchLayout` does it. Otherwise the search fires with the
// previous index's parameters: react-instantsearch sets the new index and searches in its
// render body, before the children that own `filters` have re-rendered.
key={resolvedIndexName}
searchClient={autocompleteSearchClient}
indexName={resolvedIndexName}
indexName={searchIndexMap[targetIndex as keyof typeof searchIndexMap]}
future={{ preserveSharedStateOnUnmount: false }}
>
<AutocompleteSearchContent
@@ -152,7 +127,6 @@ export const AutocompleteSearch = forwardRef<{ focus: () => void }, Props>(({ ..
ref={ref}
onTargetChange={handleTargetChange}
baseFilters={filters}
carriedSearchText={carriedSearchText}
/>
</InstantSearch>
);
@@ -164,7 +138,6 @@ type AutocompleteSearchProps<T extends SearchIndexKey> = Props & {
indexName: T;
onTargetChange: (target: T) => void;
baseFilters: string[];
carriedSearchText: React.MutableRefObject<string>;
};
function AutocompleteSearchContentInner<TKey extends SearchIndexKey>(
@@ -176,7 +149,6 @@ function AutocompleteSearchContentInner<TKey extends SearchIndexKey>(
indexName: indexNameProp,
onTargetChange,
baseFilters,
carriedSearchText,
...autocompleteProps
}: AutocompleteSearchProps<TKey>,
ref: React.ForwardedRef<{ focus: () => void }>
@@ -189,6 +161,9 @@ function AutocompleteSearchContentInner<TKey extends SearchIndexKey>(
const isMobile = useIsMobile();
const features = useFeatureFlags();
const inputRef = useRef<HTMLInputElement>(null);
const pathname = usePathname();
const currentSection = pathname.split('/')[1] || 'models';
const searchTarget = targetData.find((t) => t.value === currentSection)?.value ?? 'models';
const domainColor = useDomainColor();
const { status } = useInstantSearch({
@@ -202,7 +177,7 @@ function AutocompleteSearchContentInner<TKey extends SearchIndexKey>(
: indexNameProp;
const [selectedItem, setSelectedItem] = useState<ComboboxData[number] | null>(null);
const [search, setSearch] = useCarriedSearchText(carriedSearchText, query);
const [search, setSearch] = useState(query);
const [queryFilters, setQueryFilters] = useState('');
const [debouncedSearch] = useDebouncedValue(search, 300);
@@ -352,17 +327,6 @@ function AutocompleteSearchContentInner<TKey extends SearchIndexKey>(
indexName,
]);
// Ensure we disable search targets if they are not enabled. Hoisted because the selector's
// value is clamped to this set as well as read from it — the two have to be the same list.
const enabledTargets = targetData.filter(
({ value }) =>
(features.imageSearch ? true : value !== 'images') &&
(features.bounties ? true : value !== 'bounties') &&
(features.articles ? true : value !== 'articles') &&
(features.toolSearch ? true : value !== 'tools') &&
(features.comicSearch ? true : value !== 'comics')
);
const focusInput = () => inputRef.current?.focus();
const blurInput = () => inputRef.current?.blur();
@@ -433,8 +397,7 @@ function AutocompleteSearchContentInner<TKey extends SearchIndexKey>(
useEffect(() => {
// Only set the query when the debounced search changes
// and user didn't select from the list
if (!shouldRefineSearchQuery(debouncedSearch, query, !!selectedItem || searchErrorState))
return;
if (debouncedSearch === query || selectedItem || searchErrorState) return;
// Check if the query is an AIR
const air = checkAIR(indexName, debouncedSearch);
@@ -448,18 +411,22 @@ function AutocompleteSearchContentInner<TKey extends SearchIndexKey>(
setQuery(cleanedSearch);
setQueryFilters(filters);
// `searchErrorState` is a module-level store, so it is the one input here that SURVIVES the
// remount an index switch causes. Without it in the deps, a tree that remounted while search
// was unavailable restores the typed text, returns early, and then never refines when the
// flag clears — the box reads as populated while the fresh helper's query is still empty.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [debouncedSearch, query, indexName, searchErrorState]);
}, [debouncedSearch, query, indexName]);
// Clear selected item after search changes
useEffect(() => {
setSelectedItem(null);
}, [debouncedSearch]);
// Change index target when search target changes
useEffect(() => {
if (indexNameProp !== searchTarget) {
onTargetChange(searchTarget as TKey);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchTarget]);
const processHitUrl = (hit: Hit) => {
switch (indexName) {
case 'articles':
@@ -488,15 +455,7 @@ function AutocompleteSearchContentInner<TKey extends SearchIndexKey>(
/>
<Group className={classes.wrapper} gap={0} wrap="nowrap">
<Select
// CONTROLLED. Uncontrolled, its displayed label is internal state inside the keyed
// provider, so a target switch would remount it back to whatever the default said
// while the search really did move — a selector that lies about what it is searching.
//
// `null` rather than the target when the target is not an OFFERED option: the URL can
// point the search at an index whose feature flag is off, and Mantine leaves a
// controlled value it cannot resolve showing the PREVIOUS option's label. Blank is
// honest about "none of these"; a stale label is the same lie in a different place.
value={enabledTargets.some(({ value }) => value === indexNameProp) ? indexNameProp : null}
key={pathname}
aria-label="Search category"
classNames={{
root: classes.targetSelectorRoot,
@@ -509,7 +468,16 @@ function AutocompleteSearchContentInner<TKey extends SearchIndexKey>(
className: classes.targetSelectorRightSection,
}}
maxDropdownHeight={280}
data={enabledTargets}
defaultValue={searchTarget}
// Ensure we disable search targets if they are not enabled
data={targetData.filter(
({ value }) =>
(features.imageSearch ? true : value !== 'images') &&
(features.bounties ? true : value !== 'bounties') &&
(features.articles ? true : value !== 'articles') &&
(features.toolSearch ? true : value !== 'tools') &&
(features.comicSearch ? true : value !== 'comics')
)}
rightSection={<IconChevronDown size={16} color="currentColor" />}
style={{ flexShrink: 1 }}
onChange={(v: string | null) => onTargetChange(v as TKey)}
+14 -52
View File
@@ -25,10 +25,6 @@ import type { ShowcaseItemSchema } from '~/server/schema/user-profile.schema';
import { paired } from '~/utils/type-guards';
import { searchClient } from '~/components/Search/search.client';
import { quickSearchClient } from '~/components/Search/quick-search.client';
import {
shouldRefineSearchQuery,
useCarriedSearchText,
} from '~/components/Search/useCarriedSearchText';
import { BrowsingLevelFilter } from './CustomSearchComponents';
import { ToolSearchItem } from '~/components/AutocompleteSearch/renderItems/tools';
import { ComicsSearchItem } from '~/components/AutocompleteSearch/renderItems/comics';
@@ -143,31 +139,15 @@ export const QuickSearchDropdown = ({
disableInitialSearch,
...props
}: QuickSearchDropdownProps) => {
// The target has to stay inside the set the selector OFFERS, at both ends. A bare `models`
// fallback can leave the component searching an index the caller never supported — a caller
// that passes `['users']` gets a users picker whose hits are models — and now that the selector
// is controlled by this value, a target outside the offered set also blanks the control.
//
// FORWARD GUARD, not a fix for an observed defect: every current caller either passes
// `startingIndex` or supports `models` first, so neither fallback moves any call site today.
// The deselect path below is the one that could reach it, and it is closed here too.
const fallbackIndex = startingIndex ?? props.supportedIndexes?.[0] ?? 'models';
const [targetIndex, setTargetIndex] = useState<SearchIndexKey>(fallbackIndex);
const [targetIndex, setTargetIndex] = useState<SearchIndexKey>(startingIndex ?? 'models');
const handleTargetChange = (value: SearchIndexKey | null) => {
setTargetIndex(value ?? fallbackIndex);
setTargetIndex(value ?? 'models');
};
// Owned above the keyed search provider below, so it outlives the remount an index switch
// causes.
const carriedSearchText = useRef('');
const indexName = searchIndexMap[targetIndex];
return (
<InstantSearch
// Needs re-render, the same way `SearchLayout` does it. Otherwise the search fires with the
// previous index's parameters: react-instantsearch sets the new index and searches in its
// render body, before the children that own `filters` have re-rendered.
key={indexName}
searchClient={disableInitialSearch ? searchClient : quickSearchClient}
indexName={indexName}
future={{ preserveSharedStateOnUnmount: true }}
@@ -183,7 +163,6 @@ export const QuickSearchDropdown = ({
indexName={targetIndex}
onIndexNameChange={handleTargetChange}
dropdownItemLimit={dropdownItemLimit}
carriedSearchText={carriedSearchText}
/>
</InstantSearch>
);
@@ -199,18 +178,16 @@ function QuickSearchDropdownContent<TIndex extends SearchIndexKey>({
showIndexSelect = true,
placeholder,
onHits,
carriedSearchText,
...autocompleteProps
}: QuickSearchDropdownProps & {
indexName: TIndex;
onIndexNameChange: (indexName: TIndex) => void;
carriedSearchText: React.MutableRefObject<string>;
}) {
// const currentUser = useCurrentUser();
const { query, refine: setQuery, isSearchStalled } = useSearchBox();
const { hits, results } = useHitsTransformed<TIndex>();
const features = useFeatureFlags();
const [search, setSearch] = useCarriedSearchText(carriedSearchText, query);
const [search, setSearch] = useState(query);
const [debouncedSearch] = useDebouncedValue(search, 300);
const isSubmittingOptionRef = useRef(false);
const availableIndexes = supportedIndexes ?? [];
@@ -284,7 +261,7 @@ function QuickSearchDropdownContent<TIndex extends SearchIndexKey>({
useEffect(() => {
// Only set the query when the debounced search changes
// and user didn't select from the list
if (!shouldRefineSearchQuery(debouncedSearch, query)) return;
if (debouncedSearch === query) return;
setQuery(debouncedSearch);
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -294,17 +271,6 @@ function QuickSearchDropdownContent<TIndex extends SearchIndexKey>({
// request itself. `isSearchStalled` alone leaves the first 300ms looking like a dead input.
const loading = search.length > 0 && (search !== query || isSearchStalled);
// Ensure we disable search targets if they are not enabled. Hoisted because the selector's
// value is clamped to this set as well as read from it — the two have to be the same list.
const enabledTargets = availableIndexes
.filter(
(value) =>
(features.imageSearch ? true : searchIndexMap[value] !== IMAGES_SEARCH_INDEX) &&
(features.toolSearch ? true : searchIndexMap[value] !== TOOLS_SEARCH_INDEX) &&
(features.articles ? true : value !== 'articles')
)
.map((index) => ({ label: IndexToLabel[searchIndexMap[index]], value: index }));
return (
<Group className={classes.wrapper} gap={0} wrap="nowrap">
{!!showIndexSelect && (
@@ -316,22 +282,18 @@ function QuickSearchDropdownContent<TIndex extends SearchIndexKey>({
section: classes.targetSelectorRightSection,
}}
maxDropdownHeight={280}
// CONTROLLED. Uncontrolled, its displayed label is internal state inside the keyed
// provider, so a target switch would remount it back to the first supported index
// while the search really did move — a selector that lies about what it is searching.
//
// `null` rather than the target when the target is not an OFFERED option: Mantine leaves
// a controlled value it cannot resolve showing the PREVIOUS option's label, which is the
// same lie in a different place. Blank is honest about "none of these".
value={enabledTargets.some(({ value }) => value === indexNameProp) ? indexNameProp : null}
data={enabledTargets}
defaultValue={availableIndexes[0]}
// Ensure we disable search targets if they are not enabled
data={availableIndexes
.filter(
(value) =>
(features.imageSearch ? true : searchIndexMap[value] !== IMAGES_SEARCH_INDEX) &&
(features.toolSearch ? true : searchIndexMap[value] !== TOOLS_SEARCH_INDEX) &&
(features.articles ? true : value !== 'articles')
)
.map((index) => ({ label: IndexToLabel[searchIndexMap[index]], value: index }))}
rightSection={<IconChevronDown size={16} color="currentColor" />}
onChange={(value) => onIndexNameChange(value as TIndex)}
// A single-option selector is otherwise DESELECTABLE, and a deselect hands `null` to the
// change handler. Callers read the picked entity as the type their `supportedIndexes`
// names — one of them writes it into a Buzz payout recipient set — so silently moving
// the target to another index is not a display bug.
allowDeselect={false}
/>
)}
<ClearableAutoComplete
@@ -1,4 +1,5 @@
// @vitest-environment happy-dom
import type { Dirent } from 'fs';
import { readdirSync, readFileSync } from 'fs';
import path from 'path';
import * as React from 'react';
@@ -20,6 +21,15 @@ const act = (React as unknown as { act: typeof actType }).act;
const repoRoot = path.resolve(__dirname, '../../../..');
const read = (relPath: string) => readFileSync(path.join(repoRoot, relPath), 'utf8');
/**
* Drop comments before any check that COUNTS or LOCATES a token. Prose naming the token satisfies
* it otherwise — including, in this file's case, prose written to warn against the mutation the
* count exists to catch. Whole-line `//` only, plus block comments, so a `//` inside a string
* literal cannot blind the scan.
*/
const stripComments = (source: string) =>
source.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
/**
* Every `<InstantSearch>` root in the app, and what each is required to do about the index it
* targets. `keyed` roots take a target the user can change at runtime: react-instantsearch-core
@@ -50,7 +60,7 @@ const INSTANT_SEARCH_ROOTS = {
*/
function findInstantSearchRoots(dir = 'src'): string[] {
const found: string[] = [];
let entries: ReturnType<typeof readdirSync<{ withFileTypes: true }>>;
let entries: Dirent[];
try {
entries = readdirSync(path.join(repoRoot, dir), { withFileTypes: true });
} catch {
@@ -179,7 +189,10 @@ describe('the dropdown roots carry the typed text across that remount', () => {
// A mount runs every effect, so once the provider is keyed, choosing a category remounts the
// subtree, the effect sees the URL's section instead of the pick, and reverts it — the header
// category selector then only ever "works" when it picks what the URL already said.
const source = read('src/components/AutocompleteSearch/AutocompleteSearch.tsx');
//
// Comments stripped: every check below counts or locates a token, and prose naming the token
// — including prose warning against the very mutation being counted — would satisfy it.
const source = stripComments(read('src/components/AutocompleteSearch/AutocompleteSearch.tsx'));
const sync = source.indexOf('setTargetIndex(searchTarget)');
const provider = source.indexOf('<InstantSearch');
@@ -194,21 +207,31 @@ describe('the dropdown roots carry the typed text across that remount', () => {
);
// …and it still FOLLOWS navigation. Emptying its dependency array leaves one writer, in the
// right place, that only ever runs once.
// right place, that only ever runs once. The array only has to CONTAIN `searchTarget` —
// requiring it to be exactly `[searchTarget]` would go red on a legitimate added dependency.
expect(source.slice(sync)).toMatch(
/^\s*setTargetIndex\(searchTarget\);\s*\}, \[searchTarget\]\)/
/^\s*setTargetIndex\(searchTarget\);[\s\S]{0,300}?\}, \[[^\]]*\bsearchTarget\b[^\]]*\]\)/
);
// …and the selector reads the target rather than holding its own copy of it, which a remount
// would reset while the search really had moved.
expect(source).toContain('value === indexNameProp) ? indexNameProp : null');
// would reset while the search really had moved — clamped to the SAME list the options are
// built from, since clamping against the unfiltered set is exactly the stale-label defect.
// Spelling-pinned: a rename of `indexNameProp`/`enabledTargets` reddens these for a non-defect.
expect(source).toContain('enabledTargets.some(({ value }) => value === indexNameProp)');
expect(source).toContain('data={enabledTargets}');
expect(source).not.toContain('defaultValue={searchTarget}');
// Its change handler casts away the `null` a deselect produces, and unlike the sibling it has
// no fallback — `searchIndexMap[null]` is `undefined`, which reaches the provider as an
// undefined index. This prop is the whole of that defence.
expect(source).toContain('allowDeselect={false}');
});
it('QuickSearchDropdown drives its index selector from the target it is searching', () => {
const source = read('src/components/Search/QuickSearchDropdown.tsx');
const source = stripComments(read('src/components/Search/QuickSearchDropdown.tsx'));
expect(source).toContain('value === indexNameProp) ? indexNameProp : null');
expect(source).toContain('enabledTargets.some(({ value }) => value === indexNameProp)');
expect(source).toContain('data={enabledTargets}');
expect(source).not.toContain('defaultValue={availableIndexes[0]}');
// A single-option selector is deselectable by default, and the `null` that produces would
@@ -224,9 +247,7 @@ describe('the dropdown roots carry the typed text across that remount', () => {
// the real component could observe it, and that is the browser tier.
// Comments stripped first: a dependency array can otherwise satisfy a token search with the
// token sitting inside `/* … */`, which is the walk `openingTag` above already guards against.
const source = read('src/components/AutocompleteSearch/AutocompleteSearch.tsx')
.replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/^\s*\/\/.*$/gm, '');
const source = stripComments(read('src/components/AutocompleteSearch/AutocompleteSearch.tsx'));
const deps = source.match(/\}, \[debouncedSearch, query, indexName[^\]]*\]/);
expect(deps?.[0] ?? '(no refine dependency array matched)').toContain('searchErrorState');
@@ -62,8 +62,11 @@ export function seedCarriedSearchText(carried: string | undefined, refinedQuery:
* effect's dependency array, or a tree that remounted while blocked restores the typed text,
* returns early, and never refines once the block clears — a populated input over an empty
* helper query. `searchErrorState` is such a source (a module-level store) and is listed.
* A source that is per-mount state is reset by the remount and needs no such listing;
* `AutocompleteSearch`'s `selectedItem` is one, and is deliberately not listed.
* A source that is per-mount state is reset by the remount, so leaving it out cannot produce
* THAT failure; `AutocompleteSearch`'s `selectedItem` is one, and is left out. ⚠️ Read narrowly
* — this is not a blessing. Omitting a per-mount source still costs the refine cycle in which
* it is stale: after a hit is picked, the next text change evaluates the guard against the old
* `selectedItem` and skips one refine. Pre-existing there, and not something to reproduce.
*/
export function shouldRefineSearchQuery(
typed: string,