diff --git a/text/1136-lowLevel.subtle.sync.md b/text/1136-lowLevel.subtle.sync.md new file mode 100644 index 0000000000..dbed853d02 --- /dev/null +++ b/text/1136-lowLevel.subtle.sync.md @@ -0,0 +1,217 @@ +--- +stage: accepted +start-date: 2025-08-15T00:00:00.000Z +release-date: # In format YYYY-MM-DDT00:00:00.000Z +release-versions: +teams: + - framework +prs: + accepted: https://github.com/emberjs/rfcs/pull/1136 +project-link: +suite: +--- + + + + + +# lowLevel.subtle.sync + +## Summary + +Introduce a new low-level API, `lowLevel.subtle.sync`, 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. + +[tc39-signals]: https://github.com/tc39/proposal-signals + +## Motivation + +Some advanced scenarios require observing changes to tracked data without triggering a re-render or scheduling a revalidation. The `lowLevel.subtle.sync` API provides a mechanism for users to hook into tracked data changes at a low level, similar to [TC39's signals + watchers proposal][tc39-signals]: + +Use cases include: +- synchronizing external state whithout the need to piggy-back off DOM-rendering +- ember-concurrency's `waitFor` witch would not need to rely on observers (as today) or polling +- Building alternate renderers (instead of rendering to DOM, render to ``, or the Terminal) + +> [!CAUTION] +> This API is not intended for application logic. + +## Detailed design + +### API Signature + +```ts +type Unwatch = () => void; +function watch(callback: () => void): Unwatch; +``` + +The API is available as `lowLevel.subtle.sync` from `@ember/renderer`. + +### Lifecycle and Semantics + +- 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 whenever `dirtyTag` is called) + - Callbacks are registered and run after tracked data changes, but before the next render completes +- 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. + +### Safeguards + +- 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 + +### Comparison to TC39 Signals/Watchers + +- TC39's `watch` proposal allows observing changes to signals in JavaScript +- Ember's `lowLevel.subtle.sync` is 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][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) +- `watch` will return a method to `unwatch` which can be called at any time and will remove the callback from the in-memory list of callbacks to keep track of. + +### Example + +```gjs +import { lowLevel } from '@ember/renderer'; +import { cell } from '@ember/reactive'; + +const count = cell(0); +const increment = () => count.current++; + +lowLevel.subtle.sync(() => { + // 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! +}); + + +``` + +### `waitFor` implementation for `ember-concurrency` + +```js +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.sync(() => { + if (callback()) { + clearTimeout(timer); + pass(); + unwatch(); + unregisterDestructor(context, unwatch); + } + }); + + registerDestructor(context, unwatch); +} +``` + +usage: +```js +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!"); + }); +} +``` + +## How we teach this + +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. + +### Terminology + +- "Subtle" indicates low-level, non-intrusive observation +- "Watch" aligns with TC39 Signals terminology and developer expectations +- "lowLevel" namespace clearly indicates advanced/internal usage + +## Drawbacks + +- 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 + +## Alternatives + +n/a (for now / to start with) + +## Unresolved questions + +n/a + +## Resolved questions + +### Can this be used to create an integration with other frameworks? + +At the moment, not without increased resource usage, the timing of `lowLevel.subtle.sync()` requires the creation of an Application instance as it's currently only the Application that configures the rendering and revalidation timing -- so if you were willing to wrap reactive contexts in transparent ember applications, it may be doable. + +The smallest application you can have at the time of writing is this: +```js +import Application from 'ember-strict-application-resolver'; +import EmberRouter from '@ember/routing/router'; + +class Router extends EmberRouter { + location = 'history'; + rootURL = '/'; +} + +Router.map(function () {}); + +class App extends Application { + modules = { + './router': Router, + './templates/application': , + }; +} + +App.create({}); +``` + +There are rough plans for refactoring the rendering system more isolated from whole applications (see: Starbeam), but they're still in an experimental phase. + +### Do nested `.sync()` calls work and remain independent? + +yes -- `.sync()` would be backed by `createCache()` and each `createCache()` gets its own tracking frame which are independent of wrapping caches. \ No newline at end of file