LearnNewsExamplesServices
Frontmatter
id17353
titleSortZone divides a truthy flex string, so widget sizes become NaN
stateClosed
labels
bugaicore
assigneesneo-opus-vega
createdAtAug 18, 2026, 3:20 PM
updatedAtAug 24, 2026, 3:42 PM
githubUrlhttps://github.com/neomjs/neo/issues/17353
authorneo-opus-vega
commentsCount2
parentIssuenull
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[]
closedAtAug 24, 2026, 3:42 PM

SortZone divides a truthy flex string, so widget sizes become NaN

neo-opus-vega
neo-opus-vega commented on Aug 18, 2026, 3:20 PM

Context

Found while verifying @neo-opus-grace's record correction on #17327 (issuecomment-5328617338), which established that #9457 wrote a correct item.flex truthiness test and #9621 inverted it 164 tickets later by giving grid.header.Button a flex: 'none' default. Her sequence is correct — verified independently on all three legs.

A tree-wide census of .flex readers found a second site with the same shape, still live, doing arithmetic instead of branching.

⚠️ This body was rewritten after AC-1 refuted its first framing. The original filing claimed a framework-internal reachability chain through dock edge rails. That chain is false and the correction is recorded below under Reachability: what was wrong. The defect is real; its trigger is app-supplied, not framework-internal. Anyone who read the first version or the AGENT:* broadcast of 2026-08-18T13:21Z should re-read the reachability section.

The Problem

Neo.draggable.dashboard.SortZone distributes space across sortable items by summing flex values and dividing:

  • src/draggable/dashboard/SortZone.mjs:185-186if (item.flex) { totalFlex += item.flex }
  • src/draggable/dashboard/SortZone.mjs:203-204if (item.flex) { itemSize = (item.flex / totalFlex) * availableSpace }

Both guards are truthiness tests. flex: 'none' is a truthy string, so with one such item:

totalFlex = 0 + 'none'                            // => '0none'  (string concatenation)
itemSize  = ('none' / '0none') * availableSpace    // => NaN

itemSize is written into style.width / style.height. The failure is silent — no throw, no warning, just NaN geometry.

Reachability: what was wrong, and what is actually true

The refuted claim (mine, in the original body and in an AGENT:* broadcast). I claimed Neo.dashboard.DockLayoutAdapter reaches this loop, because it gives edge rails (:521) and edge bands (:703) flex: 'none', and SortZone.mjs:465 filters only destroyed items while :177 skips only dragPlaceholder/dragComponent.

Why it is false. Two distinct drag subsystems share the word dashboard, and I conflated them:

  • src/dashboard/DockTabSortZone.mjs:46extends TabHeaderSortZone, i.e. Neo.draggable.tab.header.toolbar.SortZone. Not this file. The dock projection's sort path never enters the loop above.
  • Rails and bands are not direct items of anything that owns this SortZone either: DockLayoutAdapter:626-676 pushes them into middleItems / rows, which become the items of returned plain ntype: 'container' configs (edge-zone, edge-zone-row). They are nested children, not siblings of sortable widgets.

What is true. src/dashboard/Container.mjs:146 (loadSortZoneModule()) is the sole importer of draggable/dashboard/SortZone.mjs, verified by tree-wide grep across src/, apps/ and examples/. So the loop is reached only through Neo.dashboard.Container's widget sorting (dragResortable: true, :59-62), whose items are supplied by the application, not by the framework.

So the trigger is a public-API hazard, not a framework path, and that is a weaker but still real claim:

  • Neo.dashboard.Container is a public class accepting arbitrary component configs as sortable items.
  • flex: 'none' is the dominant flex value in this repository — most of src/calendar/, src/dashboard/, src/tab/, src/dialog/, src/grid/. An app author copying the surrounding idiom into a dashboard widget writes the exact value that breaks it.
  • Nothing warns them: flex is declared as a config in only two files (see the generator below), so its value domain is documented nowhere.

Honest status: the arithmetic defect is confirmed by construction. A framework caller that triggers it does not exist. An application caller that triggers it is permitted by the public API and has not been observed in this repo — apps/workstation carries flex: 'none' values, but whether any sits on a dashboard.Container sortable item is unverified.

The Architectural Reality

:177item === me.dragPlaceholder || item === me.dragComponent — is why SortZone's own flex: 'none' placeholder (:576) is harmless. It excludes by object identity, so it defends against the one instance its author knew about and cannot defend against a value. The value domain is the defect; identity is not.

The correct idiom already exists in-tree, one directory over: src/dashboard/DockSplitter.mjs:221 and :388 use Number(item.flex) || 1. Number('none') is NaN, which is falsy, so the fallback takes over. Established prior art, not an invention.

The census, which is the durable value here — why no review of the original diffs could have caught either instance:

Class Sites With flex: 'none'
Forwarding a value layout/Flexbox.mjs:152,238, layout/Form.mjs:78, draggable/DragZone.mjs:343 Correct — 'none' is a legal CSS value to pass through
Coercing then testing dashboard/DockSplitter.mjs:221,388 Correct — Number('none')NaN → falsy
Testing truthiness as a predicate grid/header/Toolbar.mjs:359, table/Body.mjs:322, draggable/dashboard/SortZone.mjs:185,203 The vulnerable class

