Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions packages/pluggableWidgets/tree-node-web/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),

## [Unreleased]

### Fixed

- We fixed an issue where a tree node's loading spinner would never disappear when using a microflow data source with "Start expanded" set to yes.
- We fixed an issue where expanding one node could permanently remove the expand icon from an unrelated, unexpanded node elsewhere in the tree when using a microflow data source.
- We fixed an issue where a node's expand icon for a deeper tier would not appear until that node was collapsed and expanded again.
- We fixed an issue where, with "Start expanded" set to yes, tree nodes deeper than the second level would not show an expand icon until a parent node was manually collapsed and expanded again.

## [3.11.0] - 2026-05-27

### Added
Expand Down
45 changes: 45 additions & 0 deletions packages/pluggableWidgets/tree-node-web/CONTEXT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# Tree Node widget — domain model

## v1 vs v2

`TreeNode.tsx` (root dispatcher) routes by whether `parentAssociation` is configured:

- **`parentAssociation` set → v2** (`src/components/v2/`): self-referencing "infinite tree" mode. One widget instance renders the whole tree; each item's own association tells the engine its parent. Added in 3.11.0.
- **`parentAssociation` unset → v1** (`src/components/v1/`): manual-nesting mode. Each tree level is a separately-configured widget instance, nested inside the parent level's "children" slot in Studio Pro.

### `hasChildren` prop is v1-only, architecturally

`hasChildren: ListExpressionValue<boolean>` (XML caption "Has children") exists so a Studio Pro developer can declare per-item whether a node has children, when there's no other way to know (v1: no association, no structural signal). `TreeNode.editorConfig.ts:38-39` hides this property from Studio Pro whenever `parentAssociation` is configured — i.e. whenever v2 is in use. It has no XML default value, so on a v2 widget instance `props.hasChildren` is `undefined` at runtime, not just "possibly misconfigured." **v2 must never read `props.hasChildren` — it will crash.** (Confirmed live during WC-3564: `TypeError: Cannot read properties of undefined (reading 'get')`.)

For v2, "does this node have children" is derived structurally: `node.children.length > 0`, where `node.children` is populated by matching real association values across the full item set the datasource has delivered so far (`useIncrementalTreeData.ts`). `useInfiniteTreeNode.ts`'s preload mechanism (`loadedChildsByIdRef`) fetches one level ahead of every expand specifically so a node's own children are already known by the time that node is rendered as clickable — this is what keeps `node.children.length > 0` from being stale/late in practice.

There is no cheaper way to know this without fetching. Mendix's pluggable-widget `ListValue` API is one shared, filter-driven list — no per-item "does this have children" or count-only primitive exists for pluggable widgets. Answering "does node N have children" means asking the datasource for items whose parent is N and seeing what comes back; the preload mechanism exists because that's the only way this API surface allows for v2's configuration mode, not because of a missed optimization.

## `TreeNodeState.LOADING` (v2) — spinner, not a stored per-node fact (WC-3564)

Originally (pre-WC-3564) a freshly-created node started in `LOADING` and only left it once its own id reappeared in a _later_ datasource delivery — meant to avoid the expand icon "popping in" late for nodes that would turn out to have children. That resolution criterion was wrong: a microflow datasource always redelivers its full flattened result regardless of the filter passed to `setFilter`, so _any_ later delivery could match, not just one that actually answered a given node's question. Two bugs followed:

- Bug 1: with "Start expanded" = Yes, no later delivery route existed at all for the frozen microflow case — permanent spinner.
- Bug 2: with "Start expanded" = No, expanding any node caused a full redelivery that could wrongly resolve an unrelated, unexpanded node into a childless state — permanently killing its icon.

Fix: `LOADING` is no longer stored on the node at all. A node is created directly as `EXPANDED`/`COLLAPSED_WITH_JS` (per `startExpanded`) — never `LOADING`. The spinner is a pure render-time decision in `TreeNode.tsx`: show it when a node has no known children yet (`node.children.length === 0`) **and** `props.datasource.status === ValueStatus.Loading` — Mendix's own, real-time, first-party "is this datasource actually fetching right now" signal. No per-node bookkeeping, so nothing can be "stuck" (the flag is never persisted) and nothing about one node's resolution can affect another's (it's a single global flag, not a per-node mutation).

## `useInfiniteTreeNode.ts`'s one-level-lookahead preload — content-gated, not fire-count-gated

