Skip to content

fix: harden routing, focus, keybindings, and forms#267

Merged
RtlZeroMemory merged 4 commits intomainfrom
fix/routing-focus-forms-hardening-clean
Mar 6, 2026
Merged

fix: harden routing, focus, keybindings, and forms#267
RtlZeroMemory merged 4 commits intomainfrom
fix/routing-focus-forms-hardening-clean

Conversation

@RtlZeroMemory
Copy link
Owner

@RtlZeroMemory RtlZeroMemory commented Mar 6, 2026

Summary

  • harden route-owned keybindings and mode validation with fail-fast invalid binding handling, duplicate route shortcut detection, and owned route binding replacement
  • fix overlay and layer input semantics, focus-trap restoration/snapshot fidelity, readOnly text input behavior, and nested wheel fallback
  • tighten useForm typing and runtime behavior around same-turn state reads, async wizard gating, submit/edit locking, and field prop forwarding
  • align docs and tests with the shipped routing, focus, keybinding, and forms contracts

Verification

  • npm run build
  • npm run build:native
  • node scripts/run-tests.mjs
  • TSX_DISABLE_CACHE=1 node --import tsx --test packages/core/src/app/__tests__/keybindings.api.test.ts packages/core/src/app/__tests__/widgetRenderer.integration.test.ts packages/core/src/forms/__tests__/form.disabled.test.ts packages/core/src/forms/__tests__/form.wizard.test.ts packages/core/src/forms/__tests__/useForm.test.ts packages/core/src/keybindings/__tests__/keybinding.conflicts.test.ts packages/core/src/keybindings/__tests__/keybinding.modes.test.ts packages/core/src/router/__tests__/keybindings.test.ts packages/core/src/router/__tests__/router.test.ts packages/core/src/runtime/__tests__/focus.layers.test.ts packages/core/src/runtime/__tests__/focus.traps.test.ts packages/core/src/runtime/__tests__/wheelRouting.test.ts packages/core/src/runtime/__tests__/widgetMeta.test.ts

PTY Frame Audit

  • worker-mode Starship template run with scripted keys 2, 3, t, q
  • frame audit report (node scripts/frame-audit-report.mjs /tmp/rezi-routing-focus-audit.ndjson --latest-pid) reported:
    • backend_submitted=1914
    • worker_payload=1914
    • worker_accepted=1914
    • worker_completed=1914
    • hash_mismatch_backend_vs_worker=0
    • route coverage for bridge, engineering, and crew
  • theme change on crew produced distinct drawlist hashes in the audit log while command streams remained valid

Summary by CodeRabbit

Release Notes

  • New Features

    • Added readOnly input state: inputs remain focusable but prevent editing; separate from disabled state.
    • Enhanced form wizards with async validation support during forward navigation.
    • Improved focus zones: now remember the last focused widget and keep arrow-key navigation within active zones.
    • Focus traps now own Tab traversal and handle stacked trap scenarios correctly.
  • Bug Fixes

    • Invalid keybindings are now rejected during registration instead of silently skipped.
    • Wheel scroll events now fall back to outer scrollable containers when inner containers are clamped.
    • ESC key handling in modals and focus traps now reports closure state correctly.
  • Documentation

    • Expanded keybinding system documentation with validation behavior.
    • Clarified focus zone and focus trap semantics.
    • Updated form validation flow documentation for wizard navigation.

@coderabbitai
Copy link

coderabbitai bot commented Mar 6, 2026

Caution

Review failed

Pull request was closed or merged during review

📝 Walkthrough

Walkthrough

This PR adds keybinding registration validation with error handling, implements focus trap stacking with Tab traversal constraints and ESC handling, extends form APIs with readOnly field support and text-bindable type safety, refactors wheel routing to check multiple scrollable ancestors, and updates widget typings and documentation accordingly.

Changes

