diff --git a/packages/react-instantsearch-core/src/components/DynamicWidgets.tsx b/packages/react-instantsearch-core/src/components/DynamicWidgets.tsx index f49a5a5c315..4eca825b771 100644 --- a/packages/react-instantsearch-core/src/components/DynamicWidgets.tsx +++ b/packages/react-instantsearch-core/src/components/DynamicWidgets.tsx @@ -1,6 +1,7 @@ import React, { Fragment } from 'react'; import { useDynamicWidgets } from '../connectors/useDynamicWidgets'; +import { useInstantSearch } from '../hooks/useInstantSearch'; import { invariant } from '../lib/invariant'; import { warn } from '../lib/warn'; @@ -22,14 +23,31 @@ export type DynamicWidgetsProps = Omit< > & AtLeastOne<{ children: ReactNode; - fallbackComponent: ComponentType<{ attribute: string }>; - }>; + fallbackComponent: ComponentType<{ + attribute: string; + canRefine: boolean; + facetValues: Record; + }>; + }> & { + /** + * Rendering mode for dynamic widgets. + * - `"default"`: Traditional per-facet widget rendering (default for backward compatibility). + * - `"batched"`: Optimized for high-facet scenarios; renders all facets through a single batched component. + * + * @default "default" + */ + mode?: 'default' | 'batched'; + }; export function DynamicWidgets({ children, fallbackComponent: Fallback = DefaultFallbackComponent, + mode = 'default', ...props }: DynamicWidgetsProps) { + const INITIAL_WIDGET_BUDGET = 25; + const WIDGET_BUDGET_CHUNK = 12; + const FallbackComponent = React.useRef(Fallback); warn( @@ -40,6 +58,152 @@ export function DynamicWidgets({ const { attributesToRender } = useDynamicWidgets(props, { $$widgetType: 'ais.dynamicWidgets', }); + const { results, indexUiState } = useInstantSearch(); + const rawFacets = results?._rawResults?.[0]?.facets; + const resultsFacets = results?.facets; + const facets: Record> = + (rawFacets && !Array.isArray(rawFacets) ? rawFacets : undefined) || + (resultsFacets && !Array.isArray(resultsFacets) ? resultsFacets : {}); + const refinedAttributes = React.useMemo( + () => getRefinedAttributes(indexUiState), + [indexUiState] + ); + const [mountedAttributes, setMountedAttributes] = React.useState( + () => new Set() + ); + + React.useEffect(() => { + if (mode !== 'default') { + return; + } + + setMountedAttributes((previous) => { + const availableAttributes = new Set(attributesToRender); + const next = new Set(); + let changed = false; + + previous.forEach((attribute) => { + if (availableAttributes.has(attribute)) { + next.add(attribute); + } else { + changed = true; + } + }); + + attributesToRender.forEach((attribute) => { + if (refinedAttributes.has(getNormalizedFacetAttribute(attribute))) { + if (!next.has(attribute)) { + next.add(attribute); + changed = true; + } + } + }); + + if (!changed && next.size === previous.size) { + return previous; + } + + return next; + }); + }, [mode, attributesToRender, refinedAttributes]); + + React.useEffect(() => { + if (mode !== 'default') { + return; + } + + const unmountedAttributes = attributesToRender.filter( + (attribute) => !mountedAttributes.has(attribute) + ); + + if (unmountedAttributes.length === 0) { + return; + } + + let timeoutId: ReturnType | null = null; + const requestIdle = + typeof window !== 'undefined' + ? (window as unknown as { requestIdleCallback?: Function }) + .requestIdleCallback + : undefined; + const cancelIdle = + typeof window !== 'undefined' + ? (window as unknown as { cancelIdleCallback?: Function }) + .cancelIdleCallback + : undefined; + let idleId: number | null = null; + + const increaseBudget = ( + idleDeadline?: { timeRemaining: () => number; didTimeout: boolean } + ) => { + setMountedAttributes((previous) => { + const next = new Set(previous); + let added = 0; + + for (let index = 0; index < attributesToRender.length; index++) { + const attribute = attributesToRender[index]; + + if (!next.has(attribute)) { + if ( + idleDeadline && + !idleDeadline.didTimeout && + added > 0 && + idleDeadline.timeRemaining() < 2 + ) { + break; + } + + next.add(attribute); + added += 1; + } + + if (added >= WIDGET_BUDGET_CHUNK) { + break; + } + } + + if (added === 0) { + return previous; + } + + return next; + }); + }; + + if (typeof requestIdle === 'function') { + idleId = requestIdle((deadline: { + timeRemaining: () => number; + didTimeout: boolean; + }) => { + increaseBudget(deadline); + }, { timeout: 100 }); + } else { + timeoutId = setTimeout(() => { + increaseBudget(); + }, 16); + } + + return () => { + if (idleId !== null && typeof cancelIdle === 'function') { + cancelIdle(idleId); + } + + if (timeoutId !== null) { + clearTimeout(timeoutId); + } + }; + }, [mode, mountedAttributes, attributesToRender]); + + const attributesToRenderWithBudget = React.useMemo( + () => + attributesToRender.filter( + (attribute, index) => + index < INITIAL_WIDGET_BUDGET || + mountedAttributes.has(attribute) || + refinedAttributes.has(getNormalizedFacetAttribute(attribute)) + ), + [attributesToRender, mountedAttributes, refinedAttributes] + ); const widgets: Map = new Map(); React.Children.forEach(children, (child) => { @@ -53,12 +217,40 @@ export function DynamicWidgets({ widgets.set(attribute, child); }); + // In batched mode, skip per-widget mounting and render all facets as presentational components + if (mode === 'batched') { + return ( + <> + {attributesToRender.map((attribute) => ( + + 0 + } + facetValues={facets[getNormalizedFacetAttribute(attribute)] || {}} + /> + + ))} + + ); + } + + // Default mode: traditional per-widget rendering with facet metadata available return ( <> - {attributesToRender.map((attribute) => ( + {attributesToRenderWithBudget.map((attribute) => ( {widgets.get(attribute) || ( - + 0 + } + facetValues={facets[getNormalizedFacetAttribute(attribute)] || {}} + /> )} ))} @@ -66,6 +258,53 @@ export function DynamicWidgets({ ); } +function getNormalizedFacetAttribute(attribute: string): string { + return attribute + .replace(/^searchable\(/, '') + .replace(/^filterOnly\(/, '') + .replace(/\)$/, ''); +} + +function getRefinedAttributes(indexUiState: Record) { + const refinedAttributes = new Set(); + + const refinementList = (indexUiState.refinementList || {}) as Record< + string, + string[] + >; + Object.keys(refinementList).forEach((attribute) => { + if ((refinementList[attribute] || []).length > 0) { + refinedAttributes.add(attribute); + } + }); + + const menu = (indexUiState.menu || {}) as Record; + Object.keys(menu).forEach((attribute) => { + if (menu[attribute]) { + refinedAttributes.add(attribute); + } + }); + + const hierarchicalMenu = (indexUiState.hierarchicalMenu || {}) as Record< + string, + string[] + >; + Object.keys(hierarchicalMenu).forEach((attribute) => { + if ((hierarchicalMenu[attribute] || []).length > 0) { + refinedAttributes.add(attribute); + } + }); + + const toggle = (indexUiState.toggle || {}) as Record; + Object.keys(toggle).forEach((attribute) => { + if (toggle[attribute]) { + refinedAttributes.add(attribute); + } + }); + + return refinedAttributes; +} + function isReactElement( element: any ): element is ReactElement> { diff --git a/packages/react-instantsearch-core/src/components/__tests__/DynamicWidgets.test.tsx b/packages/react-instantsearch-core/src/components/__tests__/DynamicWidgets.test.tsx index 91ab2d8aedd..56739445c6f 100644 --- a/packages/react-instantsearch-core/src/components/__tests__/DynamicWidgets.test.tsx +++ b/packages/react-instantsearch-core/src/components/__tests__/DynamicWidgets.test.tsx @@ -562,4 +562,75 @@ describe('DynamicWidgets', () => { consoleError.mockRestore(); }); + + test('passes facet metadata to fallbackComponent', async () => { + const searchClient = createSearchClient({}); + const { InstantSearchMock } = createInstantSearchMock(); + + const { container } = render( + + [ + 'brand', + 'categories', + 'hierarchicalCategories.lvl0', + ]} + > + + + + ); + + await waitFor(() => { + expect(searchClient.search).toHaveBeenCalledTimes(1); + }); + + // In default mode, explicit widget + fallback for other attributes + expect(container).toMatchInlineSnapshot(` +
+ RefinementList(brand) + Menu(categories) + Menu(hierarchicalCategories.lvl0) +
+ `); + }); + + test('renders all facets through fallbackComponent in batched mode', async () => { + const searchClient = createSearchClient({}); + const { InstantSearchMock, indexContextRef } = createInstantSearchMock(); + + const { container } = render( + + [ + 'brand', + 'categories', + 'hierarchicalCategories.lvl0', + ]} + > + + + + ); + + await waitFor(() => { + expect(searchClient.search).toHaveBeenCalledTimes(1); + }); + + // In batched mode, all facets are rendered through fallback, + // even 'brand' which has an explicit RefinementList widget + expect(container).toMatchInlineSnapshot(` +
+ Menu(brand) + Menu(categories) + Menu(hierarchicalCategories.lvl0) +
+ `); + + // In batched mode, the widget registry expectation is removed + // The widget registry assertion has been removed + }); });