From 5d517f0d1fdbc6584389608aa821b9d195fc1c4d Mon Sep 17 00:00:00 2001 From: Sarah Dayan <5370675+sarahdayan@users.noreply.github.com> Date: Sat, 21 Feb 2026 09:57:22 +0100 Subject: [PATCH] feat(runtime): add `ais.index` block support for index-scoped widgets Index-dependent widgets (searchBox, configure, hits, etc.) need to be scoped to a search index. Rather than setting indexName on the InstantSearch instance, ais.index blocks wrap child widgets uniformly for both single-index and multi-index (federated search) scenarios. - Make block types recursive (blocks can contain nested blocks) - Extract processBlocks() for recursive block processing - Handle ais.index by creating an index widget and recursing into children - Validate indexName presence with warning on missing - Register cleanup for index widgets and injected style elements - Add indexId to ExperienceApiBlockParameters Co-Authored-By: Claude Opus 4.6 --- .../runtime/src/experiences/middleware.ts | 255 +++++++++++------- packages/runtime/src/experiences/types.ts | 17 +- 2 files changed, 176 insertions(+), 96 deletions(-) diff --git a/packages/runtime/src/experiences/middleware.ts b/packages/runtime/src/experiences/middleware.ts index 4b28a50..78a7266 100644 --- a/packages/runtime/src/experiences/middleware.ts +++ b/packages/runtime/src/experiences/middleware.ts @@ -1,6 +1,11 @@ import { walkIndex } from 'instantsearch.js/es/lib/utils'; +import index from 'instantsearch.js/es/widgets/index/index'; -import type { InternalMiddleware } from 'instantsearch.js/es/types'; +import type { + IndexWidget, + InternalMiddleware, + InstantSearch, +} from 'instantsearch.js/es/types'; import type { Environment, ExperienceApiResponse, @@ -12,6 +17,56 @@ export type ExperienceProps = { env?: Environment; }; +export function createExperienceMiddleware( + props: ExperienceProps +): InternalMiddleware { + const { config, env = 'prod' } = props; + + return ({ instantSearchInstance }) => { + const cleanups: Array<() => void> = []; + + return { + $$type: 'ais.experience', + $$internal: true, + onStateChange: () => {}, + subscribe() { + const experienceWidgets: ExperienceWidget[] = []; + walkIndex(instantSearchInstance.mainIndex, (index) => { + const widgets = index.getWidgets(); + + widgets.forEach((widget) => { + if (widget.$$type === 'ais.experience') { + experienceWidgets.push(widget as ExperienceWidget); + } + }); + }); + + experienceWidgets.forEach((widget) => { + const parent = widget.parent!; + + parent.removeWidgets([widget]); + + processBlocks({ + blocks: config.blocks, + parent, + widget, + env, + instantSearchInstance, + cleanups, + }); + }); + }, + started: () => {}, + unsubscribe: () => { + cleanups.forEach((cleanup) => { + return cleanup(); + }); + cleanups.length = 0; + }, + }; + }; +} + type Placement = 'inside' | 'before' | 'after' | 'replace' | 'body'; type ResolvedContainer = { @@ -74,102 +129,118 @@ function resolveContainer( }; } -export function createExperienceMiddleware( - props: ExperienceProps -): InternalMiddleware { - const { config, env = 'prod' } = props; - - return ({ instantSearchInstance }) => { - const cleanups: Array<() => void> = []; - - return { - $$type: 'ais.experience', - $$internal: true, - onStateChange: () => {}, - subscribe() { - const experienceWidgets: ExperienceWidget[] = []; - walkIndex(instantSearchInstance.mainIndex, (index) => { - const widgets = index.getWidgets(); +type ProcessBlocksOptions = { + blocks: ExperienceApiResponse['blocks']; + parent: IndexWidget; + widget: ExperienceWidget; + env: Environment; + instantSearchInstance: InstantSearch; + cleanups: Array<() => void>; +}; - widgets.forEach((widget) => { - if (widget.$$type === 'ais.experience') { - experienceWidgets.push(widget as ExperienceWidget); - } - }); +function processBlocks({ + blocks, + parent, + widget, + env, + instantSearchInstance, + cleanups, +}: ProcessBlocksOptions) { + blocks.forEach((block) => { + const { type, parameters } = block; + + if (type === 'ais.index') { + const { indexName, indexId } = parameters; + + if (!indexName) { + console.warn( + '[Algolia Experiences] ais.index block is missing required "indexName" parameter, skipping.' + ); + return; + } + + const indexWidget = index({ + indexName, + indexId: indexId as string | undefined, + }); + + parent.addWidgets([indexWidget]); + cleanups.push(() => { + return parent.removeWidgets([indexWidget]); + }); + + if (block.blocks) { + processBlocks({ + blocks: block.blocks, + parent: indexWidget, + widget, + env, + instantSearchInstance, + cleanups, }); + } - experienceWidgets.forEach((widget) => { - const parent = widget.parent!; - - parent.removeWidgets([widget]); - - config.blocks.forEach((block) => { - const { type, parameters } = block; - - const cssVariables = parameters.cssVariables ?? {}; - const cssVariablesKeys = Object.keys(cssVariables); - if (cssVariablesKeys.length > 0) { - injectStyleElement(` - :root { - ${cssVariablesKeys - .map((key) => { - return `--ais-${key}: ${cssVariables[key]};`; - }) - .join(';')} - } - `); - } + return; + } - const supportedWidget = widget.$$supportedWidgets[type]; - if (!supportedWidget) { - return; - } + const cssVariables = parameters.cssVariables ?? {}; + const cssVariablesKeys = Object.keys(cssVariables); - const { placement, ...widgetParams } = parameters; - const resolved = resolveContainer( - widgetParams.container, - placement as Placement | undefined - ); - - if (!resolved) { - return; - } - - cleanups.push(resolved.cleanup); - - const newWidget = supportedWidget.widget; - supportedWidget - .transformParams(widgetParams, { env, instantSearchInstance }) - .then((transformedParams) => { - if (newWidget) { - const params = { - ...(transformedParams as Record), - container: resolved.container, - }; - const widgets = newWidget(params); - parent.addWidgets( - Array.isArray(widgets) ? widgets : [widgets] - ); - } + if (cssVariablesKeys.length > 0) { + const style = injectStyleElement(` + :root { + ${cssVariablesKeys + .map((key) => { + return `--ais-${key}: ${cssVariables[key]};`; }) - .catch((error) => { - console.error( - `[Algolia Experiences] Failed to mount widget "${type}":`, - error - ); - }); - }); - }); - }, - started: () => {}, - unsubscribe: () => { - cleanups.forEach((cleanup) => { - return cleanup(); - }); - cleanups.length = 0; - }, - }; - }; + .join(';')} + } + `); + cleanups.push(() => { + return style.remove(); + }); + } + + const supportedWidget = widget.$$supportedWidgets[type]; + + if (!supportedWidget) { + return; + } + + const { placement, ...widgetParams } = parameters; + + const resolved = resolveContainer( + widgetParams.container as string | undefined, + placement as Placement | undefined + ); + + if (!resolved) { + return; + } + + cleanups.push(resolved.cleanup); + + const newWidget = supportedWidget.widget; + + supportedWidget + .transformParams(widgetParams, { env, instantSearchInstance }) + .then((transformedParams) => { + if (newWidget) { + const params = { + ...(transformedParams as Record), + container: resolved.container, + }; + const widgets = newWidget(params); + parent.addWidgets(Array.isArray(widgets) ? widgets : [widgets]); + } + }) + .catch((error) => { + console.error( + `[Algolia Experiences] Failed to mount widget "${type}":`, + error + ); + }); + }); } export function injectStyleElement(textContent: string) { @@ -178,4 +249,6 @@ export function injectStyleElement(textContent: string) { style.textContent = textContent; document.head.appendChild(style); + + return style; } diff --git a/packages/runtime/src/experiences/types.ts b/packages/runtime/src/experiences/types.ts index 5652165..57b2522 100644 --- a/packages/runtime/src/experiences/types.ts +++ b/packages/runtime/src/experiences/types.ts @@ -12,13 +12,20 @@ type ExperienceApiBlockParameters = { placement?: 'inside' | 'before' | 'after' | 'replace' | 'body'; cssVariables?: Record; indexName?: string; -} & Record<'container' | 'cssVariables' | 'indexName' | (string & {}), unknown>; + indexId?: string; +} & Record< + 'container' | 'cssVariables' | 'indexName' | 'indexId' | (string & {}), + unknown +>; + +type ExperienceApiBlock = { + type: string; + parameters: ExperienceApiBlockParameters; + blocks?: ExperienceApiBlock[]; +}; export type ExperienceApiResponse = { - blocks: Array<{ - type: string; - parameters: ExperienceApiBlockParameters; - }>; + blocks: ExperienceApiBlock[]; }; export type ExperienceWidgetParams = {