Cohort / File(s) Summary
Documentation
docs/guide/concepts.md, docs/guide/hooks-reference.md, docs/guide/input-and-focus.md, docs/recipes/form-validation.md
Added explanations for keybinding validation, focus zone/trap stacking semantics, form readonly behavior, and wizard async validation flow.
Keybinding Validation & Management
packages/core/src/app/createApp.ts, packages/core/src/keybindings/manager.ts, packages/core/src/keybindings/index.ts, packages/core/src/app/__tests__/keybindings.api.test.ts
Introduced keybinding registration validation with ZRUI_INVALID_PROPS errors, sourceTag tracking via ManagedBinding and RegisterBindingsOptions, removeBindingsBySource public API, mode graph cycle detection, and validation test coverage.
Router Keybinding Integration
packages/core/src/router/keybindings.ts, packages/core/src/router/router.ts, packages/core/src/router/__tests__/keybindings.test.ts, packages/core/src/router/__tests__/router.test.ts
Added RouteKeybindingEntry type, collectRouteKeybindingEntries with duplicate detection, routeIdsByKeybinding tracking, and duplicate keybinding validation tests.
Form Types & Text-Bindable Fields
packages/core/src/forms/types.ts, packages/core/src/forms/index.ts, packages/core/src/index.ts
Introduced UseFormTextFieldName type constraint for text-input-compatible fields, added readOnly to UseFormInputBinding, generified field-access methods with keyof T constraints, and exported new type.
Form Implementation & Wizard Logic
packages/core/src/forms/useForm.ts, packages/core/src/forms/__tests__/useForm.test.ts, packages/core/src/forms/__tests__/form.wizard.test.ts, packages/core/src/forms/__tests__/form.disabled.test.ts, packages/core/src/forms/__tests__/form.async-validation.test.ts
Refactored state management with updateFormState helper and stateRef pattern, implemented getWizardTransitionSteps and resolveWizardTransition for async-aware multi-step validation, added text-bindable field validation with warnings, and expanded test coverage for wizard async flows and field readonly/disabled interactions.
Input & Readonly Support
packages/core/src/app/widgetRenderer/inputEditing.ts, packages/core/src/widgets/types.ts, packages/core/src/widgets/__tests__/modal.focus.test.ts
Added readOnly?: boolean property to InputProps and TextareaProps, guard editing operations (cut, undo, redo) with readOnly checks, and updated modal focus test expectations.
Wheel Routing & Scrollable Ancestors
packages/core/src/app/widgetRenderer.ts, packages/core/src/app/widgetRenderer/mouseRouting.ts, packages/core/src/runtime/__tests__/wheelRouting.test.ts
Replaced single-target findNearestScrollableAncestor with multi-target findScrollableAncestors returning immutable frozen arrays, refactored wheel event routing to iterate over all scrollable ancestors and apply overrides, and added test verifying fall-through to outer scrollable when inner is clamped.
Focus Trap & Zone Stacking
packages/core/src/runtime/focus.ts, packages/core/src/runtime/widgetMeta.ts, packages/core/src/runtime/__tests__/focus.traps.test.ts, packages/core/src/runtime/__tests__/focus.zones.test.ts, packages/core/src/runtime/__tests__/wheelRouting.test.ts
Implemented trap-active constraints in finalizeFocusWithPreCollectedMetadata to restrict focus within active trap's focusableIds, added collectFocusableIdsInTrapSubtree helper, updated focus expectations when traps are empty or focusables are nested, and added tests for trap/zone focus semantics.
Layer ESC Routing
packages/core/src/runtime/router/layer.ts, packages/core/src/widgets/__tests__/layers.golden.test.ts, packages/core/src/widgets/__tests__/modal.focus.test.ts, packages/core/src/runtime/__tests__/focus.layers.test.ts
Added closedLayerId field to LayerRoutingResult, updated ESC routing to report which layer was closed, and adjusted test expectations to verify layer closure reporting behavior.
Widget Metadata & Typing
packages/core/src/runtime/widgetMeta.ts, packages/core/src/runtime/__tests__/widgetMeta.test.ts, packages/core/src/app/__tests__/widgetRenderer.integration.test.ts
Extended input metadata to include readOnly: boolean, updated collectInputMetaById public API signature, added tests validating readOnly in input metadata and readonly input behavior (focusable but non-editable), and integrated readonly tests into widget renderer tests.
Keybinding Conflict & Mode Tests
packages/core/src/keybindings/__tests__/keybinding.conflicts.test.ts, packages/core/src/keybindings/__tests__/keybinding.modes.test.ts
Moved cycle detection from runtime setMode/routeKeyEvent to registration-time validateModeGraph validation, added test for dropdown active state bypassing app keybindings, and updated mode validation to reject cycles at registration.
Router Integration
packages/core/src/app/__tests__/router.integration.test.ts
Added test verifying replaceRoutes preserves existing route shortcuts when invalid keybindings are rejected, with reformatted test event payloads for readability.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • PR #265 — Overlapping code-level changes to runtime focus/layer routing, widget metadata (readOnly), wheel-scroll ancestor APIs, and focus-trap/escape semantics.
  • PR #245 — Both modify packages/core/src/app/widgetRenderer.ts for wheel handling and use immutable frozen arrays in scroll routing.
  • PR #168 — Both modify keybinding/shortcut registration surfaces, binding manager APIs, and overlay/app keybinding interaction testing.

