From af41d74e9d0510bb21f7d25e315ba7c410c72b8d Mon Sep 17 00:00:00 2001 From: Haroen Viaene Date: Thu, 7 Aug 2025 18:32:22 +0200 Subject: [PATCH 1/3] feat(index): introduce "isolated" option An isolated index does not get requested when changes happen in the parent and also does not inherit its search parameters. If you want an isolated index to search, you need to cause a search (eg by refining a child widget) isolated indices: - do not automatically search when mounted - do not show up in ui state - do not interact with onStateChange - do not get requested in server-side rendering - do not cause searches in parent indices This option is EXPERIMENTAL, and implementation details may change in the future. Things that could change are: - which widgets get rendered when a change happens - whether the index searches automatically - whether the index is included in the URL / UiState - whether the index is include in server-side rendering Usage: ```js search.addWidgets([ index({ isolated: true, indexName: 'alone' }) ]); search.addWidgets([ index({ isolated: false }).addWidgets([ index({ indexName: 'isolated' }) ]), ]); ``` [FX-3448] supersedes the following POCs: - closes #6880 - closes #5939 --- .../instantsearch.js/src/lib/InstantSearch.ts | 5 +- .../src/widgets/index/__tests__/index-test.ts | 227 ++++++++++++++++++ .../src/widgets/index/index.ts | 125 +++++++++- 3 files changed, 343 insertions(+), 14 deletions(-) diff --git a/packages/instantsearch.js/src/lib/InstantSearch.ts b/packages/instantsearch.js/src/lib/InstantSearch.ts index 774a4660e8..ff4e121052 100644 --- a/packages/instantsearch.js/src/lib/InstantSearch.ts +++ b/packages/instantsearch.js/src/lib/InstantSearch.ts @@ -515,7 +515,10 @@ See documentation: ${createDocumentationLink({ ); } - if (this.compositionID && widgets.some(isIndexWidget)) { + if ( + this.compositionID && + widgets.some((w) => isIndexWidget(w) && !w._isolated) + ) { throw new Error( withUsage( 'The `index` widget cannot be used with a composition-based InstantSearch implementation.' 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 29fe4d1bee..ac4c49ea1f 100644 --- a/packages/instantsearch.js/src/widgets/index/__tests__/index-test.ts +++ b/packages/instantsearch.js/src/widgets/index/__tests__/index-test.ts @@ -3,6 +3,7 @@ */ import { + createCompositionClient, createSearchClient, createSingleRecommendResponse, createSingleSearchResponse, @@ -155,6 +156,12 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/index-widge `); }); + it('does not throw without `indexName` option when `isolated` is true', () => { + expect(() => { + index({ isolated: true }); + }).not.toThrow(); + }); + it('is a widget', () => { const widget = index({ indexName: 'indexName' }); @@ -3345,6 +3352,81 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/index-widge }); }); + describe('getWidgetUiState', () => { + it('returns the index UI state of its widgets', () => { + const instance = index({ indexName: 'instance' }); + const search = instantsearch({ + indexName: 'root', + searchClient: createSearchClient(), + }); + search.start(); + search.addWidgets([instance.addWidgets([virtualSearchBox({})])]); + + search.renderState.instance.searchBox!.refine('hello'); + + expect(instance.getWidgetUiState({})).toEqual({ + instance: { + query: 'hello', + }, + }); + }); + + it('returns the index of its widgets and child indexes', () => { + const instance = index({ indexName: 'instance' }); + const search = instantsearch({ + indexName: 'root', + searchClient: createSearchClient(), + }); + search.start(); + search.addWidgets([ + instance.addWidgets([ + virtualSearchBox({}), + index({ indexName: 'childInstance' }).addWidgets([ + virtualPagination({}), + ]), + ]), + ]); + + search.renderState.instance.searchBox!.refine('hello'); + search.renderState.childInstance.pagination!.refine(2); + + expect(instance.getWidgetUiState({})).toEqual({ + childInstance: { + page: 3, + }, + instance: { + query: 'hello', + }, + }); + }); + + it('does not include isolated child indexes', () => { + const instance = index({ indexName: 'instance' }); + const search = instantsearch({ + indexName: 'root', + searchClient: createSearchClient(), + }); + search.start(); + search.addWidgets([ + instance.addWidgets([ + virtualSearchBox({}), + index({ indexName: 'childInstance', isolated: true }).addWidgets([ + virtualPagination({}), + ]), + ]), + ]); + + search.renderState.instance.searchBox!.refine('hello'); + search.renderState.childInstance.pagination!.refine(2); + + expect(instance.getWidgetUiState({})).toEqual({ + instance: { + query: 'hello', + }, + }); + }); + }); + describe('setIndexUiState', () => { it('updates main UI state with an object', () => { const instance = index({ indexName: 'indexName' }); @@ -3737,6 +3819,151 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/index-widge }); }); + describe('isolated', () => { + it('sets _isolated to true when isolated option is true', () => { + const instance = index({ isolated: true }); + expect(instance._isolated).toBe(true); + expect(instance.getParent()).toBeNull(); + }); + + it('sets _isolated to false when isolated option is false or omitted', () => { + const instance = index({ indexName: 'indexName' }); + expect(instance._isolated).toBe(false); + }); + + it('returns correct parent for non-isolated indices', () => { + const parent = index({ indexName: 'parentIndex' }); + const child = index({ indexName: 'childIndex' }); + parent.addWidgets([child]); + child.init(createIndexInitOptions({ parent })); + expect(child.getParent()).toBe(parent); + }); + + it('returns null parent for isolated indices', () => { + const parent = index({ indexName: 'parentIndex' }); + const child = index({ isolated: true }); + parent.addWidgets([child]); + child.init(createIndexInitOptions({ parent })); + expect(child.getParent()).toBeNull(); + }); + + it('does not search by default when isolated', async () => { + const search = instantsearch({ + searchClient: createSearchClient(), + }).addWidgets([ + index({ isolated: true }).addWidgets([virtualSearchBox({})]), + ]); + search.start(); + + await wait(0); + expect(search.client.search).toHaveBeenCalledTimes(0); + }); + + it('searches by default when not isolated', async () => { + const search = instantsearch({ + searchClient: createSearchClient(), + }).addWidgets([ + index({ isolated: false, indexName: 'a' }).addWidgets([ + virtualSearchBox({}), + ]), + ]); + search.start(); + + await wait(0); + expect(search.client.search).toHaveBeenCalledTimes(1); + expect(castToJestMock(search.client.search).mock.calls[0][0]) + .toMatchInlineSnapshot(` + [ + { + "indexName": "a", + "params": { + "query": "", + }, + }, + ] + `); + }); + + it('searches on refine while isolated', async () => { + const search = instantsearch({ + searchClient: createSearchClient(), + }).addWidgets([ + index({ isolated: true, indexName: 'a' }).addWidgets([ + virtualSearchBox({}), + ]), + ]); + search.start(); + + await wait(0); + expect(search.client.search).toHaveBeenCalledTimes(0); + + search.renderState.a.searchBox?.refine('please search now'); + + expect(search.client.search).toHaveBeenCalledTimes(1); + expect(castToJestMock(search.client.search).mock.calls[0][0]) + .toMatchInlineSnapshot(` + [ + { + "indexName": "a", + "params": { + "query": "please search now", + }, + }, + ] + `); + }); + + it('searches on refine of a child while isolated', async () => { + const search = instantsearch({ + searchClient: createSearchClient(), + }).addWidgets([ + index({ isolated: true }).addWidgets([ + index({ indexName: 'a' }), + virtualSearchBox({}), + ]), + ]); + search.start(); + + await wait(0); + expect(search.client.search).toHaveBeenCalledTimes(0); + + search.renderState[''].searchBox?.refine('please search now'); + + expect(search.client.search).toHaveBeenCalledTimes(1); + }); + + it('triggers composition when root has a compositionId', async () => { + const search = instantsearch({ + searchClient: createCompositionClient(), + compositionID: 'composition-id', + }).addWidgets([ + index({ isolated: true, indexName: 'a' }).addWidgets([ + virtualSearchBox({}), + ]), + ]); + search.start(); + + await wait(0); + // Now called for the root, as a compositionId is set + expect(search.client.search).toHaveBeenCalledTimes(1); + + search.renderState.a.searchBox?.refine('please search now'); + + expect(search.client.search).toHaveBeenCalledTimes(2); + expect(castToJestMock(search.client.search).mock.calls[1][0]) + .toMatchInlineSnapshot(` + { + "compositionID": "a", + "requestBody": { + "params": { + "query": "please search now", + }, + }, + } + `); + }); + }); + describe('on error', () => { it('resets the state', async () => { const searchClient = createSearchClient(); diff --git a/packages/instantsearch.js/src/widgets/index/index.ts b/packages/instantsearch.js/src/widgets/index/index.ts index 1818b65a19..a9be908713 100644 --- a/packages/instantsearch.js/src/widgets/index/index.ts +++ b/packages/instantsearch.js/src/widgets/index/index.ts @@ -18,10 +18,10 @@ import type { IndexUiState, Widget, ScopedResult, - SearchClient, IndexRenderState, RenderOptions, RecommendResponse, + SearchClient, } from '../../types'; import type { AlgoliaSearchHelper as Helper, @@ -37,10 +37,50 @@ const withUsage = createDocumentationMessageGenerator({ name: 'index-widget', }); -export type IndexWidgetParams = { - indexName: string; - indexId?: string; -}; +export type IndexWidgetParams = + | { + /** + * The index or composition id to target. + */ + indexName: string; + /** + * Id to use for the index if there are multiple indices with the same name. + * This will be used to create the URL and the render state. + */ + indexId?: string; + /** + * If `true`, the index will not be merged with the main helper's state. + * This means that the index will not be part of the main search request. + * + * @default false + */ + isolated?: false; + } + | { + /** + * If `true`, the index will not be merged with the main helper's state. + * This means that the index will not be part of the main search request. + * + * This option is EXPERIMENTAL, and implementation details may change in the future. + * Things that could change are: + * - which widgets get rendered when a change happens + * - whether the index searches automatically + * - whether the index is included in the URL / UiState + * - whether the index is include in server-side rendering + * + * @default false + */ + isolated: true; + /** + * The index or composition id to target. + */ + indexName?: string; + /** + * Id to use for the index if there are multiple indices with the same name. + * This will be used to create the URL and the render state. + */ + indexId?: string; + }; export type IndexInitOptions = { instantSearchInstance: InstantSearch; @@ -118,6 +158,12 @@ export type IndexWidget = Omit< | TUiState[string] | ((previousIndexUiState: TUiState[string]) => TUiState[string]) ) => void; + /** + * This index is isolated, meaning it will not be merged with the main + * helper's state. + * @private + */ + _isolated: boolean; }; /** @@ -260,11 +306,18 @@ function resolveScopedResultsFromWidgets( } const index = (widgetParams: IndexWidgetParams): IndexWidget => { - if (widgetParams === undefined || widgetParams.indexName === undefined) { + if ( + widgetParams === undefined || + (widgetParams.indexName === undefined && !widgetParams.isolated) + ) { throw new Error(withUsage('The `indexName` option is required.')); } - const { indexName, indexId = indexName } = widgetParams; + const { + indexName = '', + indexId = indexName, + isolated = false, + } = widgetParams; let localWidgets: Array = []; let localUiState: IndexUiState = {}; @@ -280,6 +333,8 @@ const index = (widgetParams: IndexWidgetParams): IndexWidget => { $$type: 'ais.index', $$widgetType: 'ais.index', + _isolated: isolated, + getIndexName() { return indexName; }, @@ -345,7 +400,7 @@ const index = (widgetParams: IndexWidgetParams): IndexWidget => { }, getParent() { - return localParent; + return isolated ? null : localParent; }, createURL( @@ -455,7 +510,11 @@ const index = (widgetParams: IndexWidgetParams): IndexWidget => { } }); - localInstantSearchInstance.scheduleSearch(); + if (isolated) { + helper?.search(); + } else { + localInstantSearchInstance.scheduleSearch(); + } } return this; @@ -546,7 +605,11 @@ const index = (widgetParams: IndexWidgetParams): IndexWidget => { helper!.recommendState = cleanedRecommendState; if (localWidgets.length) { - localInstantSearchInstance.scheduleSearch(); + if (isolated) { + helper?.search(); + } else { + localInstantSearchInstance.scheduleSearch(); + } } } @@ -587,7 +650,7 @@ const index = (widgetParams: IndexWidgetParams): IndexWidget => { // `searchClient`. Only the "main" Helper created at the `InstantSearch` // level is aware of the client. helper = algoliasearchHelper( - {} as SearchClient, + mainHelper.getClient(), parameters.index, parameters ); @@ -597,6 +660,14 @@ const index = (widgetParams: IndexWidgetParams): IndexWidget => { // which is responsible for managing the queries (it's the only one that is // aware of the `searchClient`). helper.search = () => { + if (isolated) { + instantSearchInstance.status = 'loading'; + this.render({ instantSearchInstance }); + return instantSearchInstance.compositionID + ? helper!.searchWithComposition() + : helper!.searchOnlyWithDerivedHelpers(); + } + if (instantSearchInstance.onStateChange) { instantSearchInstance.onStateChange({ uiState: instantSearchInstance.mainIndex.getWidgetUiState({}), @@ -633,7 +704,14 @@ const index = (widgetParams: IndexWidgetParams): IndexWidget => { ); }; - derivedHelper = mainHelper.derive( + const isolatedHelper = indexName + ? helper + : algoliasearchHelper({} as SearchClient, '__empty_index__', {}); + const derivingHelper = isolated + ? isolatedHelper + : nearestIsolatedHelper(parent, mainHelper); + + derivedHelper = derivingHelper.derive( () => mergeSearchParameters( mainHelper.state, @@ -804,8 +882,12 @@ const index = (widgetParams: IndexWidgetParams): IndexWidget => { // We only render index widgets if there are no results. // This makes sure `render` is never called with `results` being `null`. + // If it's an isolated index without an index name, we render all widgets, + // as there are no results to display for the isolated index itself. let widgetsToRender = - this.getResults() || derivedHelper?.lastRecommendResults + this.getResults() || + derivedHelper?.lastRecommendResults || + (isolated && !indexName) ? localWidgets : localWidgets.filter(isIndexWidget); @@ -886,6 +968,7 @@ const index = (widgetParams: IndexWidgetParams): IndexWidget => { getWidgetUiState(uiState: TUiState) { return localWidgets .filter(isIndexWidget) + .filter((w) => !w._isolated) .reduce( (previousUiState, innerIndex) => innerIndex.getWidgetUiState(previousUiState), @@ -967,3 +1050,19 @@ function storeRenderState({ }, }; } + +/** + * Walk up the parent chain to find the closest isolated index, or fall back to mainHelper + */ +function nearestIsolatedHelper( + current: IndexWidget | null, + mainHelper: Helper +): Helper { + while (current) { + if (current._isolated) { + return current.getHelper()!; + } + current = current.getParent(); + } + return mainHelper; +} From 361751a1153d07a8587d3a638352bd5fd285350a Mon Sep 17 00:00:00 2001 From: Haroen Viaene Date: Fri, 8 Aug 2025 10:02:51 +0200 Subject: [PATCH 2/3] review --- bundlesize.config.json | 6 +++--- packages/instantsearch.js/src/widgets/index/index.ts | 4 +++- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/bundlesize.config.json b/bundlesize.config.json index 7ca99172da..4004e4da08 100644 --- a/bundlesize.config.json +++ b/bundlesize.config.json @@ -10,7 +10,7 @@ }, { "path": "./packages/instantsearch.js/dist/instantsearch.production.min.js", - "maxSize": "84.50 kB" + "maxSize": "84.75 kB" }, { "path": "./packages/instantsearch.js/dist/instantsearch.development.js", @@ -18,11 +18,11 @@ }, { "path": "packages/react-instantsearch-core/dist/umd/ReactInstantSearchCore.min.js", - "maxSize": "52.25 kB" + "maxSize": "52.50 kB" }, { "path": "packages/react-instantsearch/dist/umd/ReactInstantSearch.min.js", - "maxSize": "66.25 kB" + "maxSize": "66.50 kB" }, { "path": "packages/vue-instantsearch/vue2/umd/index.js", diff --git a/packages/instantsearch.js/src/widgets/index/index.ts b/packages/instantsearch.js/src/widgets/index/index.ts index a9be908713..39509f5a49 100644 --- a/packages/instantsearch.js/src/widgets/index/index.ts +++ b/packages/instantsearch.js/src/widgets/index/index.ts @@ -66,7 +66,7 @@ export type IndexWidgetParams = * - which widgets get rendered when a change happens * - whether the index searches automatically * - whether the index is included in the URL / UiState - * - whether the index is include in server-side rendering + * - whether the index is included in server-side rendering * * @default false */ @@ -313,6 +313,8 @@ const index = (widgetParams: IndexWidgetParams): IndexWidget => { throw new Error(withUsage('The `indexName` option is required.')); } + // When isolated=true, we use an empty string as the default indexName. + // This is intentional: isolated indices do not require a real index name. const { indexName = '', indexId = indexName, From 30c6736c0256797f86792ab0dd0bf8c894fe8c37 Mon Sep 17 00:00:00 2001 From: Haroen Viaene Date: Tue, 12 Aug 2025 17:33:52 +0200 Subject: [PATCH 3/3] exp --- .../src/widgets/index/__tests__/index-test.ts | 25 +++++++++++-------- .../src/widgets/index/index.ts | 9 ++++--- 2 files changed, 19 insertions(+), 15 deletions(-) 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 ac4c49ea1f..1ab87f86b3 100644 --- a/packages/instantsearch.js/src/widgets/index/__tests__/index-test.ts +++ b/packages/instantsearch.js/src/widgets/index/__tests__/index-test.ts @@ -158,7 +158,7 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/index-widge it('does not throw without `indexName` option when `isolated` is true', () => { expect(() => { - index({ isolated: true }); + index({ EXPERIMENTAL_isolated: true }); }).not.toThrow(); }); @@ -3410,9 +3410,10 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/index-widge search.addWidgets([ instance.addWidgets([ virtualSearchBox({}), - index({ indexName: 'childInstance', isolated: true }).addWidgets([ - virtualPagination({}), - ]), + index({ + indexName: 'childInstance', + EXPERIMENTAL_isolated: true, + }).addWidgets([virtualPagination({})]), ]), ]); @@ -3821,7 +3822,7 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/index-widge describe('isolated', () => { it('sets _isolated to true when isolated option is true', () => { - const instance = index({ isolated: true }); + const instance = index({ EXPERIMENTAL_isolated: true }); expect(instance._isolated).toBe(true); expect(instance.getParent()).toBeNull(); }); @@ -3841,7 +3842,7 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/index-widge it('returns null parent for isolated indices', () => { const parent = index({ indexName: 'parentIndex' }); - const child = index({ isolated: true }); + const child = index({ EXPERIMENTAL_isolated: true }); parent.addWidgets([child]); child.init(createIndexInitOptions({ parent })); expect(child.getParent()).toBeNull(); @@ -3851,7 +3852,9 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/index-widge const search = instantsearch({ searchClient: createSearchClient(), }).addWidgets([ - index({ isolated: true }).addWidgets([virtualSearchBox({})]), + index({ EXPERIMENTAL_isolated: true }).addWidgets([ + virtualSearchBox({}), + ]), ]); search.start(); @@ -3863,7 +3866,7 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/index-widge const search = instantsearch({ searchClient: createSearchClient(), }).addWidgets([ - index({ isolated: false, indexName: 'a' }).addWidgets([ + index({ EXPERIMENTAL_isolated: false, indexName: 'a' }).addWidgets([ virtualSearchBox({}), ]), ]); @@ -3888,7 +3891,7 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/index-widge const search = instantsearch({ searchClient: createSearchClient(), }).addWidgets([ - index({ isolated: true, indexName: 'a' }).addWidgets([ + index({ EXPERIMENTAL_isolated: true, indexName: 'a' }).addWidgets([ virtualSearchBox({}), ]), ]); @@ -3917,7 +3920,7 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/index-widge const search = instantsearch({ searchClient: createSearchClient(), }).addWidgets([ - index({ isolated: true }).addWidgets([ + index({ EXPERIMENTAL_isolated: true }).addWidgets([ index({ indexName: 'a' }), virtualSearchBox({}), ]), @@ -3937,7 +3940,7 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/index-widge searchClient: createCompositionClient(), compositionID: 'composition-id', }).addWidgets([ - index({ isolated: true, indexName: 'a' }).addWidgets([ + index({ EXPERIMENTAL_isolated: true, indexName: 'a' }).addWidgets([ virtualSearchBox({}), ]), ]); diff --git a/packages/instantsearch.js/src/widgets/index/index.ts b/packages/instantsearch.js/src/widgets/index/index.ts index 39509f5a49..6d3888da6d 100644 --- a/packages/instantsearch.js/src/widgets/index/index.ts +++ b/packages/instantsearch.js/src/widgets/index/index.ts @@ -54,7 +54,7 @@ export type IndexWidgetParams = * * @default false */ - isolated?: false; + EXPERIMENTAL_isolated?: false; } | { /** @@ -70,7 +70,7 @@ export type IndexWidgetParams = * * @default false */ - isolated: true; + EXPERIMENTAL_isolated: true; /** * The index or composition id to target. */ @@ -308,7 +308,8 @@ function resolveScopedResultsFromWidgets( const index = (widgetParams: IndexWidgetParams): IndexWidget => { if ( widgetParams === undefined || - (widgetParams.indexName === undefined && !widgetParams.isolated) + (widgetParams.indexName === undefined && + !widgetParams.EXPERIMENTAL_isolated) ) { throw new Error(withUsage('The `indexName` option is required.')); } @@ -318,7 +319,7 @@ const index = (widgetParams: IndexWidgetParams): IndexWidget => { const { indexName = '', indexId = indexName, - isolated = false, + EXPERIMENTAL_isolated: isolated = false, } = widgetParams; let localWidgets: Array = [];