Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),

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

### Fixed

- Fixed approximate `MATCH`, `VLOOKUP`, `HLOOKUP`, and `XLOOKUP` incorrectly returning `#N/A` (or a wrong position) when the search range contained empty cells. [#1697](https://github.com/handsontable/hyperformula/pull/1697)

## [3.3.0] - 2026-05-20

### Added
Expand Down
15 changes: 15 additions & 0 deletions src/Lookup/AdvancedFind.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

import {DependencyGraph} from '../DependencyGraph'
import {
EmptyValue,
getRawValue,
InternalScalarValue,
RawInterpreterValue,
Expand Down Expand Up @@ -61,6 +62,13 @@ export abstract class AdvancedFind {
)
}

/**
* Linear search over an in-memory array for the value equal to `searchKey`, or — when `ifNoMatch`
* is `returnLowerBound`/`returnUpperBound` — the closest non-exceeding/non-preceding value.
* Genuinely empty cells (`EmptyValue`) are skipped, consistent with `findLastOccurrenceInOrderedRange`
* and with Excel/Google Sheets, which ignore empty cells (but not empty strings) in approximate search.
* Returns the 0-based index into `searchArray`, or `NOT_FOUND` (-1) when nothing matches.
*/
protected findNormalizedValue(searchKey: RawNoErrorScalarValue, searchArray: InternalScalarValue[], ifNoMatch: 'returnLowerBound' | 'returnUpperBound' | 'returnNotFound' = 'returnNotFound', returnOccurrence: 'first' | 'last' = 'first'): number {
const normalizedArray = searchArray
.map(getRawValue)
Expand Down Expand Up @@ -88,6 +96,13 @@ export abstract class AdvancedFind {
return i
}

// Skip empty cells in the approximate search, consistent with findLastOccurrenceInOrderedRange:
// Excel/Google Sheets ignore genuinely empty cells (but not empty strings) when looking for the
// lower/upper bound. EmptyValue would otherwise be ranked below every value by compare().
if (value === EmptyValue) {
continue
}

if (compareFn(value, searchKey) > 0) {
continue
}
Expand Down
52 changes: 46 additions & 6 deletions src/interpreter/binarySearch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@ const NOT_FOUND = -1
*
* If the search range contains duplicates, returns the last matching value. If no value found in the range satisfies the above, returns -1.
*
* Empty cells (EmptyValue) are skipped: they are not treated as ordered values during the
* approximate search. This mirrors Excel/Google Sheets, where MATCH/VLOOKUP/HLOOKUP/XLOOKUP
* ignore genuinely empty cells (but not empty strings) when looking for the lower/upper bound.
* The returned offset is always relative to the original range, so empty cells keep their slots
* and the position of the matched non-empty cell is reported unchanged.
*
* Note: this function does not normalize input strings.
*/
export function findLastOccurrenceInOrderedRange(
Expand All @@ -39,8 +45,42 @@ export function findLastOccurrenceInOrderedRange(
? (left: RawNoErrorScalarValue, right: RawInterpreterValue) => compare(left, right)
: (left: RawNoErrorScalarValue, right: RawInterpreterValue) => -compare(left, right)

const foundIndex = findLastMatchingIndex(index => compareFn(searchKey, getValueFromIndexFn(index)) >= 0, start, end)
const foundValue = getValueFromIndexFn(foundIndex)
// Exact-match mode (returnNotFound) does not skip empty cells: HF-223 changes only the approximate
// (bound) lookups, and an exact search must neither match a blank nor be redirected by one. Run the
// binary search directly over the original range — this keeps the mode O(log n) and preserves its
// pre-HF-223 behaviour: an exact hit is returned, everything else is NOT_FOUND. (The empty-skipping
// compaction below would otherwise let an exact search land on, and report, a non-empty cell that
// sits past an interspersed blank — silently changing exact-match results.)
if (ifNoMatch === 'returnNotFound') {
const exactIndex = findLastMatchingIndex(index => compareFn(searchKey, getValueFromIndexFn(index)) >= 0, start, end)
return exactIndex !== NOT_FOUND && getValueFromIndexFn(exactIndex) === searchKey ? exactIndex - start : NOT_FOUND
}

// Collect the original indices of the non-empty cells, preserving their order. Empty cells break
// the sort invariant binary search relies on (compare() ranks EmptyValue below every other value),
// so the bound search runs over the compacted, empty-free index list and the result is mapped back
// to the original index space afterwards.
//
// This pre-scan is O(n) over the range, which trades away the binary search's O(log n) guarantee.
// It is required for correctness in the bound modes: with empty cells interspersed the search
// predicate is no longer monotonic, so the binary search cannot run directly on the original range.
const nonEmptyIndices: number[] = []
for (let index = start; index <= end; index++) {
if (getValueFromIndexFn(index) !== EmptyValue) {
nonEmptyIndices.push(index)
}
}
Comment thread
marcin-kordas-hoc marked this conversation as resolved.

// With no non-empty cells there is nothing to match against. Return early so the
// ifNoMatch branches below (which treat NOT_FOUND as "key past the edge of a non-empty
// list" and may return offset 0) are not reached for an all-empty range.
if (nonEmptyIndices.length === 0) {
return NOT_FOUND
}

const foundCompactedIndex = findLastMatchingIndex(compactedIndex => compareFn(searchKey, getValueFromIndexFn(nonEmptyIndices[compactedIndex])) >= 0, 0, nonEmptyIndices.length - 1)
const foundIndex = foundCompactedIndex === NOT_FOUND ? NOT_FOUND : nonEmptyIndices[foundCompactedIndex]
const foundValue = foundIndex === NOT_FOUND ? EmptyValue : getValueFromIndexFn(foundIndex)

if (foundValue === searchKey) {
return foundIndex - start
Expand All @@ -61,8 +101,8 @@ export function findLastOccurrenceInOrderedRange(
}

// orderingDirection === 'desc'
const nextIndex = foundIndex+1
return nextIndex <= end ? nextIndex - start : NOT_FOUND
const nextIndex = nonEmptyIndices[foundCompactedIndex + 1]
return nextIndex !== undefined ? nextIndex - start : NOT_FOUND
}

if (ifNoMatch === 'returnUpperBound') {
Expand All @@ -80,8 +120,8 @@ export function findLastOccurrenceInOrderedRange(
}

// orderingDirection === 'asc'
const nextIndex = foundIndex+1
return nextIndex <= end ? nextIndex - start : NOT_FOUND
Comment thread
cursor[bot] marked this conversation as resolved.
const nextIndex = nonEmptyIndices[foundCompactedIndex + 1]
return nextIndex !== undefined ? nextIndex - start : NOT_FOUND
}

// ifNoMatch === 'returnNotFound'
Expand Down
Loading