From a8d02190144912fe4e157062ec6a8ebd1a8f4d90 Mon Sep 17 00:00:00 2001 From: Van Nguyen Date: Fri, 14 Aug 2026 21:40:07 +0700 Subject: [PATCH 01/15] fix(deps): move dev-only deps out of root dependencies and patch nanoid The `npm audit --omit=dev --audit-level=high` gate in quality-gates.yml started failing on GHSA-2v37-7h3g-55p8 (nanoid <3.3.18) without any code change on develop. nanoid reaches the tree only through vue -> @vue/compiler-sfc -> postcss, which is build-time only. Two things were wrong: - The root is private and publishes nothing, so react, react-dom and vue belong in devDependencies. They exist purely to resolve the packages' peers while building and testing, and keeping them in `dependencies` is what put vue's build-time tree in front of an `--omit=dev` audit. - nanoid was left vulnerable in the toolchain we actually run. Pin it forward with an override so it is patched, not merely excluded from the audit's scope. All four packages declare `dependencies: {}`, and no workflow installs with --omit=dev or --production, so nothing else changes resolution. Lockfile regenerated with npm@10, not the locally installed npm 11 which prunes other platforms' optional binaries: entry count is unchanged at 1606 with no additions or removals, and all 100 linux entries intact. --- package-lock.json | 87 ++++++++++++++++------------------------------- package.json | 15 +++++--- 2 files changed, 40 insertions(+), 62 deletions(-) diff --git a/package-lock.json b/package-lock.json index 32be7b4..4a39fea 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,10 +12,7 @@ "dependencies": { "@dynamic-field-kit/core": "workspace:*", "@dynamic-field-kit/react": "workspace:*", - "@dynamic-field-kit/vue": "workspace:*", - "react": "^19.2.4", - "react-dom": "^19.2.4", - "vue": "^3.5.32" + "@dynamic-field-kit/vue": "workspace:*" }, "devDependencies": { "@changesets/cli": "^2.31.1", @@ -33,9 +30,12 @@ "husky": "^9.1.7", "lint-staged": "^17.2.0", "prettier": "^2.8.8", + "react": "^19.2.4", + "react-dom": "^19.2.4", "tsup": "^8.0.1", "typescript": "~5.5.0", - "vitest": "^1.6.0" + "vitest": "^1.6.0", + "vue": "^3.5.32" } }, "node_modules/@adobe/css-tools": { @@ -65,7 +65,6 @@ "integrity": "sha512-I2YW4Zn1818toEGKPPcbv8qqglegNnWT4A4GM28LPRg4rOYwPkZzy3PAB7EIJEwZIDQgz2I+Oy0pc56BWcFVUw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@angular-devkit/core": "19.2.27", "rxjs": "7.8.1" @@ -99,7 +98,6 @@ "integrity": "sha512-63dLzZeRAZiDOWegZHDG1L3Su4m7tfh2n4uV4fXhJYQeyblAQIrmz/dsyjn2cwR021neDbteLCn7MeysKj47/g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@ampproject/remapping": "2.3.0", "@angular-devkit/architect": "0.1902.27", @@ -1098,7 +1096,6 @@ "integrity": "sha512-kMcuTUxDcnTa+JF9SaxdY+b6pkGjtefxGp/VYblSbvlzQ1rVX4wH4V9LHHyXsTOxRRNGqRJ01g0Enhq1iA19mQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@ampproject/remapping": "2.3.0", "@angular-devkit/architect": "0.1902.27", @@ -2138,7 +2135,6 @@ "integrity": "sha512-3ToiC1xZ1Y8aU7+CkgCI/tqyuPXEmYGJXO7H4uqp0xkLXUqp88rQQ4j1HmP37xSJLbCJPaIiv+cT1y+grssrww==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "chokidar": "^4.0.0", "immutable": "^5.0.2", @@ -2755,7 +2751,6 @@ "integrity": "sha512-1M3W3FjUUbVKXDMs+yQpBhnkD/pCe0Jn79rPE5W+EGWWxFoLSyGX+fhnRO5m4c9k66p3nvYrikWQ0ZzMv3M5tw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -2780,7 +2775,6 @@ "integrity": "sha512-LvjE8W58EACgTFaAoqmNe7FRsbvoQ0GvCB/rmm6AEMWx/0W/JBvWkQTrOQlwpoeYOHcMZRGdmPcZoUDwU3JySQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -2794,7 +2788,6 @@ "integrity": "sha512-tYYQk8AUz2sEkVl0a3uebduDUXPuKiGEKl2Jryrbn0xh9i1EsxoCjt1VvHnGnksGp3mz4DQihFVEnte0KeVQ5g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/core": "7.26.9", "@jridgewell/sourcemap-codec": "^1.4.14", @@ -2845,7 +2838,6 @@ "integrity": "sha512-pxzQh8ouqfE57lJlXjIzXFuRETwkfMVwS+NFCfv2yh01Qtx+vymO8ZClcJMgLPfBYinhBYX+hrRYVSa1nzlkRQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -2896,7 +2888,6 @@ "integrity": "sha512-O9ZoQKILPC1T2c64OASS75XlOLBxY81m5AAgsBKhwiFWq+V28RsO0cnwpi1YSh/z4ryH8Fe7IUFz8jGrsJi3hQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -2992,7 +2983,6 @@ "integrity": "sha512-lWBYIrF7qK5+GjY5Uy+/hEgp8OJWOD/rpy74GplYRhEauvbHDeFB8t5hPOZxCZ0Oxf4Cc36tK51/l3ymJysrKw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.26.2", @@ -5525,7 +5515,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -5549,7 +5538,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -7636,6 +7624,7 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -7657,6 +7646,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -7678,6 +7668,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -7699,6 +7690,7 @@ "os": [ "freebsd" ], + "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -7720,6 +7712,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -7741,6 +7734,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -7762,6 +7756,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -7783,6 +7778,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -7804,6 +7800,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -7825,6 +7822,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -7846,6 +7844,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -7867,6 +7866,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -7888,6 +7888,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -8415,7 +8416,6 @@ "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", @@ -8677,8 +8677,7 @@ "resolved": "https://registry.npmjs.org/@types/chai/-/chai-4.3.20.tgz", "integrity": "sha512-/pC9HAB5I/xMlc5FP77qjCnI16ChlJfW0tGa0IUcFn38VJrTV6DeZ60NU5KZBtaOZqjdpwTWohz5HU1RrhiYxQ==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/chai-subset": { "version": "1.3.6", @@ -8810,7 +8809,6 @@ "integrity": "sha512-grSwveyouVpXDwUvnpIb5noOpZQGOzbVdZXdjw8P9WOjnrUenKj2YuIh35OpXQ+UCmMQEgyvRobT5uuK9iDCUQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -8845,7 +8843,6 @@ "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -8982,7 +8979,6 @@ "integrity": "sha512-VlJEV0fOQ7BExOsHYAGrgbEiZoi8D+Bl2+f6V2RrXerRSylnp+ZBHmPvaIa8cz0Ajx7WO7Z5RqfgYg7ED1nRhA==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "5.62.0", "@typescript-eslint/types": "5.62.0", @@ -9738,7 +9734,6 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -9854,7 +9849,6 @@ "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -10694,7 +10688,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -11025,7 +11018,6 @@ "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "readdirp": "^4.0.1" }, @@ -11653,7 +11645,6 @@ "integrity": "sha512-gtTZxTDau1wL7Y7zifc2dd8jHSK/k6BTx/2Xp/BpdlAdnlYWFVt7qhJqgwi7637yRwRQ3qL4ZidbB4I8tA5VOg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", @@ -12633,7 +12624,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -12719,7 +12709,6 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -12913,7 +12902,6 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -15439,7 +15427,6 @@ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "dev": true, "license": "MIT", - "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -15730,7 +15717,6 @@ "integrity": "sha512-OJmO5+HxZLLw0RLzkqaNHzcgEAQG7C0y3aMbwtCzIUFZsLMNNq/1IdAdHEycQ58CwUO3jPTHmoN+tE5I7FQxNg==", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "copy-anything": "^3.0.5", "parse-node-version": "^1.0.1" @@ -15785,6 +15771,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "pify": "^4.0.1", "semver": "^5.6.0" @@ -15800,6 +15787,7 @@ "dev": true, "license": "ISC", "optional": true, + "peer": true, "bin": { "semver": "bin/semver" } @@ -15811,6 +15799,7 @@ "dev": true, "license": "BSD-3-Clause", "optional": true, + "peer": true, "engines": { "node": ">=0.10.0" } @@ -16905,9 +16894,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.17", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.17.tgz", - "integrity": "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==", + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", "funding": [ { "type": "github", @@ -17898,7 +17887,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.16", "picocolors": "^1.1.1", @@ -18090,7 +18078,6 @@ "integrity": "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==", "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin-prettier.js" }, @@ -18337,8 +18324,8 @@ "version": "19.2.4", "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", + "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -18347,8 +18334,8 @@ "version": "19.2.4", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", + "dev": true, "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -18777,7 +18764,6 @@ "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -18867,7 +18853,6 @@ "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "tslib": "^2.1.0" } @@ -18968,7 +18953,6 @@ "integrity": "sha512-kgW13M54DUB7IsIRM5LvJkNlpH+WhMpooUcaWGFARkF1Tc82v9mIWkCbCYf+MBvpIUBSeSOTilpZjEPr2VYE6Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "chokidar": "^4.0.0", "immutable": "^5.1.5", @@ -19053,6 +19037,7 @@ "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "dev": true, "license": "MIT" }, "node_modules/schema-utils": { @@ -20130,7 +20115,6 @@ "integrity": "sha512-LBAhFyLho16harJoWMg/nZsQYgTrg5jXOn2nCYjRUcZZEdE3qa2zb8QEDRUGVZBW4rlazf2fxkg8tztybTaqWw==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.8.2", @@ -20461,8 +20445,7 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", "dev": true, - "license": "0BSD", - "peer": true + "license": "0BSD" }, "node_modules/tsup": { "version": "8.5.1", @@ -20684,7 +20667,6 @@ "integrity": "sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -20969,7 +20951,6 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", @@ -21490,7 +21471,6 @@ "integrity": "sha512-Ljb1cnSJSivGN0LqXd/zmDbWEM0RNNg2t1QW/XUhYl/qPqyu7CsqeWtqQXHVaJsecLPuDoak2oJcZN2QoRIOag==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/expect": "1.6.1", "@vitest/runner": "1.6.1", @@ -21563,7 +21543,6 @@ "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.32.tgz", "integrity": "sha512-vM4z4Q9tTafVfMAK7IVzmxg34rSzTFMyIe0UUEijUCkn9+23lj0WRfA83dg7eQZIUlgOSGrkViIaCfqSAUXsMw==", "license": "MIT", - "peer": true, "dependencies": { "@vue/compiler-dom": "3.5.32", "@vue/compiler-sfc": "3.5.32", @@ -21658,7 +21637,6 @@ "integrity": "sha512-gX/dMkRQc7QOMzgTe6KsYFM7DxeIONQSui1s0n/0xht36HvrgbxtM1xBlgx596NbpHuQU8P7QpKwrZYwUX48nw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", @@ -21738,7 +21716,6 @@ "integrity": "sha512-QcQ72gh8a+7JO63TAx/6XZf/CWhgMzu5m0QirvPfGvptOusAxG12w2+aua1Jkjr7hzaWDnJ2n6JFeexMHI+Zjg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/bonjour": "^3.5.13", "@types/connect-history-api-fallback": "^1.5.4", @@ -22424,8 +22401,7 @@ "resolved": "https://registry.npmjs.org/zone.js/-/zone.js-0.15.1.tgz", "integrity": "sha512-XE96n56IQpJM7NAoXswY3XRLcWFW83xe0BiAOeMD7K5k5xecOeul3Qcpx6GqEeeHNkW5DWL5zOyTbEfB4eti8w==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "packages/angular": { "name": "@dynamic-field-kit/angular", @@ -22466,7 +22442,6 @@ "integrity": "sha512-FhgT3MwDIXu8vzrQHFlQnwAOFENQY8FlIHkQf4WnJc6km4L0uUqQnm7iQPmX5z6dFFDTIsPDwHRDc73CoUaNVA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ts-morph": "^21.0.0", "vfile": "^6.0.3" @@ -23168,7 +23143,6 @@ "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -23477,7 +23451,6 @@ "integrity": "sha512-+5CALsOvbNKnS+ZHMXtuUC7nL8/7F1F2DnHGjSsszX8zCjWSSviphCb/NuS9Nzf4Q03KyyDRBAXhF/8lffME4Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/chai": "^4.3.5", "@types/chai-subset": "^1.3.3", diff --git a/package.json b/package.json index 2aafb23..a764dd9 100644 --- a/package.json +++ b/package.json @@ -41,16 +41,21 @@ "lint-staged": "^17.2.0", "prettier": "^2.8.8", "tsup": "^8.0.1", + "react": "^19.2.4", + "react-dom": "^19.2.4", "typescript": "~5.5.0", - "vitest": "^1.6.0" + "vitest": "^1.6.0", + "vue": "^3.5.32" }, + "//dependencies": "the root is private and ships nothing, so react/react-dom/vue belong in devDependencies - they exist only to resolve the packages' peers while building and testing. Keeping them here is what made `npm audit --omit=dev` (the CI gate) fail on advisories in vue's build-time tree, e.g. @vue/compiler-sfc -> postcss -> nanoid.", "dependencies": { "@dynamic-field-kit/core": "workspace:*", "@dynamic-field-kit/react": "workspace:*", - "@dynamic-field-kit/vue": "workspace:*", - "react": "^19.2.4", - "react-dom": "^19.2.4", - "vue": "^3.5.32" + "@dynamic-field-kit/vue": "workspace:*" + }, + "//overrides": "nanoid <3.3.18 is GHSA-2v37-7h3g-55p8. It reaches us only through vue -> @vue/compiler-sfc -> postcss (build-time), but pin it forward so the toolchain we actually run is patched, not merely excluded from the audit.", + "overrides": { + "nanoid": "^3.3.18" }, "//lint-staged": "globs must stay a superset of what `prettier --check .` sees, or a file can pass pre-commit and fail CI format-check. eslint only runs on the extensions the lint script targets.", "lint-staged": { From 19f7701589da58cfd4653ebace36be8e412b9168 Mon Sep 17 00:00:00 2001 From: Van Nguyen Date: Fri, 14 Aug 2026 21:40:52 +0700 Subject: [PATCH 02/15] fix(scripts): report bundle sizes from package entry points show-sizes.js guessed formats from file extensions, assuming index.js is CJS and index.mjs is ESM. That holds for core and react but inverts for vue, which is `"type": "module"` and so ships ESM as dist/index.js and CommonJS as dist/index.cjs. The report therefore printed vue's ESM bundle under a CJS label and never printed its real CJS bundle at all. Angular was missing entirely, having never been added to the list. Resolve each entry from the package's own manifest instead (exports['.'].import/require, falling back to module/main), which also handles ng-packagr's ESM-only output where main and module point at the same fesm2022 bundle -- reporting that file twice would invent a CJS build that is not shipped. Before After vue (CJS): 36.31 KB vue (ESM): 36.31 KB (no vue ESM) vue (CJS): 39.37 KB (no angular) angular (ESM): 54.47 KB The collector is now exported and covered by scripts/show-sizes.test.js, which builds throwaway workspaces on a real filesystem rather than mocking fs, plus two regression tests against the repo's own packages. Root vitest.config.mjs scopes that suite to scripts/** so it cannot touch the per-package coverage floors, and quality-gates.yml runs it after the build step, where a dist exists for those two tests to read. --- .github/workflows/quality-gates.yml | 5 + package.json | 1 + scripts/show-sizes.js | 88 ++++++++++++---- scripts/show-sizes.test.js | 149 ++++++++++++++++++++++++++++ vitest.config.mjs | 11 ++ 5 files changed, 237 insertions(+), 17 deletions(-) create mode 100644 scripts/show-sizes.test.js create mode 100644 vitest.config.mjs diff --git a/.github/workflows/quality-gates.yml b/.github/workflows/quality-gates.yml index f937858..69bcd8e 100644 --- a/.github/workflows/quality-gates.yml +++ b/.github/workflows/quality-gates.yml @@ -55,6 +55,11 @@ jobs: - name: Type tests run: npm run test:types --workspace=@dynamic-field-kit/core + # Runs after the build so the suite's "every package reports a size" + # assertion has a dist to look at. + - name: Test build scripts + run: npm run test:scripts + - name: Show bundle sizes run: node scripts/show-sizes.js diff --git a/package.json b/package.json index a764dd9..9aba611 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "build:changed": "node scripts/build-changed.js", "dev": "npm run dev --workspaces", "test": "npm run test --workspaces --if-present", + "test:scripts": "vitest run", "lint": "eslint packages/core/src packages/react/src packages/vue/src packages/angular/src --ext .ts,.tsx,.js --ignore-path .eslintignore", "lint:cross-framework": "node scripts/check-cross-framework-imports.js", "verify-framework-deps": "node scripts/verify-framework-deps.js", diff --git a/scripts/show-sizes.js b/scripts/show-sizes.js index 90f0273..932b517 100644 --- a/scripts/show-sizes.js +++ b/scripts/show-sizes.js @@ -1,29 +1,83 @@ const fs = require('fs'); const path = require('path'); -const root = process.cwd(); -const packages = ['core', 'react', 'vue']; +const DEFAULT_PACKAGES = ['core', 'react', 'vue', 'angular']; -console.log('=== Bundle Sizes ===\n'); +const stripDotSlash = (entry) => entry.replace(/^\.\//, ''); -for (const pkg of packages) { - const distPath = path.join(root, 'packages', pkg, 'dist'); +/** + * Resolves a package's ESM and CJS entry points from its own manifest rather + * than guessing from file extensions. The `.js`/`.mjs` pair that tsup emits for + * core and react inverts for vue, which is `"type": "module"` and so ships ESM + * as `.js` and CommonJS as `.cjs`. + */ +function entryPoints(pkgJson) { + const conditions = (pkgJson.exports && pkgJson.exports['.']) || {}; + const esm = conditions.import || pkgJson.module; + const cjs = conditions.require || pkgJson.main; - if (!fs.existsSync(distPath)) { - console.log(`${pkg}: dist not found`); - continue; - } + // ng-packagr ships ESM only and points both `main` and `module` at the same + // fesm2022 bundle, so treating `main` as CJS there would invent a bundle. + const hasDistinctCjs = + cjs && (!esm || stripDotSlash(cjs) !== stripDotSlash(esm)); + + return { esm, cjs: hasDistinctCjs ? cjs : null }; +} + +function collectBundleSizes(root, packages = DEFAULT_PACKAGES) { + const results = []; + + for (const pkg of packages) { + const pkgDir = path.join(root, 'packages', pkg); + const manifest = path.join(pkgDir, 'package.json'); + + if (!fs.existsSync(manifest)) { + results.push({ pkg, format: null, bytes: null, missing: true }); + continue; + } + + const { esm, cjs } = entryPoints( + JSON.parse(fs.readFileSync(manifest, 'utf8')) + ); + const found = []; - const mainFile = path.join(distPath, 'index.js'); - const esmFile = path.join(distPath, 'index.mjs'); + for (const [format, entry] of [ + ['ESM', esm], + ['CJS', cjs], + ]) { + if (!entry) { + continue; + } - if (fs.existsSync(mainFile)) { - const size = fs.statSync(mainFile).size; - console.log(`${pkg} (CJS): ${(size / 1024).toFixed(2)} KB`); + const file = path.join(pkgDir, stripDotSlash(entry)); + if (fs.existsSync(file)) { + found.push({ pkg, format, bytes: fs.statSync(file).size }); + } + } + + if (found.length === 0) { + results.push({ pkg, format: null, bytes: null, missing: true }); + } else { + results.push(...found); + } } - if (fs.existsSync(esmFile)) { - const size = fs.statSync(esmFile).size; - console.log(`${pkg} (ESM): ${(size / 1024).toFixed(2)} KB`); + return results; +} + +function formatSizes(sizes) { + return sizes.map((entry) => + entry.missing + ? `${entry.pkg}: not built` + : `${entry.pkg} (${entry.format}): ${(entry.bytes / 1024).toFixed(2)} KB` + ); +} + +if (require.main === module) { + console.log('=== Bundle Sizes ===\n'); + for (const line of formatSizes(collectBundleSizes(process.cwd()))) { + console.log(line); } } + +module.exports = { collectBundleSizes, formatSizes, DEFAULT_PACKAGES }; diff --git a/scripts/show-sizes.test.js b/scripts/show-sizes.test.js new file mode 100644 index 0000000..5bd93a8 --- /dev/null +++ b/scripts/show-sizes.test.js @@ -0,0 +1,149 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { collectBundleSizes } from './show-sizes.js'; + +const tempRoots = []; + +/** + * Builds a throwaway workspace root containing one package, so the collector + * runs against a real filesystem instead of a mocked one. + */ +function makeWorkspace(pkg, packageJson, distFiles) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'show-sizes-')); + tempRoots.push(root); + + const pkgDir = path.join(root, 'packages', pkg); + fs.mkdirSync(pkgDir, { recursive: true }); + fs.writeFileSync( + path.join(pkgDir, 'package.json'), + JSON.stringify(packageJson) + ); + + for (const [relative, contents] of Object.entries(distFiles)) { + const file = path.join(pkgDir, relative); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, contents); + } + + return root; +} + +afterEach(() => { + while (tempRoots.length) { + fs.rmSync(tempRoots.pop(), { recursive: true, force: true }); + } +}); + +describe('collectBundleSizes', () => { + it('labels a "type: module" package by its exports map, not by file extension', () => { + // The vue package's shape: `.js` is the ESM build and `.cjs` is CommonJS, + // the opposite of the `.js`/`.mjs` convention core and react use. + const root = makeWorkspace( + 'vue', + { + name: '@dynamic-field-kit/vue', + type: 'module', + main: './dist/index.cjs', + module: './dist/index.js', + exports: { + '.': { + types: './dist/index.d.ts', + import: './dist/index.js', + require: './dist/index.cjs', + }, + }, + }, + { + 'dist/index.js': 'e'.repeat(2048), + 'dist/index.cjs': 'c'.repeat(1024), + } + ); + + expect(collectBundleSizes(root, ['vue'])).toEqual([ + { pkg: 'vue', format: 'ESM', bytes: 2048 }, + { pkg: 'vue', format: 'CJS', bytes: 1024 }, + ]); + }); + + it('labels a `.js`/`.mjs` package correctly too', () => { + const root = makeWorkspace( + 'core', + { + name: '@dynamic-field-kit/core', + main: 'dist/index.js', + module: 'dist/index.mjs', + exports: { + '.': { + import: './dist/index.mjs', + require: './dist/index.js', + }, + }, + }, + { + 'dist/index.mjs': 'e'.repeat(512), + 'dist/index.js': 'c'.repeat(256), + } + ); + + expect(collectBundleSizes(root, ['core'])).toEqual([ + { pkg: 'core', format: 'ESM', bytes: 512 }, + { pkg: 'core', format: 'CJS', bytes: 256 }, + ]); + }); + + it('reports an ESM-only package once when main and module are the same file', () => { + // The angular package's shape: ng-packagr ships fesm2022 only, and points + // both `main` and `module` at it. Reporting that file as CJS would be a lie. + const root = makeWorkspace( + 'angular', + { + name: '@dynamic-field-kit/angular', + type: 'module', + main: 'dist/fesm2022/dynamic-field-kit-angular.mjs', + module: 'dist/fesm2022/dynamic-field-kit-angular.mjs', + }, + { 'dist/fesm2022/dynamic-field-kit-angular.mjs': 'a'.repeat(4096) } + ); + + expect(collectBundleSizes(root, ['angular'])).toEqual([ + { pkg: 'angular', format: 'ESM', bytes: 4096 }, + ]); + }); + + it('marks a package as unbuilt when its entry file is missing', () => { + const root = makeWorkspace( + 'core', + { + name: '@dynamic-field-kit/core', + main: 'dist/index.js', + module: 'dist/index.mjs', + }, + {} + ); + + expect(collectBundleSizes(root, ['core'])).toEqual([ + { pkg: 'core', format: null, bytes: null, missing: true }, + ]); + }); + + it('covers every published package by default', () => { + const root = path.resolve(__dirname, '..'); + const reported = new Set( + collectBundleSizes(root).map((entry) => entry.pkg) + ); + + expect([...reported].sort()).toEqual(['angular', 'core', 'react', 'vue']); + }); + + it('resolves the real vue package entries to the right formats', () => { + const root = path.resolve(__dirname, '..'); + const vue = collectBundleSizes(root, ['vue']); + + // Regression guard: `dist/index.js` in the vue package is ESM. An + // extension-based guess reports it as CJS and never reports `dist/index.cjs`. + expect(vue.map((entry) => entry.format)).toEqual(['ESM', 'CJS']); + }); +}); diff --git a/vitest.config.mjs b/vitest.config.mjs new file mode 100644 index 0000000..ff50d9f --- /dev/null +++ b/vitest.config.mjs @@ -0,0 +1,11 @@ +import { defineConfig } from 'vitest/config'; + +// Root-level suite for the repo's own build/CI scripts in `scripts/`. The four +// packages each run their own vitest with their own config and coverage floor; +// this one deliberately covers nothing but `scripts/**`. +export default defineConfig({ + test: { + include: ['scripts/**/*.test.js'], + environment: 'node', + }, +}); From 660f68d54990105600ac597c0d8a7ae4aca5a695 Mon Sep 17 00:00:00 2001 From: Van Nguyen Date: Fri, 14 Aug 2026 21:45:10 +0700 Subject: [PATCH 03/15] chore: ignore vite's transient config bundle Running the angular suite locally leaves an orphaned packages/angular/vitest.config.ts.timestamp--.mjs behind when vitest does not get to clean it up, which on Windows is often. The file was ignored by neither .gitignore nor .prettierignore, so it showed up as untracked and, because `prettier --check .` walks untracked files, turned the next format-check red for reasons unrelated to any change. CI never hit this because format-check runs before any test, so the artifact only ever bit local runs and risked being committed by accident. --- .gitignore | 3 +++ .prettierignore | 6 ++++++ 2 files changed, 9 insertions(+) diff --git a/.gitignore b/.gitignore index 6cc2f4c..3e603fe 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,9 @@ node_modules/ packages/*/dist dist/ +# vite's transient config bundle, left behind when a vitest run is interrupted +*.timestamp-*.mjs + # test coverage packages/*/coverage coverage/ diff --git a/.prettierignore b/.prettierignore index 7ea4d6b..c2a57a0 100644 --- a/.prettierignore +++ b/.prettierignore @@ -7,6 +7,12 @@ build/ out/ coverage/ +# vite's transient config bundle, left behind when a vitest run is interrupted. +# Must stay ignored here as well as in .gitignore: `prettier --check .` walks +# untracked files too, so an orphaned one fails format-check after a local +# `npm run test --workspace=@dynamic-field-kit/angular`. +*.timestamp-*.mjs + # Framework caches .angular/ .next/ From 1981442542a07f23ade4fd6104347caf3a1c3dc8 Mon Sep 17 00:00:00 2001 From: Van Nguyen Date: Fri, 14 Aug 2026 21:55:40 +0700 Subject: [PATCH 04/15] fix(vue): dev-install vue instead of borrowing it from the root packages/vue peer-depends on vue but never declared it in devDependencies, so its build, typecheck and test runs resolved vue only because the workspace root happened to hoist one. packages/react and packages/angular already dev-install their own peers; vue was the outlier, and it became load-bearing on the root's declaration right as that root entry moved to devDependencies. Extend verify-framework-deps.js, which already gates this class of manifest drift in CI, with the rule that every peer must also appear in devDependencies. The script now exports its check so it can be tested, and scripts/verify-framework-deps.test.js covers both rules against throwaway workspaces plus the repo's real manifests. Running the new rule against this repo reported exactly one violation, which is the one fixed here: vue: peer-depends on vue but does not declare it in devDependencies, so it builds and tests against whatever the workspace root happens to hoist Lockfile regenerated with npm@10: 1606 entries unchanged with nothing added or removed, all 100 linux entries intact. The diff is `"dev": true` markers appearing on vue's transitive tree, which is now dev-only everywhere it is reachable. No changeset: devDependencies are not installed by consumers, so no published package changes behaviour or its dependency contract. --- package-lock.json | 28 ++++++- packages/vue/package.json | 3 +- scripts/verify-framework-deps.js | 84 ++++++++++++------- scripts/verify-framework-deps.test.js | 116 ++++++++++++++++++++++++++ 4 files changed, 198 insertions(+), 33 deletions(-) create mode 100644 scripts/verify-framework-deps.test.js diff --git a/package-lock.json b/package-lock.json index 4a39fea..9842adf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3367,6 +3367,7 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -3376,6 +3377,7 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -3424,6 +3426,7 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, "license": "MIT", "dependencies": { "@babel/types": "^7.29.7" @@ -4666,6 +4669,7 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-string-parser": "^7.29.7", @@ -6409,6 +6413,7 @@ "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { @@ -9412,6 +9417,7 @@ "version": "3.5.32", "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.32.tgz", "integrity": "sha512-4x74Tbtqnda8s/NSD6e1Dr5p1c8HdMU5RWSjMSUzb8RTcUQqevDCxVAitcLBKT+ie3o0Dl9crc/S/opJM7qBGQ==", + "dev": true, "license": "MIT", "dependencies": { "@babel/parser": "^7.29.2", @@ -9425,6 +9431,7 @@ "version": "3.5.32", "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.32.tgz", "integrity": "sha512-ybHAu70NtiEI1fvAUz3oXZqkUYEe5J98GjMDpTGl5iHb0T15wQYLR4wE3h9xfuTNA+Cm2f4czfe8B4s+CCH57Q==", + "dev": true, "license": "MIT", "dependencies": { "@vue/compiler-core": "3.5.32", @@ -9435,6 +9442,7 @@ "version": "3.5.32", "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.32.tgz", "integrity": "sha512-8UYUYo71cP/0YHMO814TRZlPuUUw3oifHuMR7Wp9SNoRSrxRQnhMLNlCeaODNn6kNTJsjFoQ/kqIj4qGvya4Xg==", + "dev": true, "license": "MIT", "dependencies": { "@babel/parser": "^7.29.2", @@ -9452,6 +9460,7 @@ "version": "3.5.32", "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.32.tgz", "integrity": "sha512-Gp4gTs22T3DgRotZ8aA/6m2jMR+GMztvBXUBEUOYOcST+giyGWJ4WvFd7QLHBkzTxkfOt8IELKNdpzITLbA2rw==", + "dev": true, "license": "MIT", "dependencies": { "@vue/compiler-dom": "3.5.32", @@ -9462,6 +9471,7 @@ "version": "3.5.32", "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.32.tgz", "integrity": "sha512-/ORasxSGvZ6MN5gc+uE364SxFdJ0+WqVG0CENXaGW58TOCdrAW76WWaplDtECeS1qphvtBZtR+3/o1g1zL4xPQ==", + "dev": true, "license": "MIT", "dependencies": { "@vue/shared": "3.5.32" @@ -9471,6 +9481,7 @@ "version": "3.5.32", "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.32.tgz", "integrity": "sha512-pDrXCejn4UpFDFmMd27AcJEbHaLemaE5o4pbb7sLk79SRIhc6/t34BQA7SGNgYtbMnvbF/HHOftYBgFJtUoJUQ==", + "dev": true, "license": "MIT", "dependencies": { "@vue/reactivity": "3.5.32", @@ -9481,6 +9492,7 @@ "version": "3.5.32", "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.32.tgz", "integrity": "sha512-1CDVv7tv/IV13V8Nip1k/aaObVbWqRlVCVezTwx3K07p7Vxossp5JU1dcPNhJk3w347gonIUT9jQOGutyJrSVQ==", + "dev": true, "license": "MIT", "dependencies": { "@vue/reactivity": "3.5.32", @@ -9493,6 +9505,7 @@ "version": "3.5.32", "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.32.tgz", "integrity": "sha512-IOjm2+JQwRFS7W28HNuJeXQle9KdZbODFY7hFGVtnnghF51ta20EWAZJHX+zLGtsHhaU6uC9BGPV52KVpYryMQ==", + "dev": true, "license": "MIT", "dependencies": { "@vue/compiler-ssr": "3.5.32", @@ -9506,6 +9519,7 @@ "version": "3.5.32", "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.32.tgz", "integrity": "sha512-ksNyrmRQzWJJ8n3cRDuSF7zNNontuJg1YHnmWRJd2AMu8Ij2bqwiiri2lH5rHtYPZjj4STkNcgcmiQqlOjiYGg==", + "dev": true, "license": "MIT" }, "node_modules/@vue/test-utils": { @@ -11820,6 +11834,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, "license": "MIT" }, "node_modules/data-urls": { @@ -12394,6 +12409,7 @@ "version": "7.0.1", "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=0.12" @@ -13188,6 +13204,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true, "license": "MIT" }, "node_modules/esutils": { @@ -16384,6 +16401,7 @@ "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" @@ -16897,6 +16915,7 @@ "version": "3.3.18", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, "funding": [ { "type": "github", @@ -17732,6 +17751,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, "license": "ISC" }, "node_modules/picomatch": { @@ -17872,6 +17892,7 @@ "version": "8.5.25", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz", "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==", + "dev": true, "funding": [ { "type": "opencollective", @@ -19565,6 +19586,7 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -20665,7 +20687,7 @@ "version": "5.5.4", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz", "integrity": "sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -21542,6 +21564,7 @@ "version": "3.5.32", "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.32.tgz", "integrity": "sha512-vM4z4Q9tTafVfMAK7IVzmxg34rSzTFMyIe0UUEijUCkn9+23lj0WRfA83dg7eQZIUlgOSGrkViIaCfqSAUXsMw==", + "dev": true, "license": "MIT", "dependencies": { "@vue/compiler-dom": "3.5.32", @@ -23571,7 +23594,8 @@ "@vue/test-utils": "^2.4.6", "jsdom": "^24.0.0", "tsup": "^8.0.1", - "vitest": "^1.6.0" + "vitest": "^1.6.0", + "vue": "^3.5.32" }, "peerDependencies": { "@dynamic-field-kit/core": "^1.3.0", diff --git a/packages/vue/package.json b/packages/vue/package.json index cac7d8a..371a7c7 100644 --- a/packages/vue/package.json +++ b/packages/vue/package.json @@ -30,7 +30,8 @@ "@vue/test-utils": "^2.4.6", "jsdom": "^24.0.0", "tsup": "^8.0.1", - "vitest": "^1.6.0" + "vitest": "^1.6.0", + "vue": "^3.5.32" }, "exports": { ".": { diff --git a/scripts/verify-framework-deps.js b/scripts/verify-framework-deps.js index 5a4664a..02b580f 100644 --- a/scripts/verify-framework-deps.js +++ b/scripts/verify-framework-deps.js @@ -1,53 +1,77 @@ #!/usr/bin/env node -// Verify that each framework package (react, vue, angular) depends only on core -// and does not depend on the other framework packages. +// Verify that each framework package (react, vue, angular) depends only on core, +// and that it dev-installs every framework it peer-depends on. -const path = require('path'); const fs = require('fs'); +const path = require('path'); + +const FRAMEWORK_PACKAGES = ['react', 'vue', 'angular']; function readJson(p) { const data = fs.readFileSync(p, 'utf8'); return JSON.parse(data); } -const base = path.resolve(__dirname, '..'); -const pkgs = { - react: path.join(base, 'packages', 'react', 'package.json'), - vue: path.join(base, 'packages', 'vue', 'package.json'), - angular: path.join(base, 'packages', 'angular', 'package.json'), -}; - -let problems = []; - -function check(pkgName, pjson) { - const other = Object.keys(pkgs).filter((n) => n !== pkgName); +function crossFrameworkProblems(pkgName, pjson) { const deps = Object.assign( {}, pjson.dependencies || {}, pjson.peerDependencies || {}, pjson.devDependencies || {} ); - other.forEach((o) => { - const alias = `@dynamic-field-kit/${o}`; - if (deps && Object.prototype.hasOwnProperty.call(deps, alias)) { - problems.push( + + return FRAMEWORK_PACKAGES.filter((other) => other !== pkgName) + .map((other) => `@dynamic-field-kit/${other}`) + .filter((alias) => Object.prototype.hasOwnProperty.call(deps, alias)) + .map( + (alias) => `${pkgName}: depends on ${alias} (should depend only on core) (in dependencies/peer/dev)` - ); - } - }); + ); } -for (const [name, p] of Object.entries(pkgs)) { - const pjson = readJson(p); - check(name, pjson); +// A peer that is never dev-installed still resolves during a local build or +// test run, but only by borrowing whatever the workspace root happens to hoist. +// That makes the package's own manifest a lie about what it was verified +// against, and it breaks the moment the root stops declaring it. +function undeclaredPeerProblems(pkgName, pjson) { + const devDeps = pjson.devDependencies || {}; + + return Object.keys(pjson.peerDependencies || {}) + .filter((peer) => !Object.prototype.hasOwnProperty.call(devDeps, peer)) + .map( + (peer) => + `${pkgName}: peer-depends on ${peer} but does not declare it in devDependencies, ` + + `so it builds and tests against whatever the workspace root happens to hoist` + ); } -if (problems.length) { - console.error('Framework dependency issues found:'); - problems.forEach((m) => console.error(' -', m)); - process.exit(1); -} else { +function findFrameworkDepProblems(root = path.resolve(__dirname, '..')) { + const problems = []; + + for (const pkgName of FRAMEWORK_PACKAGES) { + const pjson = readJson( + path.join(root, 'packages', pkgName, 'package.json') + ); + + problems.push(...crossFrameworkProblems(pkgName, pjson)); + problems.push(...undeclaredPeerProblems(pkgName, pjson)); + } + + return problems; +} + +if (require.main === module) { + const problems = findFrameworkDepProblems(); + + if (problems.length) { + console.error('Framework dependency issues found:'); + problems.forEach((m) => console.error(' -', m)); + process.exit(1); + } + console.log( - 'OK: No cross-framework dependencies detected in react/vue/angular packages.' + 'OK: react/vue/angular depend only on core and dev-install their own peers.' ); } + +module.exports = { findFrameworkDepProblems, FRAMEWORK_PACKAGES }; diff --git a/scripts/verify-framework-deps.test.js b/scripts/verify-framework-deps.test.js new file mode 100644 index 0000000..e559409 --- /dev/null +++ b/scripts/verify-framework-deps.test.js @@ -0,0 +1,116 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { findFrameworkDepProblems } from './verify-framework-deps.js'; + +const tempRoots = []; + +const FRAMEWORKS = ['react', 'vue', 'angular']; + +/** A manifest that satisfies every rule, so each test can break one thing. */ +function healthyManifest(pkg, overrides = {}) { + return { + name: `@dynamic-field-kit/${pkg}`, + peerDependencies: { '@dynamic-field-kit/core': '^1.3.0' }, + devDependencies: { '@dynamic-field-kit/core': '^1.3.0' }, + ...overrides, + }; +} + +function makeWorkspace(manifests) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'framework-deps-')); + tempRoots.push(root); + + for (const pkg of FRAMEWORKS) { + const pkgDir = path.join(root, 'packages', pkg); + fs.mkdirSync(pkgDir, { recursive: true }); + fs.writeFileSync( + path.join(pkgDir, 'package.json'), + JSON.stringify(manifests[pkg] || healthyManifest(pkg)) + ); + } + + return root; +} + +afterEach(() => { + while (tempRoots.length) { + fs.rmSync(tempRoots.pop(), { recursive: true, force: true }); + } +}); + +describe('findFrameworkDepProblems', () => { + it('accepts packages that declare every peer in devDependencies', () => { + const root = makeWorkspace({}); + + expect(findFrameworkDepProblems(root)).toEqual([]); + }); + + it('flags a package that does not dev-install a framework it peer-depends on', () => { + // A package that peer-depends on vue but never dev-installs it can only be + // built and tested by borrowing vue from the workspace root. + const root = makeWorkspace({ + vue: healthyManifest('vue', { + peerDependencies: { + '@dynamic-field-kit/core': '^1.3.0', + vue: '^3.0.0', + }, + devDependencies: { '@dynamic-field-kit/core': '^1.3.0' }, + }), + }); + + expect(findFrameworkDepProblems(root)).toEqual([ + 'vue: peer-depends on vue but does not declare it in ' + + 'devDependencies, so it builds and tests against whatever the ' + + 'workspace root happens to hoist', + ]); + }); + + it('still flags a dependency on a sibling framework package', () => { + const root = makeWorkspace({ + react: healthyManifest('react', { + dependencies: { '@dynamic-field-kit/vue': '^1.4.0' }, + }), + }); + + expect(findFrameworkDepProblems(root)).toEqual([ + 'react: depends on @dynamic-field-kit/vue (should depend only on core) (in dependencies/peer/dev)', + ]); + }); + + it('reports both kinds of problem at once', () => { + const root = makeWorkspace({ + react: healthyManifest('react', { + peerDependencies: { + '@dynamic-field-kit/core': '^1.3.0', + '@dynamic-field-kit/angular': '^1.4.0', + react: '^19.0.0', + }, + devDependencies: { '@dynamic-field-kit/core': '^1.3.0' }, + }), + }); + + // One cross-framework dependency, plus two peers left out of + // devDependencies (@dynamic-field-kit/angular and react). The sibling + // package is genuinely both problems at once, so it is reported twice. + expect(findFrameworkDepProblems(root)).toHaveLength(3); + }); + + it('applies the devDependencies rule to the core peer too', () => { + const root = makeWorkspace({ + angular: healthyManifest('angular', { devDependencies: {} }), + }); + + expect(findFrameworkDepProblems(root)).toEqual([ + 'angular: peer-depends on @dynamic-field-kit/core but does not ' + + 'declare it in devDependencies, so it builds and tests against ' + + 'whatever the workspace root happens to hoist', + ]); + }); + + it('holds for the real packages in this repo', () => { + expect(findFrameworkDepProblems(path.resolve(__dirname, '..'))).toEqual([]); + }); +}); From abd535324b5a818b481796fa66c082f81ee2b892 Mon Sep 17 00:00:00 2001 From: Van Nguyen Date: Fri, 14 Aug 2026 22:06:01 +0700 Subject: [PATCH 05/15] fix(scripts): make the cross-framework import check actually run isSourceFile() tested paths against /\.(ts|tsx|js|jsx)$/. In a regex literal `\` matches a literal backslash, not a dot, so the pattern required a backslash followed by any character and then `ts`/`tsx`/etc at end of string. No path in the repo can satisfy that, so the predicate returned false for every file, the walk callback returned early every time, and `violations` was always empty. The gate has therefore been reporting "OK: No cross-framework imports found" unconditionally since it was written -- it would have passed a package importing another framework wholesale. Verified by appending `import { DynamicInput } from '@dynamic-field-kit/react'` to packages/vue/src/index.ts: before this change the script printed OK and exited 0; after it, it exits 1 and names the file and line. Fix the escape, and restructure the script the way show-sizes.js and verify-framework-deps.js already are: export the check, guard the CLI behind require.main so importing it does not call process.exit, and derive the scanned roots from a package list rather than three hand-written paths. scripts/check-cross-framework-imports.test.js covers import and require forms, nested directories, line numbers, that core stays allowed, and that non-source files are skipped. Running the now-working check against this repo finds no violations, so nothing in packages/*/src had drifted while the gate was blind. --- scripts/check-cross-framework-imports.js | 87 +++++++------- scripts/check-cross-framework-imports.test.js | 108 ++++++++++++++++++ 2 files changed, 154 insertions(+), 41 deletions(-) create mode 100644 scripts/check-cross-framework-imports.test.js diff --git a/scripts/check-cross-framework-imports.js b/scripts/check-cross-framework-imports.js index 48c054a..a9abb84 100644 --- a/scripts/check-cross-framework-imports.js +++ b/scripts/check-cross-framework-imports.js @@ -5,9 +5,18 @@ const fs = require('fs'); const path = require('path'); +const FRAMEWORK_PACKAGES = ['react', 'vue', 'angular']; + +const IMPORT_PATTERN = /from\s+['"]@dynamic-field-kit\/(vue|angular|react)['"]/; +const REQUIRE_PATTERN = + /require\(['"]@dynamic-field-kit\/(vue|angular|react)['"]\)/; + function walk(dir, cb) { - const entries = fs.readdirSync(dir, { withFileTypes: true }); - for (const e of entries) { + if (!fs.existsSync(dir)) { + return; + } + + for (const e of fs.readdirSync(dir, { withFileTypes: true })) { const full = path.join(dir, e.name); if (e.isDirectory()) { walk(full, cb); @@ -18,52 +27,48 @@ function walk(dir, cb) { } function isSourceFile(p) { - return /\\.(ts|tsx|js|jsx)$/.test(p); + return /\.(ts|tsx|js|jsx)$/.test(p); } -const repoRoot = path.resolve(__dirname, '..'); -const roots = [ - path.resolve(repoRoot, 'packages', 'react', 'src'), - path.resolve(repoRoot, 'packages', 'vue', 'src'), - path.resolve(repoRoot, 'packages', 'angular', 'src'), -]; - -let violations = []; +function findCrossFrameworkImports(root = path.resolve(__dirname, '..')) { + const violations = []; -for (const root of roots) { - walk(root, (file) => { - if (!isSourceFile(file)) return; - const content = fs.readFileSync(file, 'utf8'); - const lines = content.split(/\r?\n/); - for (let i = 0; i < lines.length; i++) { - const line = lines[i]; - const m = line.match( - /from\s+['"]@dynamic-field-kit\/(vue|angular|react)['"]/ - ); - if (m) { - violations.push({ file, line: i + 1, framework: m[1] }); - } - const m2 = line.match( - /require\(['"]@dynamic-field-kit\/(vue|angular|react)['"]\)/ - ); - if (m2) { - violations.push({ file, line: i + 1, framework: m2[1] }); + for (const pkg of FRAMEWORK_PACKAGES) { + walk(path.join(root, 'packages', pkg, 'src'), (file) => { + if (!isSourceFile(file)) { + return; } - } - }); + + const lines = fs.readFileSync(file, 'utf8').split(/\r?\n/); + + lines.forEach((line, i) => { + const m = line.match(IMPORT_PATTERN) || line.match(REQUIRE_PATTERN); + if (m) { + violations.push({ file, line: i + 1, framework: m[1] }); + } + }); + }); + } + + return violations; } -if (violations.length) { - console.error('Cross-framework imports detected:'); - violations.forEach((v) => { - console.error( - ` - ${v.file}:${v.line} (importing @dynamic-field-kit/${v.framework})` - ); - }); - process.exit(1); -} else { +if (require.main === module) { + const violations = findCrossFrameworkImports(); + + if (violations.length) { + console.error('Cross-framework imports detected:'); + violations.forEach((v) => { + console.error( + ` - ${v.file}:${v.line} (importing @dynamic-field-kit/${v.framework})` + ); + }); + process.exit(1); + } + console.log( 'OK: No cross-framework imports found in src of react/vue/angular packages.' ); - process.exit(0); } + +module.exports = { findCrossFrameworkImports, FRAMEWORK_PACKAGES }; diff --git a/scripts/check-cross-framework-imports.test.js b/scripts/check-cross-framework-imports.test.js new file mode 100644 index 0000000..5d88717 --- /dev/null +++ b/scripts/check-cross-framework-imports.test.js @@ -0,0 +1,108 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { findCrossFrameworkImports } from './check-cross-framework-imports.js'; + +const tempRoots = []; + +/** Writes `files` (relative path -> contents) under packages//src. */ +function makeWorkspace(files) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cross-imports-')); + tempRoots.push(root); + + for (const pkg of ['react', 'vue', 'angular']) { + fs.mkdirSync(path.join(root, 'packages', pkg, 'src'), { recursive: true }); + } + + for (const [relative, contents] of Object.entries(files)) { + const file = path.join(root, relative); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, contents); + } + + return root; +} + +afterEach(() => { + while (tempRoots.length) { + fs.rmSync(tempRoots.pop(), { recursive: true, force: true }); + } +}); + +describe('findCrossFrameworkImports', () => { + it('detects an import of a sibling framework package', () => { + const root = makeWorkspace({ + 'packages/vue/src/index.ts': + "import { DynamicInput } from '@dynamic-field-kit/react';\n", + }); + + expect(findCrossFrameworkImports(root)).toEqual([ + { + file: path.join(root, 'packages', 'vue', 'src', 'index.ts'), + line: 1, + framework: 'react', + }, + ]); + }); + + it('detects a require() of a sibling framework package', () => { + const root = makeWorkspace({ + 'packages/react/src/legacy.js': + "const vue = require('@dynamic-field-kit/vue');\n", + }); + + expect(findCrossFrameworkImports(root)).toMatchObject([ + { line: 1, framework: 'vue' }, + ]); + }); + + it('reports the line number of a violation further down a file', () => { + const root = makeWorkspace({ + 'packages/angular/src/index.ts': [ + "import { defineField } from '@dynamic-field-kit/core';", + '', + "import { x } from '@dynamic-field-kit/vue';", + ].join('\n'), + }); + + expect(findCrossFrameworkImports(root)).toMatchObject([ + { line: 3, framework: 'vue' }, + ]); + }); + + it('scans nested directories', () => { + const root = makeWorkspace({ + 'packages/vue/src/layout/deep/nested.tsx': + "import x from '@dynamic-field-kit/angular';\n", + }); + + expect(findCrossFrameworkImports(root)).toHaveLength(1); + }); + + it('allows importing core', () => { + const root = makeWorkspace({ + 'packages/vue/src/index.ts': + "import { defineField } from '@dynamic-field-kit/core';\n", + }); + + expect(findCrossFrameworkImports(root)).toEqual([]); + }); + + it('ignores files that are not source files', () => { + const root = makeWorkspace({ + 'packages/vue/src/README.md': + "import { x } from '@dynamic-field-kit/react';\n", + 'packages/vue/src/data.json': '{}', + }); + + expect(findCrossFrameworkImports(root)).toEqual([]); + }); + + it('finds nothing in this repo', () => { + expect(findCrossFrameworkImports(path.resolve(__dirname, '..'))).toEqual( + [] + ); + }); +}); From bc93fcfc957bebec500137cd53d7cb6780d49f06 Mon Sep 17 00:00:00 2001 From: Van Nguyen Date: Fri, 14 Aug 2026 22:06:45 +0700 Subject: [PATCH 06/15] chore(lint): extend the lint gate to scripts/ `npm run lint` only covered packages/*/src, so nothing under scripts/ was ever linted in CI. The pre-commit hook does lint it, because lint-staged matches *.js anywhere, which meant the two gates disagreed: a script could sit in the repo with errors that would block the next person who happened to touch it. Add scripts to the lint script and clear the errors that surfaced: - build-changed.js: import order - check-cross-framework-imports.js: prefer-const, curly (already rewritten in the previous commit) - diagnose-hoist.js: execSync's return value was assigned to an unused binding. It runs with stdio: 'inherit', so output goes straight to our stdout and there is nothing to capture; drop the assignment. All three are behaviour-preserving. --- package.json | 2 +- scripts/build-changed.js | 2 +- scripts/diagnose-hoist.js | 4 +++- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 9aba611..75bcf90 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "dev": "npm run dev --workspaces", "test": "npm run test --workspaces --if-present", "test:scripts": "vitest run", - "lint": "eslint packages/core/src packages/react/src packages/vue/src packages/angular/src --ext .ts,.tsx,.js --ignore-path .eslintignore", + "lint": "eslint packages/core/src packages/react/src packages/vue/src packages/angular/src scripts --ext .ts,.tsx,.js --ignore-path .eslintignore", "lint:cross-framework": "node scripts/check-cross-framework-imports.js", "verify-framework-deps": "node scripts/verify-framework-deps.js", "lint:fix": "eslint . --ext .ts,.tsx,.js --fix", diff --git a/scripts/build-changed.js b/scripts/build-changed.js index 3e5c69c..4902ca8 100644 --- a/scripts/build-changed.js +++ b/scripts/build-changed.js @@ -1,8 +1,8 @@ #!/usr/bin/env node // Build only changed packages based on git diff +const { execSync, spawnSync } = require('child_process'); const fs = require('fs'); const path = require('path'); -const { execSync, spawnSync } = require('child_process'); function readJSON(p) { try { diff --git a/scripts/diagnose-hoist.js b/scripts/diagnose-hoist.js index 820327a..f173a18 100644 --- a/scripts/diagnose-hoist.js +++ b/scripts/diagnose-hoist.js @@ -7,7 +7,9 @@ packages.forEach((p) => { try { const dir = `./packages/${p}`; console.log(`\n=== ${p.toUpperCase()} ===`); - const out = execSync(`npm ls @dynamic-field-kit/core --depth=0`, { + // stdio: 'inherit' prints straight to our stdout, so there is nothing to + // capture from the return value. + execSync(`npm ls @dynamic-field-kit/core --depth=0`, { cwd: dir, stdio: 'inherit', }); From b15e0e7745c3cbe7405db900c706d94a211a72b5 Mon Sep 17 00:00:00 2001 From: Van Nguyen Date: Fri, 14 Aug 2026 22:07:22 +0700 Subject: [PATCH 07/15] chore(deps): drop root deps that the packages now declare themselves With packages/vue dev-installing vue, the root's react, react-dom and vue entries are redundant: every workspace that needs them declares them (packages/react, packages/vue, smoke). Leaving them meant the root's manifest implied ownership of dependencies it does not use, and a package could silently go back to borrowing from the root without verify-framework-deps.js noticing, since resolution would still succeed. @types/react moves to packages/react rather than being dropped. It is genuinely needed -- packages/react is a .tsx package and its `tsc -p tsconfig.json --noEmit` gate resolves React's types through it -- but it was only ever declared at the root, so that gate depended on hoisting too. Verified by running typecheck after the move. Root devDependencies are now exactly the workspace-wide tooling: changesets, commitlint, eslint and its plugins, husky, lint-staged, prettier, tsup, typescript, vitest. Lockfile regenerated with npm@10: 1606 entries unchanged with nothing added or removed, all 100 linux entries intact. --- package-lock.json | 7 ++----- package.json | 8 ++------ packages/react/package.json | 1 + 3 files changed, 5 insertions(+), 11 deletions(-) diff --git a/package-lock.json b/package-lock.json index 9842adf..370a80d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,7 +18,6 @@ "@changesets/cli": "^2.31.1", "@commitlint/cli": "^21.2.1", "@commitlint/config-conventional": "^21.2.0", - "@types/react": "^19.2.8", "@typescript-eslint/eslint-plugin": "^5.60.0", "@typescript-eslint/parser": "^5.60.0", "eslint": "^8.44.0", @@ -30,12 +29,9 @@ "husky": "^9.1.7", "lint-staged": "^17.2.0", "prettier": "^2.8.8", - "react": "^19.2.4", - "react-dom": "^19.2.4", "tsup": "^8.0.1", "typescript": "~5.5.0", - "vitest": "^1.6.0", - "vue": "^3.5.32" + "vitest": "^1.6.0" } }, "node_modules/@adobe/css-tools": { @@ -23569,6 +23565,7 @@ "@testing-library/jest-dom": "^6.4.0", "@testing-library/react": "^16.0.0", "@testing-library/user-event": "^14.5.0", + "@types/react": "^19.2.8", "@vitejs/plugin-react": "^4.2.0", "@vitest/coverage-v8": "^1.6.0", "jsdom": "^24.0.0", diff --git a/package.json b/package.json index 75bcf90..872c27e 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,6 @@ "@changesets/cli": "^2.31.1", "@commitlint/cli": "^21.2.1", "@commitlint/config-conventional": "^21.2.0", - "@types/react": "^19.2.8", "@typescript-eslint/eslint-plugin": "^5.60.0", "@typescript-eslint/parser": "^5.60.0", "eslint": "^8.44.0", @@ -42,13 +41,10 @@ "lint-staged": "^17.2.0", "prettier": "^2.8.8", "tsup": "^8.0.1", - "react": "^19.2.4", - "react-dom": "^19.2.4", "typescript": "~5.5.0", - "vitest": "^1.6.0", - "vue": "^3.5.32" + "vitest": "^1.6.0" }, - "//dependencies": "the root is private and ships nothing, so react/react-dom/vue belong in devDependencies - they exist only to resolve the packages' peers while building and testing. Keeping them here is what made `npm audit --omit=dev` (the CI gate) fail on advisories in vue's build-time tree, e.g. @vue/compiler-sfc -> postcss -> nanoid.", + "//dependencies": "the root is private and ships nothing, so it declares no runtime dependencies. react/react-dom/vue/@types/react used to sit here for the packages to borrow; each package now declares its own (enforced by scripts/verify-framework-deps.js), which is also what keeps `npm audit --omit=dev` from failing on advisories in a build-time tree, e.g. vue -> @vue/compiler-sfc -> postcss -> nanoid.", "dependencies": { "@dynamic-field-kit/core": "workspace:*", "@dynamic-field-kit/react": "workspace:*", diff --git a/packages/react/package.json b/packages/react/package.json index 8a13084..3f1edee 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -29,6 +29,7 @@ "@testing-library/jest-dom": "^6.4.0", "@testing-library/react": "^16.0.0", "@testing-library/user-event": "^14.5.0", + "@types/react": "^19.2.8", "@vitest/coverage-v8": "^1.6.0", "@vitejs/plugin-react": "^4.2.0", "jsdom": "^24.0.0", From 26001ad535e95769d36f57f1778b6cc5202bbf9f Mon Sep 17 00:00:00 2001 From: Van Nguyen Date: Fri, 14 Aug 2026 22:27:24 +0700 Subject: [PATCH 08/15] fix(example): give the react and vue demos real page titles Both demos shipped their scaffold defaults to the live site. The React demo -- the one the package READMEs link to most -- rendered a browser tab and link preview reading "Create Next App", described as "Generated by create next app". The Vue demo's title was "vue-app". Only the Angular demo had a real one. Match Angular's existing "Dynamic Field Kit - Example" and give each a description naming what the demo actually shows. Verified in the built output: all three now emit their own title and no stale create-next-app description survives. --- example/react-app/app/layout.tsx | 5 +++-- example/vue-app/index.html | 6 +++++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/example/react-app/app/layout.tsx b/example/react-app/app/layout.tsx index 8da647e..833365f 100644 --- a/example/react-app/app/layout.tsx +++ b/example/react-app/app/layout.tsx @@ -13,8 +13,9 @@ const geistMono = Geist_Mono({ }); export const metadata: Metadata = { - title: 'Create Next App', - description: 'Generated by create next app', + title: 'Dynamic Field Kit - React Example', + description: + 'Schema-driven forms rendered by @dynamic-field-kit/react: renderer registry, layouts, conditional fields, repeatable groups, form state and a multi-step wizard.', }; export default function RootLayout({ diff --git a/example/vue-app/index.html b/example/vue-app/index.html index 0011a17..c0468dc 100644 --- a/example/vue-app/index.html +++ b/example/vue-app/index.html @@ -4,7 +4,11 @@ - vue-app + Dynamic Field Kit - Vue Example +
From d413ddcf93129f22a3438fe0299876b1549d92d7 Mon Sep 17 00:00:00 2001 From: Van Nguyen Date: Fri, 14 Aug 2026 22:27:58 +0700 Subject: [PATCH 09/15] docs: close the README gaps and link the demo sub-routes Cross-checking every symbol the four READMEs tell you to import against the built .d.ts found no stale API -- all 58 resolve -- but 25 public exports were never mentioned anywhere. All four packages now describe their full surface. The substantive gap was async validation. `validateField` and `validateFields` are synchronous and cannot await, so a `validate` hook returning a Promise is discarded and the field reads as valid. Both `useDynamicForm` and `createDynamicFormStore` use the sync path throughout, including in handleSubmit, so async rules never surface unless the app calls `validateFieldsAsync` itself. Core's adapter section already said async schemas "must be validated through validateFieldsAsync", but no README showed how, and the async functions appeared in no adapter export list at all. Core now has a "Sync vs async validation" section, and each adapter repeats the caveat and links to it. Also documented: - core: `Properties`, `ValidatorFn`, `FieldValidatorResult` and `FieldValidatorFunction`, which appear in signatures throughout the README but had no definition; and `FormStep` next to `WizardState`. - react/vue/angular: `validateFieldAsync`, `validateFieldsAsync`, `resolveOptions` and `validators`, grouped under a heading that marks them as core re-exports rather than adapter exports. - angular: `layoutRegistry`, `LayoutRegistry`, `ColumnLayout`, `RowLayout`, `GridLayout` and `BaseInputComponent`. It was the only adapter documenting no layout registry, and its registry is the one real difference between the three -- it holds standalone components, not render functions -- so it needed its own example rather than a pointer at the React one. Demo links: the package READMEs each pointed only at their framework's landing page. React now links its enterprise-features and wizard routes, core links all three framework demos plus the wizard it documents, and vue/angular note that their wizard is a tab rather than a separate URL. All links verified to serve real pages. Angular's `## What it exports` becomes `## Exports`, matching react/vue. --- .changeset/clever-donkeys-repeat.md | 32 ++++++++ packages/angular/README.md | 49 +++++++++++- packages/core/README.md | 111 +++++++++++++++++++++++++--- packages/react/README.md | 21 +++++- packages/vue/README.md | 20 ++++- 5 files changed, 216 insertions(+), 17 deletions(-) create mode 100644 .changeset/clever-donkeys-repeat.md diff --git a/.changeset/clever-donkeys-repeat.md b/.changeset/clever-donkeys-repeat.md new file mode 100644 index 0000000..35d7575 --- /dev/null +++ b/.changeset/clever-donkeys-repeat.md @@ -0,0 +1,32 @@ +--- +'@dynamic-field-kit/angular': patch +'@dynamic-field-kit/core': patch +'@dynamic-field-kit/react': patch +'@dynamic-field-kit/vue': patch +--- + +Close the remaining documentation gaps in each package README, so every public +export is described somewhere. README ships in the npm tarball, so this reaches +the package pages only through a release. + +- **Sync vs async validation** is now spelled out in core, with the consequence + that was previously implicit: `validateField` / `validateFields` cannot await, + so a `validate` hook returning a Promise is treated as valid on the sync path. + `useDynamicForm` and `createDynamicFormStore` validate synchronously + (including on submit), so async rules have to run through + `validateFieldsAsync` explicitly. Each adapter README repeats the caveat and + links to the core section. +- Document `validateFieldAsync`, `validateFieldsAsync`, `resolveOptions` and + `validators` in the react, vue and angular export lists, separated from each + adapter's own exports so it is clear they are core re-exports. +- Document the core types that appear in every signature but had no definition + in the README: `Properties`, `ValidatorFn`, `FieldValidatorResult`, + `FieldValidatorFunction`, and `FormStep` alongside `WizardState`. +- Angular: document `layoutRegistry` / `LayoutRegistry`, the `ColumnLayout` / + `RowLayout` / `GridLayout` components and `BaseInputComponent`, with a custom + layout example. Its layout registry holds standalone components rather than + render functions, which is the one place the three adapters genuinely differ, + and it was the only adapter not documenting its registry at all. +- Rename angular's `## What it exports` to `## Exports` to match react and vue. +- Link the demo sub-routes: react's enterprise-features and wizard pages, and + from core the per-framework demos plus the wizard it documents. diff --git a/packages/angular/README.md b/packages/angular/README.md index c47711a..ebe487f 100644 --- a/packages/angular/README.md +++ b/packages/angular/README.md @@ -4,7 +4,9 @@ Angular adapter for `@dynamic-field-kit/core`. This package provides Angular components and a convenience module that render field schemas defined with `@dynamic-field-kit/core`. -Live demo: https://vannt-dev.github.io/dynamic-field-kit/angular/ +Live demo: https://vannt-dev.github.io/dynamic-field-kit/angular/ — tabs for the +basic schema, the enterprise features (`createDynamicFormStore`, HTML5 renderers, +blur wiring, DevTools) and the multi-step wizard. ## Install @@ -20,7 +22,7 @@ If you need to pin versions explicitly: npm install @dynamic-field-kit/core@^1.0.12 @dynamic-field-kit/angular@^1.2.3 ``` -## What it exports +## Exports - `DynamicInput` - `FieldInput` @@ -29,9 +31,27 @@ npm install @dynamic-field-kit/core@^1.0.12 @dynamic-field-kit/angular@^1.2.3 - `fieldRegistry` - `FieldRegistry` (class, for scoped registries) - `FIELD_REGISTRY` (injection token) -- `validateField` / `validateFields` / `resolveDisabled` / `resolveReadOnly` / `ValidationResult` - `createDynamicFormStore` (signal-based form state) - `DynamicFormDevToolsComponent` +- `layoutRegistry` / `LayoutRegistry` (class, for a scoped layout registry) +- `ColumnLayout` / `RowLayout` / `GridLayout` (the standalone layout components, registered for you) +- `BaseInputComponent` — the abstract base your custom renderers extend + +Re-exported from `@dynamic-field-kit/core` so a consumer app rarely has to import +both packages: + +- `validateField` / `validateFieldAsync` — one field, returns `string[]` +- `validateFields` / `validateFieldsAsync` — a whole schema, returns `ValidationResult` +- `resolveDisabled` / `resolveReadOnly` / `resolveOptions` — resolve a field's dynamic conditions and options +- `validators` — the built-in validator helpers (`required`, `email`, `minLength`, `compose`, …) +- `ValidationResult` + +`createDynamicFormStore` validates **synchronously** via `validateFields`, +including on submit. Fields whose `validate` hook returns a Promise are treated +as valid on that path, so run async rules through `validateFieldsAsync` +yourself. See the +[core README](https://github.com/vannt-dev/dynamic-field-kit/tree/develop/packages/core#sync-vs-async-validation) +for the full rules. ## Basic setup (Angular 19+) @@ -194,6 +214,29 @@ fields in error. > ``` +Those four names resolve through `layoutRegistry`, which holds standalone +components rather than render functions — the Angular equivalent of the React +and Vue layout registries. `ColumnLayout`, `RowLayout` and `GridLayout` are +registered for you when you import the package root; register your own the same +way: + +```ts +import { Component } from '@angular/core'; +import { layoutRegistry } from '@dynamic-field-kit/angular'; + +@Component({ + standalone: true, + selector: 'app-stack-tight', + template: `
`, +}) +export class StackTightLayout {} + +layoutRegistry.register('stack-tight', StackTightLayout); +``` + +`LayoutRegistry` is the class behind that singleton, for when you want an +isolated set of layouts instead of the shared one. + ## Derived fields with `computeValue` Give a field a `computeValue` to derive its value from the rest of the form data whenever any field changes: diff --git a/packages/core/README.md b/packages/core/README.md index 9323706..50fd64d 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -4,7 +4,12 @@ Core types and shared registries for `dynamic-field-kit`. `@dynamic-field-kit/core` is intentionally framework-agnostic. It does not import React, Vue, or Angular types in its public API. Applications define field schemas in `core`, then register framework-specific renderers through an adapter package such as `@dynamic-field-kit/react`, `@dynamic-field-kit/vue`, or `@dynamic-field-kit/angular`. -Live demo: https://vannt-dev.github.io/dynamic-field-kit/ +Live demo: https://vannt-dev.github.io/dynamic-field-kit/ — the same schema +rendered by [React](https://vannt-dev.github.io/dynamic-field-kit/react/), +[Vue](https://vannt-dev.github.io/dynamic-field-kit/vue/) and +[Angular](https://vannt-dev.github.io/dynamic-field-kit/angular/), including the +[wizard engine](https://vannt-dev.github.io/dynamic-field-kit/react/wizard/) +documented below. ## What this package provides @@ -14,7 +19,7 @@ Live demo: https://vannt-dev.github.io/dynamic-field-kit/ - `fieldRegistry` as the shared runtime registry instance, plus the `FieldRegistry` class for isolated (scoped) registries - Layout config types (`LayoutConfig`, `BaseLayout`, `ResponsiveLayout`, `ColumnLayoutConfig`, `RowLayoutConfig`, `GridLayoutConfig`) - the single source of truth re-exported by every adapter - `applyComputedValues` to resolve `computeValue` fields against form data -- `validateField`, `validateFields`, `resolveDisabled`, `resolveReadOnly` and the `ValidationResult` type for opt-in, app-supplied validation and dynamic disabled/readOnly conditions +- `validateField` / `validateFieldAsync`, `validateFields` / `validateFieldsAsync`, `resolveDisabled`, `resolveReadOnly`, `resolveOptions` and the `ValidationResult` type for opt-in, app-supplied validation and dynamic disabled/readOnly/options conditions - `isFieldGroup`, `createGroupItem`, `canAddGroupItem`, `canRemoveGroupItem` to work with repeatable field groups (`FieldDescription.fields`), plus `moveGroupItem`, `swapGroupItems`, `insertGroupItem` and `focusFirstInvalidField` for driving a group's array yourself - `zodValidator`, `yupValidator`, `valibotValidator` / `standardSchemaValidator` to validate with an existing schema library - A multi-step wizard state machine: `createWizardState`, `validateStep`, `canGoNext` / `canGoPrev`, `goNext` / `goPrev` / `goToStep`, `markStepCompleted` / `isStepCompleted` @@ -129,6 +134,39 @@ export interface FieldDescription { } ``` +The value types that show up throughout that shape, and in every signature in +this README: + +```ts +// Any form-data object. `data` and `rootData` are always this. +export type Properties = Record; + +// What `validators.*` helpers return: one message, or undefined when valid. +export type ValidatorFn = ( + value: unknown, + data?: Properties, + rootData?: Properties +) => string | undefined; + +// What a `FieldDescription.validate` hook may return. The Promise arm is what +// makes a field async-only -- see "Sync vs async validation" below. +export type FieldValidatorResult = + | string + | string[] + | undefined + | Promise; + +export type FieldValidatorFunction = ( + value: unknown, + data: Properties, + rootData?: Properties +) => FieldValidatorResult; +``` + +The schema adapters (`zodValidator` and friends) each return a +`FieldValidatorFunction`, which is why their result drops straight into +`validate`. + ## Derived fields with `computeValue` `computeValue` derives a field's value from the rest of the form data (e.g. a `fullName` computed from `firstName` + `lastName`). It is called as `(data, rootData)` - `rootData` is the top-level form even for fields nested in a group. Every adapter's `MultiFieldInput` re-evaluates it once per change, against the post-change data - it is not re-run to a fixed point, so avoid chaining `computeValue` fields into a cycle, and return a primitive or a stable reference (returning a fresh object/array each call defeats the render-skipping optimisation). In development, `applyComputedValues` warns when a `computeValue` chain does not converge in one pass. @@ -213,10 +251,43 @@ const fields: FieldDescription[] = [ `validateFields(fields, data, rootData?)` returns `{ valid, errors }`, recursing into repeatable groups (keys like `contacts[0].email`) and skipping fields that -are hidden by `appearCondition` or disabled. Use `validateFieldsAsync(fields, data, rootData?)` -when fields define async `validate` functions. Adapters call `validateField` / -`resolveDisabled` / `resolveReadOnly` per field to surface `error`, `disabled`, -and `readOnly` to renderers reactively. +are hidden by `appearCondition` or disabled. Adapters call `validateField` / +`resolveDisabled` / `resolveReadOnly` / `resolveOptions` per field to surface +`error`, `disabled`, `readOnly` and the resolved `options` to renderers +reactively. + +### Sync vs async validation + +`validateField` and `validateFields` are synchronous, and that has a consequence +worth knowing: **when a `validate` hook returns a Promise, the sync path treats +the field as valid.** It cannot await, so it discards the pending result rather +than blocking. Any field whose `validate` is `async` — or whose schema has async +refinements — must go through the async pair: + +```ts +import { + validateField, + validateFieldAsync, + validateFields, + validateFieldsAsync, +} from '@dynamic-field-kit/core'; + +// One field. Both always return string[] (empty when valid). +const errors = validateField(field, value, data, rootData); // string[] +const errorsAsync = await validateFieldAsync(field, value, data, rootData); + +// A whole schema. Both return ValidationResult -> { valid, errors }. +const result = validateFields(fields, data); // sync hooks only +const resultAsync = await validateFieldsAsync(fields, data); // awaits each hook +``` + +The framework form hooks (`useDynamicForm`, `createDynamicFormStore`) validate +synchronously, so wire async rules up through `validateFieldsAsync` yourself — +for example on submit — rather than expecting them to surface on change. + +`resolveOptions(field, data, rootData?)` returns `Properties[] | undefined`, +calling `field.options` when it is a callback and passing it through when it is a +static array. ### Schema adapters (Zod, Yup, Valibot / Standard Schema) @@ -288,10 +359,30 @@ if (valid) { | `markStepCompleted(state, index?)` | Record a step as done, defaulting to the current one | | `isStepCompleted(state, index)` | For rendering a step indicator | -`WizardState` carries `currentStep`, `currentStepIndex`, `totalSteps`, -`isFirstStep`, `isLastStep`, `steps` and `completedSteps`. `goNext` does not -validate — call `validateStep` yourself so a "save draft and come back" flow -stays possible. +Each step is a `FormStep`, and `WizardState` is what every helper above takes +and returns: + +```ts +export interface FormStep { + id: string; + title: string; + description?: string; + fields: FieldDescription[]; +} + +export interface WizardState { + currentStepIndex: number; + totalSteps: number; + isFirstStep: boolean; + isLastStep: boolean; + currentStep: FormStep; + steps: FormStep[]; + completedSteps: number[]; +} +``` + +`goNext` does not validate — call `validateStep` yourself so a "save draft and +come back" flow stays possible. ## Group array helpers diff --git a/packages/react/README.md b/packages/react/README.md index a9d845d..56ecddf 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -4,7 +4,10 @@ React adapter for `@dynamic-field-kit/core`. This package provides React components for rendering `FieldDescription[]` and exports a React-typed `fieldRegistry`, so registered renderers can be used directly as JSX components. -Live demo: https://vannt-dev.github.io/dynamic-field-kit/react/ +Live demo: https://vannt-dev.github.io/dynamic-field-kit/react/ — plus +[enterprise features](https://vannt-dev.github.io/dynamic-field-kit/react/new-features/) +(`useDynamicForm`, HTML5 renderers, blur wiring, DevTools) and a +[multi-step wizard](https://vannt-dev.github.io/dynamic-field-kit/react/wizard/). ## Install @@ -31,9 +34,23 @@ Note: `@dynamic-field-kit/core`, `react`, and `react-dom` are **peer dependencie - `FieldTypeKey` - `FieldRendererProps` - `LayoutConfig` -- `validateField` / `validateFields` / `resolveDisabled` / `resolveReadOnly` / `ValidationResult` - `defaultRenderersMap` / `getDefaultRenderer` +Re-exported from `@dynamic-field-kit/core` so a consumer app rarely has to import +both packages: + +- `validateField` / `validateFieldAsync` — one field, returns `string[]` +- `validateFields` / `validateFieldsAsync` — a whole schema, returns `ValidationResult` +- `resolveDisabled` / `resolveReadOnly` / `resolveOptions` — resolve a field's dynamic conditions and options +- `validators` — the built-in validator helpers (`required`, `email`, `minLength`, `compose`, …) +- `ValidationResult` + +`useDynamicForm` validates **synchronously** via `validateFields`, including on +submit. Fields whose `validate` hook returns a Promise are treated as valid on +that path, so run async rules through `validateFieldsAsync` yourself. See the +[core README](https://github.com/vannt-dev/dynamic-field-kit/tree/develop/packages/core#sync-vs-async-validation) +for the full rules. + `FieldGroupInput` (repeatable field groups) is used internally by `FieldInput` and doesn't need to be imported directly - see "Repeatable field groups" below. Default layouts are registered automatically when you import the package root. diff --git a/packages/vue/README.md b/packages/vue/README.md index 64e0f13..77ec142 100644 --- a/packages/vue/README.md +++ b/packages/vue/README.md @@ -4,7 +4,9 @@ Vue 3 adapter for `@dynamic-field-kit/core`. This package provides Vue components that render `FieldDescription[]` and resolve field renderers through the shared registry used by `dynamic-field-kit`. -Live demo: https://vannt-dev.github.io/dynamic-field-kit/vue/ +Live demo: https://vannt-dev.github.io/dynamic-field-kit/vue/ — tabs for the +basic schema, the enterprise features (`useDynamicForm`, HTML5 renderers, blur +wiring, DevTools) and the multi-step wizard. ## Install @@ -28,11 +30,25 @@ Note: `@dynamic-field-kit/core` and `vue` are **peer dependencies** — this ada - `FieldRendererProps` - `Properties` - `LayoutConfig` -- `validateField` / `validateFields` / `resolveDisabled` / `resolveReadOnly` / `ValidationResult` - `useDynamicForm` - `DynamicFormDevTools` - `defaultRenderersMap` / `getDefaultRenderer` +Re-exported from `@dynamic-field-kit/core` so a consumer app rarely has to import +both packages: + +- `validateField` / `validateFieldAsync` — one field, returns `string[]` +- `validateFields` / `validateFieldsAsync` — a whole schema, returns `ValidationResult` +- `resolveDisabled` / `resolveReadOnly` / `resolveOptions` — resolve a field's dynamic conditions and options +- `validators` — the built-in validator helpers (`required`, `email`, `minLength`, `compose`, …) +- `ValidationResult` + +`useDynamicForm` validates **synchronously** via `validateFields`, including on +submit. Fields whose `validate` hook returns a Promise are treated as valid on +that path, so run async rules through `validateFieldsAsync` yourself. See the +[core README](https://github.com/vannt-dev/dynamic-field-kit/tree/develop/packages/core#sync-vs-async-validation) +for the full rules. + Default layouts are registered automatically when you import the package root. Built-in layouts: From bd904157ef1f02052defd912575dbe8c600e2396 Mon Sep 17 00:00:00 2001 From: Van Nguyen Date: Fri, 14 Aug 2026 22:42:39 +0700 Subject: [PATCH 10/15] perf: let consumer bundlers drop unused parts of the adapters Components were declared as bare top-level calls -- defineComponent({...}) in vue, React.memo(...) in react. A bundler cannot prove such a call is side-effect free, so it evaluates it even when the result is unused. That pinned every default renderer and every component into an app's bundle regardless of how little of the package it imported. Annotating those calls /* @__PURE__ */ makes them droppable. Measured with esbuild, minified, framework external: react, one core helper 11,149 -> 3,258 B (-71%) react, DynamicInput only 11,150 -> 6,830 B (-39%) react, everything 17,601 -> unchanged vue, one core helper 17,379 -> 13,934 B (-20%) vue, DynamicInput only 17,379 -> 13,934 B (-20%) vue, everything 20,530 -> unchanged "Everything" staying flat is the expected result: nothing is droppable when it is all reachable. react gains more than vue because its default renderers are already plain arrow functions, so only the two memo() calls were holding them; vue's renderers are each a defineComponent() call. The published dist grows marginally (react +0.03 KB, vue +0.29 KB) since the annotations are comments carried into the bundle. That is the trade: a slightly bigger file on npm for a materially smaller bundle in the app. Also declare "sideEffects": false on core. It has no top-level execution -- the only module-scope work is `new FieldRegistry()` assigned to an export -- so the claim is accurate. It buys nothing under esbuild, which already tree-shakes core to 907 B for a single import, but it is correct metadata for bundlers that trust the flag over their own analysis. The adapters cannot claim it: their entry side-effect-imports the default layouts to register them. No behaviour change -- these are comments and a packaging flag. Full suite green: core/react/vue/angular tests, smoke, typecheck, type tests, lint, and the three verify scripts. --- .changeset/olive-pumas-shave.md | 35 +++++++++++++++++++ packages/core/package.json | 2 ++ .../react/src/components/DynamicInput.tsx | 2 +- packages/react/src/components/FieldInput.tsx | 2 +- .../vue/src/components/DynamicFormDevTools.ts | 2 +- packages/vue/src/components/DynamicInput.ts | 2 +- packages/vue/src/components/FieldInput.ts | 2 +- .../vue/src/components/MultiFieldInput.ts | 2 +- packages/vue/src/defaultRenderers.ts | 28 +++++++-------- 9 files changed, 57 insertions(+), 20 deletions(-) create mode 100644 .changeset/olive-pumas-shave.md diff --git a/.changeset/olive-pumas-shave.md b/.changeset/olive-pumas-shave.md new file mode 100644 index 0000000..959908e --- /dev/null +++ b/.changeset/olive-pumas-shave.md @@ -0,0 +1,35 @@ +--- +'@dynamic-field-kit/core': patch +'@dynamic-field-kit/react': patch +'@dynamic-field-kit/vue': patch +--- + +Let consumer bundlers drop the parts of the adapters an app does not use. + +Components were declared as bare top-level calls — `defineComponent({...})` in +vue, `React.memo(...)` in react. A bundler cannot prove such a call is +side-effect free, so it has to evaluate it even when the result is unused, which +kept every default renderer and every component in an app's bundle no matter how +little of the package it imported. Marking those calls `/* @__PURE__ */` makes +them droppable. Measured with esbuild, minified, framework external: + +| App imports | Before | After | +| -------------------------- | -------- | --------------- | +| react: one core helper | 11,149 B | 3,258 B (−71%) | +| react: `DynamicInput` only | 11,150 B | 6,830 B (−39%) | +| react: everything | 17,601 B | unchanged | +| vue: one core helper | 17,379 B | 13,934 B (−20%) | +| vue: `DynamicInput` only | 17,379 B | 13,934 B (−20%) | +| vue: everything | 20,530 B | unchanged | + +Apps that use the whole surface are unchanged, which is the expected result — +there is nothing to drop. The shipped `dist` grows slightly (react +0.03 KB, vue ++0.29 KB) because the annotations are comments in the bundle; the trade is a +bigger published file for a smaller consumer bundle. + +`@dynamic-field-kit/core` now declares `"sideEffects": false`. It has no +top-level execution at all — the only module-scope work is `new FieldRegistry()` +assigned to an export — so the claim is accurate, and it lets bundlers that rely +on the flag rather than their own analysis skip core entirely when it is unused. +The adapters deliberately do not set it: their entry side-effect-imports the +default layouts in order to register them. diff --git a/packages/core/package.json b/packages/core/package.json index 478295b..a0e7ab3 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -6,6 +6,8 @@ "main": "dist/index.js", "module": "dist/index.mjs", "types": "dist/index.d.ts", + "//sideEffects": "core has no top-level execution - the only module-scope work is `new FieldRegistry()` assigned to an export, which is pure. The adapters cannot say this: their entry side-effect-imports the default layouts to register them.", + "sideEffects": false, "publishConfig": { "access": "public" }, diff --git a/packages/react/src/components/DynamicInput.tsx b/packages/react/src/components/DynamicInput.tsx index e74895f..dc7c52e 100644 --- a/packages/react/src/components/DynamicInput.tsx +++ b/packages/react/src/components/DynamicInput.tsx @@ -91,7 +91,7 @@ const DynamicInputInner = ({ // unaffected fields don't re-render every time a sibling field's value changes. // React.memo erases the generic signature, so restore it via an `unknown` // round-trip (plain `as typeof DynamicInputInner` fails dts generation). -const DynamicInput = React.memo( +const DynamicInput = /* @__PURE__ */ React.memo( DynamicInputInner ) as unknown as typeof DynamicInputInner; diff --git a/packages/react/src/components/FieldInput.tsx b/packages/react/src/components/FieldInput.tsx index 950713b..949a84a 100644 --- a/packages/react/src/components/FieldInput.tsx +++ b/packages/react/src/components/FieldInput.tsx @@ -103,7 +103,7 @@ const FieldInputInner = ({ // `renderInfos` covers every field, so the default shallow-prop compare would // re-render all fields whenever any one of them changes. Compare only the // slice this field actually reads instead. -const FieldInput = React.memo(FieldInputInner, (prev, next) => { +const FieldInput = /* @__PURE__ */ React.memo(FieldInputInner, (prev, next) => { const name = prev.fieldDescription.name; return ( prev.fieldDescription === next.fieldDescription && diff --git a/packages/vue/src/components/DynamicFormDevTools.ts b/packages/vue/src/components/DynamicFormDevTools.ts index 396f526..e8ada7f 100644 --- a/packages/vue/src/components/DynamicFormDevTools.ts +++ b/packages/vue/src/components/DynamicFormDevTools.ts @@ -1,7 +1,7 @@ import { FieldDescription, Properties } from '@dynamic-field-kit/core'; import { defineComponent, h, PropType, ref } from 'vue'; -export const DynamicFormDevTools = defineComponent({ +export const DynamicFormDevTools = /* @__PURE__ */ defineComponent({ name: 'DynamicFormDevTools', props: { data: { diff --git a/packages/vue/src/components/DynamicInput.ts b/packages/vue/src/components/DynamicInput.ts index afe71ba..1d84b45 100644 --- a/packages/vue/src/components/DynamicInput.ts +++ b/packages/vue/src/components/DynamicInput.ts @@ -4,7 +4,7 @@ import { FieldTypeKey, Properties } from '@dynamic-field-kit/core'; import { getDefaultRenderer } from '../defaultRenderers'; import { useFieldRegistry } from '../fieldRegistryContext'; -const DynamicInput = defineComponent({ +const DynamicInput = /* @__PURE__ */ defineComponent({ name: 'DynamicInput', props: { diff --git a/packages/vue/src/components/FieldInput.ts b/packages/vue/src/components/FieldInput.ts index d4d25eb..99382cd 100644 --- a/packages/vue/src/components/FieldInput.ts +++ b/packages/vue/src/components/FieldInput.ts @@ -9,7 +9,7 @@ import { import { defineComponent, h, PropType } from 'vue'; import DynamicInput from './DynamicInput'; -const FieldInput = defineComponent({ +const FieldInput = /* @__PURE__ */ defineComponent({ name: 'FieldInput', props: { fieldDescription: { diff --git a/packages/vue/src/components/MultiFieldInput.ts b/packages/vue/src/components/MultiFieldInput.ts index eecb661..bbb5e28 100644 --- a/packages/vue/src/components/MultiFieldInput.ts +++ b/packages/vue/src/components/MultiFieldInput.ts @@ -39,7 +39,7 @@ let multiFieldInputSelfRef: Component; // than delegating to a separate group component. A separate file importing // both MultiFieldInput and FieldInput (which would need to import it back) // breaks Vue's declaration-file generation with a circular type alias. -const MultiFieldInput = defineComponent({ +const MultiFieldInput = /* @__PURE__ */ defineComponent({ name: 'MultiFieldInput', props: { diff --git a/packages/vue/src/defaultRenderers.ts b/packages/vue/src/defaultRenderers.ts index e750184..0ffd122 100644 --- a/packages/vue/src/defaultRenderers.ts +++ b/packages/vue/src/defaultRenderers.ts @@ -2,7 +2,7 @@ import { Properties } from '@dynamic-field-kit/core'; import { defineComponent, h, PropType } from 'vue'; -export const DefaultTextRenderer = defineComponent({ +export const DefaultTextRenderer = /* @__PURE__ */ defineComponent({ name: 'DefaultTextRenderer', props: { value: null, @@ -46,7 +46,7 @@ export const DefaultTextRenderer = defineComponent({ }, }); -export const DefaultNumberRenderer = defineComponent({ +export const DefaultNumberRenderer = /* @__PURE__ */ defineComponent({ name: 'DefaultNumberRenderer', props: { value: null, @@ -88,7 +88,7 @@ export const DefaultNumberRenderer = defineComponent({ }, }); -export const DefaultPasswordRenderer = defineComponent({ +export const DefaultPasswordRenderer = /* @__PURE__ */ defineComponent({ name: 'DefaultPasswordRenderer', setup(props, { attrs }) { return () => @@ -100,7 +100,7 @@ export const DefaultPasswordRenderer = defineComponent({ }, }); -export const DefaultEmailRenderer = defineComponent({ +export const DefaultEmailRenderer = /* @__PURE__ */ defineComponent({ name: 'DefaultEmailRenderer', setup(props, { attrs }) { return () => @@ -112,7 +112,7 @@ export const DefaultEmailRenderer = defineComponent({ }, }); -export const DefaultTextareaRenderer = defineComponent({ +export const DefaultTextareaRenderer = /* @__PURE__ */ defineComponent({ name: 'DefaultTextareaRenderer', props: { value: null, @@ -151,7 +151,7 @@ export const DefaultTextareaRenderer = defineComponent({ }, }); -export const DefaultCheckboxRenderer = defineComponent({ +export const DefaultCheckboxRenderer = /* @__PURE__ */ defineComponent({ name: 'DefaultCheckboxRenderer', props: { value: null, @@ -188,7 +188,7 @@ export const DefaultCheckboxRenderer = defineComponent({ }, }); -export const DefaultSelectRenderer = defineComponent({ +export const DefaultSelectRenderer = /* @__PURE__ */ defineComponent({ name: 'DefaultSelectRenderer', props: { value: null, @@ -245,7 +245,7 @@ export const DefaultSelectRenderer = defineComponent({ }, }); -export const DefaultRadioRenderer = defineComponent({ +export const DefaultRadioRenderer = /* @__PURE__ */ defineComponent({ name: 'DefaultRadioRenderer', props: { value: null, @@ -313,7 +313,7 @@ export const DefaultRadioRenderer = defineComponent({ }, }); -export const DefaultRangeRenderer = defineComponent({ +export const DefaultRangeRenderer = /* @__PURE__ */ defineComponent({ name: 'DefaultRangeRenderer', props: { value: null, @@ -354,7 +354,7 @@ export const DefaultRangeRenderer = defineComponent({ }, }); -export const DefaultFileRenderer = defineComponent({ +export const DefaultFileRenderer = /* @__PURE__ */ defineComponent({ name: 'DefaultFileRenderer', props: { value: null, @@ -397,7 +397,7 @@ export const DefaultFileRenderer = defineComponent({ }, }); -export const DefaultDateRenderer = defineComponent({ +export const DefaultDateRenderer = /* @__PURE__ */ defineComponent({ name: 'DefaultDateRenderer', setup(props, { attrs }) { return () => @@ -409,7 +409,7 @@ export const DefaultDateRenderer = defineComponent({ }, }); -export const DefaultTimeRenderer = defineComponent({ +export const DefaultTimeRenderer = /* @__PURE__ */ defineComponent({ name: 'DefaultTimeRenderer', setup(props, { attrs }) { return () => @@ -421,7 +421,7 @@ export const DefaultTimeRenderer = defineComponent({ }, }); -export const DefaultDateTimeLocalRenderer = defineComponent({ +export const DefaultDateTimeLocalRenderer = /* @__PURE__ */ defineComponent({ name: 'DefaultDateTimeLocalRenderer', setup(props, { attrs }) { return () => @@ -433,7 +433,7 @@ export const DefaultDateTimeLocalRenderer = defineComponent({ }, }); -export const DefaultSwitchRenderer = defineComponent({ +export const DefaultSwitchRenderer = /* @__PURE__ */ defineComponent({ name: 'DefaultSwitchRenderer', setup(props, { attrs }) { return () => From 80a9080ed821eb17adfa9f1118fd5448d8ee365e Mon Sep 17 00:00:00 2001 From: Van Nguyen Date: Fri, 14 Aug 2026 22:53:47 +0700 Subject: [PATCH 11/15] perf(vue): stop MultiFieldInput pinning the package into every bundle MultiFieldInput renders itself recursively for repeatable groups and reached itself through a module-scope assignment: let multiFieldInputSelfRef: Component; const MultiFieldInput = defineComponent({ ... }); multiFieldInputSelfRef = MultiFieldInput; That last line is a bare top-level assignment -- a side effect no bundler is allowed to drop. It anchored MultiFieldInput -> FieldInput -> DynamicInput -> getDefaultRenderer -> every default renderer into any app that imported the package at all, which is why the previous commit's /* @__PURE__ */ annotations only recovered 3.4 KB of a 17 KB floor: the components were pure but still reachable. Replace it with a hoisted function declaration returning MultiFieldInput. A function body is not evaluated until called, so nothing is retained until a group actually renders. The explicit `Component` return type does the job the forward-declared `let` was there for -- it stops TypeScript having to infer MultiFieldInput from inside its own initializer. Measured with esbuild, minified, vue external: one core helper 13,934 -> 2,479 B (-82%) DynamicInput only 13,934 -> 9,196 B (-34%) MultiFieldInput only 13,944 -> 13,958 B (+14 B) everything 20,530 -> 20,543 B (+13 B) Apps using the whole surface pay 13-14 bytes for the wrapper. Against the original baseline, before the PURE annotations, a vue app importing only DynamicInput goes 17,379 -> 9,196 B, a 47% reduction. react and angular were checked for the same pattern and have none. No behaviour or type change: dist/index.d.ts is byte-identical to before, and the vue suite passes unchanged at 115 tests, including the repeatable-group recursion that exercises this path. Full repo suite green. --- .changeset/brave-pandas-smile.md | 34 +++++++++++++++++++ .../vue/src/components/MultiFieldInput.ts | 33 +++++++++++------- 2 files changed, 55 insertions(+), 12 deletions(-) create mode 100644 .changeset/brave-pandas-smile.md diff --git a/.changeset/brave-pandas-smile.md b/.changeset/brave-pandas-smile.md new file mode 100644 index 0000000..6d5a85e --- /dev/null +++ b/.changeset/brave-pandas-smile.md @@ -0,0 +1,34 @@ +--- +'@dynamic-field-kit/vue': patch +--- + +Stop `MultiFieldInput` from pinning the whole package into every consumer +bundle. + +`MultiFieldInput` renders itself recursively for repeatable groups, and reached +itself through a module-scope `multiFieldInputSelfRef = MultiFieldInput` +assignment. A bare top-level assignment is a side effect no bundler is allowed +to drop, so it anchored `MultiFieldInput` → `FieldInput` → `DynamicInput` → +`getDefaultRenderer` → every default renderer, even in an app that imported none +of them. + +The self-reference is now a hoisted function declaration returning +`MultiFieldInput`. Its body is not evaluated until a group actually renders, so +nothing is retained until something uses it. The explicit return type keeps +TypeScript from having to infer `MultiFieldInput` from inside its own +initializer, which is what the forward-declared `let` was working around. + +Measured with esbuild, minified, `vue` external: + +| App imports | Before | After | +| ---------------------- | -------- | ---------------- | +| one core helper | 13,934 B | 2,479 B (−82%) | +| `DynamicInput` only | 13,934 B | 9,196 B (−34%) | +| `MultiFieldInput` only | 13,944 B | 13,958 B (+14 B) | +| everything | 20,530 B | 20,543 B (+13 B) | + +Apps that pull in the whole surface pay 13–14 bytes for the wrapper function, +which is the honest cost of the change. + +No behaviour or type change: `dist/index.d.ts` is byte-identical, and the vue +suite (115 tests, including the repeatable-group recursion) passes unchanged. diff --git a/packages/vue/src/components/MultiFieldInput.ts b/packages/vue/src/components/MultiFieldInput.ts index bbb5e28..5a40834 100644 --- a/packages/vue/src/components/MultiFieldInput.ts +++ b/packages/vue/src/components/MultiFieldInput.ts @@ -24,15 +24,26 @@ function resolveLayout(layout?: LayoutConfig) { return { type: layout.type, config: layout }; } -// Forward-declared with an explicit type so the recursive h() call inside -// renderGroupField doesn't force TypeScript to infer MultiFieldInput's type -// from within its own initializer (which fails to build: "implicitly has -// type 'any' because it does not have a type annotation and is referenced -// ... in its own initializer"). Assigned once MultiFieldInput exists below. -// Must be declared before, and assigned after, MultiFieldInput is defined, -// so it can't be a `const` despite only ever being assigned once. -// eslint-disable-next-line prefer-const -let multiFieldInputSelfRef: Component; +// The recursive h() call inside renderGroupField cannot name MultiFieldInput +// directly: that forces TypeScript to infer its type from within its own +// initializer, which fails to build with "implicitly has type 'any' because it +// does not have a type annotation and is referenced ... in its own +// initializer". A function declaration solves it twice over -- the explicit +// return type breaks the inference cycle, and the declaration is hoisted, so +// the initializer below can call it. +// +// It must stay a function rather than a module-scope +// `selfRef = MultiFieldInput` assignment. A bare top-level assignment is a side +// effect no bundler can drop, and it anchored MultiFieldInput -> FieldInput -> +// DynamicInput -> every default renderer into consumer bundles that imported +// none of them (~11.5 KB minified). A function body is not evaluated until it +// is called, so nothing is retained until something actually renders a group. +function selfRef(): Component { + // Safe despite the forward reference: this body only runs during a render, + // long after the module has finished evaluating. + // eslint-disable-next-line @typescript-eslint/no-use-before-define + return MultiFieldInput; +} // Repeatable field groups render a nested MultiFieldInput per item, so this // component renders itself recursively (see renderGroupField below) rather @@ -207,7 +218,7 @@ const MultiFieldInput = /* @__PURE__ */ defineComponent({ }, [ h('div', { style: { flex: 1 } }, [ - h(multiFieldInputSelfRef, { + h(selfRef(), { fieldDescriptions: fields, properties: item, rootData: props.rootData ?? data, @@ -268,6 +279,4 @@ const MultiFieldInput = /* @__PURE__ */ defineComponent({ }, }); -multiFieldInputSelfRef = MultiFieldInput; - export default MultiFieldInput; From ed90f2482bd3954533d9e225ac8706a331685993 Mon Sep 17 00:00:00 2001 From: Van Nguyen Date: Fri, 14 Aug 2026 23:25:34 +0700 Subject: [PATCH 12/15] chore(angular): state the target ng-packagr actually emits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tsconfig.json claimed `target: ES2019`, but ng-packagr ignores it: the published fesm2022 bundle uses ES2022 class fields (`value;` declarations, define semantics). So the config described an emit that never shipped, and tsconfig.spec.json had to override target to ES2022 with a comment explaining it was matching reality -- an override that only existed to work around the wrong value here. Set the real target on the base config and let the spec config inherit it. Verified: the published fesm2022 bundle is byte-identical before and after, which is what confirms the old value was inert. Also drop `emitDecoratorMetadata`. Angular carries its own DI metadata on the generated `ɵfac`, and grepping the built bundle for `__metadata`, `Reflect.metadata` and `design:paramtypes` returns zero hits -- the flag produced nothing while implying a reflect-metadata dependency that does not exist. --- packages/angular/tsconfig.json | 31 +++++++++++++++++------------ packages/angular/tsconfig.spec.json | 7 ++++--- 2 files changed, 22 insertions(+), 16 deletions(-) diff --git a/packages/angular/tsconfig.json b/packages/angular/tsconfig.json index 7b911f8..8379ab2 100644 --- a/packages/angular/tsconfig.json +++ b/packages/angular/tsconfig.json @@ -1,13 +1,18 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "composite": false, - "declaration": true, - "outDir": "dist", - "target": "ES2019", - "module": "ES2020", - "experimentalDecorators": true, - "emitDecoratorMetadata": true - }, - "include": ["src/**/*"] -} +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "composite": false, + "declaration": true, + "outDir": "dist", + // Matches what ng-packagr actually emits: the published fesm2022 bundle + // uses ES2022 class fields (`value;` declarations, define semantics). This + // said ES2019 for a while, which ng-packagr ignored, so the build shipped + // ES2022 while the config claimed otherwise - that mismatch is why + // tsconfig.spec.json had to override the target to test what really ships. + "target": "ES2022", + "useDefineForClassFields": true, + "module": "ES2020", + "experimentalDecorators": true + }, + "include": ["src/**/*"] +} diff --git a/packages/angular/tsconfig.spec.json b/packages/angular/tsconfig.spec.json index 602e058..2f60d5b 100644 --- a/packages/angular/tsconfig.spec.json +++ b/packages/angular/tsconfig.spec.json @@ -1,9 +1,10 @@ { "extends": "./tsconfig.json", + // target/useDefineForClassFields are inherited from tsconfig.json, which now + // states the ES2022 emit ng-packagr actually produces. Specs must keep + // matching it or they exercise class-field semantics that never ship. "compilerOptions": { - "outDir": "./out-tsc/spec", - "target": "ES2022", - "useDefineForClassFields": true + "outDir": "./out-tsc/spec" }, "include": ["src/**/*.ts", "test/**/*.ts"] } From 6379747b81da1440959a7397ab6956cce244b576 Mon Sep 17 00:00:00 2001 From: Van Nguyen Date: Fri, 14 Aug 2026 23:25:55 +0700 Subject: [PATCH 13/15] test(angular): cover option resolution, responsive layout and group bounds Angular's branch coverage sat at 75.8% against a 75 floor -- 0.8 points of headroom, so the next uncovered `if` anyone added would have turned CI red for whoever touched the file next rather than whoever wrote it. The other three packages all had double-digit headroom, so this was angular's gap, not a floor set too high. The uncovered branches were real behaviour, not unreachable code: - FieldInput.resolvedOptions (33% covered) decides between an explicit `options` input, a static array on the field description, and a dynamic options callback -- which it deliberately withholds, because resolving it needs form data only MultiFieldInput has. Passing the raw function down would make a renderer try to iterate a function. None of those three paths was tested. Asserted through a renderer that prints what it received, so the tests pin what reaches the renderer rather than what the getter returns. - MultiFieldInput's responsive layout (66%): mobile/desktop resolution, the custom breakpoint (it queries `max-width: breakpoint - 1`, so a viewport exactly at the breakpoint is desktop), the resize handler only scheduling a re-render when the mobile state actually flips, and the no-matchMedia fallback that keeps the component usable in jsdom. Also grid column/gap defaults and the min/max item guards. - DynamicFormDevTools.errorCount (50%): the `this.errors || {}` guard, without which a null errors input takes the host app's render down. Coverage: branches 75.8% -> 84.8% (54 -> 34 missed of 223), lines 91.4% -> 95.4%, functions 92.7% -> 93.9%. Headroom over the floor goes from +0.8 to +9.8, in line with core 85.9 / react 86.8 / vue 87.3. Suite goes from 74 to 91 tests. FieldInput and DynamicFormDevTools reach 100% branch coverage and MultiFieldInput 92.9%, which is the evidence these tests hit the branches they were written for rather than padding the number. --- .../angular/test/DynamicFormDevTools.spec.ts | 16 ++ packages/angular/test/FieldInput.spec.ts | 98 +++++++- .../test/MultiFieldInputLayout.spec.ts | 230 ++++++++++++++++++ 3 files changed, 343 insertions(+), 1 deletion(-) create mode 100644 packages/angular/test/MultiFieldInputLayout.spec.ts diff --git a/packages/angular/test/DynamicFormDevTools.spec.ts b/packages/angular/test/DynamicFormDevTools.spec.ts index e9f20b8..4e16fc2 100644 --- a/packages/angular/test/DynamicFormDevTools.spec.ts +++ b/packages/angular/test/DynamicFormDevTools.spec.ts @@ -73,6 +73,22 @@ describe('DynamicFormDevToolsComponent', () => { expect(badge?.textContent?.trim()).toBe('2'); }); + it('survives a null errors input instead of throwing', () => { + // `errors` defaults to {}, but a form store that has not validated yet can + // hand down null. errorCount() guards with `this.errors || {}`; without it + // the overlay would throw on Object.keys(null) and take the host app's + // render down with it. + fixture.componentRef.setInput('errors', null); + fixture.detectChanges(); + + expect(fixture.componentInstance.errorCount()).toBe(0); + expect( + (fixture.nativeElement as HTMLElement).querySelector( + '.dfk-devtools-badge' + ) + ).toBeNull(); + }); + it('shows the error count in the errors tab label', () => { fixture.componentInstance.errors = { name: ['required'] }; open(fixture); diff --git a/packages/angular/test/FieldInput.spec.ts b/packages/angular/test/FieldInput.spec.ts index d7b56b2..10276c2 100644 --- a/packages/angular/test/FieldInput.spec.ts +++ b/packages/angular/test/FieldInput.spec.ts @@ -1,4 +1,5 @@ -import { ChangeDetectorRef } from '@angular/core'; +import { NgFor } from '@angular/common'; +import { ChangeDetectorRef, Component, Input } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { BaseInputComponent } from '../src/components/BaseInput'; @@ -10,6 +11,23 @@ import { TextRendererComponent, } from './helpers/renderers'; +/** + * Renders whatever `options` it is handed, so the tests below can assert on + * what actually reached the renderer instead of reading FieldInput's getter. + * `.opt` absent means the renderer received no options at all. + */ +@Component({ + selector: 'dfk-test-options', + standalone: true, + imports: [NgFor], + template: `{{ + $any(o).label + }}`, +}) +class OptionsRendererComponent { + @Input() options?: Record[]; +} + describe('FieldInput', () => { let registry: ReturnType; @@ -132,6 +150,84 @@ describe('FieldInput', () => { }); }); +describe('FieldInput option resolution', () => { + let registry: ReturnType; + + function mount(inputs: Record) { + const fixture = TestBed.createComponent(FieldInput); + for (const [key, value] of Object.entries(inputs)) { + fixture.componentRef.setInput(key, value); + } + fixture.detectChanges(); + return fixture; + } + + function renderedOptions(fixture: { nativeElement: HTMLElement }): string[] { + return Array.from(fixture.nativeElement.querySelectorAll('.opt')).map((n) => + (n.textContent || '').trim() + ); + } + + beforeEach(() => { + registry = makeRegistry(); + registry.register('select', OptionsRendererComponent as never); + TestBed.configureTestingModule({ + imports: [FieldInput], + providers: [{ provide: FIELD_REGISTRY, useValue: registry }], + }); + }); + + it("prefers the explicit options input over the field's own options", () => { + // MultiFieldInput resolves options against the live form data and passes + // the result down, so the input has to win over whatever the schema says. + const fixture = mount({ + fieldDescription: { + name: 'city', + type: 'select', + options: [{ label: 'from schema' }], + }, + options: [{ label: 'from parent' }], + }); + + expect(renderedOptions(fixture)).toEqual(['from parent']); + }); + + it('passes a static options array from the field description straight down', () => { + const fixture = mount({ + fieldDescription: { + name: 'city', + type: 'select', + options: [{ label: 'Hanoi' }, { label: 'HCM' }], + }, + }); + + expect(renderedOptions(fixture)).toEqual(['Hanoi', 'HCM']); + }); + + it('withholds a dynamic options callback instead of passing the function down', () => { + // A callback needs the form data to resolve, which FieldInput does not + // have -- only MultiFieldInput does, and it passes the result via the + // `options` input. Handing the raw function to a renderer would make it + // try to iterate a function. + const resolve = vi.fn(() => [{ label: 'never called here' }]); + + const fixture = mount({ + fieldDescription: { name: 'city', type: 'select', options: resolve }, + }); + + expect(renderedOptions(fixture)).toEqual([]); + expect(resolve).not.toHaveBeenCalled(); + }); + + it('passes nothing when the field declares no options at all', () => { + const fixture = mount({ + fieldDescription: { name: 'city', type: 'select' }, + }); + + expect(renderedOptions(fixture)).toEqual([]); + }); +}); + describe('BaseInputComponent', () => { class TestInput extends BaseInputComponent {} diff --git a/packages/angular/test/MultiFieldInputLayout.spec.ts b/packages/angular/test/MultiFieldInputLayout.spec.ts new file mode 100644 index 0000000..f96c91e --- /dev/null +++ b/packages/angular/test/MultiFieldInputLayout.spec.ts @@ -0,0 +1,230 @@ +import { TestBed } from '@angular/core/testing'; +import type { FieldDescription } from '@dynamic-field-kit/core'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { MultiFieldInput } from '../src/components/MultiFieldInput'; +import { FIELD_REGISTRY } from '../src/fieldRegistryToken'; +import { makeRegistry, TextRendererComponent } from './helpers/renderers'; + +const FIELDS: FieldDescription[] = [ + { name: 'first', type: 'text' }, + { name: 'second', type: 'text' }, +]; + +/** + * Installs a matchMedia stub that reports "mobile" for the given viewport + * width, and records every media query the component asks about so a test can + * assert on the breakpoint it derived. jsdom ships no matchMedia at all, which + * is why the component's own `typeof window.matchMedia === 'function'` guard + * exists. + */ +function stubMatchMedia(viewportWidth: number) { + const queries: string[] = []; + const stub = vi.fn((query: string) => { + queries.push(query); + const max = Number(/max-width:\s*(\d+)px/.exec(query)?.[1] ?? NaN); + return { matches: viewportWidth <= max } as MediaQueryList; + }); + (window as unknown as { matchMedia: unknown }).matchMedia = stub; + return queries; +} + +describe('MultiFieldInput responsive layout', () => { + let registry: ReturnType; + + beforeEach(() => { + registry = makeRegistry(); + registry.register('text', TextRendererComponent as never); + TestBed.configureTestingModule({ + imports: [MultiFieldInput], + providers: [{ provide: FIELD_REGISTRY, useValue: registry }], + }); + }); + + afterEach(() => { + delete (window as unknown as { matchMedia?: unknown }).matchMedia; + }); + + function mount(layout: unknown) { + const fixture = TestBed.createComponent(MultiFieldInput); + fixture.componentRef.setInput('fieldDescriptions', FIELDS); + fixture.componentRef.setInput('properties', {}); + fixture.componentRef.setInput('layout', layout); + fixture.detectChanges(); + return fixture; + } + + const RESPONSIVE = { + type: 'responsive', + mobile: 'column', + desktop: { type: 'grid', columns: 3, gap: 16 }, + }; + + it('resolves to the desktop layout above the breakpoint', () => { + stubMatchMedia(1280); + + const fixture = mount(RESPONSIVE); + + expect(fixture.componentInstance.resolvedLayoutType).toBe('grid'); + expect(fixture.componentInstance.columns).toBe(3); + expect(fixture.componentInstance.gap).toBe(16); + }); + + it('resolves to the mobile layout below the breakpoint', () => { + stubMatchMedia(375); + + const fixture = mount(RESPONSIVE); + + expect(fixture.componentInstance.resolvedLayoutType).toBe('column'); + }); + + it('honours a custom breakpoint, querying one pixel below it', () => { + // The component asks for `max-width: px`, so a viewport + // exactly at the breakpoint counts as desktop rather than mobile. + const queries = stubMatchMedia(900); + + const fixture = mount({ ...RESPONSIVE, breakpoint: 900 }); + + expect(queries).toContain('(max-width: 899px)'); + expect(fixture.componentInstance.resolvedLayoutType).toBe('grid'); + }); + + it('falls back to the default breakpoint when none is given', () => { + const queries = stubMatchMedia(1280); + + mount(RESPONSIVE); + + expect(queries).toEqual([expect.stringMatching(/^\(max-width: \d+px\)$/)]); + expect(queries).not.toContain('(max-width: 899px)'); + }); + + it('re-renders on resize only when the mobile state actually flips', () => { + stubMatchMedia(1280); + const fixture = mount(RESPONSIVE); + const cdr = ( + fixture.componentInstance as unknown as { + cdr: { markForCheck: () => void }; + } + ).cdr; + const markForCheck = vi.spyOn(cdr, 'markForCheck'); + + // Still desktop: nothing changed, so no work should be scheduled. + fixture.componentInstance.onWindowResize(); + expect(markForCheck).not.toHaveBeenCalled(); + + // Crossing the breakpoint is the case that must schedule a re-render. + stubMatchMedia(375); + fixture.componentInstance.onWindowResize(); + expect(markForCheck).toHaveBeenCalledTimes(1); + expect(fixture.componentInstance.resolvedLayoutType).toBe('column'); + }); + + it('treats the layout as desktop when the environment has no matchMedia', () => { + // jsdom without the stub: the guard has to keep the component usable + // rather than throwing on `window.matchMedia(...)`. + delete (window as unknown as { matchMedia?: unknown }).matchMedia; + + const fixture = mount(RESPONSIVE); + + expect(fixture.componentInstance.resolvedLayoutType).toBe('grid'); + }); +}); + +describe('MultiFieldInput layout defaults', () => { + let registry: ReturnType; + + beforeEach(() => { + registry = makeRegistry(); + registry.register('text', TextRendererComponent as never); + TestBed.configureTestingModule({ + imports: [MultiFieldInput], + providers: [{ provide: FIELD_REGISTRY, useValue: registry }], + }); + }); + + function mount(layout: unknown) { + const fixture = TestBed.createComponent(MultiFieldInput); + fixture.componentRef.setInput('fieldDescriptions', FIELDS); + fixture.componentRef.setInput('properties', {}); + fixture.componentRef.setInput('layout', layout); + fixture.detectChanges(); + return fixture; + } + + it('defaults a grid without an explicit column count to two columns', () => { + const fixture = mount({ type: 'grid' }); + + expect(fixture.componentInstance.columns).toBe(2); + }); + + it('defaults the gap when the layout config omits it', () => { + const fixture = mount({ type: 'grid', columns: 4 }); + + expect(fixture.componentInstance.gap).toBe(12); + expect(fixture.componentInstance.columns).toBe(4); + }); + + it('reports two columns for a non-grid layout', () => { + const fixture = mount('column'); + + expect(fixture.componentInstance.resolvedLayoutType).toBe('column'); + expect(fixture.componentInstance.columns).toBe(2); + }); +}); + +describe('MultiFieldInput group bounds', () => { + let registry: ReturnType; + + beforeEach(() => { + registry = makeRegistry(); + registry.register('text', TextRendererComponent as never); + TestBed.configureTestingModule({ + imports: [MultiFieldInput], + providers: [{ provide: FIELD_REGISTRY, useValue: registry }], + }); + }); + + function mountGroup(field: FieldDescription, items: unknown) { + const fixture = TestBed.createComponent(MultiFieldInput); + fixture.componentRef.setInput('fieldDescriptions', [field]); + fixture.componentRef.setInput('properties', { [field.name]: items }); + fixture.detectChanges(); + return fixture; + } + + const GROUP: FieldDescription = { + name: 'contacts', + type: 'text', + fields: [{ name: 'email', type: 'text' }], + minItems: 1, + maxItems: 2, + }; + + it('refuses to add past maxItems', () => { + const changes: unknown[] = []; + const fixture = mountGroup(GROUP, [{ email: 'a' }, { email: 'b' }]); + fixture.componentInstance.onChange.subscribe((next) => changes.push(next)); + + fixture.componentInstance.onGroupItemAdd(GROUP); + + expect(changes).toEqual([]); + }); + + it('refuses to remove below minItems', () => { + const changes: unknown[] = []; + const fixture = mountGroup(GROUP, [{ email: 'a' }]); + fixture.componentInstance.onChange.subscribe((next) => changes.push(next)); + + fixture.componentInstance.onGroupItemRemove(GROUP, 0); + + expect(changes).toEqual([]); + }); + + it('treats a non-array group value as empty rather than throwing', () => { + // A schema can declare a group whose data has not been initialised yet, or + // arrives as a scalar from a stale payload. + const fixture = mountGroup(GROUP, 'not-an-array'); + + expect(fixture.componentInstance.canRemoveItem(GROUP)).toBe(false); + expect(fixture.componentInstance.canAddItem(GROUP)).toBe(true); + }); +}); From 06094ef9f75fa5583bab3d00b64ba7dcb334d39e Mon Sep 17 00:00:00 2001 From: Van Nguyen Date: Fri, 14 Aug 2026 23:26:23 +0700 Subject: [PATCH 14/15] perf: stop publishing sourcemaps tsup emitted sourcemaps with sourcesContent, so each .map carried a full copy of the TypeScript source. That is what made them usable at all -- `files` publishes only `dist`, so a map pointing at ../src/*.ts would resolve to nothing -- but it also made them roughly half of every tarball, installed by every consumer. core 157.0 -> 83.0 KB unpacked (-47%), 33.3 -> 17.5 KB packed react 207.6 -> 90.1 KB unpacked (-57%), 46.8 -> 19.2 KB packed vue 250.4 -> 109.4 KB unpacked (-56%), 47.6 -> 20.5 KB packed Nothing that reaches an application bundle changes; sourcemaps never do. What changes is install size, against the ability to step into the library's TypeScript while debugging a consuming app. Calling it out plainly: this is a trade, not a free win. The maps were not broken or dead weight -- I checked, and sourcesContent made them fully self-contained. Each tsup.config.ts now carries that reasoning beside a `sourcemap: false` that is one word from restoring them. Verified no dangling //# sourceMappingURL comments survive in any dist, so nothing 404s looking for a map that is no longer shipped. angular is unaffected; ng-packagr's published output does not carry them. --- .changeset/shiny-otters-repeat.md | 30 ++++++++++++++++++++++++++++++ packages/core/tsup.config.ts | 6 +++++- packages/react/tsup.config.ts | 6 +++++- packages/vue/tsup.config.ts | 6 +++++- 4 files changed, 45 insertions(+), 3 deletions(-) create mode 100644 .changeset/shiny-otters-repeat.md diff --git a/.changeset/shiny-otters-repeat.md b/.changeset/shiny-otters-repeat.md new file mode 100644 index 0000000..14441bd --- /dev/null +++ b/.changeset/shiny-otters-repeat.md @@ -0,0 +1,30 @@ +--- +'@dynamic-field-kit/core': patch +'@dynamic-field-kit/react': patch +'@dynamic-field-kit/vue': patch +--- + +Stop publishing sourcemaps, roughly halving what each package installs. + +tsup was emitting sourcemaps with `sourcesContent`, so every `.map` carried a +full copy of the TypeScript source. That is what made them work at all — `files` +only publishes `dist`, so a map referencing `../src/*.ts` would otherwise +resolve to nothing — but it also made them about half of each tarball, shipped +to every consumer on every install. + +| Package | Unpacked | Tarball | Files | +| ------- | ----------------------- | --------------------- | ----- | +| core | 157.0 → 83.0 KB (−47%) | 33.3 → 17.5 KB (−47%) | 8 → 6 | +| react | 207.6 → 90.1 KB (−57%) | 46.8 → 19.2 KB (−59%) | 8 → 6 | +| vue | 250.4 → 109.4 KB (−56%) | 47.6 → 20.5 KB (−57%) | 8 → 6 | + +Nothing that ends up in an application bundle changes — sourcemaps never do. +What changes is install size, and the ability to step into the library's +TypeScript source while debugging a consuming app. + +This is a deliberate trade, not a free win: the maps worked. Each package's +`tsup.config.ts` carries the reasoning next to a `sourcemap: false` that is one +word away from restoring them. + +`@dynamic-field-kit/angular` is unaffected; ng-packagr's published output does +not carry them. diff --git a/packages/core/tsup.config.ts b/packages/core/tsup.config.ts index 7933f0c..4a9f280 100644 --- a/packages/core/tsup.config.ts +++ b/packages/core/tsup.config.ts @@ -5,5 +5,9 @@ export default defineConfig({ format: ['esm', 'cjs'], dts: true, clean: true, - sourcemap: true, + // Sourcemaps are not published: tsup inlines the full TS source via + // sourcesContent, which made the maps ~48% of the tarball (core: 75.7 KB of + // 157 KB unpacked) on every consumer install. Flip back to `sourcemap: true` + // if stepping into the library source is worth that. + sourcemap: false, }); diff --git a/packages/react/tsup.config.ts b/packages/react/tsup.config.ts index 91aaa2c..0bfed6f 100644 --- a/packages/react/tsup.config.ts +++ b/packages/react/tsup.config.ts @@ -5,7 +5,11 @@ export default defineConfig({ format: ['esm', 'cjs'], dts: true, clean: true, - sourcemap: true, + // Sourcemaps are not published: tsup inlines the full TS source via + // sourcesContent, which made the maps ~48% of the tarball (core: 75.7 KB of + // 157 KB unpacked) on every consumer install. Flip back to `sourcemap: true` + // if stepping into the library source is worth that. + sourcemap: false, // QUAN TRỌNG external: ['react', 'react/jsx-runtime', '@dynamic-field-kit/core'], }); diff --git a/packages/vue/tsup.config.ts b/packages/vue/tsup.config.ts index 170dcb8..0daf56c 100644 --- a/packages/vue/tsup.config.ts +++ b/packages/vue/tsup.config.ts @@ -5,6 +5,10 @@ export default defineConfig({ format: ['esm', 'cjs'], dts: true, clean: true, - sourcemap: true, + // Sourcemaps are not published: tsup inlines the full TS source via + // sourcesContent, which made the maps ~48% of the tarball (core: 75.7 KB of + // 157 KB unpacked) on every consumer install. Flip back to `sourcemap: true` + // if stepping into the library source is worth that. + sourcemap: false, external: ['vue', '@vue/runtime-core', '@dynamic-field-kit/core'], }); From 1e0073d3aebee01f1ec9dd561e2b58ffe8c0fd63 Mon Sep 17 00:00:00 2001 From: Van Nguyen Date: Fri, 14 Aug 2026 23:37:33 +0700 Subject: [PATCH 15/15] ci: move actions off the deprecated Node 20 runtime Every run was annotating 10 warnings: actions/checkout@v4, setup-node@v4, upload-artifact@v4, download-artifact@v4 and codecov-action@v4 all declare `runs.using: node20`, which GitHub deprecated and now force-runs on Node 24 anyway. Bumped to the current majors, each verified against how this repo actually uses it rather than assumed: checkout v4 -> v7 setup-node v4 -> v7 upload-artifact v4 -> v7 download-artifact v4 -> v8 codecov-action v4 -> v7 upload-pages-artifact v3 -> v5 deploy-pages v4 -> v5 Two breaking changes in that range needed checking: - setup-node v6 limits *automatic* package-manager caching to npm. All seven call sites pass `cache: 'npm'` explicitly, so nothing relies on the detection that changed. - download-artifact v5 changed path behaviour for downloads **by ID**. Both call sites download by `name`, so the layout the verify and examples jobs depend on (`packages//dist`) is unaffected. Every input in use was confirmed to still exist in the new majors (`node-version-file`, `cache`, `name`, `path`, `retention-days`, `if-no-files-found`, `files`, `flags`, `fail_ci_if_error`), as was deploy-pages' `page_url` output that the Pages environment reads. upload-pages-artifact and deploy-pages were not in the warning list because the Pages workflow only runs on push to develop, not on a PR. Including them anyway: deploy-pages@v4 is node20, and upload-pages-artifact@v3 wraps upload-artifact@v4, so both would have warned on the next deploy. All four workflows re-parsed as valid YAML; the diff is version tags only. --- .github/workflows/ci.yml | 4 ++-- .github/workflows/deploy-pages.yml | 8 ++++---- .github/workflows/quality-gates.yml | 24 ++++++++++++------------ .github/workflows/release.yml | 4 ++-- 4 files changed, 20 insertions(+), 20 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f8dc2b0..97e73ad 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,13 +23,13 @@ jobs: github.event_name == 'pull_request' && !startsWith(github.head_ref, 'changeset-release/') steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 with: # changeset status diffs against the base branch, so it needs history. fetch-depth: 0 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v7 with: node-version-file: '.nvmrc' cache: 'npm' diff --git a/.github/workflows/deploy-pages.yml b/.github/workflows/deploy-pages.yml index 0f38d8b..8d9bb07 100644 --- a/.github/workflows/deploy-pages.yml +++ b/.github/workflows/deploy-pages.yml @@ -31,10 +31,10 @@ jobs: # prefixed or they 404. SITE_BASE: /${{ github.event.repository.name }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v7 with: node-version-file: '.nvmrc' cache: 'npm' @@ -86,7 +86,7 @@ jobs: find _site -maxdepth 2 -name index.html - name: Upload artifact - uses: actions/upload-pages-artifact@v3 + uses: actions/upload-pages-artifact@v5 with: path: _site @@ -99,4 +99,4 @@ jobs: steps: - name: Deploy to GitHub Pages id: deployment - uses: actions/deploy-pages@v4 + uses: actions/deploy-pages@v5 diff --git a/.github/workflows/quality-gates.yml b/.github/workflows/quality-gates.yml index 69bcd8e..9ab69a8 100644 --- a/.github/workflows/quality-gates.yml +++ b/.github/workflows/quality-gates.yml @@ -9,10 +9,10 @@ jobs: lint-and-build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v7 with: node-version-file: '.nvmrc' cache: 'npm' @@ -38,7 +38,7 @@ jobs: # Common ancestor of the four paths is `packages`, so the artifact stores # `/dist/...`. - name: Upload built dist - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: package-dist path: | @@ -90,10 +90,10 @@ jobs: coverage-file: packages/angular/coverage/lcov.info coverage-name: angular steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v7 with: node-version-file: '.nvmrc' cache: 'npm' @@ -110,7 +110,7 @@ jobs: - name: Upload coverage if: matrix.coverage-file != '' && success() - uses: codecov/codecov-action@v4 + uses: codecov/codecov-action@v7 with: files: ${{ matrix.coverage-file }} flags: unittests @@ -126,10 +126,10 @@ jobs: matrix: app: [react-app, vue-app, angular-app] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v7 with: node-version-file: '.nvmrc' cache: 'npm' @@ -137,7 +137,7 @@ jobs: # The example apps depend on the packages via `file:` paths, so the dist # has to exist before their install resolves them. - name: Download built dist - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: name: package-dist path: packages @@ -165,10 +165,10 @@ jobs: runs-on: ubuntu-latest needs: [lint-and-build, test] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v7 with: node-version-file: '.nvmrc' cache: 'npm' @@ -179,7 +179,7 @@ jobs: # Reuse the dist built by lint-and-build instead of rebuilding all four # packages. Extract into `packages/` so paths become packages//dist. - name: Download built dist - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: name: package-dist path: packages diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 741cfe6..5948442 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -53,13 +53,13 @@ jobs: needs: [gates] runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 with: ref: ${{ github.ref_name }} fetch-depth: 0 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v7 with: node-version-file: '.nvmrc' cache: 'npm'