Conversation
…ations (#30) * ci: optimize workspace build script and release workflow verification * feat(core): export default HTML5 field types in FieldTypeMap * feat(react): implement default HTML5 built-in fallback renderers * feat(vue): implement default HTML5 built-in fallback renderers * feat(angular): implement default HTML5 built-in fallback renderers * docs: update READMEs and examples to showcase default zero-config renderers * test: add test coverage for default password, email, and textarea renderers in React and Vue * ci: upgrade Node.js version from 20 to 22 across GitHub workflows * test(angular): expand test coverage for default HTML5 fallback renderers
…ing (#31) * feat(core): add Zod and Yup schema validation helpers and extend default renderers map * feat: add useDynamicForm, schema adapters, extended renderers, wizard engine, and DevTools * docs: update READMEs and example app with enterprise features * fix(core): parse schemas synchronously and make adapter target explicit Why: zodValidator checked safeParseAsync before safeParse. Real Zod schemas expose both, so the async branch always won and the validator returned a Promise. validateField treats a Promise as "no errors", and useDynamicForm only ever calls the synchronous validateFields - so a Zod-validated form silently reported valid for invalid data. yupValidator had a related bug: Yup throws a plain Error (not a ValidationError) when a test is async, and that internal message "Validation test of type ... returned a Promise" was surfaced to the user as a form error. The payload rule was also a guess - `value !== undefined ? value : data` - which picked the field value for a form-level schema, and differed from standardSchemaValidator. What: - Prefer sync parsing (safeParse / validateSync), falling back to the async path only when the schema genuinely requires it. - Distinguish a Yup ValidationError from its async-test error, and retry asynchronously instead of leaking the internal message. - Replace the payload heuristic with an explicit SchemaValidatorOptions `{ field, target: 'form' | 'field' }`, applied identically by all three adapters. The `zodValidator(schema, 'email')` shorthand still works. - Add zod and yup as core devDependencies; the adapters were previously only exercised against hand-rolled fakes that hid both bugs. How to test: npm run test --workspace=@dynamic-field-kit/core * feat: give useDynamicForm the same surface in every framework Why: The three form-state APIs had drifted. React was missing isSubmitting and isSubmitted, which Vue and Angular both exposed, so a React consumer had no way to disable a submit button while a submission was in flight. Angular's handleSubmit executed immediately instead of returning a handler like React and Vue, and never called preventDefault - so it could not be bound to a native form submit at all. What: - React: add isSubmitting / isSubmitted, set around handleSubmit in a try/finally so a throwing onValid still clears the flag, and reset both. - Angular: handleSubmit(onValid, onInvalid) now returns an async handler that calls preventDefault, matching React and Vue. These APIs are unreleased (published angular is 1.4.0 without the store), so no published consumer is affected. How to test: npm run test --workspace=@dynamic-field-kit/react npm run test --workspace=@dynamic-field-kit/angular * test: cover DevTools, extended renderers and group helpers Why: The new feature work landed largely untested and pushed every package below its coverage floor, which is a hard CI gate: core 68/63/79/68, react functions 77.7, vue 84/74/72/84, angular 83/71/85/83. On develop core sat at 88/85/100/88, so this was a regression introduced by the feature branch. What: Tests for the code that shipped without any - group array helpers (move/swap/insert plus focusFirstInvalidField under jsdom), wizard index clamping and the empty-steps case, the DevTools overlay across all four tabs in all three frameworks, and the extended HTML5 renderers (date/time/ datetime-local/switch/file, option id/name fallbacks, range). Coverage is now core 89.95/85.04/96.96/90.33, react 97.34/86.95/95.65/97.34, vue 98.87/87.01/94.02/98.87, angular 91.30/75.56/92.40/91.22 - all above their floors. Suite grew from 249 to 394 tests. How to test: npm run test --workspace=@dynamic-field-kit/<pkg> -- --coverage * docs: document adapter targets and add the missing changeset Why: The three feature commits on this branch added public API to all four packages without a changeset, so the work would have been merged and never released. The README also promised "Integrated zodValidator" without saying that adapters parse synchronously, which is the difference between a form that validates and one that silently passes. What: - Add a minor changeset covering core, react, vue and angular. - Document the schema adapter contract: form vs field target, the field-name shorthand, and when a schema forces validateFieldsAsync. - Note that all three frameworks share one hook surface. How to test: npx changeset status * fix(core): add "switch" to FieldTypeMap Why: Both the react and vue defaultRenderersMap register a `switch` renderer, but FieldTypeMap had no `switch` entry - so `type: 'switch'` failed to typecheck and the shipped renderer was unreachable from TypeScript. The example app hit exactly this: `Type '"switch"' is not assignable to type 'FieldTypeKey'`. What: Add `switch: boolean` to FieldTypeMap, and a type test asserting every key the default renderer maps register is a usable field type, so the two cannot drift apart again. How to test: npm run test:types --workspace=@dynamic-field-kit/core * fix: stop shipping angular sources and drop a non-existent example prop Why: Two problems that CI cannot currently see, because the example apps are not workspaces and are not referenced by any workflow, and because the angular package has no `files` field. The example passed `onBlurField` to MultiFieldInput, which has no such prop - it manages blur internally. The page therefore did not compile. The angular tarball shipped src/ and test/ alongside dist/ - 61 files where react ships 8. What: - example: remove the `onBlurField` prop so the page compiles. - angular: add `files: ["dist"]`. Tarball drops 61 -> 33 files, dist/ only. Note: useDynamicForm's handleBlur/touched cannot currently be wired into MultiFieldInput at all. Worth a follow-up on whether the component should expose a blur hook. How to test: cd example/react-app && npx next build cd packages/angular && npm pack --dry-run * ci: gate releases on the same checks as PRs, and cover what CI could not see Why: Publishing was effectively ungated. Release triggered on push to master, did not depend on CI, and ran a thinner set of checks - no lint, no format, no coverage floors, no verify scripts. The two workflows are independent, as the 2026-08-04 history shows: CI succeeded on master at 16:13:10 while Release failed at 16:13:09 on the same commit. The reverse - publishing while CI is red - was equally possible. Three more blind spots: - The example apps are not workspaces and were referenced by no workflow, so nothing compiled them. Both bugs fixed in the previous two commits (a missing FieldTypeMap entry and a non-existent prop) were sitting in an example that had never been built in CI. - No check that a PR touching a package adds a changeset. The three feature commits on this branch had none. - No dependency audit. postcss reached production deps through vue. What: - Extract every gate into a reusable quality-gates.yml (workflow_call). ci.yml and release.yml both call it, so the two can no longer drift and release blocks on `needs: [gates]`. - Add an examples job building all three demo apps against the built dist. - Add a changeset job on pull_request, and an npm audit step for production dependencies. - Pin every job to .nvmrc via node-version-file. The workflows hardcoded Node 22 while .nvmrc said 24. - Add CODEOWNERS - master has require_code_owner_reviews enabled, which does nothing without this file - and a PR template. - Widen lint-staged globs to cover .cjs/.mjs/.yaml so pre-commit stops letting through files that `prettier --check .` then fails on in CI. - Configure commit.template from `prepare`; the template file existed but was never wired up. - Override postcss to ^8.5.25. `npm audit fix` cannot resolve it here because of the known ng-packagr peer conflict. Production audit is now clean, so the new audit step passes. - changesets baseBranch master -> develop, matching where PRs actually land. How to test: npm run lint && npm run format-check && npm run typecheck npm audit --omit=dev --audit-level=high npx changeset status --since=origin/develop cd example/<app> && npm ci && npm run build * fix(ci): drop the postcss override that desynchronised package-lock Why: The override broke `npm ci` on Linux: "Missing: yaml@2.9.0 from lock file". A bare `postcss` key rewrites every postcss in the tree, including @angular-devkit/build-angular's nested copy, whose subtree resolves differently on Linux than on Windows. npm then had to re-resolve packages the lockfile had no entries for, so every CI job failed at install. The override was also unnecessary. vue's compiler-sfc asks for postcss ^8.5.8, and the advisories cover <=8.5.22 - so 8.5.25 was always inside the range npm was allowed to pick. The vulnerability existed only because the lockfile pinned 8.5.12. Refreshing that pin is the whole fix; no override is needed to hold it there. What: Remove the overrides block. The lockfile keeps postcss 8.5.25, which is a resolution npm reaches on its own, and `npm audit --omit=dev` reports zero vulnerabilities. How to test: npm ci && npm audit --omit=dev --audit-level=high * fix(ci): declare Node 22 in .nvmrc, the version CI actually runs Why: Pointing the workflows at .nvmrc was meant to remove drift, but .nvmrc said 24 while every workflow had hardcoded 22 - so the "fix" silently upgraded CI by a major version, and install broke everywhere with: npm error Missing: yaml@2.9.0 from lock file Node 22 ships npm 10.x; Node 24.18 ships npm 11.16. lint-staged declares `yaml` as an optionalDependency, and npm 11.16 requires an entry for it in the lockfile where npm 10 does not. develop's lockfile has never had that entry, so this was latent, not caused by anything in this branch. Regenerating the lockfile on Windows is not the fix: npm 11.16 prunes every other platform's optional binaries while doing it, dropping 26 entries including @lmdb/lmdb-linux-x64 and @napi-rs/nice-linux-x64-gnu, which is exactly what Linux CI needs. What: Set .nvmrc to 22 so the declared version matches the one that is actually tested and that the committed lockfile supports. The workflows keep reading .nvmrc, so there is still a single source of truth. Moving to Node 24 is a real upgrade and needs its own PR: the lockfile has to be regenerated on Linux under npm 11.16 so it keeps the cross-platform optional binaries and gains the yaml entry. How to test: npm ci * fix(ci): make the examples job actually install what the demos need Why: Two failures in the new job. example/angular-app gitignores its package-lock.json, so in a fresh checkout there is no lockfile: `cache-dependency-path` could not resolve it ("Some specified paths were not resolved") and `npm ci` had nothing to install from. example/react-app failed with module-not-found on @dynamic-field-kit/core from packages/react/dist/index.mjs. The `file:` deps resolve to the real packages/ directories, so Node walks up from there to the workspace root looking for core - and the job never ran a root install, so it was not there. It passed locally only because a root node_modules already existed. What: - Run `npm ci` at the workspace root before installing each example. - Use `npm install` for the examples so the lockfile-less angular app works; react and vue still honour their committed lockfiles. - Drop cache-dependency-path so the cache keys off the root lockfile, which always exists. How to test: rm -rf example/react-app/node_modules npm ci && cd example/react-app && npm install && npm run build * ci: skip the changeset check on changesets' own release PR The release PR consumes changesets and bumps versions, so it changes packages while correctly having no changeset left. Requiring the check without this would deadlock every release. * feat(ci): release by picking a bump from the Actions UI Why: Releases were being cut by hand - #25 edited versions directly and #27 then had to delete consumed changeset files manually. Changesets was installed but half-used, so the version bump was manual work followed by a commit. What: Release is now workflow_dispatch only, with inputs: bump patch | minor | major packages core,react,vue,angular (empty = all) message the CHANGELOG entry dry_run version and print, publish nothing scripts/create-changeset.js turns those inputs into a real changeset, so the run goes: quality gates -> changeset version -> lockfile sync -> build -> commit -> changeset publish. No version is edited by hand. Packages stay independently versioned, and changesets already committed are consumed in the same run with the largest bump per package winning. The push-to-master trigger is gone. It opened a "version packages" PR through changesets/action, which is a second, competing release path - the same overlap that produced the manual cleanup in #27. Run it on develop, not master: required status checks apply to direct pushes, so the Actions bot cannot push the release commit to master. Versions reach master through the usual develop -> master PR. Verified locally against the real changeset on this branch: core 1.3.0 -> 1.4.0, react/vue/angular 1.4.0 -> 1.5.0, internal deps rewritten to ^1.4.0, CHANGELOGs generated. The lockfile sync step was checked under npm 10 (what Node 22 ships, per .nvmrc) and keeps every platform's optional binaries - npm 11 prunes them, which is what broke install earlier on this branch. How to test: Actions > Release > Run workflow, on develop, with dry_run enabled. * feat(core): let the wizard engine actually change step Why: The engine shipped canGoNext and canGoPrev - it could say whether moving was allowed, but there was no function to move. Callers had to rebuild state with createWizardState(steps, i + 1), which resets completedSteps to [] every time. completedSteps was written in exactly one place, its initialiser, and read nowhere: declared state that nothing maintained. The README advertises a "Multi-Step Form Wizard Engine", so this was the gap between the claim and what the code could do. What: - goNext / goPrev / goToStep, all returning new state and leaving the input untouched. goNext records the step it leaves in completedSteps, so the set is now maintained rather than decorative. - markStepCompleted and isStepCompleted for driving a step indicator. - goNext at the last step and goPrev at the first return the *same* state object, so callers can compare identity to detect a no-op. goNext does not validate. Validation stays explicit through validateStep, so a wizard can allow moving on from an incomplete step if it wants to. How to test: npm run test --workspace=@dynamic-field-kit/core * feat(angular): show the error count in DevTools, matching react and vue The react and vue overlays put a red count badge on the collapsed button and render the errors tab as "errors (N)". Angular had neither, so the one framework where you cannot see errors at a glance was the one whose overlay looked identical otherwise. * feat: report field blur from MultiFieldInput in all three frameworks Why: useDynamicForm ships handleBlur, touched and validateOnBlur, but there was no way to connect them to MultiFieldInput - the component that actually renders the form. The example app tried, with onBlurField, and did not compile. Worse, blur plumbing existed only in react. Vue and Angular had none at any level: their FieldInput never passed onBlur down, and Vue's DynamicInput did not declare it - so the vue default renderers accepted an onBlur prop that could never arrive. What: - react: MultiFieldInput takes an optional onBlurField, called alongside the touched tracking it already did internally. - vue: onBlur and touched threaded through DynamicInput -> FieldInput -> MultiFieldInput, plus internal touched tracking to match react. - angular: FieldInput emits onBlurField from a `focusout` listener - it bubbles, so any renderer works without declaring a blur output of its own. MultiFieldInput re-emits it and exposes isTouched(). - example: restore onBlurField, now that the prop exists. How to test: npm run test --workspace=@dynamic-field-kit/{react,vue,angular} cd example/react-app && npm run build * docs: document the v1.4 APIs and add a runnable wizard demo Why: The v1.4 features were a bullet list with no examples, and several shipped APIs appeared nowhere in the README at all - the wizard navigation, the group array helpers, defaultRenderersMap/getDefaultRenderer, and the blur wiring. The built-in renderer list was also stale: it named 7 types when the packages ship 14. What: - Sections with real examples for form state, the wizard, DevTools and the group array helpers, each with a table of the exported surface. - Correct the renderer list, and document what each default emits. - A "Runnable Examples" section mapping each demo page to what it shows. - New /wizard page in the react example: step indicator driven by completedSteps, per-step validateStep, goNext/goPrev. Linked from the other two pages. Every identifier named in the README was checked against the built dist, and the example app compiles in CI. How to test: cd example/react-app && npm run dev # then visit /wizard * ci: publish the example apps to GitHub Pages Why: There was no link to hand someone who wants to see the library work. The example apps only ran locally, and running them means cloning the repo and building four packages first. What: - deploy-pages.yml builds all three demos and publishes them under one site: /react, /vue, /angular, plus a landing page. It runs on develop and on demand, and only when packages/ or example/ changed. - Each app takes its base path from an env var, so local dev is untouched: Next reads PAGES_BASE_PATH, Vite reads it as `base`, Angular gets --base-href on the command line. - Next now emits a static export with trailingSlash, so /wizard resolves to wizard/index.html on a plain file host. A .nojekyll file stops Pages from stripping _next. - README gains per-package npm badges and demo links. The repo's About link now points at the demos, so npm needed a home in the README. Also deletes example/react-app/next.config.ts. Next resolves next.config.js first, so the .ts file - and the `reactCompiler: true` in it - had never taken effect. Verified by adding output:'export' to the .js and watching out/ appear. And ignores example/ and smoke/ in .eslintignore. `npm run lint` only covers packages/*/src, so CI never linted them, but the pre-commit hook did - against a config written for library source, which rejects a `require` in a Next config and cannot resolve an example's own dependencies. Live at https://vannt-dev.github.io/dynamic-field-kit/ once this is on develop. How to test: Actions > Deploy demos to Pages > Run workflow
…#32) * feat(example): shared demo nav with a link back to all demos Each React page duplicated its own <nav>, and none of the three apps offered a way back to the Pages landing page - once you were in /react you were stuck there. DemoNav centralises the links and adds an absolute 'Tất cả demo' link; absolute because the landing page only exists on the deployed site, one level above each app's base path. * feat(example): show each demo's source beside it Why: A demo you cannot read the code of only proves the library runs - it does not show how. Linking to GitHub means leaving the page, and a hand-written snippet drifts from the code that is actually running. What: Each route splits into a Server Component page and a client `demo.tsx`. The page reads its own demo's source with fs and passes it to DemoShell, which renders it in a toggleable side panel with a copy button. Because every route is statically exported, the read happens at build time and the text is baked into the HTML - no runtime fetch, and the snippet is by construction the code that rendered the form next to it. Tried `?raw` imports first: Turbopack compiles them inside a private folder but fails on a real route, so fs in a Server Component is the approach that works. How to test: cd example/react-app && npm run build # then open out/wizard/index.html * feat(example): bring the vue and angular demos up to the react one Why: Neither demo referenced a single v1.4 feature. Grepping both for useDynamicForm, createDynamicFormStore, createWizardState, DynamicFormDevTools, radio, range, switch or onBlurField returned zero hits, while the react app had three pages covering all of it. Anyone opening the Vue or Angular demo saw a library two releases out of date. Neither had a way back to the Pages landing page either. What: - Vue: EnterpriseDemo.vue (useDynamicForm, extended renderers, blur wiring, DevTools) and WizardDemo.vue, as two new tabs. - Angular: the same pair as standalone components, using createDynamicFormStore and its signals, plus the (onBlurField) output. - Both gain the '← Tất cả demo' link, absolute for the same reason as react. - Both show the demo's source beside it, matching the react panel. Vue uses Vite's native `?raw`; Angular's builder has no equivalent, so scripts/embed-demo-sources.js generates a module from the real files and is wired to prestart/prebuild, which keeps the panel from ever going stale. Label is '‹/› Xem code', not '</>': Angular's template parser reads the `</` inside an interpolated string as a closing tag and fails to compile. How to test: cd example/<app> && npm install && npm run build * docs: document the v1.4 APIs in every package README Why: None of the four package READMEs mentioned the v1.4 surface. Grepping each for useDynamicForm, createDynamicFormStore, createWizardState, goNext, DynamicFormDevTools, zodValidator, onBlurField or defaultRenderersMap returned: core 0, react 2 (bare names in a list), vue 0, angular 0. Someone installing from npm and reading the package page saw a library two releases out of date. All four also linked a "Demo app" repo that is not where the demos live. What: - core: schema adapters (form vs field target, sync vs async), the wizard state machine with a table of every export, and the group array helpers. The "What this package provides" list now covers them too. - react / vue / angular: a form-state section with the full member table in each framework's idiom - props, refs, signals - plus default renderers and DevTools. Each notes how blur is wired, since that differs: a prop in react and vue, a focusout-driven @output in angular. - Point every "Live demo" link at the deployed Pages site. Every identifier written into these files was checked against the built dist. How to test: npm run format-check * chore: stop tracking docs/superpowers .gitignore already excludes .superpowers/ as "scratch/planning docs, not subject to version control", but docs/superpowers/ held the same kind of content - eight internal plan and spec files - and was tracked, so it showed up on the repo page next to the real documentation. Untracked rather than deleted: the files stay on disk, and remain in history if they are ever needed. * docs: add a patch changeset for the README updates README.md is in the published tarball - npm includes it regardless of the `files` field - so the v1.4 documentation only reaches the package pages through a release. Patch rather than --empty for that reason.
The Pages deploy failed with: Can't resolve './demo-sources' in example/angular-app/src/app The step called `npx ng build` directly to pass --base-href, which skips npm's `prebuild` hook. That hook runs scripts/embed-demo-sources.js, which generates src/app/demo-sources.ts - a file that is gitignored and therefore absent from a fresh checkout. The CI examples job did not catch it because it runs plain `npm run build`, where the hook does fire. `npm run build -- --base-href …` forwards the args to `ng build` and keeps the hook. Verified locally by deleting demo-sources.ts first: the generator ran and the emitted index.html carries <base href="/dynamic-field-kit/angular/">.
…ing (#33) * fix(deps): move dev-only deps out of root dependencies and patch nanoid The `npm audit --omit=dev --audit-level=high` gate in quality-gates.yml started failing on GHSA-2v37-7h3g-55p8 (nanoid <3.3.18) without any code change on develop. nanoid reaches the tree only through vue -> @vue/compiler-sfc -> postcss, which is build-time only. Two things were wrong: - The root is private and publishes nothing, so react, react-dom and vue belong in devDependencies. They exist purely to resolve the packages' peers while building and testing, and keeping them in `dependencies` is what put vue's build-time tree in front of an `--omit=dev` audit. - nanoid was left vulnerable in the toolchain we actually run. Pin it forward with an override so it is patched, not merely excluded from the audit's scope. All four packages declare `dependencies: {}`, and no workflow installs with --omit=dev or --production, so nothing else changes resolution. Lockfile regenerated with npm@10, not the locally installed npm 11 which prunes other platforms' optional binaries: entry count is unchanged at 1606 with no additions or removals, and all 100 linux entries intact. * fix(scripts): report bundle sizes from package entry points show-sizes.js guessed formats from file extensions, assuming index.js is CJS and index.mjs is ESM. That holds for core and react but inverts for vue, which is `"type": "module"` and so ships ESM as dist/index.js and CommonJS as dist/index.cjs. The report therefore printed vue's ESM bundle under a CJS label and never printed its real CJS bundle at all. Angular was missing entirely, having never been added to the list. Resolve each entry from the package's own manifest instead (exports['.'].import/require, falling back to module/main), which also handles ng-packagr's ESM-only output where main and module point at the same fesm2022 bundle -- reporting that file twice would invent a CJS build that is not shipped. Before After vue (CJS): 36.31 KB vue (ESM): 36.31 KB (no vue ESM) vue (CJS): 39.37 KB (no angular) angular (ESM): 54.47 KB The collector is now exported and covered by scripts/show-sizes.test.js, which builds throwaway workspaces on a real filesystem rather than mocking fs, plus two regression tests against the repo's own packages. Root vitest.config.mjs scopes that suite to scripts/** so it cannot touch the per-package coverage floors, and quality-gates.yml runs it after the build step, where a dist exists for those two tests to read. * chore: ignore vite's transient config bundle Running the angular suite locally leaves an orphaned packages/angular/vitest.config.ts.timestamp-<n>-<hash>.mjs behind when vitest does not get to clean it up, which on Windows is often. The file was ignored by neither .gitignore nor .prettierignore, so it showed up as untracked and, because `prettier --check .` walks untracked files, turned the next format-check red for reasons unrelated to any change. CI never hit this because format-check runs before any test, so the artifact only ever bit local runs and risked being committed by accident. * fix(vue): dev-install vue instead of borrowing it from the root packages/vue peer-depends on vue but never declared it in devDependencies, so its build, typecheck and test runs resolved vue only because the workspace root happened to hoist one. packages/react and packages/angular already dev-install their own peers; vue was the outlier, and it became load-bearing on the root's declaration right as that root entry moved to devDependencies. Extend verify-framework-deps.js, which already gates this class of manifest drift in CI, with the rule that every peer must also appear in devDependencies. The script now exports its check so it can be tested, and scripts/verify-framework-deps.test.js covers both rules against throwaway workspaces plus the repo's real manifests. Running the new rule against this repo reported exactly one violation, which is the one fixed here: vue: peer-depends on vue but does not declare it in devDependencies, so it builds and tests against whatever the workspace root happens to hoist Lockfile regenerated with npm@10: 1606 entries unchanged with nothing added or removed, all 100 linux entries intact. The diff is `"dev": true` markers appearing on vue's transitive tree, which is now dev-only everywhere it is reachable. No changeset: devDependencies are not installed by consumers, so no published package changes behaviour or its dependency contract. * fix(scripts): make the cross-framework import check actually run isSourceFile() tested paths against /\.(ts|tsx|js|jsx)$/. In a regex literal `\` matches a literal backslash, not a dot, so the pattern required a backslash followed by any character and then `ts`/`tsx`/etc at end of string. No path in the repo can satisfy that, so the predicate returned false for every file, the walk callback returned early every time, and `violations` was always empty. The gate has therefore been reporting "OK: No cross-framework imports found" unconditionally since it was written -- it would have passed a package importing another framework wholesale. Verified by appending `import { DynamicInput } from '@dynamic-field-kit/react'` to packages/vue/src/index.ts: before this change the script printed OK and exited 0; after it, it exits 1 and names the file and line. Fix the escape, and restructure the script the way show-sizes.js and verify-framework-deps.js already are: export the check, guard the CLI behind require.main so importing it does not call process.exit, and derive the scanned roots from a package list rather than three hand-written paths. scripts/check-cross-framework-imports.test.js covers import and require forms, nested directories, line numbers, that core stays allowed, and that non-source files are skipped. Running the now-working check against this repo finds no violations, so nothing in packages/*/src had drifted while the gate was blind. * chore(lint): extend the lint gate to scripts/ `npm run lint` only covered packages/*/src, so nothing under scripts/ was ever linted in CI. The pre-commit hook does lint it, because lint-staged matches *.js anywhere, which meant the two gates disagreed: a script could sit in the repo with errors that would block the next person who happened to touch it. Add scripts to the lint script and clear the errors that surfaced: - build-changed.js: import order - check-cross-framework-imports.js: prefer-const, curly (already rewritten in the previous commit) - diagnose-hoist.js: execSync's return value was assigned to an unused binding. It runs with stdio: 'inherit', so output goes straight to our stdout and there is nothing to capture; drop the assignment. All three are behaviour-preserving. * chore(deps): drop root deps that the packages now declare themselves With packages/vue dev-installing vue, the root's react, react-dom and vue entries are redundant: every workspace that needs them declares them (packages/react, packages/vue, smoke). Leaving them meant the root's manifest implied ownership of dependencies it does not use, and a package could silently go back to borrowing from the root without verify-framework-deps.js noticing, since resolution would still succeed. @types/react moves to packages/react rather than being dropped. It is genuinely needed -- packages/react is a .tsx package and its `tsc -p tsconfig.json --noEmit` gate resolves React's types through it -- but it was only ever declared at the root, so that gate depended on hoisting too. Verified by running typecheck after the move. Root devDependencies are now exactly the workspace-wide tooling: changesets, commitlint, eslint and its plugins, husky, lint-staged, prettier, tsup, typescript, vitest. Lockfile regenerated with npm@10: 1606 entries unchanged with nothing added or removed, all 100 linux entries intact. * fix(example): give the react and vue demos real page titles Both demos shipped their scaffold defaults to the live site. The React demo -- the one the package READMEs link to most -- rendered a browser tab and link preview reading "Create Next App", described as "Generated by create next app". The Vue demo's title was "vue-app". Only the Angular demo had a real one. Match Angular's existing "Dynamic Field Kit - <Framework> Example" and give each a description naming what the demo actually shows. Verified in the built output: all three now emit their own title and no stale create-next-app description survives. * docs: close the README gaps and link the demo sub-routes Cross-checking every symbol the four READMEs tell you to import against the built .d.ts found no stale API -- all 58 resolve -- but 25 public exports were never mentioned anywhere. All four packages now describe their full surface. The substantive gap was async validation. `validateField` and `validateFields` are synchronous and cannot await, so a `validate` hook returning a Promise is discarded and the field reads as valid. Both `useDynamicForm` and `createDynamicFormStore` use the sync path throughout, including in handleSubmit, so async rules never surface unless the app calls `validateFieldsAsync` itself. Core's adapter section already said async schemas "must be validated through validateFieldsAsync", but no README showed how, and the async functions appeared in no adapter export list at all. Core now has a "Sync vs async validation" section, and each adapter repeats the caveat and links to it. Also documented: - core: `Properties`, `ValidatorFn`, `FieldValidatorResult` and `FieldValidatorFunction`, which appear in signatures throughout the README but had no definition; and `FormStep` next to `WizardState`. - react/vue/angular: `validateFieldAsync`, `validateFieldsAsync`, `resolveOptions` and `validators`, grouped under a heading that marks them as core re-exports rather than adapter exports. - angular: `layoutRegistry`, `LayoutRegistry`, `ColumnLayout`, `RowLayout`, `GridLayout` and `BaseInputComponent`. It was the only adapter documenting no layout registry, and its registry is the one real difference between the three -- it holds standalone components, not render functions -- so it needed its own example rather than a pointer at the React one. Demo links: the package READMEs each pointed only at their framework's landing page. React now links its enterprise-features and wizard routes, core links all three framework demos plus the wizard it documents, and vue/angular note that their wizard is a tab rather than a separate URL. All links verified to serve real pages. Angular's `## What it exports` becomes `## Exports`, matching react/vue. * perf: let consumer bundlers drop unused parts of the adapters Components were declared as bare top-level calls -- defineComponent({...}) in vue, React.memo(...) in react. A bundler cannot prove such a call is side-effect free, so it evaluates it even when the result is unused. That pinned every default renderer and every component into an app's bundle regardless of how little of the package it imported. Annotating those calls /* @__PURE__ */ makes them droppable. Measured with esbuild, minified, framework external: react, one core helper 11,149 -> 3,258 B (-71%) react, DynamicInput only 11,150 -> 6,830 B (-39%) react, everything 17,601 -> unchanged vue, one core helper 17,379 -> 13,934 B (-20%) vue, DynamicInput only 17,379 -> 13,934 B (-20%) vue, everything 20,530 -> unchanged "Everything" staying flat is the expected result: nothing is droppable when it is all reachable. react gains more than vue because its default renderers are already plain arrow functions, so only the two memo() calls were holding them; vue's renderers are each a defineComponent() call. The published dist grows marginally (react +0.03 KB, vue +0.29 KB) since the annotations are comments carried into the bundle. That is the trade: a slightly bigger file on npm for a materially smaller bundle in the app. Also declare "sideEffects": false on core. It has no top-level execution -- the only module-scope work is `new FieldRegistry()` assigned to an export -- so the claim is accurate. It buys nothing under esbuild, which already tree-shakes core to 907 B for a single import, but it is correct metadata for bundlers that trust the flag over their own analysis. The adapters cannot claim it: their entry side-effect-imports the default layouts to register them. No behaviour change -- these are comments and a packaging flag. Full suite green: core/react/vue/angular tests, smoke, typecheck, type tests, lint, and the three verify scripts. * perf(vue): stop MultiFieldInput pinning the package into every bundle MultiFieldInput renders itself recursively for repeatable groups and reached itself through a module-scope assignment: let multiFieldInputSelfRef: Component; const MultiFieldInput = defineComponent({ ... }); multiFieldInputSelfRef = MultiFieldInput; That last line is a bare top-level assignment -- a side effect no bundler is allowed to drop. It anchored MultiFieldInput -> FieldInput -> DynamicInput -> getDefaultRenderer -> every default renderer into any app that imported the package at all, which is why the previous commit's /* @__PURE__ */ annotations only recovered 3.4 KB of a 17 KB floor: the components were pure but still reachable. Replace it with a hoisted function declaration returning MultiFieldInput. A function body is not evaluated until called, so nothing is retained until a group actually renders. The explicit `Component` return type does the job the forward-declared `let` was there for -- it stops TypeScript having to infer MultiFieldInput from inside its own initializer. Measured with esbuild, minified, vue external: one core helper 13,934 -> 2,479 B (-82%) DynamicInput only 13,934 -> 9,196 B (-34%) MultiFieldInput only 13,944 -> 13,958 B (+14 B) everything 20,530 -> 20,543 B (+13 B) Apps using the whole surface pay 13-14 bytes for the wrapper. Against the original baseline, before the PURE annotations, a vue app importing only DynamicInput goes 17,379 -> 9,196 B, a 47% reduction. react and angular were checked for the same pattern and have none. No behaviour or type change: dist/index.d.ts is byte-identical to before, and the vue suite passes unchanged at 115 tests, including the repeatable-group recursion that exercises this path. Full repo suite green. * chore(angular): state the target ng-packagr actually emits tsconfig.json claimed `target: ES2019`, but ng-packagr ignores it: the published fesm2022 bundle uses ES2022 class fields (`value;` declarations, define semantics). So the config described an emit that never shipped, and tsconfig.spec.json had to override target to ES2022 with a comment explaining it was matching reality -- an override that only existed to work around the wrong value here. Set the real target on the base config and let the spec config inherit it. Verified: the published fesm2022 bundle is byte-identical before and after, which is what confirms the old value was inert. Also drop `emitDecoratorMetadata`. Angular carries its own DI metadata on the generated `ɵfac`, and grepping the built bundle for `__metadata`, `Reflect.metadata` and `design:paramtypes` returns zero hits -- the flag produced nothing while implying a reflect-metadata dependency that does not exist. * test(angular): cover option resolution, responsive layout and group bounds Angular's branch coverage sat at 75.8% against a 75 floor -- 0.8 points of headroom, so the next uncovered `if` anyone added would have turned CI red for whoever touched the file next rather than whoever wrote it. The other three packages all had double-digit headroom, so this was angular's gap, not a floor set too high. The uncovered branches were real behaviour, not unreachable code: - FieldInput.resolvedOptions (33% covered) decides between an explicit `options` input, a static array on the field description, and a dynamic options callback -- which it deliberately withholds, because resolving it needs form data only MultiFieldInput has. Passing the raw function down would make a renderer try to iterate a function. None of those three paths was tested. Asserted through a renderer that prints what it received, so the tests pin what reaches the renderer rather than what the getter returns. - MultiFieldInput's responsive layout (66%): mobile/desktop resolution, the custom breakpoint (it queries `max-width: breakpoint - 1`, so a viewport exactly at the breakpoint is desktop), the resize handler only scheduling a re-render when the mobile state actually flips, and the no-matchMedia fallback that keeps the component usable in jsdom. Also grid column/gap defaults and the min/max item guards. - DynamicFormDevTools.errorCount (50%): the `this.errors || {}` guard, without which a null errors input takes the host app's render down. Coverage: branches 75.8% -> 84.8% (54 -> 34 missed of 223), lines 91.4% -> 95.4%, functions 92.7% -> 93.9%. Headroom over the floor goes from +0.8 to +9.8, in line with core 85.9 / react 86.8 / vue 87.3. Suite goes from 74 to 91 tests. FieldInput and DynamicFormDevTools reach 100% branch coverage and MultiFieldInput 92.9%, which is the evidence these tests hit the branches they were written for rather than padding the number. * perf: stop publishing sourcemaps tsup emitted sourcemaps with sourcesContent, so each .map carried a full copy of the TypeScript source. That is what made them usable at all -- `files` publishes only `dist`, so a map pointing at ../src/*.ts would resolve to nothing -- but it also made them roughly half of every tarball, installed by every consumer. core 157.0 -> 83.0 KB unpacked (-47%), 33.3 -> 17.5 KB packed react 207.6 -> 90.1 KB unpacked (-57%), 46.8 -> 19.2 KB packed vue 250.4 -> 109.4 KB unpacked (-56%), 47.6 -> 20.5 KB packed Nothing that reaches an application bundle changes; sourcemaps never do. What changes is install size, against the ability to step into the library's TypeScript while debugging a consuming app. Calling it out plainly: this is a trade, not a free win. The maps were not broken or dead weight -- I checked, and sourcesContent made them fully self-contained. Each tsup.config.ts now carries that reasoning beside a `sourcemap: false` that is one word from restoring them. Verified no dangling //# sourceMappingURL comments survive in any dist, so nothing 404s looking for a map that is no longer shipped. angular is unaffected; ng-packagr's published output does not carry them. * ci: move actions off the deprecated Node 20 runtime Every run was annotating 10 warnings: actions/checkout@v4, setup-node@v4, upload-artifact@v4, download-artifact@v4 and codecov-action@v4 all declare `runs.using: node20`, which GitHub deprecated and now force-runs on Node 24 anyway. Bumped to the current majors, each verified against how this repo actually uses it rather than assumed: checkout v4 -> v7 setup-node v4 -> v7 upload-artifact v4 -> v7 download-artifact v4 -> v8 codecov-action v4 -> v7 upload-pages-artifact v3 -> v5 deploy-pages v4 -> v5 Two breaking changes in that range needed checking: - setup-node v6 limits *automatic* package-manager caching to npm. All seven call sites pass `cache: 'npm'` explicitly, so nothing relies on the detection that changed. - download-artifact v5 changed path behaviour for downloads **by ID**. Both call sites download by `name`, so the layout the verify and examples jobs depend on (`packages/<pkg>/dist`) is unaffected. Every input in use was confirmed to still exist in the new majors (`node-version-file`, `cache`, `name`, `path`, `retention-days`, `if-no-files-found`, `files`, `flags`, `fail_ci_if_error`), as was deploy-pages' `page_url` output that the Pages environment reads. upload-pages-artifact and deploy-pages were not in the warning list because the Pages workflow only runs on push to develop, not on a PR. Including them anyway: deploy-pages@v4 is node20, and upload-pages-artifact@v3 wraps upload-artifact@v4, so both would have warned on the next deploy. All four workflows re-parsed as valid YAML; the diff is version tags only.
…g a problem (#34) * chore(angular): upgrade ng-packagr to 19 to match the Angular it builds ng-packagr sat at 17.3.0 while the package builds against Angular 19, and its peer ranges said so plainly: peer @angular/compiler-cli: ^17.0.0 installed: 19.2.20 peer typescript: >=5.2 <5.5 installed: 5.6.3 Both violated, which is why installing in packages/angular needed --legacy-peer-deps. It worked -- the emitted bundle carries `ɵɵngDeclareComponent ... version: "19.2.20"` -- but on a combination ng-packagr never claimed to support. 19.2.2 matches exactly: compiler-cli ^19.x and typescript >=5.5 <5.9. The install now completes with plain `npm install`, no --legacy-peer-deps, and drops 36 transitive packages (ng-packagr 17's cacache/tar/minipass/esbuild-wasm chain) for 4. The published artifact is smaller, and the part consumers load is unchanged: fesm2022 bundle byte-identical index.d.ts identical public-api.d.ts identical tarball 317.1 -> 165.9 KB unpacked, 75.8 -> 33.7 KB packed files 33 -> 19 The difference is the per-file `esm2022/` output, which ng-packagr stopped emitting in 18 because the fesm2022 bundle is what the Angular linker consumes. Its `esm2022` and `esm` export conditions go with it; `types` and `default` remain, which is what every Angular 19+ library ships. Angular suite passes unchanged at 91 tests, typecheck and the three verify scripts pass, and the angular demo app still builds against the package through its `file:` dependency. Lockfile regenerated with npm@10: all 100 linux entries intact. * chore(deps): take in-range updates across the workspace `npm update` only, so every package.json is untouched and nothing moved outside a range this repo already declared. Direct dependencies that advanced: @analogjs/vite-plugin-angular 1.16.1 -> 1.22.5 @analogjs/vitest-angular 1.16.1 -> 1.22.5 @angular/* (7 packages) 19.2.20 -> 19.2.25 @commitlint/cli 21.2.1 -> 21.2.2 @commitlint/config-conventional 21.2.0 -> 21.2.2 @testing-library/user-event 14.6.1 -> 14.6.4 @types/node 20.19.38 -> 20.19.43 @types/react 19.2.14 -> 19.2.18 @vue/test-utils 2.4.6 -> 2.4.11 lint-staged 17.2.0 -> 17.3.0 react, react-dom 19.2.4 -> 19.2.8 vue 3.5.32 -> 3.5.41 Two entries in the lockfile diff look like major jumps and are not. `rimraf` at the hoist root goes 3.0.2 -> 6.1.3, but core and angular already declared ^6.1.3 -- the 3.0.2 that used to sit there was a transitive from flat-cache, which now keeps its own nested copy. `vite` appears at the root as 6.4.3, pulled in by the newer @analogjs; the vite that packages/react declares stays at 5.4.21 under its own node_modules, matching its ^5.4.0, and vitest keeps its nested 5.4.21 too. Linux optional binaries went 100 -> 115 entries, an increase from the new transitive platform packages -- not the pruning that npm 11 causes, which is why this was regenerated with npm@10 as usual. Verified after a clean `npm@10 ci`: build, typecheck, core type tests, all four package suites, the published-package smoke suite, test:scripts, lint, format-check, audit, the three verify scripts, and all three example app builds. Bundle sizes are unchanged bar a few bytes from the newer tsup/esbuild. * chore(core): put core on the same vitest as the rest of the workspace core was pinned to vitest 0.34 while react, vue and angular ran 1.6, and that single split was the cause of two workarounds the repo had been carrying: - 0.34 has no `--typecheck` CLI flag, so `test:types` had to use the 0.34-only `vitest typecheck --run` subcommand. - The hoisted `@vitest/coverage-v8@1.6.1` is incompatible with 0.34 (reports 0% or errors), so core alone used `@vitest/coverage-istanbul@0.34.6` as its own nested devDependency. Moving core to ^1.6.0 with `@vitest/coverage-v8@^1.6.0` removes both. core now resolves the hoisted vitest 1.6.1 with no nested copy at all, and the istanbul provider is gone. Two config shapes had to change with it, and both were verified live rather than assumed -- a silently-ignored gate is worse than no gate: - `test:types` becomes `vitest --run --typecheck.only`, which reproduces the old scope exactly (1 file, 9 tests, types.test-d.ts only). Appending `const broken: number = 'nope'` to types.test-d.ts makes it exit 1 with `TypeCheckError: Type 'string' is not assignable to type 'number'`. - The coverage floor moves from 0.34's flat `coverage.lines/statements/ functions/branches` keys to 1.x's `coverage.thresholds` wrapper. This is the risky half: leaving the flat keys would have made vitest 1.x ignore them and the floor would have vanished without a word. Raising statements to 99 makes the run exit 1 with `ERROR: Coverage for statements (95.12%) does not meet global threshold (99%)`, so the new shape is being read. Switching provider moves the reported numbers, since v8 and istanbul count differently: core now reports 95.12 statements / 88.69 branches / 100 functions / 95.12 lines against the unchanged 85/75/85/85 floor, where istanbul reported 91.1 lines / 85.9 branches. Verified after a clean npm@10 ci: build, typecheck, type tests, all four suites both plain and with --coverage, smoke, test:scripts, lint, format-check and the three verify scripts. * docs(changeset): record the angular packaging change and core's non-release ng-packagr 19 changes what the angular tarball contains, so that needs a patch. core's move to vitest 1.6 touched only devDependencies and test config, neither of which ships, so it gets an empty changeset rather than a version bump that would publish an identical tarball. * chore(lint): migrate to eslint 9 flat config eslint 8.57.1 is end of life -- `npm ci` printed "This version is no longer supported" on every install. Moving to 9 means flat config, since eslint 9 reads neither .eslintrc.cjs nor .eslintignore. Both files are replaced by eslint.config.mjs, translated rule for rule. .eslintignore's contents become the global `ignores`, comment and all: the example apps and smoke workspace stay excluded because the lint script never covered them, while the pre-commit hook matches *.js anywhere and would otherwise lint them against a config written for library source. Dependency changes fall out of it: - @typescript-eslint/{eslint-plugin,parser} 5 -> the `typescript-eslint` meta package at 8.67.0, which is what supports flat config. The parser is still declared explicitly: eslint-plugin-import resolves it by name through eslint-module-utils, and without the declaration 71 of the first run's 77 errors were that require failing. - eslint-config-airbnb-base and eslint-plugin-node are removed. Neither appeared in the old config's `extends` or `plugins` -- they had been installed but unused. - eslint-plugin-prettier is removed rather than upgraded. v5 requires prettier >=3, which would have dragged the prettier 2 -> 3 reformat into this change. It is redundant here anyway: `npm run format-check` is already its own CI gate and lint-staged runs `prettier --write` before eslint, so linting formatting was a slower second copy of a check that already exists. eslint-config-prettier stays, at 10.1.8, to keep turning off rules that fight the formatter. - Stayed on eslint 9 rather than 10 deliberately: eslint-plugin-import 2.32 declares peers only up to ^9, and this branch opens by removing a peer violation, not adding one. eslint 9 and typescript-eslint 8 then surfaced six real things: - typescript-eslint 8 flags unused catch bindings by default, which v5 did not. scripts/build-changed.js had three; they become optional catch bindings. - eslint 9 reports unused disable directives by default. Three were stale: a no-empty-interface disable in core/src/types.ts guarding an interface that is not empty and a rule v8 has since renamed; an import/no-unresolved disable in vue's DynamicInput (narrowed to the import/order it still needs); and a no-explicit-any disable in vue's defaultRenderers for a rule this config sets to off. A clean lint run is also what a flat config with a wrong `files` glob produces, so the rules were checked against a probe file rather than assumed. On a .ts file, curly, eqeqeq, semi, max-len, prefer-const and @typescript-eslint/no-unused-vars all fire; on a second probe, import/order and import/no-unresolved fire. example/, smoke/ and *.d.ts are still ignored. Verified after a clean npm@10 ci: build, lint, format-check, typecheck, core type tests, all four suites, smoke, test:scripts, audit, the three verify scripts, and all three example app builds. The eslint@8 end-of-life warning is gone from npm ci. * docs(changeset): widen the no-release note to core, react and vue The eslint 9 migration touched core and vue sources too, but only by removing stale eslint-disable comments. Built dist for all three is byte-identical to develop, checked by building both revisions and diffing, so the empty changeset still describes the truth.
Four pieces of package config had drifted out of sync with what the repo actually does. None of them ship - `files` publishes `dist` alone, and the built `dist` for all four packages is byte-identical to develop. * Drop the hand-release scripts from all four packages. `release:patch|minor|major` (and angular's `publish:dist`) ran `npm version && npm publish` directly. That contradicts `release.yml`, which says in its header comment "Nothing is versioned by hand" and exists precisely so a release goes through `changeset version`, gets a CHANGELOG entry, a release commit and a per-package tag. Running one of these scripts would have published a version with none of that, and left `changeset publish` unable to tag the commit it never made. * Make the per-package `lint` scripts true. vue and angular declared `"lint": "echo \"skip lint for Vue\""`, which reads as "this package is not linted". It is: the root `lint` script passes `packages/vue/src` and `packages/angular/src` to eslint on every CI run. All four packages now run `eslint src`, matching what core already did, and react gains the script it was missing. All four pass. * Drop `@vitest/coverage-v8` from packages/angular. Angular's `vitest.config.ts` sets `provider: 'istanbul'` and CI runs it with `--coverage`, so the v8 provider was installed and never loaded. `@vitest/coverage-istanbul` stays. * Raise angular's branch coverage floor 70 -> 75. The other three packages have sat at 75 since the floor was introduced; angular was the odd one out. It measures 84.75% (189/223), so this is slack that was already there, and it keeps ~10 points of headroom. Verified the floor still bites: at 90 the run exits 1 with "ERROR: Coverage for branches (84.75%) does not meet global threshold". * Point core's `@types/node` at the Node it is built on. It declared `^20.0.0` while `.nvmrc` pins 22, so the types described an older runtime than the one CI and every contributor runs. Now `^22.0.0`, resolving to 22.20.1 nested under packages/core. Lockfile refreshed with npm@10, not the npm 11 on this machine: 106 linux platform entries before and after, no optional binaries pruned. Verified: lint, format-check, build, typecheck, core type tests, all four suites with --coverage (117/116/115/91), the published-package smoke suite, test:scripts, audit, and the three verify scripts.
* chore(lint): lint the test files too, and fix the 13 errors that were hiding
`npm run lint` listed four `src` directories and `scripts`, so no test
file has ever been linted in CI. lint-staged does lint them - it matches
`*.{ts,tsx,js,jsx}` anywhere - which means the errors were real and
merely invisible until a commit happened to stage the file holding one.
Reformatting the repo is exactly the kind of change that stages all of
them at once, so this had to be dealt with first.
Thirteen errors had accumulated:
5 import/order core, react, angular specs
6 no-unused-vars react layout renderers, vue mock renderers
1 no-unused-vars ('vi') react FieldRegistryProvider
1 max-len (135 > 120) vue FieldGroupInput template string
The import/order ones are eslint --fix output. The unused ones take the
`_` prefix the config already allows and the repo already uses
(`ngOnChanges(_changes)`, `computeValue: (_data, rootData)`). The long
line is a Vue inline template split across two concatenated strings; the
template it produces is character-for-character the same.
The scope fix is `eslint packages scripts` rather than a longer list of
directories. The flat config already ignores dist, node_modules, coverage,
`*.d.ts`, `example/` and `smoke/`, so pointing it at `packages` picks up
src, test, and each package's vitest/tsup config, and a directory added
later is covered without anyone remembering to extend a list. Per-package
scripts become `eslint .` for the same reason.
That pulled in one more file than before: `packages/vue/vitest.config.ts`
imports `vitest/config`, which eslint-plugin-import's node resolver cannot
follow because it does not read `exports` maps. `^vitest` joins `^@angular`,
`^vue` and `^@dynamic-field-kit` in the `import/no-unresolved` ignore list,
which exists for exactly this.
Verified the widened gate bites: an unused const appended to
packages/core/test/registry.test.ts makes `npm run lint` exit 1, which it
would have passed before this change.
Verified: lint, format-check, typecheck, core type tests, all four suites
with --coverage (117/116/115/91).
* chore(deps): move to prettier 3 and take its trailing-comma default
prettier 2.8.8 is two majors behind and has had no release since April
2023. Nothing was broken by it - this is deliberate debt payment, kept in
its own commit because it touches 105 files and none of the changes mean
anything.
The diff splits in two, and the split is worth stating:
* 19 files change because prettier 3 formats them differently - markdown,
and a few expressions where it now parenthesises `??` inside a ternary.
* 86 more change only because prettier 3 flipped the `trailingComma`
default from "es5" to "all". This takes the new default rather than
pinning the old one, and writes `"trailingComma": "all"` into
.prettierrc.json explicitly, so it reads as a decision instead of an
inherited default. The payoff is quieter diffs from here: adding an
argument or a property stops showing up as a change to the line above
it. Reverting is one word plus a `prettier --write .`.
Shipped output is unchanged wherever it can be. core, react and vue build
a byte-identical `dist` - tsup/esbuild reprints from the AST, so source
formatting never reaches the bundle. angular's `fesm2022` differs by 36
lines because ng-packagr carries formatting through: the two cosmetic
changes above, plus a `</pre\n>` continuation pulled onto one line. The
`<pre>` content is identical either way - it ends at `</` in both - so the
DevTools output does not move, and the angular suite agrees at 91 tests.
Verified: format-check, lint, build, typecheck, core type tests, all four
suites with --coverage (117/116/115/91), the published-package smoke
suite, test:scripts, and the three verify scripts.
* chore: keep the prettier 3 reformat out of git blame
A 105-file formatting commit sits on top of every line it touched, so
`git blame` on any of them points here instead of at whoever last
changed the code. GitHub reads .git-blame-ignore-revs automatically;
locally it takes
git config blame.ignoreRevsFile .git-blame-ignore-revs
* chore(deps): eslint 10, which meant replacing eslint-plugin-import
eslint 9.39.5 -> 10.9.1. The upgrade is not a version bump: eslint 10
cannot be installed alongside eslint-plugin-import at all.
eslint-plugin-import@2.32.0 (latest)
peer eslint: ^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9
There is no ^10 in that range and no newer release. The options were to
force the install, to drop the import rules, or to move to the maintained
fork. Forcing it would put --legacy-peer-deps back into this repo, which
#34 had just finished removing, and the import rules are worth keeping -
`import-x/order` is the reason imports in this repo are sorted at all. So:
eslint-plugin-import -> eslint-plugin-import-x@4.17.1, which declares
`eslint: ^8.57.0 || ^9.0.0 || ^10.0.0`. Plain `npm install` resolves, no
flags.
Also bumped for the same peer reason: @eslint/js 9 -> 10, globals 16 -> 17,
typescript-eslint and its parser 8.67 -> 8.68.
Three things in eslint.config.mjs had to change beyond renaming
`import/*` to `import-x/*`:
* The resolver. import-x 4 dropped the string-keyed `import/resolver`
map for a resolver object, so `{ node: { extensions: [...] } }` became
`'import-x/resolver-next': [importX.createNodeResolver({ extensions })]`.
createNodeResolver is the plugin's own, so no eslint-import-resolver-*
package is needed, and it reads `exports` maps - which is why
`^vitest` could have stayed out of the no-unresolved ignore list, had
it not already been there for the old resolver.
* `importX.flatConfigs.typescript` is dropped. It wants
eslint-import-resolver-typescript installed, and without it every rule
reports "typescript with invalid interface loaded as resolver". What
that preset actually did here was turn off four rules TypeScript
already covers, so those four are turned off directly instead, with
the reason written next to them: import-x resolves
`@dynamic-field-kit/core` to its built `dist/index.mjs`, where a
type-only export has no runtime binding, so `import-x/named` reported
all 52 `import type { FieldDescription }` sites as missing.
* `/* eslint-disable import/order */` at the top of
packages/vue/src/components/DynamicInput.ts is deleted rather than
renamed. eslint 10 reports an unknown rule name in a disable comment,
which is how this got noticed - and the file's imports satisfy
import-x/order on their own, so the suppression had been dead for a
while. Same species as the stale disables #34 removed.
Verified the rules still bite, not merely pass: an import of a
nonexistent module added to a test file makes `npm run lint` exit 1 with
import-x/no-unresolved.
dist is byte-identical for all four packages - the deleted comment is a
comment, and tsup strips it either way.
Verified: lint, format-check, build, typecheck, core type tests, all four
suites with --coverage (117/116/115/91), the smoke suite, test:scripts,
audit, and the three verify scripts. Lockfile refreshed with npm@10; linux
platform entries 106 -> 118, none lost.
* chore(deps): vitest 1.6 -> 4.1, which drags vite 8 in with it
vitest 1.6.1 shipped in May 2024 and is three majors behind. Moving is
not a one-line bump, because vitest 4 declares `vite: ^6 || ^7 || ^8`,
and packages/react pinned vite ^5.4.0. So the upgrade is a stack:
vitest 1.6.1 -> 4.1.11 (all 4 packages + root + smoke)
@vitest/coverage-v8 1.6.1 -> 4.1.11 (core, react, vue)
@vitest/coverage-istanbul 1.6.1 -> 4.1.11 (angular)
vite 5.4.21 -> 8.2.2 (react)
@vitejs/plugin-react 4.7.0 -> 6.1.1 (react, smoke - peers vite ^8)
@analogjs/vite-plugin-angular 1.22.5 -> 2.7.1 (angular - peers vite ^6||^7||^8)
@analogjs/vitest-angular 1.22.5 -> 2.7.1
jsdom 24.1.3 -> 29.1.1 (react, vue, angular, smoke)
@vue/test-utils 2.4.11 -> 2.5.0
Angular stays on 19: analog 2.7.1 lists `@angular-devkit/build-angular`
^17 through ^22, so the plugin upgrade needed by vitest 4 does not force
the framework upgrade with it. Plain `npm install`, no flags.
No config changes were needed. `coverage.thresholds`, `globals`,
`environment: 'jsdom'`, `setupFiles`, `globalSetup` and core's
`vitest --run --typecheck.only` all mean the same thing in 4 as in 1.6.
That last one is worth stating, because a silently-ignored config is the
failure mode this repo has been bitten by before. Verified live, per
provider, that the floors still fail the run rather than passing quietly:
core statements 85 -> 99 : exit 1, "Coverage for statements (91.01%)..."
react statements 85 -> 99 : exit 1, "Coverage for statements (92.64%)..."
vue branches 75 -> 90 : exit 1, "Coverage for branches (82.7%)..."
angular branches 75 -> 90 : exit 1, "Coverage for branches (84.75%)..."
Measured coverage under the new providers, all clearing their floors of
statements/lines/functions 85 and branches 75:
core 91.01 / 86.74 / 97.33 / 91.32
react 92.64 / 85.24 / 96.55 / 93.51
vue 95.91 / 82.70 / 96.36 / 95.75
angular 95.62 / 84.75 / 93.90 / 95.48
vue's branch coverage is the thinnest at 7.7 points of headroom.
The lockfile loses 27 `@esbuild/linux-*` entries, which looks exactly like
the npm 11 pruning this repo was burned by - it is not. It was refreshed
with npm@10 as always, and the entries disappear because vite 8 replaced
esbuild with rolldown, so three nested vite trees stopped depending on
esbuild at all. Every tree that still uses it keeps its full set: 45
`@esbuild/linux-*` entries remain across @angular/build, ng-packagr, tsup
and the root vite. 24 entries arrive for rolldown, unrs-resolver and
lightningcss.
Verified: lint, format-check, build, typecheck, core type tests (9, no
type errors), all four suites (117/116/115/91), the published-package
smoke suite, test:scripts (19), audit, and the three verify scripts.
* feat(ci): prove the React peer range, and fix the Angular one
Both adapters declared support they had never tested.
* react declares `react: ^18.0.0 || ^19.0.0`, but the workspace installs
React 19 and the suite has only ever run against it. React 18 was half
of a promise with nothing behind it.
* angular declares `@angular/core: ">=13 <22"`. That lower bound is
simply wrong. The published fesm2022 is partial-compilation output and
each declaration in it records the Angular version needed to link it;
the highest here is `minVersion: "14.0.0"`. An Angular 13 app would
install this package happily and then fail in the linker. Now `>=14`.
The upper bound stays at `<22` - a v19-compiled partial bundle links
forward, which is the direction Angular guarantees.
The react half gets a script rather than a CI matrix leg, because a
matrix leg does not work. Installing React 18 into this workspace leaves
npm hoisting one React to the root and nesting another, and the render
then dies with "A React Element from an older version of React was
rendered" - an artifact of the install layout, not a real
incompatibility. Three different pinning strategies (workspace devDeps,
root `overrides`, root devDeps) each produced a different 18/19 split.
So scripts/verify-react-peer-range.js does what a consumer does instead:
`npm pack` core and react, install the tarballs into a throwaway project
outside the workspace next to one exact React major, and server-render a
form with both a registered renderer and a built-in one.
`renderToString` is the deliberate choice - no jsdom, and it is the one
render path whose API is identical across 18 and 19.
Result: React 18 works. The range was honest, it just had no evidence.
react ^18: react 18.3.1 rendered: 89 chars
react ^19: react 19.2.8 rendered: 89 chars
Verified the check fails rather than passing vacuously: adding '17' to
MAJORS exits 1, because npm's own peer resolution refuses the install.
It runs in the `verify` job, as its own step after the offline
verification scripts, since it is the only one that installs from the
network and so the only one that can go red for reasons unrelated to the
code.
Verified: lint, format-check, build, all four suites (117/116/115/91),
the smoke suite, and the three existing verify scripts.
* chore(deps): put the whole repo on one TypeScript, and take in-range updates
The repo was compiling itself with two different TypeScript versions:
the root declared `~5.5.0`, packages/angular `~5.6.0`. So the `.d.ts`
that consumers get for core, react and vue was emitted by 5.5, while
angular's specs and ng-packagr build ran on 5.6 - and nothing made that
deliberate, it was just two bumps that never met.
Both are now `~5.8.3`, which resolves to a single hoisted copy;
packages/angular no longer carries a nested one. 5.8 is the ceiling, not
a preference: ng-packagr 19 peers `typescript >=5.5 <5.9`, so 5.9 and
the 7.x line are out until the Angular toolchain moves. typescript-eslint
8.68 accepts it (`>=4.8.4 <6.1.0`).
Also ran `npm update`, so in-range dependencies advanced without any
package.json range being touched: zod 4.4.3 -> 4.5.4, @vue/test-utils
and vue, @testing-library/react and user-event, lint-staged, and the
transitive tree behind them.
Compiled output is unchanged. `dist/index.*` and every `.d.ts` across
all four packages is byte-identical, and so is angular's fesm2022
bundle. The single file that moves is angular's fesm2022 sourcemap,
where 5.8 encodes the mappings slightly differently.
Lockfile refreshed with npm@10. linux platform entries 106 -> 135, with
54 `@esbuild/linux-*` still present across the trees that use esbuild.
Verified: lint, format-check, build, typecheck, core type tests (9, no
type errors), all four suites with --coverage (117/116/115/91), the
smoke suite, test:scripts (19), audit, and all four verify scripts
including the React peer-range check.
* fix(angular): stop publishing a 70.7 KB sourcemap nobody knew was there
When core, react and vue stopped shipping sourcemaps, angular was written
off in that changeset as unaffected: "ng-packagr's published output does
not carry them". It does. `npm pack --dry-run` in packages/angular lists
72.4kB dist/fesm2022/dynamic-field-kit-angular.mjs.map
and the map has `sourcesContent` with 12 embedded TypeScript files - the
same shape as the tsup maps that were dropped for being half of every
tarball. It has been published with every angular release since.
Unpacked 165.9 KB -> 97.1 KB (-41%)
Tarball 33.7 KB -> 18.2 KB (-46%)
Files 19 -> 18
Same trade as the other three, and worth naming as a trade: the map
worked, and this costs the ability to step into the library's TypeScript
from a consuming app.
ng-packagr has no `sourcemap: false`, so scripts/strip-sourcemaps.js runs
as a `postbuild` step and deletes both the map and the trailing
`//# sourceMappingURL=` comment. Deleting only the map - or excluding it
at publish time with a `files` negation, which was the first attempt -
leaves every consumer's devtools fetching a URL that 404s.
The script is deliberately narrow. It only strips a sourceMappingURL
comment anchored to the end of a file, so a URL that appears inside the
code survives; that case has a test. Six tests in
scripts/strip-sourcemaps.test.js, which take test:scripts from 19 to 25.
The bundle consumers load is otherwise identical - it differs by that one
comment line, and the angular suite passes unchanged at 91 tests.
Verified: lint, format-check, build, typecheck, all four suites
(117/116/115/91), the smoke suite, test:scripts (25), and all four verify
scripts.
* chore(example): refresh the demo lockfiles, which described a repo from three releases ago
Both example apps link the workspace packages through `file:` dependencies,
so their lockfiles carry a snapshot of what those packages declared at the
time. That snapshot had drifted badly:
../../packages/core version 1.2.0 -> 1.3.0
vitest ^0.34 -> ^4.1.11
coverage istanbul -> v8
missing yup, zod
Nothing was broken by it - CI installs the examples with `npm install`, not
`npm ci`, so a stale lockfile is re-resolved rather than enforced, which is
also why nobody noticed. But it made the files actively misleading to read.
Regenerated by installing each example. All three demo apps build:
next 16 (react), vite (vue), and the Angular CLI production build against
packages/angular/dist.
The file listed 91778d3, the prettier 3 reformat commit. #44 was squash-merged, so that commit is not reachable from develop - the whole branch landed as e4f8dbd. git tolerates an unresolvable rev in this file rather than failing, so nothing was broken. But the entry named a commit that does not exist, and the mechanism it was meant to drive no longer has a target: the reformat is now fused with the vitest 4 and eslint 10 migrations in a single commit, and ignoring that commit in blame would hide the authorship of real changes. So the list is emptied and the header explains why, rather than deleting the file - the convention is still worth having for the next formatting-only commit, which should be merged without squashing.
The form state hooks, schema adapters, wizard engine, DevTools and extended renderers were labelled 'v1.4+' in the root README, the three demo apps and the changeset that documents them. They have never been released: adapters are on npm at 1.4.0 without them, and core at 1.3.0. The release that ships them puts every package at 1.5.0, so the label pointed at a version where the features do not exist. Nothing published changes here - the package READMEs, which are what npm shows, carry no such label.
core is a whole minor behind the three adapters - 1.3.0 against 1.4.0 - because it had fewer releases early on, not because the packages diverged. They are released together every time: release.yml writes a changeset for every package unless its `packages` input names a subset. A `linked` group makes every package released in the same pass take the same version, so the pending changesets now produce 1.5.0 across the board (core skips 1.4.0) instead of core 1.4.0 against adapters 1.5.0. Verified by running `changeset version` on a throwaway clone with and without the group. Deliberately `linked` and not `fixed`: an angular-only patch must not bump and republish three packages whose tarballs did not change. The peer ranges stay at ^1.3.0, and `onlyUpdatePeerDependentsWhenOutOfRange` keeps them there. Every core symbol the three adapters import - all 26, checked against the exports of the published core@1.3.0 tarball - has existed since 1.3.0, so ^1.3.0 is accurate; none of them touches the wizard engine, the schema validators or the new group helpers. config.json cannot carry comments, so the reasoning lives in .changeset/README.md.
…ver mentioned Three things were wrong on the package's npm page: - The 'pin versions explicitly' example still said core@^1.0.12 and angular@^1.2.3, three release lines behind what npm serves. - Nine public exports appeared nowhere in the README: FieldInputProps, DynamicFormOptions, FieldTypeKey, LayoutConfig, ColumnLayoutConfig, RowLayoutConfig, GridLayoutConfig, BaseLayoutConfig and ResponsiveLayoutConfig. Checked by parsing dist/public-api.d.ts and grepping each name: 30 exports, 21 documented. core (50), react (28) and vue (28) are each at 100%, so angular was the only one with a gap. - 'Legacy setup (Angular 14 and earlier with NgModule)' names versions the package cannot run on - the peer floor is >=14, because the published fesm2022 records minVersion 14.0.0. It is the NgModule path that is legacy, not Angular 14.
…hree releases ago All three example READMEs still said the app renders two fields, 'name' as text and 'age' as number, and listed a Main Files section naming only the entry component. Each app has had three or four demos for a while: the basic schema, the feature tour (dynamic options, validators, async validation, appearCondition / disabledCondition), the enterprise demo (useDynamicForm / createDynamicFormStore, HTML5 renderers, DevTools) and the wizard - most of them with a source panel beside the running form. Each README now maps tab or route -> source file -> what it demonstrates, and carries the two things that actually trip someone up locally: - The demos depend on the packages through file: paths that resolve to dist, so the workspace has to be built before npm install here. - The angular demo must be built through npm, not the ng binary: prebuild / prestart generate the gitignored src/app/demo-sources.ts, and skipping the hook fails the build on a missing module. This is the same trap 4bee6eb fixed in CI.
All four package.json files say "license": "MIT" and the root README says MIT (c) vannt-dev, but there was no LICENSE file anywhere in the repo - so the published tarballs carried the claim without the terms. react@1.4.0 on npm contains exactly README.md, dist/ and package.json. npm always includes a LICENSE at the package root regardless of `files`, so a copy in each package plus one at the repo root is enough. Verified with `npm pack --dry-run`: all four tarballs now list LICENSE (core/react/vue 7 files, angular 19).
packages/angular/package.json has said "sideEffects": false since the package
was created, but src/layout/defaultLayouts.ts ends with three module-scope
layoutRegistry.register() calls. That is the same side effect react and vue
list their layout modules for; angular was simply missed.
The flag is now ["**/fesm2022/*.mjs"]. It is a glob rather than a path
because ng-packagr copies the field verbatim into dist/package.json, where
everything resolves one directory lower, and a bundler reads whichever
package.json is nearest the module it is looking at - dist/package.json for the
fesm bundle, the root one for the package as a whole. The glob is correct from
both.
Nothing observable changes today, which is why this is a claim and not a bug:
installing angular@1.4.0 from npm and bundling with esbuild while importing only
DynamicInput already kept register("column"), register("row") and
register("grid") - the fesm2022 output is one module and the app is using it.
Rebuilt and re-bundled with the new flag: same three calls, and the angular
suite passes at 91 tests.
Every package ships two sets of declarations but declared a single `types` target for both, so `arethetypeswrong` on the built tarballs reported core and react "masquerading as CJS" and vue "masquerading as ESM" - an ESM import resolved to the CommonJS declaration file, and the reverse. Each `exports` map now declares `types` per condition. react also had no top-level `types` at all; it resolved only because TypeScript falls back to the file next to `main`. Angular did not merely mismatch, it failed outright. Its entry is an `.mjs` bundle, so TypeScript reads its declarations in ESM mode, where the extensionless `export * from './public-api'` is error TS2834 - and that is in `dist/index.d.ts`, the first file a consumer reaches, so the package could not be imported at all on node16 resolution. Fixing the source imports would not have helped: ng-packagr writes that file itself and stamps it "Generated bundle index. Do not edit." So the extensions are added to the build output instead, by a new `postbuild` step. It resolves each specifier against the emitted tree, so a directory import becomes `./layout/index.js` rather than a broken `./layout.js`, and leaves anything it cannot resolve alone rather than inventing a path. Angular also gains an `exports` map and `"type": "module"`, which is what it has always been. `show-sizes.js` read `exports['.'].import` as a path, which the nested condition objects turned into "entry.replace is not a function" - a CI step. It now unwraps a condition to its target.
…y lacked All four packages ship `README` and `LICENSE` but nothing else the npm page uses: no `homepage`, so the page links to no documentation; no `bugs`, so it links to no issue tracker; and no `repository.directory`, so the repository link points at the monorepo root rather than the package. `CHANGELOG.md` now ships too - it is generated on release and was being written for no one. angular was shipping its README and LICENSE twice, once at the root and once more where ng-packagr had copied them into `dist`. `publishConfig.provenance` signs each tarball and links it back to the workflow run that built it, which needs `id-token: write` on the release workflow. npm fails the publish outright if it cannot mint that token, so the permission and the manifests have to land together.
`changeset publish` writes a git tag per package, so 1.5.0 landed as four tags
that all point at the same commit and say the same thing:
`@dynamic-field-kit/{core,react,vue,angular}@1.5.0`. With all four packages on
a single version line by design, that is noise on the tag list and on the
releases page.
Its tagging is now off, and the workflow writes one annotated `v<version>` tag
instead, named after core - the anchor of the version line. The exact version
of each package goes in the tag body, which is what keeps the tag honest in the
one case where the names could drift apart: `linked`, unlike `fixed`, lets an
angular-only patch bump angular without touching the other three.
The check fires on every pull request, so the `develop -> master` sync that follows a release fails it: that PR carries work whose changesets were consumed by the release itself, and there is nothing left to add. Adding one would mean releasing the same work a second time. It now runs only on PRs into develop, which is the branch where unreleased work accumulates and so the only branch where a missing changeset is a real defect. The existing exemption for the release PR changesets opens stays as it was.
Each package carried three keywords, two of which were its own name. npm ranks search partly on keywords, so these could realistically only be found by someone who already knew what to type. The repository has had the right vocabulary as GitHub topics since the start - dynamic-forms, form-builder, form-engine, form-validation, schema-driven, headless, and the framework names - and npm never saw any of it. Each package now carries that vocabulary plus the terms its own users would search, including the schema libraries it genuinely adapts: zod, yup, valibot and Standard Schema. Deliberately not json-schema, which is a different thing and not supported. `homepage` pointed at the package README on GitHub, which is the same text npm already renders on the package page from the shipped README - so the link led back to where the reader already was. It now points at the live demo, where the forms run. `repository` still carries the source.
…g it
Why: `FieldRendererProps` was a type nobody enforced. Each adapter hand-wrote
the object it handed the registered renderer, so the three lists drifted:
React dropped `placeholder`, `min`, `max`, `step`, `accept` and `multiple`;
Vue dropped `required`, `id`, `dirty` and the aria flags; Angular dropped
`touched`, `dirty` and `id`. Setting `placeholder` on a `FieldDescription`
therefore did nothing at all on React and Vue - no error, no warning, the
value simply vanished - and a renderer written for one framework could not be
ported to another, which is the opposite of the point of a shared schema.
Field ids had a second, unrelated problem: adapters built them as
`dfk-field-${name}`, from the field name alone. Two forms holding a field of
the same name emitted the same DOM id twice, which is invalid HTML and leaves
every `label[for]` pointing at two inputs.
What:
- `FIELD_RENDERER_PROP_KEYS` names the contract; `buildFieldRendererProps`
produces the whole bag once, resolving disabled/readOnly/options, validating
(skipping disabled fields, whose errors a user cannot act on) and setting the
aria flags.
- `makeFieldId(field, prefix)` returns `field.id` when set, else
`${prefix}-${name}`, so callers can namespace ids per form instance.
- `FieldDescription` gains `id`, to pin one field's id outright.
- `ariaDescribedBy` is deliberately left unset: no adapter renders the
description or error node, so pointing it at an id that may not exist would
be worse than omitting it.
How to test: `npm run test --workspace=@dynamic-field-kit/core`
Why: three of the reported bugs were one design flaw. Touched state had two independent trackers that never met - `useDynamicForm`'s, and a private one inside `MultiFieldInput` that only blur could set, which was the one renderers actually saw. So `setFieldTouched` from an `onInvalid` handler changed nothing visible, submitting a form nobody had focused showed no errors at all (the button looked broken), and `reset()` could not clear touched left behind by an earlier round, so a form that stays mounted across submits kept showing the previous errors. Separately, ids came from the field name alone, so two forms holding the same field name emitted duplicate DOM ids. What: - `MultiFieldInput` accepts `touched` as a controlled prop, making `useDynamicForm` the single source of truth exactly as `properties`/`onChange` already were for data, plus `onTouchedChange` and a `form` shorthand that wires data, change, blur and touched in one prop. - Omitting `touched` keeps the old internal tracker, so nothing breaks; a forwardRef handle exposes `resetTouched`/`setFieldTouched`/`getTouched` for that mode, so clearing it no longer needs a `key` remount. - `handleSubmit` calls the new `touchAll()` before validating; `resetTouched()` and `setTouched` round out the hook. - Ids are namespaced per instance via `useId` (SSR-safe, and its delimiters are stripped so the result works as a CSS selector). `idPrefix` pins them - `idPrefix="dfk-field"` reproduces the old ids. - `FieldInput` builds its props through core's `buildFieldRendererProps`, and `DynamicInput` spreads them rather than re-listing each one, which is how `placeholder`, `min`, `max`, `step`, `accept` and `multiple` went missing. How to test: `npm run test --workspace=@dynamic-field-kit/react`
Why: the Vue adapter carried the same architecture as React, so it carried the
same three bugs - a private touched tracker the form store could not drive or
reset, and ids built from the field name alone that collide when two forms hold
the same field. On top of that its renderer prop list had drifted the other
way: `required`, `id`, `dirty` and the aria flags never reached a renderer, and
neither did `placeholder`.
What:
- `MultiFieldInput` gains a controlled `touched` prop, `onTouchedChange`, a
`form` shorthand (which unwraps the composable's refs), and exposes
`resetTouched`/`setFieldTouched`/`getTouched` for the uncontrolled mode.
- `useDynamicForm` gains `touchAll()`/`resetTouched()`, and `handleSubmit`
touches everything before validating.
- Ids are namespaced per instance from the component uid; `idPrefix` pins them.
- `properties` defaults to undefined rather than `{}` so the `form` shorthand
can tell "not passed" from "passed empty".
- `FieldInput` builds props through core's `buildFieldRendererProps`, and
`DynamicInput` declares every contract key - an undeclared key is a
fallthrough attribute, not a prop, which is why they were unreachable.
- `className` is delivered as `class`, and only as `class`. Forwarding
`className` too lets it fall through to a renderer's root element, where Vue
assigns `el.className`; an undefined value becomes `''` and wipes the class
the renderer set on itself, which broke the bundled radio group.
How to test: `npm run test --workspace=@dynamic-field-kit/vue`
Why: this adapter was the worst off. `touched` was not in `DynamicInput`'s KNOWN_PROPS and not an input on `BaseInputComponent`, so no Angular renderer could ever receive it - `MultiFieldInput.isTouched()` existed but nothing in the template called it, making it dead code. An Angular renderer therefore had no way to do "only show the error once the user leaves the field" without reimplementing blur tracking from scratch. `dirty` and `id` were missing the same way, and ids were not emitted at all. The store's `touched` signal was likewise disconnected from what rendered. What: - `BaseInputComponent`/`FieldInputProps` and `DynamicInput`'s KNOWN_PROPS now carry the whole contract: `touched`, `dirty`, `id`, the aria flags and `min`/`max`/`step`/`accept`/`multiple`. The HTML5 fallbacks set `id` too. - `FieldInput` resolves everything through core's `buildFieldRendererProps`, once per change-detection pass rather than once per binding, and takes `data` so cross-field validation and dynamic options resolve against the real form. Explicitly bound inputs still override, so mounting it directly still works. - `MultiFieldInput` gains a controlled `touched` input, a `touchedChange` output, public `resetTouched()`/`setFieldTouched()`, per-field `dirty`, and per-instance id namespacing with an `idPrefix` override. - `createDynamicFormStore` gains `touchAll()`/`resetTouched()`, and `handleSubmit` touches everything before validating. - Drops `getResolvedOptions`/`getDisabled`/`getReadOnly`/`getError` from `MultiFieldInput`: the template no longer calls them, and a second copy of that logic beside the shared one is how the adapters drifted apart to begin with. The core equivalents are already re-exported from this package. The FieldInput spec's "withholds a dynamic options callback" case is updated rather than kept: it encoded the old limitation that FieldInput had no form data. It does now, so the callback resolves, matching React and Vue. How to test: `npm run test --workspace=@dynamic-field-kit/angular`
…prop Why: core now owns the renderer prop list, but each adapter still has to carry those keys across its own component boundary - React through DynamicInput's Props interface, Vue through its declared props (an undeclared key becomes a fallthrough attribute, not a prop) and its forwarding call, Angular through KNOWN_PROPS plus a matching @input. Nothing stopped one of them from quietly dropping a key again, which is precisely how six props went missing on React, four on Vue and four on Angular without a single test failing. What: `scripts/check-renderer-prop-parity.js` parses the contract out of core and probes each adapter's source for every key, with one documented exception - Vue's `class` in place of `className`. Wired into the quality-gates verify job and available as `npm run lint:renderer-parity`. How to test: `node scripts/check-renderer-prop-parity.js`, then delete a key from any adapter's list and watch it exit 1 naming the adapter and the prop.
… ownership Why: the READMEs described a `FieldRendererProps` that no adapter actually delivered in full - the snippets predated `touched`, `dirty`, `error`, the aria flags and the numeric/file props - and said nothing about which adapter forwards what. They also still implied touched could be driven through `setFieldTouched` alone, the pattern that quietly did nothing. What: - Core and root READMEs carry the real interface, say that parity is enforced by a build check rather than convention, and name the one deliberate deviation (Vue's `class`) and the one prop no adapter fills in (`ariaDescribedBy`). - Each adapter README documents the `form` shorthand as the recommended wiring, the controlled `touched` prop, `touchAll`/`resetTouched`, the ref/handle for the uncontrolled mode, and a "Field ids" section covering `idPrefix` and `FieldDescription.id`. - Changeset spells out the one behaviour change: generated ids are no longer the literal `dfk-field-*`. How to test: `npm run format-check`
Angular 19 -> 21 across the package, the demo app and the root, plus TypeScript 5.9.3, zone.js 0.16, @testing-library/jest-dom 7 and @types/node 26. Three things had to change for the bump to actually work. Angular 21 ships every entry point through `exports` alone, with no `main` or `types`. `tsconfig.base.json` still asks for node10 resolution, which cannot read an `exports` map, so the Angular package stopped resolving its own dependencies: `tsc -p packages/angular` reported 16 x TS2307, and every spec died in `test/setup.ts` on `@angular/core/testing`. Both the Angular package tsconfig and the demo app's now use `moduleResolution: "Bundler"`, which is what the Angular CLI itself uses - the demo app failed the same way, on `@angular/common/http` and `@angular/core/primitives/di`. ng-packagr 21 emits the type declarations as `dist/types/<name>.d.ts` instead of `dist/index.d.ts`, so the manifest's `types` pointed at a file the build no longer produces - the package would have published with types that resolve to nothing. `types` and `exports` now name the file that actually ships. `scripts/verify-package-entrypoints.js` fails the build on any manifest path that the build does not emit, so this cannot come back quietly; it runs in the quality-gates verify job next to the other checks. The lockfile was regenerated with npm 10 (npm 11 prunes other platforms' optional binaries on Windows). The previous one had ten entries under packages/angular/node_modules with no `resolved`/`integrity` at all and no entry for `injection-js`, which ng-packagr 21 requires - `npm ci` refused it outright and the Angular build died on the missing module. Every platform binary the Linux runners need is still present. The two `vitest.config.ts` files that Vite warned about are renamed to `.mts`; the contents are unchanged and nothing referenced them by path.
Matches the React adapter: the baseline was a const snapshot taken in setup() and never reassigned. It now tracks the first non-undefined properties, or comes from initialProperties / form.baselineValues. The first-seen tracker is a ref rather than a plain binding because the baseline is a computed, which only re-evaluates on reactive reads.
… store Mirrors the React and Vue adapters.
Completes the three-adapter fix. This adapter turned out to be the least affected: init() only records a baseline when properties is set, so values arriving after mount were already handled correctly - the late-load test added here passed before the fix. What was broken is that the initialised guard pinned the baseline permanently, so a store reset could never move it. The new initialProperties input is that escape hatch; pass store.baselineValues() into it. The private field is renamed to firstSeenProperties to free the name for the input, matching React and Vue.
It was hard-coded undefined, so focusFirstInvalidField - which selects [aria-invalid=true] - had nothing to find for consumers whose renderers followed the official recipe. makeErrorId defines the convention once, in one place, for all three adapters.
1.6.0 moved placeholder, min, max, step, accept and multiple to the top level of FieldDescription. Values left behind in props are overwritten by the resolved contract and vanish with no throw and no warning - the one upgrade hazard a consumer cannot diagnose from the outside. Fires once per field+key and only outside production.
The default renderers forwarded aria-describedby but never rendered the error they were handed, so the reference had nothing to point at. Emitted as a fragment sibling - no wrapper element, so layout is unchanged - and only where no custom renderer is registered, so consumers rendering their own message do not get a second copy.
Mirrors the React adapter's markup exactly, returned as a fragment array so no wrapper element appears. This adapter declares error as [String, Array], unlike React where core only ever supplies an array, so the message is normalised before use - indexing a raw string would have rendered its first character.
Completes the three-adapter error node, so aria-describedby resolves on every adapter instead of dangling. Two adapter-specific notes. The template uses *ngIf, not the @if block: the peer range starts at Angular 16 and block control flow is 17+. And the condition asks the registry directly rather than reading a flag set in render(), which runs in ngAfterViewInit - by then this template's bindings are already checked for the pass, and under OnPush nothing would mark them dirty again.
Also re-exports makeErrorId from all three adapters - check-docs-api- references caught that the recipe imported it from the react package, which did not have it. Angular re-exported none of the renderer-prop helpers, so it gains buildFieldRendererProps, makeFieldId and FIELD_RENDERER_PROP_KEYS alongside, matching react and vue.
Validation messages could only be set per field, per form, by passing a string to each validator - so translating a form meant touching every field description. t lives on the existing ValidationContext rather than a new parameter: FieldDescription.validate already takes that context as its fourth argument, so there was no free slot and no need to invent one. An async validator gets the resolver for free as a result.
Each validator computed its message when the field description was built, so a catalog could never reach it. Resolution moves inside the returned closure, with an explicitly passed string still winning over any catalog - every existing call site behaves identically. validators.matches lands in the same change because its default message needs that machinery; every consumer was hand-writing the same (value, data) => value !== data.other for confirm-password fields.
validateField and validateFields gain a trailing optional context, so a catalog reaches the validators - including inside repeatable groups, where the recursive call now forwards it. validateFieldsAsync needed no change: its options bag already is the ValidationContext and was already threaded recursively, so t flows there as soon as a caller supplies it.
Both validateFieldsAsync call sites spread the context before setting signal, rather than replacing the options object - dropping the signal there would silently disable run cancellation.
debounceMs was declared in FieldDescription, published in the .d.ts and read by no implementation anywhere - setting it did nothing. It now debounces this loader. Everything hard lives here rather than three times over in the adapters: debounce, abort of a superseded run, a run counter that discards an out-of-order response even when the signal is ignored, and shallow deps comparison. An AbortError is deliberately not an error state - being superseded is normal and would otherwise flash a failure on every keystroke of a search box. options takes one signature, not a union of sync and async shapes: a union defeats TypeScript's contextual inference, so every existing options: (data) => ... would have started erroring under noImplicitAny. Returning a promise is what makes a loader async. resolveOptions now returns undefined for those, so no renderer is handed a Promise.
optionsStatus and optionsError join FIELD_RENDERER_PROP_KEYS, so lint:renderer-parity now requires all three adapters to forward them - it currently fails on angular, which the next commits fix. onOptionsQuery is deliberately not in that list: it is a callback, attached alongside onValueChange and onBlur, and putting it there would make the parity script check the wrong kind of thing.
Two things this needed beyond wiring the loader in. FieldInput's memo comparator only compares this field's own slice of the data, so a field whose optionsDeps read *another* field would never re-render to notice the change - a country/city pair would load once and never again. Async-options fields now compare the whole data object. resolveOptions now drops a promise returned by a loader that detection missed, and warns with the fix. constructor.name === 'AsyncFunction' does not survive a memoiser, a spy or a transpiler helper, and handing the renderer a pending promise as its option list is worse than an empty one. This mirrors what the validate path already does.
Mirrors the React adapter. The watch is deep on the whole data object because optionsDeps can read another field's value, and the loader - not the component - decides whether anything it cares about changed.
Completes the three-adapter loader; lint:renderer-parity now reports 23 props instead of 21. The loader callback calls markForCheck: these components are OnPush, so an async arrival happens outside any event the view is checked for and the options would otherwise load and never appear. onOptionsQuery is declared on BaseInputComponent only - redeclaring it on DynamicInput is a TS4114-class error under useDefineForClassFields.
The root README covered the new API; the per-package ones did not mention any of it. Each adapter README now documents baselineValues, getDirtyValues, the messages catalog, initialProperties, async options and the default renderers' new error node, and the core README carries the full catalog key table and the async options reference the adapters link to. One paragraph in the core README had become actively wrong: it still said ariaDescribedBy is the one prop no adapter fills in. It now explains what replaced that and why the old reasoning, though sound, left focusFirstInvalidField doing nothing.
Seven defects in code added by this branch, each with a test that fails
without the fix.
react: the options loader was built in the render body and disposed by
the effect cleanup, but the ref was never cleared - StrictMode's
mount/cleanup/mount left every field holding a disposed loader, so async
options sat at 'loading' forever in any development build. It is now
created lazily and re-created after disposal.
angular: onOptionsQuery never reached a renderer. applyProps iterates
KNOWN_PROPS, which deliberately excludes callbacks, so search-remote was
dead on this adapter despite being documented. Callbacks now have their
own pass, covering the component, the sync and the fallback paths.
angular: the HTML5 fallback never set aria-invalid or aria-describedby,
so the new error node had nothing pointing at it and
focusFirstInvalidField still found nothing - the exact failure the
migration guide claims is fixed. Applied once after the fallback builds,
rather than in each of its four branches.
react: the default error node rendered error[0] of a bare string, which
is its first character. Vue and Angular already normalised; React did
not, and DynamicInput is publicly exported with error typed
string | string[].
core: a retry after a failed load emitted status 'loading' while still
carrying the previous optionsError. The loading transition now clears it.
core: isAsyncOptions returned true for optionsMode: 'async' on a field
whose options is a static array, and fetchNow then threw "load is not a
function" out of a lifecycle hook. It now requires a callable first.
angular: swapping a field from async to synchronous options left the
stale optionsState winning in buildFieldRendererProps, serving the old
list forever with the loader never disposed.
The one finding not fixed is the baseline when properties starts as {}
rather than undefined: {} is a real value, and a form that opens blank
cannot be told apart from one still waiting on a fetch. Characterised by
two tests and documented as a caveat with initialProperties as the
escape hatch.
Changing a field's name swaps the loader (adapters key each field by name, so it remounts); changing only the options closure on a same-named field does not. That asymmetry is deliberate and now has a test saying so. Rebuilding on closure identity would refetch in a loop for the very common case of a fields array built inline in a component body, which gets a fresh closure on every render.
Owner
Author
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Verification