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/.github/workflows/quality-gates.yml b/.github/workflows/quality-gates.yml index 676f464..b8ce1af 100644 --- a/.github/workflows/quality-gates.yml +++ b/.github/workflows/quality-gates.yml @@ -193,12 +193,24 @@ 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 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/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/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/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/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/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/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/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/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/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`. */ 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([]); + }); +}); diff --git a/scripts/verify-angular-peer-range.js b/scripts/verify-angular-peer-range.js new file mode 100644 index 0000000..fb7c948 --- /dev/null +++ b/scripts/verify-angular-peer-range.js @@ -0,0 +1,259 @@ +#!/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. + * + * 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 + */ +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; +} + +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'); + 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); + } + + // 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(', ')}).`, + ); + } 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();