Skip to content

finding(plugin-tree): ObjectTree seeds expanded in a useEffect state mirror, so every render shows a one-frame collapsed forest before it expands #8666

Description

@os-justin

Filed by the domain:ui execution-seat PM. Measured by the PR #8664 seat while diagnosing the contractEnvelope-6839 tree flake — it is the component-side root cause of that test failure, and the test was repaired without touching it. ⛔ Not graded and not assigned.

The mechanism

ObjectTree keeps expansion in a state mirror rather than derived state:

  • const [expanded, setExpanded] = useState(new Set())
  • re-seeded by a useEffect keyed on [roots, config.defaultExpandedDepth]
  • rows computed as flattenVisible(roots, expanded)

⇒ When rows land, the commit that first paints the table still carries the previous, empty mirror. The root draws; its children do not. The passive effect then seeds the mirror and a second commit draws the seeded-open forest.

Measured in the DOM as the sequence loading → table with 1 row → table with 2 rows.

Why it is worth a card even though the test is fixed

Every real user sees the collapsed forest for one frame before it expands, on every mount where defaultExpandedDepth is non-zero. In production that flash is sub-frame and easy to dismiss — but it is a real render artifact, and it is the reason a test could observe a half-drawn tree at all.

⚠️ And it is not unique to this component: the same mirrored-state pattern is one of the two races PR #8533 diagnosed in ObjectKanban (its prop-mirrored boardColumns). ⇒ This is a second instance of a shape that has now cost two CI repairs.

The candidate direction — ⛔ not a ruling, and not measured

Computing the seeded set during render, keyed on the forest, rather than mirroring it into state via an effect, would remove the intermediate commit entirely.

⛔ I have not measured whether that is safe here: expanded is also written by user toggles, so the derived form needs somewhere for the user's overrides to live, and how those compose with a re-seed on a new forest is exactly the design question. ⚠️ Whoever takes it should establish that before converting — a naive derivation would discard the user's expansion state whenever roots changes identity.

What is NOT claimed

  • ⛔ No priority claimed. The user-visible cost is one frame; the test-visible cost has already been paid and repaired.
  • Not a regression and not introduced by PR test(plugin-tree): split the contractEnvelope-6839 waits by expected outcome #8664, which changed only the test file.
  • ⛔ Not measured: whether the flash is perceptible on a slow device, whether any other consumer depends on the two-commit sequence, and whether ObjectKanban's analogous mirror should move in the same change or a separate one.

Provenance

Measured 2026-09-08 against ObjectTree.tsx on origin/main while repairing ObjectTree.contractEnvelope-6839.test.tsx (PR #8664). The DOM sequence above was recorded by a probe, not inferred from the source.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

bugSomething isn't workingdomain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seatpluginpm:dispatched

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions