fix(search): rebuild the dropdown search provider when its index changes

`SearchLayout` keys its `<InstantSearch>` on the index name and says why:
"Needs re-render. Otherwise the prev. index will screw up the app." The header
autocomplete and the quick-search dropdown never got that key, so they carry the
defect the comment describes.

react-instantsearch-core calls `helper.setIndex(indexName).search()` in its
RENDER body (`lib/useInstantSearchApi.js`), and the provider renders before its
children. So on a target switch the search fires against the NEW index while the
helper still holds the PREVIOUS target's `Configure` parameters — the children
that own `filters` have not re-rendered yet. Keying the provider makes React
build a fresh one instead, and the children mount their parameters onto it
before it searches.

This is additive to the request-level guard already on this branch, and it
covers a direction that guard structurally cannot. The guard drops a request
naming an attribute its target index does not declare. When the previous
target's attributes are all declared on the new index — `articles` and
`collections` are subsets of `models` — the stale parameter set is perfectly
valid there, so it is sent, and the clauses the new target builds only for
itself are simply missing from it. That answers 200 and appears in no error
signal. The key closes it at the source: there is no stale parameter set to
send.

The reason the dropdowns could not just copy `SearchLayout` is that remounting
clears the text the user is typing, which lives inside the provider's subtree.
So each root now holds that text in a ref ABOVE the keyed boundary and the
remounted input is seeded from it (`useCarriedSearchText`). Seeding is not only
cosmetic: a rebuilt helper reports an empty query, so the seeded text differs
from it, and that difference is what makes each component's existing "push the
text into the helper" effect fire again — the search is RE-RUN on the new index
rather than the input merely re-displaying the old text. An empty carrier falls
back to the helper's own query, which is what a first mount did before.

Every write to that text goes through the setter the hook returns, so the
carrier can never hold a value the input no longer shows.

Tests, in `src/components/Search/__tests__/dropdown-index-remount.test.ts`:

- A ledger of every `<InstantSearch>` root in `src/`, derived from the tree so it
  fails when the population grows or shrinks. Each keyed root must key on the
  very expression it passes as `indexName` — not merely carry some key, which
  could disagree with the index. `CollectionSelectModal` is the one exclusion and
  its stated reason is asserted, not taken on trust: its index is a fixed member
  of `searchIndexMap`, so there is no switch to survive.
- The carry, asserted structurally on both dropdown roots: the ref is declared
  above the provider, threaded into the content component, and read through the
  hook — and the `useState(query)` shape it replaced, which comes back empty on
  a remount, is banned.
- The hook's behaviour, exercised in the node tier against a real React remount:
  text typed before a key change survives it, and comes back differing from the
  rebuilt helper's empty query. The negative control is the same tree seeded the
  old way, which loses the text — without it, a harness that silently never
  remounted would pass every other assertion while asserting nothing.

