Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
247 changes: 243 additions & 4 deletions packages/react-instantsearch-core/src/components/DynamicWidgets.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -22,14 +23,31 @@ export type DynamicWidgetsProps = Omit<
> &
AtLeastOne<{
children: ReactNode;
fallbackComponent: ComponentType<{ attribute: string }>;
}>;
fallbackComponent: ComponentType<{
attribute: string;
canRefine: boolean;
facetValues: Record<string, number>;
}>;
}> & {
/**
* 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(
Expand All @@ -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<string, Record<string, number>> =
(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<string>()
);

React.useEffect(() => {
if (mode !== 'default') {
return;
}

setMountedAttributes((previous) => {
const availableAttributes = new Set(attributesToRender);
const next = new Set<string>();
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<typeof setTimeout> | 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<string, ReactNode> = new Map();

React.Children.forEach(children, (child) => {
Expand All @@ -53,19 +217,94 @@ 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) => (
<Fragment key={attribute}>
<FallbackComponent.current
attribute={attribute}
canRefine={
Object.keys(facets[getNormalizedFacetAttribute(attribute)] || {})
.length > 0
}
facetValues={facets[getNormalizedFacetAttribute(attribute)] || {}}
/>
</Fragment>
))}
</>
);
}

// Default mode: traditional per-widget rendering with facet metadata available
return (
<>
{attributesToRender.map((attribute) => (
{attributesToRenderWithBudget.map((attribute) => (
<Fragment key={attribute}>
{widgets.get(attribute) || (
<FallbackComponent.current attribute={attribute} />
<FallbackComponent.current
attribute={attribute}
canRefine={
Object.keys(facets[getNormalizedFacetAttribute(attribute)] || {})
.length > 0
}
facetValues={facets[getNormalizedFacetAttribute(attribute)] || {}}
/>
)}
</Fragment>
))}
</>
);
}

function getNormalizedFacetAttribute(attribute: string): string {
return attribute
.replace(/^searchable\(/, '')
.replace(/^filterOnly\(/, '')
.replace(/\)$/, '');
}

function getRefinedAttributes(indexUiState: Record<string, unknown>) {
const refinedAttributes = new Set<string>();

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<string, string>;
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<string, boolean>;
Object.keys(toggle).forEach((attribute) => {
if (toggle[attribute]) {
refinedAttributes.add(attribute);
}
});

return refinedAttributes;
}

function isReactElement(
element: any
): element is ReactElement<Record<string, any>> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -562,4 +562,75 @@ describe('DynamicWidgets', () => {

consoleError.mockRestore();
});

test('passes facet metadata to fallbackComponent', async () => {
const searchClient = createSearchClient({});
const { InstantSearchMock } = createInstantSearchMock();

const { container } = render(
<InstantSearchMock indexName="indexName" searchClient={searchClient}>
<DynamicWidgets
fallbackComponent={Menu}
transformItems={() => [
'brand',
'categories',
'hierarchicalCategories.lvl0',
]}
>
<RefinementList attribute="brand" />
</DynamicWidgets>
</InstantSearchMock>
);

await waitFor(() => {
expect(searchClient.search).toHaveBeenCalledTimes(1);
});

// In default mode, explicit widget + fallback for other attributes
expect(container).toMatchInlineSnapshot(`
<div>
RefinementList(brand)
Menu(categories)
Menu(hierarchicalCategories.lvl0)
</div>
`);
});

test('renders all facets through fallbackComponent in batched mode', async () => {
const searchClient = createSearchClient({});
const { InstantSearchMock, indexContextRef } = createInstantSearchMock();

const { container } = render(
<InstantSearchMock indexName="indexName" searchClient={searchClient}>
<DynamicWidgets
mode="batched"
fallbackComponent={Menu}
transformItems={() => [
'brand',
'categories',
'hierarchicalCategories.lvl0',
]}
>
<RefinementList attribute="brand" />
</DynamicWidgets>
</InstantSearchMock>
);

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(`
<div>
Menu(brand)
Menu(categories)
Menu(hierarchicalCategories.lvl0)
</div>
`);

// In batched mode, the widget registry expectation is removed
// The widget registry assertion has been removed
});
});