Skip to content

fix(core): harden runtime lifecycle and layer routing#265

Merged
RtlZeroMemory merged 6 commits intomainfrom
codex/runtime-lifecycle-hardening
Mar 6, 2026
Merged

fix(core): harden runtime lifecycle and layer routing#265
RtlZeroMemory merged 6 commits intomainfrom
codex/runtime-lifecycle-hardening

Conversation

@RtlZeroMemory
Copy link
Owner

@RtlZeroMemory RtlZeroMemory commented Mar 6, 2026

Summary

  • harden app startup, shutdown, fatal-callback handling, and sync frame follow-up scheduling
  • validate focus container ids, publish stable metadata/local-state snapshots, and tighten layer escape ownership
  • fix perf ring-buffer max rollover, sync lifecycle docs/changelog, and add regression coverage

Verification

  • npx tsx --test packages/core/src/app/tests/terminalProfile.test.ts packages/core/src/app/tests/syncFrameAckFastPath.test.ts packages/core/src/app/tests/onEventHandlers.test.ts packages/core/src/app/tests/onFocusChange.test.ts
  • npx tsx --test packages/core/src/runtime/tests/focus.layers.test.ts packages/core/src/runtime/tests/localStateStore.test.ts packages/core/src/runtime/tests/widgetMeta.test.ts packages/core/src/runtime/tests/duplicateIdFatal.test.ts packages/core/src/perf/tests/perf.test.ts
  • TSX_DISABLE_CACHE=1 npx tsx --test packages/core/src/keybindings/tests/keybinding.conflicts.test.ts packages/core/src/widgets/tests/layers.golden.test.ts packages/core/src/widgets/tests/overlay.edge.test.ts packages/core/src/widgets/tests/modal.focus.test.ts

Summary by CodeRabbit

  • Documentation

    • Updated lifecycle/run guidance, HSR behavior, and global event examples to use app.onEvent(...) and ev.detail.
  • Bug Fixes

    • Better error reporting and handling for focus/container ID validation, event and focus-change handler failures, startup/terminal-profile edge cases, ESC/top-layer ownership, and rendering/lifecycle fault paths.
  • API Changes

    • Input metadata now includes a readOnly flag; event emission semantics clarified.
  • Tests

    • Expanded coverage for lifecycle, focus, routing, perf, error-drain, terminal-profile, and rendering/coalescing scenarios.

@coderabbitai
Copy link

coderabbitai bot commented Mar 6, 2026

Caution

Review failed

Pull request was closed or merged during review

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Tightens app lifecycle and render coalescing, centralizes thrown-value formatting, enforces unique focus-container IDs, changes top-layer-only Escape routing semantics, hardens widget-renderer focus/scroll/layout behavior, makes local-state snapshots immutable, and adds extensive tests across lifecycle, routing, perf, and renderer paths.

Changes

Cohort / File(s) Summary
Changelog & Docs
CHANGELOG.md, docs/guide/lifecycle-and-updates.md
Updated Unreleased entries; clarified run() signal registration behavior, HSR replaceView/replaceRoutes scope, and global event API examples.
App orchestration & lifecycle
packages/core/src/app/createApp.ts, packages/core/src/app/topLevelViewError.ts, packages/core/src/app/rawRenderer.ts
Added lifecycleGeneration/backendStarted guards, render-request coalescing and deferred-render bookkeeping; replaced enqueueFatal calls with fatalNowOrEnqueue; adjusted emit/release/process/drain signatures; improved backend abort/cleanup and teardown flows; switched to shared describeThrown formatting.
App tests
packages/core/src/app/__tests__/*
New and updated tests covering event-batch fault draining, onEvent/onFocusChange failure semantics, sync frame-ack follow-ups, terminal-profile/startup lifecycle edge cases, and onUserCodeError resilience.
Error formatting utility
packages/core/src/debug/describeThrown.ts
New exported helper describeThrown(value) to consistently stringify thrown values; modules now import it instead of local helpers.
Widget renderer & layout
packages/core/src/app/widgetRenderer.ts
Preserve trapReturnFocusById in FocusManagerState; route focus-zone callback errors into user-code error sink; gather multiple scrollable ancestors for wheel routing; always rebuild hidden constraint state; increase settle-pass cap based on graph size.
Commit / focus-container validation
packages/core/src/runtime/commit.ts, packages/core/src/runtime/__tests__/*
Add FocusContainerKind, isFocusContainerVNode, ensureFocusContainerId; track seenFocusContainerIds through commit pipeline; surface ZRUI_INVALID_PROPS / ZRUI_DUPLICATE_ID fatal errors; use describeThrown for error messages.
Widget metadata & inputs
packages/core/src/runtime/widgetMeta.ts, packages/core/src/runtime/__tests__/*
Detect and error on duplicate focus-container IDs, scope focusable attribution for traps/zones, propagate input readOnly into InputMeta, and add createWidgetMetadataCollector/export plus tests.
Router / Escape handling
packages/core/src/runtime/router/layer.ts, packages/core/src/runtime/router/types.ts, packages/core/src/widgets/__tests__/*, packages/core/src/keybindings/__tests__/*
routeLayerEscape now targets the topmost layer only; top layer owns Escape (consumed:true) when it won't close; returns callbackError when onClose throws; tests updated to match new top-layer ownership semantics.
Local state immutability
packages/core/src/runtime/localState.ts, packages/core/src/runtime/__tests__/localStateStore.test.ts
Added ReadonlyMap/Set snapshot classes and clone helpers; virtual-list and tree stores now store cloned read-only snapshots to prevent external mutation; tests added.
Perf changes & tests
packages/core/src/perf/perf.ts, packages/core/src/perf/__tests__/perf.test.ts
Removed PhaseRing.max field; compute max from active samples; added rolling-snapshot and perfReset tests.
Focus / layer / widget tests
packages/core/src/runtime/*, packages/core/src/widgets/*
Multiple test updates for ESC routing semantics, duplicate-id fatalities, focus-layer behavior, and overlay/escape ordering to reflect changed routing and consumption rules.

Sequence Diagram(s)

sequenceDiagram
  participant Client as App API
  participant Scheduler as Lifecycle Scheduler
  participant Backend as Backend
  participant Renderer as Renderer
  participant FatalSink as Fatal Queue

  Client->>Scheduler: start()/emit(event)
  Scheduler->>Backend: start() (guarded by lifecycleGeneration)
  Backend-->>Scheduler: started / start-failed
  alt start aborted or failed
    Scheduler->>Backend: cleanupStartedBackendAfterAbort()
    Scheduler->>FatalSink: fatalNowOrEnqueue(fatal)
  else started
    Scheduler->>Renderer: scheduleRender() (coalesced / deferred)
    Renderer->>Scheduler: commit/render (may schedule follow-up)
    alt user-code throws in event/commit/render
      Renderer->>FatalSink: fatalNowOrEnqueue(fatal)
      Scheduler->>Backend: stop()/dispose() (teardown)
    end
  end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Poem

🐰 I hopped through stacks and guarded the start,
IDs made unique so no two may part,
Renders queue gentle, faults drained in their turn,
I nibbled the edges where tricky bits burn,
Tests clap their paws — a tidy release, my art.

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 15.09% 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(core): harden runtime lifecycle and layer routing' directly aligns with the primary objectives of the PR, which include hardening app startup/shutdown, fixing lifecycle handling, and tightening layer escape ownership.

✏️ 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 codex/runtime-lifecycle-hardening

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

Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: fef7139e0c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

rectById: this.rectById,
scrollOverrides: this.scrollOverrides,
findNearestScrollableAncestor: (targetId) => this.findNearestScrollableAncestor(targetId),
findScrollableAncestors: (targetId) => this.findScrollableAncestors(targetId),

Choose a reason for hiding this comment

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

P1 Badge Pass wheel ancestor callback under expected key

routeMouseWheel still reads ctx.findNearestScrollableAncestor (see packages/core/src/app/widgetRenderer/mouseRouting.ts), but this call site now passes the callback as findScrollableAncestors, so the wheel fallback path will call undefined and throw on mouse-wheel events that reach generic scroll-container routing. In production this turns a normal wheel input into a fatal widget routing threw error instead of scrolling.

Useful? React with 👍 / 👎.

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: 6

Caution

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

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

2389-2402: ⚠️ Potential issue | 🟠 Major

Guard focus-zone error reporting against sink failures.

At Line 2390 and Line 2401, reportUserCodeError(...) is called inside catch blocks without a guard. If the sink throws, routing can fail while handling an already-caught widget callback error.

🛡️ Suggested hardening
-        } catch (error: unknown) {
-          this.reportUserCodeError(`focusZone onExit threw: ${describeThrown(error)}`);
+        } catch (error: unknown) {
+          this.reportFocusZoneCallbackError("onExit", error);
         }
@@
-        } catch (error: unknown) {
-          this.reportUserCodeError(`focusZone onEnter threw: ${describeThrown(error)}`);
+        } catch (error: unknown) {
+          this.reportFocusZoneCallbackError("onEnter", error);
         }
private reportFocusZoneCallbackError(phase: "onEnter" | "onExit", error: unknown): void {
  const detail = `focusZone ${phase} threw: ${describeThrown(error)}`;
  try {
    this.reportUserCodeError(detail);
  } catch (sinkError: unknown) {
    const c = (globalThis as { console?: { error?: (message: string) => void } }).console;
    c?.error?.(
      `[rezi][runtime] onUserCodeError sink threw while reporting focusZone ${phase}: ${describeThrown(
        sinkError,
      )}; original=${detail}`,
    );
  }
}

As per coding guidelines: **/*.ts: All code changes must comply with docs/dev/code-standards.md including TypeScript strictness, runtime/layout/reconciliation invariants, and callback safety with async cancellation.

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

