Repository navigation
keymap: keep layers in place when useBindings re-registers them - #1785
Merged
Merged
Conversation
useBindings re-registered its layer on every effect run, as a dispose and a register. Each emitted a state event, so every keymap selector rebuilt the command catalog and re-formatted every binding label, even when the layer had not changed. In OpenCode's prompt this cost about half of each edit's JavaScript. The Solid and React adapters now replace their layer in one batch. A layer that differs from the registered one only in its functions keeps its position and objects, and those objects now call the latest functions, so re-registering it emits no event and does no catalog work. On main such a layer moved to the newest position; it now stays where it was. A changed layer is replaced as before, with one state event instead of two. Source grows by 25 lines: the layer comparison, the in-place refresh, and the replace seam. Issue: 334
The first version copied new functions into the caller's command objects. A getter, a store proxy, a non-enumerable run, or one object shared by two layers then crashed, kept old code, or leaked handlers across layers. In-place mutations and symbol keys also passed as unchanged, and a failed reactive subscription was never retried. Layers replaced through useBindings now hand out keymap-owned copies of plain commands whose methods call the latest registration; other command shapes and reactive matchers always take a full replace. Each layer's data is copied at registration and compared with that copy. A replaced layer, changed or not, keeps its position, so precedence follows mount order (on main it moved to the newest position). A kept layer emits state when an enabled condition gives a new result. Source grows by 144 lines: command ownership and forwarding, the data copy, the enabled check and the position bookkeeping. Issue: 334
A kept layer could still miss a change: re-evaluating the old enabled closure read the same external state as the new one, functions lost their own properties in the owned copies, and symbol keys on arrays and in-place changes to a Map passed as unchanged. A layer is now kept only when its data is plain objects, arrays, primitives and property-free functions with enumerable string keys, and it is compared with a copy taken at the previous registration, along with its target and the compile registries. Everything else takes a full replace in the same position. A new enabled result is compared with the last one state listeners saw. registerLayer normalizes command names in place again, as on main. Issue: 334
The plain-data check started below a rest spread of the layer, so a getter, a frozen layer or a non-enumerable priority or commands field reached the fast path, and changes to them were dropped. It read descriptor values, so a proxy whose reads changed compared as unchanged, and an own __proto__ key was lost in the copies. The check now walks the layer object itself, keeping only its target by identity, reads every value through the object, and rejects __proto__ keys. Such layers take the full replace. Issue: 334
The fast path compared a copy of the caller's layer but compiled from the caller's object, so a proxy could pass the check with one value and compile another. It trusted compile hooks by identity, so an impure transformer changed the compiled layer without an event and left a stale sequence tree. A plain-object target was compared by content. A plain-data layer is now compiled from the copy the check read, and it is kept only if its compiled data matches the registered layer's, with the target compared by identity. The registry snapshot and the input comparison are gone. Issue: 334
Compile hooks run after the input check, so a command transformer could put run on a prototype or make it non-enumerable, and the owned copy lost it. The comparison read compiled values directly, so a getter there ran and could throw out of replaceLayer, and a Map changed in place compared as unchanged. Only compiled commands that are plain data get owned copies. The comparison reads through property descriptors, treats accessors, non-plain objects and functions with properties as changed, and takes the full replace if it throws. Issue: 334
The fast path compiled from a deep copy, so exact command filters and ctx.require, which match with Object.is, stopped matching the caller's objects, and a deeply equal new object was kept without an event. A plain-data layer is compiled from the caller's objects again, as on main. The object-valued fields of the layer, its commands and its bindings must be the same objects for the layer to be kept; binding keys and the containers themselves still compare by content. The copy is gone. Issue: 334
The identity check read the caller's input, so attrs, fields and requirements that field compilers create were never compared, and a proxy could give the check one requirement and the compile another. A fresh but equal object then changed filters and dispatch without a state event. The check now takes the requirement data, command fields, owned command data and attrs from the compiled layer, the objects queries and dispatch use, and compares them by identity. The input walk is gone. Issue: 334
A kept layer took the new compiled records and values, so layer fields and attrs, and the field and attr records themselves, changed identity in the graph snapshot and getCommandBindings with no state event. A hand-written list of exposed objects kept missing one. A kept layer now stays whole: nothing the API hands out changes. Only functions move, by retargeting forwarders (owned command methods, function binding commands, function layer fields) or by writing the new function into a writable slot. The layer is kept if its new compiled data equals the old, where objects the caller or a compile hook supplied compare by identity and the keymap's own containers by content. A differential test over random layers checks both paths against a fresh registration. Issue: 334
A kept layer moved a fresh requirement function into its requires but only evaluated targets with matchers, so direct condition reads turned false while the catalog and dispatch stayed enabled, with no state event. On the fast path every target with conditions (requirements or matchers) is now evaluated once with the new functions; if any result differs from the one listeners last saw, one state event fires and the caches are rebuilt. The differential now draws condition functions that are fresh on every registration and flip their results. Issue: 334
simonklee
added a commit
to anomalyco/opencode
that referenced
this pull request
Oct 26, 2026
Layers write `slash` inline, so each rerun hands the keymap a new slash object, both inside the command's `opencode` object and as the command's own `slash` field. With anomalyco/opentui#1785 the keymap compares the objects a layer supplies by identity, so a rerun of a layer with slash commands still rebuilt it. Compare nested command data by its fields, and register the kept command's fields, so a rerun with equal slash data hands the keymap the same objects. Only the new functions are copied into the kept command.
simonklee
added a commit
to anomalyco/opencode
that referenced
this pull request
Oct 27, 2026
Layers write `slash` inline, so each rerun hands the keymap a new slash object, both inside the command's `opencode` object and as the command's own `slash` field. With anomalyco/opentui#1785 the keymap compares the objects a layer supplies by identity, so a rerun of a layer with slash commands still rebuilt it. Compare nested command data by its fields, and register the kept command's fields, so a rerun with equal slash data hands the keymap the same objects. Only the new functions are copied into the kept command.
…egister-rebuilds-catalog
carbon's review of the plain-data fast path found that a kept layer compared condition results with values recorded at the last state event, which goes stale when the event has no listener or a reader reads later; that a condition turning false left the pending sequence armed until someone read it; and that a useBindings slot lost its mount order whenever its layer was briefly empty, waited for a target, or lost its target. Requirement and attr values from field compilers, functions too, now compare by identity, so they never move in place. A kept refresh compares each matcher's new result with the old functions' result and with every result read since the last state event, and validates the pending sequence at once. A layer slot reserves its order when it is created and holds its layer before the batch flushes, so a re-entrant state listener sees it. Exposed command fields are the owned command's forwarders, and a container a hook made non-extensible is replaced instead of written. Issue: 334
A binding transformer that hands out an unreadable or cyclic object reaches the kept-layer comparison, so the catch and depth bound there are reachable; a test now covers them, as well as the plain-data depth boundary, an enabled flip that no reader read, and a reader that read again at the last event. The writable check is dropped: a container that is not extensible already covers frozen and sealed ones. Tests that loop over many keymaps use renderer-free test keymaps, which removes the MaxListenersExceeded warnings; the identity test that block two already covers is removed, and a tautological assertion is fixed. Issue: 334
The adapter docs grouped command fields with attr and require values under "functions too", but function command fields are forwarded and kept. The slot-order test now reads the registered command view, which lists layers in mount order, instead of sorting the graph by order. Issue: 334
Dicklesworthstone
added a commit
to Dicklesworthstone/opentui
that referenced
this pull request
Oct 29, 2026
…yer fixes Local main 214606b is 53 ahead and 9 behind anomalyco origin/main. The merge-tree is one clean tree fdf506f, with 0 path deletions. 65 files change, 5304 insertions and 794 deletions. Incoming: - 354dbce native: reuse a text size when a column's height cap grows (anomalyco#1859) - d598095 native: fit drawn text to a cell width (anomalyco#1828) - c19ea1e native: sort z changes before paint and settle idle update timing (anomalyco#1829) - 1ad4219 core: simulate the session driver on virtual time (anomalyco#1855) - 577cef7 native: simulate output links deterministically (anomalyco#1854) - 15945ae core: fix markdown restyle leftovers after the list restyle (anomalyco#1857) - 1a138a2 docs: expose release history (anomalyco#1861) - 960f1e5 keymap: keep layers in place when useBindings re-registers them (anomalyco#1785) - 6395079 fix(renderer): decode Uint8Array writes in split-footer capture-stdout (anomalyco#1735) The work lands in the native scene and yoga bridge, the core renderer and markdown restyle, keymap layers, and the docs site (release history and keymap pages). The OpenAI File Downloader user-agent line stays at AGENTS.md:64 and packages/web/AGENTS.md:159. The 53 fork commits stay on main. Pushed only to the Dicklesworthstone/opentui fork (main, and master only when fork/master is a strict ancestor). anomalyco/opentui is not pushed. The harden and track experiment refs stay unfolded. Co-Authored-By: Claude <[email protected]>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
useBindingsin@opentui/keymap/solidre-registered its layer on every effect run as a dispose plus aregister. That emitted two
stateevents, and every keymap selector rebuilt the catalog and re-formatted everylabel, even when the layer had not changed.
The Solid and React adapters now replace their layer in one batch, and the layer keeps its mount position, even
while empty or waiting for a target (main moved it to the top). A plain-data layer that
differs only in its functions is kept whole, with no
stateevent; its functions forward to the new ones.statestill fires when anenabledor other matcher result differs from the old one or from one read since thelast event, and a pending sequence through a disabled layer is cleared.
requireandattrvalues fromfield compilers, functions included, compare by identity.
OpenCode benefits only once it passes a stable object in each command's
opencodefield, which its own changedoes. With it, a typed character goes from 20
stateevents and 15 catalog rebuilds to none.Tests cover event and rebuild counts, the plain-data and identity rules, conditions, pending sequences, mount
order, and a differential against a fresh registration.