LearnNewsExamplesServices
Frontmatter
id18771
titleA grid column renderer from an ancestor runs with the column as this, so examples/grid/cellEditing renders nothing
stateClosed
labels
bugairegressiongrid
assigneesneo-opus-grace
createdAtSep 16, 2026, 11:59 AM
updatedAtSep 16, 2026, 3:28 PM
githubUrlhttps://github.com/neomjs/neo/issues/18771
authorneo-opus-grace
commentsCount0
parentIssue18762
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[]
closedAtSep 16, 2026, 3:28 PM

A grid column renderer from an ancestor runs with the column as this, so examples/grid/cellEditing renders nothing

Closed v13.2.0/archive-v13-2-0-chunk-21 bugairegressiongrid
neo-opus-grace
neo-opus-grace commented on Sep 16, 2026, 11:59 AM

Context

examples/grid/cellEditing renders no rows at all. Measured on dev@d486a03a8d by loading /examples/grid/cellEditing/index.html in Chromium: 0 elements match .neo-grid-cell, and the App Worker logs

vdom update failed neo-grid-body-1 TypeError: this.getStateProvider is not a function
    at Column.countryRenderer (examples/grid/cellEditing/MainContainer.mjs:34:33)
    at Row.applyRendererOutput (src/grid/Row.mjs:190:42)

Column.countryRenderer is the tell: the method is defined on Neo.examples.grid.cellEditing.MainContainer and reached through renderer: 'up.countryRenderer', but it runs with the column as this.

This is a regression of #6620, which fixed exactly this example:

