Skip to content

refactor(core): split widget metadata collection by concern#286

Merged
RtlZeroMemory merged 1 commit intomainfrom
codex/split-widget-meta
Mar 17, 2026
Merged

refactor(core): split widget metadata collection by concern#286
RtlZeroMemory merged 1 commit intomainfrom
codex/split-widget-meta

Conversation

@RtlZeroMemory
Copy link
Copy Markdown
Owner

@RtlZeroMemory RtlZeroMemory commented Mar 17, 2026

Summary

  • Split packages/core/src/runtime/widgetMeta.ts into internal helper modules.
  • Kept the public API unchanged.
  • No intended behavior change.

Why

  • Reduce the runtime metadata monolith on the focus/routing path.

Validation

  • npm install
  • npm run build:native
  • npm run lint
  • npm run typecheck
  • npm run build
  • node scripts/run-tests.mjs --filter "packages/core/dist/runtime/__tests__/widgetMeta"
  • node scripts/run-tests.mjs --filter "packages/core/dist/runtime/__tests__/focus"
  • node scripts/run-tests.mjs --filter "packages/core/dist/app/__tests__/focusDispatcher|packages/core/dist/app/__tests__/commandPaletteRouting" (matched 0 files because run-tests.mjs treats --filter as a literal substring)
  • node scripts/run-tests.mjs --filter "packages/core/dist/app/__tests__/commandPaletteRouting.test.js"
  • node scripts/run-tests.mjs --filter "packages/core/dist/app/__tests__/focusDispatcher.controller.test.js"
  • node scripts/run-tests.mjs

PTY / frame-audit evidence

  • Followed docs/dev/live-pty-debugging.md with deterministic viewport runs.
  • Native backend prerequisite for the PTY runs: npm run build:native.
  • Real template PTY run:
    • stty rows 40 cols 120 && : > /tmp/rezi-widget-meta-frame.ndjson && : > /tmp/rezi-widget-meta-starship.log && env -u NO_COLOR REZI_STARSHIP_EXECUTION_MODE=worker REZI_STARSHIP_DEBUG=1 REZI_STARSHIP_DEBUG_LOG=/tmp/rezi-widget-meta-starship.log REZI_FRAME_AUDIT=1 REZI_FRAME_AUDIT_LOG=/tmp/rezi-widget-meta-frame.ndjson npx tsx packages/create-rezi/templates/starship/src/main.ts
    • node scripts/frame-audit-report.mjs /tmp/rezi-widget-meta-frame.ndjson --latest-pid
    • Report summary: records=10147, backend_submitted=480, worker_payload=480, worker_accepted=480, worker_completed=480, hash_mismatch_backend_vs_worker=0.
    • Route summary included bridge and crew.
  • Controlled PTY harness run for explicit focus/modal/palette paths:
    • stty rows 30 cols 100 && : > /tmp/rezi-widget-meta-frame.ndjson && : > /tmp/rezi-widget-meta-harness.log && env -u NO_COLOR REZI_FRAME_AUDIT=1 REZI_FRAME_AUDIT_LOG=/tmp/rezi-widget-meta-frame.ndjson REZI_WIDGET_META_HARNESS_LOG=/tmp/rezi-widget-meta-harness.log node /tmp/rezi-widget-meta-pty.mjs
    • Exercised: focus traversal (Tab), modal/focus-trap open and close (Enter, Tab, Esc), command palette path (Ctrl+P, de, Enter), and quit (q).
    • Harness log evidence:
      • help-trigger-press
      • help-close
      • key ctrl+p
      • palette-select id=deploy
      • palette-close
      • shutdown code=0
    • node scripts/frame-audit-report.mjs /tmp/rezi-widget-meta-frame.ndjson --latest-pid
    • Report summary: records=255, backend_submitted=12, worker_payload=12, worker_accepted=12, worker_completed=12, native_summary_records=12, native_header_records=24, hash_mismatch_backend_vs_worker=0.
    • Frame hash / size deltas from the overlay path:
      • frame 8: hash32 0x8b5c2cde, byteLen 6956, cmdCount 111
      • frame 9: hash32 0x3a6b9eb9, byteLen 7152, cmdCount 115
      • frame 10: hash32 0x234e0f71, byteLen 7212, cmdCount 116
      • frame 11: hash32 0x1c5d7787, byteLen 7140, cmdCount 115
      • frame 12: hash32 0x67d8a2e3, byteLen 4212, cmdCount 76

Summary by CodeRabbit

  • Refactor
    • Reorganized internal widget metadata collection system into modularized components for improved maintainability. Focus management, input handling, and accessibility metadata collection refactored for better code organization while maintaining full backward compatibility with existing APIs.

@chatgpt-codex-connector
Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 17, 2026

📝 Walkthrough

Walkthrough

