Skip to content

fix(input): suppress browser defaults for controls bound through an action map - #582

Merged
Exoridus merged 1 commit into
mainfrom
fix/action-map-browser-capture
Aug 20, 2026
Merged

fix(input): suppress browser defaults for controls bound through an action map#582
Exoridus merged 1 commit into
mainfrom
fix/action-map-browser-capture

Conversation

@Exoridus

Copy link
Copy Markdown
Owner

Closes the last gap left by the input-actions sweep.

The bug

InputManager suppresses a keyboard default for exactly those channels
something has bound, and until now only InputBinding ever fed that ledger:
the refcount was written in createBinding() and unwound in the binding
detacher, 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. Space scrolled the
page, the arrow keys scrolled, / opened the browser's quick-find, and the
function keys reached the browser. The action fired too; the page just moved
underneath it.

The fix

No second policy, no new public API, no capture flag. The manager's ledger
stays the single owner of the decision and keeps its refcount semantics, and
action maps become a second writer to it:

  • ActionMapOwner gains three optional internal hooks -
    _retainActionMapCapture, _refreshActionMapCapture,
    _releaseActionMapCapture - alongside the ones it already declares for the
    batch sequence and the channel snapshot, optional for the same reason those
    are.
  • ActionMap calls them from the three places its bindings can change
    ownership 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 goes
    through 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.
  • SceneInputs forwards all three to app.input and holds nothing itself, so
    a map attached directly, attached to a scene, or pushed inside an
    InputScope all land in the same ledger.
  • InputManager keeps the channels each attached map claims and applies a
    rebind 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 / releaseCapture helpers, bit for
    bit 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.ts drives real KeyboardEvents against a real
InputManager and asserts on defaultPrevented, so it pins the contract a
player experiences rather than the private map:

  • Space, the arrow keys and / are captured through an action map
  • a key nothing binds keeps its default
  • rebinding moves the capture; a binding profile moves it and restoring
    defaults moves it back
  • two maps on one key hold it until the last one detaches
  • detaching releases; destroying a scene releases; popping a scope releases
  • a detached map may be rebound freely and only reaches the ledger on reattach
  • an unavailable (suspended) scene keeps its capture
  • a direct InputBinding is unaffected

All twelve fail on main except the three that are vacuously true there.

Validation

  • pnpm verify:quick - all 16 gates pass
  • vitest --project=exojs test/input test/core - 1944 tests pass
  • API reference regenerated (one line, the attach doc comment)

…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
Exoridus enabled auto-merge (squash) August 20, 2026 20:53
@codecov

codecov Bot commented Aug 20, 2026

Copy link
Copy Markdown

Bundle Report

Changes will increase total bundle size by 5.53kB (0.03%) ⬆️. This is within the configured threshold ✅

Detailed changes
Bundle name Size Change
exo-esm-esm 1.04MB 1.1kB (0.11%) ⬆️
exo-esm-modules-esm 2.92MB 3.33kB (0.11%) ⬆️
exo-full-iife-Exo-iife 1.65MB 1.1kB (0.07%) ⬆️

Affected Assets, Files, and Routes:

view changes for bundle: exo-esm-esm

Assets Changed:

Asset Name Size Change Total Size Change (%)
exo.esm.js 1.1kB 1.04MB 0.11%

Files in exo.esm.js:

  • ./src/input/actions/ActionMap.ts → Total Size: 15.33kB

  • ./src/input/InputManager.ts → Total Size: 46.37kB

  • ./src/core/scene/SceneInputs.ts → Total Size: 14.75kB

view changes for bundle: exo-full-iife-Exo-iife

Assets Changed:

Asset Name Size Change Total Size Change (%)
exo.full.iife.min.js 1.1kB 1.65MB 0.07%
view changes for bundle: exo-esm-modules-esm

Assets Changed:

Asset Name Size Change Total Size Change (%)
input/InputManager.js 2.66kB 47.15kB 5.97% ⚠️
input/actions/ActionMap.js 135 bytes 15.5kB 0.88%
core/scene/SceneInputs.js 543 bytes 15.06kB 3.74%

Files in input/InputManager.js:

  • ./src/input/InputManager.ts → Total Size: 46.37kB

Files in input/actions/ActionMap.js:

  • ./src/input/actions/ActionMap.ts → Total Size: 15.33kB

Files in core/scene/SceneInputs.js:

  • ./src/core/scene/SceneInputs.ts → Total Size: 14.75kB

@codecov

codecov Bot commented Aug 20, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 93.87755% with 3 lines in your changes missing coverage. Please review.
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
src/input/InputManager.ts 93.02% 3 Missing ⚠️

📢 Thoughts on this report? Let us know!

@Exoridus
Exoridus merged commit 6b3216f into main Aug 20, 2026
20 checks passed
@Exoridus
Exoridus deleted the fix/action-map-browser-capture branch August 20, 2026 21:05
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