From ac0e6ec7d4d230ca6ca5f77aada7e63b600e9886 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Fri, 15 Aug 2025 19:02:06 -0400 Subject: [PATCH 1/7] draft --- text/0000-lowLevel.subtle.watch.md | 137 +++++++++++++++++++++++++++++ 1 file changed, 137 insertions(+) create mode 100644 text/0000-lowLevel.subtle.watch.md diff --git a/text/0000-lowLevel.subtle.watch.md b/text/0000-lowLevel.subtle.watch.md new file mode 100644 index 0000000000..a4d657e4e3 --- /dev/null +++ b/text/0000-lowLevel.subtle.watch.md @@ -0,0 +1,137 @@ +--- +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: # Fill this in with the URL for the Proposal RFC PR +project-link: +suite: +--- + + + + + +# RFC: lowLevel.subtle.watch + +## Summary + +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. + +[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.watch` API provides a mechanism for users to hook into tracked data changes at a low level, similar to TC39's signals/watchers proposal, but scoped to Ember's rendering infrastructure. + +Use cases include: +- Building debugging tools that need to observe tracked data mutations +- Profiling or logging tracked data changes for performance analysis +- Advanced integrations with external state management libraries +- Development tools for tracking reactivity patterns + +This API is not intended for general application logic or UI updates. + +## Detailed design + +### API Signature + +```ts +type Unwatch = () => void; +function watch(callback: () => void): Unwatch; +``` + +The API is available as `lowLevel.subtle.watch` 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.watch` 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.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! +}); + + +``` + +## 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 \ No newline at end of file From 05c6621b8beab474afc5951244e9b8a936dae21b Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Fri, 15 Aug 2025 19:06:01 -0400 Subject: [PATCH 2/7] oh my --- text/0000-lowLevel.subtle.watch.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/text/0000-lowLevel.subtle.watch.md b/text/0000-lowLevel.subtle.watch.md index a4d657e4e3..15e3c344cb 100644 --- a/text/0000-lowLevel.subtle.watch.md +++ b/text/0000-lowLevel.subtle.watch.md @@ -42,15 +42,15 @@ It is not a replacement for computed properties, autotracking, or other high-lev ## Motivation -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, but scoped to Ember's rendering infrastructure. +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][tc39-signals]: Use cases include: -- Building debugging tools that need to observe tracked data mutations -- Profiling or logging tracked data changes for performance analysis -- Advanced integrations with external state management libraries -- Development tools for tracking reactivity patterns +- 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) -This API is not intended for general application logic or UI updates. +> [!CAUTION] +> This API is not intended for application logic. ## Detailed design From 8d740ba2619234c992da6029f9e474333dae9677 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Fri, 15 Aug 2025 19:07:14 -0400 Subject: [PATCH 3/7] Rename file, update meta --- ...0-lowLevel.subtle.watch.md => 1136-lowLevel.subtle.watch.md} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename text/{0000-lowLevel.subtle.watch.md => 1136-lowLevel.subtle.watch.md} (98%) diff --git a/text/0000-lowLevel.subtle.watch.md b/text/1136-lowLevel.subtle.watch.md similarity index 98% rename from text/0000-lowLevel.subtle.watch.md rename to text/1136-lowLevel.subtle.watch.md index 15e3c344cb..fc11a69802 100644 --- a/text/0000-lowLevel.subtle.watch.md +++ b/text/1136-lowLevel.subtle.watch.md @@ -6,7 +6,7 @@ release-versions: teams: - framework prs: - accepted: # Fill this in with the URL for the Proposal RFC PR + accepted: https://github.com/emberjs/rfcs/pull/1136 project-link: suite: --- From b07dfcdf4bc4bb21189342587cbc9a50dfdc4590 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Fri, 15 Aug 2025 19:18:59 -0400 Subject: [PATCH 4/7] Add ember-concurrency example --- text/1136-lowLevel.subtle.watch.md | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/text/1136-lowLevel.subtle.watch.md b/text/1136-lowLevel.subtle.watch.md index fc11a69802..d11b8c5299 100644 --- a/text/1136-lowLevel.subtle.watch.md +++ b/text/1136-lowLevel.subtle.watch.md @@ -27,7 +27,7 @@ suite: Leave as is -# RFC: lowLevel.subtle.watch +# lowLevel.subtle.watch ## Summary @@ -108,6 +108,33 @@ lowLevel.subtle.watch(() => { ``` +### `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.watch(() => { + if (callback()) { + clearTimeout(timer); + pass(); + unwatch(); + unregisterDestructor(context, unwatch); + } + }); + + registerDestructor(context, unwatch); +} +``` + ## 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. From 621ce49a19d0433c1e37ab7beacd2b2efcc01976 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Fri, 15 Aug 2025 19:20:15 -0400 Subject: [PATCH 5/7] Demo --- text/1136-lowLevel.subtle.watch.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/text/1136-lowLevel.subtle.watch.md b/text/1136-lowLevel.subtle.watch.md index d11b8c5299..7c4438cb03 100644 --- a/text/1136-lowLevel.subtle.watch.md +++ b/text/1136-lowLevel.subtle.watch.md @@ -135,6 +135,23 @@ function waitFor(context, callback, timeout = 10_000) { } ``` +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. From 3b3ee382ce8b97653ecc790a1fa21868e81da263 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Mon, 18 Aug 2025 10:14:41 -0400 Subject: [PATCH 6/7] Some q/a --- text/1136-lowLevel.subtle.watch.md | 52 +++++++++++++++++++++++++----- 1 file changed, 44 insertions(+), 8 deletions(-) diff --git a/text/1136-lowLevel.subtle.watch.md b/text/1136-lowLevel.subtle.watch.md index 7c4438cb03..dbed853d02 100644 --- a/text/1136-lowLevel.subtle.watch.md +++ b/text/1136-lowLevel.subtle.watch.md @@ -27,11 +27,11 @@ suite: Leave as is -# lowLevel.subtle.watch +# lowLevel.subtle.sync ## Summary -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. +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. @@ -42,7 +42,7 @@ It is not a replacement for computed properties, autotracking, or other high-lev ## Motivation -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][tc39-signals]: +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 @@ -61,7 +61,7 @@ type Unwatch = () => void; function watch(callback: () => void): Unwatch; ``` -The API is available as `lowLevel.subtle.watch` from `@ember/renderer`. +The API is available as `lowLevel.subtle.sync` from `@ember/renderer`. ### Lifecycle and Semantics @@ -80,7 +80,7 @@ The API is available as `lowLevel.subtle.watch` from `@ember/renderer`. ### Comparison to TC39 Signals/Watchers - TC39's `watch` proposal allows observing changes to signals in JavaScript -- Ember's `lowLevel.subtle.watch` is similar in spirit but scoped to tracked properties and the rendering lifecycle +- 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) @@ -95,7 +95,7 @@ import { cell } from '@ember/reactive'; const count = cell(0); const increment = () => count.current++; -lowLevel.subtle.watch(() => { +lowLevel.subtle.sync(() => { // This callback runs when tracked data changes console.log('Tracked data changed! :: ', count.current); @@ -122,7 +122,7 @@ function waitFor(context, callback, timeout = 10_000) { fail = reject; }); let timer = setTimeout(() => fail(`Timed out waiting ${timeout}ms!`), timeout); - let unwatch = lowLevel.subtle.watch(() => { + let unwatch = lowLevel.subtle.sync(() => { if (callback()) { clearTimeout(timer); pass(); @@ -178,4 +178,40 @@ n/a (for now / to start with) ## Unresolved questions -n/a \ No newline at end of file +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 From 745aba9a3739aae8e3b44d6ee7a918244841fb7a Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Mon, 18 Aug 2025 10:15:03 -0400 Subject: [PATCH 7/7] Rename file --- ...1136-lowLevel.subtle.watch.md => 1136-lowLevel.subtle.sync.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename text/{1136-lowLevel.subtle.watch.md => 1136-lowLevel.subtle.sync.md} (100%) diff --git a/text/1136-lowLevel.subtle.watch.md b/text/1136-lowLevel.subtle.sync.md similarity index 100% rename from text/1136-lowLevel.subtle.watch.md rename to text/1136-lowLevel.subtle.sync.md