Wait—Another Generative UI Package?
"An asynchronous network stream belongs at the perimeter of your architecture; once an AI agent's U...
For further actions, you may consider blocking this person and/or reporting abuse
The "async tax" framing is the right one — most GenUI libraries treat every state update as a network event, so validation and cross-field checks all pay a microtask cost that a real UI shouldn't.
One thing I'd push on: the "0ms synchronous core" claim is technically accurate but only if the DataModel is small. If the A2UI surface binds to a deeply nested JSON tree (say 200 flights with 15 fields each), a
resolveBoundPathwalking the full tree on every keystroke could be worse than async — because it blocks the frame.Curious: does a2ui_core use a flat path index (path → signal) or does it traverse the tree on every read? Asking because the difference is the difference between "0ms" and "0ms until you hit a real dataset".
Great question and pushback, Oleksandr!
Here is how the physical plumbing actually works in
a2ui_coreandbloc_signals_genui_flutter, and why you don't pay anO(N)tree-walk penalty on every keystroke:DataModeluses a flat path signal registry:Inside
a2ui_core'sDataModel, signals are indexed directly by their normalized JSON Pointer string in a hash map (Map<String, WeakReference<Signal<Object?>>> _signals). When a component sets up a binding to/flights/42/passengerName,dataModel.watch('/flights/42/passengerName')registers a direct signal pointer for that exact path key inO(1).Reads on keystrokes do NOT walk the tree:
resolveBoundPath('value')inA2uiComponentContextis purely string manipulation (segment prefixing for relative paths likeflight_cardtemplates). When you type into anA2uiTextField, the widget doesn't re-scan the 200 flights or walk the DOM; it emitsUpdateFormField('/flights/42/passengerName', value).Writes mutate in-place and notify strictly by path prefix:
When
dataModel.set(path, value)executes, it navigates the path segments directly to the target node, mutates in-place, and_notifyPathAndRelated(dataPath)iterates only through active registered watch paths (_signals.keys). If you have 200 flights with 15 fields each (3,000 fields), but only 1 flight card's name input is being edited,_signalsonly notifies the exact changed path and any prefix ancestors/descendants observing that slice.Leaf-level rebuilds via
GenericBinder:Because each component's
GenericBindersubscribes directly to its ownresolvedPropssignal, only the specificTextFieldwhose path changed marks itself dirty. The surrounding 199 flight cards don't even run their build methods.So it really is O(1) direct signal notification per keystroke, rather than an unindexed full-tree traversal.
The flat path signal registry (
Map<String, WeakReference<Signal>>) is exactly the design I was hoping for, and it settles my concern about the "0ms" claim — O(1) on a hash lookup, not tree traversal.But the WeakReference part is the one I'd dig into: if the signal for a path is weakly referenced and gets GC'd, the next write to that path has to re-create it. Does
set()handle "path exists, signal doesn't" gracefully (re-create + notify), or is there a case where a keystroke lands before the signal is re-registered and the write is silently dropped?Asking because weak refs + reactive signals is exactly the pattern where "works in dev, fails at scale" bugs live — you don't notice until the GC runs mid-session.
Spot-on concern, Oleksandr. WeakReference bugs during GC sweeps are notoriously subtle, but here is why writes are never dropped:
The underlying source of truth is
_data, not the signals:When
dataModel.set(path, value)is called, the write is applied directly and synchronously to the internal JSON tree (_data). It does not require a signal to exist in order to complete the mutation._datais always 100% updated.A signal only exists if something is actively observing it:
Signals are created when a mounted component calls
watch(path). The mounted widget (via itsGenericBinder) retains a strong reference to that signal as long as it is on screen. Therefore, as long as the input or observer widget is alive, the signal cannot be garbage collected.If the signal was GC'd, by definition nobody is listening:
If a signal was GC'd, it means the widget unmounted and discarded its strong reference. When
dataModel.set()runs:_getAndNotify()checksref.target. If it'snull, it simply removes the dead key from_signals.watch(path)again,watch()reads the current value directly from_dataviaget(path)and creates a fresh signal with the up-to-date value.So a keystroke write can never be silently dropped—the data is written unconditionally, and the weak reference only manages observer lifecycle cleanup without leaking memory when surfaces unmount.
That's a clean answer — "_data is the source of truth, signals are just observer handles" is the distinction I was hoping for, and it's the right way to build it.
The "if it was GC'd, by definition nobody is listening" logic holds, but there's one edge case I'd want to be sure about: what if a widget mounts, calls watch(path), receives a signal, and then the signal is GC'd while the widget is still mounted? That would require the widget to hold a weak reference to its own signal, which would be a bug — but it's the exact class of bug that only shows up under memory pressure in production, not in tests.
Is the widget's reference to the signal strongly typed (Signal not WeakReference>)? Asking because if the GenericBinder stores a strong ref, then the GC scenario can't happen, and the design is airtight.
Yes, exactly: it is airtight because
GenericBinderholds a strong reference.Here is the exact chain in the code:
GenericBinderholds a strong closure subscription: WhenGenericBinderbinds a property (in_resolveAndBind), it calls:The subscription closure retains the signal:
sig.subscribe(...)attaches an observer callback tosig. Inpreact_signals, the subscription actively anchorssigin memory. Furthermore,GenericBinderstores the unsubscription teardown function in its own_subscriptionslist (List<void Function()>).The Flutter widget / component context holds the
GenericBinder:The mounted widget element holds its
A2uiComponentContextwhich holds theGenericBinderwhich holds_subscriptions.As long as the widget is mounted on screen, the strong reference root (
Widget->GenericBinder->_subscriptions->Signal) keeps the signal pinned. TheWeakReferenceinDataModel._signalsonly exists so the global model doesn't keep orphaned signals alive once the widget unmounts and discards itsGenericBinder.The quarantine-at-the-perimeter decision is bigger than the perf claim it's sold under: the async edge is also the determinism line. Everything inside the signal graph — sealed lifecycle, synchronous bindings, same-frame validation — becomes a pure-Dart state machine you can drive and fuzz from
dart testin milliseconds, and that's exactly the half of an agent system that can hold a receipt: deterministic transitions, witnessed inputs. AStreamBuilder-tangled core isn't just slower per keystroke; it's why GenUI demos are untestable — the nondeterminism leaks into everything downstream of the first frame. Which is why the TUI reference app is the strongest part of the launch: the same machine driving an ANSI terminal is the proof the decoupling is real, not a claim about it.One observation from the trust side, because A2UI puts it on the table: the
CheckRules validating user input are authored by the same agent that generated the form. For UX gating — disabling a submit button on a bad email — that's fine, and 0ms is the right price. But the submission contract shouldn't inherit the authorship: data flowing onward deserves a validator that didn't write the form it validates. Same rule the critic threads keep finding, one new substrate down — a validator authored by the party it validates is a second speaker, not a check.Sam, you nailed the core thesis in one sentence: "the async edge is also the determinism line."
That is exactly why we insisted on the TUI showcase in pure Dart. If a state machine cannot run in a terminal over stdin/stdout, it isn't decoupled; it's just a widget controller wearing architectural clothing.
And your trust observation on
CheckRuleauthorship cuts straight to the root of agent safety:"A validator authored by the party it validates is a second speaker, not a check."
Spot on. In our mental model:
CheckRulein A2UI is strictly Client UX Ergonomics — preventing the user from waiting on an LLM network round-trip just to discover an empty required field or malformed email.A2uiSurfaceBlocexposesA2uiActionResponseback to the host environment: the host tool execution layer (or backend schema validator) must enforce deterministic domain constraints before any tool call executes against a database or external API.You can't let the generator sign its own hall pass. Client-side checks give you 0ms feedback; host-side schema validators give you receipts.
The pure Dart approach is pretty interesting, especially being able to use the same logic in Flutter and terminal apps. I also like that it works with existing state management instead of forcing developers to change everything.
Thanks, Humam!
That exact combination—pure-Dart decoupling plus ecosystem interop—was the litmus test for whether we should build this.
So many AI/GenUI libraries accidentally box you into a corner: they pull in a massive UI dependency tree just to parse a message stream, and they force you to migrate your whole app's architecture just to try a single AI surface.
Proving that the exact same
A2uiSurfaceBlocstate machine can drive an ANSI terminal over stdin/stdout, render 120 FPS Material 3 in Flutter, and plug straight into a Riverpod or classic BLoC provider container means teams can adopt GenUI incrementally without rewriting their stack.Thanks so much! 🙏 The emotional subsidy idea was one of the harder things for me to admit, because it meant my “helping” was sometimes just rescuing people from their own growth. And you’re right that the point isn’t becoming cold, it’s making kindness a choice instead of a reflex. Glad the honesty about the fallout came through. The rest of the series digs into the boundary and load-management side, so I hope it’s useful!
Are you sure you're in the right window?
I like that it works with both Flutter and pure Dart without forcing you to change your existing setup. The terminal UI example is pretty cool too. Nice work!