fix(input): suppress browser defaults for controls bound through an action map - #582
Merged
Conversation
…ction map Only `InputBinding` fed the manager's keyboard-capture ledger, so a game that builds its controls out of actions - the recommended model - got no protection for the same keys: Space scrolled the page, the arrow keys scrolled, and the quick-find key opened the browser's own search. The ledger stays the single owner of that decision and keeps its refcount semantics. An action map retains its keyboard channels on attach, refreshes them as a difference after an atomic binding change, and releases them on detach; a scene facade forwards the three calls rather than keeping a second ledger, so a map reached through `scene.inputs` or an `InputScope` lands in the same place as one attached directly. A detached map may be rebound freely and only re-enters the ledger when it attaches again. Capture follows binding lifetime, not per-frame availability: a map masked by a higher scope or suspended with its scene keeps its keys, which is what a direct binding has always done.
Exoridus
enabled auto-merge (squash)
August 20, 2026 20:53
Bundle ReportChanges will increase total bundle size by 5.53kB (0.03%) ⬆️. This is within the configured threshold ✅ Detailed changes
Affected Assets, Files, and Routes:view changes for bundle: exo-esm-esmAssets Changed:
Files in
view changes for bundle: exo-full-iife-Exo-iifeAssets Changed:
view changes for bundle: exo-esm-modules-esmAssets Changed:
Files in
Files in
Files in
|
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
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.
Closes the last gap left by the input-actions sweep.
The bug
InputManagersuppresses a keyboard default for exactly those channelssomething has bound, and until now only
InputBindingever fed that ledger:the refcount was written in
createBinding()and unwound in the bindingdetacher, and nowhere else.
A game that builds its controls out of actions - the model the guides
recommend - therefore got no protection for the same keys.
Spacescrolled thepage, the arrow keys scrolled,
/opened the browser's quick-find, and thefunction keys reached the browser. The action fired too; the page just moved
underneath it.
The fix
No second policy, no new public API, no
captureflag. The manager's ledgerstays the single owner of the decision and keeps its refcount semantics, and
action maps become a second writer to it:
ActionMapOwnergains three optional internal hooks -_retainActionMapCapture,_refreshActionMapCapture,_releaseActionMapCapture- alongside the ones it already declares for thebatch sequence and the channel snapshot, optional for the same reason those
are.
ActionMapcalls them from the three places its bindings can changeownership or value:
_attach,detach, and the end of_applyAtomically.Hooking the atomic path rather than the public mutators covers
rebind(),applyProfile(), restoring defaults, and any future mutation that goesthrough it, and it fires only when the map has an owner - a detached map may
be rebound freely and re-enters the ledger once, on its next attach.
SceneInputsforwards all three toapp.inputand holds nothing itself, soa map attached directly, attached to a scene, or pushed inside an
InputScopeall land in the same ledger.InputManagerkeeps the channels each attached map claims and applies arebind as a difference, so a key that survives the change never drops to zero
in between and hand a frame's default back to the browser. Direct bindings
now go through the same
retainCapture/releaseCapturehelpers, bit forbit the behavior they had.
Capture follows binding lifetime, not per-frame availability: a map masked by a
higher scope, or suspended with its scene, keeps its keys - which is what a
direct binding has always done, and what keeps a suspend/resume cycle from
handing the page a frame of scrolling.
Tests
test/input/action-capture.test.tsdrives realKeyboardEvents against a realInputManagerand asserts ondefaultPrevented, so it pins the contract aplayer experiences rather than the private map:
Space, the arrow keys and/are captured through an action mapdefaults moves it back
InputBindingis unaffectedAll twelve fail on
mainexcept the three that are vacuously true there.Validation
pnpm verify:quick- all 16 gates passvitest --project=exojs test/input test/core- 1944 tests passattachdoc comment)