Support multiple Typesense search blocks on the same page

Created on 22 September 2025, 11 days ago

Problem/Motivation

The current Typesense search block implementation only supports one search block per page because all block instances share the same drupalSettings['search_api_typesense'] configuration. When multiple blocks are placed on the same page, the settings from the last rendered block overwrite the previous ones, causing conflicts and preventing proper initialization of multiple search instances.

This limitation prevents users from creating different search experiences on the same page, such as having separate search blocks for different content types or collections with different configurations.

Steps to reproduce

1. Create two or more Typesense search blocks with different configurations (different collections, facets, or settings)
2. Place both blocks on the same page
3. Observe that only the last rendered block works correctly
4. Check browser developer tools to see that drupalSettings.search_api_typesense contains only the configuration from the last block

Proposed resolution

Implement a namespaced configuration system that allows multiple search blocks to coexist on the same page:

1. Update TypesenseSearchBlock::build() method:

php
public function build(): array {
  // ... existing code ...

  $block_id = $this->getDerivativeId() ?: $this->getPluginId() . '_' . uniqid();

  return [
    'content' => [
      '#theme' => 'search_api_typesense_search',
      '#block_id' => $block_id,
      '#collection_render_parameters' => $collection_render_parameters,
      '#facets' => $facets,
      '#attached' => [
        'drupalSettings' => [
          'search_api_typesense' => [
            'blocks' => [
              $block_id => [
                'server' => [
                  'apiKey' => $this->configuration['search_only_key'] ?? '',
                  'nodes' => $configuration['nodes'],
                ],
                'collection_specific_search_parameters' => $collection_specific_search_parameters,
                'collection_render_parameters' => $collection_render_parameters,
                'current_langcode' => $this->getCurrentLanguage()->getId(),
                'hits_per_page' => $this->configuration['hits_per_page'],
                'facets' => $facets,
              ],
            ],
          ],
        ],
      ],
    ],
  ];
}

2. Update search.js to handle multiple blocks:

javascript
((Drupal, TypesenseInstantSearchAdapter, instantsearch) => {
  Drupal.behaviors.search = {
    attach(context, settings) {
      if (!settings.search_api_typesense?.blocks) {
        return;
      }

      Object.entries(settings.search_api_typesense.blocks).forEach(([blockId, blockSettings]) => {
        const searchboxSelector = `#searchbox-${blockId}`;
        const [searchbox] = once('searchbox', searchboxSelector, context);

        if (searchbox === undefined) {
          return;
        }

        this.initializeSearch(blockId, blockSettings);
      });
    },

    initializeSearch(blockId, blockSettings) {
      const typesenseInstantsearchAdapter = new TypesenseInstantSearchAdapter({
        server: blockSettings.server,
        additionalSearchParameters: {
          exclude_fields: 'embedding',
          exhaustive_search: true,
        },
        collectionSpecificSearchParameters: blockSettings.collection_specific_search_parameters,
      });

      const searchClient = typesenseInstantsearchAdapter.searchClient;
      const collections = blockSettings.collection_render_parameters;
      const firstCollection = collections[0];

      const search = instantsearch({
        searchClient,
        indexName: firstCollection.collection_name,
      });

      const getCollectionWidgets = (collection) => [
        instantsearch.widgets.stats({
          container: `#stats-${collection.collection_name}-${blockId}`,
        }),
        instantsearch.widgets.infiniteHits({
          container: `#hits-${collection.collection_name}-${blockId}`,
          cssClasses: {
            list: 'search-results',
            loadMore: 'btn btn--load-more',
          },
          templates: {
            item(hit, { html, components }) {
              if (blockSettings.debug) {
                return html`
                  <article class="search-result-debug">
                    <ul>
                      ${Object.keys(hit).map(
                        (key) =>
                          html`<li>
                            <strong>${key}:</strong> ${JSON.stringify(
                              hit[key],
                              null,
                              2,
                            )}
                          </li>`,
                      )}
                    </ul>
                  </article>
                `;
              }
              if (hit.rendered_item) {
                return html`${hit.rendered_item}`;
              }
              return html`
                <article>
                  <h3>${components.Highlight({ hit, attribute: 'title' })}</h3>
                  <small> (${hit.id})</small>
                </article>
              `;
            },
          },
        }),
      ];

      search.addWidgets([
        instantsearch.widgets.configure({
          hitsPerPage: blockSettings.hits_per_page,
          ...(blockSettings.current_langcode && {
            facetFilters: [`langcode:${blockSettings.current_langcode}`],
          }),
        }),
        instantsearch.widgets.searchBox({
          container: `#searchbox-${blockId}`,
          placeholder: 'Search...',
        }),
        ...getCollectionWidgets(firstCollection),
      ]);

      if (collections.length > 1) {
        collections.slice(1).forEach((collection) => {
          search.addWidgets([
            instantsearch.widgets
              .index({ indexName: collection.collection_name })
              .addWidgets(getCollectionWidgets(collection)),
          ]);
        });
      }

      const facetWidgets = {
        string: (facet) =>
          instantsearch.widgets.refinementList({
            container: `#facet-${facet.name}-${blockId}`,
            attribute: facet.name,
            searchable: true,
          }),
        number: (facet) =>
          instantsearch.widgets.rangeSlider({
            container: `#facet-${facet.name}-${blockId}`,
            attribute: facet.name,
          }),
        bool: (facet) =>
          instantsearch.widgets.toggleRefinement({
            container: `#facet-${facet.name}-${blockId}`,
            attribute: facet.name,
            templates: {
              labelText: facet.label,
            },
          }),
      };

      blockSettings.facets.forEach((facet) => {
        if (facetWidgets[facet.type]) {
          search.addWidgets([facetWidgets[facet.type](facet)]);
        }
      });

      search.start();
    },
  };
})(Drupal, TypesenseInstantSearchAdapter, instantsearch);