commit date scope the renderer is called with
dd4572d8d5 (#6620) 2025-04-05 column.rendererScope || me — me was grid.View, the caller
f691b3faf0 (#8970, Row pooling) 2026-02-03 column.rendererScope || column — reintroduced in the new grid/Row.mjs

The Row-pooling refactor moved applyRendererOutput from grid.View into Neo.grid.Row and restored the pre-#6620 default on the way.

The Problem

Two independent losses put the ancestor's method on the column's this:

  1. grid.column.Base#beforeSetRenderer (src/grid/column/Base.mjs:219) calls resolveCallback(value, this) and keeps only .fn:

       return resolveCallback(value, this).fn || value

    resolveCallback (src/util/Function.mjs:158-171) returns {fn, scope} and, for an up. name, walks the parent chain until it finds the owner — {fn: MainContainer.prototype.countryRenderer, scope: theMainContainer}. The scope it found is dropped on the floor.

  2. grid.Row#applyRendererOutput (src/grid/Row.mjs:190) then supplies its own default:

       rendererOutput = column.renderer.call(column.rendererScope || column, rendererConfig)

    So a renderer that came from an ancestor is invoked with the column. Neo.grid.column.Base has no getStateProvider, and the render throws inside the body's update — which is why the grid renders nothing rather than one bad cell.

The default renderer is unaffected and must stay that way: renderer_: 'cellRenderer' (src/grid/column/Base.mjs:73) resolves on the column itself, so resolveCallback returns the column as scope, which is correct.

The Architectural Reality

surface today
src/util/Function.mjs:158 resolveCallback returns {fn, scope}; up. walks scope.parent until the name resolves
src/grid/column/Base.mjs:219 beforeSetRenderer keeps .fn, discards .scope
src/grid/column/Base.mjs:81 rendererScope {Neo.core.Base|String|null}, the designed home for a scope
src/grid/Row.mjs:172 rendererScope 'me'/'this' means the column — grid.column.Component (:55) relies on it
src/grid/Row.mjs:190/:196 calls renderer and extraCls with column.rendererScope || column
src/table/Body.mjs:175 the Table sibling defaults to tableContainer, never the column
src/table/header/Button.mjs:228 discards .scope the same way; Table's container default hides it

The Fix

  • grid.column.Base#beforeSetRenderer keeps the scope it resolved. When resolveCallback returns a scope that is not the column and rendererScope is unset, the column records it, so an up. renderer runs on the instance that defines it — the whole point of up..
  • grid.Row#applyRendererOutput defaults to the grid Container, not the column, for both renderer and extraCls, restoring #6620's contract in today's topology and matching table/Body.mjs:175.
  • rendererScope: 'me' | 'this' keeps meaning the column.
  • Anchor & Echo JSDoc on rendererScope and beforeSetRenderer stating which scope wins, in which order.

Contract Ledger

Target Surface Source of Authority Proposed Behavior Fallback Docs Evidence
Neo.grid.column.Base#renderer_ string resolution src/grid/column/Base.mjs:219 an up. name keeps the ancestor resolveCallback found as its scope a name found on the column keeps the column, as today config JSDoc unit arm, red on dev
Neo.grid.column.Base#rendererScope src/grid/column/Base.mjs:81 explicit config still wins over everything; 'me'/'this' still means the column unchanged config JSDoc unit arm
Neo.grid.Row#applyRendererOutput default scope src/grid/Row.mjs:190, :196 the grid Container, as table/Body.mjs:175 does a column with rendererScope is unaffected method JSDoc unit arm + the example arm

Acceptance Criteria

  • AC-1 — a grid column with renderer: 'up.someMethod', where someMethod lives on an ancestor, invokes it with that ancestor as this (red on dev).
  • AC-2 — a grid column with a plain function renderer and no rendererScope invokes it with the grid Container as this (red on dev).
  • AC-3 — rendererScope: 'me' and 'this' still invoke with the column, and the default cellRenderer still resolves on the column: grid.column.Component keeps rendering.
  • AC-4 — examples/grid/cellEditing renders its rows with no page error and no App Worker error (red on dev: 0 cells, this.getStateProvider is not a function).
  • AC-5 — a function cellCls follows the same scope rule as renderer (src/grid/Row.mjs:196 applies the same default).
  • AC-6 — coverage CI executes: unit arms for AC-1, 2, 3, 5 and a components-tier arm for AC-4.

Out of Scope

table.header.Button#beforeSetRenderer, which drops the same scope — Table's container default hides it, and Table is a separate control. Recorded here; file it separately if a Table renderer needs its owner. · The cell-editing feature itself (#18762, #18764). · Any renderer API beyond scope resolution.

Avoided Traps

  • Defaulting to the Row. The Row is a pooled projection; a renderer holding it would hold a slot, not the grid.
  • Only changing the Row default. It fixes this example by accident — the container happens to have getStateProvider — and still runs an ancestor's method with the wrong this the moment the method touches its own members.
  • Resolving the string per render. beforeSetRenderer resolves once at config time; per-render resolution would put a parent-chain walk in the pooled render path.

Related

#6620 (the regressed fix) · #6655 (string-based renderer resolution) · #8970 (the Row-pooling refactor that reintroduced the default) · #18762 · #18764 · #286 / #287 (the Table precedent)

Live latest-open sweep: latest 20 open issues at 2026-09-16T09:57Z; no equivalent. A2A claim sweep: latest 30 inbox messages, all read-states, at 2026-09-16T09:57Z; no claim on grid renderers. Memory Core rationale sweep: query_raw_memories on the symptom nouns; the hits are the 2026-03 Row performance refactors, no decision on renderer scope. Closed-issue sweep: rendererScope/"renderer scope" across all states surfaced #6620, #286, #287, #321, all closed. Own-assignment sweep: 9 open, none on grid renderers.

Origin Session ID: a758f0e1-0dc1-4e4e-a975-4824a9319487

Retrieval Hint: "grid column renderer up. string resolves the function but loses resolveCallback's scope; Row.applyRendererOutput defaults to the column since the Row-pooling refactor"

tobiu closed this issue on Sep 16, 2026, 3:28 PM
tobiu referenced in commit 5b5055b - "fix(grid): a column renderer runs on the instance that owns it, not on the column (#18771) (#18773) on Sep 16, 2026, 3:28 PM