In `@packages/core/src/app/widgetRenderer.ts` around lines 2389 - 2402, The catch
blocks that call reportUserCodeError directly (around the focus-zone onExit and
onEnter handlers) must be hardened so a failing sink cannot bubble while
handling an already-caught widget error; add a helper method (e.g.,
reportFocusZoneCallbackError(phase: "onEnter" | "onExit", error: unknown)) that
builds the detail string using describeThrown(error), calls
this.reportUserCodeError(detail) inside its own try/catch, and on sink failure
logs a safe fallback to globalThis.console.error including the sink error (via
describeThrown) and the original detail; replace the two direct
reportUserCodeError(...) calls with calls to
reportFocusZoneCallbackError("onExit", error) and
reportFocusZoneCallbackError("onEnter", error).

2125-2141: ⚠️ Potential issue | 🔴 Critical

Build-blocking wheel-routing context mismatch at line 2140: property name and return type incompatible.

The call passes findScrollableAncestors (plural, returns array) but RouteMouseWheelContext expects findNearestScrollableAncestor (singular, returns single object or null). This is a TypeScript compile error (TS2561 type incompatibility).

Fix
-      findScrollableAncestors: (targetId) => this.findScrollableAncestors(targetId),
+      findNearestScrollableAncestor: (targetId: string | null) =>
+        this.findScrollableAncestors(targetId)[0] ?? null,
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/src/app/widgetRenderer.ts` around lines 2125 - 2141, The
routeMouseWheel call is passing this.findScrollableAncestors (plural, returns an
array) but RouteMouseWheelContext expects findNearestScrollableAncestor
(singular, returns one element or null); fix by passing a wrapper named
findNearestScrollableAncestor that calls this.findScrollableAncestors(targetId)
and returns the first result or null, and supply that wrapper in the
routeMouseWheel context (ensure the property key is
findNearestScrollableAncestor), so update the object passed to routeMouseWheel
accordingly (referencing routeMouseWheel, RouteMouseWheelContext,
findScrollableAncestors, and findNearestScrollableAncestor).
packages/core/src/runtime/commit.ts (1)

1972-1980: ⚠️ Potential issue | 🟡 Minor

Apply formatter output in the commit path block

CI reports formatting drift in this segment; please apply formatter output to unblock checks.

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

In `@packages/core/src/runtime/commit.ts` around lines 1972 - 1980, The code in
the commit path around ensureInteractiveId, ensureFocusContainerId and the
lifecycle lists (ctx.collectLifecycleInstanceIds with ctx.lists.reused and
ctx.lists.mounted) has formatting drift; run and apply the project's formatter
(prettier/clang-format/ruff as used by the repo) to this block so
spacing/indentation/braces match CI expectations, ensuring the if/else and
return lines for idFatal, focusContainerFatal and the prev branch are formatted
consistently with the rest of the file.
packages/core/src/app/createApp.ts (2)

25-59: ⚠️ Potential issue | 🟡 Minor

Resolve formatter/import-order CI failures in this file

CI is still failing on import ordering and formatting for this segment; please apply formatter output so checks pass.

Also applies to: 691-696

🤖 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 25 - 59, The imports in
createApp.ts are mis-ordered/formatted and causing CI failures; reorder and
reformat this import block to match the project's import-style rules (group and
sort imports consistently and keep type-only imports together), then run the
project formatter/linter (e.g. prettier/eslint --fix or the repo's format
script) to apply exact formatting. Focus on the listed symbols to locate the
block: ZrUiError/ZrUiErrorCode, BACKEND_* markers and
BackendEventBatch/RuntimeBackend, UiEvent/ZrevEvent,
BindingMap/KeyContext/KeybindingManagerState/ModeBindingMap, the keybinding
helpers
(createManagerState/getBindings/getMode/getPendingChord/registerBindings/registerModes/resetChordState/routeKeyEvent/setMode),
ZR_MOD_CTRL, ResponsiveBreakpointThresholds/normalizeBreakpointThresholds, Rect,
describeThrown, and PERF_ENABLED/perfMarkStart/perfMarkEnd/perfNow/perfRecord;
also fix the similar ordering/formatting issue referenced around lines 691-696
by applying the same reformatting. Ensure no manual stylistic deviations so CI
formatting checks pass.

1491-1501: ⚠️ Potential issue | 🟠 Major

Prevent duplicate batch.release() calls when a turn aborts mid-iteration

At Line 1514 and Line 1535, drainIgnored(items) drains the full turn payload, including already-processed eventBatch entries that were already released inside processEventBatch(). With inline fatal transitions, this can double-release backend resources.

🔧 Proposed fix
-function drainIgnored(items: readonly WorkItem[]): void {
-  for (const it of items) {
+function drainIgnored(items: readonly WorkItem[], fromIndex = 0): void {
+  for (let i = fromIndex; i < items.length; i++) {
+    const it = items[i];
     if (it.kind === "eventBatch") {
       try {
         it.batch.release();
       } catch {
         // ignore
@@
 function processTurn(items: readonly WorkItem[]): void {
   renderRequestQueuedForCurrentTurn = false;
   const st = sm.state;
   if (st === "Disposed" || st === "Faulted") {
     drainIgnored(items);
     return;
   }

   let sawKick = false;
-  for (const item of items) {
+  for (let index = 0; index < items.length; index++) {
+    const item = items[index];
     if (sm.state === "Faulted" || sm.state === "Disposed") {
-      drainIgnored(items);
+      drainIgnored(items, index);
       return;
     }

     switch (item.kind) {
       case "fatal": {
         doFatal(item.code, item.detail);
-        drainIgnored(items);
+        drainIgnored(items, index + 1);
         return;
       }
@@
         processEventBatch(item.batch);
         if (sm.state !== "Running") {
-          drainIgnored(items);
+          drainIgnored(items, index + 1);
           return;
         }

Based on learnings: Applies to packages/core/src/runtime/{commit.ts,reconcile.ts,router/wheel.ts}|packages/core/src/app/createApp.ts|packages/core/src/layout/|packages/core/src/renderer/|packages/core/src/drawlist/|packages/core/src/binary/ : Extra care required when modifying danger zones: runtime commit/reconcile, wheel router, app creation, layout engine, renderer, drawlist, and binary modules; must verify runtime invariants and reconciliation behavior.

Also applies to: 1512-1515, 1520-1522, 1534-1536

🤖 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 1491 - 1501, drainIgnored
currently unconditionally calls batch.release() for every WorkItem of kind
"eventBatch", which can double-release resources that were already released
inside processEventBatch; change drainIgnored to skip releasing batches that
have already been released by tracking identity: maintain a local Set (e.g.,
releasedBatches) or check a processed flag on the WorkItem before calling
batch.release(), and ensure processEventBatch marks the batch (or WorkItem) as
released/processed when it releases it so drainIgnored can detect and avoid
calling batch.release() again; update references to the WorkItem/eventBatch
handling accordingly to prevent duplicate releases.
🧹 Nitpick comments (2)
packages/core/src/runtime/__tests__/localStateStore.test.ts (1)

35-66: Add a regression case for mutating the returned snapshot objects.

Consider also mutating state.measuredHeights / state.loadingKeys / state.expandedSet after set() and asserting store.get(...) remains unchanged. That would guard the full “stable snapshot” contract.

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

In `@packages/core/src/runtime/__tests__/localStateStore.test.ts` around lines 35
- 66, Add assertions that mutating the snapshot objects returned from set() does
not affect the store by mutating the state returned from set() and then
verifying store.get(...) remains unchanged; specifically, after calling
createVirtualListStateStore().set("list", { measuredHeights }) mutate
state.measuredHeights (e.g., set a new key) and assert
store.get("list").measuredHeights does not contain that new key, and likewise
after createTreeStateStore().set("tree", { loadingKeys, expandedSet,
expandedSetRef: Object.freeze([...]) }) mutate state.loadingKeys and
state.expandedSet and assert store.get("tree").loadingKeys and
store.get("tree").expandedSet do not reflect those mutations to ensure snapshots
are stable.
packages/core/src/widgets/__tests__/layers.golden.test.ts (1)

816-818: Add consumed assertion for the closeOnEscape=false case

This test now validates “not closed,” but it no longer locks in Escape consumption/ownership. Adding that assertion would prevent silent routing regressions.

✅ Suggested test hardening
     assert.equal(result.closedLayerId, undefined);
     assert.equal(closedLayer, null);
+    assert.equal(result.consumed, true);

Based on learnings: Applies to **/*.{ts,tsx,jsx} : Modal overlays must support close with Escape key.

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

In `@packages/core/src/widgets/__tests__/layers.golden.test.ts` around lines 816 -
818, The test checks that the layer was not closed
(assert.equal(result.closedLayerId, undefined); assert.equal(closedLayer, null))
but doesn’t assert that the Escape key was not consumed; add an assertion that
verifies Escape was not consumed/owned in the closeOnEscape=false case (e.g.,
assert.equal(result.consumed['Escape'], false) or the appropriate shape used in
this test harness) so the test locks in that Escape is not captured when
closeOnEscape is false; update the test near the existing asserts for
closedLayer and closedLayerId, referencing result and the Escape key consumption
field.
🤖 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/createApp/topLevelViewError.ts`:
- Around line 1-4: The import statements in topLevelViewError.ts are not in the
project's expected order; reorder them to satisfy the linter/formatter (group
and sort imports per project rules) so that imports are consistently ordered
(e.g., external packages, then local modules, then types, each alphabetized) —
specifically reorder the imports for ZrevEvent, describeThrown,
ZR_MOD_ALT|CTRL|META|SHIFT, and VNode into the correct sorted/grouped order and
re-run the formatter/linter to confirm the import-order error is resolved.

