| stage | start-date | release-date | release-versions | teams | prs | project-link | suite | |||
|---|---|---|---|---|---|---|---|---|---|---|
accepted |
2025-08-15 00:00:00 UTC |
|
|
Introduce a new low-level API, lowLevel.subtle.watch, available from @ember/renderer, which allows users to register a callback that runs when tracked data changes. This API is designed for advanced use cases and is not intended for general application reactivity.
It is not a replacement for computed properties, autotracking, or other high-level reactivity features.
Caution
This is not a tool for general use.
Some advanced scenarios require observing changes to tracked data without triggering a re-render or scheduling a revalidation. The lowLevel.subtle.watch API provides a mechanism for users to hook into tracked data changes at a low level, similar to TC39's signals + watchers proposal:
Use cases include:
- synchronizing external state whithout the need to piggy-back off DOM-rendering
- ember-concurrency's
waitForwitch would not need to rely on observers (as today) or polling - Building alternate renderers (instead of rendering to DOM, render to
<canvas>, or the Terminal)
Caution
This API is not intended for application logic.
type Unwatch = () => void;
function watch(callback: () => void): Unwatch;The API is available as lowLevel.subtle.watch from @ember/renderer.
- The callback runs during the transaction in
_renderRoot, piggybacking on infrastructure that prevents sets to tracked data during render- This is not immediately after tracked data changes, nor during
scheduleRevalidate(which is called wheneverdirtyTagis called) - Callbacks are registered and run after tracked data changes, but before the next render completes
- This is not immediately after tracked data changes, nor during
- Multiple callbacks may be registered; all will run in the same serially in relation to each other (if the same tracked data changes)
- Callbacks must not mutate tracked data. Attempting to do so will throw an error -- the backtracking re-render protection message.
- If a callback attempts to set tracked data, an error is thrown to prevent feedback loops and maintain render integrity
- Callbacks are run in a controlled environment, leveraging Ember's transaction system to avoid side effects
- This API is intended for low-level integrations and debugging tools, not for general application logic
- TC39's
watchproposal allows observing changes to signals in JavaScript - Ember's
lowLevel.subtle.watchis similar in spirit but scoped to tracked properties and the rendering lifecycle - This API does not provide direct access to the changed value or path; it is a notification mechanism only
- Unlike TC39 watchers, this API is tied to Ember's render transaction system
- if/when TC39 Signals are implemented, the implementation of this behavior can be swapped out for the native implementation (as would be the case for all of Ember's reactivity)
watchwill return a method tounwatchwhich can be called at any time and will remove the callback from the in-memory list of callbacks to keep track of.
import { lowLevel } from '@ember/renderer';
import { cell } from '@ember/reactive';
const count = cell(0);
const increment = () => count.current++;
lowLevel.subtle.watch(() => {
// This callback runs when tracked data changes
console.log('Tracked data changed! :: ', count.current);
// Forbidden operations: setting tracked data
// count.current = 'new value'; // Will throw!
});
<template>
<button onclick={{increment}}>++</button>
</template>import { lowLevel } from '@ember/renderer';
import { registerDestructor, unregisterDestructor } from '@ember/destroyable';
function waitFor(context, callback, timeout = 10_000) {
let pass;
let fail;
let promise = new Promise((resolve, reject) => {
pass = resolve;
fail = reject;
});
let timer = setTimeout(() => fail(`Timed out waiting ${timeout}ms!`), timeout);
let unwatch = lowLevel.subtle.watch(() => {
if (callback()) {
clearTimeout(timer);
pass();
unwatch();
unregisterDestructor(context, unwatch);
}
});
registerDestructor(context, unwatch);
}usage:
import { task, waitFor } from 'ember-concurrency';
export class Demo extends Component {
@tracked foo = 0;
myTask = task(async () => {
console.log("Waiting for `foo` to become 5");
await waitFor(this, () => this.foo === 5);
console.log("`foo` is 5!");
});
}Until we prove that this isn't problematic, we should not provide documentation other than basic API docs on the export.
We do want to encourage intrepid developers to explore implementation of other renderers using this utility.
- "Subtle" indicates low-level, non-intrusive observation
- "Watch" aligns with TC39 Signals terminology and developer expectations
- "lowLevel" namespace clearly indicates advanced/internal usage
- Exposes internals that may be misused for application logic
- May increase complexity for debugging and maintenance
- Lack of unsubscribe mechanism may lead to memory leaks if misused
- May encourage patterns that bypass Ember's intended reactivity model
- Could be confused with higher-level reactivity APIs despite "subtle" naming
n/a (for now / to start with)
n/a