From a49091be03005444e8ca8398fb0f3cf19aab1d1b Mon Sep 17 00:00:00 2001 From: Emmanuel Krebs Date: Wed, 8 Apr 2026 15:32:21 +0200 Subject: [PATCH 1/7] feat(helper): support multifeed composition responses Compositions can return multiple result sets (feeds), each identified by a `feedID`. Previously, `_runComposition` spliced only 1 result per derived helper, discarding additional feeds. This changes `queriesCount` to `Infinity` so all feeds are captured, and builds a `_feedResults` map and `_feedOrder` array on the primary `SearchResults` for downstream consumption by `connectFeeds` (Layer 2). Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/algoliasearch-helper/index.d.ts | 15 + .../src/algoliasearch.helper.js | 36 +- .../spec/algoliasearch.helper/composition.js | 392 ++++++++++++++++++ 3 files changed, 436 insertions(+), 7 deletions(-) create mode 100644 packages/algoliasearch-helper/test/spec/algoliasearch.helper/composition.js diff --git a/packages/algoliasearch-helper/index.d.ts b/packages/algoliasearch-helper/index.d.ts index cf1c9983590..43a9a26ccf8 100644 --- a/packages/algoliasearch-helper/index.d.ts +++ b/packages/algoliasearch-helper/index.d.ts @@ -1430,6 +1430,21 @@ declare namespace algoliasearchHelper { _rawResults: Array>; _state: SearchParameters; + /** + * The feed identifier, present when this result comes from a multifeed composition response. + */ + feedID?: string; + + /** + * Map of feed ID to SearchResults, populated when the composition response contains multiple feeds. + */ + _feedResults?: Record; + + /** + * Ordered list of feed IDs matching the composition response order. + */ + _feedOrder?: string[]; + /** * Marker which can be added to search results to identify them as created without a search response. * This is for internal use, e.g., avoiding caching in infinite hits, or delaying the display of these results. diff --git a/packages/algoliasearch-helper/src/algoliasearch.helper.js b/packages/algoliasearch-helper/src/algoliasearch.helper.js index d62b81b4b0e..f4e4131fecd 100644 --- a/packages/algoliasearch-helper/src/algoliasearch.helper.js +++ b/packages/algoliasearch-helper/src/algoliasearch.helper.js @@ -1695,7 +1695,8 @@ AlgoliaSearchHelper.prototype._runComposition = function () { states.push({ state: derivedState, - queriesCount: derivedStateQueries.length, + // Infinity so splice grabs all feed results from a multifeed composition response + queriesCount: Infinity, helper: derivedHelper, }); @@ -1884,12 +1885,33 @@ AlgoliaSearchHelper.prototype._dispatchAlgoliaResponse = function ( return; } - helper.lastResults = new SearchResults( - state, - specificResults, - self._searchResultsOptions - ); - if (rawContent !== undefined) helper.lastResults._rawContent = rawContent; + // Multifeed composition: build per-feed SearchResults map + if (specificResults.length > 0 && specificResults[0].feedID) { + var feedResults = {}; + var feedOrder = specificResults.map(function (r) { + return r.feedID; + }); + specificResults.forEach(function (r) { + var sr = new SearchResults(state, [r], self._searchResultsOptions); + if (rawContent !== undefined) sr._rawContent = rawContent; + feedResults[r.feedID] = sr; + }); + helper.lastResults = new SearchResults( + state, + [specificResults[0]], + self._searchResultsOptions + ); + if (rawContent !== undefined) helper.lastResults._rawContent = rawContent; + helper.lastResults._feedResults = feedResults; + helper.lastResults._feedOrder = feedOrder; + } else { + helper.lastResults = new SearchResults( + state, + specificResults, + self._searchResultsOptions + ); + if (rawContent !== undefined) helper.lastResults._rawContent = rawContent; + } helper.emit('result', { results: helper.lastResults, diff --git a/packages/algoliasearch-helper/test/spec/algoliasearch.helper/composition.js b/packages/algoliasearch-helper/test/spec/algoliasearch.helper/composition.js new file mode 100644 index 00000000000..2c6cb3b02a6 --- /dev/null +++ b/packages/algoliasearch-helper/test/spec/algoliasearch.helper/composition.js @@ -0,0 +1,392 @@ +'use strict'; + +var algoliaSearchHelper = require('../../../index'); + +function makeFakeCompositionClient(response) { + return { + search: jest.fn(function () { + return Promise.resolve(response); + }), + }; +} + +function makeFeedResult(feedID, overrides) { + return Object.assign( + { + feedID: feedID, + hits: [], + nbHits: 0, + page: 0, + nbPages: 0, + hitsPerPage: 10, + processingTimeMS: 1, + query: '', + index: 'my-index', + }, + overrides + ); +} + +describe('composition multifeed dispatch', function () { + test('multifeed response populates _feedResults and _feedOrder', function () { + var client = makeFakeCompositionClient({ + results: [ + makeFeedResult('products', { + hits: [{ objectID: '1', name: 'Product A' }], + nbHits: 100, + nbPages: 10, + }), + makeFeedResult('articles', { + hits: [{ objectID: '2', title: 'Article B' }], + nbHits: 50, + nbPages: 5, + }), + ], + }); + + var helper = algoliaSearchHelper(client, 'my-composition-id'); + var derivedHelper = helper.derive(function (state) { + return state; + }); + + var resultPromise = new Promise(function (resolve) { + derivedHelper.on('result', resolve); + }); + + helper.searchWithComposition(); + + return resultPromise.then(function (event) { + var results = event.results; + + // _feedResults map is populated + expect(results._feedResults).toBeDefined(); + expect(Object.keys(results._feedResults)).toEqual([ + 'products', + 'articles', + ]); + + // _feedOrder preserves response order + expect(results._feedOrder).toEqual(['products', 'articles']); + + // Each feed has its own SearchResults with correct data + var products = results._feedResults.products; + expect(products.hits).toEqual([{ objectID: '1', name: 'Product A' }]); + expect(products.nbHits).toBe(100); + expect(products.feedID).toBe('products'); + + var articles = results._feedResults.articles; + expect(articles.hits).toEqual([{ objectID: '2', title: 'Article B' }]); + expect(articles.nbHits).toBe(50); + expect(articles.feedID).toBe('articles'); + + // Primary lastResults uses first feed + expect(results.hits).toEqual([{ objectID: '1', name: 'Product A' }]); + expect(results.nbHits).toBe(100); + expect(results.feedID).toBe('products'); + + derivedHelper.detach(); + }); + }); + + test('single-feed response without feedID has no _feedResults (backward compat)', function () { + var client = makeFakeCompositionClient({ + results: [ + { + hits: [{ objectID: '1' }], + nbHits: 1, + page: 0, + nbPages: 1, + hitsPerPage: 10, + processingTimeMS: 1, + query: '', + index: 'my-index', + }, + ], + }); + + var helper = algoliaSearchHelper(client, 'my-composition-id'); + var derivedHelper = helper.derive(function (state) { + return state; + }); + + var resultPromise = new Promise(function (resolve) { + derivedHelper.on('result', resolve); + }); + + helper.searchWithComposition(); + + return resultPromise.then(function (event) { + var results = event.results; + + expect(results._feedResults).toBeUndefined(); + expect(results._feedOrder).toBeUndefined(); + expect(results.hits).toEqual([{ objectID: '1' }]); + expect(results.nbHits).toBe(1); + + derivedHelper.detach(); + }); + }); + + test('no compositionID (empty index) dispatches null results', function () { + var client = makeFakeCompositionClient({ + results: [], + }); + + var helper = algoliaSearchHelper(client, ''); + var derivedHelper = helper.derive(function (state) { + return state; + }); + + var resultPromise = new Promise(function (resolve) { + derivedHelper.on('result', resolve); + }); + + helper.searchWithComposition(); + + return resultPromise.then(function (event) { + expect(event.results).toBeNull(); + + derivedHelper.detach(); + }); + }); + + test('captures all feeds regardless of count', function () { + var client = makeFakeCompositionClient({ + results: [ + makeFeedResult('feed1'), + makeFeedResult('feed2'), + makeFeedResult('feed3'), + ], + }); + + var helper = algoliaSearchHelper(client, 'my-composition-id'); + var derivedHelper = helper.derive(function (state) { + return state; + }); + + var resultPromise = new Promise(function (resolve) { + derivedHelper.on('result', resolve); + }); + + helper.searchWithComposition(); + + return resultPromise.then(function (event) { + var results = event.results; + + expect(results._feedOrder).toEqual(['feed1', 'feed2', 'feed3']); + expect(Object.keys(results._feedResults)).toEqual([ + 'feed1', + 'feed2', + 'feed3', + ]); + + derivedHelper.detach(); + }); + }); + + test('rawContent is propagated to each feed SearchResults', function () { + var client = makeFakeCompositionClient({ + results: [makeFeedResult('products'), makeFeedResult('articles')], + compositionID: 'my-composition-id', + }); + + var helper = algoliaSearchHelper(client, 'my-composition-id'); + var derivedHelper = helper.derive(function (state) { + return state; + }); + + var resultPromise = new Promise(function (resolve) { + derivedHelper.on('result', resolve); + }); + + helper.searchWithComposition(); + + return resultPromise.then(function (event) { + var results = event.results; + + expect(results._rawContent).toEqual({ compositionID: 'my-composition-id' }); + expect(results._feedResults.products._rawContent).toEqual({ + compositionID: 'my-composition-id', + }); + expect(results._feedResults.articles._rawContent).toEqual({ + compositionID: 'my-composition-id', + }); + + derivedHelper.detach(); + }); + }); + + test('client.search is called with composition query format', function () { + var client = makeFakeCompositionClient({ + results: [makeFeedResult('products')], + }); + + var helper = algoliaSearchHelper(client, 'my-composition-id'); + var derivedHelper = helper.derive(function (state) { + return state; + }); + + helper.searchWithComposition(); + + expect(client.search).toHaveBeenCalledTimes(1); + var query = client.search.mock.calls[0][0]; + expect(query.compositionID).toBe('my-composition-id'); + expect(query.requestBody).toBeDefined(); + expect(query.requestBody.params).toBeDefined(); + + derivedHelper.detach(); + }); + + test('derivedHelper emits search event on searchWithComposition', function () { + var searched = jest.fn(); + var client = { + search: jest.fn(function () { + return new Promise(function () {}); + }), + }; + + var helper = algoliaSearchHelper(client, 'my-composition-id'); + var derivedHelper = helper.derive(function (state) { + return state; + }); + + derivedHelper.on('search', searched); + + expect(searched).toHaveBeenCalledTimes(0); + + helper.searchWithComposition(); + + expect(searched).toHaveBeenCalledTimes(1); + expect(searched).toHaveBeenLastCalledWith({ + state: helper.state, + results: null, + }); + + derivedHelper.detach(); + }); + + test('empty results with valid compositionID creates empty SearchResults', function () { + var client = makeFakeCompositionClient({ + results: [], + }); + + var helper = algoliaSearchHelper(client, 'my-composition-id'); + var derivedHelper = helper.derive(function (state) { + return state; + }); + + var resultPromise = new Promise(function (resolve) { + derivedHelper.on('result', resolve); + }); + + helper.searchWithComposition(); + + return resultPromise.then(function (event) { + var results = event.results; + + expect(results).toBeDefined(); + expect(results._feedResults).toBeUndefined(); + expect(results._feedOrder).toBeUndefined(); + expect(results.hits).toBeUndefined(); + expect(results.nbHits).toBeUndefined(); + + derivedHelper.detach(); + }); + }); + + test('multiple derived helpers throws (single-query guard)', function () { + var client = makeFakeCompositionClient({ results: [] }); + + var helper = algoliaSearchHelper(client, 'my-composition-id'); + var derivedHelper1 = helper.derive(function (state) { + return state; + }); + var derivedHelper2 = helper.derive(function (state) { + return state; + }); + + expect(function () { + helper.searchWithComposition(); + }).toThrow('Only one query is allowed when using a composition.'); + + derivedHelper1.detach(); + derivedHelper2.detach(); + }); + + test('error from client.search emits error event on helper', function () { + var searchError = new Error('Composition search failed'); + var client = { + search: jest.fn(function () { + return Promise.reject(searchError); + }), + }; + + var helper = algoliaSearchHelper(client, 'my-composition-id'); + var derivedHelper = helper.derive(function (state) { + return state; + }); + + var errorPromise = new Promise(function (resolve) { + helper.on('error', resolve); + }); + + helper.searchWithComposition(); + + return errorPromise.then(function (event) { + expect(event.error).toBe(searchError); + + derivedHelper.detach(); + }); + }); + + test('sequential searches with different feeds update results correctly', function () { + var firstResponse = { + results: [ + makeFeedResult('products', { hits: [{ objectID: '1' }], nbHits: 1 }), + makeFeedResult('articles', { hits: [{ objectID: '2' }], nbHits: 1 }), + ], + }; + + var secondResponse = { + results: [ + makeFeedResult('videos', { hits: [{ objectID: '3' }], nbHits: 1 }), + ], + }; + + var callCount = 0; + var client = { + search: jest.fn(function () { + callCount++; + if (callCount === 1) return Promise.resolve(firstResponse); + return Promise.resolve(secondResponse); + }), + }; + + var helper = algoliaSearchHelper(client, 'my-composition-id'); + var derivedHelper = helper.derive(function (state) { + return state; + }); + + var resultCount = 0; + var secondResultPromise = new Promise(function (resolve) { + derivedHelper.on('result', function (event) { + resultCount++; + if (resultCount === 2) resolve(event); + }); + }); + + helper.searchWithComposition(); + helper.searchWithComposition(); + + return secondResultPromise.then(function (event) { + var results = event.results; + + // Second response replaces first: only 'videos', no 'products'/'articles' + expect(results._feedOrder).toEqual(['videos']); + expect(Object.keys(results._feedResults)).toEqual(['videos']); + expect(results._feedResults.videos.hits).toEqual([{ objectID: '3' }]); + + derivedHelper.detach(); + }); + }); +}); From 343e93a1a0e7c4bef91cfd417c0ac09736110f51 Mon Sep 17 00:00:00 2001 From: Emmanuel Krebs Date: Wed, 8 Apr 2026 15:41:48 +0200 Subject: [PATCH 2/7] chore: bump bundlesize limits for multifeed helper changes Co-Authored-By: Claude Opus 4.6 (1M context) --- bundlesize.config.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bundlesize.config.json b/bundlesize.config.json index 453e6767773..a4d61ec0fab 100644 --- a/bundlesize.config.json +++ b/bundlesize.config.json @@ -2,7 +2,7 @@ "files": [ { "path": "packages/algoliasearch-helper/dist/algoliasearch.helper.js", - "maxSize": "43 kB" + "maxSize": "43.25 kB" }, { "path": "packages/algoliasearch-helper/dist/algoliasearch.helper.min.js", @@ -10,7 +10,7 @@ }, { "path": "./packages/instantsearch.js/dist/instantsearch.production.min.js", - "maxSize": "121.25 kB" + "maxSize": "121.5 kB" }, { "path": "./packages/instantsearch.js/dist/instantsearch.development.js", From 450d71afcf30cc3fd03018a606fffba4345c0ecd Mon Sep 17 00:00:00 2001 From: Emmanuel Krebs Date: Wed, 8 Apr 2026 18:40:05 +0200 Subject: [PATCH 3/7] refactor(helper): omit queriesCount for composition instead of using Infinity Use undefined queriesCount to signal "take all results" in _dispatchAlgoliaResponse, avoiding the Infinity hack. When queriesCount is undefined (composition path), use results directly; when defined (regular search path), splice as before. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/algoliasearch-helper/src/algoliasearch.helper.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/algoliasearch-helper/src/algoliasearch.helper.js b/packages/algoliasearch-helper/src/algoliasearch.helper.js index f4e4131fecd..c19251f1fbf 100644 --- a/packages/algoliasearch-helper/src/algoliasearch.helper.js +++ b/packages/algoliasearch-helper/src/algoliasearch.helper.js @@ -1695,8 +1695,6 @@ AlgoliaSearchHelper.prototype._runComposition = function () { states.push({ state: derivedState, - // Infinity so splice grabs all feed results from a multifeed composition response - queriesCount: Infinity, helper: derivedHelper, }); @@ -1875,7 +1873,9 @@ AlgoliaSearchHelper.prototype._dispatchAlgoliaResponse = function ( var state = s.state; var queriesCount = s.queriesCount; var helper = s.helper; - var specificResults = results.splice(0, queriesCount); + var specificResults = queriesCount !== undefined + ? results.splice(0, queriesCount) + : results; if (!state.index) { helper.emit('result', { From 4ce052c0d73b87972b929fda419aa1a6b1aff0df Mon Sep 17 00:00:00 2001 From: Emmanuel Krebs Date: Wed, 8 Apr 2026 18:52:05 +0200 Subject: [PATCH 4/7] refactor(helper): remove composition type definitions, defer to layer 2 The feedID, _feedResults, and _feedOrder type definitions have no consumers yet. Defer the typing decision (interface vs class, naming) to Layer 2 when connectFeeds introduces the first consumer. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/algoliasearch-helper/index.d.ts | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/packages/algoliasearch-helper/index.d.ts b/packages/algoliasearch-helper/index.d.ts index 43a9a26ccf8..cf1c9983590 100644 --- a/packages/algoliasearch-helper/index.d.ts +++ b/packages/algoliasearch-helper/index.d.ts @@ -1430,21 +1430,6 @@ declare namespace algoliasearchHelper { _rawResults: Array>; _state: SearchParameters; - /** - * The feed identifier, present when this result comes from a multifeed composition response. - */ - feedID?: string; - - /** - * Map of feed ID to SearchResults, populated when the composition response contains multiple feeds. - */ - _feedResults?: Record; - - /** - * Ordered list of feed IDs matching the composition response order. - */ - _feedOrder?: string[]; - /** * Marker which can be added to search results to identify them as created without a search response. * This is for internal use, e.g., avoiding caching in infinite hits, or delaying the display of these results. From 46ee2b14a62693b3882bc01162a339d55d0c0a14 Mon Sep 17 00:00:00 2001 From: Emmanuel Krebs Date: Thu, 9 Apr 2026 12:03:19 +0200 Subject: [PATCH 5/7] refactor(helper): use lastResults.feeds array instead of _feedResults map MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace _feedResults (Record) and _feedOrder (string[]) with a single lastResults.feeds (CompositionSearchResults[]) ordered array. This simplifies the API and ensures feeds survive the SSR getInitialResults → JSON → hydration round-trip naturally. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/algoliasearch-helper/index.d.ts | 12 +++ .../src/algoliasearch.helper.js | 20 ++--- .../spec/algoliasearch.helper/composition.js | 86 ++++++++----------- 3 files changed, 52 insertions(+), 66 deletions(-) diff --git a/packages/algoliasearch-helper/index.d.ts b/packages/algoliasearch-helper/index.d.ts index cf1c9983590..6098796e010 100644 --- a/packages/algoliasearch-helper/index.d.ts +++ b/packages/algoliasearch-helper/index.d.ts @@ -1521,6 +1521,18 @@ declare namespace algoliasearchHelper { * @return all the refinements */ getRefinements(): SearchResults.Refinement[]; + + /** Ordered array of per-feed results, present when results come from a multifeed composition response. */ + feeds?: CompositionSearchResults[]; + } + + /** + * SearchResults from a composition feed response. + * Each feed in the composition response produces one of these. + */ + export interface CompositionSearchResults extends SearchResults { + /** The feed identifier for this result set. */ + feedID: string; } export type Banner = { diff --git a/packages/algoliasearch-helper/src/algoliasearch.helper.js b/packages/algoliasearch-helper/src/algoliasearch.helper.js index c19251f1fbf..16b9f5cf633 100644 --- a/packages/algoliasearch-helper/src/algoliasearch.helper.js +++ b/packages/algoliasearch-helper/src/algoliasearch.helper.js @@ -1885,25 +1885,15 @@ AlgoliaSearchHelper.prototype._dispatchAlgoliaResponse = function ( return; } - // Multifeed composition: build per-feed SearchResults map + // Multifeed composition: build ordered SearchResults array on lastResults if (specificResults.length > 0 && specificResults[0].feedID) { - var feedResults = {}; - var feedOrder = specificResults.map(function (r) { - return r.feedID; - }); - specificResults.forEach(function (r) { + var feeds = specificResults.map(function (r) { var sr = new SearchResults(state, [r], self._searchResultsOptions); if (rawContent !== undefined) sr._rawContent = rawContent; - feedResults[r.feedID] = sr; + return sr; }); - helper.lastResults = new SearchResults( - state, - [specificResults[0]], - self._searchResultsOptions - ); - if (rawContent !== undefined) helper.lastResults._rawContent = rawContent; - helper.lastResults._feedResults = feedResults; - helper.lastResults._feedOrder = feedOrder; + helper.lastResults = feeds[0]; + helper.lastResults.feeds = feeds; } else { helper.lastResults = new SearchResults( state, diff --git a/packages/algoliasearch-helper/test/spec/algoliasearch.helper/composition.js b/packages/algoliasearch-helper/test/spec/algoliasearch.helper/composition.js index 2c6cb3b02a6..fa9d53c18f2 100644 --- a/packages/algoliasearch-helper/test/spec/algoliasearch.helper/composition.js +++ b/packages/algoliasearch-helper/test/spec/algoliasearch.helper/composition.js @@ -28,7 +28,7 @@ function makeFeedResult(feedID, overrides) { } describe('composition multifeed dispatch', function () { - test('multifeed response populates _feedResults and _feedOrder', function () { + test('multifeed response populates lastResults.feeds', function () { var client = makeFakeCompositionClient({ results: [ makeFeedResult('products', { @@ -55,40 +55,32 @@ describe('composition multifeed dispatch', function () { helper.searchWithComposition(); - return resultPromise.then(function (event) { - var results = event.results; - - // _feedResults map is populated - expect(results._feedResults).toBeDefined(); - expect(Object.keys(results._feedResults)).toEqual([ - 'products', - 'articles', - ]); - - // _feedOrder preserves response order - expect(results._feedOrder).toEqual(['products', 'articles']); + return resultPromise.then(function () { + // lastResults.feeds is an ordered array of SearchResults + expect(derivedHelper.lastResults.feeds).toHaveLength(2); - // Each feed has its own SearchResults with correct data - var products = results._feedResults.products; + var products = derivedHelper.lastResults.feeds[0]; expect(products.hits).toEqual([{ objectID: '1', name: 'Product A' }]); expect(products.nbHits).toBe(100); expect(products.feedID).toBe('products'); - var articles = results._feedResults.articles; + var articles = derivedHelper.lastResults.feeds[1]; expect(articles.hits).toEqual([{ objectID: '2', title: 'Article B' }]); expect(articles.nbHits).toBe(50); expect(articles.feedID).toBe('articles'); - // Primary lastResults uses first feed - expect(results.hits).toEqual([{ objectID: '1', name: 'Product A' }]); - expect(results.nbHits).toBe(100); - expect(results.feedID).toBe('products'); + // lastResults points to the first feed + expect(derivedHelper.lastResults.hits).toEqual([ + { objectID: '1', name: 'Product A' }, + ]); + expect(derivedHelper.lastResults.nbHits).toBe(100); + expect(derivedHelper.lastResults.feedID).toBe('products'); derivedHelper.detach(); }); }); - test('single-feed response without feedID has no _feedResults (backward compat)', function () { + test('single-feed response without feedID has no feeds (backward compat)', function () { var client = makeFakeCompositionClient({ results: [ { @@ -115,13 +107,10 @@ describe('composition multifeed dispatch', function () { helper.searchWithComposition(); - return resultPromise.then(function (event) { - var results = event.results; - - expect(results._feedResults).toBeUndefined(); - expect(results._feedOrder).toBeUndefined(); - expect(results.hits).toEqual([{ objectID: '1' }]); - expect(results.nbHits).toBe(1); + return resultPromise.then(function () { + expect(derivedHelper.lastResults.feeds).toBeUndefined(); + expect(derivedHelper.lastResults.hits).toEqual([{ objectID: '1' }]); + expect(derivedHelper.lastResults.nbHits).toBe(1); derivedHelper.detach(); }); @@ -170,15 +159,11 @@ describe('composition multifeed dispatch', function () { helper.searchWithComposition(); - return resultPromise.then(function (event) { - var results = event.results; - - expect(results._feedOrder).toEqual(['feed1', 'feed2', 'feed3']); - expect(Object.keys(results._feedResults)).toEqual([ - 'feed1', - 'feed2', - 'feed3', - ]); + return resultPromise.then(function () { + expect(derivedHelper.lastResults.feeds).toHaveLength(3); + expect(derivedHelper.lastResults.feeds[0].feedID).toBe('feed1'); + expect(derivedHelper.lastResults.feeds[1].feedID).toBe('feed2'); + expect(derivedHelper.lastResults.feeds[2].feedID).toBe('feed3'); derivedHelper.detach(); }); @@ -201,14 +186,16 @@ describe('composition multifeed dispatch', function () { helper.searchWithComposition(); - return resultPromise.then(function (event) { - var results = event.results; - - expect(results._rawContent).toEqual({ compositionID: 'my-composition-id' }); - expect(results._feedResults.products._rawContent).toEqual({ + return resultPromise.then(function () { + expect(derivedHelper.lastResults.feeds[0]._rawContent).toEqual({ compositionID: 'my-composition-id', }); - expect(results._feedResults.articles._rawContent).toEqual({ + expect(derivedHelper.lastResults.feeds[1]._rawContent).toEqual({ + compositionID: 'my-composition-id', + }); + + // lastResults (first feed) also has _rawContent + expect(derivedHelper.lastResults._rawContent).toEqual({ compositionID: 'my-composition-id', }); @@ -285,8 +272,7 @@ describe('composition multifeed dispatch', function () { var results = event.results; expect(results).toBeDefined(); - expect(results._feedResults).toBeUndefined(); - expect(results._feedOrder).toBeUndefined(); + expect(derivedHelper.lastResults.feeds).toBeUndefined(); expect(results.hits).toBeUndefined(); expect(results.nbHits).toBeUndefined(); @@ -378,13 +364,11 @@ describe('composition multifeed dispatch', function () { helper.searchWithComposition(); helper.searchWithComposition(); - return secondResultPromise.then(function (event) { - var results = event.results; - + return secondResultPromise.then(function () { // Second response replaces first: only 'videos', no 'products'/'articles' - expect(results._feedOrder).toEqual(['videos']); - expect(Object.keys(results._feedResults)).toEqual(['videos']); - expect(results._feedResults.videos.hits).toEqual([{ objectID: '3' }]); + expect(derivedHelper.lastResults.feeds).toHaveLength(1); + expect(derivedHelper.lastResults.feeds[0].feedID).toBe('videos'); + expect(derivedHelper.lastResults.feeds[0].hits).toEqual([{ objectID: '3' }]); derivedHelper.detach(); }); From 3b5631189c05630e826f7f3086d1a3b7b5f91d6c Mon Sep 17 00:00:00 2001 From: Emmanuel Krebs Date: Thu, 9 Apr 2026 16:52:02 +0200 Subject: [PATCH 6/7] fix(helper): avoid circular ref in multifeed lastResults Create a separate SearchResults instance for lastResults instead of reusing feeds[0], which caused circular references during JSON.stringify (lastResults.feeds[0] === lastResults). Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/algoliasearch-helper/src/algoliasearch.helper.js | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/algoliasearch-helper/src/algoliasearch.helper.js b/packages/algoliasearch-helper/src/algoliasearch.helper.js index 16b9f5cf633..f6e3c9008b3 100644 --- a/packages/algoliasearch-helper/src/algoliasearch.helper.js +++ b/packages/algoliasearch-helper/src/algoliasearch.helper.js @@ -1892,7 +1892,13 @@ AlgoliaSearchHelper.prototype._dispatchAlgoliaResponse = function ( if (rawContent !== undefined) sr._rawContent = rawContent; return sr; }); - helper.lastResults = feeds[0]; + // Separate instance from feeds[0] to avoid circular ref in JSON.stringify + helper.lastResults = new SearchResults( + state, + [specificResults[0]], + self._searchResultsOptions + ); + if (rawContent !== undefined) helper.lastResults._rawContent = rawContent; helper.lastResults.feeds = feeds; } else { helper.lastResults = new SearchResults( From ec3854e12f2a09b5636146346d38f3a71f087175 Mon Sep 17 00:00:00 2001 From: Emmanuel Krebs Date: Fri, 10 Apr 2026 14:27:11 +0200 Subject: [PATCH 7/7] apply suggestions from code review Co-authored-by: Haroen Viaene --- packages/algoliasearch-helper/src/algoliasearch.helper.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/algoliasearch-helper/src/algoliasearch.helper.js b/packages/algoliasearch-helper/src/algoliasearch.helper.js index f6e3c9008b3..f5a220cc577 100644 --- a/packages/algoliasearch-helper/src/algoliasearch.helper.js +++ b/packages/algoliasearch-helper/src/algoliasearch.helper.js @@ -1892,14 +1892,13 @@ AlgoliaSearchHelper.prototype._dispatchAlgoliaResponse = function ( if (rawContent !== undefined) sr._rawContent = rawContent; return sr; }); - // Separate instance from feeds[0] to avoid circular ref in JSON.stringify helper.lastResults = new SearchResults( state, [specificResults[0]], self._searchResultsOptions ); - if (rawContent !== undefined) helper.lastResults._rawContent = rawContent; helper.lastResults.feeds = feeds; + if (rawContent !== undefined) helper.lastResults._rawContent = rawContent; } else { helper.lastResults = new SearchResults( state,