Skip to content

feat(ember-form): add Ember adapter (@tanstack/ember-form)#2156

Draft
NullVoxPopuli-ai-agent wants to merge 3 commits intoTanStack:mainfrom
NullVoxPopuli-ai-agent:ember-form-adapter
Draft

feat(ember-form): add Ember adapter (@tanstack/ember-form)#2156
NullVoxPopuli-ai-agent wants to merge 3 commits intoTanStack:mainfrom
NullVoxPopuli-ai-agent:ember-form-adapter

Conversation

@NullVoxPopuli-ai-agent
Copy link
Copy Markdown

Summary

Adds @tanstack/ember-form, a new framework adapter for Ember (6.x, gjs/gts only, v2 addon) wrapping @tanstack/form-core.

  • createForm(parent, options) — Returns the underlying FormApi extended with useStore(selector?). The form is mounted immediately and unmounted via @ember/destroyable's registerDestructor against the supplied parent (typically this from a @glimmer/component).
  • <Field @form @name [@validators] [@defaultValue] …> — Constructs a FieldApi, manages its mount lifecycle, and yields it. The yielded field.state is autotracked, so reads in templates rerender on store changes.
  • <Subscribe @form [@selector]> — Yields a reactive slice of the form's store, recomputed on subscription updates.
  • form.useStore(selector?) — Imperative variant (e.g. for @cached getters).

Modeled closely on @tanstack/svelte-form's API, swapping Svelte runes for Glimmer's @tracked.

Implementation notes

  • TS sources (.ts for plain modules, .gts for components). Builds via the standard Embroider v2 addon scaffold (@embroider/addon-dev/rollup + babel + ember-tsc for .d.ts).
  • The .state reactive surface is implemented with a small TrackedValue<T> (a @tracked current: T box). On the field's store subscription firing, the box's current is reassigned, which dirties the autotracking tag and schedules a rerender of templates that read it. The same primitive backs useStore and <Subscribe>.
  • We rely on ember-source's provision of @glimmer/tracking rather than the standalone @glimmer/tracking@1.x, whose consume() is a no-op stub that breaks autotracking under modern Ember.

Testing

pnpm --filter @tanstack/ember-form

Script Tool Status
test:eslint eslint + ember-eslint-parser pass
test:types ember-tsc --noEmit pass
test:lib vite-built tests + testem on Chrome 7/7 ✅
test:build publint --strict pass
build rollup + ember-tsc declarations pass

Test coverage (Chrome via testem):

  • initial Field render with defaults
  • user input → field state reactivity
  • field-level validators showing/clearing errors
  • <Subscribe> reactivity across selectors
  • useStore selector reactivity
  • handleSubmitonSubmit({ value })

Open items / questions

  • CI: test:lib requires Chrome (testem). ubuntu-latest ships Chrome so it should work in the existing Nx pipeline; happy to swap to a headless setup that mirrors how other adapters run their browser tests if that's preferred.
  • Changeset / version-fixed group: I haven't added @tanstack/ember-form to .changeset/config.json#fixed — adding it would auto-bump the package to 1.x to match the others on first publish. Let me know which path you'd like.
  • Bound form.Field: Svelte exposes <form.Field> via closure-binding; the Ember version requires @form={{this.form}} explicitly. I can add a closure-bound variant if you'd like to mirror the Svelte ergonomics.
  • Reactive options: Svelte takes () => opts so option changes propagate. Ember's createForm currently captures options once; users can call form.update(opts) to re-apply. A @cached autotracking variant is a possible follow-up.

Marking this draft and would love your feedback on shape, ergonomics, and CI integration.

Test plan

  • CI passes Nx affected pipeline (test:eslint, test:types, test:lib, test:build, build)
  • pnpm start from packages/ember-form runs the demo-app

🤖 Generated with Claude Code

Introduces @tanstack/ember-form, an Ember v2 addon (Ember 6+, gjs/gts) wrapping
@tanstack/form-core via Glimmer's @Tracked autotracking.

- createForm(parent, opts): returns FormApi with reactive useStore(selector)
- <Field @Form @name>: yields a FieldApi whose .state is autotracked
- <Subscribe @Form @selector>: yields a reactive slice of form state
- All tests pass (test:lib testem-on-Chrome) plus test:eslint, test:types,
  test:build (publint), and build (rollup + ember-tsc declarations).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 8, 2026

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 3cbb8746-f3bc-440b-b4d4-ad41e5195bdc

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Tip