This pull request restructures the widget metadata collection system by extracting implementation from a monolithic 1122-line file into four focused internal modules (collector, focusContainers, focusInfo, helpers). The public API surface remains unchanged through re-exports. No behavioral changes to runtime logic.

Changes

Cohort / File(s) Summary
Public API Surface
packages/core/src/runtime/widgetMeta.ts
Re-exports all widget metadata types and collection functions from new internal modules (collector, focusContainers, focusInfo, helpers) instead of implementing them directly.
Metadata Collection Core
packages/core/src/runtime/widgetMeta/collector.ts
Implements the main WidgetMetadataCollector class performing single-pass depth-first traversal to collect consolidated metadata snapshots. Includes focus containers, focusable IDs, enabled states, pressable IDs, and input metadata with internal pool reuse for optimization.
Focus Container Management
packages/core/src/runtime/widgetMeta/focusContainers.ts
Handles traversal-based collection of focus zones and traps metadata. Includes duplicate ID detection, focusable ID aggregation within containers, and parent zone linkage for nested zones.
Accessibility & Focus Info
packages/core/src/runtime/widgetMeta/focusInfo.ts
Encapsulates structured accessibility metadata for focusable widgets. Provides field context extraction and comprehensive FocusInfo construction with labels, required flags, error aggregation, and accessibility announcements.
Interactive Widget Utilities
packages/core/src/runtime/widgetMeta/helpers.ts
Exports helper functions and InputMeta type for interactive widget inspection. Includes focusability/enablement checks, input metadata collection, and traversal utilities respecting widget protocol capabilities.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • PR #170: Modifies the same enablement logic (isEnabledInteractive, collectEnabledMap) and single-pass collector pattern that this PR extracts and reorganizes into new modules.
  • PR #265: Directly overlaps with the new widgetMeta modules, implementing the same runtime metadata collection and focus-container validation exported symbols.
  • PR #158: Touches shared widget-metadata code paths (readInteractiveId, isFocusableInteractive, collectPressableIds); this PR reorganizes that logic into new modules while the related PR introduces widget protocol changes.

Poem

🐰 A tale of code untangled, modular and bright,
Four files emerge from one, each bearing its light,
Collectors and containers, focus and info aligned,
No logic changed, just order redesigned—
Refactored with care, the metadata takes flight! ✨

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 40.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 'refactor(core): split widget metadata collection by concern' accurately describes the main change: reorganizing a monolithic widget metadata file into modular internal modules while preserving the public API.

✏️ 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
  • Commit unit tests in branch codex/split-widget-meta
📝 Coding Plan
  • Generate coding plan for human review comments

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

Copy link
Copy Markdown

@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: 2

🧹 Nitpick comments (4)
packages/core/src/runtime/widgetMeta/focusContainers.ts (1)

85-107: Subtle difference: trap subtree allows nested zones.

The collectFocusableIdsInTrapSubtree function only stops at nested traps/modals (lines 93-95), allowing zones within traps. This is the correct behavior per the comment on line 237-238, but differs from collectFocusableIdsInSubtree. Consider adding a brief comment here explaining why zones are included.

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

In `@packages/core/src/runtime/widgetMeta/focusContainers.ts` around lines 85 -
107, Add a short inline comment above the collectFocusableIdsInTrapSubtree
function that explains why zones are intentionally not treated like nested
traps/modals (i.e., zones inside a trap should be traversed and their focusable
IDs collected), and note that this differs from collectFocusableIdsInSubtree
which stops at zones; reference the function name
collectFocusableIdsInTrapSubtree and the contrasting
collectFocusableIdsInSubtree so future readers understand the deliberate
behavior.
packages/core/src/runtime/widgetMeta/helpers.ts (1)

193-210: Consider simplifying InputMeta construction.

The conditional spread pattern creates two code paths. Since Object.freeze is called regardless, you could simplify by always including the optional callbacks in metaBase when defined.

