From d2b79da028eb37cc1309ffc511caf10f3f1feeb3 Mon Sep 17 00:00:00 2001 From: Chris Garrett Date: Tue, 5 May 2020 14:09:10 -0700 Subject: [PATCH 1/8] Adds `invokeHelper` RFC --- text/0000-invoke-helper.md | 475 +++++++++++++++++++++++++++++++++++++ 1 file changed, 475 insertions(+) create mode 100644 text/0000-invoke-helper.md diff --git a/text/0000-invoke-helper.md b/text/0000-invoke-helper.md new file mode 100644 index 0000000000..77abbcd8c0 --- /dev/null +++ b/text/0000-invoke-helper.md @@ -0,0 +1,475 @@ +- Start Date: 2020-04-30 +- Relevant Team(s): Ember.js +- RFC PR: (after opening the RFC PR, update this with a link to it and update the file name) +- Tracking: (leave this empty) + +# JavaScript Helper Invocation API + +## Summary + +This RFC proposes a new API, `invokeHelper`, which can be used to invoke a +helper definition, creating an instance of the helper in JavaScript. + +```js +// app/components/data-loader.js +import Component from '@glimmer/component'; +import Helper from '@ember/component/helper'; +import { invokeHelper } from '@ember/helper'; + +class FetchTask { + @tracked isLoading = true; + @tracked isError = false; + @tracked result = null; + + constructor(url) { + this.run(url); + } + + async run(url) { + try { + let response = await fetch(url); + this.result = await response.json(); + } catch { + this.isError = true; + } finally { + this.isLoading = false; + } + } +} + +class RemoteData extends Helper { + compute([url]) { + return new FetchTask(url); + } +} + +export default class DataLoader extends Component { + data = invokeHelper(this, RemoteData, () => { + positional: [this.args.url] + }); +} +``` +```hbs + +{{#if this.data.value.isLoading}} + Loading... +{{else if this.data.value.isError}} + Something went wrong! +{{else}} + {{this.data.value.result}} +{{/if}} +``` + +## Motivation + +As Ember has evolved, the framework has been developing a model of reactive, +incremental computation. This model is oriented around _templates_, which map +data and JavaScript business logic into HTML that is displayed to the user. + +On the first render, this mapping is fairly similar to standard programming +languages. You can imagine that every component is like a _function call_, +receiving arguments and data, processing it, and placing it within its own +template, ultimately producing HTML as the "return value" of the component. +Components can use other components, much like functions can call other +functions, resulting in a tree structure of nested components, which is +the application. At the root of our application is a single "main" component +(similar to the main function in many programming languages) which takes +arguments and returns the full HTML of the initial render. + +Where Ember's programming model really begins to differ is in _subsequent_ +renders. Rather than re-running the entire program whenever something changes, +producing new HTML as a result, Ember incrementally re-runs the portions of the +program which have changed. It knows about these portions via its change +tracking mechanism, _autotracking_. + +This means fundamentally that the tree of components differs from a tree of +functions because components can _live longer_. They exist until the portion of +the program that they belong to has been removed by an incremental update, and +as such, they have a _lifecycle_. Unlike a function, a component can _update_ +over time, and will be _destroyed_ at some unknown point in the future. + +Components previously exposed this lifecycle directly via a number of +lifeycle hooks, but there were many issues with these hooks. These stemmed from +the fact that components were the smallest _atom_ for reactive composition. In +the world of functions, a piece of code can always be broken out into a new +function, giving the user the ability to extract repeated functionality, +abstracting common patterns and concepts and reducing brittleness. + +```js +// before +function myProgram(data = []) { + for (let item of data) { + // ... + } + + let values = data.map(() => { + // ... + }); + + while (values.length) { + let value = values.pop(); + // ... + } +} +``` +```js +// after +function initialProcessing(data) { + for (let item of data) { + // ... + } +} + +function extractValues(data) { + return data.map(() => { + // ... + }); +} + +function processValues(values) { + while (values.length) { + let value = values.pop(); + // ... + } +} + +function myProgram(data = []) { + initialProcessing(data); + + let values = extractValues(data); + + processValues(values); +} +``` + +In the reactive model of components, there often is not a way to do this +transparently, since the only portions of the code that are reactive are the +component hooks themselves. This results in related code being spread across +multiple locations, with the user being forced to keep the relationships between +these bits of code in their head at all times, and understand the interactions +between them. + +```js +import Component from '@ember/component'; +import { fetchData } from 'data-fetch-library'; + +export default class Search extends Component { + // Args + text = ''; + pollPeriod = 1000; + + didReceiveAttrs() { + if (this._previousText !== this.text) { + this._previousText = this.text; + this.data = fetchData(`www.example.com/search?query=${this.text}`); + } + + if (this._pollPeriod !== this.pollPeriod) { + this._pollPeriod = this.pollPeriod; + + cancelInterval(this._intervalId); + this._intervalId = setInterval(() => { + this.data = fetchData(`www.example.com/search?query=${this.text}`); + }, pollPeriod); + } + } + + willDestroy() { + cancelInterval(this._intervalId); + } +} +``` + +There are a few other constructs in templates that have the same reactive +lifecycle as components - helpers and modifiers. And helpers in particular are +very useful, because they can receive arguments like components, but they can +return _any_ value, not just HTML. The only issue is that they can _only_ be +used in templates, which limits the places where they can be used to extract +common functionality + +This RFC proposes adding a way to create helpers within JavaScript directly, +extending the reactive model in a way that allows users to extract common +reactive code and patterns, and reuse them transparently. This will make helpers +the new reactive atom of the system - the reactive equivalent of a "function" in +our incremental model. Like components, they have a lifecycle, and can update +over time. Unlike components, they can exist nested in JavaScript classes _and_ +in templates, and they can produce any type of value, making them much more +flexible. + +```js +import Component from '@glimmer/component'; +import { remoteData } from '../helpers/fetch'; +import { poll } from '../helpers/remoteData'; +import Helper from '@ember/component/helper'; + +class FetchTask { + @tracked isLoading = true; + @tracked isError = false; + @tracked result = null; + + constructor(url) { + this.url = url; + this.run(); + } + + async run() { + try { + let response = await fetch(this.url); + this.result = await response.json(); + } catch { + this.isError = true; + } finally { + this.isLoading = false; + } + } + + refresh() { + this.run(); + } +} + +class RemoteData extends Helper { + compute([url]) { + return new FetchTask(url); + } +} + + +class Poll extends Helper { + intervalId = null; + + compute([callback, pollPeriod]) { + cancelInterval(this.intervalId); + + this.intervalId = setInterval(callback, pollPeriod); + } + + willDestroy() { + cancelInterval(this.intervalId); + } +} + +export default class Search extends Component { + data = invokeHelper(this, RemoteData, () => ({ + positional: [`www.example.com/search?query=${this.text}`] + })); + + constructor() { + super(...arguments); + + invokeHelper(this, Poll, () => ({ + positional: [() => this.data.value.refresh(), this.pollPeriod] + })); + } +} +``` + +Note how the concerns are completely separated in this version of the component. +The polling logic is self contained, and separated from the data fetching logic. +Both sets of logic are able to contain their lifecycles, updating based on +changes to tracked state, and tearing down when the program destroys them. In +the future, convenience APIs can be added to make invoking them easier to read: + +```js +export default class Search extends Component { + @use data = remoteData(() => `www.example.com/search?query=${this.text}`); + + constructor() { + super(...arguments); + + use(this, poll(() => [() => this.data.refresh(), this.pollPeriod])); + } +} +``` + +## Dependencies + +This RFC depends on the [Helper Manager RFC](https://github.com/emberjs/rfcs/pull/625). + +## Detailed design + +This RFC proposes adding the `invokeHelper` function, imported from +`@ember/helper`. The function will have the following interface (using +TypeScript types for brevity and clarity): + +```ts +interface TemplateArgs { + positional?: unknown[], + named?: Record +} + +type HelperDefinition = object; + +interface Helper { + value: unknown; +} + +function invokeHelper( + context: object, + definition: HelperDefinition, + argsGetter?: (context: object) => TemplateArgs +): Helper; +``` + +Let's step through the arguments to the function one by one: + +#### `context` + +This is the parent context for the helper definition. The helper will be +associated as a destroyable to this parent context, using the destroyables API, +so that its lifecycle is tied to the parent. The only requirement of the parent +is that is an object of some kind that can be destroyed. + +This allows helper's lifecycles to be entangled correctly with the parent, and +encourages users to ensure they've properly handled the lifecycle of their +helper. + +#### `definition` + +This is the helper definition. It can be any object, with the only requirement +being that a helper manager has been associated with it via the +`setHelperManager` API. + +#### `argsGetter` + +This is an optional function that produces the arguments to the helper. The +function receives the parent context as an argument, and must return an object +with a `positional` property that is an array and/or a `named` property that is +an object. + +This getter function will be _autotracked_ when it is run, so the process of +retrieving the arguments is autotracked. If any of the values used to create the +arguments object change, the helper will be updated, just like in templates. + +### Return Value + +The function returns an instance of the helper. The public API of the instance +consists of a `value` property, which will internally be implemented as a getter +that triggers the proper lifecycle hooks on the helper and returns its value, if +it has a value. If it does not, then the helper will do nothing when `value` is +accessed and it will always return `undefined`. + +If the helper has a scheduled effect, there is no public API for users to access +the effect or run it eagerly. It will run as scheduled, until the helper is +destroyed. + +Using `destroy()` from the destroyables API on the helper instance will trigger +its destruction early. Users can do this to clean up a helper before the parent +context is destroyed. + +### Effect Helper Timing Semantics + +Standard helpers that return a value will only be updated when they are used, +either in JavaScript or in the template. The args getter and the relevant helper +manager lifecycle hooks will be called when the `value` property on the helper +is used. + +Side-effecting helpers, by contrast, run their updates specifically when +scheduled. When introduced by the Helper Manager RFC, there was no relative +ordering specified in the scheduling of side-effecting helpers, because there +was no way for them to have _children_, and we don't generally specify ordering +of siblings. With this RFC, it will be possible to invoke a side-effecting +helper within another side-effecting helper, so they will be able to have +children for the first time. + +This RFC proposes modifying the Helper Manager RFC to specify that the +`runEffect` hook of a helper always runs _after_ the `runEffect` hooks of its +children. This mirrors the timing semantics of modifier hooks in templates. + +### Ergonomics + +This is a low-level API for invoking helpers and creating instances. The API is +meant to be functional, but not particularly readable or ergonomic. This API can +be wrapped with higher level, more ergonomic APIs in the ecosystem, until we're +sure what the final API should be. + +## How we teach this + +This API is meant to be a low-level primitive which will eventually be replaced +with higher level wrappers, possibly decorators, that will be much easier to use +and recommend to average app developers. As such, it will only be taught through +API documentation. + +### API Docs + +#### `invokeHelper` + +The `invokeHelper` function can be used to create a helper instance in +JavaScript. + +```js +import Component from '@glimmer/component'; +import Helper from '@ember/component/helper'; +import { invokeHelper } from '@ember/helper'; + +class FetchTask { + @tracked isLoading = true; + @tracked isError = false; + @tracked result = null; + + constructor(url) { + this.url = url; + this.run(); + } + + async run() { + try { + let response = await fetch(this.url); + this.result = await response.json(); + } catch { + this.isError = true; + } finally { + this.isLoading = false; + } + } +} + +class RemoteData extends Helper { + compute([url]) { + return new FetchTask(url); + } +} + +export default class Example extends Component { + data = invokeHelper(this, RemoteData, () => ({ + positional: [`www.example.com/search?query=${this.text}`] + })); + + get result() { + return this.data.value.result; + } +} +``` + +It receives three arguments: + +* `context`: The parent context of the helper. When the parent is torn down and + removed, the helper will be as well. +* `definition`: The definition of the helper. +* `argsGetter`: An optional function that produces the arguments to the helper. + The function receives the parent context as an argument, and must return an + object with a `positional` property that is an array and/or a `named` + property that is an object. + +And it returns a helper instance which has a `value` property. This property +will return the value of the helper, if it has one. If not, it will return +`undefined`. + +## Drawbacks + +- Additional API surface complexity. There will be additional ways to use + helpers that we will have to teach users about in general. This is true, but + given it helps to solve a lot of common problems that users have in Octane it + should be worthwhile. + +- This API is a primitive that is not particularly ergonomic or user friendly, + but this is part of the point. It gets the job done, and can be built on top + of to create a better high level API. + +## Alternatives + +- The [`@use` and Resources RFC](https://github.com/emberjs/rfcs/pull/567) + proposes a higher level approach to this problem space, but had a number of + concerns and drawbacks. After much discussion, we decided that it would be + better to ship the primitives to build something like it in user-space, and + prove out the ideas in it. From 7762a81cb897fb4609035aa5dbc1500eabe29b3f Mon Sep 17 00:00:00 2001 From: Chris Garrett Date: Thu, 7 May 2020 17:17:33 -0700 Subject: [PATCH 2/8] rename --- text/{0000-invoke-helper.md => 0626-invoke-helper.md} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename text/{0000-invoke-helper.md => 0626-invoke-helper.md} (99%) diff --git a/text/0000-invoke-helper.md b/text/0626-invoke-helper.md similarity index 99% rename from text/0000-invoke-helper.md rename to text/0626-invoke-helper.md index 77abbcd8c0..321c9b8ee7 100644 --- a/text/0000-invoke-helper.md +++ b/text/0626-invoke-helper.md @@ -1,6 +1,6 @@ - Start Date: 2020-04-30 - Relevant Team(s): Ember.js -- RFC PR: (after opening the RFC PR, update this with a link to it and update the file name) +- RFC PR: https://github.com/emberjs/rfcs/pull/626 - Tracking: (leave this empty) # JavaScript Helper Invocation API From 7bb66d6d2e4a39e8bdf681d2d8acf5dc18ad42a1 Mon Sep 17 00:00:00 2001 From: Chris Garrett Date: Thu, 4 Jun 2020 13:32:03 -0700 Subject: [PATCH 3/8] updates based on feedback --- text/0626-invoke-helper.md | 393 ++++++++++++++++++++++--------------- 1 file changed, 239 insertions(+), 154 deletions(-) diff --git a/text/0626-invoke-helper.md b/text/0626-invoke-helper.md index 321c9b8ee7..f7070e447a 100644 --- a/text/0626-invoke-helper.md +++ b/text/0626-invoke-helper.md @@ -16,48 +16,22 @@ import Component from '@glimmer/component'; import Helper from '@ember/component/helper'; import { invokeHelper } from '@ember/helper'; -class FetchTask { - @tracked isLoading = true; - @tracked isError = false; - @tracked result = null; - - constructor(url) { - this.run(url); - } - - async run(url) { - try { - let response = await fetch(url); - this.result = await response.json(); - } catch { - this.isError = true; - } finally { - this.isLoading = false; - } +class PlusOne extends Helper { + compute([num]) { + return number + 1; } } -class RemoteData extends Helper { - compute([url]) { - return new FetchTask(url); - } -} - -export default class DataLoader extends Component { - data = invokeHelper(this, RemoteData, () => { - positional: [this.args.url] +export default class PlusOne extends Component { + plusOne = invokeHelper(this, RemoteData, () => { + return { + positional: [this.args.number], + }; }); } ``` ```hbs - -{{#if this.data.value.isLoading}} - Loading... -{{else if this.data.value.isError}} - Something went wrong! -{{else}} - {{this.data.value.result}} -{{/if}} +{{this.plusOne.value}} ``` ## Motivation @@ -89,11 +63,13 @@ as such, they have a _lifecycle_. Unlike a function, a component can _update_ over time, and will be _destroyed_ at some unknown point in the future. Components previously exposed this lifecycle directly via a number of -lifeycle hooks, but there were many issues with these hooks. These stemmed from -the fact that components were the smallest _atom_ for reactive composition. In -the world of functions, a piece of code can always be broken out into a new -function, giving the user the ability to extract repeated functionality, -abstracting common patterns and concepts and reducing brittleness. +lifeycle hooks, making components the smallest _atom_ for reactive composition. +This presented an issue for composability in general. In the world of functions, +a piece of code can always be broken out into a new function, giving the user +the ability to extract repeated functionality, abstracting common patterns and +concepts and reducing brittleness. For instance, in the following example we +extract several portions of the `myProgram` function to make it clearer what +each section is doing, and isolate its behavior. ```js // before @@ -142,124 +118,252 @@ function myProgram(data = []) { } ``` -In the reactive model of components, there often is not a way to do this +Since components are the smallest reactive atom, there often is not a way to do this transparently, since the only portions of the code that are reactive are the component hooks themselves. This results in related code being spread across multiple locations, with the user being forced to keep the relationships between these bits of code in their head at all times, and understand the interactions between them. +Consider this search component, which updates performs a cancellable fetch +request and updates the document title whenever the search query is updated: + ```js import Component from '@ember/component'; -import { fetchData } from 'data-fetch-library'; +import { fetch, cancelFetch } from 'fetch-with-cancel'; export default class Search extends Component { // Args - text = ''; - pollPeriod = 1000; + query = ''; + + @tracked data; didReceiveAttrs() { - if (this._previousText !== this.text) { - this._previousText = this.text; - this.data = fetchData(`www.example.com/search?query=${this.text}`); - } + // Update the document title + document.title = `Search Result for "${this.query}"`; - if (this._pollPeriod !== this.pollPeriod) { - this._pollPeriod = this.pollPeriod; + // Cancel the previous fetch if it's still running + cancelFetch(this.promise); - cancelInterval(this._intervalId); - this._intervalId = setInterval(() => { - this.data = fetchData(`www.example.com/search?query=${this.text}`); - }, pollPeriod); - } + // create a new fetch request, and set the data property to the response + this.promise = fetch(`www.example.com/search?query=${this.query}`) + .then((response) => response.json()); + .then((data) => this.data = data); } willDestroy() { - cancelInterval(this._intervalId); + cancelFetch(this.promise); } } ``` -There are a few other constructs in templates that have the same reactive -lifecycle as components - helpers and modifiers. And helpers in particular are -very useful, because they can receive arguments like components, but they can -return _any_ value, not just HTML. The only issue is that they can _only_ be -used in templates, which limits the places where they can be used to extract -common functionality - -This RFC proposes adding a way to create helpers within JavaScript directly, -extending the reactive model in a way that allows users to extract common -reactive code and patterns, and reuse them transparently. This will make helpers -the new reactive atom of the system - the reactive equivalent of a "function" in -our incremental model. Like components, they have a lifecycle, and can update -over time. Unlike components, they can exist nested in JavaScript classes _and_ -in templates, and they can produce any type of value, making them much more -flexible. +This component mixes two separate concerns in its lifecycle hooks - fetching the +data, and updating the document title. We can extract these into utility +functions in some isolated cases, but it becomes difficult when functionality +covers multiple parts of the lifecycle, like with the fetch logic here: ```js -import Component from '@glimmer/component'; -import { remoteData } from '../helpers/fetch'; -import { poll } from '../helpers/remoteData'; -import Helper from '@ember/component/helper'; - -class FetchTask { - @tracked isLoading = true; - @tracked isError = false; - @tracked result = null; +import Component from '@ember/component'; +import { fetch, cancelFetch } from 'fetch-with-cancel'; - constructor(url) { - this.url = url; - this.run(); - } +function updateDocumentTitle(title) { + document.title = title; +} - async run() { - try { - let response = await fetch(this.url); - this.result = await response.json(); - } catch { - this.isError = true; - } finally { - this.isLoading = false; - } - } +function updateFetch(url, previousPromise) { + // Cancel the previous fetch if it's still running + cancelFetch(previousPromise); - refresh() { - this.run(); - } + // create a new fetch request, and set the data property to the response + return fetch(url) + .then((response) => response.json()); + .then((data) => this.data = data); } -class RemoteData extends Helper { - compute([url]) { - return new FetchTask(url); - } +function teardownFetch(promise) { + cancelFetch(promise); } +export default class Search extends Component { + // Args + query = ''; -class Poll extends Helper { - intervalId = null; + @tracked data; - compute([callback, pollPeriod]) { - cancelInterval(this.intervalId); + didReceiveAttrs() { + updateDocumentTitle(`Search Result for "${this.query}"`) - this.intervalId = setInterval(callback, pollPeriod); + this.promise = updateFetch(`www.example.com/search?query=${this.query}`, this.promise); } willDestroy() { - cancelInterval(this.intervalId); + teardownFetch(this.promise); } } +``` + +We can see here that we needed to add two separate helper functions to extract +the data fetching functionality, one to handle updating the fetch, and one to +handle tearing it down, because those different pieces of code need to run at +different portions of the component lifecycle. If we want to reuse these +functions elsewhere, this adds a lot of boilerplate to integrate the functions +in each lifecycle hook. + +There are a few alternatives that would allow us to extract this functionality +together. + +1. We could use mixins, since they allow us to specify multiple functions and + mix them into a class. Mixins however introduce a lot of complexity in the + inheritance hierarchy and are considered an antipattern, so this is not a + good choice overall. + +2. We could extract the functionality out to separate components. Components + have a contained lifecycle, so they can manage any piece of functionality + completely in isolation. This works nicely for the document title, but adds + a lot of complexity for the data fetching, since we need to yield the data + back out via the template: + + ```js + // app/components/doc-title.js + import Component from '@ember/component'; + + export default class DocTitle extends Component { + didReceiveAttrs() { + document.title = this.title; + } + } + ``` + ```js + // app/components/fetch-data.js + import Component from '@ember/component'; + import { fetch, cancelFetch } from 'fetch-with-cancel'; + + export default class FetchData extends Component { + @tracked data; + + didReceiveAttrs() { + // Cancel the previous fetch if it's still running + cancelFetch(this.promise); + + // create a new fetch request, and set the data property to the response + this.promise = fetch(this.url) + .then((response) => response.json()); + .then((data) => this.data = data); + } + + willDestroy() { + cancelFetch(this.promise); + } + } + ``` + ```hbs + + {{yield this.data}} + ``` + ```hbs + + + + + ... + + ``` + + This structure is also not ideal because the components aren't being used for + templating, they're just being used for logic effectively. So, there's no + reason to add the overhead of a component here. + +3. We could use other template constructs, such as helpers or modifiers. Both + helpers and modifiers have lifecycles, like components, and can be used to + contain functionality. Modifiers aren't really a good choice here though, + because it would require us to add an element that we don't need. So, helpers + are the better option. + + Helpers work, but like components they require us to move some of our logic + into the template, even if that isn't really necessary: + + ```js + // app/helpers/doc-title.js + import { helper } from '@ember/component/helper'; + + export default helper(([title]) => { + document.title = title; + }); + ``` + ```js + // app/helpers/fetch-data.js + import Helper from '@ember/component/helper'; + import { fetch, cancelFetch } from 'fetch-with-cancel'; + + export default class FetchData extends Helper { + @tracked data; + + compute([url]) { + if (this._url !== url) { + this.url = url; + + // Cancel the previous fetch if it's still running + cancelFetch(this.promise); + + // create a new fetch request, and set the data property to the response + this.promise = fetch(url) + .then((response) => response.json()); + .then((data) => this.data = data); + } + + return this.data; + } + + willDestroy() { + cancelFetch(this.promise); + } + } + ``` + ```hbs + + {{doc-title 'Search Result for "{{@query}}"'}} + + {{#let (fetch-data "www.example.com/search?query={{@query}}") as |data|}} + ... + {{/let}} + ``` + +Out of these options, helpers are the closest to what we want - they are low +overhead, they produce computed values directly without a template, and +with the recent addition of effect helpers they can be used side-effect to +accomplish tasks like setting the document title. The only downside is that they +can only be invoked in templates, so they require you to design your components +around using them in templates only. This can be difficult to do in many cases, +where the data wants to be accessed to create derived state for instance. + +This RFC proposes adding a way to create helpers within JavaScript directly, +extending the reactive model in a way that allows users to extract common +reactive code and patterns, and reuse them transparently. This will make helpers +the new reactive atom of the system - the reactive equivalent of a "function" in +our incremental model. Like components, they have a lifecycle, and can update +over time. Unlike components, they can exist nested in JavaScript classes _and_ +in templates, and they can produce any type of value, making them much more +flexible. + +```js +// app/components/search.js +import Component from '@ember/component'; export default class Search extends Component { - data = invokeHelper(this, RemoteData, () => ({ - positional: [`www.example.com/search?query=${this.text}`] - })); + data = invokeHelper(this, FetchData, () => { + return { + positional: [`www.example.com/search?query=${this.query}`], + }; + }); constructor() { super(...arguments); - invokeHelper(this, Poll, () => ({ - positional: [() => this.data.value.refresh(), this.pollPeriod] - })); + invokeHelper(this, DocTitle, () => { + return { + positional: [`Search Result for "${this.query}"`], + }; + }); } } ``` @@ -272,12 +376,12 @@ the future, convenience APIs can be added to make invoking them easier to read: ```js export default class Search extends Component { - @use data = remoteData(() => `www.example.com/search?query=${this.text}`); + @use data = fetchData(() => `www.example.com/search?query=${this.query}`); constructor() { super(...arguments); - use(this, poll(() => [() => this.data.refresh(), this.pollPeriod])); + use(this, docTitle(() => `Search Result for "${this.query}"`)); } } ``` @@ -305,7 +409,7 @@ interface Helper { } function invokeHelper( - context: object, + parentDestroyable: object, definition: HelperDefinition, argsGetter?: (context: object) => TemplateArgs ): Helper; @@ -313,12 +417,13 @@ function invokeHelper( Let's step through the arguments to the function one by one: -#### `context` +#### `parentDestroyable` -This is the parent context for the helper definition. The helper will be -associated as a destroyable to this parent context, using the destroyables API, -so that its lifecycle is tied to the parent. The only requirement of the parent -is that is an object of some kind that can be destroyed. +This is the parent for the helper definition. The helper will be associated as a +destroyable to this parent context, using the destroyables API, so that its +lifecycle is tied to the parent. The only requirement of the parent is that is +an object of some kind that can be destroyed. If the parent has an owner, this +owner will also be passed to the helper manager that it is invoked on. This allows helper's lifecycles to be entangled correctly with the parent, and encourages users to ensure they've properly handled the lifecycle of their @@ -398,48 +503,28 @@ The `invokeHelper` function can be used to create a helper instance in JavaScript. ```js +// app/components/data-loader.js import Component from '@glimmer/component'; import Helper from '@ember/component/helper'; import { invokeHelper } from '@ember/helper'; -class FetchTask { - @tracked isLoading = true; - @tracked isError = false; - @tracked result = null; - - constructor(url) { - this.url = url; - this.run(); - } - - async run() { - try { - let response = await fetch(this.url); - this.result = await response.json(); - } catch { - this.isError = true; - } finally { - this.isLoading = false; - } - } -} - -class RemoteData extends Helper { - compute([url]) { - return new FetchTask(url); +class PlusOne extends Helper { + compute([num]) { + return number + 1; } } -export default class Example extends Component { - data = invokeHelper(this, RemoteData, () => ({ - positional: [`www.example.com/search?query=${this.text}`] - })); - - get result() { - return this.data.value.result; - } +export default class PlusOne extends Component { + plusOne = invokeHelper(this, RemoteData, () => { + return { + positional: [this.args.number], + }; + }); } ``` +```hbs +{{this.plusOne.value}} +``` It receives three arguments: From e08156adc748e11dc026a55608588036aeac063b Mon Sep 17 00:00:00 2001 From: Chris Garrett Date: Thu, 4 Jun 2020 14:01:04 -0700 Subject: [PATCH 4/8] fixes --- text/0626-invoke-helper.md | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/text/0626-invoke-helper.md b/text/0626-invoke-helper.md index f7070e447a..63e200b45e 100644 --- a/text/0626-invoke-helper.md +++ b/text/0626-invoke-helper.md @@ -170,14 +170,14 @@ function updateDocumentTitle(title) { document.title = title; } -function updateFetch(url, previousPromise) { +function updateFetch(url, callback, previousPromise) { // Cancel the previous fetch if it's still running cancelFetch(previousPromise); // create a new fetch request, and set the data property to the response return fetch(url) .then((response) => response.json()); - .then((data) => this.data = data); + .then((data) => callback(data)); } function teardownFetch(promise) { @@ -193,7 +193,11 @@ export default class Search extends Component { didReceiveAttrs() { updateDocumentTitle(`Search Result for "${this.query}"`) - this.promise = updateFetch(`www.example.com/search?query=${this.query}`, this.promise); + this.promise = updateFetch( + `www.example.com/search?query=${this.query}`, + (data) => this.data = data, + this.promise + ); } willDestroy() { @@ -270,8 +274,7 @@ together. ``` This structure is also not ideal because the components aren't being used for - templating, they're just being used for logic effectively. So, there's no - reason to add the overhead of a component here. + templating, they're just being used for logic effectively. 3. We could use other template constructs, such as helpers or modifiers. Both helpers and modifiers have lifecycles, like components, and can be used to From 93f3c47f5f71f57148ec8e9136f8b5a3c31ee91a Mon Sep 17 00:00:00 2001 From: Chris Garrett Date: Fri, 5 Jun 2020 10:07:28 -0700 Subject: [PATCH 5/8] updates based on feedback --- text/0626-invoke-helper.md | 47 +++++++++++++++++++++----------------- 1 file changed, 26 insertions(+), 21 deletions(-) diff --git a/text/0626-invoke-helper.md b/text/0626-invoke-helper.md index 63e200b45e..f199ce329a 100644 --- a/text/0626-invoke-helper.md +++ b/text/0626-invoke-helper.md @@ -331,13 +331,13 @@ together. {{/let}} ``` -Out of these options, helpers are the closest to what we want - they are low -overhead, they produce computed values directly without a template, and -with the recent addition of effect helpers they can be used side-effect to -accomplish tasks like setting the document title. The only downside is that they -can only be invoked in templates, so they require you to design your components -around using them in templates only. This can be difficult to do in many cases, -where the data wants to be accessed to create derived state for instance. +Out of these options, helpers are the closest to what we want - they produce +computed values directly without a template, and with the recent addition of +effect helpers they can be used side-effect to accomplish tasks like setting the +document title. The only downside is that they can only be invoked in templates, +so they require you to design your components around using them in templates +only. This can be difficult to do in many cases, where the data wants to be +accessed to create derived state for instance. This RFC proposes adding a way to create helpers within JavaScript directly, extending the reactive model in a way that allows users to extract common @@ -414,7 +414,7 @@ interface Helper { function invokeHelper( parentDestroyable: object, definition: HelperDefinition, - argsGetter?: (context: object) => TemplateArgs + computeArgs?: (context: object) => TemplateArgs ): Helper; ``` @@ -423,10 +423,10 @@ Let's step through the arguments to the function one by one: #### `parentDestroyable` This is the parent for the helper definition. The helper will be associated as a -destroyable to this parent context, using the destroyables API, so that its -lifecycle is tied to the parent. The only requirement of the parent is that is -an object of some kind that can be destroyed. If the parent has an owner, this -owner will also be passed to the helper manager that it is invoked on. +destroyable to this parent context, using the [destroyables API](https://github.com/emberjs/rfcs/blob/master/text/0580-destroyables.md), +so that its lifecycle is tied to the parent. The only requirement of the parent +is that it is an object of some kind that can be destroyed. If the parent has an +owner, this owner will also be passed to the helper manager that it is invoked on. This allows helper's lifecycles to be entangled correctly with the parent, and encourages users to ensure they've properly handled the lifecycle of their @@ -436,9 +436,9 @@ helper. This is the helper definition. It can be any object, with the only requirement being that a helper manager has been associated with it via the -`setHelperManager` API. +[`setHelperManager` API](https://github.com/emberjs/rfcs/blob/master/text/0625-helper-managers.md#detailed-design). -#### `argsGetter` +#### `computeArgs` This is an optional function that produces the arguments to the helper. The function receives the parent context as an argument, and must return an object @@ -451,7 +451,7 @@ arguments object change, the helper will be updated, just like in templates. ### Return Value -The function returns an instance of the helper. The public API of the instance +The function returns a reference to the helper. The public API of the reference consists of a `value` property, which will internally be implemented as a getter that triggers the proper lifecycle hooks on the helper and returns its value, if it has a value. If it does not, then the helper will do nothing when `value` is @@ -461,7 +461,7 @@ If the helper has a scheduled effect, there is no public API for users to access the effect or run it eagerly. It will run as scheduled, until the helper is destroyed. -Using `destroy()` from the destroyables API on the helper instance will trigger +Using `destroy()` from the destroyables API on the helper reference will trigger its destruction early. Users can do this to clean up a helper before the parent context is destroyed. @@ -486,7 +486,7 @@ children. This mirrors the timing semantics of modifier hooks in templates. ### Ergonomics -This is a low-level API for invoking helpers and creating instances. The API is +This is a low-level API for invoking helpers and creating references. The API is meant to be functional, but not particularly readable or ergonomic. This API can be wrapped with higher level, more ergonomic APIs in the ecosystem, until we're sure what the final API should be. @@ -498,6 +498,11 @@ with higher level wrappers, possibly decorators, that will be much easier to use and recommend to average app developers. As such, it will only be taught through API documentation. +Once community addons are built with higher level APIs that are more ergonomic, +we should also add a section in the guides that uses them to demonstrate +techniques for using helpers in JS. This strategy is similar to how modifiers +are documented today. + ### API Docs #### `invokeHelper` @@ -534,14 +539,14 @@ It receives three arguments: * `context`: The parent context of the helper. When the parent is torn down and removed, the helper will be as well. * `definition`: The definition of the helper. -* `argsGetter`: An optional function that produces the arguments to the helper. +* `computeArgs`: An optional function that produces the arguments to the helper. The function receives the parent context as an argument, and must return an object with a `positional` property that is an array and/or a `named` property that is an object. -And it returns a helper instance which has a `value` property. This property -will return the value of the helper, if it has one. If not, it will return -`undefined`. +And it returns a reference to the helper which has a `value` property. This +property will return the value of the helper, if it has one. If not, it will +return `undefined`. ## Drawbacks From 351ab4223edf1aa9fe232b988afddd4eec8c7f7c Mon Sep 17 00:00:00 2001 From: Chris Garrett Date: Fri, 5 Jun 2020 10:12:36 -0700 Subject: [PATCH 6/8] remove dependencies --- text/0626-invoke-helper.md | 4 ---- 1 file changed, 4 deletions(-) diff --git a/text/0626-invoke-helper.md b/text/0626-invoke-helper.md index f199ce329a..2ac5fa5e96 100644 --- a/text/0626-invoke-helper.md +++ b/text/0626-invoke-helper.md @@ -389,10 +389,6 @@ export default class Search extends Component { } ``` -## Dependencies - -This RFC depends on the [Helper Manager RFC](https://github.com/emberjs/rfcs/pull/625). - ## Detailed design This RFC proposes adding the `invokeHelper` function, imported from From 6f3b29b58c58500ff8949ac9dac23aef84a52556 Mon Sep 17 00:00:00 2001 From: Chris Garrett Date: Fri, 5 Jun 2020 12:15:01 -0700 Subject: [PATCH 7/8] update to Cache API --- text/0626-invoke-helper.md | 44 ++++++++++++++++++-------------------- 1 file changed, 21 insertions(+), 23 deletions(-) diff --git a/text/0626-invoke-helper.md b/text/0626-invoke-helper.md index 2ac5fa5e96..79830578f5 100644 --- a/text/0626-invoke-helper.md +++ b/text/0626-invoke-helper.md @@ -401,17 +401,13 @@ interface TemplateArgs { named?: Record } -type HelperDefinition = object; +type HelperDefinition = object; -interface Helper { - value: unknown; -} - -function invokeHelper( +function invokeHelper( parentDestroyable: object, - definition: HelperDefinition, + definition: HelperDefinition, computeArgs?: (context: object) => TemplateArgs -): Helper; +): Cache; ``` Let's step through the arguments to the function one by one: @@ -447,19 +443,17 @@ arguments object change, the helper will be updated, just like in templates. ### Return Value -The function returns a reference to the helper. The public API of the reference -consists of a `value` property, which will internally be implemented as a getter -that triggers the proper lifecycle hooks on the helper and returns its value, if -it has a value. If it does not, then the helper will do nothing when `value` is -accessed and it will always return `undefined`. +The function returns a Cache instance, as defined in the [Autotracking Memoization RFC](https://github.com/emberjs/rfcs/blob/master/text/0615-autotracking-memoization.md#detailed-design). +This cache returns the most recent value of the helper, and will update whenever +the helper updates. Users can access the value using the `getValue` function for +caches. -If the helper has a scheduled effect, there is no public API for users to access -the effect or run it eagerly. It will run as scheduled, until the helper is -destroyed. +If the helper has a scheduled effect, using `getValue` on the cache will not run +it eagerly. It will run as scheduled, until the helper is destroyed. -Using `destroy()` from the destroyables API on the helper reference will trigger -its destruction early. Users can do this to clean up a helper before the parent -context is destroyed. +The cache will be also be destroyable, so that using `destroy()` from the +destroyables API on it will trigger its destruction early. Users can do this to +clean up a helper before the parent context is destroyed. ### Effect Helper Timing Semantics @@ -482,7 +476,7 @@ children. This mirrors the timing semantics of modifier hooks in templates. ### Ergonomics -This is a low-level API for invoking helpers and creating references. The API is +This is a low-level API for invoking helpers and creating instances. The API is meant to be functional, but not particularly readable or ergonomic. This API can be wrapped with higher level, more ergonomic APIs in the ecosystem, until we're sure what the final API should be. @@ -540,9 +534,13 @@ It receives three arguments: object with a `positional` property that is an array and/or a `named` property that is an object. -And it returns a reference to the helper which has a `value` property. This -property will return the value of the helper, if it has one. If not, it will -return `undefined`. +And it returns a Cache instance that contains the most recent value of the +helper. You can access the helper using `getValue()` like any other cache. The +cache is also destroyable, and using the `destroy()` function on it will cause +the helper to be torn down. + +Note that using `getValue()` on helpers that have scheduled effects will not +trigger the effect early. Effects will continue to run at their scheduled time. ## Drawbacks From 744f66298548e175de3b2eb4bb72071139c86db7 Mon Sep 17 00:00:00 2001 From: Ilya Radchenko Date: Thu, 18 Jun 2020 12:51:05 -0400 Subject: [PATCH 8/8] fix example typo --- text/0626-invoke-helper.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/text/0626-invoke-helper.md b/text/0626-invoke-helper.md index 79830578f5..f154557b8b 100644 --- a/text/0626-invoke-helper.md +++ b/text/0626-invoke-helper.md @@ -16,14 +16,14 @@ import Component from '@glimmer/component'; import Helper from '@ember/component/helper'; import { invokeHelper } from '@ember/helper'; -class PlusOne extends Helper { +class PlusOneHelper extends Helper { compute([num]) { return number + 1; } } export default class PlusOne extends Component { - plusOne = invokeHelper(this, RemoteData, () => { + plusOne = invokeHelper(this, PlusOneHelper, () => { return { positional: [this.args.number], };