💬 Introducing Slack Agent: The best way for teams to turn conversations into code.

Slack Agent is built on CodeRabbit's deep understanding of your code, so your team can collaborate across the entire SDLC without losing context.

  • Generate code and open pull requests
  • Plan features and break down work
  • Investigate incidents and troubleshoot customer tickets together
  • Automate recurring tasks and respond to alerts with triggers
  • Summarize progress and report instantly

Built for teams:

  • Shared memory across your entire org—no repeating context
  • Per-thread sandboxes to safely plan and execute work
  • Governance built-in—scoped access, auditability, and budget controls

One agent for your entire SDLC. Right inside Slack.

👉 Get started


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@NullVoxPopuli-ai-agent
Copy link
Copy Markdown
Author

cc @NullVoxPopuli — drafted per your direction (Ember 6 v2 addon, gjs/gts only, modeled on svelte-form). Couldn't add you as a reviewer programmatically due to fork permissions; please assign yourself when convenient.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

let's delete this and the config folder -- for this project, keeping up with the blueprint upstream doesn't matter

Adds an ergonomic shorthand: createForm now exposes a Field component bound
to the owning form via lexical scope, so consumers can write

  <this.form.Field @name="firstName" as |field|>
    ...
  </this.form.Field>

instead of repeating @Form={{this.form}} everywhere. Mirrors svelte-form's
<form.Field> shape. Also updates the demo-app + README to use the bound
form, and adds a rendering test.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@NullVoxPopuli-ai-agent
Copy link
Copy Markdown
Author

Pushed a follow-up: createForm now exposes a closure-bound Field so consumers can write <this.form.Field @name="..." /> instead of <Field @form={{this.form}} @name="..." />. Matches svelte-form's <form.Field> ergonomics. Tests + demo + README updated; full pipeline still green (8/8 rendering tests, eslint, ember-tsc, publint, rollup build).

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we should not use this. use trackedObject (which means our minimum supported ember version is 6.8, which is fine)

this.#api = new FieldApi({ form, name, ...rest } as never);

