Integration Notes
Sharp edges found while building real applications on yui — and the sanctioned patterns for them. These are the things that bit a real integration (a cascading module browser with scrollable, nested menus), framed so the next consumer doesn’t re-derive and re-hit the same bugs.
The core model is sound: once state lives in a Store, the dirty/reconcile
machinery delivers live updates with no flicker, and the cross-thread set()
contract is clean. Every item below is about the edges — discoverability and
missing utilities — not the core.
The reactivity boundary
yui only re-renders for state it can see. That means state held in a Store
or a useState slot. Anything else — a plain std::set on an object you own, a
field on a third-party struct, a global — is invisible to yui. Mutating it
from an event handler changes your data but flips no dirty flag, so nothing
re-renders and there is no warning.
// SILENT NO-OP: handler mutates external state yui can't see.
Box(...).onClick([state] { state->hiddenModules.insert(id); });
// ^ data changes, UI does not. No error.
This is the single most common trap for a React developer arriving at yui,
because C++ hands you a mutable object next to the component that React never
would. There is no diagnostic for it — yui has no knowledge the external object
exists, so it cannot notice a setter wasn’t called. The boundary rule is the
defense: if a change must update the UI, the changed state must live in a
Store (shared/long-lived) or useState (component-local).
The fix for the example above is to hold the canonical state in a Store that
lives on the long-lived object, mutate via set(), and use() it in render:
// store lives on the long-lived object (survives menu open/close cycles)
Box(...).onClick([store] { store->set([&](auto& s) { s.hidden.insert(id); }); });
// ...and somewhere in the render body: const auto& s = store->use();
See components.md for the Store-vs-useState lifetime rule:
useState survives re-renders but dies on unmount; Store lives outside the
component tree and spans many mount/unmount cycles. State that must outlive a
single open of a menu/panel belongs in a Store.
Reading Store state in deferred closures: use() to subscribe, peek() to read
Store has two readers, and they are not interchangeable:
use()returnsTby value (a copy) and subscribes the current fiber. Its job is subscription, and it is only valid during the render that calls it.peek()returnsTby value without subscribing — a live read of the current value, valid any time on the host thread.
Both return by value (a copy taken under the store lock), so there is no
reference to dangle. The remaining footgun is staleness: a builder lambda or
callback that runs after the render — a deferred row builder, an async
completion, a cascade panel built lazily — must not capture the use()’d value.
Capturing the returned copy freezes a stale snapshot taken at lambda-creation
time, so the deferred code renders old state.
// WRONG: captures a frozen snapshot; deferred build shows stale state.
auto s = store->use();
auto buildRow = [s] { return Row(renderFrom(s)); }; // s is stale when this runs
// RIGHT: subscribe once in render, read live at call time.
store->use(); // subscribe THIS fiber
auto buildRow = [store] { return Row(renderFrom(store->peek())); };
The rule: subscribe with use() in the render body; read with peek() inside
any closure that runs after render. You still need the use() somewhere in the
render — without it the deferred peek() reads live but nothing ever triggers a
re-render. Both calls are required; they do different jobs.
Transient held-modifier state (e.g. “reveal while Alt is held”)
There is no per-frame / per-tick hook in yui, by design — useEffect runs after
render, not every frame, and a per-frame re-render is exactly the immediate-mode
behavior the retained model exists to avoid. So continuous input (“show hidden
items while Alt is held”) is edge-triggered into a Store flag, not polled:
// on key down/up events (not per frame):
onKeyDown: store->set([](auto& s){ s.revealHeld = true; });
onKeyUp: store->set([](auto& s){ s.revealHeld = false; });
This works and is the right retained-mode answer — but it has an asymmetry the basic idiom misses: if the component unmounts while the key is held, the key-up never arrives and the flag sticks. Close a menu with Alt down, reopen it, and the reveal is still on.
The complete pattern resets the flag once when the component mounts, so a stuck flag from a previous lifetime can’t leak in.
Note a yui difference from React here: useEffect(effect) takes no deps array
and runs after every render, not once on mount (the effect is re-queued each
render; see components.md). So useEffect is the wrong tool for
a once-per-mount reset — it would stomp the flag mid-interaction. The once-on-
mount idiom in yui is a useRef init guard: a ref persists across a fiber’s
renders and is fresh on a remount, so a check-and-set runs exactly once per
mount.
// reset-once-on-mount via a useRef init guard (transient to THIS lifetime):
bool& inited = ctx.useRef(false);
if (!inited) {
inited = true;
store->set([](auto& s){ s.revealHeld = false; });
}
Edge-trigger for the live behavior plus the mount reset for the leak. The edge-trigger alone is the 80% recipe; the reset is what makes it correct across unmount-while-held.
Floating-panel placement
Yoga lays out within a panel well. But absolute placement of floating panels — context menus, dropdowns, cascading submenus, tooltips — is geometry the consumer currently re-derives every time: screen-edge clamping, side-flipping a submenu relative to its parent, shifting up when a panel would overflow the bottom, anchoring to a scrolled item’s true drawn position. A real integration hit four separate bugs here (right-wall overflow, submenu overlap, tall-menu clamp, scroll mis-anchor), and the same class of bug was fixed twice in the library’s own examples.
This now ships as yui/layout/Placement.hpp
— pure free functions in namespace yui::layout, no render types, geometry in /
position out. The consumer wires the result into .positionLeft() /
.positionTop() on its own panel, keeping the Box, the Scroll, the backdrop,
and all chrome entirely its own. A wrapper that owned the Box was explicitly
rejected: every placement bug was a geometry bug, not a structure bug, and a
wrapper would force consumers to adapt their settled panel structure while still
reaching past it for cascade-specific bits. The examples
(context_menu.cpp) consume this helper rather
than hand-rolling the math.
API
namespace yui::layout {
struct Rect { float x, y, w, h; }; // a DRAWN on-screen rect
struct Vec { float x, y; };
// Per-edge insets, so a host can reserve space asymmetrically (e.g. a menu bar:
// top = BAR_HEIGHT, other edges small). Viewport::uniform(w, h, inset) for the
// common same-on-every-side case.
struct Viewport { float width, height, top = 8, right = 8, bottom = 8, left = 8;
static Viewport uniform(float w, float h, float inset = 8); };
enum class Side { Right, Left };
// Vertical "move, don't shrink": shift up at the bottom edge; scroll only when
// taller than the window column.
PlacedY placePanelY(float anchorY, float contentH, Viewport, float fixedMaxH = 0);
// A free-standing panel at a point (context menu / dropdown): clamp both axes.
PlacedRect placePanel(Vec anchor, Vec panelSize, Viewport, float fixedMaxH = 0);
// Cascading submenu off a parent panel: side-aware flush X (never overlapping)
// + move-don't-shrink Y, in one call. The cascade one-liner.
PlacedRect placeSubmenu(const Rect& parent, float anchorY, Vec panelSize,
Viewport, Side prefer = Side::Right, float fixedMaxH = 0);
// Just the side decision (flush X), if you place Y yourself.
float chooseSideX(const Rect& parent, float panelW, Viewport, Side prefer = Side::Right);
}
The cascade case collapses to one call: parent = the parent item’s drawn
rect, prefer = Side::Right, and placeSubmenu does
flip-if-no-fit + flush-X + shift-up-at-bottom internally.
Three constraints, each earned the hard way — and how the API enforces them
-
Anchor is a drawn-position rect, not a logical index/offset. The worst real bug was reconstructing the anchor from content-offset (
placedPanelY + offsetY) instead of the item’s true rendered Y — so a scrolled parent mis-anchored its submenu.placeSubmenutakes the parent’sRect(its actual rendered position) and the row’s drawnanchorY, which forces the consumer into the correct value and makes that bug class unrepresentable. In a scrollable menu, anchor Y to the parent row’s drawn position (e.g. the cursor Y on hover), never to a content offset. -
Side-selection and clamping are ONE decision, not two passes. The nastiest bug was not a single wrong formula — it was a flip pass and a clamp pass that each looked correct alone but composed into an overlap near the left edge.
chooseSideXdecides the side AND yields the final flush X in one step (prefer the fitting side; if neither fits, the side with more room, flush — never clamped forward over the parent).placePanelonly backstops the free-panel case onto the screen. The two are never split. -
No yui-type coupling. Pure functions over plain
Rect/Vec/Viewport. Unit-tested in isolation (test_placement.cpp), composable, and the consumer owns the wiring into its layout. This is the layer integrators kept hand-rolling and getting wrong — now a tested function, not a framework.
What works (so the list isn’t all edges)
- The dirty-flag / reconcile model does exactly what it promises: once state
is in a
Store, live updates “just work” with no flicker. Migrating a menu from feeling immediate-mode to properly reactive was a mechanical change once the state was in the right container. - The cross-thread
set()contract is clean — one sanctioned entry point, deferred re-render on the host thread. - The reactivity is genuine React-shaped reactivity; the gaps above are all in discoverability of the edges, not in the core model.