diff --git a/.agents/docs/ink-divergences.md b/.agents/docs/ink-divergences.md index a2f73be..92c913d 100644 --- a/.agents/docs/ink-divergences.md +++ b/.agents/docs/ink-divergences.md @@ -398,34 +398,26 @@ Reconciler/runtime mechanics that differ from React internally yet produce **byt terminal output, because a commit always paints `f(current host tree)` — _how_ the tree was built never reaches the terminal: -- **A `v-if=false` branch (or a `null`/`false`/`undefined` child) leaves a comment anchor - (`TuiComment`)** where Ink emits no node, but it is inert: no yoga node, paints nothing, - never shifts a sibling's yoga index, and is skipped for the positional `` index - in all three squash paths (`G52`). Output equals omitting the element. This also governs - ``'s own children guard: a childless `` (or one whose only child is a - `null`/`false`/`v-if=false` comment anchor) renders **no node** (matching Ink for `null`, - consistent with every other component). It diverges from Ink only for a literal `{false}` / - `{cond && x}`-false child — React's `false !== null`, so Ink renders an empty node (and a gap - slot); Vue collapses `false`/`null` to the same `TuiComment` and cannot distinguish them, so - it omits the node. Keeping `` consistent with the comment-anchor model is the - principled choice. +- **Vue leaves a comment placeholder where React renders nothing.** A `null`/`false`/`undefined` + child or a `v-if="false"` branch is materialized by `@vue/runtime-core` as a **comment vnode** + — the position anchor `v-if` uses to refill its slot when the condition flips back — which + vue-tui's host renderer creates as a `TuiComment`. React/Ink emit no node at all here, so + vue-tui's host tree carries comment nodes Ink's never has. **To keep the output byte-identical, + vue-tui makes the `TuiComment` inert:** no yoga node, paints nothing, never shifts a sibling's + yoga index, and is skipped when counting the positional `` index (the + `if (child.type !== "comment") index++` guards across all three squash paths — top-level paint, + nested transform, screen-reader; `G52`). Net output equals omitting the element. The same model + drives ``'s own guard: a slot that is empty or all-comments renders **no node** + (`return null`), matching Ink's `children == null` guard for the common `{null}` / + `{cond ? x : null}` idioms. **One residual divergence:** a literal `{false}` / `{cond && x}`-false + child — React keeps `false !== null`, so Ink renders an empty node (a gap slot in a flex-gap + container) while Vue collapses `false` and `null` into the same `TuiComment` and omits it. That + gap-slot mismatch is the deliberate, structurally unavoidable cost of one comment-anchor model + everywhere. - **Commit timing is deliberately Ink-aligned** — leading+trailing throttle at - `ceil(1000/maxFps)` ≈ 32 ms (Ink's `renderThrottleMs`), synchronous resize — even though - re-renders are Vue's fine-grained reactivity, not a React subtree re-render. -- **Keyed lists use Vue core's `patchKeyedChildren`** (LIS), not React's fiber diff; output - depends on the final tree, not the move order. -- **`wrapText` truncate has a per-line short-circuit** before its whole-string `cli-truncate`: - if every `\n`-split line already fits `width` it returns the lines unchanged, otherwise it - truncates the whole string once (as Ink does). Ink instead gates at paint time on the - **widest line** (`widestLine(text) > maxWidth`) before calling `wrapText`, which then - whole-string-truncates with no per-line check. The two paint-time gates (vue's per-line - `every`, Ink's widest-line) admit the same multi-line texts in practice, so production output - matches — documented so the divergent short-circuit branch isn't "fixed" to bare whole-string - truncate (which would collapse perfectly-fitting multi-line text to one line). -- **The animation scheduler rounds the `setTimeout` delay up** (`Math.ceil(earliest - now)`) - where Ink passes the raw fractional delay. `setTimeout` truncates a fractional delay and would - fire early, re-skip (`now < nextDueTime`), and reschedule a ~0 ms delay — a sub-ms busy-loop. - Non-behavioral (Node coerces the delay to an int anyway); the in-code comment explains it. + `ceil(1000/maxFps)` ms (34ms at the default `maxFps=30`, matching Ink's `renderThrottleMs`), + synchronous resize — even though re-renders are Vue's fine-grained reactivity, not a React + subtree re-render. --- diff --git a/packages/runtime/src/animation-scheduler.ts b/packages/runtime/src/animation-scheduler.ts index b0a1396..b36c984 100644 --- a/packages/runtime/src/animation-scheduler.ts +++ b/packages/runtime/src/animation-scheduler.ts @@ -55,9 +55,10 @@ export function createAnimationScheduler(renderThrottleMs = 0): AnimationSchedul } if (earliest === Number.POSITIVE_INFINITY) return; scheduledDueTime = earliest; - // Round up: setTimeout truncates fractional delays, which would fire the - // timer before `earliest`. onTick then skips (now < nextDueTime) and - // reschedules a ~0ms delay, busy-looping until the clock catches up. + // Round up: setTimeout truncates a fractional delay, so the timer would fire + // just before `earliest`; onTick then finds nothing due (now < nextDueTime), + // skips, and reschedules — one wasted wakeup per frame. Ceiling makes it fire + // at-or-after the due time, so the frame lands on the first wakeup. const delay = Math.ceil(Math.max(0, earliest - performance.now())); timer = setTimeout(onTick, delay); } diff --git a/packages/runtime/src/render.ts b/packages/runtime/src/render.ts index e2592ef..6c0d5bf 100644 --- a/packages/runtime/src/render.ts +++ b/packages/runtime/src/render.ts @@ -1008,13 +1008,13 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp const unthrottled = debug || isScreenReaderEnabled; const renderThrottleMs = !unthrottled && maxFps > 0 ? Math.max(1, Math.ceil(1000 / maxFps)) : 0; - const schedulerOptions: { immediate: boolean; throttleMs?: number } = { + // Unthrottled (debug / screen-reader) commits fire every tick, so the + // throttle window is unused there — renderThrottleMs is already 0. Otherwise + // it's the maxFps-derived window (34ms at the default maxFps=30). + const scheduler = createCommitScheduler(commit, { immediate: unthrottled, - }; - if (!unthrottled) { - schedulerOptions.throttleMs = renderThrottleMs; - } - const scheduler = createCommitScheduler(commit, schedulerOptions); + throttleMs: renderThrottleMs, + }); mountedScheduler = scheduler; mountedCommit = commit; scheduledCommit = scheduler.schedule; diff --git a/packages/runtime/src/scheduler.ts b/packages/runtime/src/scheduler.ts index 65b378a..4d16ba5 100644 --- a/packages/runtime/src/scheduler.ts +++ b/packages/runtime/src/scheduler.ts @@ -12,19 +12,21 @@ export interface CommitScheduler { export interface CommitSchedulerOptions { /** Disable time-based throttle (used in tests / debug mode). */ immediate?: boolean; - /** Override throttle interval in ms. Takes precedence over the default 32ms. */ - throttleMs?: number; + /** + * Throttle window in ms — the leading+trailing commit interval. The caller + * derives it from `maxFps` (`ceil(1000/maxFps)`, i.e. 34ms at the default + * maxFps=30, matching Ink). Unused when `immediate` is set (commits fire + * every tick); pass 0 there. + */ + throttleMs: number; } -/** Default minimum interval between commits (~30fps). */ -const DEFAULT_THROTTLE_MS = 32; - export function createCommitScheduler( commit: () => void, - options: CommitSchedulerOptions = {}, + options: CommitSchedulerOptions, ): CommitScheduler { const immediate = options.immediate ?? false; - const throttleMs = options.throttleMs ?? DEFAULT_THROTTLE_MS; + const throttleMs = options.throttleMs; let scheduled = false; // Multiple concurrent flush() callers can be waiting on the same pending // commit; settle all of them rather than overwriting a single resolver.