const stateBox = new TrackedValue(this.#api.store.state);
Object.defineProperty(this.#api, 'state', {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is going to need a "why" comment about why this is here -- as this is a sort of unconventional thing to do on someone else's API

) {
super(owner as never, args);

const { form, name, ...rest } = args;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do the arguments hear not need to be reactive?

selector?: (
state: FormState<
TParentData,
any,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

are these anys appropriate?

Comment thread packages/ember-form/README.md Outdated

## Compatibility

- Ember.js v6.0 or above (gjs/gts only)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this should really be ember-source 6.8+

ember.js isn't a package (that's relevant anyway)

Comment thread packages/ember-form/README.md Outdated
## Compatibility

- Ember.js v6.0 or above (gjs/gts only)
- `@glimmer/component` v2
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we don't need to specify this, because package manager peer errors will tell the user

Comment thread packages/ember-form/rollup.config.mjs Outdated
plugins: [
addon.publicEntrypoints(['**/*.js', 'index.js']),

addon.appReexports([
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

let's remove this plugin

Comment thread packages/ember-form/rollup.config.mjs Outdated
configFile: babelConfig,
}),

addon.hbs(),
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

let's remove this plugin

Comment thread packages/ember-form/rollup.config.mjs Outdated

addon.hbs(),
addon.gjs(),
addon.keepAssets(['**/*.css']),
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

let's remove this plugin

Comment thread packages/ember-form/vite.config.mjs Outdated
import { babel } from '@rollup/plugin-babel';

// For scenario testing
const isCompat = Boolean(process.env.ENABLE_COMPAT_BUILD);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we are not going to support / test against compat builds -- remove this (and everything else related to ENABLE_COMPAT_BUILD)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this test file should be split in to multiple test files, based on the concept being tested

Copy link
Copy Markdown

@NullVoxPopuli NullVoxPopuli left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lets of updates needed -- be sure to update the docs in this repo as well

Per review on TanStack#2156:

* drop scaffold cruft: config/, .env.development, ENABLE_COMPAT_BUILD branches,
  addon.appReexports/hbs/keepAssets, ember-template-lint, @embroider/compat,
  @ember/test-waiters
* replace TrackedValue helper with @glimmer/validator#trackedObject (the
  same primitive @ember/reactive/collections re-exports). Bumps minimum
  ember-source peer to ^6.8.0 and removes -private/tracked-state.ts.
* make Field args reactive: a @cached _syncArgs getter calls api.update()
  whenever any this.args.* changes, mirroring svelte-form's $effect.pre
* add why-comment on Object.defineProperty(api, 'state', ...) explaining
  the intentional shadowing of FieldApi#state for tracked reads
* parameterize SubscribeSignature generics so consumers no longer see
  any-typed selector params
* split tests by concept: create-form, field, subscribe, and
  field-reactive-args (which exercises the new @cached args sync)
* docs/framework/ember/: full port of svelte-form's quick-start + 7 guides
  (basic-concepts, validation, dynamic-validation, async-initial-values,
  arrays, linked-fields, form-composition), wired into docs/config.json
* README: bump compat to ember-source 6.8+, drop @glimmer/component peer
  note, link to the new docs

NODE_ENV=development is set on test:lib so @embroider/macros runs in
runtime mode (required for setTesting in test-helper.js).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@NullVoxPopuli-ai-agent
Copy link
Copy Markdown
Author

Pushed a review-response commit (6c06223). Summary against your inline notes:

Mechanical cleanup

  • Deleted config/ and .env.development
  • rollup.config.mjs: removed addon.appReexports, addon.hbs, addon.keepAssets, plus the empty ember-addon.app-js block in package.json
  • vite.config.mjs + babel.config.cjs: stripped all ENABLE_COMPAT_BUILD branches; dropped @embroider/compat and ember-template-lint from devDeps; dropped the lint:hbs* scripts
  • @ember/test-waiters was unused — removed

Reactivity

  • Deleted src/-private/tracked-state.ts. createForm, <Field>, and <Subscribe> now use trackedObject (imported from @glimmer/validator, where @ember/reactive/collections re-exports it from — that path isn't yet in @embroider/addon-dev's virtual-peer list, so the underlying import avoids the resolver warning). Min ember-source peer is now ^6.8.0.
  • <Field> args are reactive: a @cached _syncArgs getter calls api.update(...) whenever any this.args.* changes, mirroring svelte-form's $effect.pre. Read in the template so the autotracking entanglement actually fires.
  • Added a comment on the Object.defineProperty(api, 'state', ...) shadowing inside <Field> explaining why we replace the prototype getter (it's intentionally unconventional).
  • <Subscribe>'s signature is now parameterized over the form generics; the any[]s in FormState<...> are gone.

Tests

  • Split create-form-test.gts into 4: create-form-test, field-test, subscribe-test, and a new field-reactive-args-test that swaps @validators at runtime and asserts the new validator runs (covers the @cached _syncArgs flow). 10/10 pass.

Docs

  • Added docs/framework/ember/quick-start.md and 7 guides (basic-concepts, validation, dynamic-validation, async-initial-values, arrays, linked-fields, form-composition), all ported faithfully from svelte's. Wired into docs/config.json for both Getting Started and Guides; pnpm test:docs (link verifier) passes.
  • README: compat updated to ember-source 6.8+, dropped the @glimmer/component peer mention, added a link to the new docs.

Notes for your re-review

  • form-composition.md lacks an App-form section — createFormCreator doesn't exist on the Ember adapter yet, and I called that out near the top of the guide and used Glimmer wrapper components instead. Happy to add createFormCreator in a follow-up if you want it as part of this PR.
  • async-initial-values.md demonstrates reactiveweb's trackedFunction for the data-loading example. If you'd rather a different primitive (warp-drive, Ember Data, route models), it's a one-snippet swap.
  • I added NODE_ENV=development to the test:lib script so @embroider/macros runs in runtime mode; without it, setTesting(true) in tests/test-helper.js fails at compile time once the compat plugin is gone.

Full pipeline still green: 10/10 rendering tests, eslint, ember-tsc, publint --strict, rollup build, sherif, and pnpm test:docs.


<template>
<this.form.Field @name="people" @mode="array" as |field|>
{{#each field.state.value as |person i|}}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why specify i? do we expect users to care about the index?


export default class MyForm extends Component {
form = createForm(this, {
...formOpts,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fromOpts is not defined

}
```

> Why pass `this`? `createForm` mounts the underlying `FormApi` immediately and registers a destructor against the parent so that store subscriptions are cleaned up when the component is torn down. Passing `this` ties the form's lifetime to your component.
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good callout

@crutchcorn
Copy link
Copy Markdown
Member

@NullVoxPopuli given the noise on this PR, can you message me via DMs on Bsky/Discord when this is ready for review or when you have clarifying questions? :)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants