From 899979bad03d8f494d69de173deb3da834d3025c Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Wed, 30 Sep 2026 20:37:58 -0400 Subject: [PATCH 1/5] Bulid time feature flags (draft) --- text/0000-build-time-feature-flags.md | 252 ++++++++++++++++++++++++++ 1 file changed, 252 insertions(+) create mode 100644 text/0000-build-time-feature-flags.md diff --git a/text/0000-build-time-feature-flags.md b/text/0000-build-time-feature-flags.md new file mode 100644 index 0000000000..14b2e62f8b --- /dev/null +++ b/text/0000-build-time-feature-flags.md @@ -0,0 +1,252 @@ +--- +stage: accepted +start-date: 2026-09-30T00:00:00.000Z +release-date: +release-versions: +teams: + - cli + - framework + - learning +prs: + accepted: # Fill this in with the URL for the Proposal RFC PR +project-link: +suite: +--- + + +# Build-time Feature Flags + +## Summary + +`ember-source` reads every flag from `import.meta.env?.EMBER_*`, and from nowhere else: + +```js +// @ember/-internals/environment +export const DEFAULT_ASYNC_OBSERVERS = import.meta.env?.EMBER_DEFAULT_ASYNC_OBSERVERS ?? true; +``` + +The bundler replaces the expression with a literal. +Every read of `DEFAULT_ASYNC_OBSERVERS` then folds to a constant, and the minifier removes the code that the app does not use. +This covers canary features, optional features, `EmberENV`, and deprecations that can remove their code early. + +Existing config files keep working, because the build reads them and turns them into `import.meta.env` values. +Minimal apps with no config files get a way to set flags for the first time. +The runtime global `window.EmberENV` stops being a source of flags. + +## Motivation + +Ember has three places to configure the framework, and all three are runtime-only: + +| Source | Where the app sets it | How `ember-source` reads it | +| --- | --- | --- | +| Canary features | `EmberENV.FEATURES` in `config/environment.js` | `isEnabled('FLAG')` from `@ember/canary-features` | +| Optional features | `config/optional-features.json` | `@ember/optional-features` copies the values into `EmberENV` | +| `EmberENV` | `config/environment.js` | `ENV` from `@ember/-internals/environment` | + +All three end up in `window.EmberENV`. +In an app with `@embroider/compat`, Embroider writes the `EmberENV` object from `config/environment.js` into `/@embroider/virtual/vendor.js`, which runs before Ember loads. +Because the values arrive at runtime, every branch ships to every user. +An app that turned on `default-async-observers` years ago still downloads the sync observer path. +[RFC 1234](./1234-deprecate-ember-object.md) adds a flag that turns off `EmberObject`, but the code still ships until the build can remove it. + +A build-time value alone does not fix this. +Assume that `ember-source` reads a flag from `import.meta.env` and falls back to `window.EmberENV`. +Then each flag that the build does not set still depends on a runtime value, and both branches stay. +One source of truth is a requirement for dead-code removal. + +Vite, Rolldown, esbuild, and Rollup all replace `import.meta.env.*` with literals at build time. +If Ember reads its flags from `import.meta.env`, then the tools that apps already use can remove dead code, with no Ember-specific plugin in the minifier. + +Minimal apps have no path at all. +The [`v2-app-hello-world-template`](https://github.com/emberjs/ember.js/tree/main/smoke-tests/v2-app-hello-world-template) in the ember.js repo and the `minimal-app` output of [`ember.nvp`](https://github.com/NullVoxPopuli/ember.nvp) have no `@embroider/compat`, no `config/environment.js`, and no `vendor.js`. +The `ember.nvp` output has an `EmberENV: {}` key in `app/config.ts`, but nothing copies it to `window.EmberENV`, so it has no effect. +The only way for these apps to set a flag today is a hand-written `window.EmberENV` assignment that runs before the first import of `ember-source`. + +`ember-source` already uses this pattern internally. +`@glimmer/local-debug-flags` reads `import.meta.env?.VM_LOCAL_DEV`. + +## Detailed design + +### The read pattern + +Each flag is one exported `const`, and `ENV` is built from those constants: + +```js +// @ember/-internals/environment +export const FLAG = import.meta.env?.EMBER_FLAG ?? ; +export const FEATURE_FOO_BAR = import.meta.env?.EMBER_FEATURE_FOO_BAR ?? false; + +export const ENV = { + FLAG, + FEATURES: { FOO_BAR: FEATURE_FOO_BAR }, +}; +``` + +Code in `ember-source` imports the `const`, and does not read `ENV.FLAG`. +`ENV` stays for Ember Inspector and for `isEnabled`, and it holds the same values. + +- `import.meta.env?.` uses optional chaining because some environments do not define `import.meta.env`. Examples: a plain `