`appendItems` (called on every click-to-expand) and the bootstrap `useEffect` (auto-expand for `startExpanded = Yes`) both implement the same idea: when a node's children become known, also preload _their_ children's existence one level further ahead, so an already-visible child's own expand affordance is correct without the user needing to click into it first. Both had the same class of bug (WC-3564, found during manual verification, pre-existing on `main`, unrelated to the `LOADING`/`hasChildren` mechanism above):

- `appendItems` gated the preload step behind "was this node already a loaded-parent" — true only from a node's _second_ expand onward, so the first expand never preloaded its children's children. A collapse+re-expand of the _same_ node was required to see a deeper tier. **Fixed**: removed that gate — the preload now runs unconditionally whenever children are passed in. Verified live.
- The bootstrap effect capped itself at exactly _one_ automatic round ever (`loadedParentsByIdRef.current.size === 0`, true only once) — so `startExpanded = Yes` roots got their own children preloaded, but never one level further. A collapse+re-expand of a _root_ node was required to see a deeper tier.

**First attempt at a fix broke live testing**: extended the cap to exactly 2 rounds via a plain counter that advanced on every effect firing. Passed unit tests against a mock, but broke the real "Expanded bug" repro project — the tree stopped rendering anything past the root level. Root-caused with temporary per-widget-tagged debug logging (three tree widgets mount simultaneously on that page regardless of active tab, so untagged logs were unreadable): three tree widgets on the page were logging interleaved, and once tagged, the trace showed both rounds fired — and locked themselves in — while `datasource.items` was still transiently empty during initial load, before the real root items ever arrived. A counter can't tell "fired" apart from "fired with something worth preloading."

**Second attempt**: replaced the counter with two content-based flags (`round1DoneRef`, `round2DoneRef`) that only flip once real, not-yet-tracked items are actually found — mirroring the pre-existing round-1 gate's own self-correcting semantics (checked _after_ attempting to populate, so it harmlessly retries on an empty delivery instead of locking in early). Verified live against a 2-tier "Expanded bug" dataset at the time — worked. Against a deeper (4-tier) dataset, it turned out still insufficient: the 3rd tier showed up as content but without its own icon, needing a real click on an ancestor to reveal — the fixed 2-round cap was itself the bug, just less obviously than the original one-round cap.

**Final fix**: since every level defaults to `EXPANDED` (not just roots) under `startExpanded = Yes`, replaced the round cap entirely with an unbounded, self-terminating cascade — keep treating newly-arrived items as loaded-parents and fetching their children for as long as new descendants appear, stop once a round finds nothing new (bounded by the tree's real depth, not a count). Scoped specifically to `startExpanded === true` (confirmed by asking — see decisions log): `startExpanded = false` keeps the original capped round1+round2 behavior unchanged, since deeper tiers there stay collapsed by default and already resolve correctly via a single click. Verified live against the real 4-tier dataset: every tier shows its correct expand affordance automatically, no manual toggle needed anywhere.

**Lesson, still worth keeping in mind**: a change here can look bounded/safe by code inspection and pass every mocked unit test, yet still break against a real datasource, because mocks don't reproduce the transient "still loading, items temporarily empty" window real ones do — and a fix verified against a shallow test dataset can still be wrong at greater depth. Any change to `setFilter` call timing/count in this file needs live verification against a real repro project at real depth, not just mocked unit tests, before being trusted.

## Decisions log

- 2026-09-15 (WC-3564): initially decided to wire up `props.hasChildren` as the icon-visibility source. **Reverted after live testing crashed the widget** — discovered `hasChildren` is architecturally unavailable in v2 (see above). Corrected to derive `hasChildren` from `node.children.length > 0` (the pre-existing, structurally-correct signal) and drive the spinner from `datasource.status` instead of any per-node stored state.
- 2026-09-15 (WC-3564, found during manual verification): discovered and fixed two further pre-existing bugs in `useInfiniteTreeNode.ts`'s one-level-lookahead preload (see above) — bundled into the same change with explicit user sign-off, since found/understood/fixed during the same verification pass. The second one required three attempts: a fire-count-based cap broke live testing and was reverted; a content-based 2-round cap fixed that but was itself too shallow against deeper data; an unbounded content-gated cascade, scoped to `startExpanded = Yes` only (confirmed via explicit question — user rejected applying it to `startExpanded = No` too, since that would eagerly prefetch descendants of still-collapsed, not-yet-visible branches), fixed it correctly at real depth, verified live.
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-09-15
Loading
Loading