From af9ecf36a93d5a49763ded881a7e09a051c48778 Mon Sep 17 00:00:00 2001 From: Rezo Date: Wed, 11 Feb 2026 14:57:46 +0400 Subject: [PATCH 1/2] feat(ink-compat): add getInnerHeight and Box scroll compatibility --- package-lock.json | 563 ++++++++++++------ packages/ink-compat/package.json | 5 +- .../src/__tests__/api.surface.test.ts | 2 + .../src/__tests__/compat.thirdparty.test.tsx | 97 ++- .../src/__tests__/measurement.test.tsx | 137 +++++ .../ink-compat/src/__tests__/props.test.ts | 14 + packages/ink-compat/src/index.ts | 2 +- packages/ink-compat/src/measureElement.ts | 3 +- packages/ink-compat/src/measurement.ts | 176 +++++- packages/ink-compat/src/props/boxProps.ts | 26 + packages/ink-compat/src/reconciler/convert.ts | 101 +++- packages/ink-compat/src/reconciler/types.ts | 2 + packages/ink-compat/src/types.ts | 11 +- 13 files changed, 912 insertions(+), 227 deletions(-) diff --git a/package-lock.json b/package-lock.json index 533e2b95..236d665c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -333,6 +333,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/gradient-string": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@types/gradient-string/-/gradient-string-1.1.6.tgz", + "integrity": "sha512-LkaYxluY4G5wR1M4AKQUal2q61Di1yVVCw42ImFTuaIoQVgmV0WP1xUaLB8zwb47mp82vWTpePI9JmrjEnJ7nQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/tinycolor2": "*" + } + }, "node_modules/@types/hast": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", @@ -379,6 +389,13 @@ "@types/react": "*" } }, + "node_modules/@types/tinycolor2": { + "version": "1.4.6", + "resolved": "https://registry.npmjs.org/@types/tinycolor2/-/tinycolor2-1.4.6.tgz", + "integrity": "sha512-iEN8J0BoMnsWBqjVbWH/c0G0Hh7O21lpR2/+PrvAVgWdzL7eexIFm4JN/Wn10PTcmNdtS6U67r499mlWMXOxNw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/unist": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", @@ -393,6 +410,18 @@ "dev": true, "license": "MIT" }, + "node_modules/ansi-escapes": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-6.2.1.tgz", + "integrity": "sha512-4nJ3yixlEthEJ9Rk4vPcdBRkZvQZlYyu8j4/Mqz5sgIkddmEnH2Yj2ZrnP9S3tQOvSNRUIgVNF/1yPpRAGNRig==", + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/ansi-regex": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", @@ -525,6 +554,51 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/cli-spinners": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", + "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-3.1.0.tgz", + "integrity": "sha512-wfOBkjXteqSnI59oPcJkcPl/ZmwvMMOj340qUIY1SKZCv0B9Cf4D4fAucRkIKQmsIuYK3x1rrgU7MeGRruiuiA==", + "license": "MIT", + "dependencies": { + "slice-ansi": "^5.0.0", + "string-width": "^5.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate/node_modules/slice-ansi": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-5.0.0.tgz", + "integrity": "sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.0.0", + "is-fullwidth-code-point": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, "node_modules/code-excerpt": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/code-excerpt/-/code-excerpt-4.0.0.tgz", @@ -537,6 +611,26 @@ "node": "^12.20.0 || ^14.13.1 || >=16.0.0" } }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, "node_modules/convert-to-spaces": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/convert-to-spaces/-/convert-to-spaces-2.0.1.tgz", @@ -572,6 +666,12 @@ "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", "license": "MIT" }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "license": "MIT" + }, "node_modules/entities": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", @@ -594,6 +694,63 @@ "node": ">=8" } }, + "node_modules/gradient-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/gradient-string/-/gradient-string-2.0.2.tgz", + "integrity": "sha512-rEDCuqUQ4tbD78TpzsMtt5OIf0cBCSDWSJtUDaF6JsAh+k0v9r++NzxNEG87oDZx9ZwGhD8DaezR2L/yrw0Jdw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.2", + "tinygradient": "^1.1.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/gradient-string/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/gradient-string/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/hello-counter": { "resolved": "examples/hello-counter", "link": true @@ -610,6 +767,94 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/ink": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/ink/-/ink-4.4.1.tgz", + "integrity": "sha512-rXckvqPBB0Krifk5rn/5LvQGmyXwCUpBfmTwbkQNBY9JY8RSl3b8OftBNEYxg4+SWUhEKcPifgope28uL9inlA==", + "license": "MIT", + "dependencies": { + "@alcalzone/ansi-tokenize": "^0.1.3", + "ansi-escapes": "^6.0.0", + "auto-bind": "^5.0.1", + "chalk": "^5.2.0", + "cli-boxes": "^3.0.0", + "cli-cursor": "^4.0.0", + "cli-truncate": "^3.1.0", + "code-excerpt": "^4.0.0", + "indent-string": "^5.0.0", + "is-ci": "^3.0.1", + "is-lower-case": "^2.0.2", + "is-upper-case": "^2.0.2", + "lodash": "^4.17.21", + "patch-console": "^2.0.0", + "react-reconciler": "^0.29.0", + "scheduler": "^0.23.0", + "signal-exit": "^3.0.7", + "slice-ansi": "^6.0.0", + "stack-utils": "^2.0.6", + "string-width": "^5.1.2", + "type-fest": "^0.12.0", + "widest-line": "^4.0.1", + "wrap-ansi": "^8.1.0", + "ws": "^8.12.0", + "yoga-wasm-web": "~0.3.3" + }, + "engines": { + "node": ">=14.16" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "react": ">=18.0.0", + "react-devtools-core": "^4.19.1" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "react-devtools-core": { + "optional": true + } + } + }, + "node_modules/ink-gradient": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/ink-gradient/-/ink-gradient-3.0.0.tgz", + "integrity": "sha512-OVyPBovBxE1tFcBhSamb+P1puqDP6pG3xFe2W9NiLgwUZd9RbcjBeR7twLbliUT9navrUstEf1ZcPKKvx71BsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/gradient-string": "^1.1.2", + "gradient-string": "^2.0.2", + "prop-types": "^15.8.1", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + }, + "peerDependencies": { + "ink": ">=4" + } + }, + "node_modules/ink-spinner": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/ink-spinner/-/ink-spinner-5.0.0.tgz", + "integrity": "sha512-EYEasbEjkqLGyPOUc8hBJZNuC5GvXGMLu0w5gdTNskPc7Izc5vO3tdQEYnzvshucyGCBXc86ig0ujXPMWaQCdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cli-spinners": "^2.7.0" + }, + "engines": { + "node": ">=14.16" + }, + "peerDependencies": { + "ink": ">=4.0.0", + "react": ">=18.0.0" + } + }, "node_modules/iota-array": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/iota-array/-/iota-array-1.0.0.tgz", @@ -807,6 +1052,16 @@ "node": ">=v0.6.5" } }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/omggif": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/omggif/-/omggif-1.0.10.tgz", @@ -846,6 +1101,18 @@ "node": ">=12.13.0" } }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, "node_modules/punycode.js": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", @@ -872,6 +1139,13 @@ "node": ">=0.10.0" } }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "dev": true, + "license": "MIT" + }, "node_modules/react-reconciler": { "version": "0.29.2", "resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.29.2.tgz", @@ -937,6 +1211,22 @@ "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", "license": "ISC" }, + "node_modules/slice-ansi": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-6.0.0.tgz", + "integrity": "sha512-6bn4hRfkTvDfUoEQYkERg0BVF1D0vrX9HEkMl08uDiNWvVvjylLHvZFZWkDo6wjT8tUctbYl1nCOuE66ZTaUtA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "is-fullwidth-code-point": "^4.0.0" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, "node_modules/stack-utils": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", @@ -958,6 +1248,23 @@ "node": ">=14.15.0" } }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/strip-ansi": { "version": "7.1.2", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", @@ -973,6 +1280,19 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/terminal-kit": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/terminal-kit/-/terminal-kit-3.1.2.tgz", @@ -992,6 +1312,24 @@ "node": ">=16.13.0" } }, + "node_modules/tinycolor2": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.6.0.tgz", + "integrity": "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinygradient": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/tinygradient/-/tinygradient-1.1.5.tgz", + "integrity": "sha512-8nIfc2vgQ4TeLnk2lFj4tRLvvJwEfQuabdsmvDdQPT0xlk9TaNtpGd6nNRxXoK6vQhN6RSzj+Cnp5tTQmpxmbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/tinycolor2": "^1.4.0", + "tinycolor2": "^1.0.0" + } + }, "node_modules/tree-kit": { "version": "0.8.8", "resolved": "https://registry.npmjs.org/tree-kit/-/tree-kit-0.8.8.tgz", @@ -1007,6 +1345,18 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/type-fest": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.12.0.tgz", + "integrity": "sha512-53RyidyjvkGpnWPMF9bQgFtWp+Sl8O2Rp13VavmJgfAP9WWG6q6TkrKU8iyJdnwnfgHI6k2hTlgqH4aSdjoTbg==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/typedoc": { "version": "0.28.16", "resolved": "https://registry.npmjs.org/typedoc/-/typedoc-0.28.16.tgz", @@ -1061,6 +1411,38 @@ "integrity": "sha512-Gw+zz50YNKPDKXs+9d+aKAjVwpjNwqzvNpLigIruT4HA9lMZNdMqs9x07kKHB/L9WRzqp4+DlTU5s4wG2esdoA==", "license": "MIT" }, + "node_modules/widest-line": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-4.0.1.tgz", + "integrity": "sha512-o0cyEG0e8GPzT4iGHphIOh0cJOV8fivsXxddQasHPHfoZf1ZexrfeA21w2NaEN1RHE+fXlfISmOE8R9N3u3Qig==", + "license": "MIT", + "dependencies": { + "string-width": "^5.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/ws": { "version": "8.19.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", @@ -1124,182 +1506,6 @@ "node": ">=18" } }, - "packages/bench/node_modules/ansi-escapes": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-6.2.1.tgz", - "integrity": "sha512-4nJ3yixlEthEJ9Rk4vPcdBRkZvQZlYyu8j4/Mqz5sgIkddmEnH2Yj2ZrnP9S3tQOvSNRUIgVNF/1yPpRAGNRig==", - "license": "MIT", - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "packages/bench/node_modules/cli-truncate": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-3.1.0.tgz", - "integrity": "sha512-wfOBkjXteqSnI59oPcJkcPl/ZmwvMMOj340qUIY1SKZCv0B9Cf4D4fAucRkIKQmsIuYK3x1rrgU7MeGRruiuiA==", - "license": "MIT", - "dependencies": { - "slice-ansi": "^5.0.0", - "string-width": "^5.0.0" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "packages/bench/node_modules/cli-truncate/node_modules/slice-ansi": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-5.0.0.tgz", - "integrity": "sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.0.0", - "is-fullwidth-code-point": "^4.0.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/slice-ansi?sponsor=1" - } - }, - "packages/bench/node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "license": "MIT" - }, - "packages/bench/node_modules/ink": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/ink/-/ink-4.4.1.tgz", - "integrity": "sha512-rXckvqPBB0Krifk5rn/5LvQGmyXwCUpBfmTwbkQNBY9JY8RSl3b8OftBNEYxg4+SWUhEKcPifgope28uL9inlA==", - "license": "MIT", - "dependencies": { - "@alcalzone/ansi-tokenize": "^0.1.3", - "ansi-escapes": "^6.0.0", - "auto-bind": "^5.0.1", - "chalk": "^5.2.0", - "cli-boxes": "^3.0.0", - "cli-cursor": "^4.0.0", - "cli-truncate": "^3.1.0", - "code-excerpt": "^4.0.0", - "indent-string": "^5.0.0", - "is-ci": "^3.0.1", - "is-lower-case": "^2.0.2", - "is-upper-case": "^2.0.2", - "lodash": "^4.17.21", - "patch-console": "^2.0.0", - "react-reconciler": "^0.29.0", - "scheduler": "^0.23.0", - "signal-exit": "^3.0.7", - "slice-ansi": "^6.0.0", - "stack-utils": "^2.0.6", - "string-width": "^5.1.2", - "type-fest": "^0.12.0", - "widest-line": "^4.0.1", - "wrap-ansi": "^8.1.0", - "ws": "^8.12.0", - "yoga-wasm-web": "~0.3.3" - }, - "engines": { - "node": ">=14.16" - }, - "peerDependencies": { - "@types/react": ">=18.0.0", - "react": ">=18.0.0", - "react-devtools-core": "^4.19.1" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "react-devtools-core": { - "optional": true - } - } - }, - "packages/bench/node_modules/slice-ansi": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-6.0.0.tgz", - "integrity": "sha512-6bn4hRfkTvDfUoEQYkERg0BVF1D0vrX9HEkMl08uDiNWvVvjylLHvZFZWkDo6wjT8tUctbYl1nCOuE66ZTaUtA==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.2.1", - "is-fullwidth-code-point": "^4.0.0" - }, - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/chalk/slice-ansi?sponsor=1" - } - }, - "packages/bench/node_modules/string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "license": "MIT", - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "packages/bench/node_modules/type-fest": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.12.0.tgz", - "integrity": "sha512-53RyidyjvkGpnWPMF9bQgFtWp+Sl8O2Rp13VavmJgfAP9WWG6q6TkrKU8iyJdnwnfgHI6k2hTlgqH4aSdjoTbg==", - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "packages/bench/node_modules/widest-line": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-4.0.1.tgz", - "integrity": "sha512-o0cyEG0e8GPzT4iGHphIOh0cJOV8fivsXxddQasHPHfoZf1ZexrfeA21w2NaEN1RHE+fXlfISmOE8R9N3u3Qig==", - "license": "MIT", - "dependencies": { - "string-width": "^5.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "packages/bench/node_modules/wrap-ansi": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", - "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, "packages/core": { "name": "@rezi-ui/core", "version": "0.1.0-alpha.0", @@ -1342,7 +1548,10 @@ "devDependencies": { "@rezi-ui/testkit": "0.1.0-alpha.0", "@types/react": "^18.0.0", - "@types/react-reconciler": "^0.28.9" + "@types/react-reconciler": "^0.28.9", + "ink": "^4.4.1", + "ink-gradient": "^3.0.0", + "ink-spinner": "^5.0.0" }, "engines": { "node": ">=18" diff --git a/packages/ink-compat/package.json b/packages/ink-compat/package.json index 0fa3250c..839a9f5f 100644 --- a/packages/ink-compat/package.json +++ b/packages/ink-compat/package.json @@ -35,6 +35,9 @@ "devDependencies": { "@rezi-ui/testkit": "0.1.0-alpha.0", "@types/react": "^18.0.0", - "@types/react-reconciler": "^0.28.9" + "@types/react-reconciler": "^0.28.9", + "ink": "^4.4.1", + "ink-gradient": "^3.0.0", + "ink-spinner": "^5.0.0" } } diff --git a/packages/ink-compat/src/__tests__/api.surface.test.ts b/packages/ink-compat/src/__tests__/api.surface.test.ts index b1765595..75d69aa5 100644 --- a/packages/ink-compat/src/__tests__/api.surface.test.ts +++ b/packages/ink-compat/src/__tests__/api.surface.test.ts @@ -3,6 +3,7 @@ import { type RenderOptions, ResizeObserver, getBoundingBox, + getInnerHeight, getScrollHeight, render, } from "../index.js"; @@ -10,6 +11,7 @@ import { describe("api surface", () => { test("exports measurement and observer APIs", () => { assert.equal(typeof getBoundingBox, "function"); + assert.equal(typeof getInnerHeight, "function"); assert.equal(typeof getScrollHeight, "function"); assert.equal(typeof ResizeObserver, "function"); }); diff --git a/packages/ink-compat/src/__tests__/compat.thirdparty.test.tsx b/packages/ink-compat/src/__tests__/compat.thirdparty.test.tsx index b5567cad..5e6ad3f4 100644 --- a/packages/ink-compat/src/__tests__/compat.thirdparty.test.tsx +++ b/packages/ink-compat/src/__tests__/compat.thirdparty.test.tsx @@ -1,7 +1,9 @@ import { assert, describe, test } from "@rezi-ui/testkit"; -import type React from "react"; -import { useEffect, useState } from "react"; -import { Text, Transform, render } from "../index.js"; +import Gradient from "ink-gradient"; +import { Transform } from "ink"; +import Spinner from "ink-spinner"; +import React from "react"; +import { Box, type DOMElement, Text, getInnerHeight, getScrollHeight, render } from "../index.js"; import { StubBackend, encodeZrevBatchV1, @@ -18,30 +20,31 @@ async function pushInitialResize(backend: StubBackend): Promise { await flushMicrotasks(10); } -describe("third-party compatibility smoke", () => { - test("ink-spinner pattern (stateful interval + ) triggers render updates", async () => { - function SpinnerLike() { - const frames = ["-", "\\", "|", "/"] as const; - const [frame, setFrame] = useState(0); +async function renderToLastFrameBytes(tree: React.ReactNode): Promise { + const backend = new StubBackend(); + const inst = render(tree, { internal_backend: backend, exitOnCtrlC: false }); - useEffect(() => { - const timer = setInterval(() => { - setFrame((prev) => (prev + 1) % frames.length); - }, 10); - return () => clearInterval(timer); - }, [frames.length]); + await flushMicrotasks(10); + await pushInitialResize(backend); + + const bytes = backend.requestedFrames[backend.requestedFrames.length - 1] ?? new Uint8Array(); - return {frames[frame]}; - } + inst.unmount(); + await inst.waitUntilExit(); + return bytes; +} + +describe("third-party compatibility (real packages)", () => { + test("ink-spinner renders and advances frames over time", async () => { const backend = new StubBackend(); - const inst = render(, { internal_backend: backend, exitOnCtrlC: false }); + const inst = render(, { internal_backend: backend, exitOnCtrlC: false }); await flushMicrotasks(10); await pushInitialResize(backend); const initialFrames = backend.requestedFrames.length; - await new Promise((resolve) => setTimeout(resolve, 40)); + await new Promise((resolve) => setTimeout(resolve, 140)); await flushMicrotasks(10); assert.ok(backend.requestedFrames.length > initialFrames); @@ -50,30 +53,60 @@ describe("third-party compatibility smoke", () => { await inst.waitUntilExit(); }); - test("ink-gradient pattern (Transform transform callback) receives flattened text", async () => { - let lastInput = ""; - - function GradientLike(props: Readonly<{ children: React.ReactNode }>) { - const transform = (children: string) => { - lastInput = children; - return `\u001B[31m${children}\u001B[39m`; - }; + test("ink-gradient uses Ink Transform behavior with real package runtime", async () => { + const gradientElement = (Gradient as unknown as (props: { + name: string; + children: React.ReactNode; + }) => React.ReactElement<{ + transform: (input: string) => string; + children: React.ReactNode; + }>)({ + name: "rainbow", + children: rainbow, + }); + + assert.equal(gradientElement.type, Transform); + assert.equal(typeof gradientElement.props.transform, "function"); + + const frame = await renderToLastFrameBytes( + + rainbow + , + ); - return {props.children}; - } + assert.ok(frame.length > 0); + }); + test("third-party components remain compatible with scroll measurement semantics", async () => { const backend = new StubBackend(); + const containerRef = React.createRef(); + const inst = render( - - rainbow - , + + + + line-2 + + line-3 + , { internal_backend: backend, exitOnCtrlC: false }, ); await flushMicrotasks(10); await pushInitialResize(backend); - assert.equal(lastInput, "rainbow"); + const container = containerRef.current; + assert.ok(container); + assert.equal(getInnerHeight(container), 1); + assert.ok(getScrollHeight(container) >= 3); assert.ok(backend.requestedFrames.length >= 1); inst.unmount(); diff --git a/packages/ink-compat/src/__tests__/measurement.test.tsx b/packages/ink-compat/src/__tests__/measurement.test.tsx index 90f79cf6..5b68b514 100644 --- a/packages/ink-compat/src/__tests__/measurement.test.tsx +++ b/packages/ink-compat/src/__tests__/measurement.test.tsx @@ -5,6 +5,7 @@ import { type DOMElement, Text, getBoundingBox, + getInnerHeight, getScrollHeight, measureElement, render, @@ -110,4 +111,140 @@ describe("measurement", () => { inst.unmount(); await inst.waitUntilExit(); }); + + test("getInnerHeight matches inner/client height semantics and updates on rerender", async () => { + const backend = new StubBackend(); + const ref = React.createRef(); + + const inst = render( + + body + , + { internal_backend: backend, exitOnCtrlC: false }, + ); + + await flushMicrotasks(10); + await pushInitialResize(backend); + + const node = ref.current; + assert.ok(node); + assert.equal(measureElement(node).height, 6); + assert.equal(getInnerHeight(node), 4); + + inst.rerender( + + body + , + ); + await flushMicrotasks(10); + + assert.equal(measureElement(node).height, 8); + assert.equal(getInnerHeight(node), 6); + + inst.unmount(); + await inst.waitUntilExit(); + }); + + test("overflowY=scroll and scrollTop update visible viewport in Gemini-like container", async () => { + const backend = new StubBackend(); + const containerRef = React.createRef(); + const itemRefs = [ + React.createRef(), + React.createRef(), + React.createRef(), + React.createRef(), + ]; + + const tree = (scrollTop: number) => ( + + + row-1 + + + row-2 + + + row-3 + + + row-4 + + + ); + + const inst = render(tree(0), { internal_backend: backend, exitOnCtrlC: false }); + await flushMicrotasks(10); + await pushInitialResize(backend); + + const container = containerRef.current; + const first = itemRefs[0]!.current; + const second = itemRefs[1]!.current; + const third = itemRefs[2]!.current; + assert.ok(container); + assert.ok(first); + assert.ok(second); + assert.ok(third); + + assert.equal(getScrollHeight(container), 4); + assert.equal(getBoundingBox(first).y, 0); + assert.equal(getBoundingBox(second).y, 1); + assert.equal(measureElement(third).height, 0); + + inst.rerender(tree(1)); + await flushMicrotasks(10); + + assert.equal(getScrollHeight(container), 4); + assert.equal(measureElement(first).height, 0); + assert.equal(getBoundingBox(second).y, 0); + assert.equal(getBoundingBox(third).y, 1); + + inst.unmount(); + await inst.waitUntilExit(); + }); + + test("scrollbarThumbColor is accepted as a renderer no-op without affecting scroll metrics", async () => { + const backend = new StubBackend(); + const containerRef = React.createRef(); + + const inst = render( + + + a + + + b + + + c + + , + { internal_backend: backend, exitOnCtrlC: false }, + ); + + await flushMicrotasks(10); + await pushInitialResize(backend); + + const container = containerRef.current; + assert.ok(container); + assert.equal(getScrollHeight(container), 3); + assert.ok(backend.requestedFrames.length >= 1); + + inst.unmount(); + await inst.waitUntilExit(); + }); }); diff --git a/packages/ink-compat/src/__tests__/props.test.ts b/packages/ink-compat/src/__tests__/props.test.ts index 56e79534..b95aad57 100644 --- a/packages/ink-compat/src/__tests__/props.test.ts +++ b/packages/ink-compat/src/__tests__/props.test.ts @@ -108,6 +108,20 @@ describe("props: mapBoxProps()", () => { const out = mapBoxProps({ display: "none" }); assert.equal(out.hidden, true); }); + + test("overflowY=scroll and scrollbarThumbColor map into runtime scroll metadata", () => { + const out = mapBoxProps({ + overflow: "hidden", + overflowY: "scroll", + scrollTop: 7, + scrollbarThumbColor: "yellow", + }); + + assert.equal(out.overflow, "hidden"); + assert.equal(out.overflowY, "scroll"); + assert.equal(out.scrollTop, 7); + assert.equal(out.scrollbarThumbColor, "yellow"); + }); }); describe("props: mapTextProps()", () => { diff --git a/packages/ink-compat/src/index.ts b/packages/ink-compat/src/index.ts index 6f64cb6e..b1fc3eb9 100644 --- a/packages/ink-compat/src/index.ts +++ b/packages/ink-compat/src/index.ts @@ -23,7 +23,7 @@ export { render } from "./render.js"; // Measurement export { default as measureElement } from "./measureElement.js"; -export { getBoundingBox, getScrollHeight, getScrollWidth } from "./measureElement.js"; +export { getBoundingBox, getInnerHeight, getScrollHeight, getScrollWidth } from "./measureElement.js"; export { default as ResizeObserver, ResizeObserverEntry } from "./resizeObserver.js"; // Types diff --git a/packages/ink-compat/src/measureElement.ts b/packages/ink-compat/src/measureElement.ts index 682f0142..eeb8c89f 100644 --- a/packages/ink-compat/src/measureElement.ts +++ b/packages/ink-compat/src/measureElement.ts @@ -1,11 +1,12 @@ import { getBoundingBox, + getInnerHeight, getScrollHeight, getScrollWidth, measureElementFromLayout, } from "./measurement.js"; import type { DOMElement } from "./types.js"; -export { getBoundingBox, getScrollHeight, getScrollWidth } from "./measurement.js"; +export { getBoundingBox, getInnerHeight, getScrollHeight, getScrollWidth } from "./measurement.js"; /** * Measure the dimensions of a `` element. diff --git a/packages/ink-compat/src/measurement.ts b/packages/ink-compat/src/measurement.ts index da5b96f1..24fdfc68 100644 --- a/packages/ink-compat/src/measurement.ts +++ b/packages/ink-compat/src/measurement.ts @@ -1,4 +1,4 @@ -import type { HostElement, HostLayoutRect, HostRoot } from "./reconciler/types.js"; +import type { HostElement, HostLayoutRect, HostNode, HostRoot } from "./reconciler/types.js"; import { ResizeObserverEntry } from "./resizeObserver.js"; import type { DOMElement } from "./types.js"; @@ -9,6 +9,7 @@ type ResizeObserverLike = Readonly<{ }>; const ZERO_LAYOUT: HostLayoutRect = Object.freeze({ x: 0, y: 0, width: 0, height: 0 }); +const ZERO_INSETS = Object.freeze({ top: 0, right: 0, bottom: 0, left: 0 }); function toLayout(rect: CoreRect | undefined): HostLayoutRect { if (!rect) return ZERO_LAYOUT; @@ -27,6 +28,131 @@ function readLayout(node: DOMElement): HostLayoutRect { return layout ?? ZERO_LAYOUT; } +function coerceNonNegativeNumber(v: unknown): number | undefined { + if (typeof v === "number" && Number.isFinite(v) && v >= 0) return v; + if (typeof v === "string") { + const n = Number(v); + if (Number.isFinite(n) && n >= 0) return n; + } + return undefined; +} + +function readScrollOffset(v: unknown): number { + if (typeof v !== "number" || !Number.isFinite(v) || v <= 0) return 0; + return v; +} + +function normalizeOverflow(v: unknown): "visible" | "hidden" | "scroll" { + return v === "hidden" || v === "scroll" ? v : "visible"; +} + +function readOverflowFromProps(props: Record): Readonly<{ + overflow: "visible" | "hidden" | "scroll"; + overflowX: "visible" | "hidden" | "scroll"; + overflowY: "visible" | "hidden" | "scroll"; +}> { + const overflow = normalizeOverflow(props["overflow"]); + const overflowX = normalizeOverflow(props["overflowX"] ?? overflow); + const overflowY = normalizeOverflow(props["overflowY"] ?? overflow); + return { overflow, overflowX, overflowY }; +} + +function readBorderInsets(props: Record): Readonly<{ + top: number; + right: number; + bottom: number; + left: number; +}> { + const hasAnyBorder = props["borderStyle"] !== undefined || props["border"] !== undefined; + const borderStyle = props["borderStyle"] ?? props["border"]; + const hasBorder = hasAnyBorder && borderStyle !== "none"; + + if (!hasBorder) return ZERO_INSETS; + + const top = typeof props["borderTop"] === "boolean" ? props["borderTop"] : true; + const right = typeof props["borderRight"] === "boolean" ? props["borderRight"] : true; + const bottom = typeof props["borderBottom"] === "boolean" ? props["borderBottom"] : true; + const left = typeof props["borderLeft"] === "boolean" ? props["borderLeft"] : true; + return { + top: top ? 1 : 0, + right: right ? 1 : 0, + bottom: bottom ? 1 : 0, + left: left ? 1 : 0, + }; +} + +function resolveVerticalInsets(props: Record): number { + const padding = coerceNonNegativeNumber(props["padding"]) ?? 0; + const paddingY = coerceNonNegativeNumber(props["paddingY"]) ?? padding; + const paddingTop = coerceNonNegativeNumber(props["paddingTop"]) ?? paddingY; + const paddingBottom = coerceNonNegativeNumber(props["paddingBottom"]) ?? paddingY; + const border = readBorderInsets(props); + return paddingTop + paddingBottom + border.top + border.bottom; +} + +function estimateTextNodeHeight(node: HostElement): number { + let lines = 0; + for (const child of node.children) { + if (child.kind !== "text") continue; + lines += Math.max(1, child.text.split("\n").length); + } + return Math.max(1, lines); +} + +function estimateNodeHeight(node: HostNode): number { + if (node.kind === "text") return Math.max(1, node.text.split("\n").length); + + const explicitHeight = coerceNonNegativeNumber((node.props as { height?: unknown }).height); + if (explicitHeight !== undefined) return explicitHeight; + + if (node.type === "ink-spacer") return 1; + if (node.type === "ink-text" || node.type === "ink-virtual-text") return estimateTextNodeHeight(node); + + const direction = (node.props as { flexDirection?: unknown }).flexDirection; + const isColumn = direction === "column" || direction === "column-reverse"; + const insets = resolveVerticalInsets(node.props); + + if (node.children.length === 0) return insets; + + if (isColumn) { + let total = insets; + for (const child of node.children) { + total += estimateNodeHeight(child); + } + return total; + } + + let max = 0; + for (const child of node.children) { + const next = estimateNodeHeight(child); + if (next > max) max = next; + } + return insets + max; +} + +function estimateScrollHeight(node: HostElement): number { + if (node.children.length === 0) return resolveVerticalInsets(node.props); + + const direction = (node.props as { flexDirection?: unknown }).flexDirection; + const isColumn = direction === "column" || direction === "column-reverse"; + const insets = resolveVerticalInsets(node.props); + + if (isColumn) { + let total = insets; + for (const child of node.children) { + total += estimateNodeHeight(child); + } + return total; + } + + let max = 0; + for (const child of node.children) { + const next = estimateNodeHeight(child); + if (next > max) max = next; + } + return insets + max; +} + export function measureElementFromLayout( node: DOMElement, ): Readonly<{ width: number; height: number }> { @@ -49,6 +175,16 @@ export function getBoundingBox(node: DOMElement): Readonly<{ }; } +export function getInnerHeight(node: DOMElement): number { + const element = node as HostElement; + const scrollState = element.internal_scrollState; + if (scrollState) return scrollState.clientHeight; + + const layout = readLayout(node); + const border = readBorderInsets(element.props); + return Math.max(0, layout.height - border.top - border.bottom); +} + export function getScrollHeight(node: DOMElement): number { const st = (node as HostElement).internal_scrollState; return st?.scrollHeight ?? 0; @@ -85,6 +221,9 @@ function updateNodeLayout( let maxChildX = 0; let maxChildY = 0; + const borderInsets = readBorderInsets(node.props); + const contentOriginX = layout.x + borderInsets.left; + const contentOriginY = layout.y + borderInsets.top; let maxRelRight = 0; let maxRelBottom = 0; @@ -106,8 +245,8 @@ function updateNodeLayout( maxChildY = Math.max(maxChildY, childRect.y + childRect.height); } - const relRight = childRect.x - layout.x + childRect.width; - const relBottom = childRect.y - layout.y + childRect.height; + const relRight = childRect.x - contentOriginX + childRect.width; + const relBottom = childRect.y - contentOriginY + childRect.height; if (relRight > maxRelRight) maxRelRight = relRight; if (relBottom > maxRelBottom) maxRelBottom = relBottom; } @@ -125,11 +264,34 @@ function updateNodeLayout( node.internal_layout = layout; - const clientWidth = layout.width; - const clientHeight = layout.height; + const overflow = readOverflowFromProps(node.props); + const clientWidth = Math.max(0, layout.width - borderInsets.left - borderInsets.right); + const clientHeight = Math.max(0, layout.height - borderInsets.top - borderInsets.bottom); + + let scrollHeight = Math.max(clientHeight, maxRelBottom); + let scrollWidth = Math.max(clientWidth, maxRelRight); + const rawScrollTop = readScrollOffset(node.props["scrollTop"]); + const rawScrollLeft = readScrollOffset(node.props["scrollLeft"]); + + if (overflow.overflowY === "scroll") { + const estimated = estimateScrollHeight(node); + scrollHeight = Math.max(scrollHeight, estimated, maxRelBottom + rawScrollTop); + } + + if (overflow.overflowX === "scroll") { + scrollWidth = Math.max(scrollWidth, maxRelRight + rawScrollLeft); + } + + const scrollTopMax = Math.max(0, scrollHeight - clientHeight); + const scrollLeftMax = Math.max(0, scrollWidth - clientWidth); + const scrollTop = overflow.overflowY === "scroll" ? Math.min(rawScrollTop, scrollTopMax) : 0; + const scrollLeft = overflow.overflowX === "scroll" ? Math.min(rawScrollLeft, scrollLeftMax) : 0; + node.internal_scrollState = { - scrollHeight: Math.max(clientHeight, maxRelBottom), - scrollWidth: Math.max(clientWidth, maxRelRight), + scrollTop, + scrollLeft, + scrollHeight, + scrollWidth, clientHeight, clientWidth, }; diff --git a/packages/ink-compat/src/props/boxProps.ts b/packages/ink-compat/src/props/boxProps.ts index c08f3a23..c7b2a95d 100644 --- a/packages/ink-compat/src/props/boxProps.ts +++ b/packages/ink-compat/src/props/boxProps.ts @@ -10,6 +10,12 @@ export type MappedInkBox = Readonly<{ stackKind: "row" | "column"; stackProps: StackProps; reverseChildren: boolean; + overflow: "visible" | "hidden" | "scroll"; + overflowX: "visible" | "hidden" | "scroll"; + overflowY: "visible" | "hidden" | "scroll"; + scrollTop: number; + scrollLeft: number; + scrollbarThumbColor?: string; }>; type Mutable = { -readonly [K in keyof T]: T[K] }; @@ -38,6 +44,15 @@ function coerceNumber(v: number | string | undefined): number | undefined { return n; } +function normalizeOverflow(v: unknown): "visible" | "hidden" | "scroll" { + return v === "hidden" || v === "scroll" ? v : "visible"; +} + +function coerceScrollOffset(v: unknown): number { + if (typeof v !== "number" || !Number.isFinite(v)) return 0; + return v <= 0 ? 0 : v; +} + function mapAlignItems(v: BoxProps["alignItems"]): StackProps["align"] | undefined { if (!v) return undefined; if (v === "flex-start") return "start"; @@ -107,6 +122,11 @@ function mapGap(p: BoxProps, isRow: boolean): number | undefined { export function mapBoxProps(p: BoxProps): MappedInkBox { const hidden = p.display === "none"; + const overflow = normalizeOverflow(p.overflow); + const overflowX = normalizeOverflow(p.overflowX ?? overflow); + const overflowY = normalizeOverflow(p.overflowY ?? overflow); + const scrollTop = coerceScrollOffset(p.scrollTop); + const scrollLeft = coerceScrollOffset(p.scrollLeft); const dir = p.flexDirection ?? "row"; const isRow = dir === "row" || dir === "row-reverse"; @@ -196,5 +216,11 @@ export function mapBoxProps(p: BoxProps): MappedInkBox { stackKind: isRow ? "row" : "column", stackProps, reverseChildren, + overflow, + overflowX, + overflowY, + scrollTop, + scrollLeft, + ...(p.scrollbarThumbColor === undefined ? {} : { scrollbarThumbColor: p.scrollbarThumbColor }), }; } diff --git a/packages/ink-compat/src/reconciler/convert.ts b/packages/ink-compat/src/reconciler/convert.ts index dee962e1..de79d7e0 100644 --- a/packages/ink-compat/src/reconciler/convert.ts +++ b/packages/ink-compat/src/reconciler/convert.ts @@ -3,10 +3,96 @@ import { InkCompatError } from "../errors.js"; import { mapBoxProps } from "../props.js"; import type { BoxProps } from "../types.js"; import { convertText, sanitizeTextForTerminal, splitSpansByNewline } from "./textUtils.js"; -import type { HostNode, HostRoot } from "./types.js"; +import type { HostElement, HostNode, HostRoot } from "./types.js"; type ConvertCtx = Readonly<{ staticVNodes: VNode[] }>; +type ConvertedChild = Readonly<{ vnode: VNode; estimatedHeight: number }>; + +function coerceNonNegativeNumber(v: unknown): number | undefined { + if (typeof v === "number" && Number.isFinite(v) && v >= 0) return v; + if (typeof v === "string") { + const n = Number(v); + if (Number.isFinite(n) && n >= 0) return n; + } + return undefined; +} + +function resolveVerticalInsets(props: Record): number { + const padding = coerceNonNegativeNumber(props["padding"]) ?? 0; + const paddingY = coerceNonNegativeNumber(props["paddingY"]) ?? padding; + const paddingTop = coerceNonNegativeNumber(props["paddingTop"]) ?? paddingY; + const paddingBottom = coerceNonNegativeNumber(props["paddingBottom"]) ?? paddingY; + + const hasAnyBorder = props["borderStyle"] !== undefined || props["border"] !== undefined; + const borderStyle = props["borderStyle"] ?? props["border"]; + const hasBorder = hasAnyBorder && borderStyle !== "none"; + const borderTop = typeof props["borderTop"] === "boolean" ? props["borderTop"] : hasBorder; + const borderBottom = + typeof props["borderBottom"] === "boolean" ? props["borderBottom"] : hasBorder; + + return paddingTop + paddingBottom + (borderTop ? 1 : 0) + (borderBottom ? 1 : 0); +} + +function estimateTextLines(node: HostElement): number { + let lines = 0; + for (const child of node.children) { + if (child.kind !== "text") continue; + lines += Math.max(1, sanitizeTextForTerminal(child.text).split("\n").length); + } + return Math.max(1, lines); +} + +function estimateNodeHeight(node: HostNode): number { + if (node.kind === "text") { + return Math.max(1, sanitizeTextForTerminal(node.text).split("\n").length); + } + + const explicitHeight = coerceNonNegativeNumber((node.props as { height?: unknown }).height); + if (explicitHeight !== undefined) return explicitHeight; + + if (node.type === "ink-spacer") return 1; + if (node.type === "ink-text" || node.type === "ink-virtual-text") return estimateTextLines(node); + + const direction = (node.props as { flexDirection?: unknown }).flexDirection; + const isColumn = direction === "column" || direction === "column-reverse"; + const insets = resolveVerticalInsets(node.props); + + if (node.children.length === 0) return insets; + + if (isColumn) { + let total = insets; + for (const child of node.children) { + total += estimateNodeHeight(child); + } + return total; + } + + let max = 0; + for (const child of node.children) { + const next = estimateNodeHeight(child); + if (next > max) max = next; + } + return insets + max; +} + +function applyVerticalScroll(children: readonly ConvertedChild[], scrollTop: number): VNode[] { + if (children.length === 0 || scrollTop <= 0) return children.map((c) => c.vnode); + + let offset = scrollTop; + let start = 0; + while (start < children.length) { + const child = children[start]; + if (!child) break; + const h = Math.max(1, Math.round(child.estimatedHeight)); + if (offset < h) break; + offset -= h; + start++; + } + + return children.slice(start).map((c) => c.vnode); +} + function convertNode(node: HostNode, ctx: ConvertCtx): VNode | null { if (node.kind === "text") { // Strings are only valid inside . The reconciler enforces this already. @@ -29,13 +115,18 @@ function convertNode(node: HostNode, ctx: ConvertCtx): VNode | null { const mapped = mapBoxProps(node.props as unknown as BoxProps); if (mapped.hidden) return null; - const childVNodes: VNode[] = []; + const childVNodes: ConvertedChild[] = []; for (const c of node.children) { const v = convertNode(c, ctx); - if (v) childVNodes.push(v); + if (!v) continue; + childVNodes.push({ vnode: v, estimatedHeight: estimateNodeHeight(c) }); } if (isStatic && childVNodes.length === 0) return null; if (mapped.reverseChildren) childVNodes.reverse(); + const visibleChildren = + mapped.overflowY === "scroll" + ? applyVerticalScroll(childVNodes, mapped.scrollTop) + : childVNodes.map((c) => c.vnode); const measuredId = node.internal_id; const stackPropsWithId = mapped.wrapper @@ -43,8 +134,8 @@ function convertNode(node: HostNode, ctx: ConvertCtx): VNode | null { : { ...mapped.stackProps, id: measuredId }; const stack = mapped.stackKind === "row" - ? ui.row(stackPropsWithId, childVNodes) - : ui.column(stackPropsWithId, childVNodes); + ? ui.row(stackPropsWithId, visibleChildren) + : ui.column(stackPropsWithId, visibleChildren); const wrapperWithId = mapped.wrapper ? { ...mapped.wrapper, id: measuredId } : null; const vnode = wrapperWithId ? ui.box(wrapperWithId, [stack]) : stack; diff --git a/packages/ink-compat/src/reconciler/types.ts b/packages/ink-compat/src/reconciler/types.ts index 3f4e179e..9edf4261 100644 --- a/packages/ink-compat/src/reconciler/types.ts +++ b/packages/ink-compat/src/reconciler/types.ts @@ -10,6 +10,8 @@ export type HostLayoutRect = Readonly<{ }>; export type HostScrollState = Readonly<{ + scrollTop: number; + scrollLeft: number; scrollHeight: number; scrollWidth: number; clientHeight: number; diff --git a/packages/ink-compat/src/types.ts b/packages/ink-compat/src/types.ts index fb3b4d88..d08e9c74 100644 --- a/packages/ink-compat/src/types.ts +++ b/packages/ink-compat/src/types.ts @@ -89,9 +89,12 @@ export type BoxProps = Readonly<{ borderBottomDimColor?: boolean; borderLeftDimColor?: boolean; borderRightDimColor?: boolean; - overflow?: "visible" | "hidden"; - overflowX?: "visible" | "hidden"; - overflowY?: "visible" | "hidden"; + overflow?: "visible" | "hidden" | "scroll"; + overflowX?: "visible" | "hidden" | "scroll"; + overflowY?: "visible" | "hidden" | "scroll"; + scrollTop?: number; + scrollLeft?: number; + scrollbarThumbColor?: string; children?: ReactNode; }>; @@ -196,6 +199,8 @@ export type DOMElement = { parentNode?: DOMElement; internal_layout?: Readonly<{ x: number; y: number; width: number; height: number }>; internal_scrollState?: Readonly<{ + scrollTop: number; + scrollLeft: number; scrollHeight: number; scrollWidth: number; clientHeight: number; From e64e51d70e3a90f9c9484d9b07214d633dc4fbac Mon Sep 17 00:00:00 2001 From: Rezo Date: Wed, 11 Feb 2026 15:03:58 +0400 Subject: [PATCH 2/2] fix(ink-compat): resolve CI lint/typecheck regressions --- .../src/__tests__/compat.thirdparty.test.tsx | 18 +++-- .../src/__tests__/measurement.test.tsx | 24 +++--- packages/ink-compat/src/index.ts | 7 +- packages/ink-compat/src/measurement.ts | 79 ++++++++++++------- packages/ink-compat/src/reconciler/convert.ts | 31 +++++--- 5 files changed, 98 insertions(+), 61 deletions(-) diff --git a/packages/ink-compat/src/__tests__/compat.thirdparty.test.tsx b/packages/ink-compat/src/__tests__/compat.thirdparty.test.tsx index 5e6ad3f4..21e646fa 100644 --- a/packages/ink-compat/src/__tests__/compat.thirdparty.test.tsx +++ b/packages/ink-compat/src/__tests__/compat.thirdparty.test.tsx @@ -1,6 +1,6 @@ import { assert, describe, test } from "@rezi-ui/testkit"; -import Gradient from "ink-gradient"; import { Transform } from "ink"; +import Gradient from "ink-gradient"; import Spinner from "ink-spinner"; import React from "react"; import { Box, type DOMElement, Text, getInnerHeight, getScrollHeight, render } from "../index.js"; @@ -54,13 +54,15 @@ describe("third-party compatibility (real packages)", () => { }); test("ink-gradient uses Ink Transform behavior with real package runtime", async () => { - const gradientElement = (Gradient as unknown as (props: { - name: string; - children: React.ReactNode; - }) => React.ReactElement<{ - transform: (input: string) => string; - children: React.ReactNode; - }>)({ + const gradientElement = ( + Gradient as unknown as (props: { + name: string; + children: React.ReactNode; + }) => React.ReactElement<{ + transform: (input: string) => string; + children: React.ReactNode; + }> + )({ name: "rainbow", children: rainbow, }); diff --git a/packages/ink-compat/src/__tests__/measurement.test.tsx b/packages/ink-compat/src/__tests__/measurement.test.tsx index 5b68b514..49bb7bfe 100644 --- a/packages/ink-compat/src/__tests__/measurement.test.tsx +++ b/packages/ink-compat/src/__tests__/measurement.test.tsx @@ -148,12 +148,10 @@ describe("measurement", () => { test("overflowY=scroll and scrollTop update visible viewport in Gemini-like container", async () => { const backend = new StubBackend(); const containerRef = React.createRef(); - const itemRefs = [ - React.createRef(), - React.createRef(), - React.createRef(), - React.createRef(), - ]; + const firstRef = React.createRef(); + const secondRef = React.createRef(); + const thirdRef = React.createRef(); + const fourthRef = React.createRef(); const tree = (scrollTop: number) => ( { overflowX="hidden" scrollTop={scrollTop} > - + row-1 - + row-2 - + row-3 - + row-4 @@ -185,9 +183,9 @@ describe("measurement", () => { await pushInitialResize(backend); const container = containerRef.current; - const first = itemRefs[0]!.current; - const second = itemRefs[1]!.current; - const third = itemRefs[2]!.current; + const first = firstRef.current; + const second = secondRef.current; + const third = thirdRef.current; assert.ok(container); assert.ok(first); assert.ok(second); diff --git a/packages/ink-compat/src/index.ts b/packages/ink-compat/src/index.ts index b1fc3eb9..c000434d 100644 --- a/packages/ink-compat/src/index.ts +++ b/packages/ink-compat/src/index.ts @@ -23,7 +23,12 @@ export { render } from "./render.js"; // Measurement export { default as measureElement } from "./measureElement.js"; -export { getBoundingBox, getInnerHeight, getScrollHeight, getScrollWidth } from "./measureElement.js"; +export { + getBoundingBox, + getInnerHeight, + getScrollHeight, + getScrollWidth, +} from "./measureElement.js"; export { default as ResizeObserver, ResizeObserverEntry } from "./resizeObserver.js"; // Types diff --git a/packages/ink-compat/src/measurement.ts b/packages/ink-compat/src/measurement.ts index 24fdfc68..6471b0f1 100644 --- a/packages/ink-compat/src/measurement.ts +++ b/packages/ink-compat/src/measurement.ts @@ -7,6 +7,25 @@ type CoreRect = Readonly<{ x: number; y: number; w: number; h: number }>; type ResizeObserverLike = Readonly<{ internalTrigger: (entries: ResizeObserverEntry[]) => void; }>; +type MeasurementProps = Readonly<{ + overflow?: unknown; + overflowX?: unknown; + overflowY?: unknown; + borderStyle?: unknown; + border?: unknown; + borderTop?: unknown; + borderRight?: unknown; + borderBottom?: unknown; + borderLeft?: unknown; + padding?: unknown; + paddingY?: unknown; + paddingTop?: unknown; + paddingBottom?: unknown; + scrollTop?: unknown; + scrollLeft?: unknown; + flexDirection?: unknown; + height?: unknown; +}>; const ZERO_LAYOUT: HostLayoutRect = Object.freeze({ x: 0, y: 0, width: 0, height: 0 }); const ZERO_INSETS = Object.freeze({ top: 0, right: 0, bottom: 0, left: 0 }); @@ -46,33 +65,33 @@ function normalizeOverflow(v: unknown): "visible" | "hidden" | "scroll" { return v === "hidden" || v === "scroll" ? v : "visible"; } -function readOverflowFromProps(props: Record): Readonly<{ +function readOverflowFromProps(props: MeasurementProps): Readonly<{ overflow: "visible" | "hidden" | "scroll"; overflowX: "visible" | "hidden" | "scroll"; overflowY: "visible" | "hidden" | "scroll"; }> { - const overflow = normalizeOverflow(props["overflow"]); - const overflowX = normalizeOverflow(props["overflowX"] ?? overflow); - const overflowY = normalizeOverflow(props["overflowY"] ?? overflow); + const overflow = normalizeOverflow(props.overflow); + const overflowX = normalizeOverflow(props.overflowX ?? overflow); + const overflowY = normalizeOverflow(props.overflowY ?? overflow); return { overflow, overflowX, overflowY }; } -function readBorderInsets(props: Record): Readonly<{ +function readBorderInsets(props: MeasurementProps): Readonly<{ top: number; right: number; bottom: number; left: number; }> { - const hasAnyBorder = props["borderStyle"] !== undefined || props["border"] !== undefined; - const borderStyle = props["borderStyle"] ?? props["border"]; + const hasAnyBorder = props.borderStyle !== undefined || props.border !== undefined; + const borderStyle = props.borderStyle ?? props.border; const hasBorder = hasAnyBorder && borderStyle !== "none"; if (!hasBorder) return ZERO_INSETS; - const top = typeof props["borderTop"] === "boolean" ? props["borderTop"] : true; - const right = typeof props["borderRight"] === "boolean" ? props["borderRight"] : true; - const bottom = typeof props["borderBottom"] === "boolean" ? props["borderBottom"] : true; - const left = typeof props["borderLeft"] === "boolean" ? props["borderLeft"] : true; + const top = typeof props.borderTop === "boolean" ? props.borderTop : true; + const right = typeof props.borderRight === "boolean" ? props.borderRight : true; + const bottom = typeof props.borderBottom === "boolean" ? props.borderBottom : true; + const left = typeof props.borderLeft === "boolean" ? props.borderLeft : true; return { top: top ? 1 : 0, right: right ? 1 : 0, @@ -81,11 +100,11 @@ function readBorderInsets(props: Record): Readonly<{ }; } -function resolveVerticalInsets(props: Record): number { - const padding = coerceNonNegativeNumber(props["padding"]) ?? 0; - const paddingY = coerceNonNegativeNumber(props["paddingY"]) ?? padding; - const paddingTop = coerceNonNegativeNumber(props["paddingTop"]) ?? paddingY; - const paddingBottom = coerceNonNegativeNumber(props["paddingBottom"]) ?? paddingY; +function resolveVerticalInsets(props: MeasurementProps): number { + const padding = coerceNonNegativeNumber(props.padding) ?? 0; + const paddingY = coerceNonNegativeNumber(props.paddingY) ?? padding; + const paddingTop = coerceNonNegativeNumber(props.paddingTop) ?? paddingY; + const paddingBottom = coerceNonNegativeNumber(props.paddingBottom) ?? paddingY; const border = readBorderInsets(props); return paddingTop + paddingBottom + border.top + border.bottom; } @@ -102,15 +121,17 @@ function estimateTextNodeHeight(node: HostElement): number { function estimateNodeHeight(node: HostNode): number { if (node.kind === "text") return Math.max(1, node.text.split("\n").length); - const explicitHeight = coerceNonNegativeNumber((node.props as { height?: unknown }).height); + const props = node.props as MeasurementProps; + const explicitHeight = coerceNonNegativeNumber(props.height); if (explicitHeight !== undefined) return explicitHeight; if (node.type === "ink-spacer") return 1; - if (node.type === "ink-text" || node.type === "ink-virtual-text") return estimateTextNodeHeight(node); + if (node.type === "ink-text" || node.type === "ink-virtual-text") + return estimateTextNodeHeight(node); - const direction = (node.props as { flexDirection?: unknown }).flexDirection; + const direction = props.flexDirection; const isColumn = direction === "column" || direction === "column-reverse"; - const insets = resolveVerticalInsets(node.props); + const insets = resolveVerticalInsets(props); if (node.children.length === 0) return insets; @@ -131,11 +152,12 @@ function estimateNodeHeight(node: HostNode): number { } function estimateScrollHeight(node: HostElement): number { - if (node.children.length === 0) return resolveVerticalInsets(node.props); + const props = node.props as MeasurementProps; + if (node.children.length === 0) return resolveVerticalInsets(props); - const direction = (node.props as { flexDirection?: unknown }).flexDirection; + const direction = props.flexDirection; const isColumn = direction === "column" || direction === "column-reverse"; - const insets = resolveVerticalInsets(node.props); + const insets = resolveVerticalInsets(props); if (isColumn) { let total = insets; @@ -181,7 +203,7 @@ export function getInnerHeight(node: DOMElement): number { if (scrollState) return scrollState.clientHeight; const layout = readLayout(node); - const border = readBorderInsets(element.props); + const border = readBorderInsets(element.props as MeasurementProps); return Math.max(0, layout.height - border.top - border.bottom); } @@ -213,6 +235,7 @@ function updateNodeLayout( idRects: ReadonlyMap, observerBatches: Map, ): HostLayoutRect { + const props = node.props as MeasurementProps; let layout = node.type === "ink-box" ? toLayout(idRects.get(node.internal_id)) : ZERO_LAYOUT; let haveChildBounds = false; @@ -221,7 +244,7 @@ function updateNodeLayout( let maxChildX = 0; let maxChildY = 0; - const borderInsets = readBorderInsets(node.props); + const borderInsets = readBorderInsets(props); const contentOriginX = layout.x + borderInsets.left; const contentOriginY = layout.y + borderInsets.top; let maxRelRight = 0; @@ -264,14 +287,14 @@ function updateNodeLayout( node.internal_layout = layout; - const overflow = readOverflowFromProps(node.props); + const overflow = readOverflowFromProps(props); const clientWidth = Math.max(0, layout.width - borderInsets.left - borderInsets.right); const clientHeight = Math.max(0, layout.height - borderInsets.top - borderInsets.bottom); let scrollHeight = Math.max(clientHeight, maxRelBottom); let scrollWidth = Math.max(clientWidth, maxRelRight); - const rawScrollTop = readScrollOffset(node.props["scrollTop"]); - const rawScrollLeft = readScrollOffset(node.props["scrollLeft"]); + const rawScrollTop = readScrollOffset(props.scrollTop); + const rawScrollLeft = readScrollOffset(props.scrollLeft); if (overflow.overflowY === "scroll") { const estimated = estimateScrollHeight(node); diff --git a/packages/ink-compat/src/reconciler/convert.ts b/packages/ink-compat/src/reconciler/convert.ts index de79d7e0..879aec27 100644 --- a/packages/ink-compat/src/reconciler/convert.ts +++ b/packages/ink-compat/src/reconciler/convert.ts @@ -8,6 +8,16 @@ import type { HostElement, HostNode, HostRoot } from "./types.js"; type ConvertCtx = Readonly<{ staticVNodes: VNode[] }>; type ConvertedChild = Readonly<{ vnode: VNode; estimatedHeight: number }>; +type LayoutSizingProps = Readonly<{ + padding?: unknown; + paddingY?: unknown; + paddingTop?: unknown; + paddingBottom?: unknown; + borderStyle?: unknown; + border?: unknown; + borderTop?: unknown; + borderBottom?: unknown; +}>; function coerceNonNegativeNumber(v: unknown): number | undefined { if (typeof v === "number" && Number.isFinite(v) && v >= 0) return v; @@ -18,18 +28,17 @@ function coerceNonNegativeNumber(v: unknown): number | undefined { return undefined; } -function resolveVerticalInsets(props: Record): number { - const padding = coerceNonNegativeNumber(props["padding"]) ?? 0; - const paddingY = coerceNonNegativeNumber(props["paddingY"]) ?? padding; - const paddingTop = coerceNonNegativeNumber(props["paddingTop"]) ?? paddingY; - const paddingBottom = coerceNonNegativeNumber(props["paddingBottom"]) ?? paddingY; +function resolveVerticalInsets(props: LayoutSizingProps): number { + const padding = coerceNonNegativeNumber(props.padding) ?? 0; + const paddingY = coerceNonNegativeNumber(props.paddingY) ?? padding; + const paddingTop = coerceNonNegativeNumber(props.paddingTop) ?? paddingY; + const paddingBottom = coerceNonNegativeNumber(props.paddingBottom) ?? paddingY; - const hasAnyBorder = props["borderStyle"] !== undefined || props["border"] !== undefined; - const borderStyle = props["borderStyle"] ?? props["border"]; + const hasAnyBorder = props.borderStyle !== undefined || props.border !== undefined; + const borderStyle = props.borderStyle ?? props.border; const hasBorder = hasAnyBorder && borderStyle !== "none"; - const borderTop = typeof props["borderTop"] === "boolean" ? props["borderTop"] : hasBorder; - const borderBottom = - typeof props["borderBottom"] === "boolean" ? props["borderBottom"] : hasBorder; + const borderTop = typeof props.borderTop === "boolean" ? props.borderTop : hasBorder; + const borderBottom = typeof props.borderBottom === "boolean" ? props.borderBottom : hasBorder; return paddingTop + paddingBottom + (borderTop ? 1 : 0) + (borderBottom ? 1 : 0); } @@ -56,7 +65,7 @@ function estimateNodeHeight(node: HostNode): number { const direction = (node.props as { flexDirection?: unknown }).flexDirection; const isColumn = direction === "column" || direction === "column-reverse"; - const insets = resolveVerticalInsets(node.props); + const insets = resolveVerticalInsets(node.props as LayoutSizingProps); if (node.children.length === 0) return insets;