Skip to content

Commit ed10ce5

Browse files
fix(lookup): skip empty cells in approximate MATCH/lookup search (HF-223)
Excel and Google Sheets ignore genuinely empty cells (but not empty strings) when computing the lower/upper bound for an approximate match. HyperFormula instead landed on an empty cell during binary search and returned #N/A (its EmptyValue Symbol never matched the key), or, in descending mode, reported the wrong position. findLastOccurrenceInOrderedRange now runs the ordered search over a compacted list of non-empty cell indices and maps the result back to the original index space, so empty cells keep their slots and the matched non-empty cell's original 1-based position is reported unchanged. When the range has no non-empty cells the function returns NOT_FOUND, so an all-empty range yields #N/A for every direction/bound instead of falling through to the offset-0 branches. The in-memory ordered path in AdvancedFind.findNormalizedValue skips EmptyValue for the same reason. Empty strings are unaffected (text is ranked above numbers, so they still terminate a numeric run). Exact match (matchType 0) is untouched. Shared by approximate MATCH(+/-1), sorted VLOOKUP/HLOOKUP, and XLOOKUP(searchMode +/-2). Tests for the public test suite are tracked separately in handsontable/hyperformula-tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 72205bd commit ed10ce5

3 files changed

Lines changed: 43 additions & 6 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
1111

1212
- Added an Indonesian (Bahasa Indonesia) language pack. [#1674](https://github.com/handsontable/hyperformula/pull/1674)
1313

14+
### Fixed
15+
16+
- Fixed approximate `MATCH`, `VLOOKUP`, `HLOOKUP`, and `XLOOKUP` incorrectly returning `#N/A` (or a wrong position) when the search range contained empty cells; empty cells are now skipped during approximate matching, consistent with Excel and Google Sheets. [HF-223]
17+
1418
## [3.3.0] - 2026-05-20
1519

1620
### Added

src/Lookup/AdvancedFind.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
import {DependencyGraph} from '../DependencyGraph'
77
import {
8+
EmptyValue,
89
getRawValue,
910
InternalScalarValue,
1011
RawInterpreterValue,
@@ -88,6 +89,13 @@ export abstract class AdvancedFind {
8889
return i
8990
}
9091

92+
// Skip empty cells in the approximate search, consistent with findLastOccurrenceInOrderedRange:
93+
// Excel/Google Sheets ignore genuinely empty cells (but not empty strings) when looking for the
94+
// lower/upper bound. EmptyValue would otherwise be ranked below every value by compare().
95+
if (value === EmptyValue) {
96+
continue
97+
}
98+
9199
if (compareFn(value, searchKey) > 0) {
92100
continue
93101
}

src/interpreter/binarySearch.ts

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,12 @@ const NOT_FOUND = -1
2020
*
2121
* If the search range contains duplicates, returns the last matching value. If no value found in the range satisfies the above, returns -1.
2222
*
23+
* Empty cells (EmptyValue) are skipped: they are not treated as ordered values during the
24+
* approximate search. This mirrors Excel/Google Sheets, where MATCH/VLOOKUP/HLOOKUP/XLOOKUP
25+
* ignore genuinely empty cells (but not empty strings) when looking for the lower/upper bound.
26+
* The returned offset is always relative to the original range, so empty cells keep their slots
27+
* and the position of the matched non-empty cell is reported unchanged.
28+
*
2329
* Note: this function does not normalize input strings.
2430
*/
2531
export function findLastOccurrenceInOrderedRange(
@@ -35,12 +41,31 @@ export function findLastOccurrenceInOrderedRange(
3541
? (index: number) => getRawValue(dependencyGraph.getCellValue(simpleCellAddress(range.sheet, index, range.start.row)))
3642
: (index: number) => getRawValue(dependencyGraph.getCellValue(simpleCellAddress(range.sheet, range.start.col, index)))
3743

44+
// Collect the original indices of the non-empty cells, preserving their order. Empty cells break
45+
// the sort invariant binary search relies on (compare() ranks EmptyValue below every other value),
46+
// so the search runs over the compacted, empty-free index list and the result is mapped back to the
47+
// original index space afterwards.
48+
const nonEmptyIndices: number[] = []
49+
for (let index = start; index <= end; index++) {
50+
if (getValueFromIndexFn(index) !== EmptyValue) {
51+
nonEmptyIndices.push(index)
52+
}
53+
}
54+
55+
// With no non-empty cells there is nothing to match against. Return early so the
56+
// ifNoMatch branches below (which treat NOT_FOUND as "key past the edge of a non-empty
57+
// list" and may return offset 0) are not reached for an all-empty range.
58+
if (nonEmptyIndices.length === 0) {
59+
return NOT_FOUND
60+
}
61+
3862
const compareFn = orderingDirection === 'asc'
3963
? (left: RawNoErrorScalarValue, right: RawInterpreterValue) => compare(left, right)
4064
: (left: RawNoErrorScalarValue, right: RawInterpreterValue) => -compare(left, right)
4165

42-
const foundIndex = findLastMatchingIndex(index => compareFn(searchKey, getValueFromIndexFn(index)) >= 0, start, end)
43-
const foundValue = getValueFromIndexFn(foundIndex)
66+
const foundCompactedIndex = findLastMatchingIndex(compactedIndex => compareFn(searchKey, getValueFromIndexFn(nonEmptyIndices[compactedIndex])) >= 0, 0, nonEmptyIndices.length - 1)
67+
const foundIndex = foundCompactedIndex === NOT_FOUND ? NOT_FOUND : nonEmptyIndices[foundCompactedIndex]
68+
const foundValue = foundIndex === NOT_FOUND ? EmptyValue : getValueFromIndexFn(foundIndex)
4469

4570
if (foundValue === searchKey) {
4671
return foundIndex - start
@@ -61,8 +86,8 @@ export function findLastOccurrenceInOrderedRange(
6186
}
6287

6388
// orderingDirection === 'desc'
64-
const nextIndex = foundIndex+1
65-
return nextIndex <= end ? nextIndex - start : NOT_FOUND
89+
const nextIndex = nonEmptyIndices[foundCompactedIndex + 1]
90+
return nextIndex !== undefined ? nextIndex - start : NOT_FOUND
6691
}
6792

6893
if (ifNoMatch === 'returnUpperBound') {
@@ -80,8 +105,8 @@ export function findLastOccurrenceInOrderedRange(
80105
}
81106

82107
// orderingDirection === 'asc'
83-
const nextIndex = foundIndex+1
84-
return nextIndex <= end ? nextIndex - start : NOT_FOUND
108+
const nextIndex = nonEmptyIndices[foundCompactedIndex + 1]
109+
return nextIndex !== undefined ? nextIndex - start : NOT_FOUND
85110
}
86111

87112
// ifNoMatch === 'returnNotFound'

0 commit comments

Comments
 (0)