import type { AlgoliaSearchHelper } from 'algoliasearch-helper'; import algoliasearchHelper from 'algoliasearch-helper'; import EventEmitter from '@algolia/events'; import type { IndexWidget } from '../widgets/index/index'; import index from '../widgets/index/index'; import version from './version'; import createHelpers from './createHelpers'; import { createDocumentationMessageGenerator, createDocumentationLink, defer, noop, warning, setIndexHelperState, } from './utils'; import type { InsightsClient as AlgoliaInsightsClient, SearchClient, Widget, UiState, CreateURL, Middleware, MiddlewareDefinition, RenderState, InitialResults, } from '../types'; import type { RouterProps } from '../middlewares/createRouterMiddleware'; import { createRouterMiddleware } from '../middlewares/createRouterMiddleware'; import type { InsightsEvent } from '../middlewares/createInsightsMiddleware'; import { createMetadataMiddleware, isMetadataEnabled, } from '../middlewares/createMetadataMiddleware'; const withUsage = createDocumentationMessageGenerator({ name: 'instantsearch', }); function defaultCreateURL() { return '#'; } // this purposely breaks typescript's type inference to ensure it's not used // as it's used for a default parameter for example // source: https://github.com/Microsoft/TypeScript/issues/14829#issuecomment-504042546 type NoInfer = [T][T extends any ? 0 : never]; /** * Global options for an InstantSearch instance. */ export type InstantSearchOptions< TUiState extends UiState = UiState, TRouteState = TUiState > = { /** * The name of the main index */ indexName: string; /** * The search client to plug to InstantSearch.js * * Usage: * ```javascript * // Using the default Algolia search client * instantsearch({ * indexName: 'indexName', * searchClient: algoliasearch('appId', 'apiKey') * }); * * // Using a custom search client * instantsearch({ * indexName: 'indexName', * searchClient: { * search(requests) { * // fetch response based on requests * return response; * }, * searchForFacetValues(requests) { * // fetch response based on requests * return response; * } * } * }); * ``` */ searchClient: SearchClient; /** * The locale used to display numbers. This will be passed * to `Number.prototype.toLocaleString()` */ numberLocale?: string; /** * A hook that will be called each time a search needs to be done, with the * helper as a parameter. It's your responsibility to call `helper.search()`. * This option allows you to avoid doing searches at page load for example. */ searchFunction?: (helper: AlgoliaSearchHelper) => void; /** * Function called when the state changes. * * Using this function makes the instance controlled. This means that you * become in charge of updating the UI state with the `setUiState` function. */ onStateChange?: (params: { uiState: TUiState; setUiState( uiState: TUiState | ((previousUiState: TUiState) => TUiState) ): void; }) => void; /** * Injects a `uiState` to the `instantsearch` instance. You can use this option * to provide an initial state to a widget. Note that the state is only used * for the first search. To unconditionally pass additional parameters to the * Algolia API, take a look at the `configure` widget. */ initialUiState?: NoInfer; /** * Time before a search is considered stalled. The default is 200ms */ stalledSearchDelay?: number; /** * Router configuration used to save the UI State into the URL or any other * client side persistence. Passing `true` will use the default URL options. */ routing?: RouterProps | boolean; /** * the instance of search-insights to use for sending insights events inside * widgets like `hits`. * * @deprecated This property will be still supported in 4.x releases, but not further. It is replaced by the `insights` middleware. For more information, visit https://www.algolia.com/doc/guides/getting-insights-and-analytics/search-analytics/click-through-and-conversions/how-to/send-click-and-conversion-events-with-instantsearch/js/ */ insightsClient?: AlgoliaInsightsClient; }; export type InstantSearchStatus = 'idle' | 'loading' | 'stalled' | 'error'; /** * The actual implementation of the InstantSearch. This is * created using the `instantsearch` factory function. * It emits the 'render' event every time a search is done */ class InstantSearch< TUiState extends UiState = UiState, TRouteState = TUiState > extends EventEmitter { public client: InstantSearchOptions['searchClient']; public indexName: string; public insightsClient: AlgoliaInsightsClient | null; public onStateChange: InstantSearchOptions['onStateChange'] | null = null; public helper: AlgoliaSearchHelper | null; public mainHelper: AlgoliaSearchHelper | null; public mainIndex: IndexWidget; public started: boolean; public templatesConfig: Record; public renderState: RenderState = {}; public _stalledSearchDelay: number; public _searchStalledTimer: any; public _initialUiState: TUiState; public _initialResults: InitialResults | null; public _createURL: CreateURL; public _searchFunction?: InstantSearchOptions['searchFunction']; public _mainHelperSearch?: AlgoliaSearchHelper['search']; public middleware: Array<{ creator: Middleware; instance: MiddlewareDefinition; }> = []; public sendEventToInsights: (event: InsightsEvent) => void; /** * The status of the search. Can be "idle", "loading", "stalled", or "error". */ public status: InstantSearchStatus = 'idle'; /** * The last returned error from the Search API. * The error gets cleared when the next valid search response is rendered. */ public error: Error | undefined = undefined; /** * @deprecated use `status === 'stalled'` instead */ public get _isSearchStalled(): boolean { warning( false, `\`InstantSearch._isSearchStalled\` is deprecated and will be removed in InstantSearch.js 5.0. Use \`InstantSearch.status === "stalled"\` instead.` ); return this.status === 'stalled'; } public constructor(options: InstantSearchOptions) { super(); // prevent `render` event listening from causing a warning this.setMaxListeners(100); const { indexName = null, numberLocale, initialUiState = {} as TUiState, routing = null, searchFunction, stalledSearchDelay = 200, searchClient = null, insightsClient = null, onStateChange = null, } = options; if (indexName === null) { throw new Error(withUsage('The `indexName` option is required.')); } if (searchClient === null) { throw new Error(withUsage('The `searchClient` option is required.')); } if (typeof searchClient.search !== 'function') { throw new Error( `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/` ); } if (typeof searchClient.addAlgoliaAgent === 'function') { searchClient.addAlgoliaAgent(`instantsearch.js (${version})`); } warning( insightsClient === null, `\`insightsClient\` property has been deprecated. It is still supported in 4.x releases, but not further. It is replaced by the \`insights\` middleware. For more information, visit https://www.algolia.com/doc/guides/getting-insights-and-analytics/search-analytics/click-through-and-conversions/how-to/send-click-and-conversion-events-with-instantsearch/js/` ); if (insightsClient && typeof insightsClient !== 'function') { throw new Error( withUsage('The `insightsClient` option should be a function.') ); } warning( !(options as any).searchParameters, `The \`searchParameters\` option is deprecated and will not be supported in InstantSearch.js 4.x. You can replace it with the \`configure\` widget: \`\`\` search.addWidgets([ configure(${JSON.stringify((options as any).searchParameters, null, 2)}) ]); \`\`\` See ${createDocumentationLink({ name: 'configure', })}` ); this.client = searchClient; this.insightsClient = insightsClient; this.indexName = indexName; this.helper = null; this.mainHelper = null; this.mainIndex = index({ indexName, }); this.onStateChange = onStateChange; this.started = false; this.templatesConfig = { helpers: createHelpers({ numberLocale }), compileOptions: {}, }; this._stalledSearchDelay = stalledSearchDelay; this._searchStalledTimer = null; this._createURL = defaultCreateURL; this._initialUiState = initialUiState; this._initialResults = null; if (searchFunction) { this._searchFunction = searchFunction; } this.sendEventToInsights = noop; if (routing) { const routerOptions = typeof routing === 'boolean' ? undefined : routing; this.use(createRouterMiddleware(routerOptions)); } if (isMetadataEnabled()) { this.use(createMetadataMiddleware()); } } /** * Hooks a middleware into the InstantSearch lifecycle. */ public use(...middleware: Middleware[]): this { const newMiddlewareList = middleware.map((fn) => { const newMiddleware = { subscribe: noop, started: noop, unsubscribe: noop, onStateChange: noop, ...fn({ instantSearchInstance: this as unknown as InstantSearch< UiState, UiState >, }), }; this.middleware.push({ creator: fn, instance: newMiddleware, }); return newMiddleware; }); // If the instance has already started, we directly subscribe the // middleware so they're notified of changes. if (this.started) { newMiddlewareList.forEach((m) => { m.subscribe(); m.started(); }); } return this; } /** * Removes a middleware from the InstantSearch lifecycle. */ public unuse(...middlewareToUnuse: Middleware[]): this { this.middleware .filter((m) => middlewareToUnuse.includes(m.creator)) .forEach((m) => m.instance.unsubscribe()); this.middleware = this.middleware.filter( (m) => !middlewareToUnuse.includes(m.creator) ); return this; } // @major we shipped with EXPERIMENTAL_use, but have changed that to just `use` now public EXPERIMENTAL_use(...middleware: Middleware[]): this { warning( false, 'The middleware API is now considered stable, so we recommend replacing `EXPERIMENTAL_use` with `use` before upgrading to the next major version.' ); return this.use(...middleware); } /** * Adds a widget to the search instance. * A widget can be added either before or after InstantSearch has started. * @param widget The widget to add to InstantSearch. * * @deprecated This method will still be supported in 4.x releases, but not further. It is replaced by `addWidgets([widget])`. */ public addWidget(widget: Widget) { warning( false, 'addWidget will still be supported in 4.x releases, but not further. It is replaced by `addWidgets([widget])`' ); return this.addWidgets([widget]); } /** * Adds multiple widgets to the search instance. * 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) { if (!Array.isArray(widgets)) { throw new Error( withUsage( 'The `addWidgets` method expects an array of widgets. Please use `addWidget`.' ) ); } 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.' ) ); } this.mainIndex.addWidgets(widgets); return this; } /** * Removes a widget from the search instance. * @deprecated This method will still be supported in 4.x releases, but not further. It is replaced by `removeWidgets([widget])` * @param widget The widget instance to remove from InstantSearch. * * The widget must implement a `dispose()` method to clear its state. */ public removeWidget(widget: Widget | IndexWidget) { warning( false, 'removeWidget will still be supported in 4.x releases, but not further. It is replaced by `removeWidgets([widget])`' ); return this.removeWidgets([widget]); } /** * Removes multiple widgets from the search instance. * @param widgets Array of widgets instances to remove from InstantSearch. * * The widgets must implement a `dispose()` method to clear their states. */ public removeWidgets(widgets: Array) { if (!Array.isArray(widgets)) { throw new Error( withUsage( 'The `removeWidgets` method expects an array of widgets. Please use `removeWidget`.' ) ); } 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; } /** * Ends the initialization of InstantSearch.js and triggers the * first search. This method should be called after all widgets have been added * to the instance of InstantSearch.js. InstantSearch.js also supports adding and removing * widgets after the start as an **EXPERIMENTAL** feature. */ public start() { if (this.started) { throw new Error( withUsage('The `start` method has already been called once.') ); } // This Helper is used for the queries, we don't care about its state. The // states are managed at the `index` level. We use this Helper to create // DerivedHelper scoped into the `index` widgets. // In Vue InstantSearch' hydrate, a main helper gets set before start, so // we need to respect this helper as a way to keep all listeners correct. const mainHelper = this.mainHelper || algoliasearchHelper(this.client, this.indexName); mainHelper.search = () => { this.status = 'loading'; // @MAJOR: use scheduleRender here // For now, widgets don't expect to be rendered at the start of `loading`, // so it would be a breaking change to add an extra render. We don't have // these guarantees about the render event, thus emitting it once more // isn't a breaking change. this.emit('render'); // This solution allows us to keep the exact same API for the users but // under the hood, we have a different implementation. It should be // completely transparent for the rest of the codebase. Only this module // is impacted. return mainHelper.searchOnlyWithDerivedHelpers(); }; if (this._searchFunction) { // this client isn't used to actually search, but required for the helper // to not throw errors const fakeClient = { search: () => new Promise(noop), } as any as SearchClient; this._mainHelperSearch = mainHelper.search.bind(mainHelper); mainHelper.search = () => { const mainIndexHelper = this.mainIndex.getHelper(); const searchFunctionHelper = algoliasearchHelper( fakeClient, mainIndexHelper!.state.index, mainIndexHelper!.state ); searchFunctionHelper.once('search', ({ state }) => { mainIndexHelper!.overrideStateWithoutTriggeringChangeEvent(state); this._mainHelperSearch!(); }); // Forward state changes from `searchFunctionHelper` to `mainIndexHelper` searchFunctionHelper.on('change', ({ state }) => { mainIndexHelper!.setState(state); }); this._searchFunction!(searchFunctionHelper); return mainHelper; }; } // Only the "main" Helper emits the `error` event vs the one for `search` // and `results` that are also emitted on the derived one. mainHelper.on('error', ({ error }) => { if (!(error instanceof Error)) { // typescript lies here, error is in some cases { name: string, message: string } const err = error as Record; error = Object.keys(err).reduce((acc, key) => { (acc as any)[key] = err[key]; return acc; }, new Error(err.message)); } // If an error is emitted, it is re-thrown by events. In previous versions // we emitted {error}, which is thrown as: // "Uncaught, unspecified \"error\" event. ([object Object])" // To avoid breaking changes, we make the error available in both // `error` and `error.error` // @MAJOR emit only error (error as any).error = error; this.error = error; this.status = 'error'; this.scheduleRender(false); // This needs to execute last because it throws the error. this.emit('error', error); }); this.mainHelper = mainHelper; this.middleware.forEach(({ instance }) => { instance.subscribe(); }); this.mainIndex.init({ instantSearchInstance: this as unknown as InstantSearch, parent: null, uiState: this._initialUiState, }); if (this._initialResults) { const originalScheduleSearch = this.scheduleSearch; // We don't schedule a first search when initial results are provided // because we already have the results to render. This skips the initial // network request on the browser on `start`. this.scheduleSearch = defer(noop); // We also skip the initial network request when widgets are dynamically // added in the first tick (that's the case in all the framework-based flavors). // When we add a widget to `index`, it calls `scheduleSearch`. We can rely // on our `defer` util to restore the original `scheduleSearch` value once // widgets are added to hook back to the regular lifecycle. defer(() => { this.scheduleSearch = originalScheduleSearch; })(); } // We only schedule a search when widgets have been added before `start()` // because there are listeners that can use these results. // This is especially useful in framework-based flavors that wait for // dynamically-added widgets to trigger a network request. It avoids // having to batch this initial network request with the one coming from // `addWidgets()`. // Later, we could also skip `index()` widgets and widgets that don't read // the results, but this is an optimization that has a very low impact for now. else if (this.mainIndex.getWidgets().length > 0) { this.scheduleSearch(); } // Keep the previous reference for legacy purpose, some pattern use // the direct Helper access `search.helper` (e.g multi-index). this.helper = this.mainIndex.getHelper(); // track we started the search if we add more widgets, // to init them directly after add this.started = true; this.middleware.forEach(({ instance }) => { instance.started(); }); } /** * Removes all widgets without triggering a search afterwards. This is an **EXPERIMENTAL** feature, * if you find an issue with it, please * [open an issue](https://github.com/algolia/instantsearch.js/issues/new?title=Problem%20with%20dispose). * @return {undefined} This method does not return anything */ public dispose(): void { this.scheduleSearch.cancel(); this.scheduleRender.cancel(); clearTimeout(this._searchStalledTimer); this.removeWidgets(this.mainIndex.getWidgets()); this.mainIndex.dispose(); // You can not start an instance two times, therefore a disposed instance // needs to set started as false otherwise this can not be restarted at a // later point. this.started = false; // The helper needs to be reset to perform the next search from a fresh state. // If not reset, it would use the state stored before calling `dispose()`. this.removeAllListeners(); this.mainHelper!.removeAllListeners(); this.mainHelper = null; this.helper = null; this.middleware.forEach(({ instance }) => { instance.unsubscribe(); }); } public scheduleSearch = defer(() => { if (this.started) { this.mainHelper!.search(); } }); public scheduleRender = defer((shouldResetStatus: boolean = true) => { if (!this.mainHelper!.hasPendingRequests()) { clearTimeout(this._searchStalledTimer); this._searchStalledTimer = null; if (shouldResetStatus) { this.status = 'idle'; this.error = undefined; } } this.mainIndex.render({ instantSearchInstance: this as unknown as InstantSearch, }); this.emit('render'); }); public scheduleStalledRender() { if (!this._searchStalledTimer) { this._searchStalledTimer = setTimeout(() => { this.status = 'stalled'; this.scheduleRender(); }, this._stalledSearchDelay); } } /** * Set the UI state and trigger a search. * @param uiState The next UI state or a function computing it from the current state * @param callOnStateChange private parameter used to know if the method is called from a state change */ public setUiState( uiState: TUiState | ((previousUiState: TUiState) => TUiState), callOnStateChange: boolean = true ): void { if (!this.mainHelper) { throw new Error( withUsage('The `start` method needs to be called before `setUiState`.') ); } // We refresh the index UI state to update the local UI state that the // main index passes to the function form of `setUiState`. this.mainIndex.refreshUiState(); const nextUiState = typeof uiState === 'function' ? uiState(this.mainIndex.getWidgetUiState({}) as TUiState) : uiState; if (this.onStateChange && callOnStateChange) { this.onStateChange({ uiState: nextUiState, setUiState: (finalUiState) => { setIndexHelperState( typeof finalUiState === 'function' ? finalUiState(nextUiState) : finalUiState, this.mainIndex ); this.scheduleSearch(); this.onInternalStateChange(); }, }); } else { setIndexHelperState(nextUiState, this.mainIndex); this.scheduleSearch(); this.onInternalStateChange(); } } public getUiState(): TUiState { if (this.started) { // We refresh the index UI state to make sure changes from `refine` are taken in account this.mainIndex.refreshUiState(); } return this.mainIndex.getWidgetUiState({}) as TUiState; } public onInternalStateChange = defer(() => { const nextUiState = this.mainIndex.getWidgetUiState({}); this.middleware.forEach(({ instance }) => { instance.onStateChange({ uiState: nextUiState, }); }); }); public createURL(nextState: TUiState = {} as TUiState): string { if (!this.started) { throw new Error( withUsage('The `start` method needs to be called before `createURL`.') ); } return this._createURL(nextState); } public refresh() { if (!this.mainHelper) { throw new Error( withUsage('The `start` method needs to be called before `refresh`.') ); } this.mainHelper.clearCache().search(); } } export default InstantSearch;