# @salesforce/ui-bundle-template-feature-react-search

![Status: Beta](https://img.shields.io/badge/status-beta-orange)

Configuration-driven, multi-source search for **React** applications on the
Salesforce platform.

> 🧪 **Beta.** This feature is in beta. It is usable today, but the API
> (component props, config schema, and exported primitives) may change in a
> future release, and breaking changes can land in a minor version. Pin the
> package version and review the [CHANGELOG](./CHANGELOG.md) before upgrading.

> ⚛️ **React only.** This package ships React components and hooks built on
> `react` 19 and `react-router` 7. Use it in a React application; it is not
> compatible with LWC or other non-React UIs — see [Requirements](#requirements).

You describe **what** is searchable in a single config object; the package owns
**how** it's searched — GraphQL query construction, filter/sort/pagination
state, URL sync, default rendering, and the orchestration UI. Most apps drop in
one component and get a complete search page with zero custom code:

```tsx
import { Search, config } from "@salesforce/ui-bundle-template-feature-react-search";

export default function SearchPage() {
  return <Search config={config} />;
}
```

Adding a new searchable sObject or CMS content channel is a single entry in
your config — no code changes.

---

## Table of contents

- [Requirements](#requirements)
- [Installation](#installation)
- [Quick start](#quick-start)
- [The `<Search>` component](#the-search-component)
  - [Props](#props)
- [Configuration](#configuration)
  - [Global pagination](#global-pagination-searchconfigpagination)
  - [sObject source config](#sobject-source-config)
    - [`displayFields` shapes](#displayfields-shapes)
    - [`filterBy` types](#filterby-types)
    - [`sortBy` and `defaultSort`](#sortby-and-defaultsort)
  - [CMS source config](#cms-source-config)
  - [`routePattern` & detail-page linking](#routepattern--detail-page-linking)
  - [A complete annotated sObject source](#a-complete-annotated-sobject-source)
  - [A complete annotated CMS source](#a-complete-annotated-cms-source)
- [Use cases](#use-cases)
  - [1. Unified search across many objects](#1-unified-search-across-many-objects)
  - [2. Single-object search (`restrictTo`)](#2-single-object-search-restrictto)
  - [3. Hand off a term from another page](#3-hand-off-a-term-from-another-page)
  - [4. Custom row layout for one source](#4-custom-row-layout-for-one-source)
  - [5. Hide a source from the results](#5-hide-a-source-from-the-results)
  - [6. Replace a source's filter sidebar](#6-replace-a-sources-filter-sidebar)
  - [7. Headless — drive your own UI with `useSearch`](#7-headless--drive-your-own-ui-with-usesearch)
  - [8. Mixed card layouts per source](#8-mixed-card-layouts-per-source)
  - [9. Global pagination across all sources](#9-global-pagination-across-all-sources)
  - [10. Build a custom unified-results UI (grid + pager + filters + count)](#10-build-a-custom-unified-results-ui-grid--pager--filters--count)
  - [11. Choosing a merge order (sequential vs proportional)](#11-choosing-a-merge-order-sequential-vs-proportional)
- [URL and state conventions](#url-and-state-conventions)
- [Advanced: CMS search](#advanced-cms-search)
  - [Enabling CMS search (API v68 gate)](#enabling-cms-search-api-v68-gate)
  - [UIBundle id resolution and the per-fetch gate](#uibundle-id-resolution-and-the-per-fetch-gate)
  - [`uibundleIds` vs `channelIds`](#uibundleids-vs-channelids)
  - [Content-type discovery](#content-type-discovery)
  - [Seeding content](#seeding-content)
  - [Mounting `GlobalSearchBox` + `<Search>` at `/search`](#mounting-globalsearchbox--search-at-search)
- [Internal structure](#internal-structure)
- [Public API](#public-api)
- [Troubleshooting & common mistakes](#troubleshooting--common-mistakes)

---

## Requirements

This is a **React-only** package. Your application must provide:

- **React 19** and **react-router 7** — the components render React elements and
  use react-router for navigation, URL sync, and result links.
- **`@salesforce/platform-sdk`** — sObject sources are fetched via
  `createDataSDK().graphql.query()` against the Salesforce uiapi GraphQL bridge;
  CMS sources are fetched through the Managed Content search API (see
  [Advanced: CMS search](#advanced-cms-search)).
- **lucide-react** — used for the control icons.

Your app must be wrapped in a react-router router (e.g. a `<BrowserRouter>` or a
data router), since the search components read and write the URL.

---

## Installation

```bash
npm install @salesforce/ui-bundle-template-feature-react-search
```

Then import what you need from the package entry point:

```tsx
import { Search, config } from "@salesforce/ui-bundle-template-feature-react-search";
```

---

## Quick start

The package ships an example `config` you can import to try things out
immediately. Render the drop-in `<Search>` against it:

```tsx
import { Search, config } from "@salesforce/ui-bundle-template-feature-react-search";

export default function SearchPage() {
  return <Search config={config} title="Search" searchPlaceholder="Search across Salesforce…" />;
}
```

This renders: a search bar, a live total-result count, a scope dropdown (when
the config has 2+ sources), and one results section per source — each with
auto-rendered rows, a filter sidebar, a sort dropdown, per-source pagination,
and a per-source Reset button inside its filter panel. (Reset is per-source;
there is no global reset button.) Set `pagination.mode: "merged"` and the same
component instead renders one combined grid with a single shared pager
([§9](#9-global-pagination-across-all-sources)). Everything is driven by the
config you pass in.

For a real application you'll typically pass [your own config](#configuration).

---

## The `<Search>` component

`<Search>` is the top of a layered API. Use it as-is, override individual
slots, or drop down to the `useSearch` hook for full control:

```
<Search config />                         ← drop-in (most apps)
   │
   ├── <SearchBar /> + <SearchResults />  ← compose primitives + own header
   │      └── <SourceSection /> per source
   │             ├── <DefaultResultRow />     ← override per source
   │             └── <DefaultFilterPanel />   ← override per source
   │
   └── useSearch(config)                  ← headless: build your own UI
```

### Props

| Prop                | Type                                             | Default                        | Purpose                                                                                     |
| ------------------- | ------------------------------------------------ | ------------------------------ | ------------------------------------------------------------------------------------------- |
| `config`            | `SearchConfig`                                   | — (required)                   | The source list. Pass your own, or the example `config` export.                             |
| `title`             | `string`                                         | `"Search"`                     | Heading shown by the default header.                                                        |
| `subtitle`          | `string`                                         | —                              | Sub-heading shown by the default header.                                                    |
| `searchPlaceholder` | `string`                                         | `"Search…"`                    | Placeholder for the search input.                                                           |
| `restrictTo`        | `{ kind: "sobject"; key: string }`               | —                              | Lock the page to one source. Hides the scope dropdown and only fetches/renders that source. |
| `showScopeSelector` | `boolean`                                        | `true` when 2+ sources         | Force-show or hide the scope dropdown. Always hidden when `restrictTo` is set.              |
| `allScopeLabel`     | `string`                                         | `"All"`                        | Label for the "search everything" entry in the scope dropdown.                              |
| `renderResult`      | `Record<string, ((node) => ReactNode) \| false>` | —                              | Per source key: custom row renderer, or `false` to hide that source.                        |
| `renderFilters`     | `Record<string, (() => ReactNode) \| false>`     | —                              | Per source key: custom filter sidebar, or `false` to suppress filters for that source.      |
| `emptyMessages`     | `Record<string, string>`                         | —                              | Per source key: message shown when that source has no results.                              |
| `renderHeader`      | `(handle: SearchHandle) => ReactNode`            | built-in title/subtitle header | Replace the entire header region.                                                           |
| `className`         | `string`                                         | —                              | Extra classes on the outer container.                                                       |

`renderResult` / `renderFilters` / `emptyMessages` are keyed by the source
`key` from your config (e.g. `accounts`, `contacts`, `content`).

> Mounting `<Search>` behind a launcher input (e.g. a home-page search box) has
> one routing caveat of its own — see
> [Mounting `GlobalSearchBox` + `<Search>` at `/search`](#mounting-globalsearchbox--search-at-search).

---

## Configuration

A `SearchConfig` is `{ sources: SourceConfig[] }` plus an optional global
`pagination` block, where each source is either an `SObjectSourceConfig`
([sObject source config](#sobject-source-config)) or a `CmsSourceConfig`
([CMS source config](#cms-source-config)). Define it in your own code and pass
it to `<Search config={...} />`:

```tsx
import { Search } from "@salesforce/ui-bundle-template-feature-react-search";
import type { SearchConfig } from "@salesforce/ui-bundle-template-feature-react-search";

const config: SearchConfig = {
  // One global pagination block — the single source of truth for the whole
  // search experience. `mode` / `mergeOrder` / `pageSize` / `pageSizeOptions`
  // apply to every object type and in every scope, including when the dropdown
  // narrows to one source and on single-object pages (restrictTo / lockedScope).
  // Omit to default to per-source mode, page size 10, options [10, 25, 50].
  pagination: {
    mode: "merged", // "per-source" (default) | "merged"
    mergeOrder: "proportional", // "sequential" (default) | "interleaved" | "proportional"
    pageSize: 12,
    pageSizeOptions: [12, 24, 48],
  },
  sources: [
    {
      kind: "sobject",
      key: "accounts",
      objectName: "Account",
      label: "Accounts",
      routePattern: "/accounts/:id",
      searchableFields: ["Name", "Phone", "Industry"],
      displayFields: ["Name", "Industry", "Phone"],
    },
  ],
};

export default function SearchPage() {
  return <Search config={config} />;
}
```

**One pagination config for everything.** There is no per-source pagination
_config_ — `config.pagination` is the single source of truth. The same
`pageSize` / `pageSizeOptions` apply across every object type and in every
scope — the unified `/search` page, when the scope dropdown narrows to one
source, and single-object pages locked with `restrictTo` / `lockedScope`.
How that config is _displayed_ depends on `mode`: the default `"per-source"`
paginates each object's section on its own cursor, while `"merged"` collapses
in-scope sources into one combined grid whose page order is set by `mergeOrder`
(see [§9](#9-global-pagination-across-all-sources)).

You can also keep the config as a JSON file and import it — cast it to
`SearchConfig` since the structure is not validated at runtime (see the note at
the end of [A complete annotated sObject source](#a-complete-annotated-sobject-source)).

Sources come in two kinds: `"sobject"` (queried through the platform-sdk
`uiapi` GraphQL bridge) and `"cms"` (queried through the Managed Content
search API). See [sObject source config](#sobject-source-config) and
[CMS source config](#cms-source-config) below, and
[Advanced: CMS search](#advanced-cms-search) for how CMS sources are wired
end to end.

### Global pagination (`SearchConfig.pagination`)

Optional. One block governs pagination for the whole search experience —
sources carry no pagination config of their own. Omit it to default to
per-source mode, page size `10`, options `[10, 25, 50]`. (Note: `mode`
`"per-source"` still paginates each object's section independently — see the
`mode` row below and [§9](#9-global-pagination-across-all-sources).)

| Field             | Required | Type                                                                         | Description                                                                                                                                                                       |
| ----------------- | -------- | ---------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `pageSize`        | ✅       | `number`                                                                     | Items per page (across all sources in `"merged"` mode; per source otherwise).                                                                                                     |
| `pageSizeOptions` | ✅       | `number[]`                                                                   | Page sizes offered in the "results per page" control.                                                                                                                             |
| `mode`            |          | `"per-source"` \| `"merged"` (default `per-source`)                          | `"merged"` windows the cumulative result set into fixed pages of `pageSize`; `"per-source"` advances each source's own cursor. See [§9](#9-global-pagination-across-all-sources). |
| `mergeOrder`      |          | `"sequential"` \| `"interleaved"` \| `"proportional"` (default `sequential`) | In `"merged"` mode, how nodes from different sources are ordered. Ignored in `"per-source"` mode. See [§9](#9-global-pagination-across-all-sources).                              |

### sObject source config

sObject sources work out of the box — no runtime id resolution and no gating.
They query the org's `uiapi` GraphQL bridge through the sobject adapter
(`adapters/sobject`).

| Field              | Required | Type                      | Description                                                                                                                                                                                                                          |
| ------------------ | -------- | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `kind`             | ✅       | `"sobject"`               | Discriminator selecting the runtime adapter.                                                                                                                                                                                         |
| `key`              | ✅       | `string`                  | Stable id — used as the GraphQL alias, URL namespace, and result-map key. Must match `/^[A-Za-z_][A-Za-z0-9_]*$/`.                                                                                                                   |
| `objectName`       | ✅       | `string`                  | GraphQL object name (`"Account"`, `"Contact"`, …).                                                                                                                                                                                   |
| `label`            | ✅       | `string`                  | Plural display label — section header, scope dropdown, empty-state messages (`"Accounts"`).                                                                                                                                          |
| `labelSingular`    |          | `string`                  | Singular label for one record (`"Account"`) — used for the merged-grid source badge. Falls back to `label`.                                                                                                                          |
| `searchableFields` | ✅       | `string[]`                | Fields the global `q` term matches. OR-ed as `like %q%`. Supports dot-paths (`"Owner.Name"`).                                                                                                                                        |
| `displayFields`    | ✅       | `DisplayField[]`          | Fields selected in the GraphQL query and used by the default row layout. (See below.)                                                                                                                                                |
| `routePattern`     |          | `string`                  | Makes the default row a `<Link>`. Tokens like `:id` / `:Name` are substituted per record. Your app must register a matching route to render the target ([details](#routepattern--detail-page-linking)). Omit for non-clickable rows. |
| `idField`          |          | `string` (default `"Id"`) | Unique-id field. Drives the `:id` route token. Always selected automatically — **do not** list it in `displayFields`.                                                                                                                |
| `filterBy`         |          | `FilterFieldConfig[]`     | Structured filters for the source. Today each renders an input in the per-source sidebar.                                                                                                                                            |
| `sortBy`           |          | `SortFieldConfig[]`       | Options in the per-source sort dropdown.                                                                                                                                                                                             |
| `defaultSort`      |          | `{ field, direction }`    | Initial sort applied when the URL has none. `direction` is `"ASC"` or `"DESC"`.                                                                                                                                                      |
| `whereTypeName`    |          | `string`                  | GraphQL `where` variable type. Defaults to `${objectName}_Filter`. Override only for non-conventional schemas.                                                                                                                       |
| `orderByTypeName`  |          | `string`                  | GraphQL `orderBy` variable type. Defaults to `${objectName}_OrderBy`.                                                                                                                                                                |

#### `displayFields` shapes

Each entry controls both the GraphQL selection set **and** the default row
layout (first entry → row title; the rest → subtitle, joined with `·`):

| Form                                     | GraphQL emitted                            | Use for                                                           |
| ---------------------------------------- | ------------------------------------------ | ----------------------------------------------------------------- |
| `"Name"` (string)                        | `Name @optional { value displayValue }`    | Ordinary scalar fields.                                           |
| `{ name: "Owner", subfields: ["Name"] }` | `Owner @optional { Name @optional { … } }` | Relationship traversal (parent/lookup).                           |
| `{ name: "SomeId", raw: true }`          | `SomeId`                                   | Fields not wrapped in `{ value displayValue }` (Id-like scalars). |

> The `idField` (default `"Id"`) is always emitted — never list it explicitly.

#### `filterBy` types

Each `FilterFieldConfig` is `{ field, label, type, options?, placeholder?, dateMode?, helpText?, min?, max? }`.
The `type` drives which input renders and how the `where` clause is built. `min`/`max` (numeric-only) declare an inclusive bound, enforced in both the input widget and the `where` clause.

| `type`          | UI                                 | `where` clause                               |
| --------------- | ---------------------------------- | -------------------------------------------- |
| `text`          | Text input                         | `{ field: { like: "%value%" } }`             |
| `picklist`      | Single-select dropdown             | `{ field: { eq: value } }`                   |
| `multipicklist` | Multi-select dropdown (checkboxes) | `{ field: { in: [...] } }` (or `eq` for one) |
| `numeric`       | min / max number inputs            | `{ field: { gte, lte } }`                    |
| `boolean`       | Tri-state (any/true/false)         | `{ field: { eq: true \| false } }`           |
| `date`          | See `dateMode`                     | `{ field: { gte \| lte: { value } } }`       |
| `daterange`     | See `dateMode`                     | `and` of `gte` / `lte`                       |
| `datetime`      | See `dateMode`                     | start/end-of-day ISO bounds                  |
| `datetimerange` | See `dateMode`                     | `and` of start/end-of-day ISO bounds         |

For `picklist` / `multipicklist`, options are **auto-fetched** from the GraphQL
aggregate API (`groupBy`) on first render. Skip the fetch by supplying inline
`options: [{ value, label }]` — required for fields that aren't group-by-able
(formulas, long text, etc.).

For the date-family types, `dateMode` selects the comparison chrome (defaults to
`"comparison"`):

| `dateMode`     | UI                                                                  |
| -------------- | ------------------------------------------------------------------- |
| `"comparison"` | An **After** / **Before** selector with one date input (default).   |
| `"range"`      | A single **Between** control — two date inputs (lower–upper bound). |
| `"both"`       | A **Between** / **After** / **Before** selector spanning the two.   |

#### `sortBy` and `defaultSort`

`sortBy` is a list of `{ field, label }`. Each becomes an option in the
per-source sort dropdown (with an ASC/DESC toggle). `defaultSort` sets the
initial order when no sort is present in the URL:

```jsonc
"sortBy": [
	{ "field": "CloseDate", "label": "Close Date" },
	{ "field": "Amount", "label": "Amount" }
],
"defaultSort": { "field": "CloseDate", "direction": "DESC" }
```

Omit `sortBy` entirely to hide the sort dropdown for that source.

**Query construction.** `buildSearchQuery` (`api/buildSearchQuery.ts`)
assembles the GraphQL from the source config, `buildOrderBy` builds the sort
clause, and picklist filter values come from `useDistinctValues` /
`fetchDistinctValues` (see [Public API](#public-api)). The whole path runs
through the platform data SDK — no enablement step beyond declaring the
source.

> **FLS caveat.** A field the running user can't read is silently dropped by
> the platform, so an sObject source that "returns nothing" usually means the
> app's permission set is missing field access, not a config error.

### CMS source config

A CMS source (`kind: "cms"`) is deliberately minimal — there's no `channelId`,
no content types, and no `displayFields` in config; all of that is resolved at
runtime (see [Advanced: CMS search](#advanced-cms-search)). It's routed via
`adapters/registry.ts` to `cmsAdapter` (`adapters/cms/index.ts`).

| Field           | Required | Type     | Description                                                                                                                                                                                                                                      |
| --------------- | -------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `kind`          | ✅       | `"cms"`  | Discriminator selecting the CMS adapter.                                                                                                                                                                                                         |
| `key`           | ✅       | `string` | Stable id — GraphQL alias, URL namespace, result-map key. Must match `/^[A-Za-z_][A-Za-z0-9_]*$/`, same as sObject sources.                                                                                                                      |
| `label`         | ✅       | `string` | Plural display label — section header, scope dropdown, empty-state messages.                                                                                                                                                                     |
| `labelSingular` |          | `string` | Singular label for one item — used for the merged-grid source badge. Falls back to `label`.                                                                                                                                                      |
| `routePattern`  |          | `string` | Makes the default row a `<Link>`. Tokens are `:id` (the `managedContentId`), `:key`, and `:contentType`. Falls back to `/content/:contentType/:id` when omitted. See [`routePattern` & detail-page linking](#routepattern--detail-page-linking). |

**CMS & misc knobs**

| Knob               | Values / notes                                                                                                                                                                                               |
| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Per-cms-source     | `key`, `label`, `labelSingular?`, `routePattern?` — nothing else, by design.                                                                                                                                 |
| `MIN_QUERY_LENGTH` | `constants.ts`, currently `3`. Both backends reject shorter terms server-side; gated client-side in `useSearch.ts` and `GlobalSearchBox.tsx` so the UI never shows a spurious failure for a too-short query. |

### `routePattern` & detail-page linking

**By default, result cards are NOT clickable** — the feature renders each row as
plain (non-linked) text (see the final `return <div>…` branch in
`DefaultResultRow.tsx` / `CmsResultRow.tsx`). Making a card clickable is a
two-part change, and **both parts are required** — one without the other either
does nothing or 404s:

1. **In this feature's config**: give the source a `routePattern`. This is
   the _only_ thing that turns the row into a `<Link>`. Without it the row stays
   non-clickable no matter what routes the app defines.
2. **In the app** (e.g. `src/routes.tsx`): the detail page must actually exist and be
   registered at a matching dynamic route (e.g.
   `<Route path="/accounts/:id" element={<AccountDetail />} />`, implementing an
   `AccountDetail` page/component that reads the `:id` param and renders that
   object's details). The feature only builds the href (e.g. `/accounts/001…`);
   it does not own any route. If that route isn't registered, the (now-clickable)
   card navigates to a dead URL and 404s.

So: `routePattern` present **and** a matching detail route/page in the app →
clickable card that opens a detail page. `routePattern` absent → non-clickable
card (the default). `routePattern` present but no app route → clickable card
that 404s. Point `routePattern` at routes you already have (or add them
alongside), and give every searchable source its own detail route. If you don't
want clickable rows — for example when details open in a modal instead of on a
route — omit `routePattern` entirely (this is what the `propertymanagementapp`
template does: it has no `routePattern` and opens a detail modal from a custom
`renderResult` instead).

- **How the link is built:** `routePattern` is a path template with `:token`
  placeholders resolved per result.
  - **sObject rows** (`components/results/DefaultResultRow.tsx`,
    `resolveRoute(routePattern, node, idField)`): `:fieldName` tokens are
    substituted from the record's field values, and a bare `:id` maps to the
    source's `idField` (default `Id`). Each token is a **single field name** —
    unlike `searchableFields`, `routePattern` tokens do **not** support
    dot-paths (`:Owner.Name` resolves only `:Owner` and leaves the row
    non-clickable via the runtime-null fallback below). For a nested value,
    expose it as a flat field or use `renderResult`. The shipped `accounts`
    source uses `"/accounts/:id"`.
  - **CMS rows** (`components/results/CmsResultRow.tsx`): tokens are `:id`
    (the `managedContentId`), `:key`, and `:contentType`; when no
    `routePattern` is set it falls back to `/content/:contentType/:id`.
  - **Runtime-null fallback:** if **any** token resolves to `null` for a
    record, that row falls back to a plain, non-clickable layout rather than
    emitting a broken link. Make sure tokenized fields are present in
    `displayFields`.
- **Authoring the app-side route + page** (part 2 above): register the matching
  dynamic route in the app's `routes.tsx` (e.g. `path: "accounts/:id"` /
  `path: "content/:contentType/:id"`). That route's component reads the route
  param (`useParams`) and fetches the single record/content item — sObject
  detail: fetch the one record by id via the platform data SDK (see the
  `experience-ui-bundle-salesforce-data-access` skill — do not hand-roll
  `fetch`); CMS detail: fetch the content item by `managedContentId` through the
  CMS delivery API.
- **Generating the page:** whichever kind of detail page you need, nothing inside
  this search feature changes beyond setting `routePattern` — the page and route
  live in the app, not in this directory. Pick the skill by source type:
  - **sObject object-detail page** — a normal routed page; generate it with the
    `experience-ui-bundle-frontend-generate` skill (its page types include "detail
    view"; record data is fetched via the platform Data SDK per the
    `experience-ui-bundle-salesforce-data-access` skill).
  - **CMS content-detail page** — generate it with the
    `experience-cms-content-render` skill. That skill owns CMS content _fetch +
    render_ (the delivery API, `contentKey`/`contentType`, RichText, and image
    resolution) and **takes precedence** over `experience-ui-bundle-frontend-generate`
    for the rendering portion — the UI skill owns only the page/layout shell. Its
    Detail Page branch writes `src/pages/<type>/<PageName>.tsx` and inserts the
    route, so the app gets both the page and the matching route wired for you. (It
    is scoped to _rendering existing_ content — use this search feature, not that
    skill, for the search itself.)

### A complete annotated sObject source

```jsonc
{
  "kind": "sobject", // discriminator
  "key": "leads", // GraphQL alias + URL namespace + result key
  "objectName": "Lead", // GraphQL type
  "label": "Leads", // plural — section header + scope option
  "labelSingular": "Lead", // singular — merged-grid source badge (optional; falls back to label)
  "routePattern": "/leads/:id", // default row → <Link to="/leads/<id>">
  "idField": "Id", // default; drives :id (don't add to displayFields)
  "searchableFields": ["Name", "Company", "Email"], // global q is OR-ed across these
  "displayFields": [
    "Name", // row title
    "Title", // ┐
    "Company", // ├ subtitle (joined with " · ")
    "Email", // ┘
    { "name": "Owner", "subfields": ["Name"] }, // relationship traversal
  ],
  "filterBy": [
    { "field": "Status", "label": "Status", "type": "picklist" },
    { "field": "Industry", "label": "Industry", "type": "picklist" },
  ],
  "sortBy": [
    { "field": "Name", "label": "Name" },
    { "field": "CreatedDate", "label": "Created Date" },
  ],
  "defaultSort": { "field": "CreatedDate", "direction": "DESC" },
}
```

That single entry gives Leads a full search experience: a global-term match
across name/company/email, two auto-populated picklist filters, a sort
dropdown, pagination (governed by the top-level `pagination` block), and
clickable result rows linking to `/leads/<id>`.

> **No runtime schema validation.** A config object is trusted as-is; a typo
> surfaces at GraphQL query time, not when the config loads. Layer your own
> validation (e.g. a zod schema) if you need stricter guarantees.

### A complete annotated CMS source

```jsonc
{
  "kind": "cms", // discriminator selecting the CMS adapter
  "key": "content", // GraphQL alias + URL namespace + result key
  "label": "Content", // plural — section header + scope option
  "labelSingular": "Content Item", // singular — merged-grid source badge (optional; falls back to label)
  "routePattern": "/content/:contentType/:id", // optional; this is also the built-in fallback when omitted
}
```

That's the entire config surface for a CMS source — channel resolution,
content-type discovery, and query construction all happen at runtime (see
[Advanced: CMS search](#advanced-cms-search)). There is no `displayFields`,
`filterBy`, or `sortBy` for CMS sources today; rows render through
`CmsResultRow` and scope/content-type entries in the dropdown come from the
content types discovered at runtime, not from config.

---

## Use cases

### 1. Unified search across many objects

The default. Every source in the config is searched from one box; a scope
dropdown lets the user narrow to a single object. Best for a global
"search everything" page.

```tsx
<Search config={config} title="Search" />
```

### 2. Single-object search (`restrictTo`)

Reuse the same config but lock the page to one source. The scope dropdown is
hidden and only the matching source is fetched and rendered — ideal for a
dedicated "Account search" or "Browse Contacts" page where the object is
implied by the route.

```tsx
<Search
  config={config}
  title="Search Accounts"
  searchPlaceholder="Search by name, phone, or industry…"
  restrictTo={{ kind: "sobject", key: "accounts" }}
/>
```

### 3. Hand off a term from another page

The global term lives in the URL under `?q=` (the exported `GLOBAL_QUERY_KEY`).
Navigate to the search page with `?q=` pre-filled and the input + results
populate on arrival — handy for a simple search box on a Home page:

```tsx
import { useNavigate } from "react-router";

function HomeSearchBox() {
  const navigate = useNavigate();
  const onSubmit = (term: string) => {
    // `q` matches GLOBAL_QUERY_KEY, so the search page pre-fills on arrival.
    navigate(`/accounts${term ? `?q=${encodeURIComponent(term)}` : ""}`);
  };
  // …render an input that calls onSubmit…
}
```

> See also [Advanced: CMS search → Mounting `GlobalSearchBox` + `<Search>` at `/search`](#mounting-globalsearchbox--search-at-search)
> — the package ships a ready-made `GlobalSearchBox` launcher that does
> exactly this navigation for you, as long as you mount `<Search>` (or your own
> results UI) at the route it navigates to.

### 4. Custom row layout for one source

Override the default renderer for specific sources by key; others keep the
default. Return `ReactNode` from `(node) => …` — narrow `node` to your own
type. Use the exported `fieldValue()` to read `{ value, displayValue }` fields:

```tsx
import { Search, fieldValue } from "@salesforce/ui-bundle-template-feature-react-search";

<Search
  config={config}
  renderResult={{
    opportunities: (node) => {
      const o = node as OpportunityNode;
      return (
        <div className="flex justify-between">
          <span>{fieldValue(o.Name)}</span>
          <span className="text-muted-foreground">{fieldValue(o.StageName)}</span>
        </div>
      );
    },
  }}
/>;
```

### 5. Hide a source from the results

Pass `false` for that source's `renderResult`. The source skips both the
network request and its UI section:

```tsx
<Search config={config} renderResult={{ accounts: false }} />
```

### 6. Replace a source's filter sidebar

Supply your own filter UI built from the exported filter inputs (they wire into
the search state via `FilterContext`). Pass `false` to suppress filters
entirely for a source.

```tsx
import {
  Search,
  TextFilter,
  SelectFilter,
} from "@salesforce/ui-bundle-template-feature-react-search";

<Search
  config={config}
  renderFilters={{
    accounts: () => (
      <>
        <TextFilter field="Name" label="Account Name" />
        {/* SelectFilter / MultiSelectFilter take an explicit `options` list. */}
        <SelectFilter
          field="Industry"
          label="Industry"
          options={[
            { value: "Technology", label: "Technology" },
            { value: "Finance", label: "Finance" },
          ]}
        />
      </>
    ),
  }}
/>;
```

### 7. Headless — drive your own UI with `useSearch`

Skip the orchestration components entirely and build a bespoke layout. The hook
owns state, URL sync, and fetching; you render whatever you like from the
returned `SearchHandle`.

```tsx
import { useSearch } from "@salesforce/ui-bundle-template-feature-react-search";

function MySearch() {
  const { q, setQ, sources, loading, error, resetAll } = useSearch(config);
  // sources[key].result.nodes, .filters, .sort, .pagination …
  // build your own UI from these.
}
```

Lock the headless hook to a single source with
`useSearch(config, { lockedScope: "accounts" })`.

### 8. Mixed card layouts per source

`renderResult` is keyed by source, so each object type can render a completely
different card while the search bar, scope, and pagination stay shared. Sources
without an entry fall back to the default row. Here Accounts render as a stat
card and Contacts as an avatar row:

```tsx
import { Search, fieldValue } from "@salesforce/ui-bundle-template-feature-react-search";

<Search
  config={config}
  renderResult={{
    // Accounts → a bordered "stat" card.
    accounts: (node) => {
      const a = node as AccountNode;
      return (
        <div className="rounded-lg border p-4 flex items-center justify-between">
          <div>
            <p className="font-semibold">{fieldValue(a.Name)}</p>
            <p className="text-sm text-muted-foreground">{fieldValue(a.Industry)}</p>
          </div>
          <span className="text-sm tabular-nums">{fieldValue(a.AnnualRevenue) ?? "—"}</span>
        </div>
      );
    },
    // Contacts → an avatar + two-line card.
    contacts: (node) => {
      const c = node as ContactNode;
      const name = fieldValue(c.Name) ?? "—";
      return (
        <div className="flex items-center gap-3 py-1">
          <span className="flex size-9 items-center justify-center rounded-full bg-muted text-sm font-medium">
            {name.slice(0, 1)}
          </span>
          <div>
            <p className="font-medium">{name}</p>
            <p className="text-sm text-muted-foreground">{fieldValue(c.Email)}</p>
          </div>
        </div>
      );
    },
    // `opportunities` omitted → keeps the built-in default row.
  }}
/>;
```

To render every source's results as cards in a responsive grid instead of the
default stacked list, drop down to the headless hook (next example shows the
same pattern) and wrap each source's `result.nodes` in your own grid container.

### 9. Global pagination across all sources

By default the drop-in `<Search>` paginates each source independently. Set
`config.pagination.mode: "merged"` and `<Search>` switches to a single combined
grid with one shared pager over the **cumulative** result set automatically — no
prop needed (that's exactly what [§10](#10-build-a-custom-unified-results-ui-grid--pager--filters--count)
assembles by hand for a fully custom UI). Either way, the merged behaviour is
built from three aggregate accessors `useSearch` derives from the in-scope
sources:

| Accessor                  | Use                                                                           |
| ------------------------- | ----------------------------------------------------------------------------- |
| `handle.inScopeSources`   | The `SourceController[]` participating in the current scope (config order).   |
| `handle.mergedResults`    | In-scope nodes flattened into one `{ sourceKey, node }[]` for a single grid.  |
| `handle.globalPagination` | Aggregate `pageIndex` / `pageCount` / `pageSize` / `hasNext…` + page actions. |

**Pick a pagination mode** via `config.pagination.mode`:

| Mode           | `mergedResults`                      | Page size means…                  | `goToPage`       |
| -------------- | ------------------------------------ | --------------------------------- | ---------------- |
| `"merged"`     | exactly the current page's window    | items **per page** (e.g. 6 total) | jump to any page |
| `"per-source"` | every in-scope source's current page | items **per source** (6 × N)      | adjacent only    |

Use `"merged"` when you want a true combined list — "6 per page" shows exactly 6
cards and `pageCount = ceil(totalCount / pageSize)`. The default `"per-source"`
keeps each source on its own cursor (cheaper for deep lists, but a page can hold
up to `sources × pageSize` items).

**Order sources** with `config.pagination.mergeOrder`:

| Order                    | `mergedResults` layout                                                  |
| ------------------------ | ----------------------------------------------------------------------- |
| `"sequential"` (default) | all of source 1, then source 2, … — the first source leads.             |
| `"interleaved"`          | round-robin, one per source per round — every source ranks **equally**. |
| `"proportional"`         | each source spread evenly by its result count — share **matches size**. |

Pass `"interleaved"` to give every object type equal footing regardless of how
many results it has; pass `"proportional"` to weight types by their result count
(a source with 100 hits appears ~10× as often as one with 10, so each page
mirrors the overall mix). Proportional reads each source's `totalCount`. Page
boundaries stay exact under all three orders.

```tsx
import {
  useSearch,
  SearchBar,
  DefaultResultRow,
  PaginationControls,
} from "@salesforce/ui-bundle-template-feature-react-search";

function GloballyPaginatedSearch() {
  // mode + mergeOrder live in config.pagination (e.g. { mode: "merged",
  // mergeOrder: "interleaved", pageSize: 6, pageSizeOptions: [6, 12, 24] }).
  const handle = useSearch(config);
  const { mergedResults, globalPagination: page } = handle;

  // One combined, cumulatively-paginated grid. Each item knows its source, so
  // you can pick the right card/row per node.
  const sourceByKey = Object.fromEntries(
    handle.inScopeSources.map((c) => [c.config.key, c.config]),
  );

  return (
    <div className="max-w-4xl mx-auto py-6 space-y-6">
      <SearchBar value={handle.q} onChange={handle.setQ} placeholder="Search…" />

      <p className="text-sm text-muted-foreground">
        {page.totalCount} results · Page {page.pageIndex + 1} of {page.pageCount}
      </p>

      <ul className="divide-y">
        {mergedResults.map(({ sourceKey, node }, i) => (
          <li key={i} className="py-2">
            <DefaultResultRow node={node} source={sourceByKey[sourceKey]} />
          </li>
        ))}
      </ul>

      {/* Passing pageCount + onGoToPage renders numbered page buttons. */}
      <PaginationControls
        pageIndex={page.pageIndex}
        pageCount={page.pageCount}
        hasNextPage={page.hasNextPage}
        hasPreviousPage={page.hasPreviousPage}
        pageSize={page.pageSize}
        pageSizeOptions={page.pageSizeOptions}
        onNextPage={page.goToNextPage}
        onPreviousPage={page.goToPreviousPage}
        onGoToPage={page.goToPage}
        onPageSizeChange={page.setPageSize}
        disabled={handle.loading}
      />
    </div>
  );
}
```

These accessors track scope automatically: in `"all"` scope they span every
source; under a single scope (or `lockedScope`) they collapse to just that one,
so the same layout serves both the unified page and per-object pages.

See [§10](#10-build-a-custom-unified-results-ui-grid--pager--filters--count) for
a complete custom UI — merged grid, pagination widget, per-source filter panels,
and a result count — assembled from these accessors and the exported primitives.

> **How `"merged"` is exact.** For global page `P`, every in-scope source fetches
> `(P + 1) × pageSize` rows from its front; concatenating them in config order
> and slicing `[P × pageSize, (P + 1) × pageSize)` yields the precise page,
> because the first `(P + 1) × pageSize` items of the concatenation are always
> the true cumulative prefix. Cost grows with page depth (each Next refetches a
> larger window), so it suits a combined grid, not an infinite feed.
>
> **`"per-source"` model.** Sources paginate with independent cursors — no global
> offset — so the "global page" is the furthest-advanced in-scope source's page.
> `goToNextPage` advances only sources that still have data; a source that runs
> out is **left behind** and drops out of `mergedResults` on later pages (its
> final page was already shown). `pageCount` is the max of
> `ceil(totalCount / pageSize)` across in-scope sources, and `goToPage` honours
> only adjacent pages. For fully independent pagers, render one `<SourceSection>`
> (or `PaginationControls`) per `handle.sources[key]` and skip the aggregate API.

### 10. Build a custom unified-results UI (grid + pager + filters + count)

For a combined **card grid** with one **shared** pager, a **total-results
count**, and **per-source filter panels**, the fastest path is
`config.pagination.mode: "merged"` on the drop-in `<Search>` — it renders all of
this ([§9](#9-global-pagination-across-all-sources)). Drop to the headless
recipe below only when you need control the drop-in doesn't expose (a bespoke
grid container, custom chrome placement, extra surrounding UI): assemble it
yourself from `useSearch` plus the exported primitives. This is what the
`propertymanagementapp` template's `UnifiedSearch` component used to hand-roll
before adopting merged mode; the skeleton below is the same recipe, trimmed to
the essentials.

The pieces you compose:

| Piece                 | What renders it                                                 | Source of truth                       |
| --------------------- | --------------------------------------------------------------- | ------------------------------------- |
| Search box            | `<SearchBar value={handle.q} onChange={handle.setQ} />`         | `handle.q` / `handle.setQ`            |
| Scope selector        | `<ScopeSelector config scope onScopeChange />`                  | `handle.scope` / `handle.setScope`    |
| **Result count**      | your own `<p>` reading `page.totalCount`                        | `handle.globalPagination.totalCount`  |
| **Merged grid**       | map over `handle.mergedResults` → your card per `sourceKey`     | `handle.mergedResults`                |
| **Pagination widget** | `<PaginationControls …{page} />`                                | `handle.globalPagination`             |
| **Filter panel**      | `<FilterProvider>` + `<DefaultFilterPanel source>` (per source) | `handle.inScopeSources[i]` controller |

Two rules make the layout work in **every** scope — the unified "All" view, a
single source picked in the selector, and a `lockedScope` page — from one
implementation:

1. **Render the count on its own row, directly above the grid, unconditionally.**
   `page.totalCount` is the cumulative in-scope total, so it's meaningful whether
   one source or all of them are in scope. Don't gate it on scope.
2. **Show per-source filter chrome only when a single source is in scope.** Under
   "All", heterogeneous per-source filters can't be combined into one panel, so
   collapse to just the count. Detect this with `handle.scope !== ALL_SCOPE`
   (then `handle.inScopeSources[0]` is the sole controller).

```tsx
import {
  useSearch,
  SearchBar,
  ScopeSelector,
  SortControl,
  ActiveFilters,
  FilterProvider,
  FilterResetButton,
  DefaultFilterPanel,
  PaginationControls,
  ALL_SCOPE,
} from "@salesforce/ui-bundle-template-feature-react-search";

function UnifiedSearch({ restrictTo }: { restrictTo?: string }) {
  // config.pagination = { mode: "merged", mergeOrder: "proportional",
  //   pageSize: 6, pageSizeOptions: [6, 12, 24] } — see §9 and §11.
  const handle = useSearch(config, { lockedScope: restrictTo });
  const { globalPagination: page, mergedResults } = handle;

  const singleSourceInScope = handle.scope !== ALL_SCOPE;
  const soleController = singleSourceInScope ? handle.inScopeSources[0] : undefined;

  return (
    <div className="max-w-6xl mx-auto px-4 py-6 space-y-6">
      {/* Row 1 — search box + scope selector (hidden when the scope is locked). */}
      <div className="flex flex-wrap items-center gap-3">
        <SearchBar value={handle.q} onChange={handle.setQ} placeholder="Search…" />
        {!handle.scopeLocked && (
          <ScopeSelector config={config} scope={handle.scope} onScopeChange={handle.setScope} />
        )}
      </div>

      {/* Row 2 — the sole source's filters + sort, only under a single scope. */}
      {singleSourceInScope && soleController && (
        <FilterProvider
          filters={soleController.filters.active}
          onFilterChange={soleController.filters.set}
          onFilterRemove={soleController.filters.remove}
          onReset={() =>
            soleController.filters.active.forEach((f) => soleController.filters.remove(f.field))
          }
        >
          <div className="rounded-md border p-3 space-y-3">
            <div className="flex items-center justify-between">
              <h3 className="text-sm font-semibold">Filters</h3>
              <FilterResetButton size="sm" />
            </div>
            <div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
              <DefaultFilterPanel source={soleController.config} />
            </div>
            {soleController.config.sortBy && (
              <SortControl
                configs={soleController.config.sortBy}
                sort={soleController.sort.current}
                onSortChange={soleController.sort.set}
              />
            )}
            <ActiveFilters
              filters={soleController.filters.active}
              onRemove={soleController.filters.remove}
            />
          </div>
        </FilterProvider>
      )}

      {/* Count — its own row, directly above the grid, in EVERY scope. */}
      {!handle.loading && !handle.error && (
        <p className="text-sm text-muted-foreground" role="status">
          {page.totalCount} total result{page.totalCount === 1 ? "" : "s"}
        </p>
      )}

      {/* Merged, cumulatively-paginated card grid. Each item knows its source. */}
      <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
        {mergedResults.map(({ sourceKey, node }, i) => (
          <MyCard key={i} sourceKey={sourceKey} node={node} />
        ))}
      </div>

      {/* One shared pager over the cumulative set. Passing pageCount + onGoToPage
          renders numbered page buttons; omit them for prev/next only. */}
      {page.totalCount > 0 && (
        <PaginationControls
          pageIndex={page.pageIndex}
          pageCount={page.pageCount}
          hasNextPage={page.hasNextPage}
          hasPreviousPage={page.hasPreviousPage}
          pageSize={page.pageSize}
          pageSizeOptions={page.pageSizeOptions}
          onNextPage={page.goToNextPage}
          onPreviousPage={page.goToPreviousPage}
          onGoToPage={page.goToPage}
          onPageSizeChange={page.setPageSize}
          disabled={handle.loading || !!handle.error}
        />
      )}
    </div>
  );
}
```

Notes:

- **`MyCard`** switches on `sourceKey` to pick the right card component per
  object type (see [§8](#8-mixed-card-layouts-per-source)). Read node fields with
  the exported `fieldValue()`, or render a `<DefaultResultRow node source>` for a
  built-in row.
- **Filter primitives are wired through `FilterProvider`**, not props — anything
  inside it (`DefaultFilterPanel`, or individual `TextFilter` / `SelectFilter`
  from [§6](#6-replace-a-sources-filter-sidebar)) reads and writes that source's
  filter state automatically.
- **The pager needs `mode: "merged"`** in `config.pagination` for the "shows
  exactly `pageSize` cards, `goToPage` jumps anywhere" behavior above. Under the
  default `"per-source"` mode the same widget still works but paginates each
  source on its own cursor (see the §9 mode table).
- **This same component serves single-object pages** — pass `restrictTo` (a
  source key) as `lockedScope`; `scopeLocked` becomes `true`, the selector hides,
  and `mergedResults` / `page` collapse to that one source. The count and pager
  code paths are unchanged.

### 11. Choosing a merge order (sequential vs proportional)

`config.pagination.mergeOrder` decides how `"merged"`-mode results from
different sources are ordered within each page. It only matters when 2+ sources
are in scope; it's ignored in `"per-source"` mode and under a single scope.

| Order                    | Behavior                                                                | Reach for it when…                                                                                              |
| ------------------------ | ----------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
| `"sequential"` (default) | All of source 1, then all of source 2, … in config order.               | One source is primary and the rest are secondary — e.g. Accounts first, then supporting Contacts/Opportunities. |
| `"interleaved"`          | Round-robin, one per source per round — every source ranks **equally**. | You want a fair sample of each type up front regardless of how many hits each has (e.g. a "spotlight" grid).    |
| `"proportional"`         | Each source spread across the list in proportion to its result count.   | You want each page to mirror the overall mix — larger result sets appear more often, matching their share.      |

**Sequential — front-loads by config order.** The first source fills the early
pages; later sources only appear once the earlier ones are exhausted. Best when
there's a natural priority. With `pageSize: 6` and Accounts (100 hits) before
Contacts (20), page 1 is 6 Accounts, and Contacts don't surface until ~page 17.

```tsx
const config: SearchConfig = {
  pagination: {
    mode: "merged",
    mergeOrder: "sequential",
    pageSize: 6,
    pageSizeOptions: [6, 12, 24],
  },
  sources: [
    { kind: "sobject", key: "accounts" /* … */ }, // leads
    { kind: "sobject", key: "contacts" /* … */ }, // follows
    { kind: "sobject", key: "opportunities" /* … */ }, // last
  ],
};
```

**Proportional — every page reflects the mix.** Each source is spread evenly by
its `totalCount`, so a source with 100 hits appears ~5× as often as one with 20.
With the same 100 Accounts / 20 Contacts and `pageSize: 6`, roughly every page is
5 Accounts + 1 Contact — the 5:1 ratio of the whole result set — so a user sees
all types from page 1 without one type monopolizing the front.

```tsx
const config: SearchConfig = {
  pagination: {
    mode: "merged",
    mergeOrder: "proportional",
    pageSize: 6,
    pageSizeOptions: [6, 12, 24],
  },
  sources: [
    { kind: "sobject", key: "accounts" /* … */ },
    { kind: "sobject", key: "contacts" /* … */ },
    { kind: "sobject", key: "opportunities" /* … */ },
  ],
};
```

> Proportional reads each source's `totalCount` to compute its share, so a source
> that omits `totalCount` falls back to its fetched node count for weighting. Page
> boundaries stay exact under all three orders — only the **order within** the
> cumulative list changes, never which items belong to page `P`.

---

## URL and state conventions

State is reflected in the URL (debounced 300 ms), so searches are
bookmarkable and shareable:

| URL param                       | Meaning                                       |
| ------------------------------- | --------------------------------------------- |
| `q`                             | Global search term (broadcast to all sources) |
| `scope=<key>`                   | Narrow to one source (omitted = "all")        |
| `s.<key>.f.<field>=<value>`     | Single-value filter for a source              |
| `s.<key>.f.<field>.min=<value>` | Range/numeric/date lower bound                |
| `s.<key>.f.<field>.max=<value>` | Range/numeric/date upper bound                |
| `s.<key>.sort=<field>`          | Source-specific sort field                    |
| `s.<key>.dir=ASC\|DESC`         | Source-specific sort direction                |
| `s.<key>.ps=<n>`                | Source-specific page size                     |
| `s.<key>.page=<n>`              | Source-specific 1-based page index            |

Changing `q` resets every source's pagination; changing a source's filter or
sort resets that source's pagination only. Cursors back the prev/next paging
but are not persisted to the URL (only the page index is).

When `restrictTo` / `lockedScope` is set, `?scope=` is **not** written — the
route already implies the source.

---

## Advanced: CMS search

This is the part most likely to be missed — read all of it before assuming
CMS search "just works" after adding a `cms` source to your config.

### Enabling CMS search (API v68 gate)

- **API version requirement.** Search on CMS content is supported only when the
  bundle is built against **API version 68.0 or greater**. The build-time
  version (`__SF_API_VERSION__`, injected from the resolved org's API version)
  is the version every SDK request is actually issued at, so it is the gate.

### UIBundle id resolution and the per-fetch gate

- **No static id in config.** The UIBundle id is resolved at runtime by
  `adapters/cms/searchChannel.ts::getUIBundleId()`, which calls
  `@salesforce/platform-sdk`'s `getCurrentApp()` and reads
  `identity.bundleId` — the UIBundle **record id** (a `9YE...` id). This is
  only populated when running on the WebApp surface (the runtime injects
  `SFDC_ENV`, per `packages/sdk/platform-sdk/src/core/app.ts`). Locally, or on
  any non-WebApp surface, `bundleId` is `undefined`/`""`, and the CMS source
  is skipped with no error — by design.
- **The gate:** `hooks/useSearch.ts` resolves `getUIBundleId()` **and**
  `getOrgSupportsCmsSearch()` once per fetch and, for the CMS source, includes
  the CMS request only when BOTH pass: `isConfiguredUIBundleId(uiBundleId)`
  (`adapters/cms/searchChannel.ts`) and the build API-version check (above).
  `isConfiguredUIBundleId` validates the `9YE` shape
  (`UI_BUNDLE_ID_PATTERN = /^9YE[a-zA-Z0-9]{12,15}$/`) — an empty or malformed
  id, or a build below v68, causes the CMS source to be silently dropped from
  the request (no per-source error banner), not sent to the server.
- **CMS UI surfaces hide on a gated build too.** When CMS search is skipped for
  the build-version reason, its scope entry and result section would only ever
  be empty, so both are hidden using the SAME gate as the fetch skip —
  `isCmsSearchSupported()` (the synchronous core of `getOrgSupportsCmsSearch()`,
  in `adapters/cms/api/orgApiVersionService.ts`). Two minimal guards read it:
  `ScopeSelector` skips the CMS `<SelectItem>` ("Content") and `SearchResults`
  skips the CMS `SourceSection` ("Content" heading + empty state). Both are safe
  during render because the gate reads only the build-time API version. (The
  runtime `bundleId` gate above is _not_ build-constant — it can differ per
  surface — so it stays a per-fetch skip and does not hide the UI; a CMS source
  skipped only for a missing `bundleId` still shows its section, empty.)

### `uibundleIds` vs `channelIds`

- **The query field:** `adapters/cms/cmsQueryFragment.ts` sends the resolved
  `$UIBundleId` into `searchIdentifiers.uibundleIds` (NOT `channelIds`) on
  `managed_content.search.searchContentInChannels`. Content-type filtering
  (`$cmsContentTypeFQNs`) is omitted on the bootstrap call and supplied once
  discovery completes.
  - **Why `uibundleIds` (evidence from core).** `ContentSearchIdentifiersInput`
    has three id-space fields — `channelIds` (`0ap` Managed Content channel ids),
    `siteIds`, and `uibundleIds` — and the `9YE` UIBundle **record id** belongs
    only in `uibundleIds`. In core (`core-2206/core-266-public`): the schema
    `ui-services-private/.../graphql-schemas/mcontent-search.graphqls` documents
    `uibundleIds` as _"Each UI bundle ID is resolved to its associated Managed
    Content channel(s)"_; the resolver
    `mcontent-impl/.../ManagedContentGraphQLSearchServiceImpl.resolveChannelIdsFromSitesAndUiBundles()`
    resolves each id via `getManagedContentChannelsByTarget(uibundleId)` (the id
    is the channel's **TargetEntityId**, i.e. a UIBundle record id); the UDD
    `lwr-udd/.../UIBundle.entity.xml` sets `keyPrefix="9YE"`; and the unit test
    `ManagedContentGraphQLSearchServiceImplTest.testUiBundleId_resolvesToChannelIds_andIsSearched()`
    passes a `9YE…` id into `uibundleIds` and asserts it resolves (W-23364628).
    `channelIds` values are validated as channel/site ids and never run through
    the target-entity resolver, so a `9YE` id sent there fails with
    `"9YE… isn't a valid managed content channel ID or a site ID"` — the exact
    error this wiring fixes. (Server-side, resolution of `uibundleIds`/`siteIds`
    is gated by the core feature flag `isAllowTargetEntityIdsEnabled()`.)

### Content-type discovery

- **Content-type discovery:** after the first CMS result returns,
  `adapters/cms/channelResolver.ts::resolveChannelId()` extracts the `0ap`
  managed-content-channel id from
  `nodes[0].managedContentChannelDeliveryDetails[0].managedContentChannelDetails.id`.
  `adapters/cms/hooks/useSearchableContentTypes.ts` (session-cached) then
  fetches `GET /connect/cms/channels/{channelId}/searchable-content-types` to
  populate the scope dropdown's per-content-type entries. Note: this `0ap` id
  is a _different_ id space from the `9YE` UIBundle id above — don't confuse
  the two when debugging.

### Seeding content

- **Seeding content:** the resolved `bundleId` only surfaces content published
  to the UIBundle's own channel (WebApp type), so CMS content must be published
  there — not to the Experience Site COMMUNITY channel. Content published to
  the wrong channel returns zero results even when the id/gate/query wiring is
  correct, so empty CMS results with everything else in place usually points at
  the publishing target rather than the code.
- **Mounting:** add a `{ "kind": "cms", "key": "...", "label": "..." }` entry
  to `config.sources` (in `config.json`, or a custom `SearchConfig` object at
  runtime), then render `<Search config={config} />` somewhere routed — see
  the next section.

### Mounting `GlobalSearchBox` + `<Search>` at `/search`

- `GlobalSearchBox` (`components/GlobalSearchBox.tsx`) is a **pure
  launcher** — a text input + button that calls `navigate('/search?q=...')`
  on submit. **It renders no results itself.**
- **`GlobalSearchBox` only routes to `/search`; you must mount `<Search>` at
  that route for results to render.** In a template that ships one, `routes.tsx`
  does exactly that: `path: "search"` renders `<GlobalSearch config={config} .../>`
  (`GlobalSearch` is `Search` aliased on import). If an app drops in
  `GlobalSearchBox` without also mounting `<Search>` (or a custom results UI
  built on `useSearch`) at the route it navigates to, searches will appear to
  do nothing.

For `<Search>`'s own props (`restrictTo`, `renderResult` / `renderFilters`,
`showScopeSelector`, etc.), see [Props](#props) above.

---

## Internal structure

- `config.json` (co-located with the source in a consuming feature) declares
  `sources: SourceConfig[]` — a discriminated union on `kind` — plus an
  optional `pagination` block. The shipped default has 3 `"sobject"` sources
  (`accounts`, `contacts`, `opportunities`) and one `"cms"` source
  (`key: "content"`).
- `loadConfig.ts` re-exports `config.json`, typed as `SearchConfig`. For a
  pure config change (add/remove a source, tweak fields), **edit
  `config.json` directly — no code changes required.**
- `types.ts` is the source of truth for `SourceConfig` (`SObjectSourceConfig |
CmsSourceConfig`) and `SearchConfig`.
- `adapters/registry.ts` maps `kind` → adapter (`sobject → sObjectAdapter`,
  `cms → cmsAdapter`). Adding a new backend `kind` means registering an
  adapter here plus extending the `SourceConfig` union in `types.ts` — out of
  scope for typical customization; most tasks only touch `config.json`.

---

## Public API

Everything is exported from the package entry point, grouped by concern:

```ts
// Core (both source types) — the drop-in component, the hook, orchestration
// primitives, and shared utilities.
import {
  Search,
  config,
  useSearch,
  useAsyncData,
  runSearch,
  buildSearchQuery,
  getAdapter,
  SearchBar,
  SearchResults,
  SourceSection,
  SortControl,
  PaginationControls,
  ScopeSelector,
  ActiveFilters,
  DefaultFilterPanel,
  FilterProvider,
  FilterResetButton,
  useFilterField,
  useFilterPanel,
  FilterFieldWrapper,
  fieldValue,
  buildGlobalQueryClause,
  readSourceParams,
  writeSourceParams,
  GLOBAL_QUERY_KEY,
  ALL_SCOPE,
} from "@salesforce/ui-bundle-template-feature-react-search";

// sObject sources — GraphQL filter/sort clause builders, picklist value
// fetching, the filter input components, and the default sObject row.
import {
  useDistinctValues,
  fetchDistinctValues,
  buildOrderBy,
  buildFilter,
  TextFilter,
  SelectFilter,
  MultiSelectFilter,
  NumericRangeFilter,
  BooleanFilter,
  DateRangeFilter,
  DefaultResultRow,
} from "@salesforce/ui-bundle-template-feature-react-search";

// CMS source — the Managed Content search adapter, content-type discovery,
// content-type FQN helpers, and the default CMS row.
import {
  cmsAdapter,
  useSearchableContentTypes,
  isValidCmsFqn,
  formatContentTypeLabel,
  CmsResultRow,
} from "@salesforce/ui-bundle-template-feature-react-search";

// Types
import type {
  SearchConfig,
  SObjectSourceConfig,
  CmsSourceConfig,
  SourceAdapter,
  DisplayField,
  SourceController,
  SourceResult,
  SourcePageInfo,
  SearchHandle,
  SearchScope,
  RenderResultFn,
  FilterFieldConfig,
  FilterFieldType,
  ActiveFilterValue,
  SortFieldConfig,
  SortState,
  PicklistOption,
  SourceRequest,
  SearchQueryPayload,
} from "@salesforce/ui-bundle-template-feature-react-search";
```

See `index.ts` in the package source for the full exported surface if a
primitive you need isn't listed above.

---

## Troubleshooting & common mistakes

- **Missing `searchableFields`** — the global `q` won't match that source.
  Provide at least one field, or omit the source when `q` is non-empty.
- **Relationship field without `subfields`** — `"Owner"` as a bare string emits
  `Owner @optional { value displayValue }`, which is wrong for a relationship.
  Use `{ name: "Owner", subfields: ["Name"] }`.
- **Listing `idField` in `displayFields`** — it's selected automatically; listing
  it again duplicates the selection.
- **`routePattern` token not in `displayFields`** — the token can't resolve and
  every row silently falls back to a non-clickable layout.
- **`routePattern` without a matching route** — see
  [`routePattern` & detail-page linking](#routepattern--detail-page-linking) for
  the full two-part requirement (config `routePattern` + an app-side route).
- **Non-conforming `key`** — must match `/^[A-Za-z_][A-Za-z0-9_]*$/`; it becomes
  a GraphQL alias and variable prefix. Invalid keys produce malformed documents.
- **Picklist auto-fetch failing** — the aggregate `groupBy` requires a
  group-by-able field. For formulas / long text, supply inline `options`.
- **Overriding `whereTypeName` / `orderByTypeName` needlessly** — only set these
  when the schema deviates from `${ObjectName}_Filter` / `${ObjectName}_OrderBy`.
  Wrong names cause GraphQL parse errors.

### CMS gotchas

- **`bundleId` is `undefined` locally.** Outside the WebApp runtime surface
  (e.g. local dev), `getCurrentApp().identity.bundleId` is `undefined`/`""`, so
  the CMS source is skipped with no error — this is by design, not a bug. See
  [UIBundle id resolution and the per-fetch gate](#uibundle-id-resolution-and-the-per-fetch-gate).
- **CMS silently dropped pre-v68 or with a bad id.** A build below API v68, or
  an empty/malformed `9YE` id, causes the CMS source to be dropped from the
  request with no per-source error banner — it just never gets sent. See
  [Enabling CMS search (API v68 gate)](#enabling-cms-search-api-v68-gate).
- **The CMS scope entry / results section disappears on a gated build.** When
  the build's API version doesn't support CMS search, `ScopeSelector` and
  `SearchResults` both hide the CMS entry/section using the same build-version
  gate — this is expected, not a rendering bug. See
  [UIBundle id resolution and the per-fetch gate](#uibundle-id-resolution-and-the-per-fetch-gate).
- **Wrong publish channel → zero results.** CMS content must be published to
  the UIBundle's own WebApp channel, not the Experience Site COMMUNITY channel.
  If the id/gate/query wiring all look correct but CMS results are empty, check
  the publish target first. See [Seeding content](#seeding-content).