Matrix: the four structural tests are RED at `bbeffcf71e` (no `key` prop, no
carrier) and green at HEAD. The behavioural and ledger tests are new-behaviour
guards, not regression tests, and are green at both. Three mutants of the hook
were each killed by their own assertion, with the source restored by digest
after each and a green re-run after the sweep.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZacxDev
2026-09-18 18:01:49 -05:00
parent bbeffcf71e
commit 19adaeb56e
4 changed files with 327 additions and 3 deletions
@@ -27,6 +27,7 @@ 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 { 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';
@@ -91,6 +92,9 @@ 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('');
const isModels = targetIndex === 'models';
const isImages = targetIndex === 'images';
@@ -115,10 +119,16 @@ 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={searchIndexMap[targetIndex as keyof typeof searchIndexMap]}
indexName={resolvedIndexName}
future={{ preserveSharedStateOnUnmount: false }}
>
<AutocompleteSearchContent
@@ -127,6 +137,7 @@ export const AutocompleteSearch = forwardRef<{ focus: () => void }, Props>(({ ..
ref={ref}
onTargetChange={handleTargetChange}
baseFilters={filters}
carriedSearchText={carriedSearchText}
/>
</InstantSearch>
);
@@ -138,6 +149,7 @@ type AutocompleteSearchProps<T extends SearchIndexKey> = Props & {
indexName: T;
onTargetChange: (target: T) => void;
baseFilters: string[];
carriedSearchText: React.MutableRefObject<string>;
};
function AutocompleteSearchContentInner<TKey extends SearchIndexKey>(
@@ -149,6 +161,7 @@ function AutocompleteSearchContentInner<TKey extends SearchIndexKey>(
indexName: indexNameProp,
onTargetChange,
baseFilters,
carriedSearchText,
...autocompleteProps
}: AutocompleteSearchProps<TKey>,
ref: React.ForwardedRef<{ focus: () => void }>
@@ -177,7 +190,7 @@ function AutocompleteSearchContentInner<TKey extends SearchIndexKey>(
: indexNameProp;
const [selectedItem, setSelectedItem] = useState<ComboboxData[number] | null>(null);
const [search, setSearch] = useState(query);
const [search, setSearch] = useCarriedSearchText(carriedSearchText, query);
const [queryFilters, setQueryFilters] = useState('');
const [debouncedSearch] = useDebouncedValue(search, 300);
+12 -1
View File
@@ -25,6 +25,7 @@ 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 { useCarriedSearchText } from '~/components/Search/useCarriedSearchText';
import { BrowsingLevelFilter } from './CustomSearchComponents';
import { ToolSearchItem } from '~/components/AutocompleteSearch/renderItems/tools';
import { ComicsSearchItem } from '~/components/AutocompleteSearch/renderItems/comics';
@@ -143,11 +144,18 @@ export const QuickSearchDropdown = ({
const handleTargetChange = (value: SearchIndexKey | null) => {
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 }}
@@ -163,6 +171,7 @@ export const QuickSearchDropdown = ({
indexName={targetIndex}
onIndexNameChange={handleTargetChange}
dropdownItemLimit={dropdownItemLimit}
carriedSearchText={carriedSearchText}
/>
</InstantSearch>
);
@@ -178,16 +187,18 @@ 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] = useState(query);
const [search, setSearch] = useCarriedSearchText(carriedSearchText, query);
const [debouncedSearch] = useDebouncedValue(search, 300);
const isSubmittingOptionRef = useRef(false);
const availableIndexes = supportedIndexes ?? [];
@@ -0,0 +1,249 @@
// @vitest-environment happy-dom
import { readdirSync, readFileSync } from 'fs';
import path from 'path';
import * as React from 'react';
import type { act as actType } from 'react-dom/test-utils';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, describe, expect, it } from 'vitest';
import {
seedCarriedSearchText,
useCarriedSearchText,
} from '~/components/Search/useCarriedSearchText';
// React 18.3 exposes `act` on the `react` export, but our @types/react (18.0.x) predates that
// typing. Use the runtime `React.act` and borrow the signature from react-dom/test-utils — the
// same arrangement the other node-tier React tests in this repo use.
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');
/**
* 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
* calls `helper.setIndex(indexName).search()` in its RENDER body, and the provider renders before
* the children that own `filters`, so a target switch on an unkeyed root searches the NEW index
* with the PREVIOUS target's parameters. `key` makes React build a fresh provider instead.
*
* The ledger is exhaustive on purpose: a new root added without a decision fails this rather than
* inheriting a default.
*/
const INSTANT_SEARCH_ROOTS = {
'src/components/Search/SearchLayout.tsx': 'keyed',
'src/components/AutocompleteSearch/AutocompleteSearch.tsx': 'keyed',
'src/components/Search/QuickSearchDropdown.tsx': 'keyed',
// Exempt, and the reason is asserted below rather than taken on trust: its index is a fixed
// member of `searchIndexMap`, so there is no switch for a stale parameter set to survive.
'src/components/CollectionSelectModal/CollectionSelectModal.tsx': 'static-index',
} as const;
/** Every non-test `.tsx` under `src/` that renders an `<InstantSearch>` element, repo-relative. */
function findInstantSearchRoots(dir = 'src'): string[] {
const found: string[] = [];
for (const entry of readdirSync(path.join(repoRoot, dir), { withFileTypes: true })) {
const relPath = `${dir}/${entry.name}`;
if (entry.isDirectory()) {
found.push(...findInstantSearchRoots(relPath));
} else if (entry.name.endsWith('.tsx') && !entry.name.includes('.test.')) {
if (read(relPath).includes('<InstantSearch')) found.push(relPath);
}
}
return found;
}
/** The opening `<InstantSearch …>` tag, with `//` comments stripped so prose can't satisfy a prop match. */
function openingTag(source: string): string {
// The ELEMENT, not a mention of it: prose naming the tag matches the same pattern, so the tag is
// identified by the prop every root must pass rather than by its name alone.
const tags = [...source.matchAll(/<InstantSearch\b[^>]*>/g)]
.map((m) => m[0].replace(/^\s*\/\/.*$/gm, ''))
.filter((tag) => /\ssearchClient=\{/.test(tag));
if (tags.length !== 1)
throw new Error(`expected one <InstantSearch> element, found ${tags.length}`);
return tags[0];
}
function propExpression(tag: string, prop: string): string | null {
const match = tag.match(new RegExp(`(?:^|\\s)${prop}=\\{([^}]*)\\}`));
return match ? match[1].trim() : null;
}
describe('the InstantSearch roots', () => {
it('is the set this ledger accounts for', () => {
// Derived from the tree, so the ledger fails when the population grows OR shrinks — a new
// root cannot be added without deciding what it does about a changing index.
expect(findInstantSearchRoots().sort()).toEqual(Object.keys(INSTANT_SEARCH_ROOTS).sort());
});
for (const [relPath, policy] of Object.entries(INSTANT_SEARCH_ROOTS)) {
if (policy === 'static-index') {
it(`${relPath} targets a fixed index, so it needs no key`, () => {
const tag = openingTag(read(relPath));
expect(propExpression(tag, 'indexName')).toMatch(/^searchIndexMap\.[A-Za-z]+$/);
expect(propExpression(tag, 'key')).toBeNull();
});
continue;
}
it(`${relPath} keys its provider on the very expression it passes as indexName`, () => {
const tag = openingTag(read(relPath));
const indexName = propExpression(tag, 'indexName');
const key = propExpression(tag, 'key');
// Not merely "a key is present": the key and the index have to be the SAME expression, or
// they can disagree and the provider survives a switch it was supposed to be rebuilt for.
expect(indexName).toBeTruthy();
expect(key).toBe(indexName);
});
}
});
describe('the dropdown roots carry the typed text across that remount', () => {
// Keying the provider remounts the subtree the typed text lives in, so each of these two has to
// hold that text ABOVE the provider. Asserted structurally because the components themselves are
// browser-tier; the carry mechanism's behaviour is exercised further down.
const dropdowns = [
'src/components/AutocompleteSearch/AutocompleteSearch.tsx',
'src/components/Search/QuickSearchDropdown.tsx',
];
for (const relPath of dropdowns) {
it(`${relPath} holds the text above the keyed boundary and seeds the input from it`, () => {
const source = read(relPath);
const carrierDeclaration = source.indexOf("const carriedSearchText = useRef('')");
const provider = source.indexOf('<InstantSearch');
expect(carrierDeclaration).toBeGreaterThan(-1);
expect(provider).toBeGreaterThan(carrierDeclaration);
expect(source).toContain('carriedSearchText={carriedSearchText}');
expect(source).toContain('useCarriedSearchText(carriedSearchText, query)');
// The shape this replaced. State seeded from the helper's own query comes back EMPTY on a
// remount, which is the regression a re-introduced `useState(query)` would be.
expect(source).not.toContain('useState(query)');
});
}
});
const roots: Root[] = [];
afterEach(async () => {
for (const root of roots.splice(0)) await act(async () => root.unmount());
});
type Harness = {
render: (indexName: string, refinedQuery: string) => Promise<void>;
text: () => string;
type: (value: string) => Promise<void>;
};
/**
* A parent that owns the carrier and a child keyed on the index name — the arrangement both
* dropdowns now use. `carried: false` is the negative control: the same tree with the text seeded
* from the helper's query instead, which is what these components did before.
*/
function mount(carried: boolean): Harness {
const container = document.body.appendChild(document.createElement('div'));
const root = createRoot(container);
roots.push(root);
let write: ((value: string) => void) | null = null;
function Child({
carriedRef,
refinedQuery,
}: {
carriedRef: React.MutableRefObject<string>;
refinedQuery: string;
}) {
const viaCarrier = useCarriedSearchText(carriedRef, refinedQuery);
const viaState = React.useState(refinedQuery);
const [text, setText] = carried ? viaCarrier : viaState;
write = setText;
return React.createElement('span', null, text);
}
function Parent({ indexName, refinedQuery }: { indexName: string; refinedQuery: string }) {
const carriedRef = React.useRef('');
return React.createElement(Child, { key: indexName, carriedRef, refinedQuery });
}
return {
render: async (indexName, refinedQuery) => {
await act(async () => root.render(React.createElement(Parent, { indexName, refinedQuery })));
},
text: () => container.textContent ?? '',
type: async (value) => {
await act(async () => write?.(value));
},
};
}
describe('useCarriedSearchText', () => {
it('keeps the typed text when the index changes and the provider is rebuilt', async () => {
const harness = mount(true);
await harness.render('models_v9', '');
await harness.type('dreamshaper');
expect(harness.text()).toBe('dreamshaper');
await harness.render('articles_v6', '');
expect(harness.text()).toBe('dreamshaper');
});
it('leaves that text differing from the rebuilt helper query, so the search is re-run', async () => {
// The refine effect in each component is `if (debouncedSearch === query) return;`. A rebuilt
// helper reports an empty query, so this inequality is what makes it fire on the new index
// instead of the input merely re-displaying the old text.
const harness = mount(true);
await harness.render('models_v9', '');
await harness.type('dreamshaper');
await harness.render('articles_v6', '');
expect(harness.text()).not.toBe('');
});
it('NEGATIVE CONTROL — the same remount drops the text without the carrier', async () => {
// Proves the remount in the test above is real. Without this, a harness that silently never
// remounted would pass every assertion above while asserting nothing.
const harness = mount(false);
await harness.render('models_v9', '');
await harness.type('dreamshaper');
expect(harness.text()).toBe('dreamshaper');
await harness.render('articles_v6', '');
expect(harness.text()).toBe('');
});
it('seeds a first mount from the helper query, since nothing has been typed yet', async () => {
const harness = mount(true);
await harness.render('models_v9', 'restored-from-url');
expect(harness.text()).toBe('restored-from-url');
});
it('carries a cleared input as cleared, not as the helper query', async () => {
const harness = mount(true);
await harness.render('models_v9', '');
await harness.type('dreamshaper');
await harness.type('');
await harness.render('articles_v6', 'restored-from-url');
// An empty carrier falls back to the helper query — the first-mount behaviour above. That is
// the documented precedence, pinned here so a change to it is a decision rather than a drift.
expect(harness.text()).toBe('restored-from-url');
});
});
describe('seedCarriedSearchText', () => {
it('prefers carried text over the helper query', () => {
expect(seedCarriedSearchText('dreamshaper', 'restored-from-url')).toBe('dreamshaper');
});
it('falls back to the helper query when nothing is carried', () => {
expect(seedCarriedSearchText('', 'restored-from-url')).toBe('restored-from-url');
expect(seedCarriedSearchText(undefined, 'restored-from-url')).toBe('restored-from-url');
});
});
@@ -0,0 +1,51 @@
import type { MutableRefObject } from 'react';
import { useCallback, useState } from 'react';
/**
* Carries the text a user has typed across the remount that an index switch causes.
*
* Both dropdown search roots key `<InstantSearch>` on the resolved index name, the way
* `SearchLayout` already does. That key is what keeps a search from firing with the PREVIOUS
* index's parameters: react-instantsearch-core calls `helper.setIndex(indexName).search()` in its
* RENDER body, and `<InstantSearch>` renders before the children that own the query parameters, so
* without the key a target switch searches the new index with the old target's `filters`. Keying it
* builds a fresh helper instead, and the children mount their parameters onto it before it searches.
*
* The cost of the key is that the typed text lives INSIDE that subtree, so a remount would wipe it.
* `carriedRef` is owned by the component that renders `<InstantSearch>` — above the keyed boundary,
* so it survives — and this hook seeds the remounted input from it and keeps it written.
*
* `refinedQuery` is the search helper's own query. It is only the seed on a FIRST mount: a freshly
* built helper reports `''`, so a remount that carries text seeds a value that differs from it, and
* that difference is what makes each component's existing "push the text into the helper" effect
* fire again. That is what re-RUNS the search on the new index rather than only re-displaying the
* text.
*
* @returns the current text, and a setter that writes the carrier as well as the state. Every write
* has to go through that setter — a bare `setState` leaves the carrier holding stale text, which
* the next remount would then restore over the newer value.
*/
export function useCarriedSearchText(
carriedRef: MutableRefObject<string>,
refinedQuery: string
): [string, (value: string) => void] {
const [text, setText] = useState(() => seedCarriedSearchText(carriedRef.current, refinedQuery));
const write = useCallback(
(value: string) => {
carriedRef.current = value;
setText(value);
},
[carriedRef]
);
return [text, write];
}
/**
* What a mounting input starts with. Carried text wins; an empty carrier falls back to the helper's
* own query, which is the behaviour a first mount had before the carrier existed.
*/
export function seedCarriedSearchText(carried: string | undefined, refinedQuery: string): string {
return carried ? carried : refinedQuery;
}