✨ Optional simplification
-        const metaBase: InputMeta = {
+        const meta: InputMeta = Object.freeze({
           instanceId: node.instanceId,
           value,
           disabled,
           readOnly,
           multiline,
           rows,
           wordWrap,
-        };
-        const meta: InputMeta =
-          onInput || onBlur
-            ? Object.freeze({
-                ...metaBase,
-                ...(onInput ? { onInput } : {}),
-                ...(onBlur ? { onBlur } : {}),
-              })
-            : Object.freeze(metaBase);
+          ...(onInput && { onInput }),
+          ...(onBlur && { onBlur }),
+        });
         m.set(id, meta);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/src/runtime/widgetMeta/helpers.ts` around lines 193 - 210, The
InputMeta creation unnecessarily branches to either freeze metaBase or a spread
including optional callbacks; simplify by always constructing a single object
that starts from metaBase and conditionally spreads onInput/onBlur when present,
then call Object.freeze once and store it (replace the ternary that defines meta
with a single Object.freeze({ ...metaBase, ...(onInput ? { onInput } : {}),
...(onBlur ? { onBlur } : {}) }) and keep the subsequent m.set(id, meta) call);
update references to metaBase/meta in this function accordingly.
packages/core/src/runtime/widgetMeta/focusInfo.ts (1)

96-135: Consider adding input to the visible label switch.

The kindToAnnouncementPrefix handles input (line 102-103), but readFocusableVisibleLabel doesn't have an input case. Input widgets often have a placeholder prop that could serve as a visible label fallback.

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

In `@packages/core/src/runtime/widgetMeta/focusInfo.ts` around lines 96 - 135,
readFocusableVisibleLabel currently omits a branch for vnode.kind === "input"
even though kindToAnnouncementPrefix recognizes "input"; update
readFocusableVisibleLabel to handle the "input" kind by using the input's
visible label sources (e.g., label element, aria-label, aria-labelledby) and
fall back to the vnode.props.placeholder when no other visible label exists so
placeholders serve as fallback visible labels for input widgets.
packages/core/src/runtime/widgetMeta/collector.ts (1)

196-243: Consider extracting shared input metadata logic.

The input metadata collection here (lines 196-243) duplicates the logic from collectInputMetaById in helpers.ts (lines 157-221). Consider extracting a shared helper function to avoid drift between implementations.

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

In `@packages/core/src/runtime/widgetMeta/collector.ts` around lines 196 - 243,
The block in the vnode.kind === "input" branch duplicates logic from
helpers.ts:collectInputMetaById; extract the shared logic into a single helper
(e.g., buildInputMeta or extractInputMeta) that accepts the props and
node.instanceId and returns an InputMeta or null, then replace the duplicated
code in collector.ts to call that helper before setting this._inputById; ensure
the helper reproduces the same behavior (id/value validation,
disabled/readOnly/multiline/rows/wordWrap parsing, function casting for
onInput/onBlur, Object.freeze) and keep existing symbols like InputMeta,
collectInputMetaById, vnode, and this._inputById to locate where to wire it in.
🤖 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/runtime/widgetMeta/collector.ts`:
- Around line 176-185: The current logic in collector.ts lets proto.openGated
overwrite the result from proto.disableable; update the checks so they are
combined instead of overwritten: when handling proto.disableable set enabled =
disabled !== true as now, and when handling proto.openGated update enabled by
combining with the prior value (e.g., enabled = enabled && (open === true))
before calling this._enabledById.set(interactiveId, enabled); reference the
proto.disableable/proto.openGated branches, vnode.props disabled/open reads,
interactiveId, and this._enabledById.set to locate where to apply the fix.

In `@packages/core/src/runtime/widgetMeta/helpers.ts`:
- Around line 101-113: The current logic in the block that reads id =
readInteractiveId(node.vnode) and uses getWidgetProtocol(node.vnode.kind) lets
the openGated check overwrite the prior disableable check; change the logic so
both checks are combined and a widget is enabled only if it passes all
applicable gates. Concretely, compute enabled by evaluating both gates (for
example: if proto.disableable, require (props.disabled !== true); if
proto.openGated, require (props.open === true)) and set enabled to the AND of
those results before calling m.set(id, enabled), referencing readInteractiveId,
getWidgetProtocol, node.vnode.props and m.set to locate the code.

---

Nitpick comments:
In `@packages/core/src/runtime/widgetMeta/collector.ts`:
- Around line 196-243: The block in the vnode.kind === "input" branch duplicates
logic from helpers.ts:collectInputMetaById; extract the shared logic into a
single helper (e.g., buildInputMeta or extractInputMeta) that accepts the props
and node.instanceId and returns an InputMeta or null, then replace the
duplicated code in collector.ts to call that helper before setting
this._inputById; ensure the helper reproduces the same behavior (id/value
validation, disabled/readOnly/multiline/rows/wordWrap parsing, function casting
for onInput/onBlur, Object.freeze) and keep existing symbols like InputMeta,
collectInputMetaById, vnode, and this._inputById to locate where to wire it in.

In `@packages/core/src/runtime/widgetMeta/focusContainers.ts`:
- Around line 85-107: Add a short inline comment above the
collectFocusableIdsInTrapSubtree function that explains why zones are
intentionally not treated like nested traps/modals (i.e., zones inside a trap
should be traversed and their focusable IDs collected), and note that this
differs from collectFocusableIdsInSubtree which stops at zones; reference the
function name collectFocusableIdsInTrapSubtree and the contrasting
collectFocusableIdsInSubtree so future readers understand the deliberate
behavior.

