From ccbf4dd5a361a28997e9e7ad5a10750e8ac33f7b Mon Sep 17 00:00:00 2001 From: Van Nguyen Date: Fri, 4 Sep 2026 02:03:45 +0700 Subject: [PATCH 1/4] fix: declare the peer ranges the packages can actually satisfy The Angular package declared `@angular/core` and `@angular/common` as `>=14 <22` while its form store imports `signal` and `computed`, which Angular introduced in 16. npm accepted an install on 14 or 15 and the package then threw on import - the manifest promised something it had not been able to do for a long time. The range is now `>=16 <22`, so the same install is refused up front. Verified both ways: Angular 16 and 21 import and share one registry with core, and an install against 15 is now rejected. Vue moves from `^3.0.0` to `^3.2.0`, because `useDynamicForm` now aborts whatever is still in flight when the owning effect scope is disposed. Without it an unmounted form held its request open until the response came back. `getCurrentScope` and `onScopeDispose` are both Vue 3.2, and the `getCurrentScope()` guard is for calling the composable outside a scope, which the tests do. Neither range was ever exercised at its floor - the suites only ever run against whatever the workspace installs. `scripts/verify-vue-peer-range.js` server-renders the packed tarballs under Vue 3.2 and the newest 3.x; `scripts/verify-angular-peer-range.js` installs them against Angular 16 and 21 and checks the package imports, its components evaluate, and the registry is shared. Both run in the CI verify job beside the React one. Angular is import-level rather than render-level on purpose: the published fesm2022 needs the CLI's linker to instantiate a component, and import-and-wire is the level that breaks across majors - which is precisely how a floor of 14 survived years of `signal()`. Angular 21 deprecates `@angular/platform-browser-dynamic`. It is gone from the package's devDependencies and from the demo app, which never used it (it bootstraps with `bootstrapApplication`), and the test setup initialises through `@angular/platform-browser/testing` instead. Removing that devDependency meant regenerating the lockfile, and npm on Windows prunes optional entries the Linux runners need while doing it - it dropped @emnapi/core, @emnapi/runtime, @noble/hashes and yaml, which is enough for Unknown command: "ci" Did you mean this? npm ci # Clean install a project To see a list of supported npm commands, run: npm help to refuse the lockfile on CI. They are restored here. --- .github/workflows/quality-gates.yml | 21 ++- example/angular-app/package.json | 1 - package-lock.json | 123 +++++++-------- packages/angular/package.json | 5 +- packages/angular/test/setup.ts | 12 +- packages/vue/package.json | 2 +- packages/vue/src/useDynamicForm.ts | 13 +- packages/vue/test/useDynamicForm.test.ts | 30 ++++ scripts/verify-angular-peer-range.js | 171 ++++++++++++++++++++ scripts/verify-vue-peer-range.js | 189 +++++++++++++++++++++++ 10 files changed, 486 insertions(+), 81 deletions(-) create mode 100644 scripts/verify-angular-peer-range.js create mode 100644 scripts/verify-vue-peer-range.js diff --git a/.github/workflows/quality-gates.yml b/.github/workflows/quality-gates.yml index 676f464..e340711 100644 --- a/.github/workflows/quality-gates.yml +++ b/.github/workflows/quality-gates.yml @@ -195,10 +195,21 @@ jobs: node scripts/verify-package-entrypoints.js node scripts/integration-cross-registry.js - # Packs the built packages and renders them under every React major the - # peerDependencies range claims, in a throwaway project outside the - # workspace. Separate from the step above because it installs from the - # network, so it is the one verification step that can fail for reasons - # unrelated to the code. + # Packs the built packages and exercises them under both ends of every + # framework range the peerDependencies claim, in throwaway projects + # outside the workspace. Separate from the step above because these + # install from the network, so they are the verification steps that can + # fail for reasons unrelated to the code. + # + # React and Vue render; Angular only imports and checks the registry - + # its published fesm2022 needs the CLI's linker to instantiate a + # component. That is still the level that breaks across majors, which is + # how a declared floor of 14 survived years of using signal() (16+). - name: Verify the declared React peer range run: node scripts/verify-react-peer-range.js + + - name: Verify the declared Vue peer range + run: node scripts/verify-vue-peer-range.js + + - name: Verify the declared Angular peer range + run: node scripts/verify-angular-peer-range.js diff --git a/example/angular-app/package.json b/example/angular-app/package.json index ea542db..c7e2050 100644 --- a/example/angular-app/package.json +++ b/example/angular-app/package.json @@ -12,7 +12,6 @@ "@angular/common": "^21.2.0", "@angular/core": "^21.2.0", "@angular/platform-browser": "^21.2.0", - "@angular/platform-browser-dynamic": "^21.2.0", "@dynamic-field-kit/angular": "file:../../packages/angular/dist", "@dynamic-field-kit/core": "file:../../packages/core", "zone.js": "~0.16.0" diff --git a/package-lock.json b/package-lock.json index fbd01fe..7e506ac 100644 --- a/package-lock.json +++ b/package-lock.json @@ -63,6 +63,7 @@ "integrity": "sha512-avRNF3ryDkWqBNege4kIuEalGYI7XnmFJXf/IT/hDjSeHObi+NNXLYfYBcZifjOYuChTRcr7FhR2Ogu4EToiBg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "magic-string": "^0.30.21", "obug": "^2.1.1", @@ -119,6 +120,7 @@ "integrity": "sha512-ipRoLCsfI7NiAKKl8EpDYXLFn09df1Cyz2Qy+VOOvoWO1D4VZo9GeW76U2CnrJm7VuBANfMKwhN5FitZr0HPxQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@angular-devkit/core": "21.2.23", "rxjs": "7.8.2" @@ -138,6 +140,7 @@ "integrity": "sha512-gNG268WXiSaTU0kYBn4rpiIiZ7YuGyaXEv4BGxLSQU5yFEtuN+urOUjiSDDHeC4vXzAGCRnXGK3zJbJwDct9fg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@ampproject/remapping": "2.3.0", "@angular-devkit/architect": "0.2102.23", @@ -339,6 +342,7 @@ "integrity": "sha512-Gp//H+p97uR2xa9b9HknNf+o5VHq89c1GvbeOZPjHKIvV0ZnOEoU2PWDb3Gsy84HojIr1tSYOZ8YGJjbXGtvmA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@ampproject/remapping": "2.3.0", "@angular-devkit/architect": "0.2102.23", @@ -456,6 +460,7 @@ "integrity": "sha512-VWvtZL4xfYmXC56q/1BmtW0San8POAuHOADePJV1Hrjt430QTW5xaXuONZA7ujMs/B3u3PBZEWueKl2JH4LznQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -473,6 +478,7 @@ "integrity": "sha512-3tdHaE30abvVzGSwtjpK/r4i8gqSZ6Q0Xc79XvLSTB3kuo4HYir9uvvc7L4YmhBLfSE64CkJPW+2tLiyz8U8jw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -486,6 +492,7 @@ "integrity": "sha512-lbXXz8e8wCVecHNjb4kXiUPPNSrzZPE7WsQ+nNTEgnMDsFIqWJzmf3FagmNaZ5/wX1G2jnSZFjK4rpacmBIn4Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/core": "7.29.7", "@jridgewell/sourcemap-codec": "^1.4.14", @@ -519,6 +526,7 @@ "integrity": "sha512-2pqDzm3T7/h7QF8XfDziVieEOZqNBM95qNgStM8vmJpFzhJEyLtD89xMQ0e365XcgaTOBEaMCWPswq6OuCIEUQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -565,6 +573,7 @@ "integrity": "sha512-+SNwcb/Xg4VjSmlaJ4/RyJLTtRKVMF4VBo5H0WD+ICugSMYiyuiP7AHB9nzuQt0TeLcrv4KY364z43LueA/iwA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -582,26 +591,6 @@ } } }, - "node_modules/@angular/platform-browser-dynamic": { - "version": "21.2.22", - "resolved": "https://registry.npmjs.org/@angular/platform-browser-dynamic/-/platform-browser-dynamic-21.2.22.tgz", - "integrity": "sha512-EBu3mCNgHPRGwTdzV8XtVtP4Bu9JE4F35Zc6kOr7yt8NRceW1/PlujFL+NfmeIEoAWSdboPgHq3BIBFcRJ81RA==", - "deprecated": "@angular/platform-browser-dynamic is deprecated. Use `@angular/platform-browser` instead.", - "dev": true, - "license": "MIT", - "dependencies": { - "tslib": "^2.3.0" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - }, - "peerDependencies": { - "@angular/common": "21.2.22", - "@angular/compiler": "21.2.22", - "@angular/core": "21.2.22", - "@angular/platform-browser": "21.2.22" - } - }, "node_modules/@asamuzakjp/css-color": { "version": "5.1.11", "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", @@ -684,6 +673,7 @@ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", @@ -3064,6 +3054,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=20.19.0" }, @@ -3112,6 +3103,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=20.19.0" } @@ -3178,7 +3170,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "tslib": "^2.4.0" } @@ -6745,6 +6736,7 @@ "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", @@ -7014,6 +7006,7 @@ "integrity": "sha512-k97ENvZWtvA6yqz5/FS6a7duDgOPEeOQOc2iKS/nY6mX6qJUKtLnWzQS+Xj6tXweyj6ZcTAK2Qecetnvi9nCLA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~8.3.0" } @@ -7157,6 +7150,7 @@ "integrity": "sha512-l4b0DhWioGg6Gt2ebGlvfkFMOjRsauxtsnDRwUSRX1qHq3HdTfQHV8wW9zEXeciai6HfeaKOedQn2Zoofx3WBw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.69.0", "@typescript-eslint/types": "8.69.0", @@ -7306,6 +7300,7 @@ "integrity": "sha512-tUbx60BBqQa31kXF5MCsOOLL5E/WzUuxIn7YpAvq+eaUlqvk8/NXnXMBNAdLCr0icjkzem7iUA5QqWHe/hJ1aw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.69.0", @@ -7918,6 +7913,7 @@ "integrity": "sha512-qbhQZEFmycr+ni/qyuccS4sucNN7VAbDfbkvNxWOX2VfgFm90MNs3/UhRNKoPMEIVn0F8gdlYjLPvqxHwHeQOA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vue/compiler-core": "3.5.42", "@vue/shared": "3.5.42" @@ -7999,6 +7995,7 @@ "integrity": "sha512-2++5dUyYS4gvo7xQXSECUDhB7TS0aOl5SeVfC5qSq1Jgfhjvegw1zqhwTIR3imZ+QYPJQw9gfcFvXGAjGZ7ajQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vue/compiler-ssr": "3.5.42", "@vue/runtime-dom": "3.5.42", @@ -8248,6 +8245,7 @@ "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -8323,6 +8321,7 @@ "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -9023,6 +9022,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.11.12", "caniuse-lite": "^1.0.30001809", @@ -9725,6 +9725,7 @@ "integrity": "sha512-gtTZxTDau1wL7Y7zifc2dd8jHSK/k6BTx/2Xp/BpdlAdnlYWFVt7qhJqgwi7637yRwRQ3qL4ZidbB4I8tA5VOg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", @@ -10596,6 +10597,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -10680,6 +10682,7 @@ "integrity": "sha512-9VaAkDURekixUQJy0oJYl2DcN6oKMfxay7XzaGYAWQwsb6qfKf+x76R2k1L8kb1boc+FyCAaTA9GmiKaaiaF+A==", "dev": true, "license": "MIT", + "peer": true, "workspaces": [ "packages/*" ], @@ -13043,6 +13046,7 @@ "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "jiti": "lib/jiti-cli.mjs" } @@ -13285,6 +13289,7 @@ "integrity": "sha512-umRhrCH7fCi8Uj2RcwKjJdvUORTjeWqkdKx0LbcZvjIwsAVsnIAGcxHaqowPeBFBjQuWOeC/bve0AlpFzF/+SQ==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "copy-anything": "^3.0.5", "parse-node-version": "^1.0.1" @@ -13432,7 +13437,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -13454,7 +13458,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -13476,7 +13479,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -13498,7 +13500,6 @@ "os": [ "freebsd" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -13520,7 +13521,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -13542,7 +13542,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -13564,7 +13563,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -13586,7 +13584,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -13608,7 +13605,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -13630,7 +13626,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -13652,7 +13647,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -15413,6 +15407,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.16", "picocolors": "^1.1.1", @@ -15923,6 +15918,7 @@ "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -15933,6 +15929,7 @@ "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -16350,6 +16347,7 @@ "integrity": "sha512-3Df9jsstwhccuEfmAMi9l8XUh/GOkVObmFTU7CCVBysEbcOZLl84jCtaAZMcPiMz2EGKsATzQcU+Xr3n/wU6cg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "1.0.9" }, @@ -16466,6 +16464,7 @@ "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "tslib": "^2.1.0" } @@ -16559,6 +16558,7 @@ "integrity": "sha512-fDz1zJpd5GycprAbu4Q2PV/RprsRtKC/0z82z0JLgdytmcq0+ujJbJ/09bPGDxCLkKY3Np5cRAOcWiVkLXJURg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "chokidar": "^4.0.0", "immutable": "^5.0.2", @@ -17372,16 +17372,6 @@ "license": "MIT", "optional": true }, - "node_modules/string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "dev": true, - "license": "MIT", - "dependencies": { - "safe-buffer": "~5.2.0" - } - }, "node_modules/string-argv": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz", @@ -17498,6 +17488,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", @@ -17633,6 +17633,7 @@ "integrity": "sha512-jTwoImyr/QbOWFFso3YoU3ik0jBBDJ6JTOQiy/J2YxVJdZCc+5u7skhNwiOR3FQIygFqVUPHl7qbbxtjW2K3Qg==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", @@ -17933,7 +17934,8 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "dev": true, - "license": "0BSD" + "license": "0BSD", + "peer": true }, "node_modules/tsup": { "version": "8.5.1", @@ -18660,6 +18662,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -18806,6 +18809,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "napi-postinstall": "^0.3.4" }, @@ -18922,6 +18926,7 @@ "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0 || ^0.28.0", "fdir": "^6.5.0", @@ -18997,6 +19002,7 @@ "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/expect": "4.1.11", "@vitest/mocker": "4.1.11", @@ -19087,6 +19093,7 @@ "integrity": "sha512-4RyHQTbQvOPs3MfvUO1Sg0YRrKNnA0mAVtvpd12Tg1fKDN7OHBUl1IqSn8zGJjK9nI3NkNp8cgTpVrSZC5TTcA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vue/compiler-dom": "3.5.42", "@vue/compiler-sfc": "3.5.42", @@ -19171,6 +19178,7 @@ "integrity": "sha512-dRXm0a2qcHPUBEzVk8uph0xWSjV/xZxenQQbLwnwP7caQCYpqG1qddwlyEkIDkYn0K8tvmcrZ+bOrzoQ3HxCDw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", @@ -19277,6 +19285,7 @@ "integrity": "sha512-HNLRmamRvVavZQ+avceZifmv8hmdUjg43t6MI4SqJDwFdW7RPQwH5vzGhDRZSX59SgfbeHhLnq3g+uooWo7pVw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/bonjour": "^3.5.13", "@types/connect-history-api-fallback": "^1.5.4", @@ -19974,7 +19983,8 @@ "resolved": "https://registry.npmjs.org/zone.js/-/zone.js-0.16.3.tgz", "integrity": "sha512-ihXL9+vhYyEhXz4TDNpHeAOZN9FVrbog0Il64OIEI28UP/n5AaI6gsccRuOHBfx+206agzyoK527bYIO0Foy6A==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "packages/angular": { "name": "@dynamic-field-kit/angular", @@ -19991,7 +20001,6 @@ "@angular/core": "^21.2.0", "@angular/forms": "^21.2.0", "@angular/platform-browser": "^21.2.0", - "@angular/platform-browser-dynamic": "^21.2.0", "@dynamic-field-kit/core": "^1.5.1", "@vitest/coverage-istanbul": "^4.1.11", "jsdom": "^29.1.1", @@ -20003,8 +20012,8 @@ "zone.js": "~0.16.0" }, "peerDependencies": { - "@angular/common": ">=14 <22", - "@angular/core": ">=14 <22", + "@angular/common": ">=16 <22", + "@angular/core": ">=16 <22", "@dynamic-field-kit/core": "^1.3.0" } }, @@ -20398,6 +20407,7 @@ "integrity": "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.5", @@ -20485,7 +20495,7 @@ }, "peerDependencies": { "@dynamic-field-kit/core": "^1.3.0", - "vue": "^3.0.0" + "vue": "^3.2.0" } }, "smoke": { @@ -20514,7 +20524,6 @@ "integrity": "sha512-Nm4s/jB+4FpFsPhWGEC4h7rzksesmtnMXomo6rCMcg/b8zLQuOziRgkCS1fxDCXOlJB/6Q8oABOZ/OP6RIPj9A==", "dev": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/oxc-project" } @@ -20532,7 +20541,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -20550,7 +20558,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -20568,7 +20575,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -20586,7 +20592,6 @@ "os": [ "freebsd" ], - "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -20604,7 +20609,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -20622,7 +20626,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -20640,7 +20643,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -20658,7 +20660,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -20676,7 +20677,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -20694,7 +20694,6 @@ "os": [ "openharmony" ], - "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -20712,7 +20711,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -20730,7 +20728,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } @@ -20778,7 +20775,6 @@ "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -20806,7 +20802,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.18", "picocolors": "^1.1.1", @@ -20822,7 +20817,6 @@ "integrity": "sha512-g0EtLvBjTUB7jhyV0S/TCup3v/XSVl45vUIGbOGU4QPiyjTenCe4mKuFvW9fEgYmS2Fo42AUssRmNuMziXdrig==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@oxc-project/types": "=0.148.0", "@rolldown/pluginutils": "^1.0.0" @@ -20857,7 +20851,6 @@ "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" diff --git a/packages/angular/package.json b/packages/angular/package.json index b7afede..02b422f 100644 --- a/packages/angular/package.json +++ b/packages/angular/package.json @@ -27,8 +27,8 @@ "CHANGELOG.md" ], "peerDependencies": { - "@angular/common": ">=14 <22", - "@angular/core": ">=14 <22", + "@angular/common": ">=16 <22", + "@angular/core": ">=16 <22", "@dynamic-field-kit/core": "^1.3.0" }, "publishConfig": { @@ -54,7 +54,6 @@ "@angular/core": "^21.2.0", "@angular/forms": "^21.2.0", "@angular/platform-browser": "^21.2.0", - "@angular/platform-browser-dynamic": "^21.2.0", "@dynamic-field-kit/core": "^1.5.1", "@vitest/coverage-istanbul": "^4.1.11", "jsdom": "^29.1.1", diff --git a/packages/angular/test/setup.ts b/packages/angular/test/setup.ts index 13da307..ce38d69 100644 --- a/packages/angular/test/setup.ts +++ b/packages/angular/test/setup.ts @@ -2,11 +2,13 @@ import '@analogjs/vitest-angular/setup-zone'; import { getTestBed } from '@angular/core/testing'; import { - BrowserDynamicTestingModule, - platformBrowserDynamicTesting, -} from '@angular/platform-browser-dynamic/testing'; + BrowserTestingModule, + platformBrowserTesting, +} from '@angular/platform-browser/testing'; +// @angular/platform-browser-dynamic is deprecated as of Angular 21; its testing +// entry point moved into @angular/platform-browser/testing. getTestBed().initTestEnvironment( - BrowserDynamicTestingModule, - platformBrowserDynamicTesting(), + BrowserTestingModule, + platformBrowserTesting(), ); diff --git a/packages/vue/package.json b/packages/vue/package.json index ffe5478..d7c1191 100644 --- a/packages/vue/package.json +++ b/packages/vue/package.json @@ -24,7 +24,7 @@ ], "peerDependencies": { "@dynamic-field-kit/core": "^1.3.0", - "vue": "^3.0.0" + "vue": "^3.2.0" }, "devDependencies": { "@dynamic-field-kit/core": "^1.5.1", diff --git a/packages/vue/src/useDynamicForm.ts b/packages/vue/src/useDynamicForm.ts index 544c965..0d59e23 100644 --- a/packages/vue/src/useDynamicForm.ts +++ b/packages/vue/src/useDynamicForm.ts @@ -7,7 +7,7 @@ import { validateFields, validateFieldsAsync, } from '@dynamic-field-kit/core'; -import { computed, ref } from 'vue'; +import { computed, getCurrentScope, onScopeDispose, ref } from 'vue'; export interface UseDynamicFormOptions { fields: FieldDescription[]; @@ -38,6 +38,17 @@ export function useDynamicForm({ let submitRun = 0; let submitController: AbortController | undefined; + // Cancel whatever is still in flight when the owning component (or effect + // scope) goes away, so an unmounted form stops holding a request open. The + // guard is for calling this composable outside a scope, which the tests do + // and which onScopeDispose would otherwise warn about. + if (getCurrentScope()) { + onScopeDispose(() => { + validationController?.abort(); + submitController?.abort(); + }); + } + const isValid = computed(() => validationResult.value.valid); const isValidationComplete = computed( () => validationResult.value.complete && !isValidating.value, diff --git a/packages/vue/test/useDynamicForm.test.ts b/packages/vue/test/useDynamicForm.test.ts index 14f6abd..ed305f3 100644 --- a/packages/vue/test/useDynamicForm.test.ts +++ b/packages/vue/test/useDynamicForm.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from 'vitest'; +import { effectScope } from 'vue'; import type { FieldDescription } from '../src'; import { useDynamicForm } from '../src'; @@ -294,3 +295,32 @@ describe('useDynamicForm behaviour', () => { expect(form.isSubmitting.value).toBe(false); }); }); + +describe('scope cleanup', () => { + it('aborts an in-flight validation when the owning scope is disposed', async () => { + let seen: AbortSignal | undefined; + const scope = effectScope(); + const form = scope.run(() => + useDynamicForm({ + fields: [ + { + name: 'code', + type: 'text', + validationMode: 'async', + validate: (_v, _d, _r, context) => + new Promise(() => { + seen = context?.signal; + }), + }, + ], + }), + ); + + void form!.validateAsync(); + await Promise.resolve(); + expect(seen?.aborted).toBe(false); + + scope.stop(); + expect(seen?.aborted).toBe(true); + }); +}); diff --git a/scripts/verify-angular-peer-range.js b/scripts/verify-angular-peer-range.js new file mode 100644 index 0000000..865152f --- /dev/null +++ b/scripts/verify-angular-peer-range.js @@ -0,0 +1,171 @@ +#!/usr/bin/env node +/** + * Proves the Angular majors `@dynamic-field-kit/angular` claims in its + * `peerDependencies` actually work. + * + * The suite only ever runs against the one Angular the workspace installs, so + * the declared floor is never exercised - and the floor is what silently rots. + * The package declared `>=14` long after it started importing `signal` and + * `computed`, which do not exist before Angular 16: an install on 14 or 15 + * resolved fine and then failed on import. + * + * A render is out of reach here (the published fesm2022 needs the CLI's linker + * or JIT to instantiate components), so this checks the level that actually + * breaks across majors: the package imports under that Angular, its decorated + * classes evaluate, and it shares one registry with core. That is the same + * depth as scripts/integration-cross-registry.js, run once per range end. + * + * Run from the repo root, after `npm run build`: + * node scripts/verify-angular-peer-range.js + */ +const { execFileSync, execSync } = require('child_process'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const REPO = path.resolve(__dirname, '..'); + +// Kept in sync by hand with packages/angular/package.json +// `peerDependencies['@angular/core']`: the declared floor, and the newest major +// the range admits. A range end missing here is the gap this script closes. +const MAJORS = ['16', '21']; + +const quote = (a) => (/[\s"]/.test(a) ? '"' + a.replace(/"/g, '\\"') + '"' : a); + +const npm = (args, cwd) => + execSync(['npm', ...args.map(quote)].join(' '), { + cwd, + stdio: ['ignore', 'pipe', 'pipe'], + encoding: 'utf8', + }); + +const node = (args, cwd) => + execFileSync(process.execPath, args, { + cwd, + stdio: ['ignore', 'pipe', 'pipe'], + encoding: 'utf8', + }); + +function pack(pkgDir, outDir) { + const out = npm(['pack', '--pack-destination', outDir], pkgDir).trim(); + return path.join(outDir, out.split('\n').pop().trim()); +} + +const APP = ` +import 'zone.js'; +// Angular's compiled output falls back to JIT for some providers; loading the +// compiler up front avoids a throw on import when it is not AOT-linked. +import '@angular/compiler'; +import { VERSION } from '@angular/core'; +import { fieldRegistry as coreRegistry } from '@dynamic-field-kit/core'; +import { + fieldRegistry, + MultiFieldInput, + createDynamicFormStore, + collectFieldPaths, +} from '@dynamic-field-kit/angular'; + +const failures = []; + +coreRegistry.register('text', (props) => 'core-' + (props?.value ?? '')); +const renderer = fieldRegistry.get('text'); +if (typeof renderer !== 'function') { + failures.push('core and angular do not share a registry'); +} else if (renderer({ value: 'X' }) !== 'core-X') { + failures.push('registry wrapper returned the wrong value'); +} + +if (typeof MultiFieldInput !== 'function') { + failures.push('MultiFieldInput did not evaluate as a class'); +} + +// The store is where the signal APIs live - the reason the floor is 16. +const store = createDynamicFormStore({ + fields: [{ name: 'a', type: 'text', validate: (v) => (v ? undefined : 'Required') }], + initialValues: { a: '' }, +}); +if (store.validationStatus() !== 'invalid') { + failures.push('store did not compute a validation status: ' + store.validationStatus()); +} +if (collectFieldPaths([{ name: 'a', type: 'text' }], {}).length !== 1) { + failures.push('re-exported core helper did not work'); +} + +if (failures.length) { + throw new Error(failures.join('; ')); +} +console.log('angular ' + VERSION.full + ' imported and shares one registry'); +`; + +function verify(major, tarballs, tmpRoot) { + const dir = fs.mkdtempSync(path.join(tmpRoot, `ng-${major}-`)); + fs.writeFileSync( + path.join(dir, 'package.json'), + JSON.stringify( + { name: 'consumer', version: '0.0.0', private: true, type: 'module' }, + null, + 2, + ), + ); + fs.writeFileSync(path.join(dir, 'app.mjs'), APP); + + npm( + [ + 'install', + '--no-audit', + '--no-fund', + '--no-package-lock', + `@angular/core@^${major}`, + `@angular/common@^${major}`, + `@angular/compiler@^${major}`, + 'rxjs@^7.8.0', + 'zone.js', + ...tarballs, + ], + dir, + ); + + const installed = require( + path.join(dir, 'node_modules', '@angular', 'core', 'package.json'), + ).version; + if (!installed.startsWith(`${major}.`)) { + throw new Error(`asked for @angular/core ^${major}, got ${installed}`); + } + + const out = node(['app.mjs'], dir).trim(); + console.log(` ${out}`); + return installed; +} + +function main() { + for (const p of ['core', 'angular']) { + const dist = path.join(REPO, 'packages', p, 'dist'); + if (!fs.existsSync(dist)) { + console.error( + `packages/${p}/dist is missing - run \`npm run build\` first.`, + ); + process.exit(1); + } + } + + const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'dfk-ng-peer-')); + try { + const tarballs = [ + pack(path.join(REPO, 'packages', 'core'), tmpRoot), + pack(path.join(REPO, 'packages', 'angular'), tmpRoot), + ]; + + for (const major of MAJORS) { + console.log(`@angular/core ^${major}:`); + verify(major, tarballs, tmpRoot); + } + + console.log( + `OK: @dynamic-field-kit/angular loads at both ends of its declared Angular range (${MAJORS.join(', ')}).`, + ); + } finally { + fs.rmSync(tmpRoot, { recursive: true, force: true }); + } +} + +main(); diff --git a/scripts/verify-vue-peer-range.js b/scripts/verify-vue-peer-range.js new file mode 100644 index 0000000..5074b99 --- /dev/null +++ b/scripts/verify-vue-peer-range.js @@ -0,0 +1,189 @@ +#!/usr/bin/env node +/** + * Proves the Vue versions `@dynamic-field-kit/vue` claims in its + * `peerDependencies` actually work. + * + * The suite only ever runs against whatever Vue the workspace installs, which + * is the newest 3.x. The declared floor is never exercised there, and the floor + * is the half that breaks: the composable calls `getCurrentScope` / + * `onScopeDispose`, which did not exist before Vue 3.2. + * + * So this does what a consumer does. It packs core and vue into tarballs, + * installs them into a throwaway project outside the workspace alongside one + * exact Vue minor, and server-renders a form with `@vue/server-renderer` - + * no jsdom, and the same API on every 3.x. + * + * Run from the repo root, after `npm run build`: + * node scripts/verify-vue-peer-range.js + */ +const { execFileSync, execSync } = require('child_process'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const REPO = path.resolve(__dirname, '..'); + +// Kept in sync by hand with packages/vue/package.json `peerDependencies.vue`: +// the declared floor, and the newest 3.x. A range end missing here is the gap +// this script exists to close. +const VERSIONS = ['3.2', 'latest']; + +// npm is a .cmd shim on Windows, which node 24 refuses to spawn without a +// shell, and passing an argv array *with* a shell only concatenates it. So npm +// gets one quoted command line, and node - a real executable - gets an argv. +const quote = (a) => (/[\s"]/.test(a) ? '"' + a.replace(/"/g, '\\"') + '"' : a); + +const npm = (args, cwd) => + execSync(['npm', ...args.map(quote)].join(' '), { + cwd, + stdio: ['ignore', 'pipe', 'pipe'], + encoding: 'utf8', + }); + +const node = (args, cwd) => + execFileSync(process.execPath, args, { + cwd, + stdio: ['ignore', 'pipe', 'pipe'], + encoding: 'utf8', + }); + +function pack(pkgDir, outDir) { + const out = npm(['pack', '--pack-destination', outDir], pkgDir).trim(); + return path.join(outDir, out.split('\n').pop().trim()); +} + +// A form with one registered renderer and one default renderer, so the render +// covers both the registry path and the built-in fallback. The composable is +// exercised inside an effect scope, which is what pulls in the 3.2-only +// scope API the floor exists to guarantee. +const APP = ` +const { createSSRApp, defineComponent, effectScope, h } = require('vue'); +const { renderToString } = require('@vue/server-renderer'); +const { fieldRegistry } = require('@dynamic-field-kit/core'); +const { DynamicInput, useDynamicForm } = require('@dynamic-field-kit/vue'); + +fieldRegistry.register( + 'custom', + defineComponent({ + props: { value: null }, + setup: (props) => () => + h('input', { 'data-custom': '1', value: String(props.value ?? '') }), + }), +); + +const scope = effectScope(); +const form = scope.run(() => + useDynamicForm({ + fields: [{ name: 'a', type: 'custom' }], + initialValues: { a: 'x' }, + }), +); + +const App = defineComponent({ + setup: () => () => + h('form', null, [ + h(DynamicInput, { type: 'custom', name: 'a', value: 'x' }), + h(DynamicInput, { type: 'text', name: 'b', value: 'y' }), + ]), +}); + +renderToString(createSSRApp(App)).then((html) => { + const failures = []; + if (!html.includes('data-custom="1"')) + failures.push('registered renderer did not render'); + if (!html.includes('value="x"')) + failures.push('registered renderer lost its value'); + if (!html.includes('value="y"')) + failures.push('default text renderer lost its value'); + if (typeof form.validationStatus.value !== 'string') + failures.push('useDynamicForm did not expose validationStatus'); + + // The reason the floor is 3.2: disposing the scope must reach the + // composable's onScopeDispose without throwing. + scope.stop(); + + if (failures.length) { + console.error(html); + throw new Error(failures.join('; ')); + } + console.log( + 'vue ' + require('vue/package.json').version + ' rendered: ' + html.length + ' chars', + ); +}); +`; + +function verify(version, tarballs, tmpRoot) { + const dir = fs.mkdtempSync(path.join(tmpRoot, `vue-${version}-`)); + fs.writeFileSync( + path.join(dir, 'package.json'), + JSON.stringify( + { name: 'consumer', version: '0.0.0', private: true }, + null, + 2, + ), + ); + fs.writeFileSync(path.join(dir, 'app.cjs'), APP); + + const spec = version === 'latest' ? 'vue@latest' : `vue@~${version}`; + const rendererSpec = + version === 'latest' + ? '@vue/server-renderer@latest' + : `@vue/server-renderer@~${version}`; + + npm( + [ + 'install', + '--no-audit', + '--no-fund', + '--no-package-lock', + spec, + rendererSpec, + ...tarballs, + ], + dir, + ); + + const installed = require( + path.join(dir, 'node_modules', 'vue', 'package.json'), + ).version; + if (version !== 'latest' && !installed.startsWith(`${version}.`)) { + throw new Error(`asked for vue ~${version}, got ${installed}`); + } + + const out = node(['app.cjs'], dir).trim(); + console.log(` ${out}`); + return installed; +} + +function main() { + for (const p of ['core', 'vue']) { + const dist = path.join(REPO, 'packages', p, 'dist'); + if (!fs.existsSync(dist)) { + console.error( + `packages/${p}/dist is missing - run \`npm run build\` first.`, + ); + process.exit(1); + } + } + + const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'dfk-vue-peer-')); + try { + const tarballs = [ + pack(path.join(REPO, 'packages', 'core'), tmpRoot), + pack(path.join(REPO, 'packages', 'vue'), tmpRoot), + ]; + + for (const version of VERSIONS) { + console.log(`vue ${version}:`); + verify(version, tarballs, tmpRoot); + } + + console.log( + `OK: @dynamic-field-kit/vue renders at both ends of its declared Vue range (${VERSIONS.join(', ')}).`, + ); + } finally { + fs.rmSync(tmpRoot, { recursive: true, force: true }); + } +} + +main(); From 1a89e25dca9151eaea0a987e29095c2efb7ae7dd Mon Sep 17 00:00:00 2001 From: Van Nguyen Date: Fri, 4 Sep 2026 02:04:00 +0700 Subject: [PATCH 2/4] feat: re-export the new core helpers from every adapter `collectFieldPaths`, `indexGroupPathMap` and the `ValidationContext` type shipped in core with 1.6.0 but reached none of the adapters, so typing a validator's `context` argument meant importing `@dynamic-field-kit/core` alongside the adapter - the exact thing the curated re-export list exists to avoid, and a drift the three lists are supposed to stay free of. The adapter READMEs list them, document the corrected peer ranges, and `scripts/add-dts-extensions.js` records that ng-packagr 21 emits one rolled-up d.ts with no relative specifiers left to rewrite, so the step is a no-op now and stays only as a guard. --- .changeset/olive-cameras-repeat.md | 41 ++++++++++++++++++++++++++++++ packages/angular/README.md | 17 ++++++++++--- packages/angular/src/public-api.ts | 7 ++++- packages/react/README.md | 4 ++- packages/react/src/index.ts | 3 +++ packages/vue/README.md | 10 +++++++- packages/vue/src/index.ts | 3 +++ scripts/add-dts-extensions.js | 8 ++++++ 8 files changed, 86 insertions(+), 7 deletions(-) create mode 100644 .changeset/olive-cameras-repeat.md diff --git a/.changeset/olive-cameras-repeat.md b/.changeset/olive-cameras-repeat.md new file mode 100644 index 0000000..a4aa78a --- /dev/null +++ b/.changeset/olive-cameras-repeat.md @@ -0,0 +1,41 @@ +--- +'@dynamic-field-kit/angular': minor +'@dynamic-field-kit/core': minor +'@dynamic-field-kit/react': minor +'@dynamic-field-kit/vue': minor +--- + +Correct the peer ranges to the ones that actually work, and prove both ends of +each in CI. + +`@dynamic-field-kit/angular` declared `@angular/core` and `@angular/common` as +`>=14 <22`, but the form store is built on `signal` and `computed`, which +Angular introduced in **16**. On 14 or 15 npm accepted the install and the +package then failed on import - the manifest promised something it could not +do. The range is now `>=16 <22`, so the same install is refused up front. + +`@dynamic-field-kit/vue` moves from `vue ^3.0.0` to `^3.2.0`. +`useDynamicForm` now aborts an in-flight validation when the owning effect +scope is disposed, using `getCurrentScope` / `onScopeDispose` - both Vue 3.2. +Without this an unmounted form held its request open until the response came +back. If you are on Vue 3.0 or 3.1, stay on 1.5.x; nothing else in the package +ever required 3.2, but nothing tested below it either. + +Both ranges are now verified rather than asserted: +`scripts/verify-vue-peer-range.js` server-renders the packed tarballs under Vue +3.2 and the newest 3.x, and `scripts/verify-angular-peer-range.js` installs +them against Angular 16 and 21 and checks the package imports, its components +evaluate and it shares one registry with core. Both run in the CI verify job, +next to the React one that has existed since 1.5.0. A render is out of reach +for Angular - the published fesm2022 needs the CLI's linker to instantiate a +component - but import-and-wire is the level that breaks across majors, which +is exactly how a floor of 14 survived years of `signal()`. + +The three adapters now re-export `collectFieldPaths`, `indexGroupPathMap` and +the `ValidationContext` type from core, so typing a validator's `context` +argument no longer means importing `@dynamic-field-kit/core` alongside the +adapter. + +`@angular/platform-browser-dynamic`, which Angular 21 deprecates, is gone from +the package's devDependencies and from the demo app, which never used it - the +test setup now initialises through `@angular/platform-browser/testing`. diff --git a/packages/angular/README.md b/packages/angular/README.md index 5656cb3..807c3eb 100644 --- a/packages/angular/README.md +++ b/packages/angular/README.md @@ -53,11 +53,13 @@ both packages: - `validateField` / `validateFieldAsync` — one field, returns `string[]` - `validateFields` / `validateFieldsAsync` — a whole schema, returns `ValidationResult` +- `collectFieldPaths` — the leaf paths a schema actually has in the data (`contacts[0].email`) +- `indexGroupPathMap` — index an error or touched map by repeatable-group item - `resolveDisabled` / `resolveReadOnly` / `resolveOptions` — resolve a field's dynamic conditions and options - `validators` — the built-in validator helpers (`required`, `email`, `minLength`, `compose`, …) - `FieldDescription` / `FieldTypeKey` / `FieldRendererProps` — the schema and renderer contracts every adapter shares -- `ValidationResult` +- `ValidationResult` / `ValidationContext` `createDynamicFormStore` keeps live validation synchronous - a validator declared or detected as async is never invoked on that path. Its `handleSubmit` runs one @@ -76,9 +78,16 @@ For a complete UI integration, see the ## Supported Angular versions -The package declares `@angular/core` and `@angular/common` as -`>=14 <22`. CI builds and tests it against Angular 21, which is also what the -demo app runs; that is the version the setup below is written for. +The package declares `@angular/core` and `@angular/common` as `>=16 <22`, +and `scripts/verify-angular-peer-range.js` proves both ends in CI by installing +the packed tarballs against Angular 16 and 21 outside the workspace. + +The floor is 16 because the form store is built on `signal` and `computed`, +which Angular introduced in 16. It read `>=14` until 1.6.0: npm accepted the +install on 14 and 15 and the package then failed on import. + +The suite, the build and the demo app all run Angular 21, which is the version +the setup below is written for. ## Basic setup diff --git a/packages/angular/src/public-api.ts b/packages/angular/src/public-api.ts index 8882c3b..f87cfc0 100644 --- a/packages/angular/src/public-api.ts +++ b/packages/angular/src/public-api.ts @@ -30,12 +30,17 @@ export { validateFieldAsync, validateFields, validateFieldsAsync, + collectFieldPaths, + indexGroupPathMap, resolveDisabled, resolveReadOnly, resolveOptions, validators, } from '@dynamic-field-kit/core'; -export type { ValidationResult } from '@dynamic-field-kit/core'; +export type { + ValidationResult, + ValidationContext, +} from '@dynamic-field-kit/core'; // Scoped registry: provide FIELD_REGISTRY on a component/route to give that // subtree an isolated set of renderers. diff --git a/packages/react/README.md b/packages/react/README.md index b76706a..f9e08cc 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -41,9 +41,11 @@ both packages: - `validateField` / `validateFieldAsync` — one field, returns `string[]` - `validateFields` / `validateFieldsAsync` — a whole schema, returns `ValidationResult` +- `collectFieldPaths` — the leaf paths a schema actually has in the data (`contacts[0].email`) +- `indexGroupPathMap` — index an error or touched map by repeatable-group item - `resolveDisabled` / `resolveReadOnly` / `resolveOptions` — resolve a field's dynamic conditions and options - `validators` — the built-in validator helpers (`required`, `email`, `minLength`, `compose`, …) -- `ValidationResult` +- `ValidationResult` / `ValidationContext` `useDynamicForm` keeps live validation synchronous - a validator declared or detected as async is never invoked on that path. Its `handleSubmit` runs one diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 584b9dd..da76f1e 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -45,9 +45,12 @@ export { validateFieldAsync, validateFields, validateFieldsAsync, + collectFieldPaths, + indexGroupPathMap, resolveDisabled, resolveReadOnly, resolveOptions, validators, type ValidationResult, + type ValidationContext, } from '@dynamic-field-kit/core'; diff --git a/packages/vue/README.md b/packages/vue/README.md index 8061104..e0be5b7 100644 --- a/packages/vue/README.md +++ b/packages/vue/README.md @@ -2,6 +2,12 @@ Vue 3 adapter for `@dynamic-field-kit/core`. +Requires **Vue 3.2 or newer** (`peerDependencies: vue ^3.2.0`). The floor is +3.2 because `useDynamicForm` uses `getCurrentScope` / `onScopeDispose` to +abort an in-flight validation when the owning scope goes away. +`scripts/verify-vue-peer-range.js` renders the packed tarballs under 3.2 and +the newest 3.x in CI, so the range is proven rather than asserted. + 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/ — tabs for the @@ -39,9 +45,11 @@ both packages: - `validateField` / `validateFieldAsync` — one field, returns `string[]` - `validateFields` / `validateFieldsAsync` — a whole schema, returns `ValidationResult` +- `collectFieldPaths` — the leaf paths a schema actually has in the data (`contacts[0].email`) +- `indexGroupPathMap` — index an error or touched map by repeatable-group item - `resolveDisabled` / `resolveReadOnly` / `resolveOptions` — resolve a field's dynamic conditions and options - `validators` — the built-in validator helpers (`required`, `email`, `minLength`, `compose`, …) -- `ValidationResult` +- `ValidationResult` / `ValidationContext` `useDynamicForm` keeps live validation synchronous - a validator declared or detected as async is never invoked on that path. Its `handleSubmit` runs one diff --git a/packages/vue/src/index.ts b/packages/vue/src/index.ts index 5bd2d01..cd7e25e 100644 --- a/packages/vue/src/index.ts +++ b/packages/vue/src/index.ts @@ -24,6 +24,8 @@ export { validateFieldAsync, validateFields, validateFieldsAsync, + collectFieldPaths, + indexGroupPathMap, resolveDisabled, resolveReadOnly, resolveOptions, @@ -34,6 +36,7 @@ export { type FieldTypeMap, type Properties, type ValidationResult, + type ValidationContext, buildFieldRendererProps, makeFieldId, FIELD_RENDERER_PROP_KEYS, diff --git a/scripts/add-dts-extensions.js b/scripts/add-dts-extensions.js index 0420a7b..118146d 100644 --- a/scripts/add-dts-extensions.js +++ b/scripts/add-dts-extensions.js @@ -25,6 +25,14 @@ * rewrite to a path that does not exist would trade one resolution error for * a harder-to-read one. * + * Since ng-packagr 21 this rewrites nothing: it emits one rolled-up + * `dist/types/.d.ts` with no relative specifiers left to fix, so the + * step reports "0 relative specifiers" and `arethetypeswrong` is green on + * node16 without it. It stays as the guard it was written to be - the emit + * shape is ng-packagr's to change back, and TS2834 is silent until a consumer + * on node16 resolution hits it. `scripts/verify-package-entrypoints.js` and + * the attw check before a release are what would catch a regression. + * * Usage: node scripts/add-dts-extensions.js [...more dirs] * Paths are resolved against the caller's cwd, so a package can pass `dist`. */ From 8451c337265b50f414387009461ffb92c922458f Mon Sep 17 00:00:00 2001 From: Van Nguyen Date: Fri, 4 Sep 2026 02:08:14 +0700 Subject: [PATCH 3/4] test(ci): link the published bundle with the floor Angular's own linker Importing the package under Angular 16 proves it resolves and evaluates, but not the thing that actually breaks a consumer across majors. The published fesm2022 ships partial declarations, and a consumer's build links them with *its* Angular. If building the library on a newer Angular raised their `minVersion`, every consumer below that version would fail in the linker while installing and importing perfectly well - the failure would surface in their app, never here. So the check runs the floor's own `@angular/compiler-cli` linker over the bundle and asserts nothing is left partial. Against the ng-packagr 21 output the Angular 16 linker consumes all of it: the declarations still carry `minVersion` 12 and 14, because ng-packagr stamps what the emitted code needs, not the compiler that emitted it. Only the floor is linked. A newer linker accepting an older declaration is the direction that was never in doubt. --- scripts/verify-angular-peer-range.js | 88 ++++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) diff --git a/scripts/verify-angular-peer-range.js b/scripts/verify-angular-peer-range.js index 865152f..fb7c948 100644 --- a/scripts/verify-angular-peer-range.js +++ b/scripts/verify-angular-peer-range.js @@ -15,6 +15,12 @@ * classes evaluate, and it shares one registry with core. That is the same * depth as scripts/integration-cross-registry.js, run once per range end. * + * It then runs the floor's own Angular linker over the published fesm2022 + * bundle. That is the check that would catch the real cross-major hazard: the + * bundle ships partial declarations, and if building on a newer Angular raised + * their `minVersion`, every consumer below that version would fail in the + * linker while installing and importing perfectly well. + * * Run from the repo root, after `npm run build`: * node scripts/verify-angular-peer-range.js */ @@ -137,6 +143,83 @@ function verify(major, tarballs, tmpRoot) { return installed; } +const LINK = ` +const fs = require('fs'); +const babel = require('@babel/core'); +const { createEs2015LinkerPlugin } = require('@angular/compiler-cli/linker/babel'); +const { NodeJSFileSystem, ConsoleLogger, LogLevel } = require('@angular/compiler-cli'); + +const file = process.argv[2]; +const out = babel.transformSync(fs.readFileSync(file, 'utf8'), { + filename: file, + configFile: false, + babelrc: false, + compact: false, + plugins: [ + createEs2015LinkerPlugin({ + fileSystem: new NodeJSFileSystem(), + logger: new ConsoleLogger(LogLevel.warn), + linkerJitMode: false, + }), + ], +}); + +const leftover = (out.code.match(/ɵɵngDeclare/g) || []).length; +if (leftover > 0) { + throw new Error(leftover + ' partial declarations were left unlinked'); +} +console.log( + 'linker ' + + require('@angular/compiler-cli/package.json').version + + ' linked the published bundle, 0 partial declarations left', +); +`; + +/** + * Links the published bundle with the *floor* Angular's linker. A consumer app + * runs this as part of its own build, and it is the step that fails when a + * library was compiled by a compiler too new for it. + */ +function verifyLinker(major, tmpRoot) { + const dir = fs.mkdtempSync(path.join(tmpRoot, `link-${major}-`)); + fs.writeFileSync( + path.join(dir, 'package.json'), + JSON.stringify( + { name: 'linker', version: '0.0.0', private: true }, + null, + 2, + ), + ); + fs.writeFileSync(path.join(dir, 'link.cjs'), LINK); + + npm( + [ + 'install', + '--no-audit', + '--no-fund', + '--no-package-lock', + `@angular/compiler-cli@^${major}`, + `@angular/compiler@^${major}`, + '@babel/core', + ], + dir, + ); + + // The bundle straight off disk, not through an install: the linker only + // reads the file, and installing the tarball here would drag in the whole + // peer set for no benefit. + const bundle = path.join( + REPO, + 'packages', + 'angular', + 'dist', + 'fesm2022', + 'dynamic-field-kit-angular.mjs', + ); + const out = node(['link.cjs', bundle], dir).trim(); + console.log(` ${out}`); +} + function main() { for (const p of ['core', 'angular']) { const dist = path.join(REPO, 'packages', p, 'dist'); @@ -160,6 +243,11 @@ function main() { verify(major, tarballs, tmpRoot); } + // Only the floor: a newer linker accepting an older declaration is the + // direction that has never been in doubt. + console.log(`@angular/compiler-cli ^${MAJORS[0]} linking the bundle:`); + verifyLinker(MAJORS[0], tmpRoot); + console.log( `OK: @dynamic-field-kit/angular loads at both ends of its declared Angular range (${MAJORS.join(', ')}).`, ); From 1e5abc58ee9a64599d30a8b1d4f568988de4732b Mon Sep 17 00:00:00 2001 From: Van Nguyen Date: Fri, 4 Sep 2026 02:14:06 +0700 Subject: [PATCH 4/4] test(ci): fail the build when the docs import something that does not exist The docs carry dozens of copy-paste import statements and nothing checked them, so a rename lands, the READMEs keep the old name, and the first person to find out is a reader whose editor cannot resolve it. This reads every `import { ... } from '@dynamic-field-kit/*'` inside a fenced code block and asserts each name is exported, resolving the export list from each package's built .d.ts through the TypeScript compiler so type-only exports count. Prose is ignored - only fenced blocks are checked, because they are what people copy - and a package that has not been built is skipped rather than treated as exporting nothing. Proven non-vacuous the way the peer range checks are: adding an import of a name that does not exist exits 1 and names the file and line. The docs pass as they stand. The one thing worth knowing about the extractor is that the brace span must exclude braces - `import { h } from 'vue'` above an import of this package was otherwise swallowed into one statement, and every name in the first import was reported missing. --- .github/workflows/quality-gates.yml | 1 + scripts/check-docs-api-references.js | 191 ++++++++++++++++++++++ scripts/check-docs-api-references.test.js | 135 +++++++++++++++ 3 files changed, 327 insertions(+) create mode 100644 scripts/check-docs-api-references.js create mode 100644 scripts/check-docs-api-references.test.js diff --git a/.github/workflows/quality-gates.yml b/.github/workflows/quality-gates.yml index e340711..b8ce1af 100644 --- a/.github/workflows/quality-gates.yml +++ b/.github/workflows/quality-gates.yml @@ -193,6 +193,7 @@ jobs: node scripts/check-cross-framework-imports.js node scripts/check-renderer-prop-parity.js node scripts/verify-package-entrypoints.js + node scripts/check-docs-api-references.js node scripts/integration-cross-registry.js # Packs the built packages and exercises them under both ends of every diff --git a/scripts/check-docs-api-references.js b/scripts/check-docs-api-references.js new file mode 100644 index 0000000..34f2fed --- /dev/null +++ b/scripts/check-docs-api-references.js @@ -0,0 +1,191 @@ +#!/usr/bin/env node +// Verify that every symbol the docs import from a @dynamic-field-kit package +// is actually exported by it. Documentation drifts silently: a rename lands, +// the READMEs keep the old name, and nothing fails until a reader copies the +// snippet. Only import statements inside fenced code blocks are checked - +// they are unambiguous, unlike prose, and they are what people copy. +// +// Exports are read from each package's built .d.ts through the TypeScript +// compiler, so type-only exports count too. Packages that are not built are +// skipped, the way the other integration checks skip. + +const fs = require('fs'); +const path = require('path'); + +const PACKAGES = ['core', 'react', 'vue', 'angular']; + +const DOC_GLOBS = [ + 'README.md', + 'docs', + ...PACKAGES.map((p) => path.join('packages', p, 'README.md')), +]; + +const FENCE = /^```([A-Za-z0-9]*)\s*$/; +const CODE_LANGS = new Set([ + 'ts', + 'tsx', + 'js', + 'jsx', + 'typescript', + 'javascript', +]); + +// [^{}] rather than [\s\S] so the span cannot run from one import statement +// through the next: `import { h } from 'vue'` sitting above an import of this +// package was otherwise read as a single statement, and every name in the +// first one was reported as missing. +const IMPORT = + /import\s*\{([^{}]*)\}\s*from\s*['"]@dynamic-field-kit\/([a-z]+)['"]/g; + +/** Named imports of @dynamic-field-kit packages inside fenced code blocks. */ +function collectDocImports(file) { + const lines = fs.readFileSync(file, 'utf8').split(/\r?\n/); + + // Blank out everything that is not inside a code fence, keeping line numbers + // intact so a problem can point at the real line. + let lang = null; + const code = lines.map((line) => { + const fence = FENCE.exec(line); + if (fence) { + lang = lang === null ? fence[1].toLowerCase() : null; + return ''; + } + return lang !== null && CODE_LANGS.has(lang) ? line : ''; + }); + + const text = code.join('\n'); + const found = []; + for (const match of text.matchAll(IMPORT)) { + const line = text.slice(0, match.index).split('\n').length; + const names = match[1] + .split(',') + .map((part) => part.trim()) + .filter(Boolean) + // `type Foo`, `Foo as Bar` - the exported name is the first identifier. + .map((part) => + part + .replace(/^type\s+/, '') + .split(/\s+as\s+/)[0] + .trim(), + ) + .filter((name) => /^[A-Za-z_$][\w$]*$/.test(name)); + + for (const name of names) { + found.push({ pkg: match[2], name, line }); + } + } + return found; +} + +/** One message per documented import the package does not export. */ +function findDocApiProblems(files, exportsByPackage) { + return files.flatMap((file) => + collectDocImports(file) + .filter(({ pkg, name }) => { + const exported = exportsByPackage[pkg]; + // A package that was not built is not evidence of anything. + return exported !== undefined && !exported.has(name); + }) + .map( + ({ pkg, name, line }) => + `${path.basename(file)}:${line} imports ${name} from @dynamic-field-kit/${pkg}, which does not export it`, + ), + ); +} + +function typesEntry(root, pkg) { + const dir = path.join(root, 'packages', pkg); + const manifestPath = path.join(dir, 'package.json'); + if (!fs.existsSync(manifestPath)) { + return undefined; + } + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + const declared = manifest.types || manifest.typings; + if (!declared) { + return undefined; + } + const entry = path.join(dir, declared); + return fs.existsSync(entry) ? entry : undefined; +} + +/** Exported names per package, read from the built declarations. */ +function collectExports(root) { + // Resolved lazily so the unit tests can exercise the pure functions above + // without TypeScript or a build. + const ts = require('typescript'); + const byPackage = {}; + + for (const pkg of PACKAGES) { + const entry = typesEntry(root, pkg); + if (!entry) { + continue; + } + const program = ts.createProgram([entry], { + noEmit: true, + skipLibCheck: true, + moduleResolution: ts.ModuleResolutionKind.Bundler, + module: ts.ModuleKind.ESNext, + target: ts.ScriptTarget.ES2022, + }); + const source = program.getSourceFile(entry); + const symbol = + source && program.getTypeChecker().getSymbolAtLocation(source); + if (!symbol) { + continue; + } + byPackage[pkg] = new Set( + program + .getTypeChecker() + .getExportsOfModule(symbol) + .map((s) => s.getName()), + ); + } + + return byPackage; +} + +function docFiles(root) { + return DOC_GLOBS.flatMap((entry) => { + const full = path.join(root, entry); + if (!fs.existsSync(full)) { + return []; + } + if (fs.statSync(full).isDirectory()) { + return fs + .readdirSync(full) + .filter((f) => f.endsWith('.md')) + .map((f) => path.join(full, f)); + } + return [full]; + }); +} + +module.exports = { + collectDocImports, + findDocApiProblems, + collectExports, + docFiles, +}; + +if (require.main === module) { + const root = process.cwd(); + const exportsByPackage = collectExports(root); + + if (Object.keys(exportsByPackage).length === 0) { + console.log('Docs API check skipped: no package has been built yet.'); + process.exit(0); + } + + const problems = findDocApiProblems(docFiles(root), exportsByPackage); + if (problems.length > 0) { + console.error('Documented imports that do not exist:'); + for (const problem of problems) { + console.error(` - ${problem}`); + } + process.exit(1); + } + + console.log( + `Docs API check passed: every documented import resolves against ${Object.keys(exportsByPackage).join(', ')}.`, + ); +} diff --git a/scripts/check-docs-api-references.test.js b/scripts/check-docs-api-references.test.js new file mode 100644 index 0000000..072bcc2 --- /dev/null +++ b/scripts/check-docs-api-references.test.js @@ -0,0 +1,135 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { + collectDocImports, + findDocApiProblems, +} from './check-docs-api-references.js'; + +const tempRoots = []; + +function makeDoc(markdown) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'doc-api-')); + tempRoots.push(root); + const file = path.join(root, 'README.md'); + fs.writeFileSync(file, markdown); + return file; +} + +afterEach(() => { + while (tempRoots.length) { + fs.rmSync(tempRoots.pop(), { recursive: true, force: true }); + } +}); + +describe('collectDocImports', () => { + it('reads named imports out of a fenced code block', () => { + const file = makeDoc( + [ + '```ts', + "import { validateFields } from '@dynamic-field-kit/core';", + '```', + ].join('\n'), + ); + + expect(collectDocImports(file)).toEqual([ + { pkg: 'core', name: 'validateFields', line: 2 }, + ]); + }); + + it('handles multi-line imports, aliases and type imports', () => { + const file = makeDoc( + [ + '```tsx', + 'import {', + ' fieldRegistry as registry,', + ' type ValidationResult,', + "} from '@dynamic-field-kit/react';", + '```', + ].join('\n'), + ); + + expect(collectDocImports(file).map((i) => i.name)).toEqual([ + 'fieldRegistry', + 'ValidationResult', + ]); + }); + + it('does not read a preceding import as part of this one', () => { + const file = makeDoc( + [ + '```ts', + "import { defineComponent, h } from 'vue';", + "import { MultiFieldInput } from '@dynamic-field-kit/vue';", + '```', + ].join('\n'), + ); + + expect(collectDocImports(file).map((i) => i.name)).toEqual([ + 'MultiFieldInput', + ]); + }); + + it('ignores imports from other packages', () => { + const file = makeDoc( + ['```ts', "import { ref } from 'vue';", '```'].join('\n'), + ); + + expect(collectDocImports(file)).toEqual([]); + }); + + it('ignores prose that merely mentions an import', () => { + const file = makeDoc( + "Call `import { nope } from '@dynamic-field-kit/core'` to do the thing.", + ); + + expect(collectDocImports(file)).toEqual([]); + }); +}); + +describe('findDocApiProblems', () => { + const exportsByPackage = { + core: new Set(['validateFields', 'FieldDescription']), + }; + + it('accepts a doc that only imports things the package exports', () => { + const file = makeDoc( + [ + '```ts', + "import { validateFields, FieldDescription } from '@dynamic-field-kit/core';", + '```', + ].join('\n'), + ); + + expect(findDocApiProblems([file], exportsByPackage)).toEqual([]); + }); + + it('flags a name the package does not export', () => { + const file = makeDoc( + [ + '```ts', + "import { validateAll } from '@dynamic-field-kit/core';", + '```', + ].join('\n'), + ); + + const [problem] = findDocApiProblems([file], exportsByPackage); + expect(problem).toContain('validateAll'); + expect(problem).toContain('@dynamic-field-kit/core'); + expect(problem).toContain('README.md:2'); + }); + + it('says nothing about a package whose exports were not collected', () => { + const file = makeDoc( + [ + '```ts', + "import { whatever } from '@dynamic-field-kit/vue';", + '```', + ].join('\n'), + ); + + expect(findDocApiProblems([file], exportsByPackage)).toEqual([]); + }); +});