Skip to content

keymap: keep layers in place when useBindings re-registers them - #1785

Merged
simonklee merged 16 commits into
mainfrom
harden/334-keymap-reregister-rebuilds-catalog
Oct 29, 2026
Merged

simonklee merged 16 commits into
mainfrom
harden/334-keymap-reregister-rebuilds-catalog

Conversation

@simonklee

@simonklee simonklee commented Oct 25, 2026 •

Copy link
Copy Markdown
Member

useBindings in @opentui/keymap/solid re-registered its layer on every effect run as a dispose plus a
register. That emitted two state events, and every keymap selector rebuilt the catalog and re-formatted every
label, 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 state event; its functions forward to the new ones.
state still fires when an enabled or other matcher result differs from the old one or from one read since the
last event, and a pending sequence through a disabled layer is cleared. require and attr values from
field compilers, functions included, compare by identity.

OpenCode benefits only once it passes a stable object in each command's opencode field, which its own change
does. With it, a typed character goes from 20 state events 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.

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
simonklee requested a review from kommander as a code owner October 25, 2026 15:13
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.
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
@simonklee
simonklee merged commit 960f1e5 into main Oct 29, 2026
27 checks passed
@simonklee
simonklee deleted the harden/334-keymap-reregister-rebuilds-catalog branch October 29, 2026 21:23
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]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant