From a1d40ea49282e42a4dee2b693918e82c13a652a9 Mon Sep 17 00:00:00 2001 From: Haroen Viaene Date: Mon, 8 Sep 2025 13:23:47 +0200 Subject: [PATCH 1/3] feat(index): accept "composed" widgets in add/removeWidgets --- .../instantsearch.js/src/lib/InstantSearch.ts | 26 +------ .../src/widgets/index/index.ts | 77 +++++++++++-------- 2 files changed, 47 insertions(+), 56 deletions(-) diff --git a/packages/instantsearch.js/src/lib/InstantSearch.ts b/packages/instantsearch.js/src/lib/InstantSearch.ts index ff4e121052..7915fcdd2f 100644 --- a/packages/instantsearch.js/src/lib/InstantSearch.ts +++ b/packages/instantsearch.js/src/lib/InstantSearch.ts @@ -492,7 +492,7 @@ See documentation: ${createDocumentationLink({ * Widgets can be added either before or after InstantSearch has started. * @param widgets The array of widgets to add to InstantSearch. */ - public addWidgets(widgets: Array) { + public addWidgets(widgets: Array) { if (!Array.isArray(widgets)) { throw new Error( withUsage( @@ -501,23 +501,9 @@ See documentation: ${createDocumentationLink({ ); } - if ( - widgets.some( - (widget) => - typeof widget.init !== 'function' && - typeof widget.render !== 'function' - ) - ) { - throw new Error( - withUsage( - 'The widget definition expects a `render` and/or an `init` method.' - ) - ); - } - if ( this.compositionID && - widgets.some((w) => isIndexWidget(w) && !w._isolated) + widgets.some((w) => !Array.isArray(w) && isIndexWidget(w) && !w._isolated) ) { throw new Error( withUsage( @@ -553,7 +539,7 @@ See documentation: ${createDocumentationLink({ * * The widgets must implement a `dispose()` method to clear their states. */ - public removeWidgets(widgets: Array) { + public removeWidgets(widgets: Array) { if (!Array.isArray(widgets)) { throw new Error( withUsage( @@ -562,12 +548,6 @@ See documentation: ${createDocumentationLink({ ); } - if (widgets.some((widget) => typeof widget.dispose !== 'function')) { - throw new Error( - withUsage('The widget definition expects a `dispose` method.') - ); - } - this.mainIndex.removeWidgets(widgets); return this; diff --git a/packages/instantsearch.js/src/widgets/index/index.ts b/packages/instantsearch.js/src/widgets/index/index.ts index 6d3888da6d..1ce8883ac4 100644 --- a/packages/instantsearch.js/src/widgets/index/index.ts +++ b/packages/instantsearch.js/src/widgets/index/index.ts @@ -126,8 +126,10 @@ export type IndexWidget = Omit< nextState: SearchParameters | ((state: IndexUiState) => IndexUiState) ) => string; - addWidgets: (widgets: Array) => IndexWidget; - removeWidgets: (widgets: Array) => IndexWidget; + addWidgets: (widgets: Array) => IndexWidget; + removeWidgets: ( + widgets: Array + ) => IndexWidget; init: (options: IndexInitOptions) => void; render: (options: IndexRenderOptions) => void; @@ -432,9 +434,13 @@ const index = (widgetParams: IndexWidgetParams): IndexWidget => { withUsage('The `addWidgets` method expects an array of widgets.') ); } + const flatWidgets = widgets.reduce>( + (acc, w) => acc.concat(Array.isArray(w) ? w : [w]), + [] + ); if ( - widgets.some( + flatWidgets.some( (widget) => typeof widget.init !== 'function' && typeof widget.render !== 'function' @@ -447,7 +453,7 @@ const index = (widgetParams: IndexWidgetParams): IndexWidget => { ); } - widgets.forEach((widget) => { + flatWidgets.forEach((widget) => { if (isIndexWidget(widget)) { return; } @@ -465,8 +471,8 @@ const index = (widgetParams: IndexWidgetParams): IndexWidget => { addWidgetId(widget); }); - localWidgets = localWidgets.concat(widgets); - if (localInstantSearchInstance && Boolean(widgets.length)) { + localWidgets = localWidgets.concat(flatWidgets); + if (localInstantSearchInstance && Boolean(flatWidgets.length)) { privateHelperSetState(helper!, { state: getLocalWidgetsSearchParameters(localWidgets, { uiState: localUiState, @@ -482,7 +488,7 @@ const index = (widgetParams: IndexWidgetParams): IndexWidget => { // We compute the render state before calling `init` in a separate loop // to construct the whole render state object that is then passed to // `init`. - widgets.forEach((widget) => { + flatWidgets.forEach((widget) => { if (widget.getRenderState) { const renderState = widget.getRenderState( localInstantSearchInstance!.renderState[this.getIndexId()] || {}, @@ -501,7 +507,7 @@ const index = (widgetParams: IndexWidgetParams): IndexWidget => { } }); - widgets.forEach((widget) => { + flatWidgets.forEach((widget) => { if (widget.init) { widget.init( createInitArgs( @@ -529,15 +535,19 @@ const index = (widgetParams: IndexWidgetParams): IndexWidget => { withUsage('The `removeWidgets` method expects an array of widgets.') ); } + const flatWidgets = widgets.reduce>( + (acc, w) => acc.concat(Array.isArray(w) ? w : [w]), + [] + ); - if (widgets.some((widget) => typeof widget.dispose !== 'function')) { + if (flatWidgets.some((widget) => typeof widget.dispose !== 'function')) { throw new Error( withUsage('The widget definition expects a `dispose` method.') ); } localWidgets = localWidgets.filter( - (widget) => widgets.indexOf(widget) === -1 + (widget) => flatWidgets.indexOf(widget) === -1 ); localWidgets.forEach((widget) => { @@ -556,30 +566,31 @@ const index = (widgetParams: IndexWidgetParams): IndexWidget => { } }); - if (localInstantSearchInstance && Boolean(widgets.length)) { - const { cleanedSearchState, cleanedRecommendState } = widgets.reduce( - (states, widget) => { - // the `dispose` method exists at this point we already assert it - const next = widget.dispose!({ - helper: helper!, - state: states.cleanedSearchState, - recommendState: states.cleanedRecommendState, - parent: this, - }); - - if (next instanceof algoliasearchHelper.RecommendParameters) { - states.cleanedRecommendState = next; - } else if (next) { - states.cleanedSearchState = next; + if (localInstantSearchInstance && Boolean(flatWidgets.length)) { + const { cleanedSearchState, cleanedRecommendState } = + flatWidgets.reduce( + (states, widget) => { + // the `dispose` method exists at this point we already assert it + const next = widget.dispose!({ + helper: helper!, + state: states.cleanedSearchState, + recommendState: states.cleanedRecommendState, + parent: this, + }); + + if (next instanceof algoliasearchHelper.RecommendParameters) { + states.cleanedRecommendState = next; + } else if (next) { + states.cleanedSearchState = next; + } + + return states; + }, + { + cleanedSearchState: helper!.state, + cleanedRecommendState: helper!.recommendState, } - - return states; - }, - { - cleanedSearchState: helper!.state, - cleanedRecommendState: helper!.recommendState, - } - ); + ); const newState = localInstantSearchInstance.future .preserveSharedStateOnUnmount From c671964eb407e352c1bc3ad322954c4987a0eadf Mon Sep 17 00:00:00 2001 From: Haroen Viaene Date: Mon, 8 Sep 2025 13:52:24 +0200 Subject: [PATCH 2/3] test --- .../src/lib/__tests__/InstantSearch-test.tsx | 66 +++++++++---------- 1 file changed, 33 insertions(+), 33 deletions(-) diff --git a/packages/instantsearch.js/src/lib/__tests__/InstantSearch-test.tsx b/packages/instantsearch.js/src/lib/__tests__/InstantSearch-test.tsx index 4fd47c84ad..78c3d07389 100644 --- a/packages/instantsearch.js/src/lib/__tests__/InstantSearch-test.tsx +++ b/packages/instantsearch.js/src/lib/__tests__/InstantSearch-test.tsx @@ -86,10 +86,10 @@ describe('Usage', () => { // eslint-disable-next-line no-new new InstantSearch({ indexName: 'indexName', searchClient: undefined }); }).toThrowErrorMatchingInlineSnapshot(` -"The \`searchClient\` option is required. + "The \`searchClient\` option is required. -See documentation: https://www.algolia.com/doc/api-reference/widgets/instantsearch/js/" -`); + See documentation: https://www.algolia.com/doc/api-reference/widgets/instantsearch/js/" + `); }); it('throws if searchClient does not implement a search method', () => { @@ -98,10 +98,10 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/instantsear // eslint-disable-next-line no-new new InstantSearch({ indexName: 'indexName', searchClient: {} }); }).toThrowErrorMatchingInlineSnapshot(` -"The \`searchClient\` must implement a \`search\` method. + "The \`searchClient\` must implement a \`search\` method. -See: https://www.algolia.com/doc/guides/building-search-ui/going-further/backend-search/in-depth/backend-instantsearch/js/" -`); + See: https://www.algolia.com/doc/guides/building-search-ui/going-further/backend-search/in-depth/backend-instantsearch/js/" + `); }); describe('root index warning', () => { @@ -174,10 +174,10 @@ See: https://www.algolia.com/doc/guides/building-search-ui/going-further/backend insightsClient: 'insights', }); }).toThrowErrorMatchingInlineSnapshot(` -"The \`insightsClient\` option should be a function. + "The \`insightsClient\` option should be a function. -See documentation: https://www.algolia.com/doc/api-reference/widgets/instantsearch/js/" -`); + See documentation: https://www.algolia.com/doc/api-reference/widgets/instantsearch/js/" + `); }); it('throws if addWidgets is called with a single widget', () => { @@ -189,10 +189,10 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/instantsear // @ts-expect-error search.addWidgets({}); }).toThrowErrorMatchingInlineSnapshot(` -"The \`addWidgets\` method expects an array of widgets. Please use \`addWidget\`. + "The \`addWidgets\` method expects an array of widgets. Please use \`addWidget\`. -See documentation: https://www.algolia.com/doc/api-reference/widgets/instantsearch/js/" -`); + See documentation: https://www.algolia.com/doc/api-reference/widgets/instantsearch/js/" + `); }); it('throws if a widget without render or init method is added', () => { @@ -205,10 +205,10 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/instantsear }); search.addWidgets(widgets); }).toThrowErrorMatchingInlineSnapshot(` -"The widget definition expects a \`render\` and/or an \`init\` method. + "The widget definition expects a \`render\` and/or an \`init\` method. -See documentation: https://www.algolia.com/doc/api-reference/widgets/instantsearch/js/" -`); + See documentation: https://www.algolia.com/doc/api-reference/widgets/index-widget/js/" + `); }); it('does not throw with a widget having a init method', () => { @@ -244,10 +244,10 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/instantsear // @ts-expect-error search.removeWidgets({}); }).toThrowErrorMatchingInlineSnapshot(` -"The \`removeWidgets\` method expects an array of widgets. Please use \`removeWidget\`. + "The \`removeWidgets\` method expects an array of widgets. Please use \`removeWidget\`. -See documentation: https://www.algolia.com/doc/api-reference/widgets/instantsearch/js/" -`); + See documentation: https://www.algolia.com/doc/api-reference/widgets/instantsearch/js/" + `); }); it('throws if a widget without dispose method is removed', () => { @@ -262,10 +262,10 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/instantsear }); search.removeWidgets(widgets); }).toThrowErrorMatchingInlineSnapshot(` -"The widget definition expects a \`dispose\` method. + "The widget definition expects a \`dispose\` method. -See documentation: https://www.algolia.com/doc/api-reference/widgets/instantsearch/js/" -`); + See documentation: https://www.algolia.com/doc/api-reference/widgets/index-widget/js/" + `); }); it('throws if createURL is called before start', () => { @@ -275,10 +275,10 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/instantsear }); expect(() => search.createURL()).toThrowErrorMatchingInlineSnapshot(` -"The \`start\` method needs to be called before \`createURL\`. + "The \`start\` method needs to be called before \`createURL\`. -See documentation: https://www.algolia.com/doc/api-reference/widgets/instantsearch/js/" -`); + See documentation: https://www.algolia.com/doc/api-reference/widgets/instantsearch/js/" + `); }); it('throws if refresh is called before start', () => { @@ -288,10 +288,10 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/instantsear }); expect(() => search.refresh()).toThrowErrorMatchingInlineSnapshot(` -"The \`start\` method needs to be called before \`refresh\`. + "The \`start\` method needs to be called before \`refresh\`. -See documentation: https://www.algolia.com/doc/api-reference/widgets/instantsearch/js/" -`); + See documentation: https://www.algolia.com/doc/api-reference/widgets/instantsearch/js/" + `); }); it('warns dev with EXPERIMENTAL_use', () => { @@ -1241,10 +1241,10 @@ describe('start', () => { expect(() => { instance.start(); }).toThrowErrorMatchingInlineSnapshot(` -"The \`start\` method has already been called once. + "The \`start\` method has already been called once. -See documentation: https://www.algolia.com/doc/api-reference/widgets/instantsearch/js/" -`); + See documentation: https://www.algolia.com/doc/api-reference/widgets/instantsearch/js/" + `); }); it('keeps a mainHelper already set on the instance (Vue SSR)', () => { @@ -2217,10 +2217,10 @@ describe('setUiState', () => { expect(() => { search.setUiState({}); }).toThrowErrorMatchingInlineSnapshot(` -"The \`start\` method needs to be called before \`setUiState\`. + "The \`start\` method needs to be called before \`setUiState\`. -See documentation: https://www.algolia.com/doc/api-reference/widgets/instantsearch/js/" -`); + See documentation: https://www.algolia.com/doc/api-reference/widgets/instantsearch/js/" + `); }); test('triggers a search', async () => { From f820376506dbdbc07cf2dcdb1adb2b9e99e8a380 Mon Sep 17 00:00:00 2001 From: Haroen Viaene Date: Mon, 8 Sep 2025 13:55:36 +0200 Subject: [PATCH 3/3] test --- .../src/widgets/index/__tests__/index-test.ts | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/packages/instantsearch.js/src/widgets/index/__tests__/index-test.ts b/packages/instantsearch.js/src/widgets/index/__tests__/index-test.ts index 1ab87f86b3..0678b404af 100644 --- a/packages/instantsearch.js/src/widgets/index/__tests__/index-test.ts +++ b/packages/instantsearch.js/src/widgets/index/__tests__/index-test.ts @@ -186,6 +186,21 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/index-widge expect(instance.getWidgets()).toHaveLength(2); }); + it('accepts nested widgets', () => { + const instance = index({ indexName: 'indexName' }); + const searchBox = virtualSearchBox({}); + const pagination = virtualPagination({}); + const refinementList = virtualRefinementList({ attribute: 'brand' }); + + instance.addWidgets([searchBox, [pagination, refinementList]]); + expect(instance.getWidgets()).toHaveLength(3); + expect(instance.getWidgets()).toEqual([ + searchBox, + pagination, + refinementList, + ]); + }); + it('returns the instance to be able to chain the calls', () => { const topLevelInstance = index({ indexName: 'topLevelIndexName' }); const subLevelInstance = index({ indexName: 'subLevelIndexName' }); @@ -499,6 +514,20 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/index-widge expect(instance.getWidgets()).toHaveLength(0); }); + it('accepts nested widgets', () => { + const instance = index({ indexName: 'indexName' }); + const searchBox = virtualSearchBox({}); + const pagination = virtualPagination({}); + const refinementList = virtualRefinementList({ attribute: 'brand' }); + + instance.addWidgets([searchBox, pagination, refinementList]); + + expect(instance.getWidgets()).toHaveLength(3); + + instance.removeWidgets([searchBox, [pagination, refinementList]]); + expect(instance.getWidgets()).toHaveLength(0); + }); + it('returns the instance to be able to chain the calls', () => { const topLevelInstance = index({ indexName: 'topLevelIndexName' }); const subLevelInstance = index({ indexName: 'subLevelIndexName' });