Poem

🐰 Bindings validated with care,
Focus traps stack layer by layer,
Wheels find scrollables galore,
Readonly fields ask for no more,
Wizard steps async—ta-da!

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 25.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'fix: harden routing, focus, keybindings, and forms' accurately summarizes the main changes across the PR, covering the four primary areas of improvement: routing validation, focus behavior, keybinding handling, and form enhancements.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch fix/routing-focus-forms-hardening-clean

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 9

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
packages/core/src/app/createApp.ts (1)

41-52: ⚠️ Potential issue | 🟡 Minor

Fix import ordering to clear CI organizeImports failure.

Line 41-Line 52 currently fail the import-order check reported by CI, so this PR will remain red until the block is organized.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/src/app/createApp.ts` around lines 41 - 52, Reorder the named
imports in the import from "../keybindings/index.js" to satisfy the project's
organizeImports rule: alphabetize the specifiers so they read
createManagerState, getBindings, getMode, getPendingChord, registerBindings,
registerModes, removeBindingsBySource, resetChordState, routeKeyEvent, setMode;
leave the module path unchanged.
packages/core/src/forms/useForm.ts (1)

1-1551: ⚠️ Potential issue | 🟡 Minor

Formatter mismatch is still failing CI for this file.

The pipeline reports Prettier/formatter mismatch in packages/core/src/forms/useForm.ts; please run the formatter and commit the result.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/src/forms/useForm.ts` around lines 1 - 1551, The file
packages/core/src/forms/useForm.ts is failing CI due to formatting (Prettier)
mismatch; fix by running the project's formatter (e.g. run the repo's format
script or Prettier: prettier --write packages/core/src/forms/useForm.ts or
npm/yarn run format), review changes (especially around the exported useForm and
helper functions), stage the formatted file, and commit/push the updated file so
the Prettier check passes in CI.
🧹 Nitpick comments (1)
packages/core/src/keybindings/manager.ts (1)

137-140: Tighten parse result typing so sourceTag is preserved at compile time.

parseBindingsWithOptions can return source-tagged bindings, but ParseBindingsResult still exposes KeyBinding[], which hides that metadata in the type system.