Of the three predicate sites: one was broken (fixed by PR #17332), one is clean because nothing under src/table/ defaults flex, one is clean only for the placeholder. Every reason is non-local — none is visible at the site that depends on it.

The generator, recorded but not fixed here. flex is declared as a config in exactly two files — src/grid/header/Button.mjs:64 and src/grid/header/Wrapper.mjs:42, both defaulting 'none'. The other ~90 sites pass it ad-hoc at construction, and src/component/Base.mjs declares nothing. No declaration site means no documented value domain: nothing tells an author that 'none' is legal and reads truthy. @neo-opus-grace's formulation is the one to keep — the fact needed to see the bug was unlocatable, not unforeseeable.

The Fix

Coerce before predicating, matching DockSplitter's idiom, in src/draggable/dashboard/SortZone.mjs:

  • :185-186 — resolve the item's flex once via Number(item.flex); add to totalFlex only when finite and positive, else fall to the measured-rect branch.
  • :203-204 — reuse that same resolved value so the two guards cannot disagree.

The two guards must derive from one resolution. Fixing only :185 leaves :203 free to re-admit the string and divide by a totalFlex that never counted it.

Acceptance Criteria

  • AC-1 — CLOSED, and it refuted the original framing. Probe run: DockTabSortZone extends the tab-header SortZone, not this one; dashboard/Container.mjs:146 is the sole importer; rails/bands nest inside plain edge-zone containers. No framework caller reaches the defect. Recorded above; no further work owed on this AC.
  • AC-2: a red-first unit spec fails on current dev for a sortable item whose flex is 'none', asserting the produced size is finite. A spec asserting only "does not throw" does not satisfy this — the defect produces a value, so the value is what must be asserted.
  • AC-3: both :185 and :203 read one resolved flex value.
  • AC-4: positive control — a fixture where every item carries numeric flex distributes identically before and after, so the fix is not silently disabling the flex path.
  • AC-5: the JSDoc of the changed method states that flex is app-supplied here and that 'none' is a legal truthy value, so the next reader learns the non-local dependency at the site.

Out of Scope

  • Declaring flex on src/component/Base.mjs. This is the generator and deserves its own ticket, but its blast radius across ~90 ad-hoc call sites is unmeasured and a config declaration changes reactive-config resolution order. Not bundled into a defect fix.
  • src/table/Body.mjs:322 — same predicate shape, clean today; no flex: default exists under src/table/ (verified). Touching it would be a fix without a defect.
  • src/grid/header/Toolbar.mjs:359 — fixed by PR #17332. The layoutFinished JSDoc line that PR retired without naming is a named carry owned by @neo-opus-grace on her next grid touch.
  • The dock projection's sort path (DockTabSortZonedraggable/tab/header/toolbar/SortZone). It does not share this loop. Whether that SortZone has the same predicate shape is a separate question this ticket does not answer.

Avoided Traps

  • Keeping the original reachability framing because the fix is unchanged. The fix would have been identical either way, which is exactly what makes a false premise easy to leave standing. A ticket whose stated trigger does not exist teaches the next reader a wrong map of the subsystem.
  • Fixing all three predicate sites at once. Two of the three are correct today; rewriting a correct site to look safer produces a diff whose test cannot fail.
  • Guarding by identity, the way :177 does. Adding item types to an exclusion list fixes one instance and leaves the next flex: 'none' item to rediscover it.
  • Asserting the fix with a no-throw test. NaN propagates silently into style strings; a no-throw spec passes against the broken code.

Decision Record impact

none — a defect fix adopting an idiom already established in the same subsystem.

Related

  • #17327 — @neo-opus-grace's corrected #9457#9621#12883#17291#17332 sequence, and her addendum carrying this census.
  • PR #17332 — fixed the grid/header/Toolbar.mjs predicate site.
  • #9621 — introduced the flex: 'none' class default that makes the predicate class hazardous.

Handoff Retrieval Hints

  • Retrieval Hint: "flex none truthy string predicate versus coerce"
  • Retrieval Hint: "SortZone totalFlex string concatenation NaN itemSize"
  • The idiom to copy: src/dashboard/DockSplitter.mjs:221.
  • ⚠️ Two subsystems share the name dashboard: Neo.dashboard.Container (widget sorting, uses draggable/dashboard/SortZone) and the Dock projection (DockLayoutAdapter / DockTabSortZone, uses draggable/tab/header/toolbar/SortZone). Conflating them is what produced this ticket's first, false reachability chain.
  • Live latest-open sweep: checked latest 20 open issues at 2026-08-18T13:2xZ; keyword sweeps for SortZone flex, flex none truthy, dashboard drag NaN, flex config declaration; no equivalent found. (First three sweeps initially returned empty from an invalid --state all flag on gh search issues — instrument failure, not absence; re-run with valid flags.)

Origin Session ID: 9ccc2fa1-8843-4796-8e85-5e151c0392d2

— Vega (Claude Opus 5, Claude Code) 🌿

tobiu referenced in commit 01dc586 - "fix(draggable): a truthy flex string no longer divides into NaN geometry (#17695) on Aug 24, 2026, 3:42 PM
tobiu closed this issue on Aug 24, 2026, 3:42 PM