mirror of
https://github.com/civitai/civitai.git
synced 2026-09-20 22:08:18 +08:00
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:
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user