From 9f42daa57a025c8395afd0bb76de96af03f1f498 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Sat, 12 Sep 2026 14:25:13 +0000 Subject: [PATCH 1/2] Predict the array size of a named expression from what the name stands for A named expression had no case in the array-size predictor, so it fell to the default and was predicted as a scalar error. The referring cell was therefore never turned into an array vertex, and an array-shaped result reached the exporter, which rejects it as a #VALUE! error. A named range passed to an array-returning function failed this way while the same call on a range literal worked. The prediction now recurses into the expression the name is bound to, isRef included, so a name bound to a range predicts like the range literal it stands for and a name bound to an array-returning formula predicts like that formula. Two details worth stating. Names may refer to one another, so a set of names currently being predicted guards against a reference cycle recursing until the stack overflows; a name already on it is treated as unpredictable, which leaves the referring cell a scalar formula and lets the evaluator report the cycle. And the prediction deliberately uses the engine's own array-arithmetic setting rather than the calling state's, because the named expression has a cell of its own that is always computed with the engine setting, no matter where the name is used. Adds DependencyGraph#getFormulaAst, which the predictor needs to reach the expression behind a name. Co-Authored-By: Claude Opus 5 --- src/ArraySize.ts | 56 +++++++++++++++++++++++++- src/BuildEngineFactory.ts | 2 +- src/DependencyGraph/DependencyGraph.ts | 11 +++++ 3 files changed, 67 insertions(+), 2 deletions(-) diff --git a/src/ArraySize.ts b/src/ArraySize.ts index dc658ee29a..cf36ad1e87 100644 --- a/src/ArraySize.ts +++ b/src/ArraySize.ts @@ -6,10 +6,12 @@ import {AbsoluteCellRange} from './AbsoluteCellRange' import {SimpleCellAddress} from './Cell' import {Config} from './Config' +import {DependencyGraph} from './DependencyGraph' import {FunctionRegistry} from './interpreter/FunctionRegistry' import {InterpreterState} from './interpreter/InterpreterState' import {FunctionArgumentType} from './interpreter' -import {Ast, AstNodeType, ProcedureAst} from './parser' +import {InternalNamedExpression} from './NamedExpressions' +import {Ast, AstNodeType, NamedExpressionAst, ProcedureAst} from './parser' export class ArraySize { constructor( @@ -40,9 +42,19 @@ function arraySizeForUnaryOp(arraySize: ArraySize): ArraySize { } export class ArraySizePredictor { + /** + * Named expressions whose size is currently being predicted. + * + * Names may refer to one another, and a cycle of such references would make the prediction + * recurse until the stack overflows. A name already on this set is treated as unpredictable, + * which leaves the referring cell a scalar formula and lets the evaluator report the cycle. + */ + private readonly namedExpressionsBeingPredicted = new Set() + constructor( private config: Config, private functionRegistry: FunctionRegistry, + private dependencyGraph: DependencyGraph, ) { } @@ -84,6 +96,8 @@ export class ArraySizePredictor { return ArraySize.scalar() case AstNodeType.CELL_REFERENCE: return new ArraySize(1, 1, true) + case AstNodeType.NAMED_EXPRESSION: + return this.checkArraySizeForNamedExpression(ast, state) case AstNodeType.DIV_OP: case AstNodeType.CONCATENATE_OP: case AstNodeType.EQUALS_OP: @@ -122,6 +136,46 @@ export class ArraySizePredictor { } } + /** + * Predicts the size of a named expression referred to by a formula. + * + * A name stands for an expression, so its size is the size of that expression, the `isRef` + * flag included: a name bound to a range predicts like the range literal it stands for, and a + * name bound to an array-returning formula predicts like that formula. Without this, every + * named expression was predicted as a scalar, the referring cell was never turned into an + * array vertex, and an array-shaped result reached the exporter, which rejects it as a + * `#VALUE!` error. + * + * The prediction deliberately uses the engine's own array-arithmetic setting instead of the + * calling state's: the named expression has a cell of its own, and the evaluator always + * computes that cell with `Config.useArrayArithmetic`, no matter where the name is used. + * Inheriting the caller's flag would predict a shape the named expression never produces - + * for example a range-arithmetic name is a scalar error under the default configuration even + * when the name appears inside an array function. + */ + private checkArraySizeForNamedExpression(ast: NamedExpressionAst, state: InterpreterState): ArraySize { + const namedExpression = this.dependencyGraph.namedExpressions.nearestNamedExpression(ast.expressionName, state.formulaAddress.sheet) + + if (namedExpression === undefined || this.namedExpressionsBeingPredicted.has(namedExpression)) { + return ArraySize.error() + } + + const expression = this.dependencyGraph.getFormulaAst(namedExpression.address) + + if (expression === undefined) { + return ArraySize.scalar() + } + + this.namedExpressionsBeingPredicted.add(namedExpression) + + try { + const size = this.checkArraySize(expression, namedExpression.address) + return new ArraySize(size.width, size.height, size.isRef) + } finally { + this.namedExpressionsBeingPredicted.delete(namedExpression) + } + } + private checkArraySizeForFunction(ast: ProcedureAst, state: InterpreterState): ArraySize { const pluginArraySizeFunction = this.functionRegistry.getArraySizeFunction(ast.procedureName) diff --git a/src/BuildEngineFactory.ts b/src/BuildEngineFactory.ts index 62202a78c1..c25e2b5b3a 100644 --- a/src/BuildEngineFactory.ts +++ b/src/BuildEngineFactory.ts @@ -103,7 +103,7 @@ export class BuildEngineFactory { const arithmeticHelper = new ArithmeticHelper(config, dateTimeHelper, numberLiteralHelper) const cellContentParser = new CellContentParser(config, dateTimeHelper, numberLiteralHelper) - const arraySizePredictor = new ArraySizePredictor(config, functionRegistry) + const arraySizePredictor = new ArraySizePredictor(config, functionRegistry, dependencyGraph) const operations = new Operations(config, dependencyGraph, columnSearch, cellContentParser, parser, stats, lazilyTransformingAstService, namedExpressions, arraySizePredictor) const undoRedo = new UndoRedo(config, operations) lazilyTransformingAstService.undoRedo = undoRedo diff --git a/src/DependencyGraph/DependencyGraph.ts b/src/DependencyGraph/DependencyGraph.ts index 962dedff1a..c3f7fb0f55 100644 --- a/src/DependencyGraph/DependencyGraph.ts +++ b/src/DependencyGraph/DependencyGraph.ts @@ -607,6 +607,17 @@ export class DependencyGraph { return this.addressMapping.getCell(address, { throwIfSheetNotExists: true }) } + /** + * Gets the formula stored at the specified address. + * + * Returns `undefined` when the address holds anything other than a formula: a static value, + * an empty cell, a formula that failed to parse, or a cell of a sheet that does not exist. + */ + public getFormulaAst(address: SimpleCellAddress): Maybe { + const vertex = this.addressMapping.getCell(address) + return vertex instanceof FormulaVertex ? vertex.getFormula(this.lazilyTransformingAstService) : undefined + } + public getCellValue(address: SimpleCellAddress): InterpreterValue { if (this.isPlaceholder(address.sheet)) { return new CellError(ErrorType.REF, ErrorMessage.SheetRef) From 134d2b7b8c3ea50c8101924b5a64709f41633e6f Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Sat, 12 Sep 2026 14:44:42 +0000 Subject: [PATCH 2/2] Document the named-range array-size fix The named-expressions guide stated that "a bare =myRange + 1 does not spill" in array arithmetic mode. That sentence described the defect, so it is replaced by what the engine now does, and the function-argument bullet gains the array-returning case that was the reported symptom. The neighbouring claim about SUM(myRange + 1) returning 20 in array mode was re-measured and still holds, as does every default-mode result on the page. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 1 + docs/guide/named-expressions.md | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aeea8dd511..2d3cd6e50e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ### Fixed +- Fixed a named range evaluating to the `#VALUE!` error instead of spilling when passed to an array-returning function, and, with `useArrayArithmetic` enabled, when used as an operand. A named expression now predicts the same array size as the expression it stands for, so `=TRANSPOSE(myRange)` and `=myRange + 1` behave as `=TRANSPOSE(A1:A5)` and `=A1:A5 + 1` do. [#1772](https://github.com/handsontable/hyperformula/pull/1772) - Fixed the `AVERAGEIF` function returning a division-by-zero error when the calculated average was `0`. [#1733](https://github.com/handsontable/hyperformula/pull/1733) - Fixed the localized names of `VSTACK` and `HSTACK` in 14 language packs to match Microsoft Excel. [#1748](https://github.com/handsontable/hyperformula/pull/1748) - Fixed the MAXPOOL and MEDIANPOOL functions throwing an uncaught `TypeError` instead of returning the `#VALUE!` error when the range dimensions are not a whole multiple of the window size and the stride. [#1718](https://github.com/handsontable/hyperformula/pull/1718) diff --git a/docs/guide/named-expressions.md b/docs/guide/named-expressions.md index 99249cb25b..19f1714202 100644 --- a/docs/guide/named-expressions.md +++ b/docs/guide/named-expressions.md @@ -105,7 +105,7 @@ hfInstance.setCellContents({sheet: 0, col: 2, row: 1}, [['=SUM(SalesData) * TaxR A named expression that resolves to a range of cells behaves differently depending on where it is used: -- **As a function argument** — it works as expected. `=SUM(myRange)`, `=COUNT(myRange)`, and `=INDEX(myRange, 1, 1)` all operate on the full range. +- **As a function argument** — it works as expected. `=SUM(myRange)`, `=COUNT(myRange)`, and `=INDEX(myRange, 1, 1)` all operate on the full range. A function that returns an array receives the full range too, and its result spills: `=TRANSPOSE(myRange)` produces the same block as `=TRANSPOSE(Sheet1!$A$1:$A$5)`. - **As an operand of an operator** — the range is reduced to a single cell before the operation. In `=myRange + 1`, only the cell of the range that shares the formula's row (for a vertical range) or column (for a horizontal range) is used. If the formula's row or column falls outside the range, or the range is two-dimensional, the result is a `#VALUE!` error. - **As a bare reference** — `=myRange` on its own returns a `#VALUE!` error; a range cannot be placed directly into a single cell. @@ -113,7 +113,7 @@ In the default mode the range is reduced before the operator runs, so `=SUM(myRa When array arithmetic is enabled (`useArrayArithmetic: true`), named ranges still work as function arguments and aggregate correctly, but as an operand they behave differently from the default mode: -- A bare `=myRange + 1` does not spill — it returns a `#VALUE!` error rather than producing one result per element. +- `=myRange + 1` spills, producing one result per element, exactly as the range it stands for does: the result is the same as for `=A1:A5 + 1`. - Inside an aggregate the operator becomes element-wise. `=SUM(myRange + 1)` adds 1 to every element and then sums, so for `myRange` covering values `1..5` it returns `20` (`SUM(2, 3, 4, 5, 6)`), not the single reduced value of the default mode. ## Named columns