From b0461ee168c25036dbcd08c37492598d4eb35d6a Mon Sep 17 00:00:00 2001 From: Haroen Viaene Date: Tue, 17 Mar 2026 15:52:39 +0100 Subject: [PATCH 1/6] feat(react-instantsearch-core): add batched mode to DynamicWidgets for high-facet scenarios - Add mode prop ('default' | 'batched') to DynamicWidgets component - In batched mode, skip per-widget mounting and render all facets through fallback - Pass facet metadata (canRefine, facetValues) to fallback components - Solves performance issues with 200+ facets by eliminating O(n) widget overhead - Maintain backward compatibility with default mode --- .../src/components/DynamicWidgets.tsx | 46 +++++++++++- .../__tests__/DynamicWidgets.test.tsx | 71 +++++++++++++++++++ 2 files changed, 114 insertions(+), 3 deletions(-) diff --git a/packages/react-instantsearch-core/src/components/DynamicWidgets.tsx b/packages/react-instantsearch-core/src/components/DynamicWidgets.tsx index f49a5a5c315..c72e7d3e8da 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,12 +23,26 @@ 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 FallbackComponent = React.useRef(Fallback); @@ -40,6 +55,9 @@ export function DynamicWidgets({ const { attributesToRender } = useDynamicWidgets(props, { $$widgetType: 'ais.dynamicWidgets', }); + const { results } = useInstantSearch(); + const rawFacets = results?._rawResults?.[0]?.facets || {}; + const facets = Object.keys(rawFacets).length > 0 ? rawFacets : results?.facets; const widgets: Map = new Map(); React.Children.forEach(children, (child) => { @@ -53,12 +71,34 @@ 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?.[attribute] || {}} + /> + + ))} + + ); + } + + // Default mode: traditional per-widget rendering with facet metadata available return ( <> {attributesToRender.map((attribute) => ( {widgets.get(attribute) || ( - + 0} + facetValues={facets?.[attribute] || {}} + /> )} ))} 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 + }); }); From c91ed5b57dcd981a47f3a5dc6a574b65299f51f6 Mon Sep 17 00:00:00 2001 From: Haroen Viaene Date: Tue, 17 Mar 2026 16:20:32 +0100 Subject: [PATCH 2/6] fix(react-instantsearch-core): normalize wrapped facet attributes for fallback metadata --- .../src/components/DynamicWidgets.tsx | 28 +++++++++++++++---- 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/packages/react-instantsearch-core/src/components/DynamicWidgets.tsx b/packages/react-instantsearch-core/src/components/DynamicWidgets.tsx index c72e7d3e8da..37ce14f9fdb 100644 --- a/packages/react-instantsearch-core/src/components/DynamicWidgets.tsx +++ b/packages/react-instantsearch-core/src/components/DynamicWidgets.tsx @@ -56,8 +56,11 @@ export function DynamicWidgets({ $$widgetType: 'ais.dynamicWidgets', }); const { results } = useInstantSearch(); - const rawFacets = results?._rawResults?.[0]?.facets || {}; - const facets = Object.keys(rawFacets).length > 0 ? rawFacets : results?.facets; + 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 widgets: Map = new Map(); React.Children.forEach(children, (child) => { @@ -79,8 +82,11 @@ export function DynamicWidgets({ 0} - facetValues={facets?.[attribute] || {}} + canRefine={ + Object.keys(facets[getNormalizedFacetAttribute(attribute)] || {}) + .length > 0 + } + facetValues={facets[getNormalizedFacetAttribute(attribute)] || {}} /> ))} @@ -96,8 +102,11 @@ export function DynamicWidgets({ {widgets.get(attribute) || ( 0} - facetValues={facets?.[attribute] || {}} + canRefine={ + Object.keys(facets[getNormalizedFacetAttribute(attribute)] || {}) + .length > 0 + } + facetValues={facets[getNormalizedFacetAttribute(attribute)] || {}} /> )} @@ -106,6 +115,13 @@ export function DynamicWidgets({ ); } +function getNormalizedFacetAttribute(attribute: string): string { + return attribute + .replace(/^searchable\(/, '') + .replace(/^filterOnly\(/, '') + .replace(/\)$/, ''); +} + function isReactElement( element: any ): element is ReactElement> { From 6224b9c06f16e42dcecddb23832340b2cd840fe1 Mon Sep 17 00:00:00 2001 From: Haroen Viaene Date: Tue, 17 Mar 2026 16:51:17 +0100 Subject: [PATCH 3/6] perf(react-instantsearch-core): progressively mount DynamicWidgets default widgets --- .../src/components/DynamicWidgets.tsx | 88 ++++++++++++++++++- 1 file changed, 86 insertions(+), 2 deletions(-) diff --git a/packages/react-instantsearch-core/src/components/DynamicWidgets.tsx b/packages/react-instantsearch-core/src/components/DynamicWidgets.tsx index 37ce14f9fdb..0621fc4c94e 100644 --- a/packages/react-instantsearch-core/src/components/DynamicWidgets.tsx +++ b/packages/react-instantsearch-core/src/components/DynamicWidgets.tsx @@ -45,6 +45,9 @@ export function DynamicWidgets({ mode = 'default', ...props }: DynamicWidgetsProps) { + const INITIAL_WIDGET_BUDGET = 50; + const WIDGET_BUDGET_CHUNK = 50; + const FallbackComponent = React.useRef(Fallback); warn( @@ -55,12 +58,53 @@ export function DynamicWidgets({ const { attributesToRender } = useDynamicWidgets(props, { $$widgetType: 'ais.dynamicWidgets', }); - const { results } = useInstantSearch(); + 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 [renderBudget, setRenderBudget] = React.useState( + Math.min(INITIAL_WIDGET_BUDGET, attributesToRender.length) + ); + + React.useEffect(() => { + setRenderBudget(Math.min(INITIAL_WIDGET_BUDGET, attributesToRender.length)); + }, [attributesToRender.length]); + + React.useEffect(() => { + if (mode !== 'default') { + return; + } + + if (renderBudget >= attributesToRender.length) { + return; + } + + const timeoutId = setTimeout(() => { + setRenderBudget((previous) => + Math.min(previous + WIDGET_BUDGET_CHUNK, attributesToRender.length) + ); + }, 0); + + return () => { + clearTimeout(timeoutId); + }; + }, [mode, renderBudget, attributesToRender.length]); + + const attributesToRenderWithBudget = React.useMemo( + () => + attributesToRender.filter( + (attribute, index) => + index < renderBudget || + refinedAttributes.has(getNormalizedFacetAttribute(attribute)) + ), + [attributesToRender, renderBudget, refinedAttributes] + ); const widgets: Map = new Map(); React.Children.forEach(children, (child) => { @@ -97,7 +141,7 @@ export function DynamicWidgets({ // Default mode: traditional per-widget rendering with facet metadata available return ( <> - {attributesToRender.map((attribute) => ( + {attributesToRenderWithBudget.map((attribute) => ( {widgets.get(attribute) || ( ) { + 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> { From 5a12712c93c22733c7ed62dd32149a2e353ff007 Mon Sep 17 00:00:00 2001 From: Haroen Viaene Date: Tue, 17 Mar 2026 17:08:31 +0100 Subject: [PATCH 4/6] perf(react-instantsearch-core): schedule dynamic widget mounting on idle --- .../src/components/DynamicWidgets.tsx | 39 ++++++++++++++++--- 1 file changed, 34 insertions(+), 5 deletions(-) diff --git a/packages/react-instantsearch-core/src/components/DynamicWidgets.tsx b/packages/react-instantsearch-core/src/components/DynamicWidgets.tsx index 0621fc4c94e..6dd703bdb6f 100644 --- a/packages/react-instantsearch-core/src/components/DynamicWidgets.tsx +++ b/packages/react-instantsearch-core/src/components/DynamicWidgets.tsx @@ -45,8 +45,8 @@ export function DynamicWidgets({ mode = 'default', ...props }: DynamicWidgetsProps) { - const INITIAL_WIDGET_BUDGET = 50; - const WIDGET_BUDGET_CHUNK = 50; + const INITIAL_WIDGET_BUDGET = 25; + const WIDGET_BUDGET_CHUNK = 25; const FallbackComponent = React.useRef(Fallback); @@ -85,14 +85,43 @@ export function DynamicWidgets({ return; } - const timeoutId = setTimeout(() => { + 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 = () => { setRenderBudget((previous) => Math.min(previous + WIDGET_BUDGET_CHUNK, attributesToRender.length) ); - }, 0); + }; + + if (typeof requestIdle === 'function') { + idleId = requestIdle(() => { + increaseBudget(); + }, { timeout: 100 }); + } else { + timeoutId = setTimeout(() => { + increaseBudget(); + }, 16); + } return () => { - clearTimeout(timeoutId); + if (idleId !== null && typeof cancelIdle === 'function') { + cancelIdle(idleId); + } + + if (timeoutId !== null) { + clearTimeout(timeoutId); + } }; }, [mode, renderBudget, attributesToRender.length]); From e0f79e744a1a7398f4b0a90daebd0ac70a54fd5b Mon Sep 17 00:00:00 2001 From: Haroen Viaene Date: Tue, 17 Mar 2026 17:20:50 +0100 Subject: [PATCH 5/6] perf(react-instantsearch-core): keep progressive widget mounts sticky --- .../src/components/DynamicWidgets.tsx | 78 ++++++++++++++++--- 1 file changed, 67 insertions(+), 11 deletions(-) diff --git a/packages/react-instantsearch-core/src/components/DynamicWidgets.tsx b/packages/react-instantsearch-core/src/components/DynamicWidgets.tsx index 6dd703bdb6f..20dcb005fe0 100644 --- a/packages/react-instantsearch-core/src/components/DynamicWidgets.tsx +++ b/packages/react-instantsearch-core/src/components/DynamicWidgets.tsx @@ -68,20 +68,55 @@ export function DynamicWidgets({ () => getRefinedAttributes(indexUiState), [indexUiState] ); - const [renderBudget, setRenderBudget] = React.useState( - Math.min(INITIAL_WIDGET_BUDGET, attributesToRender.length) + const [mountedAttributes, setMountedAttributes] = React.useState( + () => new Set() ); React.useEffect(() => { - setRenderBudget(Math.min(INITIAL_WIDGET_BUDGET, attributesToRender.length)); - }, [attributesToRender.length]); + 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; } - if (renderBudget >= attributesToRender.length) { + const unmountedAttributes = attributesToRender.filter( + (attribute) => !mountedAttributes.has(attribute) + ); + + if (unmountedAttributes.length === 0) { return; } @@ -99,9 +134,29 @@ export function DynamicWidgets({ let idleId: number | null = null; const increaseBudget = () => { - setRenderBudget((previous) => - Math.min(previous + WIDGET_BUDGET_CHUNK, attributesToRender.length) - ); + 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)) { + next.add(attribute); + added += 1; + } + + if (added >= WIDGET_BUDGET_CHUNK) { + break; + } + } + + if (added === 0) { + return previous; + } + + return next; + }); }; if (typeof requestIdle === 'function') { @@ -123,16 +178,17 @@ export function DynamicWidgets({ clearTimeout(timeoutId); } }; - }, [mode, renderBudget, attributesToRender.length]); + }, [mode, mountedAttributes, attributesToRender]); const attributesToRenderWithBudget = React.useMemo( () => attributesToRender.filter( (attribute, index) => - index < renderBudget || + index < INITIAL_WIDGET_BUDGET || + mountedAttributes.has(attribute) || refinedAttributes.has(getNormalizedFacetAttribute(attribute)) ), - [attributesToRender, renderBudget, refinedAttributes] + [attributesToRender, mountedAttributes, refinedAttributes] ); const widgets: Map = new Map(); From 4644113df977b21f1fefb6fcb773bc75e544168a Mon Sep 17 00:00:00 2001 From: Haroen Viaene Date: Tue, 17 Mar 2026 17:26:48 +0100 Subject: [PATCH 6/6] perf(react-instantsearch-core): adapt widget chunking to idle budget --- .../src/components/DynamicWidgets.tsx | 22 +++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/packages/react-instantsearch-core/src/components/DynamicWidgets.tsx b/packages/react-instantsearch-core/src/components/DynamicWidgets.tsx index 20dcb005fe0..4eca825b771 100644 --- a/packages/react-instantsearch-core/src/components/DynamicWidgets.tsx +++ b/packages/react-instantsearch-core/src/components/DynamicWidgets.tsx @@ -46,7 +46,7 @@ export function DynamicWidgets({ ...props }: DynamicWidgetsProps) { const INITIAL_WIDGET_BUDGET = 25; - const WIDGET_BUDGET_CHUNK = 25; + const WIDGET_BUDGET_CHUNK = 12; const FallbackComponent = React.useRef(Fallback); @@ -133,7 +133,9 @@ export function DynamicWidgets({ : undefined; let idleId: number | null = null; - const increaseBudget = () => { + const increaseBudget = ( + idleDeadline?: { timeRemaining: () => number; didTimeout: boolean } + ) => { setMountedAttributes((previous) => { const next = new Set(previous); let added = 0; @@ -142,6 +144,15 @@ export function DynamicWidgets({ const attribute = attributesToRender[index]; if (!next.has(attribute)) { + if ( + idleDeadline && + !idleDeadline.didTimeout && + added > 0 && + idleDeadline.timeRemaining() < 2 + ) { + break; + } + next.add(attribute); added += 1; } @@ -160,8 +171,11 @@ export function DynamicWidgets({ }; if (typeof requestIdle === 'function') { - idleId = requestIdle(() => { - increaseBudget(); + idleId = requestIdle((deadline: { + timeRemaining: () => number; + didTimeout: boolean; + }) => { + increaseBudget(deadline); }, { timeout: 100 }); } else { timeoutId = setTimeout(() => {