3. Update Twig template to include block-specific IDs:

twig
<div id="searchbox-{{ block_id }}"></div>
{% for collection in collection_render_parameters %}
  <div id="stats-{{ collection.collection_name }}-{{ block_id }}"></div>
  <div id="hits-{{ collection.collection_name }}-{{ block_id }}"></div>
{% endfor %}
{% for facet in facets %}
  <div id="facet-{{ facet.name }}-{{ block_id }}"></div>
{% endfor %}

Remaining tasks

- [ ] Update TypesenseSearchBlock::build() method to generate unique block IDs and namespace settings
- [ ] Modify search.js to handle multiple block configurations and initialize separate search instances
- [ ] Update Twig template to include block ID in HTML element attributes
- [ ] Update facet widget initialization to use block-specific container selectors
- [ ] Add automated tests for multiple blocks on the same page
- [ ] Update documentation to reflect multi-block support
- [ ] Test federated search across different block instances

User interface changes

- HTML elements will include block-specific IDs (e.g., #searchbox-{block_id}, #facet-{facet_name}-{block_id})
- No visible changes to the user interface - multiple search blocks will function independently
- Each block maintains its own search state and results display

API changes

- drupalSettings.search_api_typesense structure will change from a flat configuration object to a nested structure with a blocks property
- JavaScript Drupal.behaviors.search will be updated to handle multiple block configurations
- Template variables will include a new block_id parameter

Data model changes

No database or entity model changes are required. The changes are limited to:
- Block configuration structure in drupalSettings
- HTML template structure to include block IDs
- JavaScript initialization logic

Feature request
Status

Active

Version

1.1

Component

Code

Created by

🇮🇹Italy robertoperuzzo 🇮🇹 Tezze sul Brenta, VI

Live updates comments and jobs are added and updated live.
Sign in to follow issues

Merge Requests

Comments & Activities

Not all content is available!

It's likely this issue predates Contrib.social: some issue and comment data are missing.

Production build 0.71.5 2024