mirror of
https://github.com/civitai/civitai.git
synced 2026-09-20 22:08:18 +08:00
test(search): make the selector guards constrain the thing that can go wrong
Round-3 review ofa4ca0d1ff9. 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 atbbeffcf71e. `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:
@@ -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)}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user