Type-only refactor sketch
-export type ParseBindingsResult<C> = Readonly<{
-  bindings: readonly KeyBinding<C>[];
+export type ParseBindingsResult<C, B extends KeyBinding<C> = KeyBinding<C>> = Readonly<{
+  bindings: readonly B[];
   invalidKeys: readonly InvalidKey[];
 }>;
 
-function parseBindingsWithOptions<C>(
+function parseBindingsWithOptions<C>(
   map: BindingMap<C>,
   options?: RegisterBindingsOptions,
-): ParseBindingsResult<C> {
+): ParseBindingsResult<C, ManagedBinding<C>> {

Also applies to: 189-211

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/src/keybindings/manager.ts` around lines 137 - 140, The
ParseBindingsResult type erases sourceTag metadata by returning KeyBinding<C>[];
update ParseBindingsResult to be generic over the binding item so it preserves
any source tagging (e.g., ParseBindingsResult<C, B extends KeyBinding<C> =
KeyBinding<C>>) and use that generic when returning results from
parseBindingsWithOptions and related functions; specifically change the bindings
field to readonly B[] (or readonly B[]) and update callsites/other exported
types (the parseBindingsWithOptions return type and any references around lines
referenced) so the concrete source-tagged binding type flows through the result
type instead of being widened to KeyBinding<C>.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/core/src/app/__tests__/widgetRenderer.integration.test.ts`:
- Around line 512-579: The tests claim readOnly widgets remain focusable but
never assert focus movement; after sending the TAB event via
renderer.routeEngineEvent(keyEvent(3 /* TAB */)) assert the renderer's focused
id equals the widget id ("inp" for the input test, "ta" for the textarea test)
to ensure focus traversal includes readOnly fields (use the renderer API used
elsewhere in tests to read focus state, e.g. renderer.focusedId or
renderer.getFocusedId()). Add these assertions immediately after the TAB routing
and before simulating typing/enter/undo.

In `@packages/core/src/app/createApp.ts`:
- Around line 790-798: The current replaceRouteBindings removes existing route
bindings via applyKeybindingState(removeBindingsBySource(...)) before
registering new ones, so if registerAppBindings(..., method: "replaceRoutes")
throws, the old shortcuts are lost; change this to be transactional by capturing
the current keybindingState (snapshot) then attempt to register the new bindings
first (or validate them) and only apply the removal+replacement on success, or
wrap the operations in try/catch and on any error restore the snapshot via
applyKeybindingState(snapshot); update the code paths around
replaceRouteBindings, applyKeybindingState, removeBindingsBySource, and
registerAppBindings to perform the snapshot/restore or deferred removal so
failures do not delete existing ROUTE_KEYBINDING_SOURCE bindings.

In `@packages/core/src/app/widgetRenderer/inputEditing.ts`:
- Around line 224-226: The read-only guard only prevents edits produced by
applyInputEditEvent(...) but doesn't stop the manual cut (Ctrl+X) branch from
mutating value/history; update the manual cut branch to check meta.readOnly and
short-circuit (return ROUTE_NO_RENDER_CONSUMED) before performing any mutation.
Specifically, locate the manual cut handling code/path (the branch that performs
the cut and updates value/history) and add the same meta.readOnly guard as used
with edit.action so no destructive changes occur in read-only mode.

In `@packages/core/src/forms/useForm.ts`:
- Around line 1442-1446: If options.onSubmit throws synchronously,
submittingRef.current is never cleared which blocks future submits; in the
try/catch around options.onSubmit (where submitResult is assigned) ensure you
reset submittingRef.current = false in the catch before calling
failSubmit(error) and returning, and apply the same pattern for the later block
around the async submit (the code around where submittingRef.current is set to
true at the 1480-1484 region) so any thrown errors always clear
submittingRef.current (use a catch or finally to restore the ref).
- Around line 1463-1472: The submit flow currently uses Promise.then chains
(notably the submitResult.then call and the subsequent .then/.catch blocks
around lines referenced) — change this to async/await: mark the surrounding
function (where submitResult is created/used) as async, await the submitResult
call, set submittingRef.current = false and call finishSuccessfulSubmit() after
the await on success, and wrap the await in try/catch to call failSubmit(error)
on error; update all other .then/.catch usages in the same submit flow (the
blocks around the later 1486-1509 range) to the same async/try/catch pattern so
state updates and error handling use await instead of .then chains, keeping the
same calls to submittingRef.current, finishSuccessfulSubmit, and failSubmit.

In `@packages/core/src/runtime/__tests__/widgetMeta.test.ts`:
- Around line 301-305: The test assertion formatting in the expression using
traps.get("trap1")?.focusableIds has drifted; run the project's formatter (e.g.,
npm/yarn run format or Prettier) on
packages/core/src/runtime/__tests__/widgetMeta.test.ts or the changed hunk to
normalize spacing/line breaks so the assertion line matches the repo style, then
re-run CI.

In `@packages/core/src/runtime/router/layer.ts`:
- Around line 42-45: The code incorrectly treats falsy layer IDs as absent by
using "if (!layerId)" when reading const layerId = layerStack[layerStack.length
- 1]; — change this to explicitly check for stack emptiness or undefined (e.g.,
if (layerStack.length === 0) or if (layerId === undefined)) so valid falsy IDs
(0, '', false) are not treated as missing; update the conditional that returns
Object.freeze({ consumed: false }) accordingly in the layer lookup logic that
references layerStack and layerId.
- Around line 52-55: The branch that handles missing close callbacks currently
returns Object.freeze({ consumed: true }) which lets ESC be consumed without
closing a closable top layer; update the logic around onClose.get(layerId) /
closeCallback in layer handling to instead return an object that includes
closedLayerId when the layer is closable (or fall through to trigger the close
path), e.g. detect that layerId represents a closable top layer and, if
closeCallback is absent, invoke the standard close handling or return {
consumed: true, closedLayerId: layerId } so ESC isn't swallowed; adjust the code
that reads closeCallback and the consumers of the returned object to expect
closedLayerId when applicable.

In `@packages/core/src/runtime/widgetMeta.ts`:
- Around line 831-843: The current double-scan over _containerStack causes
focusables inside a trap to be added to both _trapFocusables and the nearest
outer _zoneFocusables; change to a single backward scan that examines each
container once and: if container.kind === "trap" push focusableId into
_trapFocusables.get(container.id) and break (stop scanning), else if
container.kind === "zone" push into _zoneFocusables.get(container.id) and break;
update the loop that references _containerStack, _zoneFocusables,
_trapFocusables and focusableId accordingly so trap descendants are not also
added to outer zones.

---

Outside diff comments:
In `@packages/core/src/app/createApp.ts`:
- Around line 41-52: Reorder the named imports in the import from
"../keybindings/index.js" to satisfy the project's organizeImports rule:
alphabetize the specifiers so they read createManagerState, getBindings,
getMode, getPendingChord, registerBindings, registerModes,
removeBindingsBySource, resetChordState, routeKeyEvent, setMode; leave the
module path unchanged.

In `@packages/core/src/forms/useForm.ts`:
- Around line 1-1551: The file packages/core/src/forms/useForm.ts is failing CI
due to formatting (Prettier) mismatch; fix by running the project's formatter
(e.g. run the repo's format script or Prettier: prettier --write
packages/core/src/forms/useForm.ts or npm/yarn run format), review changes
(especially around the exported useForm and helper functions), stage the
formatted file, and commit/push the updated file so the Prettier check passes in
CI.

---

Nitpick comments:
In `@packages/core/src/keybindings/manager.ts`:
- Around line 137-140: The ParseBindingsResult type erases sourceTag metadata by
returning KeyBinding<C>[]; update ParseBindingsResult to be generic over the
binding item so it preserves any source tagging (e.g., ParseBindingsResult<C, B
extends KeyBinding<C> = KeyBinding<C>>) and use that generic when returning
results from parseBindingsWithOptions and related functions; specifically change
the bindings field to readonly B[] (or readonly B[]) and update callsites/other
exported types (the parseBindingsWithOptions return type and any references
around lines referenced) so the concrete source-tagged binding type flows
through the result type instead of being widened to KeyBinding<C>.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 550f9b3d-71c6-404f-bf42-9cb754c24dff

📥 Commits

Reviewing files that changed from the base of the PR and between e898663 and 4a43bda.

📒 Files selected for processing (36)
  • docs/guide/concepts.md
  • docs/guide/hooks-reference.md
  • docs/guide/input-and-focus.md
  • docs/guide/lifecycle-and-updates.md
  • docs/recipes/form-validation.md
  • packages/core/src/app/__tests__/keybindings.api.test.ts
  • packages/core/src/app/__tests__/widgetRenderer.integration.test.ts
  • packages/core/src/app/createApp.ts
  • packages/core/src/app/widgetRenderer.ts
  • packages/core/src/app/widgetRenderer/inputEditing.ts
  • packages/core/src/app/widgetRenderer/mouseRouting.ts
  • packages/core/src/forms/__tests__/form.disabled.test.ts
  • packages/core/src/forms/__tests__/form.wizard.test.ts
  • packages/core/src/forms/__tests__/useForm.test.ts
  • packages/core/src/forms/index.ts
  • packages/core/src/forms/types.ts
  • packages/core/src/forms/useForm.ts
  • packages/core/src/index.ts
  • packages/core/src/keybindings/__tests__/keybinding.conflicts.test.ts
  • packages/core/src/keybindings/__tests__/keybinding.modes.test.ts
  • packages/core/src/keybindings/index.ts
  • packages/core/src/keybindings/manager.ts
  • packages/core/src/router/__tests__/keybindings.test.ts
  • packages/core/src/router/__tests__/router.test.ts
  • packages/core/src/router/keybindings.ts
  • packages/core/src/router/router.ts
  • packages/core/src/runtime/__tests__/focus.layers.test.ts
  • packages/core/src/runtime/__tests__/focus.traps.test.ts
  • packages/core/src/runtime/__tests__/wheelRouting.test.ts
  • packages/core/src/runtime/__tests__/widgetMeta.test.ts
  • packages/core/src/runtime/focus.ts
  • packages/core/src/runtime/router/layer.ts
  • packages/core/src/runtime/widgetMeta.ts
  • packages/core/src/widgets/__tests__/layers.golden.test.ts
  • packages/core/src/widgets/__tests__/modal.focus.test.ts
  • packages/core/src/widgets/types.ts

@RtlZeroMemory RtlZeroMemory force-pushed the fix/routing-focus-forms-hardening-clean branch from 1b758ed to b113374 Compare March 6, 2026 10:53
@RtlZeroMemory RtlZeroMemory merged commit a60007e into main Mar 6, 2026
31 of 32 checks passed
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