In `@packages/core/src/runtime/__tests__/widgetMeta.test.ts`:
- Around line 381-387: The block containing ui.input({ id: "first-input", ...
}), ui.focusZone({ id: "zone-a" }, ...), and ui.focusTrap({ id: "trap-a",
active: true }, ...) is misformatted and failing CI; re-run the repository
formatter (or manually reformat) so the widget list and their nested arrays
match the project's formatting rules (ensure commas, indentation, and line
breaks around the array elements and trailing commas match formatter output) for
the test file's widgetMeta test where ui.input, ui.focusZone, and ui.focusTrap
are defined.

In `@packages/core/src/runtime/commit.ts`:
- Around line 544-556: The code references a non-existent VNode kind "fragment"
causing TS errors; remove fragment-specific handling and utility(s) from
commit.ts: delete or stop calling fragmentPropsEqual and remove the "fragment"
case in canFastReuseContainerSelf plus any other switch/case branches and
comparisons that check prev.kind === "fragment" (and mirror usages elsewhere in
this file such as the other case blocks around the other reported locations),
ensuring remaining branches only handle the canonical VNode kinds defined in
widgets/types.ts; update any calls that relied on fragmentPropsEqual to use the
existing props comparison logic for the actual VNode kinds or inline the
appropriate equality check.

In `@packages/core/src/runtime/localState.ts`:
- Around line 91-97: The cloneReadonlyMap and cloneReadonlySet functions return
mutable Map/Set instances that leak through frozen state objects; change them to
freeze the cloned collections before returning (e.g., create the clone with new
Map(value) / new Set(value), call Object.freeze on that instance, and return it
as a ReadonlyMap/ReadonlySet) so fields like measuredHeights, loadingKeys, and
expandedSet embedded in Object.freeze(state) cannot be mutated; update the
return to the appropriate Readonly type (or cast) while keeping function names
cloneReadonlyMap and cloneReadonlySet.

