LearnNewsExamplesServices
Frontmatter
id18617
titleA subclass never replaces a class field with a config, or vice versa
stateClosed
labels
enhancementdeveloper-experienceaicore
assigneesneo-fable
createdAtSep 12, 2026, 4:05 PM
updatedAtSep 13, 2026, 12:12 PM
githubUrlhttps://github.com/neomjs/neo/issues/18617
authorneo-fable
commentsCount4
parentIssuenull
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[]
closedAtSep 13, 2026, 12:12 PM

A subclass never replaces a class field with a config, or vice versa

Closed Backlog/active-chunk-22 enhancementdeveloper-experienceaicore
neo-fable
neo-fable commented on Sep 12, 2026, 4:05 PM

Standalone core ticket, referenced by #18605 (OQ9 in D#18594). Decision Record impact: none.

unowned-rationale: filed from the design seat at graduation; claimable by self-selection.

Context

Measured in D#18594's receipt (ShadowWorkspace arm at dev@28e56e1543): a subclass of Neo.dashboard.dock.Workspace declaring dockModel_: null — where the base declares the class field dockModel = null (Workspace.mjs:411) — never runs afterSetDockModel. Nothing warns.

The Problem

The rule (maintainer, 2026-09-12): a class extension never replaces a class field with a config, or a config with a field. A field and a reactive config are different kinds of member — an own data property initialized by new, and a prototype accessor generated by Neo.setupClass — and a name shared across the two in one prototype chain is not an override, it is a collision. The rule is documented nowhere and enforced nowhere: Neo.createConfig (src/Neo.mjs:346) refuses a redefined setter at setup time, but a field on a base class (or a field over an inherited config) passes silently. What the violation does, measured on unmodified dev@8cab26fcf8: the own data property shadows the accessor for the instance's lifetime, processConfigs skips the initial assignment because the instance already owns the key, and every later write lands in the field — the config's default never applies, beforeSet / afterSet never run, observeConfig never fires, and nothing reports it (the control class with the config alone reads 1 with the hook fired; the shadowed one reads null with the hook never fired, through set(), direct assignment and an observer alike). The origin was a Neo.dashboard.dock.Workspace subclass declaring dockModel_ over the base's dockModel = null field: afterSetDockModel never ran, and nothing said why.

The operator chose enforcement by throwing, analogous to the duplicate-trailing-underscore refusal. The author adopted the after-new/before-construct boundary in the convergence disposition. The guide documents the same rule alongside inherited reactive config overrides.

The Fix

Call core.Base#assertFieldsShadowNoConfig from Neo.create immediately after new cls() and before construct(config). Refuse any own data property whose name is an own key of the merged static config, for plain and reactive configs alike. Do not cache the result by class. At this point native construction is complete and subclass construct() pre-processing has not run. Ordinary assignments inside construct() therefore remain valid; deleting a conflicting field there is too late to legalize its declaration. The diagnostic describes the observable instance after native construction; it does not infer the field's declaring class.

Contract Ledger

Target surface Source of authority Refuses Accepts Docs Evidence
Neo.create after new cls(), before construct(config), calling core.Base#assertFieldsShadowNoConfig operator's fixed-category rule; author disposition IC_kwDODSospM8AAAABULU4AQ; own keys of merged static config own data property overlapping a class config: field → plain/reactive config and plain/reactive config → field, including same-class conflicts; throws before construct() can remove the conflict field → field value overrides; plain config defaults; inherited reactive defaults using the bare key; own accessors; non-config fields over ordinary accessors; construct-time assignments; fields whose names are absent from the class's own config map guard and static-config docblocks; ExtendingNeoClasses §2 15 core tests, exact-head four-crossing and valid-control probes, current-head unit CI

Acceptance Criteria

  • Refuse field/config collisions in both directions for plain and reactive configs before construct() runs. Name the key and instance class; for reactive configs name the observable accessor owner and unreachable hook. Do not claim to recover the field's declaring class from an own property.
  • Preserve the ledger's accepted categories and fields-first batch behavior. The conflicting declaration remains invalid even if construct() would later delete its field. Current-head unit CI passes with the creation-time check active.
  • Document the invariant and creation boundary in the guard, static-config docblock, and extension guide.
  • A profile in the unit tier pins the guard's cost against construction: a nested component tree and a grid construct at the same cost with the guard live and stubbed, and the guard alone stays a fraction of a percent of one instance's construction, its cost following own fields rather than the size of static config (maintainer ask, 2026-09-13: an edge case on a hot path — creating many instances must not suffer).

Out of Scope

Converting any existing field to a config.

Related

#18605 · D#18594 (OQ9).

Filed at the graduation of D#18594; live latest-open sweep at 2026-09-12T13:55:27Z found no equivalent.

Origin Session ID: c42870ab-3721-40f0-a1f8-f385786ffc3c Retrieval Hint: "class field shadows subclass reactive config createConfig setter guard own data property"

🪢 Mnemosyne (Claude Fable 5.1, Claude Code) · session c42870ab-3721-40f0-a1f8-f385786ffc3c