diff --git a/packages/instantsearch.js/src/lib/utils/__tests__/getHighlightFromSiblings-test.ts b/packages/instantsearch.js/src/lib/utils/__tests__/getHighlightFromSiblings-test.ts
index 7cb35c0243..bb9b9fc8fb 100644
--- a/packages/instantsearch.js/src/lib/utils/__tests__/getHighlightFromSiblings-test.ts
+++ b/packages/instantsearch.js/src/lib/utils/__tests__/getHighlightFromSiblings-test.ts
@@ -29,4 +29,33 @@ describe('getHighlightFromSiblings', () => {
test('returns the isHighlighted value with both siblings', () => {
expect(getHighlightFromSiblings(multipleMatches, 1)).toEqual(true);
});
+
+ test('adopts the shared state of present non-highlighted siblings', () => {
+ // A non-alphanumeric separator between two non-highlighted siblings adopts
+ // their `false` state. This used to collapse to `true` because of a
+ // `|| true` default that treated any `false` neighbor as highlighted.
+ const parts: HighlightedParts[] = [
+ { isHighlighted: false, value: 'Fi' },
+ { isHighlighted: false, value: ' - ' },
+ { isHighlighted: false, value: 'Black' },
+ ];
+
+ expect(getHighlightFromSiblings(parts, 1)).toEqual(false);
+ });
+
+ test('defaults a missing sibling to highlighted', () => {
+ const parts: HighlightedParts[] = [{ isHighlighted: false, value: ' - ' }];
+
+ expect(getHighlightFromSiblings(parts, 0)).toEqual(true);
+ });
+
+ test('keeps its own state when siblings disagree', () => {
+ const parts: HighlightedParts[] = [
+ { isHighlighted: true, value: 'a' },
+ { isHighlighted: false, value: ' - ' },
+ { isHighlighted: false, value: 'b' },
+ ];
+
+ expect(getHighlightFromSiblings(parts, 1)).toEqual(false);
+ });
});
diff --git a/packages/instantsearch.js/src/lib/utils/__tests__/highlight-parts-test.ts b/packages/instantsearch.js/src/lib/utils/__tests__/highlight-parts-test.ts
new file mode 100644
index 0000000000..d57f430fd6
--- /dev/null
+++ b/packages/instantsearch.js/src/lib/utils/__tests__/highlight-parts-test.ts
@@ -0,0 +1,17 @@
+import {
+ concatHighlightedParts,
+ getHighlightedParts,
+} from '../highlight-parts';
+
+describe('highlight-parts', () => {
+ describe('round trip', () => {
+ test.each([
+ 'Amazon - Fire HD8 - 8" - Tablet - 16GB - Wi-Fi - Black',
+ 'Amazon - Fire HD8 - 8" - Tablet - 16GB - Wi-Fi - Black',
+ 'Amazon',
+ 'no highlight here',
+ ])('concat(get(%p)) returns the original string', (value) => {
+ expect(concatHighlightedParts(getHighlightedParts(value))).toEqual(value);
+ });
+ });
+});
diff --git a/packages/instantsearch.js/src/lib/utils/__tests__/reverseHighlightedParts-test.ts b/packages/instantsearch.js/src/lib/utils/__tests__/reverseHighlightedParts-test.ts
index b4943754b4..85bb677111 100644
--- a/packages/instantsearch.js/src/lib/utils/__tests__/reverseHighlightedParts-test.ts
+++ b/packages/instantsearch.js/src/lib/utils/__tests__/reverseHighlightedParts-test.ts
@@ -38,7 +38,12 @@ describe('reverseHighlightedParts', () => {
value: ' - Fire HD8 - 8" - Tablet - 16GB - Wi-',
},
{ isHighlighted: true, value: 'Fi' },
- { isHighlighted: false, value: ' - ' },
+ // The ` - ` separator sits between two siblings that share the same
+ // (reversed) highlight state, so it adopts that state to keep the
+ // highlight contiguous. This previously stayed `false` because of a
+ // `|| true` sibling default that collapsed any `false` neighbor to
+ // `true`; see `getHighlightFromSiblings`.
+ { isHighlighted: true, value: ' - ' },
{ isHighlighted: true, value: 'Black' },
]);
});
diff --git a/packages/instantsearch.js/src/lib/utils/concatHighlightedParts.ts b/packages/instantsearch.js/src/lib/utils/concatHighlightedParts.ts
index fd6d97fe6c..0b4cfd21ce 100644
--- a/packages/instantsearch.js/src/lib/utils/concatHighlightedParts.ts
+++ b/packages/instantsearch.js/src/lib/utils/concatHighlightedParts.ts
@@ -1,15 +1,3 @@
-import { TAG_REPLACEMENT } from './escape-highlight';
-
-import type { HighlightedParts } from '../../types';
-
-export function concatHighlightedParts(parts: HighlightedParts[]) {
- const { highlightPreTag, highlightPostTag } = TAG_REPLACEMENT;
-
- return parts
- .map((part) =>
- part.isHighlighted
- ? highlightPreTag + part.value + highlightPostTag
- : part.value
- )
- .join('');
-}
+// Backward-compatible re-export. The implementation now lives in the unified
+// `highlight-parts` module alongside its inverse and reverse operations.
+export { concatHighlightedParts } from './highlight-parts';
diff --git a/packages/instantsearch.js/src/lib/utils/getHighlightFromSiblings.ts b/packages/instantsearch.js/src/lib/utils/getHighlightFromSiblings.ts
index 03aba197eb..17870b2e5b 100644
--- a/packages/instantsearch.js/src/lib/utils/getHighlightFromSiblings.ts
+++ b/packages/instantsearch.js/src/lib/utils/getHighlightFromSiblings.ts
@@ -1,20 +1,3 @@
-import { unescape } from './escape-html';
-
-import type { HighlightedParts } from '../../types';
-
-const hasAlphanumeric = new RegExp(/\w/i);
-
-export function getHighlightFromSiblings(parts: HighlightedParts[], i: number) {
- const current = parts[i];
- const isNextHighlighted = parts[i + 1]?.isHighlighted || true;
- const isPreviousHighlighted = parts[i - 1]?.isHighlighted || true;
-
- if (
- !hasAlphanumeric.test(unescape(current.value)) &&
- isPreviousHighlighted === isNextHighlighted
- ) {
- return isPreviousHighlighted;
- }
-
- return current.isHighlighted;
-}
+// Backward-compatible re-export. The implementation now lives in the unified
+// `highlight-parts` module alongside its inverse and reverse operations.
+export { getHighlightFromSiblings } from './highlight-parts';
diff --git a/packages/instantsearch.js/src/lib/utils/getHighlightedParts.ts b/packages/instantsearch.js/src/lib/utils/getHighlightedParts.ts
index c95f2269fb..39a71cfced 100644
--- a/packages/instantsearch.js/src/lib/utils/getHighlightedParts.ts
+++ b/packages/instantsearch.js/src/lib/utils/getHighlightedParts.ts
@@ -1,30 +1,3 @@
-import { TAG_REPLACEMENT } from './escape-highlight';
-
-export function getHighlightedParts(highlightedValue: string) {
- // @MAJOR: this should use TAG_PLACEHOLDER
- const { highlightPostTag, highlightPreTag } = TAG_REPLACEMENT;
-
- const splitByPreTag = highlightedValue.split(highlightPreTag);
- const firstValue = splitByPreTag.shift();
- const elements = !firstValue
- ? []
- : [{ value: firstValue, isHighlighted: false }];
-
- splitByPreTag.forEach((split) => {
- const splitByPostTag = split.split(highlightPostTag);
-
- elements.push({
- value: splitByPostTag[0],
- isHighlighted: true,
- });
-
- if (splitByPostTag[1] !== '') {
- elements.push({
- value: splitByPostTag[1],
- isHighlighted: false,
- });
- }
- });
-
- return elements;
-}
+// Backward-compatible re-export. The implementation now lives in the unified
+// `highlight-parts` module alongside its inverse and reverse operations.
+export { getHighlightedParts } from './highlight-parts';
diff --git a/packages/instantsearch.js/src/lib/utils/highlight-parts.ts b/packages/instantsearch.js/src/lib/utils/highlight-parts.ts
new file mode 100644
index 0000000000..a16efd79a5
--- /dev/null
+++ b/packages/instantsearch.js/src/lib/utils/highlight-parts.ts
@@ -0,0 +1,92 @@
+import { unescape } from './escape-html';
+import { TAG_REPLACEMENT } from './escape-highlight';
+
+import type { HighlightedParts } from '../../types';
+
+const hasAlphanumeric = new RegExp(/\w/i);
+
+/**
+ * Parses an Algolia highlighted string into an array of parts, where each part
+ * is flagged as highlighted or not. This is the inverse of
+ * `concatHighlightedParts`.
+ */
+export function getHighlightedParts(highlightedValue: string) {
+ // @MAJOR: this should use TAG_PLACEHOLDER
+ const { highlightPostTag, highlightPreTag } = TAG_REPLACEMENT;
+
+ const splitByPreTag = highlightedValue.split(highlightPreTag);
+ const firstValue = splitByPreTag.shift();
+ const elements = !firstValue
+ ? []
+ : [{ value: firstValue, isHighlighted: false }];
+
+ splitByPreTag.forEach((split) => {
+ const splitByPostTag = split.split(highlightPostTag);
+
+ elements.push({
+ value: splitByPostTag[0],
+ isHighlighted: true,
+ });
+
+ if (splitByPostTag[1] !== '') {
+ elements.push({
+ value: splitByPostTag[1],
+ isHighlighted: false,
+ });
+ }
+ });
+
+ return elements;
+}
+
+/**
+ * Re-serializes highlighted parts into an Algolia highlighted string. This is
+ * the inverse of `getHighlightedParts`.
+ */
+export function concatHighlightedParts(parts: HighlightedParts[]) {
+ const { highlightPreTag, highlightPostTag } = TAG_REPLACEMENT;
+
+ return parts
+ .map((part) =>
+ part.isHighlighted
+ ? highlightPreTag + part.value + highlightPostTag
+ : part.value
+ )
+ .join('');
+}
+
+/**
+ * Resolves the effective highlight state of a part by looking at its siblings.
+ * A non-alphanumeric part (e.g. a separator) surrounded by two parts that share
+ * the same highlight state adopts that state, so reversed highlighting stays
+ * contiguous across separators. A missing sibling defaults to highlighted.
+ */
+export function getHighlightFromSiblings(parts: HighlightedParts[], i: number) {
+ const current = parts[i];
+ const isNextHighlighted = parts[i + 1]?.isHighlighted ?? true;
+ const isPreviousHighlighted = parts[i - 1]?.isHighlighted ?? true;
+
+ if (
+ !hasAlphanumeric.test(unescape(current.value)) &&
+ isPreviousHighlighted === isNextHighlighted
+ ) {
+ return isPreviousHighlighted;
+ }
+
+ return current.isHighlighted;
+}
+
+/**
+ * Inverts the highlight state of each part, keeping separators contiguous with
+ * their siblings via `getHighlightFromSiblings`.
+ */
+export function reverseHighlightedParts(parts: HighlightedParts[]) {
+ if (!parts.some((part) => part.isHighlighted)) {
+ return parts.map((part) => ({ ...part, isHighlighted: false }));
+ }
+
+ return parts.map((part, i) => ({
+ ...part,
+ isHighlighted: !getHighlightFromSiblings(parts, i),
+ }));
+}
diff --git a/packages/instantsearch.js/src/lib/utils/reverseHighlightedParts.ts b/packages/instantsearch.js/src/lib/utils/reverseHighlightedParts.ts
index 559c6bd5da..3b55e00423 100644
--- a/packages/instantsearch.js/src/lib/utils/reverseHighlightedParts.ts
+++ b/packages/instantsearch.js/src/lib/utils/reverseHighlightedParts.ts
@@ -1,14 +1,3 @@
-import { getHighlightFromSiblings } from './getHighlightFromSiblings';
-
-import type { HighlightedParts } from '../../types';
-
-export function reverseHighlightedParts(parts: HighlightedParts[]) {
- if (!parts.some((part) => part.isHighlighted)) {
- return parts.map((part) => ({ ...part, isHighlighted: false }));
- }
-
- return parts.map((part, i) => ({
- ...part,
- isHighlighted: !getHighlightFromSiblings(parts, i),
- }));
-}
+// Backward-compatible re-export. The implementation now lives in the unified
+// `highlight-parts` module alongside its inverse and reverse operations.
+export { reverseHighlightedParts } from './highlight-parts';