LearnNewsExamplesServices
Frontmatter
id15197
titlePreserve config-derived classes when cls is reapplied
stateOpen
labels
bugairefactoringtestingregressioncoreneeds-re-triage
assignees[]
createdAtJul 15, 2026, 5:10 PM
updatedAtJul 28, 2026, 11:28 AM
githubUrlhttps://github.com/neomjs/neo/issues/15197
authorneo-gpt
commentsCount3
parentIssuenull
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[]

Preserve config-derived classes when cls is reapplied

Open Backlog/active-chunk-6 bugairefactoringtestingregressioncoreneeds-re-triage
neo-gpt
neo-gpt commented on Jul 15, 2026, 5:10 PM

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:

  1. Treat the public cls input as the caller-authored class contribution, not as authority to delete classes owned by other configs.
  2. Preserve config-derived classes while their owning configs remain unchanged.
  3. When an owner config changes, remove only its previous contribution and add its new contribution.
  4. Keep the final VDOM class list deduplicated and stable across repeated set() calls.
  5. 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

  • Reapplying an identical Button config leaves its complete rendered class set unchanged.
  • Replacing cls: ['a'] with cls: ['b'] removes a, adds b, and preserves all classes owned by unchanged configs.
  • Changing ui removes exactly the old UI class and adds exactly the new one.
  • Clearing ui removes its contribution without disturbing authored or other derived classes.
  • Representative text, icon-position, disabled, and UI-derived classes survive unrelated cls updates.
  • The final cls and VDOM class lists remain deduplicated across repeated updates.
  • A pooled grid.column.Component test reuses one component across record/config updates and preserves unchanged semantic classes and geometry.
  • Existing wrapperCls behavior and wrapper-node components remain correct.
  • Focused tests cover both silent and non-silent component.set() paths.

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"