In `@packages/core/src/runtime/router/layer.ts`:
- Around line 49-56: Update the LayerRoutingResult type to include the optional
callbackError property so the object returned from routeLayerEscape can carry
the caught error; locate the LayerRoutingResult definition (types.ts) and add
callbackError?: unknown (or Error) to its shape, then ensure any callers or
tests (e.g., routeLayerEscape in layer.ts which returns { consumed: true,
callbackError: error }) compile with the updated type.

In `@packages/core/src/runtime/widgetMeta.ts`:
- Around line 860-869: The loop that attributes focusables to the nearest zone
must stop when an enclosing trap/modal is encountered so trap-contained
focusables are not added to outer zones; update the scan in the method using
_containerStack and _zoneFocusables (the loop shown) to first check if
container.kind is "trap" (or the modal kind used in your code) and break without
attributing, otherwise if container.kind === "zone" push the focusableId into
_zoneFocusables.get(container.id) and break — this mirrors collectFocusZones()
behavior and prevents adding trap-contained focusables to outer zones.

---

Outside diff comments:
In `@packages/core/src/app/createApp.ts`:
- Around line 25-59: The imports in createApp.ts are mis-ordered/formatted and
causing CI failures; reorder and reformat this import block to match the
project's import-style rules (group and sort imports consistently and keep
type-only imports together), then run the project formatter/linter (e.g.
prettier/eslint --fix or the repo's format script) to apply exact formatting.
Focus on the listed symbols to locate the block: ZrUiError/ZrUiErrorCode,
BACKEND_* markers and BackendEventBatch/RuntimeBackend, UiEvent/ZrevEvent,
BindingMap/KeyContext/KeybindingManagerState/ModeBindingMap, the keybinding
helpers
(createManagerState/getBindings/getMode/getPendingChord/registerBindings/registerModes/resetChordState/routeKeyEvent/setMode),
ZR_MOD_CTRL, ResponsiveBreakpointThresholds/normalizeBreakpointThresholds, Rect,
describeThrown, and PERF_ENABLED/perfMarkStart/perfMarkEnd/perfNow/perfRecord;
also fix the similar ordering/formatting issue referenced around lines 691-696
by applying the same reformatting. Ensure no manual stylistic deviations so CI
formatting checks pass.
- Around line 1491-1501: drainIgnored currently unconditionally calls
batch.release() for every WorkItem of kind "eventBatch", which can
double-release resources that were already released inside processEventBatch;
change drainIgnored to skip releasing batches that have already been released by
tracking identity: maintain a local Set (e.g., releasedBatches) or check a
processed flag on the WorkItem before calling batch.release(), and ensure
processEventBatch marks the batch (or WorkItem) as released/processed when it
releases it so drainIgnored can detect and avoid calling batch.release() again;
update references to the WorkItem/eventBatch handling accordingly to prevent
duplicate releases.

In `@packages/core/src/app/widgetRenderer.ts`:
- Around line 2389-2402: The catch blocks that call reportUserCodeError directly
(around the focus-zone onExit and onEnter handlers) must be hardened so a
failing sink cannot bubble while handling an already-caught widget error; add a
helper method (e.g., reportFocusZoneCallbackError(phase: "onEnter" | "onExit",
error: unknown)) that builds the detail string using describeThrown(error),
calls this.reportUserCodeError(detail) inside its own try/catch, and on sink
failure logs a safe fallback to globalThis.console.error including the sink
error (via describeThrown) and the original detail; replace the two direct
reportUserCodeError(...) calls with calls to
reportFocusZoneCallbackError("onExit", error) and
reportFocusZoneCallbackError("onEnter", error).
- Around line 2125-2141: The routeMouseWheel call is passing
this.findScrollableAncestors (plural, returns an array) but
RouteMouseWheelContext expects findNearestScrollableAncestor (singular, returns
one element or null); fix by passing a wrapper named
findNearestScrollableAncestor that calls this.findScrollableAncestors(targetId)
and returns the first result or null, and supply that wrapper in the
routeMouseWheel context (ensure the property key is
findNearestScrollableAncestor), so update the object passed to routeMouseWheel
accordingly (referencing routeMouseWheel, RouteMouseWheelContext,
findScrollableAncestors, and findNearestScrollableAncestor).

In `@packages/core/src/runtime/commit.ts`:
- Around line 1972-1980: The code in the commit path around ensureInteractiveId,
ensureFocusContainerId and the lifecycle lists (ctx.collectLifecycleInstanceIds
with ctx.lists.reused and ctx.lists.mounted) has formatting drift; run and apply
the project's formatter (prettier/clang-format/ruff as used by the repo) to this
block so spacing/indentation/braces match CI expectations, ensuring the if/else
and return lines for idFatal, focusContainerFatal and the prev branch are
formatted consistently with the rest of the file.

---

Nitpick comments:
In `@packages/core/src/runtime/__tests__/localStateStore.test.ts`:
- Around line 35-66: Add assertions that mutating the snapshot objects returned
from set() does not affect the store by mutating the state returned from set()
and then verifying store.get(...) remains unchanged; specifically, after calling
createVirtualListStateStore().set("list", { measuredHeights }) mutate
state.measuredHeights (e.g., set a new key) and assert
store.get("list").measuredHeights does not contain that new key, and likewise
after createTreeStateStore().set("tree", { loadingKeys, expandedSet,
expandedSetRef: Object.freeze([...]) }) mutate state.loadingKeys and
state.expandedSet and assert store.get("tree").loadingKeys and
store.get("tree").expandedSet do not reflect those mutations to ensure snapshots
are stable.

In `@packages/core/src/widgets/__tests__/layers.golden.test.ts`:
- Around line 816-818: The test checks that the layer was not closed
(assert.equal(result.closedLayerId, undefined); assert.equal(closedLayer, null))
but doesn’t assert that the Escape key was not consumed; add an assertion that
verifies Escape was not consumed/owned in the closeOnEscape=false case (e.g.,
assert.equal(result.consumed['Escape'], false) or the appropriate shape used in
this test harness) so the test locks in that Escape is not captured when
closeOnEscape is false; update the test near the existing asserts for
closedLayer and closedLayerId, referencing result and the Escape key consumption
field.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: dddb7618-89a5-4258-b9ef-8d165e7317ef

📥 Commits

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

📒 Files selected for processing (25)
  • CHANGELOG.md
  • docs/guide/lifecycle-and-updates.md
  • packages/core/src/app/__tests__/onEventHandlers.test.ts
  • packages/core/src/app/__tests__/onFocusChange.test.ts
  • packages/core/src/app/__tests__/syncFrameAckFastPath.test.ts
  • packages/core/src/app/__tests__/terminalProfile.test.ts
  • packages/core/src/app/createApp.ts
  • packages/core/src/app/createApp/topLevelViewError.ts
  • packages/core/src/app/rawRenderer.ts
  • packages/core/src/app/widgetRenderer.ts
  • packages/core/src/debug/describeThrown.ts
  • packages/core/src/keybindings/__tests__/keybinding.conflicts.test.ts
  • packages/core/src/perf/__tests__/perf.test.ts
  • packages/core/src/perf/perf.ts
  • packages/core/src/runtime/__tests__/duplicateIdFatal.test.ts
  • packages/core/src/runtime/__tests__/focus.layers.test.ts
  • packages/core/src/runtime/__tests__/localStateStore.test.ts
  • packages/core/src/runtime/__tests__/widgetMeta.test.ts
  • packages/core/src/runtime/commit.ts
  • packages/core/src/runtime/localState.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/__tests__/overlay.edge.test.ts

Comment on lines 1 to 4
import type { ZrevEvent } from "../../events.js";
import { describeThrown } from "../../debug/describeThrown.js";
import { ZR_MOD_ALT, ZR_MOD_CTRL, ZR_MOD_META, ZR_MOD_SHIFT } from "../../keybindings/keyCodes.js";
import type { VNode } from "../../widgets/types.js";
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Sort imports to satisfy CI

This file still has an import-order failure in CI; please apply formatter/linter ordering.

🧰 Tools
🪛 GitHub Actions: ci

[error] 1-4: Import statements could be sorted.

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

In `@packages/core/src/app/createApp/topLevelViewError.ts` around lines 1 - 4, The
import statements in topLevelViewError.ts are not in the project's expected
order; reorder them to satisfy the linter/formatter (group and sort imports per
project rules) so that imports are consistently ordered (e.g., external
packages, then local modules, then types, each alphabetized) — specifically
reorder the imports for ZrevEvent, describeThrown, ZR_MOD_ALT|CTRL|META|SHIFT,
and VNode into the correct sorted/grouped order and re-run the formatter/linter
to confirm the import-order error is resolved.

Comment on lines +860 to +869
// Attribute to the nearest zone and nearest enclosing trap. Focusables
// inside zones still participate in an enclosing trap's focus scope.
for (let i = this._containerStack.length - 1; i >= 0; i--) {
const container = this._containerStack[i];
if (!container) continue;
if (container.kind === "zone") {
this._zoneFocusables.get(container.id)?.push(focusableId);
break;
}
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Do not attribute trap-contained focusables to outer zones

The zone-attribution scan currently walks past enclosing traps/modals, so focusables inside a trap can be added to an outer zone. That breaks zone scoping parity with collectFocusZones() behavior and can produce incorrect zone focus sets.

🔧 Proposed fix
-        for (let i = this._containerStack.length - 1; i >= 0; i--) {
+        for (let i = this._containerStack.length - 1; i >= 0; i--) {
           const container = this._containerStack[i];
           if (!container) continue;
+          if (container.kind === "trap") {
+            // Trap/modal boundaries own local focus scope.
+            break;
+          }
           if (container.kind === "zone") {
             this._zoneFocusables.get(container.id)?.push(focusableId);
             break;
           }
         }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/src/runtime/widgetMeta.ts` around lines 860 - 869, The loop
that attributes focusables to the nearest zone must stop when an enclosing
trap/modal is encountered so trap-contained focusables are not added to outer
zones; update the scan in the method using _containerStack and _zoneFocusables
(the loop shown) to first check if container.kind is "trap" (or the modal kind
used in your code) and break without attributing, otherwise if container.kind
=== "zone" push the focusableId into _zoneFocusables.get(container.id) and break
— this mirrors collectFocusZones() behavior and prevents adding trap-contained
focusables to outer zones.

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: 1

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/widgetRenderer.ts (1)

2389-2401: ⚠️ Potential issue | 🟠 Major

Guard reportUserCodeError here to avoid secondary sink failures escaping routing.

onExit/onEnter callback throws are now reported, but if onUserCodeError itself throws, this path can still bubble unexpectedly. Please mirror the sink-guard pattern already used in reportInputCallbackError.

Suggested patch
     if (prevZoneId !== null) {
       const prev = prevZones.get(prevZoneId);
       if (prev?.onExit) {
         try {
           prev.onExit();
         } catch (error: unknown) {
-          this.reportUserCodeError(`focusZone onExit threw: ${describeThrown(error)}`);
+          const detail = `focusZone onExit threw: ${describeThrown(error)}`;
+          try {
+            this.reportUserCodeError(detail);
+          } catch (sinkError: unknown) {
+            warnDev(
+              `[rezi][runtime] onUserCodeError sink threw while reporting focusZone onExit: ${describeThrown(sinkError)}; original=${detail}`,
+            );
+          }
         }
       }
     }
@@
     if (nextZoneId !== null) {
       const next = nextZones.get(nextZoneId);
       if (next?.onEnter) {
         try {
           next.onEnter();
         } catch (error: unknown) {
-          this.reportUserCodeError(`focusZone onEnter threw: ${describeThrown(error)}`);
+          const detail = `focusZone onEnter threw: ${describeThrown(error)}`;
+          try {
+            this.reportUserCodeError(detail);
+          } catch (sinkError: unknown) {
+            warnDev(
+              `[rezi][runtime] onUserCodeError sink threw while reporting focusZone onEnter: ${describeThrown(sinkError)}; original=${detail}`,
+            );
+          }
         }
       }
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/src/app/widgetRenderer.ts` around lines 2389 - 2401, The calls
to this.reportUserCodeError after catching exceptions from focus zone callbacks
(the reportUserCodeError invocations in the current.onExit and next.onEnter
handling) must be wrapped with the same sink-guard used by
reportInputCallbackError so a failure inside reportUserCodeError itself cannot
escape; update both spots to catch any error thrown by this.reportUserCodeError
and forward that secondary error into this.reportInputCallbackError (or
otherwise swallow it safely) so routing cannot be broken by a reporting failure.
packages/core/src/runtime/commit.ts (1)

1678-1687: ⚠️ Potential issue | 🟠 Major

Use describeThrown for this remaining user-throw path.

Line 1683 still manually formats thrown values while other paths now use describeThrown, leaving error handling behavior inconsistent in the same lifecycle flow.

💡 Proposed fix
       if (evalRes.threw !== null) {
         return {
           ok: false,
           fatal: {
             code: "ZRUI_USER_CODE_THROW",
-            detail:
-              evalRes.threw instanceof Error
-                ? `${evalRes.threw.name}: ${evalRes.threw.message}`
-                : String(evalRes.threw),
+            detail: describeThrown(evalRes.threw),
           },
         };
       }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/src/runtime/commit.ts` around lines 1678 - 1687, The
user-thrown handling branch in commitEvaluation (the evalRes.threw !== null
branch) manually formats thrown values; replace that manual formatting with a
call to describeThrown so it matches other paths: in the branch that returns
fatal { code: "ZRUI_USER_CODE_THROW", detail: ... } use
describeThrown(evalRes.threw) (or the equivalent helper used elsewhere) to
produce the detail string and keep the surrounding return structure unchanged,
ensuring you import or reference describeThrown the same way other handlers do.
🧹 Nitpick comments (1)
packages/core/src/app/widgetRenderer.ts (1)

4005-4008: Consider adding an upper hard cap to settle passes to bound worst-case frame time.

nodes.length + 1 improves deep convergence, but for very large graphs it can produce long synchronous frames. A bounded ceiling (with audit count) would keep behavior predictable under pathological trees.

Based on learnings: Extra care required when modifying danger zones: runtime commit/reconcile, wheel router, app creation, layout engine, renderer, drawlist, and binary modules; must verify runtime invariants and reconciliation behavior.

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

In `@packages/core/src/app/widgetRenderer.ts` around lines 4005 - 4008, The
current settle-pass calculation uses Math.max(3, constraintGraph.nodes.length +
1) which can produce unbounded synchronous frames for very large graphs; add an
upper hard cap (e.g. const HARD_CAP = <reasonable-number>) and compute
maxSettlePasses = Math.min(HARD_CAP, Math.max(3, constraintGraph.nodes.length +
1)); also increment or emit an audit/count metric when the cap is hit so we can
detect pathological trees; update the code around maxSettlePasses and add a
brief comment explaining the cap and the audit to preserve
runtime/reconciliation invariants (verify behavior in the layout/renderer
reconciliation paths after change).
🤖 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/commit.ts`:
- Around line 1959-1960: The small formatting drift around the
ensureFocusContainerId call is causing CI failures; open the block containing
ensureFocusContainerId(ctx.seenFocusContainerIds, instanceId, vnode) and the
subsequent early return using focusContainerFatal and reformat it to match the
project's code style (run the repo formatter / Prettier or apply the same
spacing/semicolon conventions used elsewhere), ensuring the single-line const
assignment and the if return remain correctly indented and spaced: const
focusContainerFatal = ensureFocusContainerId(...); if (focusContainerFatal)
return { ok: false, fatal: focusContainerFatal };.

---

Outside diff comments:
In `@packages/core/src/app/widgetRenderer.ts`:
- Around line 2389-2401: The calls to this.reportUserCodeError after catching
exceptions from focus zone callbacks (the reportUserCodeError invocations in the
current.onExit and next.onEnter handling) must be wrapped with the same
sink-guard used by reportInputCallbackError so a failure inside
reportUserCodeError itself cannot escape; update both spots to catch any error
thrown by this.reportUserCodeError and forward that secondary error into
this.reportInputCallbackError (or otherwise swallow it safely) so routing cannot
be broken by a reporting failure.

In `@packages/core/src/runtime/commit.ts`:
- Around line 1678-1687: The user-thrown handling branch in commitEvaluation
(the evalRes.threw !== null branch) manually formats thrown values; replace that
manual formatting with a call to describeThrown so it matches other paths: in
the branch that returns fatal { code: "ZRUI_USER_CODE_THROW", detail: ... } use
describeThrown(evalRes.threw) (or the equivalent helper used elsewhere) to
produce the detail string and keep the surrounding return structure unchanged,
ensuring you import or reference describeThrown the same way other handlers do.

---

Nitpick comments:
In `@packages/core/src/app/widgetRenderer.ts`:
- Around line 4005-4008: The current settle-pass calculation uses Math.max(3,
constraintGraph.nodes.length + 1) which can produce unbounded synchronous frames
for very large graphs; add an upper hard cap (e.g. const HARD_CAP =
<reasonable-number>) and compute maxSettlePasses = Math.min(HARD_CAP,
Math.max(3, constraintGraph.nodes.length + 1)); also increment or emit an
audit/count metric when the cap is hit so we can detect pathological trees;
update the code around maxSettlePasses and add a brief comment explaining the
cap and the audit to preserve runtime/reconciliation invariants (verify behavior
in the layout/renderer reconciliation paths after change).

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: a2b6e90d-3b25-4bb2-9db3-06fa680bcc15

📥 Commits

Reviewing files that changed from the base of the PR and between fef7139 and 4ab5690.

📒 Files selected for processing (6)
  • packages/core/src/app/__tests__/commandPaletteRouting.test.ts
  • packages/core/src/app/widgetRenderer.ts
  • packages/core/src/runtime/commit.ts
  • packages/core/src/runtime/router/types.ts
  • packages/core/src/widgets/__tests__/layers.golden.test.ts
  • packages/core/src/widgets/__tests__/modal.focus.test.ts

Comment on lines +1959 to +1960
const focusContainerFatal = ensureFocusContainerId(ctx.seenFocusContainerIds, instanceId, vnode);
if (focusContainerFatal) return { ok: false, fatal: focusContainerFatal };
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Formatter gate is failing on this block.

CI reports formatting drift around Line 1957-1963; this should be formatted to unblock merge.

🎨 Expected formatting
-    const focusContainerFatal = ensureFocusContainerId(ctx.seenFocusContainerIds, instanceId, vnode);
+    const focusContainerFatal = ensureFocusContainerId(
+      ctx.seenFocusContainerIds,
+      instanceId,
+      vnode,
+    );
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const focusContainerFatal = ensureFocusContainerId(ctx.seenFocusContainerIds, instanceId, vnode);
if (focusContainerFatal) return { ok: false, fatal: focusContainerFatal };
const focusContainerFatal = ensureFocusContainerId(
ctx.seenFocusContainerIds,
instanceId,
vnode,
);
if (focusContainerFatal) return { ok: false, fatal: focusContainerFatal };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/src/runtime/commit.ts` around lines 1959 - 1960, The small
formatting drift around the ensureFocusContainerId call is causing CI failures;
open the block containing ensureFocusContainerId(ctx.seenFocusContainerIds,
instanceId, vnode) and the subsequent early return using focusContainerFatal and
reformat it to match the project's code style (run the repo formatter / Prettier
or apply the same spacing/semicolon conventions used elsewhere), ensuring the
single-line const assignment and the if return remain correctly indented and
spaced: const focusContainerFatal = ensureFocusContainerId(...); if
(focusContainerFatal) return { ok: false, fatal: focusContainerFatal };.

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: 3

Caution

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

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

34-35: ⚠️ Potential issue | 🟡 Minor

Fix import ordering to unblock CI.

Line 34-35 and Line 58-59 are failing Biome organizeImports in the pipeline.

Also applies to: 58-59

🤖 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 34 - 35, The import
statements in createApp.ts are out of the expected ordering (specifically the
type imports for UiEvent and ZrevEvent and the later import block around the
other types), which causes Biome's organizeImports failure; reorder and group
imports according to the project's import style (external packages first, then
internal modules, and alphabetically within groups), combine and alphabetize the
type imports (e.g., the import that declares UiEvent and ZrevEvent) and the
later type import block so they are consistently ordered, remove any unused
imports, and ensure the final import ordering satisfies Biome's organizeImports
checks.
🧹 Nitpick comments (2)
packages/core/src/app/widgetRenderer.ts (1)

2421-2482: Consider trimming allocations in the scrollable-ancestor traversal hot path.

On Line 2460, freezing a newly cloned array during traversal is extra work for wheel-heavy flows. You can defer freezing until return time and keep intermediate path arrays mutable.

Low-risk micro-optimization
-    let scrollables = frame.scrollables;
+    let scrollables = frame.scrollables;

@@
-        if (hasScrollableAxis) {
-          scrollables = Object.freeze([...scrollables, { nodeId, meta }]);
-        }
+        if (hasScrollableAxis) {
+          scrollables = [...scrollables, { nodeId, meta }];
+        }

@@
-      if (nodeId === targetId) {
-        return Object.freeze([...scrollables].reverse());
-      }
+      if (nodeId === targetId) {
+        return Object.freeze(scrollables.slice().reverse());
+      }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/src/app/widgetRenderer.ts` around lines 2421 - 2482, The
traversal creates and freezes arrays on every pushed scrollable match (see
findScrollableAncestors and the local scrollables variable), which allocates and
freezes repeatedly; instead, initialize Cursor.scrollables with a plain mutable
[] (not Object.freeze([])), when discovering a scrollable create a new array
using [...scrollables, { nodeId, meta }] without freezing, pass that array into
child frames, and only call Object.freeze (and reverse as currently done) at the
two return sites (the found-path return and the final empty return) so freezing
is deferred to return time.
packages/core/src/app/createApp.ts (1)

1495-1498: Avoid double-queuing renderRequest in theme-transition turns.

Line 1495 can enqueue a second renderRequest in the same turn after scheduleThemeTransitionContinuation() already queued one, which adds avoidable no-op queue work.

♻️ Proposed tweak
-    if (dirtyTracker.getFlags() !== 0 && !renderRequestQueuedForCurrentTurn) {
+    if (
+      dirtyTracker.getFlags() !== 0 &&
+      !renderRequestQueuedForCurrentTurn &&
+      themeTransition === null
+    ) {
       renderRequestQueuedForCurrentTurn = true;
       scheduler.enqueue({ kind: "renderRequest" });
     }
🤖 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 1495 - 1498, The code can
enqueue a duplicate render request because scheduleThemeTransitionContinuation()
may already queue a renderRequest; to fix, ensure we don't enqueue twice by
using the shared renderRequestQueuedForCurrentTurn flag consistently: either
have scheduleThemeTransitionContinuation() set renderRequestQueuedForCurrentTurn
= true when it enqueues a renderRequest, or change this block (which checks
dirtyTracker.getFlags()) to also check whatever internal indicator
scheduleThemeTransitionContinuation uses before calling scheduler.enqueue({
kind: "renderRequest" }); reference symbols: dirtyTracker.getFlags,
renderRequestQueuedForCurrentTurn, scheduleThemeTransitionContinuation, and
scheduler.enqueue({ kind: "renderRequest" }).
🤖 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 828-862: The test "focusZone error reporting swallows
onUserCodeError sink failures" currently only asserts no throw and final focus,
so add counters to prove both the focus handler and the sink were invoked:
create numeric counters and increment them inside the focusZone onEnter handler
and the WidgetRenderer onUserCodeError sink (pass a function that increments a
counter and throws), then after calling renderer.routeEngineEvent(keyEvent(3 /*
TAB */)) assert that the onEnter counter is 1 and the onUserCodeError counter is
1 in addition to the existing doesNotThrow and getFocusedId assertions so the
error-routing path is deterministically exercised (refer to symbols
WidgetRenderer, onUserCodeError, onEnter, renderer.routeEngineEvent, and
renderer.getFocusedId).

In `@packages/core/src/runtime/__tests__/widgetMeta.test.ts`:
- Around line 420-433: The test currently calls
collectAllWidgetMetadata(committed) which creates a fresh collector per call and
doesn't exercise the pooled (reusable) collector path; change the test to obtain
and use the pooled collector API instead (e.g., call
createPooledWidgetMetadataCollector() or acquirePooledWidgetMetadataCollector()
from the widget meta module), use the pooledCollector.collect(committed) (or
equivalent) to collect metadata, then release/return the pooled collector after
use so the test exercises reuse/state retention in the pooled collector rather
than the one-shot collectAllWidgetMetadata call.

In `@packages/core/src/runtime/localState.ts`:
- Around line 110-112: Replace the usage of Map.prototype.forEach in the wrapper
methods with explicit for...of loops: in the forEach method of the LocalState
wrapper (the method currently calling this.map.forEach((value, key) =>
callbackfn.call(thisArg, value, key, this))), iterate with for (const [key,
value] of this.map) and invoke callbackfn.call(thisArg, value, key, this) inside
the loop; do the same for the other wrapper implementation mentioned at line
~163 (the similar wrapper that currently uses .forEach) so both follow the
for...of pattern required by the linter.

---

Outside diff comments:
In `@packages/core/src/app/createApp.ts`:
- Around line 34-35: The import statements in createApp.ts are out of the
expected ordering (specifically the type imports for UiEvent and ZrevEvent and
the later import block around the other types), which causes Biome's
organizeImports failure; reorder and group imports according to the project's
import style (external packages first, then internal modules, and alphabetically
within groups), combine and alphabetize the type imports (e.g., the import that
declares UiEvent and ZrevEvent) and the later type import block so they are
consistently ordered, remove any unused imports, and ensure the final import
ordering satisfies Biome's organizeImports checks.

---

Nitpick comments:
In `@packages/core/src/app/createApp.ts`:
- Around line 1495-1498: The code can enqueue a duplicate render request because
scheduleThemeTransitionContinuation() may already queue a renderRequest; to fix,
ensure we don't enqueue twice by using the shared
renderRequestQueuedForCurrentTurn flag consistently: either have
scheduleThemeTransitionContinuation() set renderRequestQueuedForCurrentTurn =
true when it enqueues a renderRequest, or change this block (which checks
dirtyTracker.getFlags()) to also check whatever internal indicator
scheduleThemeTransitionContinuation uses before calling scheduler.enqueue({
kind: "renderRequest" }); reference symbols: dirtyTracker.getFlags,
renderRequestQueuedForCurrentTurn, scheduleThemeTransitionContinuation, and
scheduler.enqueue({ kind: "renderRequest" }).

In `@packages/core/src/app/widgetRenderer.ts`:
- Around line 2421-2482: The traversal creates and freezes arrays on every
pushed scrollable match (see findScrollableAncestors and the local scrollables
variable), which allocates and freezes repeatedly; instead, initialize
Cursor.scrollables with a plain mutable [] (not Object.freeze([])), when
discovering a scrollable create a new array using [...scrollables, { nodeId,
meta }] without freezing, pass that array into child frames, and only call
Object.freeze (and reverse as currently done) at the two return sites (the
found-path return and the final empty return) so freezing is deferred to return
time.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 0127f9f3-97e7-4094-8b26-8c2fb749fe0a

📥 Commits

Reviewing files that changed from the base of the PR and between 4ab5690 and eb3a005.

📒 Files selected for processing (10)
  • packages/core/src/app/__tests__/eventPump.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/runtime/__tests__/localStateStore.test.ts
  • packages/core/src/runtime/__tests__/widgetMeta.test.ts
  • packages/core/src/runtime/commit.ts
  • packages/core/src/runtime/localState.ts
  • packages/core/src/runtime/widgetMeta.ts
  • packages/core/src/widgets/__tests__/layers.golden.test.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/core/src/widgets/tests/layers.golden.test.ts

Comment on lines +420 to +433
test("widgetMeta: pooled collector does not attribute trap focusables to outer zones", () => {
const committed = commitTree(
ui.focusZone({ id: "outer-zone" }, [
ui.button({ id: "outer-btn", label: "Outer" }),
ui.focusTrap({ id: "inner-trap", active: true }, [
ui.button({ id: "inner-btn", label: "Inner" }),
]),
]),
);

const metadata = collectAllWidgetMetadata(committed);

assert.deepEqual(metadata.zones.get("outer-zone")?.focusableIds, ["outer-btn"]);
assert.deepEqual(metadata.traps.get("inner-trap")?.focusableIds, ["inner-btn"]);
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

pooled collector test is currently exercising the one-shot collector path

Line 430 calls collectAllWidgetMetadata(committed), but packages/core/src/runtime/widgetMeta.ts (Lines 1109-1113) creates a fresh collector per call. This won’t catch regressions specific to reusable collector state reuse.

Proposed fix
 test("widgetMeta: pooled collector does not attribute trap focusables to outer zones", () => {
+  const collector = createWidgetMetadataCollector();
+
+  // Seed a prior collect to exercise reusable-collector state reset behavior.
+  collector.collect(
+    commitTree(
+      ui.focusTrap({ id: "seed-trap", active: true }, [
+        ui.button({ id: "seed-btn", label: "Seed" }),
+      ]),
+    ),
+  );
+
   const committed = commitTree(
     ui.focusZone({ id: "outer-zone" }, [
       ui.button({ id: "outer-btn", label: "Outer" }),
       ui.focusTrap({ id: "inner-trap", active: true }, [
         ui.button({ id: "inner-btn", label: "Inner" }),
       ]),
     ]),
   );

-  const metadata = collectAllWidgetMetadata(committed);
+  const metadata = collector.collect(committed);

   assert.deepEqual(metadata.zones.get("outer-zone")?.focusableIds, ["outer-btn"]);
   assert.deepEqual(metadata.traps.get("inner-trap")?.focusableIds, ["inner-btn"]);
 });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/src/runtime/__tests__/widgetMeta.test.ts` around lines 420 -
433, The test currently calls collectAllWidgetMetadata(committed) which creates
a fresh collector per call and doesn't exercise the pooled (reusable) collector
path; change the test to obtain and use the pooled collector API instead (e.g.,
call createPooledWidgetMetadataCollector() or
acquirePooledWidgetMetadataCollector() from the widget meta module), use the
pooledCollector.collect(committed) (or equivalent) to collect metadata, then
release/return the pooled collector after use so the test exercises reuse/state
retention in the pooled collector rather than the one-shot
collectAllWidgetMetadata call.

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.

Caution

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

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

884-890: ⚠️ Potential issue | 🟠 Major

Defer inline fatal dispatch while still inside user handler depth.

At Line 885, fatalNowOrEnqueue can call doFatal() during emit() before inEventHandlerDepth is decremented. That makes fatal callbacks run in a re-entrant context, so app API calls from fatal handlers (e.g., dispose() at Line 1887) throw and get swallowed in doFatal’s handler loop. This silently drops user cleanup/reporting logic in this failure path.

💡 Proposed fix
 function fatalNowOrEnqueue(code: ZrUiErrorCode, detail: string): void {
-  if (scheduler.isExecuting) {
+  const canFailFastInline =
+    scheduler.isExecuting && !inRender && !inCommit && inEventHandlerDepth === 0;
+  if (canFailFastInline) {
     doFatal(code, detail);
     return;
   }
   enqueueFatal(code, detail);
 }

Also applies to: 919-939, 945-963, 1886-1890

🧹 Nitpick comments (1)
packages/core/src/app/widgetRenderer.ts (1)

2426-2431: Prefer interface for these object-shape declarations.

Lines 2426–2427 define ScrollableMatch and Cursor using type with Readonly<{...}> wrapper. Per the repo's TypeScript guidelines, these should be declared as interface with readonly fields instead.

Suggested conversion
-    type ScrollableMatch = Readonly<{ nodeId: string; meta: LayoutOverflowMetadata }>;
-    type Cursor = Readonly<{
-      runtimeNode: RuntimeInstance;
-      layoutNode: LayoutTree;
-      scrollables: readonly ScrollableMatch[];
-    }>;
+    interface ScrollableMatch {
+      readonly nodeId: string;
+      readonly meta: LayoutOverflowMetadata;
+    }
+    interface Cursor {
+      readonly runtimeNode: RuntimeInstance;
+      readonly layoutNode: LayoutTree;
+      readonly scrollables: readonly ScrollableMatch[];
+    }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/src/app/widgetRenderer.ts` around lines 2426 - 2431, Convert
the two type aliases into interfaces and use readonly properties instead of
Readonly<...>; replace the type ScrollableMatch = Readonly<{ nodeId: string;
meta: LayoutOverflowMetadata }>; with an interface ScrollableMatch { readonly
nodeId: string; readonly meta: LayoutOverflowMetadata } and replace the type
Cursor = Readonly<{ runtimeNode: RuntimeInstance; layoutNode: LayoutTree;
scrollables: readonly ScrollableMatch[] }>; with an interface Cursor { readonly
runtimeNode: RuntimeInstance; readonly layoutNode: LayoutTree; readonly
scrollables: readonly ScrollableMatch[] } so the shapes are declared as
interfaces and each field is marked readonly.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@packages/core/src/app/widgetRenderer.ts`:
- Around line 2426-2431: Convert the two type aliases into interfaces and use
readonly properties instead of Readonly<...>; replace the type ScrollableMatch =
Readonly<{ nodeId: string; meta: LayoutOverflowMetadata }>; with an interface
ScrollableMatch { readonly nodeId: string; readonly meta: LayoutOverflowMetadata
} and replace the type Cursor = Readonly<{ runtimeNode: RuntimeInstance;
layoutNode: LayoutTree; scrollables: readonly ScrollableMatch[] }>; with an
interface Cursor { readonly runtimeNode: RuntimeInstance; readonly layoutNode:
LayoutTree; readonly scrollables: readonly ScrollableMatch[] } so the shapes are
declared as interfaces and each field is marked readonly.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 25bf7942-1979-4d3d-8354-fe25281324b4

📥 Commits

Reviewing files that changed from the base of the PR and between eb3a005 and ff0b22e.

📒 Files selected for processing (6)
  • packages/core/src/app/__tests__/widgetRenderer.integration.test.ts
  • packages/core/src/app/createApp.ts
  • packages/core/src/app/createApp/topLevelViewError.ts
  • packages/core/src/app/widgetRenderer.ts
  • packages/core/src/runtime/__tests__/widgetMeta.test.ts
  • packages/core/src/runtime/localState.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/core/src/app/createApp/topLevelViewError.ts

@RtlZeroMemory RtlZeroMemory merged commit 0b0063b 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