From 61c391d1a6dac7317d2d61476f067b06d7cffcc1 Mon Sep 17 00:00:00 2001 From: RtlZeroMemory <58250858+RtlZeroMemory@users.noreply.github.com> Date: Fri, 27 Feb 2026 10:13:19 +0400 Subject: [PATCH 1/7] Fix starship rendering regressions and add PTY debug runbook --- AGENTS.md | 19 ++ CLAUDE.md | 9 + docs/dev/live-pty-debugging.md | 176 ++++++++++ docs/dev/testing.md | 12 + mkdocs.yml | 1 + .../renderToDrawlist/widgets/collections.ts | 27 ++ .../src/widgets/__tests__/pagination.test.ts | 5 +- packages/core/src/widgets/pagination.ts | 12 +- .../templates/starship/src/screens/bridge.ts | 2 +- .../templates/starship/src/screens/cargo.ts | 2 +- .../templates/starship/src/screens/comms.ts | 2 +- .../templates/starship/src/screens/crew.ts | 307 +++++++++++------- .../starship/src/screens/engineering.ts | 38 ++- .../starship/src/screens/settings.ts | 4 +- packages/node/src/frameAudit.ts | 8 + packages/node/src/worker/engineWorker.ts | 136 +++++++- 16 files changed, 627 insertions(+), 133 deletions(-) create mode 100644 docs/dev/live-pty-debugging.md diff --git a/AGENTS.md b/AGENTS.md index 1e221149..9b2fceec 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -170,6 +170,25 @@ node scripts/run-tests.mjs --filter "widget" 2. If changing runtime, layout, or renderer code, also run integration tests. 3. Run the full suite before committing. +### Mandatory Live PTY Validation for UI Regressions + +For rendering/layout/theme regressions, do not stop at unit snapshots. Run the +app in a real PTY and collect frame audit evidence yourself before asking a +human to reproduce. + +Canonical runbook: + +- [`docs/dev/live-pty-debugging.md`](docs/dev/live-pty-debugging.md) + +Minimum required checks for UI regression work: + +1. Run target app/template in PTY with deterministic viewport. +2. Exercise relevant routes/keys (for starship: `1..6`, `t`, `q`). +3. Capture `REZI_FRAME_AUDIT` logs and analyze with + `node scripts/frame-audit-report.mjs ... --latest-pid`. +4. Capture app-level debug snapshots (`REZI_STARSHIP_DEBUG=1`) when applicable. +5. Include concrete evidence in your report (hash changes, route summary, key stages). + ## Verification Protocol (Two-Agent Verification) When verifying documentation or code changes, split into two passes: diff --git a/CLAUDE.md b/CLAUDE.md index 5c8e22a5..433355bb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -505,6 +505,15 @@ result.toText(); // Render to plain text for snapshots Test runner: `node:test`. Run all tests with `node scripts/run-tests.mjs`. +For rendering regressions, add a live PTY verification pass and frame-audit +evidence (not just snapshot/unit tests). Use: + +- [`docs/dev/live-pty-debugging.md`](docs/dev/live-pty-debugging.md) + +This runbook covers deterministic viewport setup, worker-mode PTY execution, +route/theme key driving, and cross-layer log analysis (`REZI_FRAME_AUDIT`, +`REZI_STARSHIP_DEBUG`, `frame-audit-report.mjs`). + ## Skills (Repeatable Recipes) Project-level skills for both Claude Code and Codex: diff --git a/docs/dev/live-pty-debugging.md b/docs/dev/live-pty-debugging.md new file mode 100644 index 00000000..bdb18d91 --- /dev/null +++ b/docs/dev/live-pty-debugging.md @@ -0,0 +1,176 @@ +# Live PTY UI Testing and Frame Audit Runbook + +This runbook documents how to validate Rezi UI behavior autonomously in a real +terminal (PTY), capture end-to-end frame telemetry, and pinpoint regressions +across core/node/native layers. + +Use this before asking a human for screenshots. + +## Why this exists + +Headless/unit tests catch many issues, but rendering regressions often involve: + +- terminal dimensions and capability negotiation +- worker transport boundaries (core -> node worker -> native) +- partial redraw/damage behavior across many frames + +The PTY + frame-audit workflow gives deterministic evidence for all of those. + +## Prerequisites + +From repo root: + +```bash +cd /home/k3nig/Rezi +npx tsc -b packages/core packages/node packages/create-rezi +``` + +## Canonical interactive run (Starship template) + +This enables: + +- app-level debug snapshots (`REZI_STARSHIP_DEBUG`) +- cross-layer frame audit (`REZI_FRAME_AUDIT`) +- worker execution path (`REZI_STARSHIP_EXECUTION_MODE=worker`) + +```bash +cd /home/k3nig/Rezi +: > /tmp/rezi-frame-audit.ndjson +: > /tmp/starship.log + +env -u NO_COLOR \ + REZI_STARSHIP_EXECUTION_MODE=worker \ + REZI_STARSHIP_DEBUG=1 \ + REZI_STARSHIP_DEBUG_LOG=/tmp/starship.log \ + REZI_FRAME_AUDIT=1 \ + REZI_FRAME_AUDIT_LOG=/tmp/rezi-frame-audit.ndjson \ + npx tsx packages/create-rezi/templates/starship/src/main.ts +``` + +Key controls in template: + +- `1..6`: route switch (bridge/engineering/crew/comms/cargo/settings) +- `t`: cycle theme +- `q`: quit + +## Deterministic viewport (important) + +Many regressions are viewport-threshold dependent. Always test with a known +size before comparing runs. + +For an interactive shell/PTY: + +```bash +stty rows 68 cols 300 +``` + +Then launch the app in that same PTY. + +## Autonomous PTY execution (agent workflow) + +When your agent runtime supports PTY stdin/stdout control: + +1. Start app in PTY mode (with env above). +2. Send key sequences (`2`, `3`, `t`, `q`) through stdin. +3. Wait between keys to allow frames to settle. +4. Quit and analyze logs. + +Do not rely only on static test snapshots for visual regressions. + +## Frame audit analysis + +Use the built-in analyzer: + +```bash +node scripts/frame-audit-report.mjs /tmp/rezi-frame-audit.ndjson --latest-pid +``` + +What to look for: + +- `backend_submitted`, `worker_payload`, `worker_accepted`, `worker_completed` + should stay aligned in worker mode. +- `hash_mismatch_backend_vs_worker` should be `0`. +- `top_opcodes` should reflect expected widget workload. +- `route_summary` should show submissions for every exercised route. +- `native_summary_records`/`native_header_records` confirm native debug pull + from worker path. + +If a log contains multiple app runs, always use `--latest-pid` (or `--pid=`) +to avoid mixed-session confusion. + +## Useful grep patterns + +```bash +rg "runtime.command|runtime.fatal|shell.layout|engineering.layout|engineering.render|crew.render" /tmp/starship.log +rg "\"stage\":\"table.layout\"|\"stage\":\"drawlist.built\"|\"stage\":\"frame.submitted\"|\"stage\":\"frame.completed\"" /tmp/rezi-frame-audit.ndjson +``` + +## Optional deep capture (drawlist bytes) + +Capture raw drawlist payload snapshots for diffing: + +```bash +env \ + REZI_FRAME_AUDIT=1 \ + REZI_FRAME_AUDIT_DUMP_DIR=/tmp/rezi-drawlist-dumps \ + REZI_FRAME_AUDIT_DUMP_MAX=20 \ + REZI_FRAME_AUDIT_DUMP_ROUTE=crew \ + npx tsx packages/create-rezi/templates/starship/src/main.ts +``` + +This writes paired `.bin` + `.json` files with hashes and metadata. + +## Native trace through frame-audit + +Native debug records are enabled by frame audit in worker mode. Controls: + +- `REZI_FRAME_AUDIT_NATIVE=1|0` (default on when frame audit is enabled) +- `REZI_FRAME_AUDIT_NATIVE_RING=` (ring size override) + +Look for stages such as: + +- `native.debug.header` +- `native.drawlist.summary` +- `native.frame.*` +- `native.perf.*` + +## Triage playbook for common regressions + +### 1) “Theme only updates animated region” + +Check: + +1. `runtime.command` contains `cycle-theme`. +2. `drawlist.built` hashes change after theme switch. +3. `frame.submitted`/`frame.completed` continue for that route. + +If hashes do not change, bug is likely in view/theme resolution. +If hashes change but screen does not, investigate native diff/damage path. + +### 2) “Table looks empty or only one row visible” + +Check `table.layout` record: + +- `bodyH` +- `visibleRows` +- `startIndex` / `endIndex` +- table rect height + +If `bodyH` is too small, inspect parent layout/flex and sibling widgets +(pagination or controls often steal height). + +### 3) “Worker mode renders differently from inline” + +Run both modes with identical viewport and compare audit summaries: + +- worker: `REZI_STARSHIP_EXECUTION_MODE=worker` +- inline: `REZI_STARSHIP_EXECUTION_MODE=inline` + +If only worker diverges, focus on backend transport and worker audit stages. + +## Guardrails + +- Keep all instrumentation opt-in via env vars. +- Never print continuous debug spam to stdout during normal app usage. +- Write logs to files (`/tmp/...`) and inspect post-run. +- Prefer deterministic viewport + scripted route/theme steps when verifying fixes. diff --git a/docs/dev/testing.md b/docs/dev/testing.md index 3eab0163..f3fdd9b1 100644 --- a/docs/dev/testing.md +++ b/docs/dev/testing.md @@ -66,6 +66,18 @@ npm run test:e2e npm run test:e2e:reduced ``` +## Live PTY Rendering Validation (for UI regressions) + +For terminal rendering/theme/layout regressions, run a live PTY session with +frame-audit instrumentation in addition to normal tests. + +Use the dedicated runbook: + +- [Live PTY UI Testing and Frame Audit Runbook](live-pty-debugging.md) + +That guide includes deterministic viewport setup, worker-mode run commands, +scripted key driving, and cross-layer telemetry analysis. + ## Test Categories ### Unit Tests diff --git a/mkdocs.yml b/mkdocs.yml index c66f6e8e..bcc9b91b 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -250,6 +250,7 @@ nav: - Repo Layout: dev/repo-layout.md - Build: dev/build.md - Testing: dev/testing.md + - Live PTY Debugging: dev/live-pty-debugging.md - Code Standards: dev/code-standards.md - Ink Compat Debugging: dev/ink-compat-debugging.md - Perf Regressions: dev/perf-regressions.md diff --git a/packages/core/src/renderer/renderToDrawlist/widgets/collections.ts b/packages/core/src/renderer/renderToDrawlist/widgets/collections.ts index 33ec6158..f92cd8fd 100644 --- a/packages/core/src/renderer/renderToDrawlist/widgets/collections.ts +++ b/packages/core/src/renderer/renderToDrawlist/widgets/collections.ts @@ -28,6 +28,7 @@ import { getTotalHeight, resolveVirtualListItemHeightSpec, } from "../../../widgets/virtualList.js"; +import { emitFrameAudit, FRAME_AUDIT_ENABLED } from "../../../perf/frameAudit.js"; import { asTextStyle } from "../../styles.js"; import { renderBoxBorder } from "../boxBorder.js"; import { isVisibleRect } from "../indices.js"; @@ -605,6 +606,32 @@ export function renderCollectionWidget( ? Math.min(rowCount, startIndex + visibleRows + overscan) : rowCount; + if (FRAME_AUDIT_ENABLED) { + emitFrameAudit( + "tableWidget", + "table.layout", + Object.freeze({ + tableId: props.id, + x: rect.x, + y: rect.y, + w: rect.w, + h: rect.h, + innerW, + innerH, + bodyY, + bodyH, + rowCount, + headerHeight, + rowHeight: safeRowHeight, + virtualized, + startIndex, + endIndex, + visibleRows, + overscan, + }), + ); + } + if (tableStore) { tableStore.set(props.id, { viewportHeight: bodyH, startIndex, endIndex }); } diff --git a/packages/core/src/widgets/__tests__/pagination.test.ts b/packages/core/src/widgets/__tests__/pagination.test.ts index d1ce4396..1b8ab376 100644 --- a/packages/core/src/widgets/__tests__/pagination.test.ts +++ b/packages/core/src/widgets/__tests__/pagination.test.ts @@ -128,7 +128,10 @@ describe("pagination ids and vnode", () => { const zoneNode = children[0]; assert.equal(zoneNode?.kind, "focusZone"); if (zoneNode?.kind !== "focusZone") return; - const ids = zoneNode.children + const controlsRow = zoneNode.children[0]; + assert.equal(controlsRow?.kind, "row"); + if (controlsRow?.kind !== "row") return; + const ids = controlsRow.children .filter((child) => child.kind === "button") .map((child) => (child.kind === "button" ? child.props.id : "")); assert.equal(ids.includes(getPaginationControlId("pages", "first")), true); diff --git a/packages/core/src/widgets/pagination.ts b/packages/core/src/widgets/pagination.ts index ea202124..6bfa73d4 100644 --- a/packages/core/src/widgets/pagination.ts +++ b/packages/core/src/widgets/pagination.ts @@ -243,6 +243,16 @@ export function buildPaginationChildren(props: PaginationProps): readonly VNode[ }); } + const controlsRow: VNode = { + kind: "row", + props: { + gap: 0, + wrap: true, + items: "center", + }, + children: Object.freeze(controls), + }; + const zone: VNode = { kind: "focusZone", props: { @@ -252,7 +262,7 @@ export function buildPaginationChildren(props: PaginationProps): readonly VNode[ columns: 1, wrapAround: false, }, - children: Object.freeze(controls), + children: Object.freeze([controlsRow]), }; return Object.freeze([zone]); diff --git a/packages/create-rezi/templates/starship/src/screens/bridge.ts b/packages/create-rezi/templates/starship/src/screens/bridge.ts index a2666219..bb4546b1 100644 --- a/packages/create-rezi/templates/starship/src/screens/bridge.ts +++ b/packages/create-rezi/templates/starship/src/screens/bridge.ts @@ -554,7 +554,7 @@ export function renderBridgeScreen( title: "Bridge Overview", context, deps, - body: ui.column({ gap: SPACE.sm, width: "100%" }, [ + body: ui.column({ gap: SPACE.sm, width: "100%", height: "100%" }, [ BridgeCommandDeck({ key: "bridge-command-deck", state, dispatch: deps.dispatch }), ]), }); diff --git a/packages/create-rezi/templates/starship/src/screens/cargo.ts b/packages/create-rezi/templates/starship/src/screens/cargo.ts index f5735e23..7cef074d 100644 --- a/packages/create-rezi/templates/starship/src/screens/cargo.ts +++ b/packages/create-rezi/templates/starship/src/screens/cargo.ts @@ -30,7 +30,7 @@ export function renderCargoScreen( title: "Cargo Hold", context, deps, - body: ui.column({ gap: SPACE.sm, width: "100%" }, [ + body: ui.column({ gap: SPACE.sm, width: "100%", height: "100%" }, [ CargoDeck({ key: "cargo-deck", state: context.state, diff --git a/packages/create-rezi/templates/starship/src/screens/comms.ts b/packages/create-rezi/templates/starship/src/screens/comms.ts index 1a115088..8830a08c 100644 --- a/packages/create-rezi/templates/starship/src/screens/comms.ts +++ b/packages/create-rezi/templates/starship/src/screens/comms.ts @@ -462,7 +462,7 @@ export function renderCommsScreen( title: "Communications", context, deps, - body: ui.column({ gap: SPACE.sm, width: "100%" }, [ + body: ui.column({ gap: SPACE.sm, width: "100%", height: "100%" }, [ CommsDeck({ key: "comms-deck", state: context.state, diff --git a/packages/create-rezi/templates/starship/src/screens/crew.ts b/packages/create-rezi/templates/starship/src/screens/crew.ts index 44f71ff1..0013fd90 100644 --- a/packages/create-rezi/templates/starship/src/screens/crew.ts +++ b/packages/create-rezi/templates/starship/src/screens/crew.ts @@ -7,6 +7,7 @@ import { type RouteRenderContext, type VNode, } from "@rezi-ui/core"; +import { debugSnapshot } from "../helpers/debug.js"; import { resolveLayout } from "../helpers/layout.js"; import { crewCounts, departmentLabel, rankBadge, statusBadge } from "../helpers/formatters.js"; import { selectedCrew, visibleCrew } from "../helpers/state.js"; @@ -193,7 +194,7 @@ const CrewDeck = defineWidget((props, ctx): VNode => { ...tableSkin(tokens), }); - const detailPanel = ui.column({ gap: SPACE.sm }, [ + const detailPanel = ui.column({ gap: SPACE.sm, width: "100%", height: "100%" }, [ maybe(selected, (member) => surfacePanel( tokens, @@ -300,8 +301,18 @@ const CrewDeck = defineWidget((props, ctx): VNode => { ), ]); - const manifestBlock = ui.column({ gap: SPACE.sm }, [ - surfacePanel(tokens, "Crew Manifest", [table]), + const manifestBlock = ui.column({ gap: SPACE.sm, width: "100%", height: "100%" }, [ + ui.box( + { + border: "none", + p: 0, + width: "100%", + flex: 1, + minHeight: 10, + overflow: "hidden", + }, + [surfacePanel(tokens, "Crew Manifest", [table])], + ), ui.pagination({ id: ctx.id("crew-pagination"), page, @@ -313,126 +324,185 @@ const CrewDeck = defineWidget((props, ctx): VNode => { const deckLayout = layout.wide ? showDetailPane - ? ui.masterDetail({ - id: ctx.id("crew-master-detail"), - masterWidth: layout.crewMasterWidth, - master: manifestBlock, - detail: detailPanel, - }) - : manifestBlock - : showDetailPane - ? ui.column({ gap: SPACE.sm }, [manifestBlock, detailPanel]) - : manifestBlock; - - const operationsPanel = surfacePanel( - tokens, - "Crew Operations", - [ - sectionHeader(tokens, "Manifest Controls", "Consistent staffing and assignment flow"), - ui.row({ gap: SPACE.md, wrap: !layout.wide, items: "start" }, [ - ui.box( + ? ui.row( { - border: "none", - p: 0, + id: ctx.id("crew-master-detail"), gap: SPACE.sm, - ...(layout.wide ? { flex: 2 } : {}), + width: "100%", + height: "100%", + items: "stretch", + wrap: false, }, [ - ui.row({ gap: SPACE.sm, wrap: true }, [ - ui.badge(`Total ${counts.total}`, { variant: "info" }), - ui.badge(`Active ${counts.active}`, { variant: "success" }), - ui.badge(`Away ${counts.away}`, { variant: "warning" }), - ui.badge(`Injured ${counts.injured}`, { variant: "error" }), - ]), - ui.form([ - ui.field({ - label: "Search Crew", - hint: "Filter by name, rank, or department", - children: ui.input({ - id: ctx.id("crew-search"), - value: props.state.crewSearchQuery, - placeholder: "Type to filter", - onInput: (value) => props.dispatch({ type: "set-crew-search", query: value }), - }), - }), - ]), - ui.actions([ - ui.button({ - id: ctx.id("crew-new-assignment"), - label: "New Assignment", - intent: "primary", - onPress: () => props.dispatch({ type: "toggle-crew-editor" }), - }), - ui.button({ - id: ctx.id("crew-edit-selected"), - label: "Edit Selected", - intent: "secondary", - onPress: () => props.dispatch({ type: "toggle-crew-editor" }), - }), - ]), + ui.box( + { + border: "none", + p: 0, + width: layout.crewMasterWidth, + height: "100%", + overflow: "hidden", + }, + [manifestBlock], + ), + ui.box( + { + border: "none", + p: 0, + flex: 1, + height: "100%", + overflow: "hidden", + }, + [detailPanel], + ), ], - ), - ...(layout.wide - ? [ - ui.box( - { - border: "none", - p: 0, - flex: 1, - }, - [ - surfacePanel( - tokens, - "Crew Snapshot", + ) + : manifestBlock + : showDetailPane + ? ui.column({ gap: SPACE.sm, width: "100%", height: "100%" }, [ + ui.box( + { border: "none", p: 0, width: "100%", flex: 1, minHeight: 10, overflow: "hidden" }, + [manifestBlock], + ), + ui.box( + { border: "none", p: 0, width: "100%", flex: 1, minHeight: 10, overflow: "hidden" }, + [detailPanel], + ), + ]) + : manifestBlock; + + const operationsPanelMaxHeight = Math.max(12, Math.min(22, Math.floor(layout.height * 0.34))); + debugSnapshot("crew.render", { + viewportCols: props.state.viewportCols, + viewportRows: props.state.viewportRows, + visibleCount: visible.length, + sortedCount: sorted.length, + page, + totalPages, + pageDataCount: pageData.length, + showDetailPane, + operationsPanelMaxHeight, + editingCrew: props.state.editingCrew, + }); + const operationsPanel = ui.box( + { + border: "none", + p: 0, + width: "100%", + height: operationsPanelMaxHeight, + overflow: "scroll", + }, + [ + surfacePanel( + tokens, + "Crew Operations", + [ + sectionHeader(tokens, "Manifest Controls", "Consistent staffing and assignment flow"), + ui.row({ gap: SPACE.md, wrap: !layout.wide, items: "start" }, [ + ui.box( + { + border: "none", + p: 0, + gap: SPACE.sm, + ...(layout.wide ? { flex: 2 } : {}), + }, + [ + ui.row({ gap: SPACE.sm, wrap: true }, [ + ui.badge(`Total ${counts.total}`, { variant: "info" }), + ui.badge(`Active ${counts.active}`, { variant: "success" }), + ui.badge(`Away ${counts.away}`, { variant: "warning" }), + ui.badge(`Injured ${counts.injured}`, { variant: "error" }), + ]), + ui.form([ + ui.field({ + label: "Search Crew", + hint: "Filter by name, rank, or department", + children: ui.input({ + id: ctx.id("crew-search"), + value: props.state.crewSearchQuery, + placeholder: "Type to filter", + onInput: (value) => props.dispatch({ type: "set-crew-search", query: value }), + }), + }), + ]), + ui.actions([ + ui.button({ + id: ctx.id("crew-new-assignment"), + label: "New Assignment", + intent: "primary", + onPress: () => props.dispatch({ type: "toggle-crew-editor" }), + }), + ui.button({ + id: ctx.id("crew-edit-selected"), + label: "Edit Selected", + intent: "secondary", + onPress: () => props.dispatch({ type: "toggle-crew-editor" }), + }), + ]), + ], + ), + ...(layout.wide + ? [ + ui.box( + { + border: "none", + p: 0, + flex: 1, + }, [ - selected - ? ui.column({ gap: SPACE.xs }, [ - ui.text(selected.name, { variant: "label" }), - ui.row({ gap: SPACE.xs, wrap: true }, [ - ui.badge(rankBadge(selected.rank).text, { - variant: rankBadge(selected.rank).variant, + surfacePanel( + tokens, + "Crew Snapshot", + [ + selected + ? ui.column({ gap: SPACE.xs }, [ + ui.text(selected.name, { variant: "label" }), + ui.row({ gap: SPACE.xs, wrap: true }, [ + ui.badge(rankBadge(selected.rank).text, { + variant: rankBadge(selected.rank).variant, + }), + ui.badge(statusBadge(selected.status).text, { + variant: statusBadge(selected.status).variant, + }), + ui.tag(departmentLabel(selected.department), { variant: "info" }), + ]), + ]) + : ui.text("No crew selected", { + variant: "caption", + style: { fg: tokens.text.muted, dim: true }, }), - ui.badge(statusBadge(selected.status).text, { - variant: statusBadge(selected.status).variant, + ui.divider(), + ui.row({ gap: SPACE.xs, wrap: true }, [ + ui.badge(`Visible ${sorted.length}`, { variant: "info" }), + ui.badge(`Page ${page}/${totalPages}`, { variant: "default" }), + ]), + staffingError + ? ui.callout("Critical staffing below minimum.", { + title: "Guardrail", + variant: "warning", + }) + : ui.callout("Critical staffing thresholds healthy.", { + title: "Guardrail", + variant: "success", }), - ui.tag(departmentLabel(selected.department), { variant: "info" }), - ]), - ]) - : ui.text("No crew selected", { - variant: "caption", - style: { fg: tokens.text.muted, dim: true }, - }), - ui.divider(), - ui.row({ gap: SPACE.xs, wrap: true }, [ - ui.badge(`Visible ${sorted.length}`, { variant: "info" }), - ui.badge(`Page ${page}/${totalPages}`, { variant: "default" }), - ]), - staffingError - ? ui.callout("Critical staffing below minimum.", { - title: "Guardrail", - variant: "warning", - }) - : ui.callout("Critical staffing thresholds healthy.", { - title: "Guardrail", - variant: "success", - }), + ], + { + tone: "inset", + p: SPACE.sm, + gap: SPACE.sm, + }, + ), ], - { - tone: "inset", - p: SPACE.sm, - gap: SPACE.sm, - }, ), - ], - ), - ] - : []), - ]), + ] + : []), + ]), + ], + { tone: "base" }, + ), ], - { tone: "base" }, ); - return ui.column({ gap: SPACE.md, width: "100%" }, [ + return ui.column({ gap: SPACE.md, width: "100%", height: "100%" }, [ operationsPanel, show( asyncCrew.loading, @@ -443,7 +513,20 @@ const CrewDeck = defineWidget((props, ctx): VNode => { ui.skeleton(44, { variant: "text" }), ], { tone: "inset" }), ), - show(!asyncCrew.loading, deckLayout), + show( + !asyncCrew.loading, + ui.box( + { + border: "none", + p: 0, + width: "100%", + flex: 1, + minHeight: 12, + overflow: "hidden", + }, + [deckLayout], + ), + ), ]); }); @@ -452,7 +535,7 @@ export function renderCrewScreen(context: RouteRenderContext, dep title: "Crew Manifest", context, deps, - body: ui.column({ gap: SPACE.sm, width: "100%" }, [ + body: ui.column({ gap: SPACE.sm, width: "100%", height: "100%" }, [ CrewDeck({ key: "crew-deck", state: context.state, diff --git a/packages/create-rezi/templates/starship/src/screens/engineering.ts b/packages/create-rezi/templates/starship/src/screens/engineering.ts index 6247de81..016b2158 100644 --- a/packages/create-rezi/templates/starship/src/screens/engineering.ts +++ b/packages/create-rezi/templates/starship/src/screens/engineering.ts @@ -371,16 +371,27 @@ const EngineeringDeck = defineWidget((props, ctx): VNode = }), ]); - const leftPane = ui.column({ gap: SPACE.sm, width: "100%" }, [ - reactorPanel, - ...(showSecondaryPanels ? [treePanel] : []), - ]); - const rightPane = ui.column({ gap: SPACE.sm, width: "100%" }, [ - powerPanel, - ...(showSecondaryPanels ? [thermalPanel, diagnosticsPanel] : []), - ]); + const leftPane = showSecondaryPanels + ? ui.column({ gap: SPACE.sm, width: "100%", height: "100%" }, [ + ui.box({ border: "none", p: 0, width: "100%", flex: 3, minHeight: 12 }, [reactorPanel]), + ui.box({ border: "none", p: 0, width: "100%", flex: 2, minHeight: 10, overflow: "hidden" }, [ + treePanel, + ]), + ]) + : ui.column({ gap: SPACE.sm, width: "100%" }, [reactorPanel]); + const rightPane = showSecondaryPanels + ? ui.column({ gap: SPACE.sm, width: "100%", height: "100%" }, [ + ui.box({ border: "none", p: 0, width: "100%", flex: 3, minHeight: 12, overflow: "hidden" }, [ + powerPanel, + ]), + ui.box({ border: "none", p: 0, width: "100%", flex: 2, minHeight: 10 }, [thermalPanel]), + ui.box({ border: "none", p: 0, width: "100%", flex: 2, minHeight: 10, overflow: "hidden" }, [ + diagnosticsPanel, + ]), + ]) + : ui.column({ gap: SPACE.sm, width: "100%" }, [powerPanel]); - const responsiveDeckHeight = Math.max( + const responsiveDeckMinHeight = Math.max( 16, contentRows - (showControlsSummary ? 12 : 10) - (showSecondaryPanels ? 0 : 2), ); @@ -395,7 +406,8 @@ const EngineeringDeck = defineWidget((props, ctx): VNode = border: "none", p: 0, width: "100%", - height: responsiveDeckHeight, + flex: 1, + minHeight: responsiveDeckMinHeight, overflow: "scroll", }, [responsiveDeckBody], @@ -491,7 +503,7 @@ const EngineeringDeck = defineWidget((props, ctx): VNode = includeResponsiveDeck: renderMode === "full", responsiveDeckMode: useWideRow ? "row" : "column", forceStackViaEnv, - responsiveDeckHeight, + responsiveDeckMinHeight, }); if (veryCompactHeight) { @@ -502,7 +514,7 @@ const EngineeringDeck = defineWidget((props, ctx): VNode = return ui.column({ gap: SPACE.sm, width: "100%" }, [controlsPanel, reactorPanel]); } - return ui.column({ gap: SPACE.sm, width: "100%" }, [ + return ui.column({ gap: SPACE.sm, width: "100%", height: "100%" }, [ controlsRegion, responsiveDeck, ]); @@ -516,7 +528,7 @@ export function renderEngineeringScreen( title: "Engineering Deck", context, deps, - body: ui.column({ gap: SPACE.sm, width: "100%" }, [ + body: ui.column({ gap: SPACE.sm, width: "100%", height: "100%" }, [ EngineeringDeck({ key: "engineering-deck", state: context.state, diff --git a/packages/create-rezi/templates/starship/src/screens/settings.ts b/packages/create-rezi/templates/starship/src/screens/settings.ts index 9b9aac93..e18b31da 100644 --- a/packages/create-rezi/templates/starship/src/screens/settings.ts +++ b/packages/create-rezi/templates/starship/src/screens/settings.ts @@ -209,7 +209,7 @@ function settingsRightRail(state: StarshipState, deps: RouteDeps): VNode { subtitle: activeTheme.label, actions: [ui.badge("Preview", { variant: "info" })], }), - body: ui.column({ gap: SPACE.xs }, [ + body: ui.column({ gap: SPACE.xs, width: "100%", height: "100%" }, [ ui.breadcrumb({ items: [{ label: "Bridge" }, { label: "Settings" }, { label: "Theme Preview" }], }), @@ -277,7 +277,7 @@ export function renderSettingsScreen( title: "Ship Settings", context, deps, - body: ui.column({ gap: SPACE.sm, width: "100%" }, [ + body: ui.column({ gap: SPACE.sm, width: "100%", height: "100%" }, [ SettingsDeck({ key: "settings-deck", state: context.state, diff --git a/packages/node/src/frameAudit.ts b/packages/node/src/frameAudit.ts index 0b98f472..63953f08 100644 --- a/packages/node/src/frameAudit.ts +++ b/packages/node/src/frameAudit.ts @@ -145,10 +145,18 @@ export const FRAME_AUDIT_NATIVE_RING_BYTES = envPositiveInt( ); // Match zr_debug category/code values. +export const ZR_DEBUG_CAT_FRAME = 1; export const ZR_DEBUG_CAT_DRAWLIST = 3; +export const ZR_DEBUG_CAT_PERF = 6; +export const ZR_DEBUG_CODE_FRAME_BEGIN = 0x0100; +export const ZR_DEBUG_CODE_FRAME_SUBMIT = 0x0101; +export const ZR_DEBUG_CODE_FRAME_PRESENT = 0x0102; +export const ZR_DEBUG_CODE_FRAME_RESIZE = 0x0103; export const ZR_DEBUG_CODE_DRAWLIST_VALIDATE = 0x0300; export const ZR_DEBUG_CODE_DRAWLIST_EXECUTE = 0x0301; export const ZR_DEBUG_CODE_DRAWLIST_CMD = 0x0302; +export const ZR_DEBUG_CODE_PERF_TIMING = 0x0600; +export const ZR_DEBUG_CODE_PERF_DIFF_PATH = 0x0601; function readContextRoute(): string | null { const g = globalThis as { diff --git a/packages/node/src/worker/engineWorker.ts b/packages/node/src/worker/engineWorker.ts index b07aef8c..4d62620d 100644 --- a/packages/node/src/worker/engineWorker.ts +++ b/packages/node/src/worker/engineWorker.ts @@ -11,10 +11,18 @@ import { parentPort, workerData } from "node:worker_threads"; import { FRAME_AUDIT_NATIVE_ENABLED, FRAME_AUDIT_NATIVE_RING_BYTES, + ZR_DEBUG_CAT_FRAME, ZR_DEBUG_CAT_DRAWLIST, + ZR_DEBUG_CAT_PERF, + ZR_DEBUG_CODE_FRAME_BEGIN, + ZR_DEBUG_CODE_FRAME_SUBMIT, + ZR_DEBUG_CODE_FRAME_PRESENT, + ZR_DEBUG_CODE_FRAME_RESIZE, ZR_DEBUG_CODE_DRAWLIST_CMD, ZR_DEBUG_CODE_DRAWLIST_EXECUTE, ZR_DEBUG_CODE_DRAWLIST_VALIDATE, + ZR_DEBUG_CODE_PERF_TIMING, + ZR_DEBUG_CODE_PERF_DIFF_PATH, createFrameAuditLogger, drawlistFingerprint, } from "../frameAudit.js"; @@ -318,6 +326,26 @@ const DEBUG_QUERY_MIN_HEADERS_CAP = DEBUG_HEADER_BYTES; const DEBUG_QUERY_MAX_HEADERS_CAP = 1 << 20; // 1 MiB const NO_RECYCLED_DRAWLISTS: readonly ArrayBuffer[] = Object.freeze([]); const DEBUG_DRAWLIST_RECORD_BYTES = 48; +const DEBUG_FRAME_RECORD_BYTES = 56; +const DEBUG_PERF_RECORD_BYTES = 24; +const DEBUG_DIFF_PATH_RECORD_BYTES = 56; +const NATIVE_FRAME_AUDIT_CATEGORY_MASK = + (1 << ZR_DEBUG_CAT_DRAWLIST) | (1 << ZR_DEBUG_CAT_FRAME) | (1 << ZR_DEBUG_CAT_PERF); + +function nativeFrameCodeName(code: number): string { + if (code === ZR_DEBUG_CODE_FRAME_BEGIN) return "frame.begin"; + if (code === ZR_DEBUG_CODE_FRAME_SUBMIT) return "frame.submit"; + if (code === ZR_DEBUG_CODE_FRAME_PRESENT) return "frame.present"; + if (code === ZR_DEBUG_CODE_FRAME_RESIZE) return "frame.resize"; + return "frame.unknown"; +} + +function nativePerfPhaseName(phase: number): string { + if (phase === 0) return "poll"; + if (phase === 1) return "submit"; + if (phase === 2) return "present"; + return "unknown"; +} type FrameAuditMeta = { frameSeq: number; @@ -438,7 +466,7 @@ function drainNativeFrameAudit(reason: string): void { engineId, { minRecordId: nativeFrameAuditNextRecordId, - categoryMask: 1 << ZR_DEBUG_CAT_DRAWLIST, + categoryMask: NATIVE_FRAME_AUDIT_CATEGORY_MASK, minSeverity: 0, maxRecords: Math.floor(headersCap / DEBUG_HEADER_BYTES), }, @@ -552,6 +580,112 @@ function drainNativeFrameAudit(reason: string): void { fillRects: dvPayload.getUint32(36, true), }); } + continue; + } + + if ( + (code === ZR_DEBUG_CODE_FRAME_BEGIN || + code === ZR_DEBUG_CODE_FRAME_SUBMIT || + code === ZR_DEBUG_CODE_FRAME_PRESENT || + code === ZR_DEBUG_CODE_FRAME_RESIZE) && + payloadSize >= DEBUG_FRAME_RECORD_BYTES + ) { + const payload = new Uint8Array(DEBUG_FRAME_RECORD_BYTES); + let wrote = 0; + try { + wrote = native.engineDebugGetPayload(engineId, recordId, payload); + } catch (err) { + frameAudit.emit("native.debug.payload_error", { + reason, + recordId: recordId.toString(), + detail: safeDetail(err), + }); + continue; + } + if (wrote >= DEBUG_FRAME_RECORD_BYTES) { + const dvPayload = new DataView(payload.buffer, payload.byteOffset, DEBUG_FRAME_RECORD_BYTES); + frameAudit.emit("native.frame.summary", { + reason, + recordId: recordId.toString(), + frameId: u64FromView(dvPayload, 0).toString(), + code, + codeName: nativeFrameCodeName(code), + cols: dvPayload.getUint32(8, true), + rows: dvPayload.getUint32(12, true), + drawlistBytes: dvPayload.getUint32(16, true), + drawlistCmds: dvPayload.getUint32(20, true), + diffBytesEmitted: dvPayload.getUint32(24, true), + dirtyLines: dvPayload.getUint32(28, true), + dirtyCells: dvPayload.getUint32(32, true), + damageRects: dvPayload.getUint32(36, true), + usDrawlist: dvPayload.getUint32(40, true), + usDiff: dvPayload.getUint32(44, true), + usWrite: dvPayload.getUint32(48, true), + }); + } + continue; + } + + if (code === ZR_DEBUG_CODE_PERF_TIMING && payloadSize >= DEBUG_PERF_RECORD_BYTES) { + const payload = new Uint8Array(DEBUG_PERF_RECORD_BYTES); + let wrote = 0; + try { + wrote = native.engineDebugGetPayload(engineId, recordId, payload); + } catch (err) { + frameAudit.emit("native.debug.payload_error", { + reason, + recordId: recordId.toString(), + detail: safeDetail(err), + }); + continue; + } + if (wrote >= DEBUG_PERF_RECORD_BYTES) { + const dvPayload = new DataView(payload.buffer, payload.byteOffset, DEBUG_PERF_RECORD_BYTES); + const phase = dvPayload.getUint32(8, true); + frameAudit.emit("native.perf.timing", { + reason, + recordId: recordId.toString(), + frameId: u64FromView(dvPayload, 0).toString(), + phase, + phaseName: nativePerfPhaseName(phase), + usElapsed: dvPayload.getUint32(12, true), + bytesProcessed: dvPayload.getUint32(16, true), + }); + } + continue; + } + + if (code === ZR_DEBUG_CODE_PERF_DIFF_PATH && payloadSize >= DEBUG_DIFF_PATH_RECORD_BYTES) { + const payload = new Uint8Array(DEBUG_DIFF_PATH_RECORD_BYTES); + let wrote = 0; + try { + wrote = native.engineDebugGetPayload(engineId, recordId, payload); + } catch (err) { + frameAudit.emit("native.debug.payload_error", { + reason, + recordId: recordId.toString(), + detail: safeDetail(err), + }); + continue; + } + if (wrote >= DEBUG_DIFF_PATH_RECORD_BYTES) { + const dvPayload = new DataView(payload.buffer, payload.byteOffset, DEBUG_DIFF_PATH_RECORD_BYTES); + frameAudit.emit("native.perf.diffPath", { + reason, + recordId: recordId.toString(), + frameId: u64FromView(dvPayload, 0).toString(), + sweepFramesTotal: u64FromView(dvPayload, 8).toString(), + damageFramesTotal: u64FromView(dvPayload, 16).toString(), + scrollAttemptsTotal: u64FromView(dvPayload, 24).toString(), + scrollHitsTotal: u64FromView(dvPayload, 32).toString(), + collisionGuardHitsTotal: u64FromView(dvPayload, 40).toString(), + pathSweepUsed: dvPayload.getUint8(48), + pathDamageUsed: dvPayload.getUint8(49), + scrollOptAttempted: dvPayload.getUint8(50), + scrollOptHit: dvPayload.getUint8(51), + collisionGuardHitsLast: dvPayload.getUint32(52, true), + }); + } } } if (!advanced) return; From e0292100ebd192c1060ffb02b21d60b16a62b97f Mon Sep 17 00:00:00 2001 From: RtlZeroMemory <58250858+RtlZeroMemory@users.noreply.github.com> Date: Fri, 27 Feb 2026 08:48:19 +0400 Subject: [PATCH 2/7] Fix starship template theme to use packed rgb colors --- .../templates/starship/src/theme.ts | 199 +++++++++--------- 1 file changed, 94 insertions(+), 105 deletions(-) diff --git a/packages/create-rezi/templates/starship/src/theme.ts b/packages/create-rezi/templates/starship/src/theme.ts index 91d1ba95..df0e53c7 100644 --- a/packages/create-rezi/templates/starship/src/theme.ts +++ b/packages/create-rezi/templates/starship/src/theme.ts @@ -1,12 +1,12 @@ import { type BadgeVariant, - type Rgb, type Rgb24, type TextStyle, type ThemeDefinition, draculaTheme, extendTheme, nordTheme, + rgb, } from "@rezi-ui/core"; import type { AlertLevel, ThemeName } from "./types.js"; @@ -42,52 +42,52 @@ const DAY_SHIFT_THEME = extendTheme(nordTheme, { focusIndicator: { bold: true, underline: false, - focusRingColor: { r: 118, g: 208, b: 255 }, + focusRingColor: rgb(118, 208, 255), }, colors: { bg: { - base: { r: 29, g: 42, b: 58 }, - elevated: { r: 40, g: 57, b: 76 }, - overlay: { r: 52, g: 72, b: 94 }, - subtle: { r: 34, g: 49, b: 67 }, + base: rgb(29, 42, 58), + elevated: rgb(40, 57, 76), + overlay: rgb(52, 72, 94), + subtle: rgb(34, 49, 67), }, fg: { - primary: { r: 236, g: 243, b: 255 }, - secondary: { r: 190, g: 217, b: 242 }, - muted: { r: 108, g: 138, b: 168 }, - inverse: { r: 20, g: 30, b: 42 }, + primary: rgb(236, 243, 255), + secondary: rgb(190, 217, 242), + muted: rgb(108, 138, 168), + inverse: rgb(20, 30, 42), }, border: { - subtle: { r: 74, g: 98, b: 126 }, - default: { r: 104, g: 136, b: 168 }, - strong: { r: 137, g: 171, b: 206 }, + subtle: rgb(74, 98, 126), + default: rgb(104, 136, 168), + strong: rgb(137, 171, 206), }, accent: { - primary: { r: 106, g: 195, b: 255 }, - secondary: { r: 129, g: 217, b: 255 }, - tertiary: { r: 180, g: 231, b: 164 }, + primary: rgb(106, 195, 255), + secondary: rgb(129, 217, 255), + tertiary: rgb(180, 231, 164), }, - info: { r: 118, g: 208, b: 255 }, - success: { r: 166, g: 228, b: 149 }, - warning: { r: 255, g: 211, b: 131 }, - error: { r: 239, g: 118, b: 132 }, + info: rgb(118, 208, 255), + success: rgb(166, 228, 149), + warning: rgb(255, 211, 131), + error: rgb(239, 118, 132), selected: { - bg: { r: 68, g: 105, b: 139 }, - fg: { r: 236, g: 243, b: 255 }, + bg: rgb(68, 105, 139), + fg: rgb(236, 243, 255), }, disabled: { - fg: { r: 95, g: 121, b: 149 }, - bg: { r: 39, g: 55, b: 72 }, + fg: rgb(95, 121, 149), + bg: rgb(39, 55, 72), }, diagnostic: { - error: { r: 239, g: 118, b: 132 }, - warning: { r: 255, g: 211, b: 131 }, - info: { r: 118, g: 208, b: 255 }, - hint: { r: 149, g: 187, b: 228 }, + error: rgb(239, 118, 132), + warning: rgb(255, 211, 131), + info: rgb(118, 208, 255), + hint: rgb(149, 187, 228), }, focus: { - ring: { r: 118, g: 208, b: 255 }, - bg: { r: 63, g: 96, b: 126 }, + ring: rgb(118, 208, 255), + bg: rgb(63, 96, 126), }, }, }); @@ -105,52 +105,52 @@ const NIGHT_SHIFT_THEME = extendTheme(draculaTheme, { focusIndicator: { bold: true, underline: false, - focusRingColor: { r: 176, g: 133, b: 255 }, + focusRingColor: rgb(176, 133, 255), }, colors: { bg: { - base: { r: 19, g: 22, b: 33 }, - elevated: { r: 28, g: 31, b: 46 }, - overlay: { r: 37, g: 41, b: 60 }, - subtle: { r: 24, g: 27, b: 40 }, + base: rgb(19, 22, 33), + elevated: rgb(28, 31, 46), + overlay: rgb(37, 41, 60), + subtle: rgb(24, 27, 40), }, fg: { - primary: { r: 244, g: 246, b: 252 }, - secondary: { r: 202, g: 185, b: 252 }, - muted: { r: 131, g: 146, b: 186 }, - inverse: { r: 19, g: 22, b: 33 }, + primary: rgb(244, 246, 252), + secondary: rgb(202, 185, 252), + muted: rgb(131, 146, 186), + inverse: rgb(19, 22, 33), }, accent: { - primary: { r: 176, g: 133, b: 255 }, - secondary: { r: 129, g: 235, b: 255 }, - tertiary: { r: 119, g: 255, b: 196 }, + primary: rgb(176, 133, 255), + secondary: rgb(129, 235, 255), + tertiary: rgb(119, 255, 196), }, - info: { r: 129, g: 235, b: 255 }, - success: { r: 110, g: 249, b: 174 }, - warning: { r: 255, g: 207, b: 124 }, - error: { r: 255, g: 118, b: 132 }, + info: rgb(129, 235, 255), + success: rgb(110, 249, 174), + warning: rgb(255, 207, 124), + error: rgb(255, 118, 132), selected: { - bg: { r: 68, g: 76, b: 112 }, - fg: { r: 244, g: 246, b: 252 }, + bg: rgb(68, 76, 112), + fg: rgb(244, 246, 252), }, disabled: { - fg: { r: 99, g: 111, b: 146 }, - bg: { r: 28, g: 31, b: 46 }, + fg: rgb(99, 111, 146), + bg: rgb(28, 31, 46), }, diagnostic: { - error: { r: 255, g: 118, b: 132 }, - warning: { r: 255, g: 207, b: 124 }, - info: { r: 129, g: 235, b: 255 }, - hint: { r: 206, g: 158, b: 255 }, + error: rgb(255, 118, 132), + warning: rgb(255, 207, 124), + info: rgb(129, 235, 255), + hint: rgb(206, 158, 255), }, focus: { - ring: { r: 176, g: 133, b: 255 }, - bg: { r: 64, g: 57, b: 96 }, + ring: rgb(176, 133, 255), + bg: rgb(64, 57, 96), }, border: { - subtle: { r: 43, g: 49, b: 71 }, - default: { r: 72, g: 81, b: 116 }, - strong: { r: 104, g: 115, b: 156 }, + subtle: rgb(43, 49, 71), + default: rgb(72, 81, 116), + strong: rgb(104, 115, 156), }, }, }); @@ -168,52 +168,52 @@ const RED_ALERT_THEME = extendTheme(draculaTheme, { focusIndicator: { bold: true, underline: false, - focusRingColor: { r: 255, g: 112, b: 112 }, + focusRingColor: rgb(255, 112, 112), }, colors: { bg: { - base: { r: 24, g: 12, b: 19 }, - elevated: { r: 34, g: 15, b: 24 }, - overlay: { r: 46, g: 21, b: 32 }, - subtle: { r: 29, g: 13, b: 22 }, + base: rgb(24, 12, 19), + elevated: rgb(34, 15, 24), + overlay: rgb(46, 21, 32), + subtle: rgb(29, 13, 22), }, fg: { - primary: { r: 255, g: 238, b: 242 }, - secondary: { r: 244, g: 190, b: 205 }, - muted: { r: 170, g: 122, b: 139 }, - inverse: { r: 24, g: 12, b: 19 }, + primary: rgb(255, 238, 242), + secondary: rgb(244, 190, 205), + muted: rgb(170, 122, 139), + inverse: rgb(24, 12, 19), }, accent: { - primary: { r: 255, g: 114, b: 144 }, - secondary: { r: 255, g: 182, b: 120 }, - tertiary: { r: 255, g: 220, b: 146 }, + primary: rgb(255, 114, 144), + secondary: rgb(255, 182, 120), + tertiary: rgb(255, 220, 146), }, - success: { r: 134, g: 247, b: 176 }, - warning: { r: 255, g: 181, b: 112 }, - error: { r: 255, g: 93, b: 117 }, - info: { r: 255, g: 141, b: 153 }, + success: rgb(134, 247, 176), + warning: rgb(255, 181, 112), + error: rgb(255, 93, 117), + info: rgb(255, 141, 153), selected: { - bg: { r: 82, g: 34, b: 52 }, - fg: { r: 255, g: 238, b: 242 }, + bg: rgb(82, 34, 52), + fg: rgb(255, 238, 242), }, disabled: { - fg: { r: 142, g: 96, b: 112 }, - bg: { r: 34, g: 15, b: 24 }, + fg: rgb(142, 96, 112), + bg: rgb(34, 15, 24), }, diagnostic: { - error: { r: 255, g: 93, b: 117 }, - warning: { r: 255, g: 181, b: 112 }, - info: { r: 255, g: 141, b: 153 }, - hint: { r: 255, g: 203, b: 133 }, + error: rgb(255, 93, 117), + warning: rgb(255, 181, 112), + info: rgb(255, 141, 153), + hint: rgb(255, 203, 133), }, focus: { - ring: { r: 255, g: 112, b: 112 }, - bg: { r: 76, g: 35, b: 50 }, + ring: rgb(255, 112, 112), + bg: rgb(76, 35, 50), }, border: { - subtle: { r: 86, g: 45, b: 61 }, - default: { r: 124, g: 65, b: 86 }, - strong: { r: 172, g: 86, b: 112 }, + subtle: rgb(86, 45, 61), + default: rgb(124, 65, 86), + strong: rgb(172, 86, 112), }, }, }); @@ -244,14 +244,10 @@ function clampChannel(value: number): number { return Math.max(0, Math.min(255, Math.round(value))); } -type ColorInput = Rgb | Rgb24; +type ColorInput = Rgb24; -function packRgb(value: Rgb): Rgb24 { - return ( - ((clampChannel(value.r) & 0xff) << 16) | - ((clampChannel(value.g) & 0xff) << 8) | - (clampChannel(value.b) & 0xff) - ); +function packRgb(value: Rgb24): Rgb24 { + return (Math.round(value) >>> 0) & 0x00ff_ffff; } function rgbChannel(value: Rgb24, shift: 0 | 8 | 16): number { @@ -259,17 +255,10 @@ function rgbChannel(value: Rgb24, shift: 0 | 8 | 16): number { } function unpackRgb(value: ColorInput): Readonly<{ r: number; g: number; b: number }> { - if (typeof value === "number") { - return Object.freeze({ - r: rgbChannel(value, 16), - g: rgbChannel(value, 8), - b: rgbChannel(value, 0), - }); - } return Object.freeze({ - r: clampChannel(value.r), - g: clampChannel(value.g), - b: clampChannel(value.b), + r: rgbChannel(value, 16), + g: rgbChannel(value, 8), + b: rgbChannel(value, 0), }); } From 56868eb96bd8ada1d4a9b38acc9480150020a02b Mon Sep 17 00:00:00 2001 From: RtlZeroMemory <58250858+RtlZeroMemory@users.noreply.github.com> Date: Fri, 27 Feb 2026 10:38:51 +0400 Subject: [PATCH 3/7] Address PR feedback and fix lint/native diff telemetry audit --- docs/dev/live-pty-debugging.md | 4 +-- .../renderToDrawlist/widgets/collections.ts | 2 +- packages/node/src/worker/engineWorker.ts | 35 +++++++++++++------ 3 files changed, 27 insertions(+), 14 deletions(-) diff --git a/docs/dev/live-pty-debugging.md b/docs/dev/live-pty-debugging.md index bdb18d91..05ed2a37 100644 --- a/docs/dev/live-pty-debugging.md +++ b/docs/dev/live-pty-debugging.md @@ -21,7 +21,7 @@ The PTY + frame-audit workflow gives deterministic evidence for all of those. From repo root: ```bash -cd /home/k3nig/Rezi +cd npx tsc -b packages/core packages/node packages/create-rezi ``` @@ -34,7 +34,7 @@ This enables: - worker execution path (`REZI_STARSHIP_EXECUTION_MODE=worker`) ```bash -cd /home/k3nig/Rezi +cd : > /tmp/rezi-frame-audit.ndjson : > /tmp/starship.log diff --git a/packages/core/src/renderer/renderToDrawlist/widgets/collections.ts b/packages/core/src/renderer/renderToDrawlist/widgets/collections.ts index f92cd8fd..c5787984 100644 --- a/packages/core/src/renderer/renderToDrawlist/widgets/collections.ts +++ b/packages/core/src/renderer/renderToDrawlist/widgets/collections.ts @@ -6,6 +6,7 @@ import { truncateWithEllipsis, } from "../../../layout/textMeasure.js"; import type { Rect } from "../../../layout/types.js"; +import { FRAME_AUDIT_ENABLED, emitFrameAudit } from "../../../perf/frameAudit.js"; import type { RuntimeInstance } from "../../../runtime/commit.js"; import type { FocusState } from "../../../runtime/focus.js"; import type { @@ -28,7 +29,6 @@ import { getTotalHeight, resolveVirtualListItemHeightSpec, } from "../../../widgets/virtualList.js"; -import { emitFrameAudit, FRAME_AUDIT_ENABLED } from "../../../perf/frameAudit.js"; import { asTextStyle } from "../../styles.js"; import { renderBoxBorder } from "../boxBorder.js"; import { isVisibleRect } from "../indices.js"; diff --git a/packages/node/src/worker/engineWorker.ts b/packages/node/src/worker/engineWorker.ts index 4d62620d..cec86dd1 100644 --- a/packages/node/src/worker/engineWorker.ts +++ b/packages/node/src/worker/engineWorker.ts @@ -11,18 +11,18 @@ import { parentPort, workerData } from "node:worker_threads"; import { FRAME_AUDIT_NATIVE_ENABLED, FRAME_AUDIT_NATIVE_RING_BYTES, - ZR_DEBUG_CAT_FRAME, ZR_DEBUG_CAT_DRAWLIST, + ZR_DEBUG_CAT_FRAME, ZR_DEBUG_CAT_PERF, - ZR_DEBUG_CODE_FRAME_BEGIN, - ZR_DEBUG_CODE_FRAME_SUBMIT, - ZR_DEBUG_CODE_FRAME_PRESENT, - ZR_DEBUG_CODE_FRAME_RESIZE, ZR_DEBUG_CODE_DRAWLIST_CMD, ZR_DEBUG_CODE_DRAWLIST_EXECUTE, ZR_DEBUG_CODE_DRAWLIST_VALIDATE, - ZR_DEBUG_CODE_PERF_TIMING, + ZR_DEBUG_CODE_FRAME_BEGIN, + ZR_DEBUG_CODE_FRAME_PRESENT, + ZR_DEBUG_CODE_FRAME_RESIZE, + ZR_DEBUG_CODE_FRAME_SUBMIT, ZR_DEBUG_CODE_PERF_DIFF_PATH, + ZR_DEBUG_CODE_PERF_TIMING, createFrameAuditLogger, drawlistFingerprint, } from "../frameAudit.js"; @@ -328,7 +328,8 @@ const NO_RECYCLED_DRAWLISTS: readonly ArrayBuffer[] = Object.freeze([]); const DEBUG_DRAWLIST_RECORD_BYTES = 48; const DEBUG_FRAME_RECORD_BYTES = 56; const DEBUG_PERF_RECORD_BYTES = 24; -const DEBUG_DIFF_PATH_RECORD_BYTES = 56; +// Must cover sizeof(zr_diff_telemetry_record_t) (includes native trailing pad). +const DEBUG_DIFF_PATH_RECORD_BYTES = 64; const NATIVE_FRAME_AUDIT_CATEGORY_MASK = (1 << ZR_DEBUG_CAT_DRAWLIST) | (1 << ZR_DEBUG_CAT_FRAME) | (1 << ZR_DEBUG_CAT_PERF); @@ -436,7 +437,7 @@ function maybeEnableNativeFrameAudit(): void { enabled: true, ringCapacity: FRAME_AUDIT_NATIVE_RING_BYTES, minSeverity: 0, - categoryMask: 0xffff_ffff, + categoryMask: NATIVE_FRAME_AUDIT_CATEGORY_MASK, captureRawEvents: false, captureDrawlistBytes: true, }); @@ -603,7 +604,11 @@ function drainNativeFrameAudit(reason: string): void { continue; } if (wrote >= DEBUG_FRAME_RECORD_BYTES) { - const dvPayload = new DataView(payload.buffer, payload.byteOffset, DEBUG_FRAME_RECORD_BYTES); + const dvPayload = new DataView( + payload.buffer, + payload.byteOffset, + DEBUG_FRAME_RECORD_BYTES, + ); frameAudit.emit("native.frame.summary", { reason, recordId: recordId.toString(), @@ -640,7 +645,11 @@ function drainNativeFrameAudit(reason: string): void { continue; } if (wrote >= DEBUG_PERF_RECORD_BYTES) { - const dvPayload = new DataView(payload.buffer, payload.byteOffset, DEBUG_PERF_RECORD_BYTES); + const dvPayload = new DataView( + payload.buffer, + payload.byteOffset, + DEBUG_PERF_RECORD_BYTES, + ); const phase = dvPayload.getUint32(8, true); frameAudit.emit("native.perf.timing", { reason, @@ -669,7 +678,11 @@ function drainNativeFrameAudit(reason: string): void { continue; } if (wrote >= DEBUG_DIFF_PATH_RECORD_BYTES) { - const dvPayload = new DataView(payload.buffer, payload.byteOffset, DEBUG_DIFF_PATH_RECORD_BYTES); + const dvPayload = new DataView( + payload.buffer, + payload.byteOffset, + DEBUG_DIFF_PATH_RECORD_BYTES, + ); frameAudit.emit("native.perf.diffPath", { reason, recordId: recordId.toString(), From 6cf158f4da0a7731fccb24217b242f52b13265c2 Mon Sep 17 00:00:00 2001 From: RtlZeroMemory <58250858+RtlZeroMemory@users.noreply.github.com> Date: Fri, 27 Feb 2026 10:43:29 +0400 Subject: [PATCH 4/7] Fix native vendor integrity check for uninitialized submodules --- .../check-native-vendor-integrity.test.mjs | 26 +++++++++++++++++++ scripts/check-native-vendor-integrity.mjs | 7 +++++ 2 files changed, 33 insertions(+) diff --git a/scripts/__tests__/check-native-vendor-integrity.test.mjs b/scripts/__tests__/check-native-vendor-integrity.test.mjs index 14ee256c..5abe8ea2 100644 --- a/scripts/__tests__/check-native-vendor-integrity.test.mjs +++ b/scripts/__tests__/check-native-vendor-integrity.test.mjs @@ -3,6 +3,7 @@ */ import { strict as assert } from "node:assert"; +import { execFileSync } from "node:child_process"; import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; @@ -21,6 +22,10 @@ function makeFixtureRoot() { return mkdtempSync(join(tmpdir(), "rezi-native-vendor-")); } +function runGit(cwd, args) { + return execFileSync("git", args, { cwd, encoding: "utf8" }).trim(); +} + function writeBaseFixture(root, commit = COMMIT_A) { writeUtf8( join(root, "packages/native/build.rs"), @@ -133,4 +138,25 @@ describe("check-native-vendor-integrity", () => { rmSync(root, { recursive: true, force: true }); } }); + + test("ignores uninitialized gitlink directory in superproject checkout", () => { + const root = makeFixtureRoot(); + try { + writeBaseFixture(root, COMMIT_A); + runGit(root, ["init"]); + runGit(root, ["config", "user.name", "Test User"]); + runGit(root, ["config", "user.email", "test@example.com"]); + runGit(root, ["add", "."]); + runGit(root, ["commit", "-m", "fixture"]); + + const result = checkNativeVendorIntegrity(root, { + resolveGitlinkCommit: () => COMMIT_A, + }); + assert.equal(result.success, true); + assert.equal(result.pinnedCommit, COMMIT_A); + assert.equal(result.gitlinkCommit, COMMIT_A); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); }); diff --git a/scripts/check-native-vendor-integrity.mjs b/scripts/check-native-vendor-integrity.mjs index df2c85ed..dd7ecb7d 100644 --- a/scripts/check-native-vendor-integrity.mjs +++ b/scripts/check-native-vendor-integrity.mjs @@ -67,6 +67,13 @@ function readSubmoduleHead(rootDir) { return null; } + // A gitlink path may exist as an ordinary directory when submodules are not + // initialized. In that case `git -C ` resolves to the parent repo and + // returns the superproject HEAD, which is not a submodule HEAD. + // Only treat vendor/zireael as checked out when it has its own git metadata. + const submoduleGitMeta = join(submoduleDir, ".git"); + if (!existsSync(submoduleGitMeta)) return null; + try { const head = runGit(submoduleDir, ["rev-parse", "HEAD"]).toLowerCase(); return isHex40(head) ? head : null; From 9cebb4f2c948fb5eb675945a967193a3cde88e42 Mon Sep 17 00:00:00 2001 From: RtlZeroMemory <58250858+RtlZeroMemory@users.noreply.github.com> Date: Fri, 27 Feb 2026 10:56:13 +0400 Subject: [PATCH 5/7] Fix CI color-style tests and native payload short-read audit --- .../__tests__/translation/colorMap.test.ts | 19 +- .../translation/propsToVNode.test.ts | 27 +- packages/node/src/worker/engineWorker.ts | 301 +++++++++--------- 3 files changed, 167 insertions(+), 180 deletions(-) diff --git a/packages/ink-compat/src/__tests__/translation/colorMap.test.ts b/packages/ink-compat/src/__tests__/translation/colorMap.test.ts index dccacde3..5bbe85dd 100644 --- a/packages/ink-compat/src/__tests__/translation/colorMap.test.ts +++ b/packages/ink-compat/src/__tests__/translation/colorMap.test.ts @@ -1,27 +1,28 @@ import assert from "node:assert/strict"; import test from "node:test"; +import { rgb } from "@rezi-ui/core"; import { parseColor } from "../../translation/colorMap.js"; test("parseColor maps named colors", () => { - assert.deepEqual(parseColor("green"), { r: 0, g: 205, b: 0 }); - assert.deepEqual(parseColor("whiteBright"), { r: 255, g: 255, b: 255 }); - assert.deepEqual(parseColor("GRAY"), { r: 127, g: 127, b: 127 }); + assert.equal(parseColor("green"), rgb(0, 205, 0)); + assert.equal(parseColor("whiteBright"), rgb(255, 255, 255)); + assert.equal(parseColor("GRAY"), rgb(127, 127, 127)); }); test("parseColor parses hex colors", () => { - assert.deepEqual(parseColor("#00ff7f"), { r: 0, g: 255, b: 127 }); - assert.deepEqual(parseColor("#abc"), { r: 170, g: 187, b: 204 }); + assert.equal(parseColor("#00ff7f"), rgb(0, 255, 127)); + assert.equal(parseColor("#abc"), rgb(170, 187, 204)); }); test("parseColor parses rgb() colors", () => { - assert.deepEqual(parseColor("rgb(12, 34, 56)"), { r: 12, g: 34, b: 56 }); - assert.deepEqual(parseColor("rgb( 0 , 255 , 127 )"), { r: 0, g: 255, b: 127 }); + assert.equal(parseColor("rgb(12, 34, 56)"), rgb(12, 34, 56)); + assert.equal(parseColor("rgb( 0 , 255 , 127 )"), rgb(0, 255, 127)); }); test("parseColor parses ansi256() colors", () => { - assert.deepEqual(parseColor("ansi256(196)"), { r: 255, g: 0, b: 0 }); - assert.deepEqual(parseColor("ansi256(244)"), { r: 128, g: 128, b: 128 }); + assert.equal(parseColor("ansi256(196)"), rgb(255, 0, 0)); + assert.equal(parseColor("ansi256(244)"), rgb(128, 128, 128)); assert.equal(parseColor("ansi256(999)"), undefined); }); diff --git a/packages/ink-compat/src/__tests__/translation/propsToVNode.test.ts b/packages/ink-compat/src/__tests__/translation/propsToVNode.test.ts index 711ead54..72888f2e 100644 --- a/packages/ink-compat/src/__tests__/translation/propsToVNode.test.ts +++ b/packages/ink-compat/src/__tests__/translation/propsToVNode.test.ts @@ -1,5 +1,6 @@ import assert from "node:assert/strict"; import test from "node:test"; +import { rgb } from "@rezi-ui/core"; import { type InkHostContainer, @@ -83,10 +84,10 @@ test("bordered box maps per-edge border styles", () => { const vnode = translateTree(containerWith(node)) as any; assert.equal(vnode.kind, "box"); - assert.deepEqual(vnode.props.borderStyleSides.top, { fg: { r: 205, g: 0, b: 0 }, dim: true }); - assert.deepEqual(vnode.props.borderStyleSides.right, { fg: { r: 0, g: 205, b: 0 } }); - assert.deepEqual(vnode.props.borderStyleSides.bottom, { fg: { r: 0, g: 0, b: 238 } }); - assert.deepEqual(vnode.props.borderStyleSides.left, { fg: { r: 205, g: 205, b: 0 }, dim: true }); + assert.deepEqual(vnode.props.borderStyleSides.top, { fg: rgb(205, 0, 0), dim: true }); + assert.deepEqual(vnode.props.borderStyleSides.right, { fg: rgb(0, 205, 0) }); + assert.deepEqual(vnode.props.borderStyleSides.bottom, { fg: rgb(0, 0, 238) }); + assert.deepEqual(vnode.props.borderStyleSides.left, { fg: rgb(205, 205, 0), dim: true }); }); test("bordered row box nests ui.row inside ui.box", () => { @@ -116,7 +117,7 @@ test("background-only box explicitly disables default borders", () => { assert.equal(vnode.kind, "box"); assert.equal(vnode.props.border, "none"); - assert.deepEqual(vnode.props.style, { bg: { r: 28, g: 28, b: 28 } }); + assert.deepEqual(vnode.props.style, { bg: rgb(28, 28, 28) }); }); test("background-only row box keeps row layout without implicit border", () => { @@ -138,7 +139,7 @@ test("styled text maps to text style", () => { assert.equal(vnode.kind, "text"); assert.equal(vnode.text, "Hello"); - assert.deepEqual(vnode.props.style, { fg: { r: 0, g: 205, b: 0 }, bold: true }); + assert.deepEqual(vnode.props.style, { fg: rgb(0, 205, 0), bold: true }); }); test("nested text produces richText spans", () => { @@ -187,7 +188,7 @@ test("ANSI SGR sequences map to richText styles", () => { assert.equal(vnode.kind, "richText"); assert.equal(vnode.props.spans.length, 3); assert.equal(vnode.props.spans[0]?.text, "Red"); - assert.deepEqual(vnode.props.spans[0]?.style?.fg, { r: 205, g: 0, b: 0 }); + assert.deepEqual(vnode.props.spans[0]?.style?.fg, rgb(205, 0, 0)); assert.equal(vnode.props.spans[1]?.text, " plain "); assert.equal("inverse" in (vnode.props.spans[1]?.style ?? {}), false); assert.equal(vnode.props.spans[2]?.text, "Inv"); @@ -203,11 +204,11 @@ test("ANSI reset restores parent style", () => { assert.equal(vnode.kind, "richText"); assert.equal(vnode.props.spans.length, 3); assert.equal(vnode.props.spans[0]?.text, "A"); - assert.deepEqual(vnode.props.spans[0]?.style?.fg, { r: 0, g: 205, b: 0 }); + assert.deepEqual(vnode.props.spans[0]?.style?.fg, rgb(0, 205, 0)); assert.equal(vnode.props.spans[1]?.text, "B"); - assert.deepEqual(vnode.props.spans[1]?.style?.fg, { r: 205, g: 0, b: 0 }); + assert.deepEqual(vnode.props.spans[1]?.style?.fg, rgb(205, 0, 0)); assert.equal(vnode.props.spans[2]?.text, "C"); - assert.deepEqual(vnode.props.spans[2]?.style?.fg, { r: 0, g: 205, b: 0 }); + assert.deepEqual(vnode.props.spans[2]?.style?.fg, rgb(0, 205, 0)); }); test("ANSI truecolor maps to RGB style", () => { @@ -219,7 +220,7 @@ test("ANSI truecolor maps to RGB style", () => { assert.equal(vnode.kind, "richText"); assert.equal(vnode.props.spans.length, 1); assert.equal(vnode.props.spans[0]?.text, "C"); - assert.deepEqual(vnode.props.spans[0]?.style?.fg, { r: 120, g: 80, b: 200 }); + assert.deepEqual(vnode.props.spans[0]?.style?.fg, rgb(120, 80, 200)); }); test("ANSI truecolor colon form maps to RGB style", () => { @@ -231,7 +232,7 @@ test("ANSI truecolor colon form maps to RGB style", () => { assert.equal(vnode.kind, "richText"); assert.equal(vnode.props.spans.length, 1); assert.equal(vnode.props.spans[0]?.text, "X"); - assert.deepEqual(vnode.props.spans[0]?.style?.fg, { r: 255, g: 120, b: 40 }); + assert.deepEqual(vnode.props.spans[0]?.style?.fg, rgb(255, 120, 40)); }); test("spacer virtual node maps to ui.spacer", () => { @@ -496,7 +497,7 @@ test("scroll overflow maps scroll props and scrollbar style", () => { assert.equal(vnode.props.overflow, "scroll"); assert.equal(vnode.props.scrollX, 2); assert.equal(vnode.props.scrollY, 5); - assert.deepEqual(vnode.props.scrollbarStyle, { fg: { r: 18, g: 52, b: 86 } }); + assert.deepEqual(vnode.props.scrollbarStyle, { fg: rgb(18, 52, 86) }); }); test("hidden overflow stays hidden without scroll axis", () => { diff --git a/packages/node/src/worker/engineWorker.ts b/packages/node/src/worker/engineWorker.ts index cec86dd1..5d58b40f 100644 --- a/packages/node/src/worker/engineWorker.ts +++ b/packages/node/src/worker/engineWorker.ts @@ -460,6 +460,54 @@ function drainNativeFrameAudit(reason: string): void { if (engineId === null) return; const headersCap = DEBUG_HEADER_BYTES * 64; const headersBuf = new Uint8Array(headersCap); + const tryReadDebugPayload = ( + recordId: bigint, + code: number, + expectedBytes: number, + opts: Readonly<{ allowPartialOnShortRead?: boolean }> = {}, + ): Uint8Array | null => { + if (engineId === null) return null; + const payload = new Uint8Array(expectedBytes); + let wrote = 0; + try { + wrote = native.engineDebugGetPayload(engineId, recordId, payload); + } catch (err) { + frameAudit.emit("native.debug.payload_error", { + reason, + recordId: recordId.toString(), + code, + expectedBytes, + detail: safeDetail(err), + }); + return null; + } + if (wrote <= 0) { + frameAudit.emit("native.debug.payload_error", { + reason, + recordId: recordId.toString(), + code, + expectedBytes, + wrote, + detail: "payload read returned non-positive length", + }); + return null; + } + if (wrote < expectedBytes) { + frameAudit.emit("native.debug.payload_error", { + reason, + recordId: recordId.toString(), + code, + expectedBytes, + wrote, + detail: "short payload read", + }); + if (opts.allowPartialOnShortRead !== true) { + return null; + } + } + return payload.subarray(0, Math.min(wrote, payload.byteLength)); + }; + for (let iter = 0; iter < 8; iter++) { let result: DebugQueryResultNative; try { @@ -516,32 +564,21 @@ function drainNativeFrameAudit(reason: string): void { if (code === ZR_DEBUG_CODE_DRAWLIST_CMD && payloadSize > 0) { const cap = Math.min(Math.max(payloadSize, 1), 4096); - const payload = new Uint8Array(cap); - let wrote = 0; - try { - wrote = native.engineDebugGetPayload(engineId, recordId, payload); - } catch (err) { - frameAudit.emit("native.debug.payload_error", { - reason, - recordId: recordId.toString(), - detail: safeDetail(err), - }); - continue; - } - if (wrote > 0) { - const view = payload.subarray(0, Math.min(wrote, payload.byteLength)); - const fp = drawlistFingerprint(view); - frameAudit.emit("native.drawlist.payload", { - reason, - recordId: recordId.toString(), - frameId: frameId.toString(), - payloadSize: view.byteLength, - hash32: fp.hash32, - prefixHash32: fp.prefixHash32, - head16: fp.head16, - tail16: fp.tail16, - }); - } + const view = tryReadDebugPayload(recordId, code, cap, { + allowPartialOnShortRead: true, + }); + if (view === null) continue; + const fp = drawlistFingerprint(view); + frameAudit.emit("native.drawlist.payload", { + reason, + recordId: recordId.toString(), + frameId: frameId.toString(), + payloadSize: view.byteLength, + hash32: fp.hash32, + prefixHash32: fp.prefixHash32, + head16: fp.head16, + tail16: fp.tail16, + }); continue; } @@ -549,38 +586,26 @@ function drainNativeFrameAudit(reason: string): void { (code === ZR_DEBUG_CODE_DRAWLIST_VALIDATE || code === ZR_DEBUG_CODE_DRAWLIST_EXECUTE) && payloadSize >= DEBUG_DRAWLIST_RECORD_BYTES ) { - const payload = new Uint8Array(DEBUG_DRAWLIST_RECORD_BYTES); - let wrote = 0; - try { - wrote = native.engineDebugGetPayload(engineId, recordId, payload); - } catch (err) { - frameAudit.emit("native.debug.payload_error", { - reason, - recordId: recordId.toString(), - detail: safeDetail(err), - }); - continue; - } - if (wrote >= DEBUG_DRAWLIST_RECORD_BYTES) { - const dvPayload = new DataView( - payload.buffer, - payload.byteOffset, - DEBUG_DRAWLIST_RECORD_BYTES, - ); - frameAudit.emit("native.drawlist.summary", { - reason, - recordId: recordId.toString(), - frameId: u64FromView(dvPayload, 0).toString(), - totalBytes: dvPayload.getUint32(8, true), - cmdCount: dvPayload.getUint32(12, true), - version: dvPayload.getUint32(16, true), - validationResult: dvPayload.getInt32(20, true), - executionResult: dvPayload.getInt32(24, true), - clipStackMaxDepth: dvPayload.getUint32(28, true), - textRuns: dvPayload.getUint32(32, true), - fillRects: dvPayload.getUint32(36, true), - }); - } + const payload = tryReadDebugPayload(recordId, code, DEBUG_DRAWLIST_RECORD_BYTES); + if (payload === null) continue; + const dvPayload = new DataView( + payload.buffer, + payload.byteOffset, + DEBUG_DRAWLIST_RECORD_BYTES, + ); + frameAudit.emit("native.drawlist.summary", { + reason, + recordId: recordId.toString(), + frameId: u64FromView(dvPayload, 0).toString(), + totalBytes: dvPayload.getUint32(8, true), + cmdCount: dvPayload.getUint32(12, true), + version: dvPayload.getUint32(16, true), + validationResult: dvPayload.getInt32(20, true), + executionResult: dvPayload.getInt32(24, true), + clipStackMaxDepth: dvPayload.getUint32(28, true), + textRuns: dvPayload.getUint32(32, true), + fillRects: dvPayload.getUint32(36, true), + }); continue; } @@ -591,114 +616,74 @@ function drainNativeFrameAudit(reason: string): void { code === ZR_DEBUG_CODE_FRAME_RESIZE) && payloadSize >= DEBUG_FRAME_RECORD_BYTES ) { - const payload = new Uint8Array(DEBUG_FRAME_RECORD_BYTES); - let wrote = 0; - try { - wrote = native.engineDebugGetPayload(engineId, recordId, payload); - } catch (err) { - frameAudit.emit("native.debug.payload_error", { - reason, - recordId: recordId.toString(), - detail: safeDetail(err), - }); - continue; - } - if (wrote >= DEBUG_FRAME_RECORD_BYTES) { - const dvPayload = new DataView( - payload.buffer, - payload.byteOffset, - DEBUG_FRAME_RECORD_BYTES, - ); - frameAudit.emit("native.frame.summary", { - reason, - recordId: recordId.toString(), - frameId: u64FromView(dvPayload, 0).toString(), - code, - codeName: nativeFrameCodeName(code), - cols: dvPayload.getUint32(8, true), - rows: dvPayload.getUint32(12, true), - drawlistBytes: dvPayload.getUint32(16, true), - drawlistCmds: dvPayload.getUint32(20, true), - diffBytesEmitted: dvPayload.getUint32(24, true), - dirtyLines: dvPayload.getUint32(28, true), - dirtyCells: dvPayload.getUint32(32, true), - damageRects: dvPayload.getUint32(36, true), - usDrawlist: dvPayload.getUint32(40, true), - usDiff: dvPayload.getUint32(44, true), - usWrite: dvPayload.getUint32(48, true), - }); - } + const payload = tryReadDebugPayload(recordId, code, DEBUG_FRAME_RECORD_BYTES); + if (payload === null) continue; + const dvPayload = new DataView( + payload.buffer, + payload.byteOffset, + DEBUG_FRAME_RECORD_BYTES, + ); + frameAudit.emit("native.frame.summary", { + reason, + recordId: recordId.toString(), + frameId: u64FromView(dvPayload, 0).toString(), + code, + codeName: nativeFrameCodeName(code), + cols: dvPayload.getUint32(8, true), + rows: dvPayload.getUint32(12, true), + drawlistBytes: dvPayload.getUint32(16, true), + drawlistCmds: dvPayload.getUint32(20, true), + diffBytesEmitted: dvPayload.getUint32(24, true), + dirtyLines: dvPayload.getUint32(28, true), + dirtyCells: dvPayload.getUint32(32, true), + damageRects: dvPayload.getUint32(36, true), + usDrawlist: dvPayload.getUint32(40, true), + usDiff: dvPayload.getUint32(44, true), + usWrite: dvPayload.getUint32(48, true), + }); continue; } if (code === ZR_DEBUG_CODE_PERF_TIMING && payloadSize >= DEBUG_PERF_RECORD_BYTES) { - const payload = new Uint8Array(DEBUG_PERF_RECORD_BYTES); - let wrote = 0; - try { - wrote = native.engineDebugGetPayload(engineId, recordId, payload); - } catch (err) { - frameAudit.emit("native.debug.payload_error", { - reason, - recordId: recordId.toString(), - detail: safeDetail(err), - }); - continue; - } - if (wrote >= DEBUG_PERF_RECORD_BYTES) { - const dvPayload = new DataView( - payload.buffer, - payload.byteOffset, - DEBUG_PERF_RECORD_BYTES, - ); - const phase = dvPayload.getUint32(8, true); - frameAudit.emit("native.perf.timing", { - reason, - recordId: recordId.toString(), - frameId: u64FromView(dvPayload, 0).toString(), - phase, - phaseName: nativePerfPhaseName(phase), - usElapsed: dvPayload.getUint32(12, true), - bytesProcessed: dvPayload.getUint32(16, true), - }); - } + const payload = tryReadDebugPayload(recordId, code, DEBUG_PERF_RECORD_BYTES); + if (payload === null) continue; + const dvPayload = new DataView(payload.buffer, payload.byteOffset, DEBUG_PERF_RECORD_BYTES); + const phase = dvPayload.getUint32(8, true); + frameAudit.emit("native.perf.timing", { + reason, + recordId: recordId.toString(), + frameId: u64FromView(dvPayload, 0).toString(), + phase, + phaseName: nativePerfPhaseName(phase), + usElapsed: dvPayload.getUint32(12, true), + bytesProcessed: dvPayload.getUint32(16, true), + }); continue; } if (code === ZR_DEBUG_CODE_PERF_DIFF_PATH && payloadSize >= DEBUG_DIFF_PATH_RECORD_BYTES) { - const payload = new Uint8Array(DEBUG_DIFF_PATH_RECORD_BYTES); - let wrote = 0; - try { - wrote = native.engineDebugGetPayload(engineId, recordId, payload); - } catch (err) { - frameAudit.emit("native.debug.payload_error", { - reason, - recordId: recordId.toString(), - detail: safeDetail(err), - }); - continue; - } - if (wrote >= DEBUG_DIFF_PATH_RECORD_BYTES) { - const dvPayload = new DataView( - payload.buffer, - payload.byteOffset, - DEBUG_DIFF_PATH_RECORD_BYTES, - ); - frameAudit.emit("native.perf.diffPath", { - reason, - recordId: recordId.toString(), - frameId: u64FromView(dvPayload, 0).toString(), - sweepFramesTotal: u64FromView(dvPayload, 8).toString(), - damageFramesTotal: u64FromView(dvPayload, 16).toString(), - scrollAttemptsTotal: u64FromView(dvPayload, 24).toString(), - scrollHitsTotal: u64FromView(dvPayload, 32).toString(), - collisionGuardHitsTotal: u64FromView(dvPayload, 40).toString(), - pathSweepUsed: dvPayload.getUint8(48), - pathDamageUsed: dvPayload.getUint8(49), - scrollOptAttempted: dvPayload.getUint8(50), - scrollOptHit: dvPayload.getUint8(51), - collisionGuardHitsLast: dvPayload.getUint32(52, true), - }); - } + const payload = tryReadDebugPayload(recordId, code, DEBUG_DIFF_PATH_RECORD_BYTES); + if (payload === null) continue; + const dvPayload = new DataView( + payload.buffer, + payload.byteOffset, + DEBUG_DIFF_PATH_RECORD_BYTES, + ); + frameAudit.emit("native.perf.diffPath", { + reason, + recordId: recordId.toString(), + frameId: u64FromView(dvPayload, 0).toString(), + sweepFramesTotal: u64FromView(dvPayload, 8).toString(), + damageFramesTotal: u64FromView(dvPayload, 16).toString(), + scrollAttemptsTotal: u64FromView(dvPayload, 24).toString(), + scrollHitsTotal: u64FromView(dvPayload, 32).toString(), + collisionGuardHitsTotal: u64FromView(dvPayload, 40).toString(), + pathSweepUsed: dvPayload.getUint8(48), + pathDamageUsed: dvPayload.getUint8(49), + scrollOptAttempted: dvPayload.getUint8(50), + scrollOptHit: dvPayload.getUint8(51), + collisionGuardHitsLast: dvPayload.getUint32(52, true), + }); } } if (!advanced) return; From 709ee5ad6858cfa08e3c439134b56c45f5437177 Mon Sep 17 00:00:00 2001 From: RtlZeroMemory <58250858+RtlZeroMemory@users.noreply.github.com> Date: Fri, 27 Feb 2026 22:39:31 +0400 Subject: [PATCH 6/7] refactor(core): reduce hot-path allocation churn --- .../core/src/layout/engine/layoutEngine.ts | 79 +- packages/core/src/layout/engine/pool.ts | 9 +- packages/core/src/layout/kinds/stack.ts | 781 +++++++++--------- .../renderToDrawlist/renderPackets.ts | 277 ++++++- .../renderer/renderToDrawlist/renderTree.ts | 10 +- 5 files changed, 735 insertions(+), 421 deletions(-) diff --git a/packages/core/src/layout/engine/layoutEngine.ts b/packages/core/src/layout/engine/layoutEngine.ts index a6cfbe6d..fd42b1ea 100644 --- a/packages/core/src/layout/engine/layoutEngine.ts +++ b/packages/core/src/layout/engine/layoutEngine.ts @@ -43,9 +43,15 @@ type MeasureCacheEntry = Readonly<{ }>; type MeasureCache = WeakMap; +type LayoutCacheLeaf = Map>; +type LayoutCacheByX = Map; +type LayoutCacheByForcedH = Map; +type LayoutCacheByForcedW = Map; +type LayoutCacheByMaxH = Map; +type LayoutAxisCache = Map; type LayoutCacheEntry = Readonly<{ - row: Map>; - column: Map>; + row: LayoutAxisCache; + column: LayoutAxisCache; }>; type LayoutCache = WeakMap; @@ -80,6 +86,7 @@ const measureCacheStack: MeasureCache[] = []; let activeLayoutCache: LayoutCache | null = null; const layoutCacheStack: LayoutCache[] = []; const syntheticThemedColumnCache = new WeakMap(); +const NULL_FORCED_DIMENSION = -1; function pushMeasureCache(cache: MeasureCache): void { measureCacheStack.push(cache); @@ -103,17 +110,70 @@ function popLayoutCache(): void { layoutCacheStack.length > 0 ? (layoutCacheStack[layoutCacheStack.length - 1] ?? null) : null; } -function layoutCacheKey( +function forcedDimensionKey(value: number | null): number { + return value === null ? NULL_FORCED_DIMENSION : value; +} + +function getLayoutCacheHit( + axisMap: LayoutAxisCache, maxW: number, maxH: number, forcedW: number | null, forcedH: number | null, x: number, y: number, -): string { - return `${String(maxW)}:${String(maxH)}:${forcedW === null ? "n" : String(forcedW)}:${ - forcedH === null ? "n" : String(forcedH) - }:${String(x)}:${String(y)}`; +): LayoutResult | null { + const byMaxH = axisMap.get(maxW); + if (!byMaxH) return null; + const byForcedW = byMaxH.get(maxH); + if (!byForcedW) return null; + const byForcedH = byForcedW.get(forcedDimensionKey(forcedW)); + if (!byForcedH) return null; + const byX = byForcedH.get(forcedDimensionKey(forcedH)); + if (!byX) return null; + const byY = byX.get(x); + if (!byY) return null; + return byY.get(y) ?? null; +} + +function setLayoutCacheValue( + axisMap: LayoutAxisCache, + maxW: number, + maxH: number, + forcedW: number | null, + forcedH: number | null, + x: number, + y: number, + value: LayoutResult, +): void { + let byMaxH = axisMap.get(maxW); + if (!byMaxH) { + byMaxH = new Map(); + axisMap.set(maxW, byMaxH); + } + let byForcedW = byMaxH.get(maxH); + if (!byForcedW) { + byForcedW = new Map(); + byMaxH.set(maxH, byForcedW); + } + const forcedWKey = forcedDimensionKey(forcedW); + let byForcedH = byForcedW.get(forcedWKey); + if (!byForcedH) { + byForcedH = new Map(); + byForcedW.set(forcedWKey, byForcedH); + } + const forcedHKey = forcedDimensionKey(forcedH); + let byX = byForcedH.get(forcedHKey); + if (!byX) { + byX = new Map(); + byForcedH.set(forcedHKey, byX); + } + let byY = byX.get(x); + if (!byY) { + byY = new Map(); + byX.set(x, byY); + } + byY.set(y, value); } function getSyntheticThemedColumn(vnode: ThemedVNode): VNode { @@ -378,14 +438,13 @@ function layoutNode( } const cache = activeLayoutCache; - const cacheKey = layoutCacheKey(maxW, maxH, forcedW, forcedH, x, y); const dirtySet = getActiveDirtySet(); let cacheHit: LayoutResult | null = null; if (cache) { const entry = cache.get(vnode); if (entry) { const axisMap = axis === "row" ? entry.row : entry.column; - cacheHit = axisMap.get(cacheKey) ?? null; + cacheHit = getLayoutCacheHit(axisMap, maxW, maxH, forcedW, forcedH, x, y); if (cacheHit && (dirtySet === null || !dirtySet.has(vnode))) { if (__layoutProfile.enabled) __layoutProfile.layoutCacheHits++; return cacheHit; @@ -697,7 +756,7 @@ function layoutNode( cache.set(vnode, entry); } const axisMap = axis === "row" ? entry.row : entry.column; - axisMap.set(cacheKey, computed); + setLayoutCacheValue(axisMap, maxW, maxH, forcedW, forcedH, x, y, computed); } return computed; diff --git a/packages/core/src/layout/engine/pool.ts b/packages/core/src/layout/engine/pool.ts index 280830f5..ecf5b237 100644 --- a/packages/core/src/layout/engine/pool.ts +++ b/packages/core/src/layout/engine/pool.ts @@ -4,7 +4,7 @@ /** Pool of reusable number arrays for layout computation. */ const arrayPool: number[][] = []; -const MAX_POOL_SIZE = 8; +const MAX_POOL_SIZE = 32; /** * Get or create a number array of the specified length, zeroed. @@ -15,7 +15,12 @@ export function acquireArray(length: number): number[] { for (let i = 0; i < arrayPool.length; i++) { const arr = arrayPool[i]; if (arr !== undefined && arr.length >= length) { - arrayPool.splice(i, 1); + const lastIndex = arrayPool.length - 1; + if (i !== lastIndex) { + const last = arrayPool[lastIndex]; + if (last !== undefined) arrayPool[i] = last; + } + arrayPool.length = lastIndex; // Zero the portion we'll use arr.fill(0, 0, length); return arr; diff --git a/packages/core/src/layout/kinds/stack.ts b/packages/core/src/layout/kinds/stack.ts index 08711801..16abad6a 100644 --- a/packages/core/src/layout/kinds/stack.ts +++ b/packages/core/src/layout/kinds/stack.ts @@ -24,7 +24,7 @@ import { getConstraintProps, } from "../engine/guards.js"; import { measureMaxContent, measureMinContent } from "../engine/intrinsic.js"; -import { releaseArray } from "../engine/pool.js"; +import { acquireArray, releaseArray } from "../engine/pool.js"; import { ok } from "../engine/result.js"; import type { LayoutTree } from "../engine/types.js"; import { resolveResponsiveValue } from "../responsive.js"; @@ -470,208 +470,218 @@ function computeWrapConstraintLine( const gapTotal = lineChildCount <= 1 ? 0 : gap * (lineChildCount - 1); const availableForChildren = clampNonNegative(mainLimit - gapTotal); - const mainSizes = new Array(lineChildCount).fill(0); - const measureMaxMain = new Array(lineChildCount).fill(0); - const crossSizes = includeChildren ? new Array(lineChildCount).fill(0) : null; + const mainSizes = acquireArray(lineChildCount); + const measureMaxMain = acquireArray(lineChildCount); + const crossSizes = includeChildren ? acquireArray(lineChildCount) : null; + const crossPass1 = acquireArray(lineChildCount); - const flexItems: FlexItem[] = []; - let remaining = availableForChildren; + try { + const flexItems: FlexItem[] = []; + let remaining = availableForChildren; - for (let i = 0; i < lineChildCount; i++) { - const child = lineChildren[i]; - if (!child || childHasAbsolutePosition(child)) continue; + for (let i = 0; i < lineChildCount; i++) { + const child = lineChildren[i]; + if (!child || childHasAbsolutePosition(child)) continue; + + if (child.kind === "spacer") { + const sp = validateSpacerProps(child.props); + if (!sp.ok) return sp; + + const maxMain = availableForChildren; + if (remaining === 0) { + mainSizes[i] = 0; + measureMaxMain[i] = 0; + continue; + } + + if (sp.value.flex > 0) { + flexItems.push({ + index: i, + flex: sp.value.flex, + shrink: 0, + basis: 0, + min: sp.value.size, + max: maxMain, + }); + continue; + } - if (child.kind === "spacer") { - const sp = validateSpacerProps(child.props); - if (!sp.ok) return sp; + const size = Math.min(sp.value.size, remaining); + mainSizes[i] = size; + measureMaxMain[i] = size; + remaining = clampNonNegative(remaining - size); + continue; + } + + const childProps = getConstraintProps(child) ?? {}; + const resolved = resolveLayoutConstraints(childProps as never, parentRect, axis.axis); + + const fixedMain = resolved[axis.mainProp]; + const minMain = resolved[axis.minMainProp]; + const maxMain = Math.min( + toFiniteMax(resolved[axis.maxMainProp], availableForChildren), + availableForChildren, + ); + const flex = resolved.flex; + + const rawMain = (childProps as ConstraintPropBag)[axis.mainProp]; + const mainIsPercent = isPercentString(rawMain); - const maxMain = availableForChildren; if (remaining === 0) { mainSizes[i] = 0; measureMaxMain[i] = 0; continue; } - if (sp.value.flex > 0) { + if (fixedMain !== null) { + const desired = clampWithin(fixedMain, minMain, maxMain); + const size = Math.min(desired, remaining); + mainSizes[i] = size; + measureMaxMain[i] = mainIsPercent ? mainLimit : size; + remaining = clampNonNegative(remaining - size); + continue; + } + + if (flex > 0) { flexItems.push({ index: i, - flex: sp.value.flex, + flex, shrink: 0, basis: 0, - min: sp.value.size, + min: minMain, max: maxMain, }); continue; } - const size = Math.min(sp.value.size, remaining); - mainSizes[i] = size; - measureMaxMain[i] = size; - remaining = clampNonNegative(remaining - size); - continue; + const childRes = measureNodeOnAxis(axis, child, remaining, crossLimit, measureNode); + if (!childRes.ok) return childRes; + const childMain = mainFromSize(axis, childRes.value); + mainSizes[i] = childMain; + measureMaxMain[i] = childMain; + remaining = clampNonNegative(remaining - childMain); } - const childProps = getConstraintProps(child) ?? {}; - const resolved = resolveLayoutConstraints(childProps as never, parentRect, axis.axis); + if (flexItems.length > 0 && remaining > 0) { + const alloc = distributeFlex(remaining, flexItems); + for (let j = 0; j < flexItems.length; j++) { + const it = flexItems[j]; + if (!it) continue; + const size = alloc[j] ?? 0; + mainSizes[it.index] = size; + const child = lineChildren[it.index]; + if (child?.kind === "spacer") { + measureMaxMain[it.index] = size; + continue; + } + const childProps = getConstraintProps(child as VNode) ?? {}; + const rawMain = (childProps as ConstraintPropBag)[axis.mainProp]; + measureMaxMain[it.index] = isPercentString(rawMain) ? mainLimit : size; + } + releaseArray(alloc); + } - const fixedMain = resolved[axis.mainProp]; - const minMain = resolved[axis.minMainProp]; - const maxMain = Math.min( - toFiniteMax(resolved[axis.maxMainProp], availableForChildren), + maybeRebalanceNearFullPercentChildren( + axis, + lineChildren, + mainSizes, + measureMaxMain, availableForChildren, + parentRect, ); - const flex = resolved.flex; - - const rawMain = (childProps as ConstraintPropBag)[axis.mainProp]; - const mainIsPercent = isPercentString(rawMain); - - if (remaining === 0) { - mainSizes[i] = 0; - measureMaxMain[i] = 0; - continue; - } - - if (fixedMain !== null) { - const desired = clampWithin(fixedMain, minMain, maxMain); - const size = Math.min(desired, remaining); - mainSizes[i] = size; - measureMaxMain[i] = mainIsPercent ? mainLimit : size; - remaining = clampNonNegative(remaining - size); - continue; - } - if (flex > 0) { - flexItems.push({ - index: i, - flex, - shrink: 0, - basis: 0, - min: minMain, - max: maxMain, - }); - continue; + let lineMain = 0; + for (let i = 0; i < lineChildCount; i++) { + lineMain += mainSizes[i] ?? 0; } + lineMain += lineChildCount <= 1 ? 0 : gap * (lineChildCount - 1); - const childRes = measureNodeOnAxis(axis, child, remaining, crossLimit, measureNode); - if (!childRes.ok) return childRes; - const childMain = mainFromSize(axis, childRes.value); - mainSizes[i] = childMain; - measureMaxMain[i] = childMain; - remaining = clampNonNegative(remaining - childMain); - } + let lineCross = 0; + const sizeCache = new Array(lineChildCount).fill(null); + const mayFeedback = new Array(lineChildCount).fill(false); + let feedbackCandidate = false; - if (flexItems.length > 0 && remaining > 0) { - const alloc = distributeFlex(remaining, flexItems); - for (let j = 0; j < flexItems.length; j++) { - const it = flexItems[j]; - if (!it) continue; - const size = alloc[j] ?? 0; - mainSizes[it.index] = size; - const child = lineChildren[it.index]; - if (child?.kind === "spacer") { - measureMaxMain[it.index] = size; - continue; - } - const childProps = getConstraintProps(child as VNode) ?? {}; + for (let i = 0; i < lineChildCount; i++) { + const child = lineChildren[i]; + if (!child || childHasAbsolutePosition(child)) continue; + const main = mainSizes[i] ?? 0; + const mm = measureMaxMain[i] ?? 0; + const childSizeRes = + main === 0 + ? measureNodeOnAxis(axis, child, 0, 0, measureNode) + : measureNodeOnAxis(axis, child, mm, crossLimit, measureNode); + if (!childSizeRes.ok) return childSizeRes; + const childCross = crossFromSize(axis, childSizeRes.value); + if (crossSizes) crossSizes[i] = childCross; + sizeCache[i] = childSizeRes.value; + const childProps = getConstraintProps(child) ?? {}; const rawMain = (childProps as ConstraintPropBag)[axis.mainProp]; - measureMaxMain[it.index] = isPercentString(rawMain) ? mainLimit : size; + const needsFeedback = + main > 0 && + mm !== main && + !isPercentString(rawMain) && + childMayNeedCrossAxisFeedback(child); + mayFeedback[i] = needsFeedback; + crossPass1[i] = childCross; + if (needsFeedback) feedbackCandidate = true; + if (childCross > lineCross) lineCross = childCross; } - releaseArray(alloc); - } - maybeRebalanceNearFullPercentChildren( - axis, - lineChildren, - mainSizes, - measureMaxMain, - availableForChildren, - parentRect, - ); + if (feedbackCandidate) { + lineCross = 0; + for (let i = 0; i < lineChildCount; i++) { + const child = lineChildren[i]; + if (!child || childHasAbsolutePosition(child)) continue; - let lineMain = 0; - for (let i = 0; i < mainSizes.length; i++) { - lineMain += mainSizes[i] ?? 0; - } - lineMain += lineChildCount <= 1 ? 0 : gap * (lineChildCount - 1); + const needsFeedback = mayFeedback[i] === true; + let size = sizeCache[i] ?? null; + if (needsFeedback) { + const main = mainSizes[i] ?? 0; + const nextSizeRes = + main === 0 + ? measureNodeOnAxis(axis, child, 0, 0, measureNode) + : measureNodeOnAxis(axis, child, main, crossLimit, measureNode); + if (!nextSizeRes.ok) return nextSizeRes; + const nextCross = crossFromSize(axis, nextSizeRes.value); + if (nextCross !== (crossPass1[i] ?? 0)) { + size = nextSizeRes.value; + sizeCache[i] = size; + if (crossSizes) crossSizes[i] = nextCross; + crossPass1[i] = nextCross; + } + } - let lineCross = 0; - const sizeCache = new Array(lineChildCount).fill(null); - const mayFeedback = new Array(lineChildCount).fill(false); - const crossPass1 = new Array(lineChildCount).fill(0); - let feedbackCandidate = false; + const cross = + crossSizes?.[i] ?? crossPass1[i] ?? (size === null ? 0 : crossFromSize(axis, size)); + if (cross > lineCross) lineCross = cross; + } + } - for (let i = 0; i < lineChildCount; i++) { - const child = lineChildren[i]; - if (!child || childHasAbsolutePosition(child)) continue; - const main = mainSizes[i] ?? 0; - const mm = measureMaxMain[i] ?? 0; - const childSizeRes = - main === 0 - ? measureNodeOnAxis(axis, child, 0, 0, measureNode) - : measureNodeOnAxis(axis, child, mm, crossLimit, measureNode); - if (!childSizeRes.ok) return childSizeRes; - const childCross = crossFromSize(axis, childSizeRes.value); - if (crossSizes) crossSizes[i] = childCross; - sizeCache[i] = childSizeRes.value; - const childProps = getConstraintProps(child) ?? {}; - const rawMain = (childProps as ConstraintPropBag)[axis.mainProp]; - const needsFeedback = - main > 0 && mm !== main && !isPercentString(rawMain) && childMayNeedCrossAxisFeedback(child); - mayFeedback[i] = needsFeedback; - crossPass1[i] = childCross; - if (needsFeedback) feedbackCandidate = true; - if (childCross > lineCross) lineCross = childCross; - } + if (!includeChildren) return ok({ main: lineMain, cross: lineCross }); - if (feedbackCandidate) { - lineCross = 0; + const plannedChildren: WrapLineChildLayout[] = []; for (let i = 0; i < lineChildCount; i++) { const child = lineChildren[i]; if (!child || childHasAbsolutePosition(child)) continue; - - const needsFeedback = mayFeedback[i] === true; - let size = sizeCache[i] ?? null; - if (needsFeedback) { - const main = mainSizes[i] ?? 0; - const nextSizeRes = - main === 0 - ? measureNodeOnAxis(axis, child, 0, 0, measureNode) - : measureNodeOnAxis(axis, child, main, crossLimit, measureNode); - if (!nextSizeRes.ok) return nextSizeRes; - const nextCross = crossFromSize(axis, nextSizeRes.value); - if (nextCross !== (crossPass1[i] ?? 0)) { - size = nextSizeRes.value; - sizeCache[i] = size; - if (crossSizes) crossSizes[i] = nextCross; - crossPass1[i] = nextCross; - } - } - - const cross = - crossSizes?.[i] ?? crossPass1[i] ?? (size === null ? 0 : crossFromSize(axis, size)); - if (cross > lineCross) lineCross = cross; + plannedChildren.push({ + child, + main: mainSizes[i] ?? 0, + measureMaxMain: measureMaxMain[i] ?? 0, + cross: crossSizes?.[i] ?? 0, + }); } - } - if (!includeChildren) return ok({ main: lineMain, cross: lineCross }); - - const plannedChildren: WrapLineChildLayout[] = []; - for (let i = 0; i < lineChildCount; i++) { - const child = lineChildren[i]; - if (!child || childHasAbsolutePosition(child)) continue; - plannedChildren.push({ - child, - main: mainSizes[i] ?? 0, - measureMaxMain: measureMaxMain[i] ?? 0, - cross: crossSizes?.[i] ?? 0, + return ok({ + children: Object.freeze(plannedChildren), + main: lineMain, + cross: lineCross, }); + } finally { + releaseArray(mainSizes); + releaseArray(measureMaxMain); + if (crossSizes) releaseArray(crossSizes); + releaseArray(crossPass1); } - - return ok({ - children: Object.freeze(plannedChildren), - main: lineMain, - cross: lineCross, - }); } function measureWrapConstraintLine( @@ -889,183 +899,191 @@ function planConstraintMainSizes( } // Advanced path: supports flexShrink/flexBasis while keeping legacy defaults. - const minMains = new Array(children.length).fill(0); - const maxMains = new Array(children.length).fill(availableForChildren); - const shrinkFactors = new Array(children.length).fill(0); + const minMains = acquireArray(children.length); + const maxMains = acquireArray(children.length); + maxMains.fill(availableForChildren, 0, children.length); + const shrinkFactors = acquireArray(children.length); - const growItems: FlexItem[] = []; - let totalMain = 0; + try { + const growItems: FlexItem[] = []; + let totalMain = 0; - for (let i = 0; i < children.length; i++) { - const child = children[i]; - if (!child || childHasAbsolutePosition(child)) continue; + for (let i = 0; i < children.length; i++) { + const child = children[i]; + if (!child || childHasAbsolutePosition(child)) continue; + + if (child.kind === "spacer") { + const sp = validateSpacerProps(child.props); + if (!sp.ok) return sp; + + const basis = sp.value.flex > 0 ? 0 : sp.value.size; + mainSizes[i] = basis; + measureMaxMain[i] = basis; + minMains[i] = 0; + maxMains[i] = availableForChildren; + shrinkFactors[i] = 0; + totalMain += basis; + + if (sp.value.flex > 0) { + growItems.push({ + index: i, + flex: sp.value.flex, + shrink: 0, + basis: 0, + min: sp.value.size, + max: availableForChildren, + }); + } + continue; + } - if (child.kind === "spacer") { - const sp = validateSpacerProps(child.props); - if (!sp.ok) return sp; + const childProps = (getConstraintProps(child) ?? {}) as Record & FlexPropBag; + const resolved = resolveLayoutConstraints(childProps as never, parentRect, axis.axis); + + const fixedMain = resolved[axis.mainProp]; + const maxMain = Math.min( + toFiniteMax(resolved[axis.maxMainProp], availableForChildren), + availableForChildren, + ); + let minMain = Math.min(resolved[axis.minMainProp], availableForChildren); + + const rawMain = childProps[axis.mainProp]; + const rawMinMain = childProps[axis.minMainProp]; + const rawFlexBasis = childProps.flexBasis; + const mainPercent = isPercentString(rawMain); + const flexBasisIsAuto = resolveResponsiveValue(rawFlexBasis) === "auto"; + + if (rawMinMain === undefined && resolved.flexShrink > 0) { + const intrinsicMinRes = measureMinContent(child, axis.axis, measureNode); + if (!intrinsicMinRes.ok) return intrinsicMinRes; + const intrinsicMain = mainFromSize(axis, intrinsicMinRes.value); + minMain = Math.max(minMain, Math.min(intrinsicMain, availableForChildren)); + } + + const normalizedMinMain = Math.min(minMain, maxMain); + minMains[i] = normalizedMinMain; + maxMains[i] = maxMain; + shrinkFactors[i] = resolved.flexShrink; + + let measuredSize: Size | null = null; + let basis: number; + if (fixedMain !== null) { + basis = clampWithin(fixedMain, normalizedMinMain, maxMain); + } else if (resolved.flexBasis !== null) { + basis = clampWithin(resolved.flexBasis, normalizedMinMain, maxMain); + } else if (flexBasisIsAuto) { + const intrinsicMaxRes = measureMaxContent(child, axis.axis, measureNode); + if (!intrinsicMaxRes.ok) return intrinsicMaxRes; + const intrinsicMain = mainFromSize(axis, intrinsicMaxRes.value); + basis = clampWithin(intrinsicMain, normalizedMinMain, maxMain); + } else if (resolved.flex > 0) { + basis = 0; + } else { + const childRes = measureNodeOnAxis( + axis, + child, + availableForChildren, + crossLimit, + measureNode, + ); + if (!childRes.ok) return childRes; + measuredSize = childRes.value; + basis = clampWithin(mainFromSize(axis, childRes.value), normalizedMinMain, maxMain); + } - const basis = sp.value.flex > 0 ? 0 : sp.value.size; mainSizes[i] = basis; - measureMaxMain[i] = basis; - minMains[i] = 0; - maxMains[i] = availableForChildren; - shrinkFactors[i] = 0; + measureMaxMain[i] = mainPercent ? mainLimit : basis; + if (collectPrecomputed && measuredSize !== null) precomputedSizes[i] = measuredSize; totalMain += basis; - if (sp.value.flex > 0) { + if (fixedMain === null && resolved.flex > 0) { + const growMin = Math.max(0, normalizedMinMain - basis); + const growCap = Math.max(0, maxMain - basis); growItems.push({ index: i, - flex: sp.value.flex, + flex: resolved.flex, shrink: 0, basis: 0, - min: sp.value.size, - max: availableForChildren, + min: growMin, + max: growCap, }); } - continue; } - const childProps = (getConstraintProps(child) ?? {}) as Record & FlexPropBag; - const resolved = resolveLayoutConstraints(childProps as never, parentRect, axis.axis); - - const fixedMain = resolved[axis.mainProp]; - const maxMain = Math.min( - toFiniteMax(resolved[axis.maxMainProp], availableForChildren), - availableForChildren, - ); - let minMain = Math.min(resolved[axis.minMainProp], availableForChildren); - - const rawMain = childProps[axis.mainProp]; - const rawMinMain = childProps[axis.minMainProp]; - const rawFlexBasis = childProps.flexBasis; - const mainPercent = isPercentString(rawMain); - const flexBasisIsAuto = resolveResponsiveValue(rawFlexBasis) === "auto"; - - if (rawMinMain === undefined && resolved.flexShrink > 0) { - const intrinsicMinRes = measureMinContent(child, axis.axis, measureNode); - if (!intrinsicMinRes.ok) return intrinsicMinRes; - const intrinsicMain = mainFromSize(axis, intrinsicMinRes.value); - minMain = Math.max(minMain, Math.min(intrinsicMain, availableForChildren)); - } - - const normalizedMinMain = Math.min(minMain, maxMain); - minMains[i] = normalizedMinMain; - maxMains[i] = maxMain; - shrinkFactors[i] = resolved.flexShrink; - - let measuredSize: Size | null = null; - let basis: number; - if (fixedMain !== null) { - basis = clampWithin(fixedMain, normalizedMinMain, maxMain); - } else if (resolved.flexBasis !== null) { - basis = clampWithin(resolved.flexBasis, normalizedMinMain, maxMain); - } else if (flexBasisIsAuto) { - const intrinsicMaxRes = measureMaxContent(child, axis.axis, measureNode); - if (!intrinsicMaxRes.ok) return intrinsicMaxRes; - const intrinsicMain = mainFromSize(axis, intrinsicMaxRes.value); - basis = clampWithin(intrinsicMain, normalizedMinMain, maxMain); - } else if (resolved.flex > 0) { - basis = 0; - } else { - const childRes = measureNodeOnAxis( - axis, - child, - availableForChildren, - crossLimit, - measureNode, - ); - if (!childRes.ok) return childRes; - measuredSize = childRes.value; - basis = clampWithin(mainFromSize(axis, childRes.value), normalizedMinMain, maxMain); + let didResize = false; + const growRemaining = availableForChildren - totalMain; + if (growItems.length > 0 && growRemaining > 0) { + const alloc = distributeFlex(growRemaining, growItems); + for (let i = 0; i < growItems.length; i++) { + const item = growItems[i]; + if (!item) continue; + const add = alloc[i] ?? 0; + if (add <= 0) continue; + const current = mainSizes[item.index] ?? 0; + const next = Math.min(maxMains[item.index] ?? availableForChildren, current + add); + if (next !== current) didResize = true; + mainSizes[item.index] = next; + } + releaseArray(alloc); } - mainSizes[i] = basis; - measureMaxMain[i] = mainPercent ? mainLimit : basis; - if (collectPrecomputed && measuredSize !== null) precomputedSizes[i] = measuredSize; - totalMain += basis; - - if (fixedMain === null && resolved.flex > 0) { - const growMin = Math.max(0, normalizedMinMain - basis); - const growCap = Math.max(0, maxMain - basis); - growItems.push({ - index: i, - flex: resolved.flex, - shrink: 0, - basis: 0, - min: growMin, - max: growCap, - }); + totalMain = 0; + for (let i = 0; i < mainSizes.length; i++) { + totalMain += mainSizes[i] ?? 0; } - } - let didResize = false; - const growRemaining = availableForChildren - totalMain; - if (growItems.length > 0 && growRemaining > 0) { - const alloc = distributeFlex(growRemaining, growItems); - for (let i = 0; i < growItems.length; i++) { - const item = growItems[i]; - if (!item) continue; - const add = alloc[i] ?? 0; - if (add <= 0) continue; - const current = mainSizes[item.index] ?? 0; - const next = Math.min(maxMains[item.index] ?? availableForChildren, current + add); - if (next !== current) didResize = true; - mainSizes[item.index] = next; + if (totalMain > availableForChildren) { + const shrinkItems: FlexItem[] = []; + for (let i = 0; i < children.length; i++) { + const child = children[i]; + if (!child || childHasAbsolutePosition(child)) continue; + shrinkItems.push({ + index: i, + flex: 0, + shrink: shrinkFactors[i] ?? 0, + basis: mainSizes[i] ?? 0, + min: minMains[i] ?? 0, + max: maxMains[i] ?? availableForChildren, + }); + } + if (shrinkItems.length > 0) { + const shrunk = shrinkFlex(availableForChildren, shrinkItems); + for (let i = 0; i < shrinkItems.length; i++) { + const item = shrinkItems[i]; + if (!item) continue; + const current = mainSizes[item.index] ?? 0; + const next = clampWithin(shrunk[i] ?? 0, item.min, item.max); + if (next !== current) didResize = true; + mainSizes[item.index] = next; + } + releaseArray(shrunk); + } } - releaseArray(alloc); - } - - totalMain = 0; - for (let i = 0; i < mainSizes.length; i++) { - totalMain += mainSizes[i] ?? 0; - } - if (totalMain > availableForChildren) { - const shrinkItems: FlexItem[] = []; for (let i = 0; i < children.length; i++) { - const child = children[i]; - if (!child || childHasAbsolutePosition(child)) continue; - shrinkItems.push({ - index: i, - flex: 0, - shrink: shrinkFactors[i] ?? 0, - basis: mainSizes[i] ?? 0, - min: minMains[i] ?? 0, - max: maxMains[i] ?? availableForChildren, - }); + if (!children[i]) continue; + if (didResize && collectPrecomputed) precomputedSizes[i] = null; } - if (shrinkItems.length > 0) { - const shrunk = shrinkFlex(availableForChildren, shrinkItems); - for (let i = 0; i < shrinkItems.length; i++) { - const item = shrinkItems[i]; - if (!item) continue; - const current = mainSizes[item.index] ?? 0; - const next = clampWithin(shrunk[i] ?? 0, item.min, item.max); - if (next !== current) didResize = true; - mainSizes[item.index] = next; - } - } - } - for (let i = 0; i < children.length; i++) { - if (!children[i]) continue; - if (didResize && collectPrecomputed) precomputedSizes[i] = null; - } - - maybeRebalanceNearFullPercentChildren( - axis, - children, - mainSizes, - measureMaxMain, - availableForChildren, - parentRect, - ); + maybeRebalanceNearFullPercentChildren( + axis, + children, + mainSizes, + measureMaxMain, + availableForChildren, + parentRect, + ); - return ok({ - mainSizes, - measureMaxMain, - precomputedSizes, - }); + return ok({ + mainSizes, + measureMaxMain, + precomputedSizes, + }); + } finally { + releaseArray(minMains); + releaseArray(maxMains); + releaseArray(shrinkFactors); + } } function planConstraintCrossSizes( @@ -1683,82 +1701,87 @@ function layoutStack( } } } else if (!needsConstraintPass) { - const mainSizes = new Array(count).fill(0); - const crossSizes = new Array(count).fill(0); + const mainSizes = acquireArray(count); + const crossSizes = acquireArray(count); - let rem = mainLimit; - for (let i = 0; i < count; i++) { - const child = vnode.children[i]; - if (!child || childHasAbsolutePosition(child)) continue; - if (rem === 0) continue; + try { + let rem = mainLimit; + for (let i = 0; i < count; i++) { + const child = vnode.children[i]; + if (!child || childHasAbsolutePosition(child)) continue; + if (rem === 0) continue; - const childSizeRes = measureNodeOnAxis(axis, child, rem, crossLimit, measureNode); - if (!childSizeRes.ok) return childSizeRes; - mainSizes[i] = mainFromSize(axis, childSizeRes.value); - crossSizes[i] = crossFromSize(axis, childSizeRes.value); - rem = clampNonNegative(rem - (mainSizes[i] ?? 0) - gap); - } + const childSizeRes = measureNodeOnAxis(axis, child, rem, crossLimit, measureNode); + if (!childSizeRes.ok) return childSizeRes; + mainSizes[i] = mainFromSize(axis, childSizeRes.value); + crossSizes[i] = crossFromSize(axis, childSizeRes.value); + rem = clampNonNegative(rem - (mainSizes[i] ?? 0) - gap); + } - let usedMain = 0; - for (let i = 0; i < mainSizes.length; i++) { - usedMain += mainSizes[i] ?? 0; - } - usedMain += childCount <= 1 ? 0 : gap * (childCount - 1); - const extra = clampNonNegative(mainLimit - usedMain); - const startOffset = computeJustifyStartOffset(justify, extra, childCount); + let usedMain = 0; + for (let i = 0; i < count; i++) { + usedMain += mainSizes[i] ?? 0; + } + usedMain += childCount <= 1 ? 0 : gap * (childCount - 1); + const extra = clampNonNegative(mainLimit - usedMain); + const startOffset = computeJustifyStartOffset(justify, extra, childCount); - let cursorMain = mainOrigin + startOffset; - let remainingMain = clampNonNegative(mainLimit - startOffset); - let childOrdinal = 0; + let cursorMain = mainOrigin + startOffset; + let remainingMain = clampNonNegative(mainLimit - startOffset); + let childOrdinal = 0; - for (let i = 0; i < count; i++) { - const child = vnode.children[i]; - if (!child || childHasAbsolutePosition(child)) continue; + for (let i = 0; i < count; i++) { + const child = vnode.children[i]; + if (!child || childHasAbsolutePosition(child)) continue; - if (remainingMain === 0) { - const childRes = layoutNodeOnAxis(axis, child, cursorMain, crossOrigin, 0, 0, layoutNode); + if (remainingMain === 0) { + const childRes = layoutNodeOnAxis(axis, child, cursorMain, crossOrigin, 0, 0, layoutNode); + if (!childRes.ok) return childRes; + children.push(childRes.value); + childOrdinal++; + continue; + } + + const childMain = mainSizes[i] ?? 0; + const childCross = crossSizes[i] ?? 0; + + let childCrossPos = crossOrigin; + let forceCross: number | null = null; + const effectiveAlign = resolveEffectiveAlign(child, align); + if (effectiveAlign === "center") { + childCrossPos = crossOrigin + Math.floor((crossLimit - childCross) / 2); + } else if (effectiveAlign === "end") { + childCrossPos = crossOrigin + (crossLimit - childCross); + } else if (effectiveAlign === "stretch") { + forceCross = crossLimit; + } + + const childRes = layoutNodeOnAxis( + axis, + child, + cursorMain, + childCrossPos, + remainingMain, + crossLimit, + layoutNode, + null, + forceCross, + ); if (!childRes.ok) return childRes; children.push(childRes.value); - childOrdinal++; - continue; - } - const childMain = mainSizes[i] ?? 0; - const childCross = crossSizes[i] ?? 0; - - let childCrossPos = crossOrigin; - let forceCross: number | null = null; - const effectiveAlign = resolveEffectiveAlign(child, align); - if (effectiveAlign === "center") { - childCrossPos = crossOrigin + Math.floor((crossLimit - childCross) / 2); - } else if (effectiveAlign === "end") { - childCrossPos = crossOrigin + (crossLimit - childCross); - } else if (effectiveAlign === "stretch") { - forceCross = crossLimit; + const hasNextChild = childOrdinal < childCount - 1; + const extraGap = hasNextChild + ? computeJustifyExtraGap(justify, extra, childCount, childOrdinal) + : 0; + const step = childMain + (hasNextChild ? gap + extraGap : 0); + cursorMain = cursorMain + step; + remainingMain = clampNonNegative(remainingMain - step); + childOrdinal++; } - - const childRes = layoutNodeOnAxis( - axis, - child, - cursorMain, - childCrossPos, - remainingMain, - crossLimit, - layoutNode, - null, - forceCross, - ); - if (!childRes.ok) return childRes; - children.push(childRes.value); - - const hasNextChild = childOrdinal < childCount - 1; - const extraGap = hasNextChild - ? computeJustifyExtraGap(justify, extra, childCount, childOrdinal) - : 0; - const step = childMain + (hasNextChild ? gap + extraGap : 0); - cursorMain = cursorMain + step; - remainingMain = clampNonNegative(remainingMain - step); - childOrdinal++; + } finally { + releaseArray(mainSizes); + releaseArray(crossSizes); } } else { const parentRect: Rect = { x: 0, y: 0, w: cw, h: ch }; diff --git a/packages/core/src/renderer/renderToDrawlist/renderPackets.ts b/packages/core/src/renderer/renderToDrawlist/renderPackets.ts index 8d74ba94..a08cf754 100644 --- a/packages/core/src/renderer/renderToDrawlist/renderPackets.ts +++ b/packages/core/src/renderer/renderToDrawlist/renderPackets.ts @@ -19,6 +19,21 @@ const refIds = new WeakMap(); let nextRefId = 1; const themeHashCache = new WeakMap(); const styleHashCache = new WeakMap(); +const textValueHashCache = new Map(); +const TEXT_VALUE_HASH_CACHE_MAX = 8_192; +const TEXT_VALUE_HASH_CACHE_MIN_LEN = 24; +type TextPacketKeyMemo = Readonly<{ + kind: string; + text: string; + props: Readonly>; + theme: Theme; + parentStyle: ResolvedTextStyle; + rectWidth: number; + rectHeight: number; + focusBits: number; + key: number; +}>; +const textPacketKeyMemo = new WeakMap(); function hashString(value: string): number { let hash = FNV_OFFSET; @@ -29,6 +44,21 @@ function hashString(value: string): number { return hash >>> 0; } +function hashTextValue(value: string): number { + if (value.length < TEXT_VALUE_HASH_CACHE_MIN_LEN) { + return hashString(value); + } + const cached = textValueHashCache.get(value); + if (cached !== undefined) return cached; + const hashed = hashString(value); + if (textValueHashCache.size >= TEXT_VALUE_HASH_CACHE_MAX) { + // Bound memory under high-cardinality dynamic text workloads. + textValueHashCache.clear(); + } + textValueHashCache.set(value, hashed); + return hashed; +} + function mixHash(hash: number, value: number): number { const mixed = Math.imul((hash ^ (value >>> 0)) >>> 0, FNV_PRIME); return mixed >>> 0; @@ -54,6 +84,46 @@ function hashUnknown(value: unknown): number { return 0; } +/** + * Hash a value by content for primitives, by identity for objects/functions. + * Returns null if the value contains unhashable content (signals uncacheable). + */ +function hashPropValue(hash: number, value: unknown): number { + if (value === null || value === undefined) return mixHash(hash, 0); + if (typeof value === "boolean") return mixHash(hash, value ? 1 : 2); + if (typeof value === "number") return mixHash(hash, (Math.trunc(value) & HASH_MASK_32) >>> 0); + if (typeof value === "string") return mixHash(hash, hashString(value)); + // Functions (callbacks) don't affect visual output — skip with stable sentinel. + if (typeof value === "function") return mixHash(hash, 0xcafe_0001); + // Arrays: hash element count + each element. + if (Array.isArray(value)) { + let out = mixHash(hash, value.length); + for (let i = 0; i < value.length; i++) { + out = hashPropValue(out, value[i]); + } + return out; + } + // Plain objects: hash by identity (preserving correctness for complex nested objects). + return mixHash(hash, hashUnknown(value)); +} + +/** + * Hash all own enumerable props of an object by content (primitives) or identity (objects). + * Callbacks (functions) receive a stable sentinel so re-created closures don't bust the cache. + */ +function hashPropsShallow(hash: number, props: Readonly>): number { + let out = hash; + const keys = Object.keys(props); + out = mixHash(out, keys.length); + for (let i = 0; i < keys.length; i++) { + const key = keys[i]; + if (key === undefined) continue; + out = mixHash(out, hashString(key)); + out = hashPropValue(out, props[key]); + } + return out; +} + function hashBoolFlag(value: boolean | undefined): number { if (value === true) return 1; if (value === false) return 2; @@ -174,6 +244,68 @@ function isTickDrivenKind(kind: RuntimeInstance["vnode"]["kind"]): boolean { return kind === "spinner"; } +/** + * Fast-path prop hashing for text/richText nodes. + * Only hashes the visual-relevant props (style, maxWidth, wrap, variant, dim, + * textOverflow) to avoid the cost of iterating all keys via hashPropsShallow. + * The text content itself is already hashed separately. + */ +function hashTextProps(hash: number, props: Readonly>): number { + const textProps = props as Readonly<{ + style?: unknown; + maxWidth?: unknown; + wrap?: unknown; + variant?: unknown; + dim?: unknown; + textOverflow?: unknown; + }>; + const style = textProps.style; + const maxWidth = textProps.maxWidth; + const wrap = textProps.wrap; + const variant = textProps.variant; + const dim = textProps.dim; + const textOverflow = textProps.textOverflow; + + // Common case for plain text nodes with no explicit props. + if ( + style === undefined && + maxWidth === undefined && + wrap === undefined && + variant === undefined && + dim === undefined && + textOverflow === undefined + ) { + return mixHash(hash, 0x7458_7430); + } + + let out = hash; + if (style !== undefined) { + out = mixHash(out, 1); + out = mixHash(out, hashUnknown(style)); + } + if (maxWidth !== undefined) { + out = mixHash(out, 2); + out = hashPropValue(out, maxWidth); + } + if (wrap !== undefined) { + out = mixHash(out, 3); + out = hashPropValue(out, wrap); + } + if (variant !== undefined) { + out = mixHash(out, 4); + out = hashPropValue(out, variant); + } + if (dim !== undefined) { + out = mixHash(out, 5); + out = hashPropValue(out, dim); + } + if (textOverflow !== undefined) { + out = mixHash(out, 6); + out = hashPropValue(out, textOverflow); + } + return out; +} + export function computeRenderPacketKey( node: RuntimeInstance, theme: Theme, @@ -187,24 +319,66 @@ export function computeRenderPacketKey( ): number { if (!isRenderPacketCacheable(node, cursorInfo)) return 0; + const focusBits = focusPressedBits(node, focusState, pressedId); + const kind = node.vnode.kind; + const vnodeText = (node.vnode as { text?: string }).text; + + if ((kind === "text" || kind === "richText") && vnodeText !== undefined) { + const props = node.vnode.props as Readonly>; + const memo = textPacketKeyMemo.get(node); + if ( + memo !== undefined && + memo.kind === kind && + memo.text === vnodeText && + memo.props === props && + memo.theme === theme && + memo.parentStyle === parentStyle && + memo.rectWidth === rectWidth && + memo.rectHeight === rectHeight && + memo.focusBits === focusBits + ) { + return memo.key; + } + } + let hash = FNV_OFFSET; - hash = mixHash(hash, hashString(node.vnode.kind)); - hash = mixHash(hash, hashUnknown(node.vnode)); - hash = mixHash(hash, hashUnknown(node.vnode.props)); + hash = mixHash(hash, hashString(kind)); + if (vnodeText !== undefined) { + hash = mixHash(hash, hashTextValue(vnodeText)); + } + if (kind === "text" || kind === "richText") { + hash = hashTextProps(hash, node.vnode.props as Readonly>); + } else { + hash = hashPropsShallow(hash, node.vnode.props as Readonly>); + } hash = mixHash(hash, hashTheme(theme)); hash = mixHash(hash, hashResolvedStyle(parentStyle)); hash = mixHash(hash, (Math.trunc(rectWidth) & HASH_MASK_32) >>> 0); hash = mixHash(hash, (Math.trunc(rectHeight) & HASH_MASK_32) >>> 0); - hash = mixHash(hash, focusPressedBits(node, focusState, pressedId)); + hash = mixHash(hash, focusBits); if (isTickDrivenKind(node.vnode.kind)) { hash = mixHash(hash, tick >>> 0); } - return hash === 0 ? 1 : hash >>> 0; + const out = hash === 0 ? 1 : hash >>> 0; + if ((kind === "text" || kind === "richText") && vnodeText !== undefined) { + textPacketKeyMemo.set(node, { + kind, + text: vnodeText, + props: node.vnode.props as Readonly>, + theme, + parentStyle, + rectWidth, + rectHeight, + focusBits, + key: out, + }); + } + return out; } export class RenderPacketRecorder implements DrawlistBuilder { - private readonly ops: RenderPacketOp[] = []; - private readonly resources: Uint8Array[] = []; + private ops: RenderPacketOp[] = []; + private resources: Uint8Array[] = []; private readonly blobResourceById = new Map(); private readonly textRunByBlobId = new Map(); private valid = true; @@ -217,9 +391,15 @@ export class RenderPacketRecorder implements DrawlistBuilder { buildPacket(): RenderPacket | null { if (!this.valid) return null; + const ops = this.ops; + const resources = this.resources; + this.ops = []; + this.resources = []; + this.blobResourceById.clear(); + this.textRunByBlobId.clear(); return Object.freeze({ - ops: Object.freeze(this.ops.slice()), - resources: Object.freeze(this.resources.slice()), + ops: Object.freeze(ops), + resources: Object.freeze(resources), }); } @@ -253,8 +433,11 @@ export class RenderPacketRecorder implements DrawlistBuilder { style?: Parameters[4], ): void { this.target.fillRect(x, y, w, h, style); - const local = { op: "FILL_RECT", x: this.localX(x), y: this.localY(y), w, h } as const; - this.ops.push(style === undefined ? local : { ...local, style }); + if (style === undefined) { + this.ops.push({ op: "FILL_RECT", x: this.localX(x), y: this.localY(y), w, h }); + return; + } + this.ops.push({ op: "FILL_RECT", x: this.localX(x), y: this.localY(y), w, h, style }); } blitRect(srcX: number, srcY: number, w: number, h: number, dstX: number, dstY: number): void { @@ -269,13 +452,22 @@ export class RenderPacketRecorder implements DrawlistBuilder { style?: Parameters[3], ): void { this.target.drawText(x, y, text, style); - const local = { + if (style === undefined) { + this.ops.push({ + op: "DRAW_TEXT_SLICE", + x: this.localX(x), + y: this.localY(y), + text, + }); + return; + } + this.ops.push({ op: "DRAW_TEXT_SLICE", x: this.localX(x), y: this.localY(y), text, - } as const; - this.ops.push(style === undefined ? local : { ...local, style }); + style, + }); } pushClip(x: number, y: number, w: number, h: number): void { @@ -347,7 +539,17 @@ export class RenderPacketRecorder implements DrawlistBuilder { this.invalidatePacket(); return; } - this.ops.push({ + const op: { + op: "DRAW_CANVAS"; + x: number; + y: number; + w: number; + h: number; + resourceId: number; + blitter: Parameters[5]; + pxWidth?: number; + pxHeight?: number; + } = { op: "DRAW_CANVAS", x: this.localX(x), y: this.localY(y), @@ -355,9 +557,10 @@ export class RenderPacketRecorder implements DrawlistBuilder { h, resourceId, blitter, - ...(pxWidth !== undefined ? { pxWidth } : {}), - ...(pxHeight !== undefined ? { pxHeight } : {}), - }); + }; + if (pxWidth !== undefined) op.pxWidth = pxWidth; + if (pxHeight !== undefined) op.pxHeight = pxHeight; + this.ops.push(op); } drawImage(...args: Parameters): void { @@ -368,7 +571,21 @@ export class RenderPacketRecorder implements DrawlistBuilder { this.invalidatePacket(); return; } - this.ops.push({ + const op: { + op: "DRAW_IMAGE"; + x: number; + y: number; + w: number; + h: number; + resourceId: number; + format: Parameters[5]; + protocol: Parameters[6]; + zLayer: Parameters[7]; + fit: Parameters[8]; + imageId: Parameters[9]; + pxWidth?: number; + pxHeight?: number; + } = { op: "DRAW_IMAGE", x: this.localX(x), y: this.localY(y), @@ -380,9 +597,10 @@ export class RenderPacketRecorder implements DrawlistBuilder { zLayer, fit, imageId, - ...(pxWidth !== undefined ? { pxWidth } : {}), - ...(pxHeight !== undefined ? { pxHeight } : {}), - }); + }; + if (pxWidth !== undefined) op.pxWidth = pxWidth; + if (pxHeight !== undefined) op.pxHeight = pxHeight; + this.ops.push(op); } buildInto(dst: Uint8Array): DrawlistBuildResult { @@ -409,10 +627,13 @@ export function emitRenderPacket( originX: number, originY: number, ): void { - const blobByResourceId: (number | null)[] = new Array(packet.resources.length); - for (let i = 0; i < packet.resources.length; i++) { - const resource = packet.resources[i]; - blobByResourceId[i] = resource ? builder.addBlob(resource) : null; + let blobByResourceId: (number | null)[] | null = null; + if (packet.resources.length > 0) { + blobByResourceId = new Array(packet.resources.length); + for (let i = 0; i < packet.resources.length; i++) { + const resource = packet.resources[i]; + blobByResourceId[i] = resource ? builder.addBlob(resource) : null; + } } for (const op of packet.ops) { @@ -444,7 +665,7 @@ export function emitRenderPacket( builder.popClip(); break; case "DRAW_CANVAS": { - const blobId = blobByResourceId[op.resourceId]; + const blobId = blobByResourceId?.[op.resourceId]; if (blobId === null || blobId === undefined) break; builder.drawCanvas( originX + op.x, @@ -459,7 +680,7 @@ export function emitRenderPacket( break; } case "DRAW_IMAGE": { - const blobId = blobByResourceId[op.resourceId]; + const blobId = blobByResourceId?.[op.resourceId]; if (blobId === null || blobId === undefined) break; builder.drawImage( originX + op.x, diff --git a/packages/core/src/renderer/renderToDrawlist/renderTree.ts b/packages/core/src/renderer/renderToDrawlist/renderTree.ts index 36594da2..4f39f302 100644 --- a/packages/core/src/renderer/renderToDrawlist/renderTree.ts +++ b/packages/core/src/renderer/renderToDrawlist/renderTree.ts @@ -151,7 +151,10 @@ export function renderTree( let renderTheme = currentTheme; if (vnode.kind === "themed") { const props = vnode.props as { theme?: unknown }; - renderTheme = mergeThemeOverride(currentTheme, props.theme); + const themeOverride = props.theme; + if (themeOverride !== undefined) { + renderTheme = mergeThemeOverride(currentTheme, themeOverride); + } } else if ( vnode.kind === "row" || vnode.kind === "column" || @@ -159,7 +162,10 @@ export function renderTree( vnode.kind === "box" ) { const props = vnode.props as { theme?: unknown }; - renderTheme = mergeThemeOverride(currentTheme, props.theme); + const themeOverride = props.theme; + if (themeOverride !== undefined) { + renderTheme = mergeThemeOverride(currentTheme, themeOverride); + } } const nodeStackLenBeforePush = nodeStack.length; From e3f5d671cea9c707b71f1623c659da10a8330266 Mon Sep 17 00:00:00 2001 From: RtlZeroMemory <58250858+RtlZeroMemory@users.noreply.github.com> Date: Fri, 27 Feb 2026 22:53:35 +0400 Subject: [PATCH 7/7] fix(layout): guard forced dimension cache keys --- packages/core/src/layout/engine/layoutEngine.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/core/src/layout/engine/layoutEngine.ts b/packages/core/src/layout/engine/layoutEngine.ts index fd42b1ea..219ca20f 100644 --- a/packages/core/src/layout/engine/layoutEngine.ts +++ b/packages/core/src/layout/engine/layoutEngine.ts @@ -111,7 +111,11 @@ function popLayoutCache(): void { } function forcedDimensionKey(value: number | null): number { - return value === null ? NULL_FORCED_DIMENSION : value; + if (value === null) return NULL_FORCED_DIMENSION; + if (value < 0) { + throw new RangeError("layout: forced dimensions must be >= 0"); + } + return value; } function getLayoutCacheHit(