Context
A runtime UI regression exposed that reapplying an unchanged component config can silently strip classes owned by other unchanged configs. This is especially visible when a pooled grid.column.Component reuses a Button for another record, but the underlying contract belongs to component.Base.
Live latest-open sweep: checked the latest 20 open issues plus targeted component/cls/ui results at 2026-07-15T15:09:57Z; no equivalent issue exists.
A direct probe on current dev reproduced the failure without an application-specific layer:
const button = Neo.create(Button, {
cls: ['probe-action'],
ui : 'ghost'
});
button.set({
cls: ['probe-action'],
ui : 'ghost'
}, true)Before the second set(), the Button classes included probe-action, neo-button, no-text, neo-button-ghost, and icon-left. Afterwards, button.ui was still "ghost", but the class list contained only probe-action and neo-button.
The Problem
cls currently contains both caller-authored classes and framework-derived classes. Reassigning the caller's unchanged cls value therefore looks like a request to remove every derived class that is absent from that input.
The reactive config system does not repair the damage: ui, text, icon position, and other owner configs did not change, so their afterSet hooks do not run again. Semantic state and rendered classes diverge:
ui === "ghost", but neo-button-ghost is absent;
- unchanged text/icon state can lose
no-text and icon-left;
- geometry and visual hierarchy change even though the owning configs did not.
This is not limited to one Button config. Any reusable component that mixes authored cls with config-derived classes can encounter the same ownership collision.
The Architectural Reality
On current dev:
src/grid/column/Component.mjs:117-147 reapplies record-derived configs to a pooled component through component.set(componentConfig, silent).
src/component/Base.mjs:406-424 reconciles cls by removing old classes absent from the new value.
src/component/Base.mjs:802-815 adds and removes the neo-<ntype>-<ui> class only when ui changes.
- Button state hooks contribute additional semantic classes to the same shared list.
The current representation has no durable ownership boundary between authored classes and classes derived from other configs. Fixing each pooled consumer by redundantly listing every derived class would leak framework internals into application configs.
The Fix
Establish ownership-safe class reconciliation in component.Base:
- Treat the public
cls input as the caller-authored class contribution, not as authority to delete classes owned by other configs.
- Preserve config-derived classes while their owning configs remain unchanged.
- When an owner config changes, remove only its previous contribution and add its new contribution.
- Keep the final VDOM class list deduplicated and stable across repeated
set() calls.
- Make pooled component reuse exercise the same contract without consumer-side class duplication.
The exact internal representation may use separate authored/derived sets or another ownership-aware composition, but it must not rely on rerunning every unrelated afterSet hook after each cls assignment.
Contract Ledger
| Target surface |
Source of authority |
Proposed behavior |
Fallback |
Docs |
Evidence |
Public cls config |
Caller-authored component config |
Adds/removes only the caller-owned class contribution |
None |
Clarify component.Base JSDoc |
Reassignment and replacement unit matrix |
| UI-derived class |
ui config |
neo-<ntype>-<ui> remains present until ui changes |
None |
Preserve existing UI contract |
Button probe plus UI-change matrix |
| Other config-derived classes |
Their owning reactive configs |
Remain stable when only cls is reapplied |
None |
Owner-hook comments where needed |
Text/icon/disabled representative tests |
| Pooled component reuse |
grid.column.Component.cellRenderer() |
Reapplying record config is idempotent for unchanged semantic state |
None |
Existing pooling JSDoc |
Real reused component test |
Decision Record impact
None. This is a bounded correction to reactive class ownership and pooled-component reuse.
Acceptance Criteria
Out of Scope
- Theme token or SCSS redesign.
- Removing Grid component pooling.
- Changing the public meaning of
ui values.
- Application-specific CSS workarounds.
Avoided Traps
- No consumer requirement to repeat
neo-<ntype>-<ui> inside cls.
- No forced component recreation or Grid body refresh.
- No blanket rerun of every reactive hook after
cls changes.
- No assertion against config values alone; final VDOM classes are part of the contract.
Retrieval Hint: "component Base cls ownership afterSetCls afterSetUi pooled grid column component neo-button-ghost no-text icon-left"
Context
A runtime UI regression exposed that reapplying an unchanged component config can silently strip classes owned by other unchanged configs. This is especially visible when a pooled
grid.column.Componentreuses a Button for another record, but the underlying contract belongs tocomponent.Base.Live latest-open sweep: checked the latest 20 open issues plus targeted component/cls/ui results at 2026-07-15T15:09:57Z; no equivalent issue exists.
A direct probe on current
devreproduced the failure without an application-specific layer:const button = Neo.create(Button, { cls: ['probe-action'], ui : 'ghost' }); button.set({ cls: ['probe-action'], ui : 'ghost' }, true)Before the second
set(), the Button classes includedprobe-action,neo-button,no-text,neo-button-ghost, andicon-left. Afterwards,button.uiwas still"ghost", but the class list contained onlyprobe-actionandneo-button.The Problem
clscurrently contains both caller-authored classes and framework-derived classes. Reassigning the caller's unchangedclsvalue therefore looks like a request to remove every derived class that is absent from that input.The reactive config system does not repair the damage:
ui, text, icon position, and other owner configs did not change, so theirafterSethooks do not run again. Semantic state and rendered classes diverge:ui === "ghost", butneo-button-ghostis absent;no-textandicon-left;This is not limited to one Button config. Any reusable component that mixes authored
clswith config-derived classes can encounter the same ownership collision.The Architectural Reality
On current
dev:src/grid/column/Component.mjs:117-147reapplies record-derived configs to a pooled component throughcomponent.set(componentConfig, silent).src/component/Base.mjs:406-424reconcilesclsby removing old classes absent from the new value.src/component/Base.mjs:802-815adds and removes theneo-<ntype>-<ui>class only whenuichanges.The current representation has no durable ownership boundary between authored classes and classes derived from other configs. Fixing each pooled consumer by redundantly listing every derived class would leak framework internals into application configs.
The Fix
Establish ownership-safe class reconciliation in
component.Base:clsinput as the caller-authored class contribution, not as authority to delete classes owned by other configs.set()calls.The exact internal representation may use separate authored/derived sets or another ownership-aware composition, but it must not rely on rerunning every unrelated
afterSethook after eachclsassignment.Contract Ledger
clsconfigcomponent.BaseJSDocuiconfigneo-<ntype>-<ui>remains present untiluichangesclsis reappliedgrid.column.Component.cellRenderer()Decision Record impact
None. This is a bounded correction to reactive class ownership and pooled-component reuse.
Acceptance Criteria
cls: ['a']withcls: ['b']removesa, addsb, and preserves all classes owned by unchanged configs.uiremoves exactly the old UI class and adds exactly the new one.uiremoves its contribution without disturbing authored or other derived classes.clsupdates.clsand VDOM class lists remain deduplicated across repeated updates.grid.column.Componenttest reuses one component across record/config updates and preserves unchanged semantic classes and geometry.component.set()paths.Out of Scope
uivalues.Avoided Traps
neo-<ntype>-<ui>insidecls.clschanges.Retrieval Hint: "component Base cls ownership afterSetCls afterSetUi pooled grid column component neo-button-ghost no-text icon-left"