Pasting a formula that references column names by name (e.g. =A_COL * B_COL) into a grid cell now automatically translates those names into that row's actual cell references (=B4 * C4) before the value is written, so Handsontable's formulas plugin can evaluate it — covering both a normal grid-level paste and pasting directly into an already-open cell editor.
Live formula support has also been generalized: rather than being restricted to admin-designated HARDFORMULA/SOFTFORMULA columns, any character-typed column can now hold a live formula. Formulas are enabled for every table (not just ones with a formula rule), and a =-led value is only ever auto-escaped into inert text when it comes from the backend — a value the user types or pastes always evaluates as a real formula. A new "Apply as formula" context-menu action lets a user explicitly promote a backend-escaped cell to a live formula when they want to.
Intent
Column names aren't valid formula references — HyperFormula only understands cell references like B4, the same way Excel does. Without translation, a user pasting a formula written in terms of column names (the natural way to think about it, and the same convention already used for admin-defined HARDFORMULA/SOFTFORMULA rule values) would just get a #NAME? error or inert literal text. This closes that gap for the most common real path — pasting — without expanding into typed edits, where a user can just type the real reference directly.
That work surfaced a second problem: once formulas are enabled for a table at all, HyperFormula treats every=-led cell as a live formula, in any column — including backend data that merely happens to start with =. The original fix scoped live formulas to HARDFORMULA/SOFTFORMULA columns only, escaping everything else, but that also blocked a user from ever typing or pasting a real, working formula into any other column. The model here instead scopes the risk to what it actually is — backend-sourced text that looks like a formula — and leaves user input alone, since a user typing = is deliberately writing a formula. "Apply as formula" covers the case where a user wants to promote backend-seeded text after the fact.
Changes
substituteColumnReferences.ts (new): pure function translating
column names in a formula-like string to row-relative cell references.
Deliberately does not delegate to the existing parseFormulaRule (used
for admin-defined rule values, which also substitute DC.USER_NAME/DC.ORIG_VALUE/DC.ROW_STATUS) — this is scoped to
column names only, since those DC.* variables have no clear meaning for
a value a user is pasting fresh. Reuses parseFormulaRule's escapeRegExpMetacharacters/substituteBoundedToken helpers (now
exported) rather than sharing its full quoted-span-walking loop, to
avoid risking a subtle behavior change in that already-shipped,
reviewed function.
isCharacterColumn.ts (new): identifies formula-eligible columns by
the backend's own DDTYPE === 'C' classification, replacing the earlier
HARDFORMULA/SOFTFORMULA-only allow-list.
escapeCharacterColumnValue.ts (new): escapes a =-led value to
inert text; used only for backend-sourced data, never for user input.
autoEscapedCellTracker.ts (new): tracks which cells the app itself
auto-escaped, in a PK-keyed map kept separate from the row objects (so
the marker can never leak into the submit payload) and independent of
physical row index (so it survives insert/delete/sort). Submission and
"Apply as formula" only ever touch cells this map confirms the app
marked — a genuinely-literal '=... value from the backend, or one the
user typed themselves, is never touched.
unescapeFormula.ts (new): strips the app's own escape marker back
off before submission.
editor.component.ts:
hotTable.formulas is now enabled unconditionally, for every table.
beforePaste hook and the open-cell-editor paste listener (see
below) now translate column names for any character column, not just
HARDFORMULA/SOFTFORMULA ones.
New paste listener on the grid's root element covers pasting
directly into an open cell editor (double-click, then paste) — a
separate Handsontable code path that beforePaste never sees, since
it's a native browser paste into the editor's own textarea rather
than a grid-level paste the CopyPaste plugin intercepts. Manually
dispatches a synthetic input event afterward, mirroring what a real
(non-prevented) paste would have triggered, so the editor's own
internal value-tracking picks up the change.
Initial load escapes backend =-led values in character columns
(skipping HARDFORMULA/SOFTFORMULA base columns, which legitimately
hold a live formula string) and records which cells were escaped.
beforeChange no longer escapes anything — it only clears a cell's
auto-escape marker if the user edits it, since the marker no longer
describes what's there.
Submission strips the auto-escape marker only from cells the app
itself marked, regardless of whether the table has any formula rules.
New "Apply as formula" context-menu item, shown only when the
selection contains an auto-escaped cell; strips just that marker,
leaving any genuinely-literal cell in the same selection untouched.
Fixed a real regression hit while enabling formulas everywhere:
Handsontable's own maxRows grid setting (a licensing cap on how many
rows can be added) silently overrides HyperFormula's own sheet-size
limit, breaking any table whose existing row count exceeds that cap. maxRows now never goes below the actual loaded row count.
parseFormulaRule.ts: escapeRegExpMetacharacters and substituteBoundedToken exported (no logic change) for reuse above.
Mock data (getdata.js): seeded MPE_X_FORMULA_TEST's PLAIN_TEXT_COL with a backend =-led value and a genuinely-literal '=... value, to exercise the escape/round-trip behavior.
characterColumnFormula.integration.spec.ts (new): real
Handsontable + HyperFormula instance tests covering escape-on-load,
user input never being escaped, the submit round-trip, "Apply as
formula", and a regression guard for maxRows.
Full existing parseFormulaRule.spec.ts suite re-verified unchanged
as a regression check after exporting the two shared helpers.
editor.cy.ts: end-to-end coverage for grid-level paste and
open-cell-editor paste (including on a table with zero formula
rules), quoted-text protection, pasting a formula-looking value into
a character column (now evaluates live), the backend-seeded escape
and submit round-trip, and the "Apply as formula" menu item.
## Summary
Pasting a formula that references column names by name (e.g. `=A_COL * B_COL`) into a grid cell now automatically translates those names into that row's actual cell references (`=B4 * C4`) before the value is written, so Handsontable's formulas plugin can evaluate it — covering both a normal grid-level paste and pasting directly into an already-open cell editor.
Live formula support has also been generalized: rather than being restricted to admin-designated HARDFORMULA/SOFTFORMULA columns, any character-typed column can now hold a live formula. Formulas are enabled for every table (not just ones with a formula rule), and a `=`-led value is only ever auto-escaped into inert text when it comes from the backend — a value the user types or pastes always evaluates as a real formula. A new "Apply as formula" context-menu action lets a user explicitly promote a backend-escaped cell to a live formula when they want to.
## Intent
Column names aren't valid formula references — HyperFormula only understands cell references like `B4`, the same way Excel does. Without translation, a user pasting a formula written in terms of column names (the natural way to think about it, and the same convention already used for admin-defined HARDFORMULA/SOFTFORMULA rule values) would just get a `#NAME?` error or inert literal text. This closes that gap for the most common real path — pasting — without expanding into typed edits, where a user can just type the real reference directly.
That work surfaced a second problem: once formulas are enabled for a table at all, HyperFormula treats *every* `=`-led cell as a live formula, in any column — including backend data that merely happens to start with `=`. The original fix scoped live formulas to HARDFORMULA/SOFTFORMULA columns only, escaping everything else, but that also blocked a user from ever typing or pasting a real, working formula into any other column. The model here instead scopes the *risk* to what it actually is — backend-sourced text that looks like a formula — and leaves user input alone, since a user typing `=` is deliberately writing a formula. "Apply as formula" covers the case where a user wants to promote backend-seeded text after the fact.
## Changes
- **`substituteColumnReferences.ts`** (new): pure function translating
column names in a formula-like string to row-relative cell references.
Deliberately does not delegate to the existing `parseFormulaRule` (used
for admin-defined rule values, which also substitute
`DC.USER_NAME`/`DC.ORIG_VALUE`/`DC.ROW_STATUS`) — this is scoped to
column names only, since those DC.\* variables have no clear meaning for
a value a user is pasting fresh. Reuses `parseFormulaRule`'s
`escapeRegExpMetacharacters`/`substituteBoundedToken` helpers (now
exported) rather than sharing its full quoted-span-walking loop, to
avoid risking a subtle behavior change in that already-shipped,
reviewed function.
- **`isCharacterColumn.ts`** (new): identifies formula-eligible columns by
the backend's own `DDTYPE === 'C'` classification, replacing the earlier
HARDFORMULA/SOFTFORMULA-only allow-list.
- **`escapeCharacterColumnValue.ts`** (new): escapes a `=`-led value to
inert text; used only for backend-sourced data, never for user input.
- **`autoEscapedCellTracker.ts`** (new): tracks which cells the app itself
auto-escaped, in a PK-keyed map kept separate from the row objects (so
the marker can never leak into the submit payload) and independent of
physical row index (so it survives insert/delete/sort). Submission and
"Apply as formula" only ever touch cells this map confirms the app
marked — a genuinely-literal `'=...` value from the backend, or one the
user typed themselves, is never touched.
- **`unescapeFormula.ts`** (new): strips the app's own escape marker back
off before submission.
- **`editor.component.ts`**:
- `hotTable.formulas` is now enabled unconditionally, for every table.
- `beforePaste` hook and the open-cell-editor `paste` listener (see
below) now translate column names for any character column, not just
HARDFORMULA/SOFTFORMULA ones.
- New `paste` listener on the grid's root element covers pasting
directly into an open cell editor (double-click, then paste) — a
separate Handsontable code path that `beforePaste` never sees, since
it's a native browser paste into the editor's own textarea rather
than a grid-level paste the CopyPaste plugin intercepts. Manually
dispatches a synthetic `input` event afterward, mirroring what a real
(non-prevented) paste would have triggered, so the editor's own
internal value-tracking picks up the change.
- Initial load escapes backend `=`-led values in character columns
(skipping HARDFORMULA/SOFTFORMULA base columns, which legitimately
hold a live formula string) and records which cells were escaped.
- `beforeChange` no longer escapes anything — it only clears a cell's
auto-escape marker if the user edits it, since the marker no longer
describes what's there.
- Submission strips the auto-escape marker only from cells the app
itself marked, regardless of whether the table has any formula rules.
- New "Apply as formula" context-menu item, shown only when the
selection contains an auto-escaped cell; strips just that marker,
leaving any genuinely-literal cell in the same selection untouched.
- Fixed a real regression hit while enabling formulas everywhere:
Handsontable's own `maxRows` grid setting (a licensing cap on how many
rows can be *added*) silently overrides HyperFormula's own sheet-size
limit, breaking any table whose *existing* row count exceeds that cap.
`maxRows` now never goes below the actual loaded row count.
- **`parseFormulaRule.ts`**: `escapeRegExpMetacharacters` and
`substituteBoundedToken` exported (no logic change) for reuse above.
- **Mock data** (`getdata.js`): seeded `MPE_X_FORMULA_TEST`'s
`PLAIN_TEXT_COL` with a backend `=`-led value and a genuinely-literal
`'=...` value, to exercise the escape/round-trip behavior.
- **Tests**:
- `substituteColumnReferences.spec.ts`, `isCharacterColumn.spec.ts`,
`escapeCharacterColumnValue.spec.ts`, `autoEscapedCellTracker.spec.ts`,
`unescapeFormula.spec.ts` (all new, TDD).
- `characterColumnFormula.integration.spec.ts` (new): real
Handsontable + HyperFormula instance tests covering escape-on-load,
user input never being escaped, the submit round-trip, "Apply as
formula", and a regression guard for `maxRows`.
- Full existing `parseFormulaRule.spec.ts` suite re-verified unchanged
as a regression check after exporting the two shared helpers.
- `editor.cy.ts`: end-to-end coverage for grid-level paste and
open-cell-editor paste (including on a table with zero formula
rules), quoted-text protection, pasting a formula-looking value into
a character column (now evaluates live), the backend-seeded escape
and submit round-trip, and the "Apply as formula" menu item.
- Paste a formula using column names (e.g. "=A_COL * B_COL") into any
cell and have it translated to that row's cell references
("=B4 * C4") so HyperFormula can evaluate it - covers both a
grid-level paste and pasting directly into an open cell editor
- Only applies on tables that already have formulas enabled (an
existing HARDFORMULA/SOFTFORMULA column); otherwise the pasted text
is left untouched rather than becoming inert translated text
- New substituteColumnReferences() reuses parseFormulaRule's
boundary-matching helpers but deliberately skips DC.* variable
substitution, which only makes sense for admin-defined rule values
Verdict: Approve (with minor suggestions) — clean, well-documented, well-tested change that correctly mirrors the already-shipped parseFormulaRule column→cell-ref mapping.
Critical
None.
Warnings
None blocking. A couple of shared limitations inherited from parseFormulaRule (same regex/guard approach) — noted for awareness, not as regressions:
The quoted-span regex "[^"]*"|'[^']*' in substituteColumnReferences.ts does not handle doubled-quote escaping ("" inside a quoted string, the SAS/Excel convention). A formula like ="say ""PRICE"" " would mis-segment the quoted span and could expose PRICE to substitution. This is identical to parseFormulaRule's pattern, so it's an accepted pre-existing limitation rather than a new bug — worth a follow-up issue if formulas with embedded escaped quotes are expected in pasted values.
If a real column is literally named like a produced cell reference (e.g. a column named B1), a prior substitution's output could be re-matched by a later column's pass. Extreme edge case; same behavior as parseFormulaRule.
Suggestions
paste listener cleanup (editor.component.ts:4244): the root-element paste listener is attached once in initSetup (good — not per edit session, despite the comment mentioning edit sessions) but has no corresponding removeEventListener in ngOnDestroy. The existing mousedown listener at line 3269 follows the same no-cleanup pattern, and the rootElement is part of Angular's DOM that gets torn down with the component (so the listener is GC'd with the node), so this is consistent rather than a leak. If you ever address the mousedown one, address this in the same pass.
Guard ordering in the paste listener (editor.component.ts:4248): editor.row === null is the weaker of the two guards — getActiveEditor() can return a finished-state editor whose row may not be null. The primary, reliable guard is event.target !== editor.TEXTAREA (the editor's TEXTAREA only matches the paste target while editing). Consider reordering so the event.target !== editor.TEXTAREA check reads first for clarity, though the current order is functionally safe because the target check is ANDed in the same expression.
Looks Good
substituteColumnReferences is a pure function with 8 focused unit cases covering the core example, row-relative refs, boundary rules (no surrounding blanks → no match), quoted-string protection, and explicit no-DC.* substitution. TDD coverage is solid.
The if (this.hotTable.formulas) gate correctly restricts translation to tables where HyperFormula is enabled — on formula-disabled tables the pasted text is left as an inert literal, verified by e2e test 41.
Column→cell-ref mapping uses this.headerColumns (which includes the appended EDIT_STATUS_COLUMN_NAME), keeping index alignment consistent with both the grid and parseFormulaRule — no off-by-one.
No double-translation: the open-editor paste path and the grid-level beforePaste path are mutually exclusive (native textarea paste vs. CopyPaste-plugin-intercepted paste), and the event.target !== editor.TEXTAREA guard prevents the root listener from re-processing grid-level pastes.
parseFormulaRule.ts change is purely additive (two export keywords) — zero logic change, and the existing parseFormulaRule.spec.ts regression-checks it.
coerceNumericRow runs before substitution but safely skips formula strings (isNaN("=...") is true), so no coercion corruption of pasted formulas.
e2e tests 40–43 cover grid-level paste, the formula-disabled no-op, quoted-text protection, and open-editor paste — the first tests in this suite to simulate a clipboard paste into Handsontable.
Reviewed by Hermes Agent
## Hermes Agent Code Review
**Verdict: Approve (with minor suggestions)** — clean, well-documented, well-tested change that correctly mirrors the already-shipped `parseFormulaRule` column→cell-ref mapping.
### Critical
None.
### Warnings
None blocking. A couple of shared limitations inherited from `parseFormulaRule` (same regex/guard approach) — noted for awareness, not as regressions:
- The quoted-span regex `"[^"]*"|'[^']*'` in `substituteColumnReferences.ts` does not handle doubled-quote escaping (`""` inside a quoted string, the SAS/Excel convention). A formula like `="say ""PRICE"" "` would mis-segment the quoted span and could expose `PRICE` to substitution. This is identical to `parseFormulaRule`'s pattern, so it's an accepted pre-existing limitation rather than a new bug — worth a follow-up issue if formulas with embedded escaped quotes are expected in pasted values.
- If a real column is literally named like a produced cell reference (e.g. a column named `B1`), a prior substitution's output could be re-matched by a later column's pass. Extreme edge case; same behavior as `parseFormulaRule`.
### Suggestions
1. **`paste` listener cleanup** (`editor.component.ts:4244`): the root-element `paste` listener is attached once in `initSetup` (good — not per edit session, despite the comment mentioning edit sessions) but has no corresponding `removeEventListener` in `ngOnDestroy`. The existing `mousedown` listener at line 3269 follows the same no-cleanup pattern, and the rootElement is part of Angular's DOM that gets torn down with the component (so the listener is GC'd with the node), so this is consistent rather than a leak. If you ever address the `mousedown` one, address this in the same pass.
2. **Guard ordering in the `paste` listener** (`editor.component.ts:4248`): `editor.row === null` is the weaker of the two guards — `getActiveEditor()` can return a finished-state editor whose `row` may not be `null`. The primary, reliable guard is `event.target !== editor.TEXTAREA` (the editor's TEXTAREA only matches the paste target while editing). Consider reordering so the `event.target !== editor.TEXTAREA` check reads first for clarity, though the current order is functionally safe because the target check is ANDed in the same expression.
### Looks Good
- `substituteColumnReferences` is a pure function with 8 focused unit cases covering the core example, row-relative refs, boundary rules (no surrounding blanks → no match), quoted-string protection, and explicit no-`DC.*` substitution. TDD coverage is solid.
- The `if (this.hotTable.formulas)` gate correctly restricts translation to tables where HyperFormula is enabled — on formula-disabled tables the pasted text is left as an inert literal, verified by e2e test 41.
- Column→cell-ref mapping uses `this.headerColumns` (which includes the appended `EDIT_STATUS_COLUMN_NAME`), keeping index alignment consistent with both the grid and `parseFormulaRule` — no off-by-one.
- No double-translation: the open-editor `paste` path and the grid-level `beforePaste` path are mutually exclusive (native textarea paste vs. CopyPaste-plugin-intercepted paste), and the `event.target !== editor.TEXTAREA` guard prevents the root listener from re-processing grid-level pastes.
- `parseFormulaRule.ts` change is purely additive (two `export` keywords) — zero logic change, and the existing `parseFormulaRule.spec.ts` regression-checks it.
- `coerceNumericRow` runs before substitution but safely skips formula strings (`isNaN("=...")` is true), so no coercion corruption of pasted formulas.
- e2e tests 40–43 cover grid-level paste, the formula-disabled no-op, quoted-text protection, and open-editor paste — the first tests in this suite to simulate a clipboard paste into Handsontable.
Reviewed by Hermes Agent
This paste listener is attached once in initSetup (which runs once per table load, not per edit session — so the comment's intent is correct), but there's no matching removeEventListener in ngOnDestroy. This matches the existing mousedown listener at line 3269 (same no-cleanup pattern), and since hot.rootElement is part of the component DOM that Angular tears down, the listener is GC'd with the node — so it's consistent rather than a leak. Worth addressing together with the mousedown listener if you ever do a cleanup pass.
Nit on the comment: "rather than attaching a new listener per edit session" — editTable() (the per-edit-session entry point) is a different method; this listener lives in initSetup, so the phrasing slightly mislocates the alternative it's guarding against. The real risk it avoids is re-attachment on repeated initSetup retries (line 3510), which don't reach here once hotInstance is ready.
This `paste` listener is attached once in `initSetup` (which runs once per table load, not per edit session — so the comment's intent is correct), but there's no matching `removeEventListener` in `ngOnDestroy`. This matches the existing `mousedown` listener at line 3269 (same no-cleanup pattern), and since `hot.rootElement` is part of the component DOM that Angular tears down, the listener is GC'd with the node — so it's consistent rather than a leak. Worth addressing together with the `mousedown` listener if you ever do a cleanup pass.
Nit on the comment: "rather than attaching a new listener per edit session" — `editTable()` (the per-edit-session entry point) is a different method; this listener lives in `initSetup`, so the phrasing slightly mislocates the alternative it's guarding against. The real risk it avoids is re-attachment on repeated `initSetup` retries (line 3510), which don't reach here once `hotInstance` is ready.
editor.row === null is the weaker guard here — getActiveEditor() can return a finished-state editor whose row isn't reliably null across Handsontable versions. The load-bearing check is event.target !== editor.TEXTAREA (the editor's TEXTAREA only matches the paste target while a cell is actively being edited). Functionally safe as written (both are ANDed), but consider leading with the event.target !== editor.TEXTAREA check for clarity, since that's the one that actually distinguishes grid-level paste from in-editor paste.
`editor.row === null` is the weaker guard here — `getActiveEditor()` can return a finished-state editor whose `row` isn't reliably `null` across Handsontable versions. The load-bearing check is `event.target !== editor.TEXTAREA` (the editor's TEXTAREA only matches the paste target while a cell is actively being edited). Functionally safe as written (both are ANDed), but consider leading with the `event.target !== editor.TEXTAREA` check for clarity, since that's the one that actually distinguishes grid-level paste from in-editor paste.
Shared limitation (identical to parseFormulaRule.ts:53): this quoted-span regex doesn't handle doubled-quote escaping ("" inside a quoted string). A pasted value like ="say ""PRICE"" " would mis-segment and could expose PRICE to substitution. Not a regression since parseFormulaRule has the same pattern, but worth a shared follow-up if pasted formulas may contain escaped embedded quotes.
Shared limitation (identical to `parseFormulaRule.ts:53`): this quoted-span regex doesn't handle doubled-quote escaping (`""` inside a quoted string). A pasted value like `="say ""PRICE"" "` would mis-segment and could expose `PRICE` to substitution. Not a regression since `parseFormulaRule` has the same pattern, but worth a shared follow-up if pasted formulas may contain escaped embedded quotes.
Subtle: substitutions run sequentially over columnNames, and substituteBoundedToken's output is fed back into the next column's pass. If a real column were named like a produced cell ref (e.g. a column literally named B1), an earlier substitution's output could be re-matched and re-substituted by this later pass. substituteBoundedToken's surrounding-blank requirement makes this unlikely in practice, and parseFormulaRule has the same ordering — flagging only as an accepted shared edge case.
Subtle: substitutions run sequentially over `columnNames`, and `substituteBoundedToken`'s output is fed back into the next column's pass. If a real column were named like a produced cell ref (e.g. a column literally named `B1`), an earlier substitution's output could be re-matched and re-substituted by this later pass. `substituteBoundedToken`'s surrounding-blank requirement makes this unlikely in practice, and `parseFormulaRule` has the same ordering — flagging only as an accepted shared edge case.
Only backend-sourced `=`-led values are auto-escaped now; anything the
user types or pastes into a character column evaluates as a real
formula, and "Apply as formula" lets a user promote an auto-escaped
cell explicitly. Formulas are enabled on every table instead of only
ones with formula rules.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Summary
Pasting a formula that references column names by name (e.g.
=A_COL * B_COL) into a grid cell now automatically translates those names into that row's actual cell references (=B4 * C4) before the value is written, so Handsontable's formulas plugin can evaluate it — covering both a normal grid-level paste and pasting directly into an already-open cell editor.Live formula support has also been generalized: rather than being restricted to admin-designated HARDFORMULA/SOFTFORMULA columns, any character-typed column can now hold a live formula. Formulas are enabled for every table (not just ones with a formula rule), and a
=-led value is only ever auto-escaped into inert text when it comes from the backend — a value the user types or pastes always evaluates as a real formula. A new "Apply as formula" context-menu action lets a user explicitly promote a backend-escaped cell to a live formula when they want to.Intent
Column names aren't valid formula references — HyperFormula only understands cell references like
B4, the same way Excel does. Without translation, a user pasting a formula written in terms of column names (the natural way to think about it, and the same convention already used for admin-defined HARDFORMULA/SOFTFORMULA rule values) would just get a#NAME?error or inert literal text. This closes that gap for the most common real path — pasting — without expanding into typed edits, where a user can just type the real reference directly.That work surfaced a second problem: once formulas are enabled for a table at all, HyperFormula treats every
=-led cell as a live formula, in any column — including backend data that merely happens to start with=. The original fix scoped live formulas to HARDFORMULA/SOFTFORMULA columns only, escaping everything else, but that also blocked a user from ever typing or pasting a real, working formula into any other column. The model here instead scopes the risk to what it actually is — backend-sourced text that looks like a formula — and leaves user input alone, since a user typing=is deliberately writing a formula. "Apply as formula" covers the case where a user wants to promote backend-seeded text after the fact.Changes
substituteColumnReferences.ts(new): pure function translatingcolumn names in a formula-like string to row-relative cell references.
Deliberately does not delegate to the existing
parseFormulaRule(usedfor admin-defined rule values, which also substitute
DC.USER_NAME/DC.ORIG_VALUE/DC.ROW_STATUS) — this is scoped tocolumn names only, since those DC.* variables have no clear meaning for
a value a user is pasting fresh. Reuses
parseFormulaRule'sescapeRegExpMetacharacters/substituteBoundedTokenhelpers (nowexported) rather than sharing its full quoted-span-walking loop, to
avoid risking a subtle behavior change in that already-shipped,
reviewed function.
isCharacterColumn.ts(new): identifies formula-eligible columns bythe backend's own
DDTYPE === 'C'classification, replacing the earlierHARDFORMULA/SOFTFORMULA-only allow-list.
escapeCharacterColumnValue.ts(new): escapes a=-led value toinert text; used only for backend-sourced data, never for user input.
autoEscapedCellTracker.ts(new): tracks which cells the app itselfauto-escaped, in a PK-keyed map kept separate from the row objects (so
the marker can never leak into the submit payload) and independent of
physical row index (so it survives insert/delete/sort). Submission and
"Apply as formula" only ever touch cells this map confirms the app
marked — a genuinely-literal
'=...value from the backend, or one theuser typed themselves, is never touched.
unescapeFormula.ts(new): strips the app's own escape marker backoff before submission.
editor.component.ts:hotTable.formulasis now enabled unconditionally, for every table.beforePastehook and the open-cell-editorpastelistener (seebelow) now translate column names for any character column, not just
HARDFORMULA/SOFTFORMULA ones.
pastelistener on the grid's root element covers pastingdirectly into an open cell editor (double-click, then paste) — a
separate Handsontable code path that
beforePastenever sees, sinceit's a native browser paste into the editor's own textarea rather
than a grid-level paste the CopyPaste plugin intercepts. Manually
dispatches a synthetic
inputevent afterward, mirroring what a real(non-prevented) paste would have triggered, so the editor's own
internal value-tracking picks up the change.
=-led values in character columns(skipping HARDFORMULA/SOFTFORMULA base columns, which legitimately
hold a live formula string) and records which cells were escaped.
beforeChangeno longer escapes anything — it only clears a cell'sauto-escape marker if the user edits it, since the marker no longer
describes what's there.
itself marked, regardless of whether the table has any formula rules.
selection contains an auto-escaped cell; strips just that marker,
leaving any genuinely-literal cell in the same selection untouched.
Handsontable's own
maxRowsgrid setting (a licensing cap on how manyrows can be added) silently overrides HyperFormula's own sheet-size
limit, breaking any table whose existing row count exceeds that cap.
maxRowsnow never goes below the actual loaded row count.parseFormulaRule.ts:escapeRegExpMetacharactersandsubstituteBoundedTokenexported (no logic change) for reuse above.getdata.js): seededMPE_X_FORMULA_TEST'sPLAIN_TEXT_COLwith a backend=-led value and a genuinely-literal'=...value, to exercise the escape/round-trip behavior.substituteColumnReferences.spec.ts,isCharacterColumn.spec.ts,escapeCharacterColumnValue.spec.ts,autoEscapedCellTracker.spec.ts,unescapeFormula.spec.ts(all new, TDD).characterColumnFormula.integration.spec.ts(new): realHandsontable + HyperFormula instance tests covering escape-on-load,
user input never being escaped, the submit round-trip, "Apply as
formula", and a regression guard for
maxRows.parseFormulaRule.spec.tssuite re-verified unchangedas a regression check after exporting the two shared helpers.
editor.cy.ts: end-to-end coverage for grid-level paste andopen-cell-editor paste (including on a table with zero formula
rules), quoted-text protection, pasting a formula-looking value into
a character column (now evaluates live), the backend-seeded escape
and submit round-trip, and the "Apply as formula" menu item.
- Paste a formula using column names (e.g. "=A_COL * B_COL") into any cell and have it translated to that row's cell references ("=B4 * C4") so HyperFormula can evaluate it - covers both a grid-level paste and pasting directly into an open cell editor - Only applies on tables that already have formulas enabled (an existing HARDFORMULA/SOFTFORMULA column); otherwise the pasted text is left untouched rather than becoming inert translated text - New substituteColumnReferences() reuses parseFormulaRule's boundary-matching helpers but deliberately skips DC.* variable substitution, which only makes sense for admin-defined rule valuesTest Coverage
Services: 22/60 (37%) | Macros: 12/42 (29%) | Overall: 34/102 (33%) — run
2026-08-19T10:00Zon772a359PR diff
Generated by Hermes Agent
Hermes Agent Code Review
Verdict: Approve (with minor suggestions) — clean, well-documented, well-tested change that correctly mirrors the already-shipped
parseFormulaRulecolumn→cell-ref mapping.Critical
None.
Warnings
None blocking. A couple of shared limitations inherited from
parseFormulaRule(same regex/guard approach) — noted for awareness, not as regressions:"[^"]*"|'[^']*'insubstituteColumnReferences.tsdoes not handle doubled-quote escaping (""inside a quoted string, the SAS/Excel convention). A formula like="say ""PRICE"" "would mis-segment the quoted span and could exposePRICEto substitution. This is identical toparseFormulaRule's pattern, so it's an accepted pre-existing limitation rather than a new bug — worth a follow-up issue if formulas with embedded escaped quotes are expected in pasted values.B1), a prior substitution's output could be re-matched by a later column's pass. Extreme edge case; same behavior asparseFormulaRule.Suggestions
pastelistener cleanup (editor.component.ts:4244): the root-elementpastelistener is attached once ininitSetup(good — not per edit session, despite the comment mentioning edit sessions) but has no correspondingremoveEventListenerinngOnDestroy. The existingmousedownlistener at line 3269 follows the same no-cleanup pattern, and the rootElement is part of Angular's DOM that gets torn down with the component (so the listener is GC'd with the node), so this is consistent rather than a leak. If you ever address themousedownone, address this in the same pass.Guard ordering in the
pastelistener (editor.component.ts:4248):editor.row === nullis the weaker of the two guards —getActiveEditor()can return a finished-state editor whoserowmay not benull. The primary, reliable guard isevent.target !== editor.TEXTAREA(the editor's TEXTAREA only matches the paste target while editing). Consider reordering so theevent.target !== editor.TEXTAREAcheck reads first for clarity, though the current order is functionally safe because the target check is ANDed in the same expression.Looks Good
substituteColumnReferencesis a pure function with 8 focused unit cases covering the core example, row-relative refs, boundary rules (no surrounding blanks → no match), quoted-string protection, and explicit no-DC.*substitution. TDD coverage is solid.if (this.hotTable.formulas)gate correctly restricts translation to tables where HyperFormula is enabled — on formula-disabled tables the pasted text is left as an inert literal, verified by e2e test 41.this.headerColumns(which includes the appendedEDIT_STATUS_COLUMN_NAME), keeping index alignment consistent with both the grid andparseFormulaRule— no off-by-one.pastepath and the grid-levelbeforePastepath are mutually exclusive (native textarea paste vs. CopyPaste-plugin-intercepted paste), and theevent.target !== editor.TEXTAREAguard prevents the root listener from re-processing grid-level pastes.parseFormulaRule.tschange is purely additive (twoexportkeywords) — zero logic change, and the existingparseFormulaRule.spec.tsregression-checks it.coerceNumericRowruns before substitution but safely skips formula strings (isNaN("=...")is true), so no coercion corruption of pasted formulas.Reviewed by Hermes Agent
@@ -4215,0 +4241,4 @@// element (paste events bubble) rather than attaching a new listener// per edit session, to avoid accumulating listeners across repeated// edits.hot.rootElement.addEventListener('paste', (event: ClipboardEvent) => {This
pastelistener is attached once ininitSetup(which runs once per table load, not per edit session — so the comment's intent is correct), but there's no matchingremoveEventListenerinngOnDestroy. This matches the existingmousedownlistener at line 3269 (same no-cleanup pattern), and sincehot.rootElementis part of the component DOM that Angular tears down, the listener is GC'd with the node — so it's consistent rather than a leak. Worth addressing together with themousedownlistener if you ever do a cleanup pass.Nit on the comment: "rather than attaching a new listener per edit session" —
editTable()(the per-edit-session entry point) is a different method; this listener lives ininitSetup, so the phrasing slightly mislocates the alternative it's guarding against. The real risk it avoids is re-attachment on repeatedinitSetupretries (line 3510), which don't reach here oncehotInstanceis ready.@@ -4215,0 +4245,4 @@if (!this.hotTable.formulas) returnconst editor: any = hot.getActiveEditor()if (!editor || editor.row === null || event.target !== editor.TEXTAREA)editor.row === nullis the weaker guard here —getActiveEditor()can return a finished-state editor whoserowisn't reliablynullacross Handsontable versions. The load-bearing check isevent.target !== editor.TEXTAREA(the editor's TEXTAREA only matches the paste target while a cell is actively being edited). Functionally safe as written (both are ANDed), but consider leading with theevent.target !== editor.TEXTAREAcheck for clarity, since that's the one that actually distinguishes grid-level paste from in-editor paste.@@ -0,0 +17,4 @@const hasLeadingEquals = formulaText.startsWith('=')const formulaBody = hasLeadingEquals ? formulaText.slice(1) : formulaTextconst quotedSpanPattern = /"[^"]*"|'[^']*'/gShared limitation (identical to
parseFormulaRule.ts:53): this quoted-span regex doesn't handle doubled-quote escaping (""inside a quoted string). A pasted value like="say ""PRICE"" "would mis-segment and could exposePRICEto substitution. Not a regression sinceparseFormulaRulehas the same pattern, but worth a shared follow-up if pasted formulas may contain escaped embedded quotes.@@ -0,0 +24,4 @@const substituteUnquotedSpan = (span: string): string => {let substituted = spancolumnNames.forEach((columnName, columnIndex) => {Subtle: substitutions run sequentially over
columnNames, andsubstituteBoundedToken's output is fed back into the next column's pass. If a real column were named like a produced cell ref (e.g. a column literally namedB1), an earlier substitution's output could be re-matched and re-substituted by this later pass.substituteBoundedToken's surrounding-blank requirement makes this unlikely in practice, andparseFormulaRulehas the same ordering — flagging only as an accepted shared edge case.View command line instructions
Checkout
From your project repository, check out a new branch and test the changes.