In `@packages/core/src/runtime/widgetMeta/focusInfo.ts`:
- Around line 96-135: readFocusableVisibleLabel currently omits a branch for
vnode.kind === "input" even though kindToAnnouncementPrefix recognizes "input";
update readFocusableVisibleLabel to handle the "input" kind by using the input's
visible label sources (e.g., label element, aria-label, aria-labelledby) and
fall back to the vnode.props.placeholder when no other visible label exists so
placeholders serve as fallback visible labels for input widgets.

In `@packages/core/src/runtime/widgetMeta/helpers.ts`:
- Around line 193-210: The InputMeta creation unnecessarily branches to either
freeze metaBase or a spread including optional callbacks; simplify by always
constructing a single object that starts from metaBase and conditionally spreads
onInput/onBlur when present, then call Object.freeze once and store it (replace
the ternary that defines meta with a single Object.freeze({ ...metaBase,
...(onInput ? { onInput } : {}), ...(onBlur ? { onBlur } : {}) }) and keep the
subsequent m.set(id, meta) call); update references to metaBase/meta in this
function accordingly.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 1117dede-2602-4115-90e1-09711ed69110

📥 Commits

Reviewing files that changed from the base of the PR and between 49f2997 and be1515b.

📒 Files selected for processing (5)
  • packages/core/src/runtime/widgetMeta.ts
  • packages/core/src/runtime/widgetMeta/collector.ts
  • packages/core/src/runtime/widgetMeta/focusContainers.ts
  • packages/core/src/runtime/widgetMeta/focusInfo.ts
  • packages/core/src/runtime/widgetMeta/helpers.ts

Comment on lines +176 to +185
if (proto.disableable) {
const disabled = (vnode.props as { disabled?: unknown }).disabled;
enabled = disabled !== true;
}
if (proto.openGated) {
const open = (vnode.props as { open?: unknown }).open;
enabled = open === true;
}
this._enabledById.set(interactiveId, enabled);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Same logic issue: openGated overwrites disableable result.

This has the same bug as collectEnabledMap in helpers.ts (lines 101-113). The enabled variable should combine both checks.

🐛 Proposed fix
         if (proto.disableable) {
           const disabled = (vnode.props as { disabled?: unknown }).disabled;
           enabled = disabled !== true;
         }
         if (proto.openGated) {
           const open = (vnode.props as { open?: unknown }).open;
-          enabled = open === true;
+          enabled = enabled && open === true;
         }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/src/runtime/widgetMeta/collector.ts` around lines 176 - 185,
The current logic in collector.ts lets proto.openGated overwrite the result from
proto.disableable; update the checks so they are combined instead of
overwritten: when handling proto.disableable set enabled = disabled !== true as
now, and when handling proto.openGated update enabled by combining with the
prior value (e.g., enabled = enabled && (open === true)) before calling
this._enabledById.set(interactiveId, enabled); reference the
proto.disableable/proto.openGated branches, vnode.props disabled/open reads,
interactiveId, and this._enabledById.set to locate where to apply the fix.

Comment on lines +101 to +113
const id = readInteractiveId(node.vnode);
if (id !== null && !m.has(id)) {
let enabled = true;
const proto = getWidgetProtocol(node.vnode.kind);
if (proto.disableable) {
const disabled = (node.vnode.props as { disabled?: unknown }).disabled;
enabled = disabled !== true;
}
if (proto.openGated) {
const open = (node.vnode.props as { open?: unknown }).open;
enabled = open === true;
}
m.set(id, enabled);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Potential logic issue: openGated check overwrites disableable result.

When a widget is both disableable and openGated, the enabled variable is first set based on disabled !== true, then unconditionally overwritten by open === true. This means a widget that is open=true but disabled=true would incorrectly show as enabled.

Compare with isEnabledInteractive (lines 23-39) which returns null (disabled) if either condition fails. The logic here should likely use && or early returns to preserve both checks.

🐛 Proposed fix to preserve both checks
       if (proto.disableable) {
         const disabled = (node.vnode.props as { disabled?: unknown }).disabled;
         enabled = disabled !== true;
       }
       if (proto.openGated) {
         const open = (node.vnode.props as { open?: unknown }).open;
-        enabled = open === true;
+        enabled = enabled && open === true;
       }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/src/runtime/widgetMeta/helpers.ts` around lines 101 - 113, The
current logic in the block that reads id = readInteractiveId(node.vnode) and
uses getWidgetProtocol(node.vnode.kind) lets the openGated check overwrite the
prior disableable check; change the logic so both checks are combined and a
widget is enabled only if it passes all applicable gates. Concretely, compute
enabled by evaluating both gates (for example: if proto.disableable, require
(props.disabled !== true); if proto.openGated, require (props.open === true))
and set enabled to the AND of those results before calling m.set(id, enabled),
referencing readInteractiveId, getWidgetProtocol, node.vnode.props and m.set to
locate the code.

@RtlZeroMemory RtlZeroMemory merged commit 2980659 into main Mar 17, 2026
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