Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implemented async bindings in #let. #412

Merged
merged 3 commits into from May 12, 2023
Merged

Implemented async bindings in #let. #412

merged 3 commits into from May 12, 2023

Conversation

radekmie
Copy link
Collaborator

@radekmie radekmie commented May 5, 2023

In this pull request, I made #let handle Promises. It supersedes #409 as it solves the same problem, but there's no #letAwait needed.

How it works

All Blaze Views store a binding mapping (_scopeBindings), where #each stores the @index variable and #let stores the locally scoped variables. Each binding is not a value but rather a ReactiveVar that is updated when needed.

In this PR, these ReactiveVars no longer store the value but rather a Binding object. Type-wise, it's either undefined (pending), { error } (rejected), or { value } (resolved). Synchronous values are immediately resolved (i.e., { value } is used). The other states are reserved for asynchronous bindings (i.e., values wrapped with Promises).

That means the following template:

<template name="example">
  {{#let name=getName}}
    Hi, {{name}}!
  {{/let}}
</template>

Works with both synchronous and asynchronous getName:

Template.example.helpers({
  // Synchronous value.
  getName: 'John',

  // Asynchronous value.
  getName: Promise.resolve('John'),

  // Synchronous helper.
  getName: () => 'John',

  // Asynchronous helper.
  getName: async () => 'John',
});

Async state

As the unwrapping of Promises is not synchronous, all asynchronous values and helpers will start in a pending state. In such cases, the resolved value is always undefined. That means the template above will show Hi, ! at first for both asynchronous examples. Similarly, a rejection will make the value undefined.

But there are cases where we'd like to know whether the operation is still pending or if it failed. To make it possible, there are three new global helpers:

  • @pending, which checks whether any of the given bindings is still pending.
  • @rejected, which checks whether any of the given bindings has rejected.
  • @resolved, which checks whether any of the given bindings has resolved.

The usage looks as follows:

<template name="example">
  {{#let name=getName}}
    {{#if @pending 'name'}}
      We are fetching your name...
    {{/if}}
    {{#if @rejected 'name'}}
      Sorry, an error occured!
    {{/if}}
    {{#if @resolved 'name'}}
      Hi, {{name}}!
    {{/if}}
  {{/let}}
</template>

All of them accept a list of names to check. Passing no arguments is the same as passing all bindings from the inner-most #let:

<template name="example">
  {{#let name=getName}}
    {{#let greeting=getGreeting}}
      {{#if @pending}}
        We are fetching your greeting...
      {{/if}}
      {{#if @rejected 'name'}}
        Sorry, an error occurred while fetching your name!
      {{/if}}
      {{#if @resolved 'greeting' 'name'}}
        {{greeting}}, {{name}}!
      {{/if}}
    {{/let}}
  {{/let}}
</template>

Given the following helpers:

Template.example.helpers({
  getGreeting: new Promise(resolve => setTimeout(() => resolve('Hi'), 2000)),
  getName: new Promise(resolve => setTimeout(() => resolve('John'), 1000)),
});

We'll see three states:

  • "We are fetching your greeting..."
  • "We are fetching your greeting..." and ", John!"
  • "Hi, John!"

Backward compatibility

As long as you never returned Promises from your helpers used in #let blocks, everything should work as before. If you did, these will be unwrapped. Note that direct usage of asynchronous helpers (e.g., {{counterAsync}}) won't work and will render [object Promise] instead.

TODO

  • Decide whether @pending/@rejected/@resolved should check for any or all bindings.
    • We've decided to leave it as any for now, document this behavior, and mark it as a subject it change.
  • Decide what level of synchronisation is needed for bindings. Right now there’s none, so if an asynchronous helper reactively triggers multiple times we use the latest resolved value, not the one from the latest promise. I can imagine some may want it to be the latter.
    • We've decided not do anything for now, document this behavior (it's technically a pitfall), and mark it as a subject it change.
  • Decide whether @pending should be true when a resolved helper is recalculated. Another option is to remove the value/error when restarted. In other words: is pending a state or a flag (the latter would work just like SWR).
    • We've decided to leave it as false for now (i.e., once resolved, @pending will never turn to true ever again), document this behavior, and mark it as a subject it change.
  • Unit tests for all sorts of cases described above and used in the below example.
A detailed example used for testing
<template name="example">
  <pre>
    Cursors.
    1. #each,      cursorGetterSync:  {{#each      cursorGetterSync }}{{#if @index}} - {{/if}}{{_id}}{{/each}}
    2. #each,      cursorValueSync:   {{#each      cursorValueSync  }}{{#if @index}} - {{/if}}{{_id}}{{/each}}
    {{!-- #each expects an array or cursor. --}}
    3. {{!-- #each,      cursorGetterAsync: {{#each      cursorGetterAsync}}{{#if @index}} - {{/if}}{{_id}}{{/each}} --}}
    4. {{!-- #each,      cursorValueAsync:  {{#each      cursorValueAsync }}{{#if @index}} - {{/if}}{{_id}}{{/each}} --}}
    {{!-- #eachAwait does not exist yet. --}}
    5. {{!-- #eachAwait, cursorGetterAsync: {{#eachAwait cursorGetterAsync}}{{#if @index}} - {{/if}}{{_id}}{{/eachAwait}} --}}
    6. {{!-- #eachAwait, cursorGetterSync:  {{#eachAwait cursorGetterSync }}{{#if @index}} - {{/if}}{{_id}}{{/eachAwait}} --}}
    7. {{!-- #eachAwait, cursorValueAsync:  {{#eachAwait cursorValueAsync }}{{#if @index}} - {{/if}}{{_id}}{{/eachAwait}} --}}
    8. {{!-- #eachAwait, cursorValueSync:   {{#eachAwait cursorValueSync  }}{{#if @index}} - {{/if}}{{_id}}{{/eachAwait}} --}}

    Primitives.
    1. #let,      primitiveGetterAsync: {{#let      x=primitiveGetterAsync}}{{x}}{{/let}}
    2. #let,      primitiveGetterSync:  {{#let      x=primitiveGetterSync }}{{x}}{{/let}}
    3. #let,      primitiveValueAsync:  {{#let      x=primitiveValueAsync }}{{x}}{{/let}}
    4. #let,      primitiveValueSync:   {{#let      x=primitiveValueSync  }}{{x}}{{/let}}

    Objects (inner accessors).
    1. #let,      asyncObjectAsyncProperty: {{#let      x=asyncObjectAsyncProperty}}{{x.foo}}{{/let}}
    2. #let,      asyncObjectSyncProperty:  {{#let      x=asyncObjectSyncProperty }}{{x.foo}}{{/let}}
    3. #let,      syncObjectAsyncProperty:  {{#let      x=syncObjectAsyncProperty }}{{x.foo}}{{/let}}
    4. #let,      syncObjectSyncProperty:   {{#let      x=syncObjectSyncProperty  }}{{x.foo}}{{/let}}

    Objects (outer accessors).
    1. #let,      asyncObjectAsyncProperty: {{#let      x=asyncObjectAsyncProperty.foo}}{{x}}{{/let}}
    2. #let,      asyncObjectSyncProperty:  {{#let      x=asyncObjectSyncProperty.foo }}{{x}}{{/let}}
    3. #let,      syncObjectAsyncProperty:  {{#let      x=syncObjectAsyncProperty.foo }}{{x}}{{/let}}
    4. #let,      syncObjectSyncProperty:   {{#let      x=syncObjectSyncProperty.foo  }}{{x}}{{/let}}

    Async states of #let (single).
    1. (D) {{#let x=delayed }}{{#if @pending    }}x is pending{{/if}}{{#if @rejected    }}x rejected{{/if}}{{#if @resolved    }}x resolved: {{x}}{{/if}}{{/let}}
    2. (D) {{#let x=delayed }}{{#if @pending 'x'}}x is pending{{/if}}{{#if @rejected 'x'}}x rejected{{/if}}{{#if @resolved 'x'}}x resolved: {{x}}{{/if}}{{/let}}
    3. (P) {{#let x=pending }}{{#if @pending    }}x is pending{{/if}}{{#if @rejected    }}x rejected{{/if}}{{#if @resolved    }}x resolved: {{x}}{{/if}}{{/let}}
    4. (P) {{#let x=pending }}{{#if @pending 'x'}}x is pending{{/if}}{{#if @rejected 'x'}}x rejected{{/if}}{{#if @resolved 'x'}}x resolved: {{x}}{{/if}}{{/let}}
    5. (R) {{#let x=rejected}}{{#if @pending    }}x is pending{{/if}}{{#if @rejected    }}x rejected{{/if}}{{#if @resolved    }}x resolved: {{x}}{{/if}}{{/let}}
    6. (R) {{#let x=rejected}}{{#if @pending 'x'}}x is pending{{/if}}{{#if @rejected 'x'}}x rejected{{/if}}{{#if @resolved 'x'}}x resolved: {{x}}{{/if}}{{/let}}

    Async states of #let (multiple).
    1. (DD) {{#let x=delayed  y=delayed }}{{#if @pending}}pending{{else}}       {{/if}} {{#if @rejected}}rejected{{else}}        {{/if}} {{#if @resolved}}resolved: ({{x}}, {{y}}){{/if}}{{/let}}
    2. (DP) {{#let x=delayed  y=pending }}{{#if @pending}}pending{{else}}       {{/if}} {{#if @rejected}}rejected{{else}}        {{/if}} {{#if @resolved}}resolved: ({{x}}, {{y}}){{/if}}{{/let}}
    3. (DR) {{#let x=delayed  y=rejected}}{{#if @pending}}pending{{else}}       {{/if}} {{#if @rejected}}rejected{{else}}        {{/if}} {{#if @resolved}}resolved: ({{x}}, {{y}}){{/if}}{{/let}}
    4. (PD) {{#let x=pending  y=delayed }}{{#if @pending}}pending{{else}}       {{/if}} {{#if @rejected}}rejected{{else}}        {{/if}} {{#if @resolved}}resolved: ({{x}}, {{y}}){{/if}}{{/let}}
    5. (PP) {{#let x=pending  y=pending }}{{#if @pending}}pending{{else}}       {{/if}} {{#if @rejected}}rejected{{else}}        {{/if}} {{#if @resolved}}resolved: ({{x}}, {{y}}){{/if}}{{/let}}
    6. (PR) {{#let x=pending  y=rejected}}{{#if @pending}}pending{{else}}       {{/if}} {{#if @rejected}}rejected{{else}}        {{/if}} {{#if @resolved}}resolved: ({{x}}, {{y}}){{/if}}{{/let}}
    7. (RD) {{#let x=rejected y=delayed }}{{#if @pending}}pending{{else}}       {{/if}} {{#if @rejected}}rejected{{else}}        {{/if}} {{#if @resolved}}resolved: ({{x}}, {{y}}){{/if}}{{/let}}
    8. (RP) {{#let x=rejected y=pending }}{{#if @pending}}pending{{else}}       {{/if}} {{#if @rejected}}rejected{{else}}        {{/if}} {{#if @resolved}}resolved: ({{x}}, {{y}}){{/if}}{{/let}}
    9. (RR) {{#let x=rejected y=rejected}}{{#if @pending}}pending{{else}}       {{/if}} {{#if @rejected}}rejected{{else}}        {{/if}} {{#if @resolved}}resolved: ({{x}}, {{y}}){{/if}}{{/let}}

    Reactive helpers.
    1. counterAsync(): {{#let x=counterAsync}}{{x}}{{/let}}
    2. counterSync():  {{#let x=counterSync }}{{x}}{{/let}}

    Nested #let.
    {{#let name=getName}}
      {{#let greeting=getGreeting}}
        {{#if @pending}}
          We are fetching your greeting...
        {{/if}}
        {{#if @rejected 'name'}}
          Sorry, an error occurred while fetching your name!
        {{/if}}
        {{#if @resolved 'greeting' 'name'}}
          {{greeting}}, {{name}}!
        {{/if}}
      {{/let}}
    {{/let}}
  </pre>
  <button>Increase counter</button>
</template>
import { Mongo } from 'meteor/mongo';
import { Random } from 'meteor/random';
import { ReactiveVar } from 'meteor/reactive-var';
import { Template } from 'meteor/templating';

import './main.html';

const Collection = new Mongo.Collection(null);
Collection.insertAsync({});
Collection.insertAsync({});
Collection.insertAsync({});

Template.example.onCreated(function () {
  this.counter = new ReactiveVar(0);
});

Template.example.events({
  'click button': (_, template) => template.counter.set(template.counter.get() + 1),
});

Template.example.helpers({
  // Cursors.
  cursorGetterAsync: () => Promise.resolve(Collection.find()),
  cursorGetterSync: () => Collection.find(),
  cursorValueAsync: Promise.resolve(Collection.find()),
  cursorValueSync: Collection.find(),

  // Primitives.
  primitiveGetterAsync: () => Promise.resolve(Random.id()),
  primitiveGetterSync: () => Random.id(),
  primitiveValueAsync: Promise.resolve(Random.id()),
  primitiveValueSync: Random.id(),

  // Objects.
  asyncObjectAsyncProperty: Promise.resolve({ foo: Promise.resolve(Random.id()) }),
  asyncObjectSyncProperty: Promise.resolve({ foo: Random.id() }),
  syncObjectAsyncProperty: { foo: Promise.resolve(Random.id()) },
  syncObjectSyncProperty: { foo: Random.id() },

  // Async states of #let.
  delayed: new Promise(resolve => setTimeout(() => resolve(Random.id()), 1000)),
  pending: new Promise(() => {}),
  rejected: Promise.reject(Random.id()),

  // Reactive helpers.
  counterAsync: async () => Template.instance().counter.get(),
  counterSync: () => Template.instance().counter.get(),

  // Nested #let.
  getGreeting: new Promise(resolve => setTimeout(() => resolve('Hi'), 2000)),
  getName: new Promise(resolve => setTimeout(() => resolve('John'), 1000)),
});

@radekmie radekmie self-assigned this May 5, 2023
@radekmie radekmie added this to the Blaze 3.0 milestone May 5, 2023
@radekmie radekmie mentioned this pull request May 5, 2023
5 tasks
@radekmie radekmie linked an issue May 5, 2023 that may be closed by this pull request
@DanielDornhardt
Copy link

Thank you @radekmie, also & especially for the great explanations & examples! That'll be very helpful going forward.

packages/blaze/lookup.js Outdated Show resolved Hide resolved
@radekmie radekmie changed the base branch from master to release-2.7 May 12, 2023 07:13
@radekmie radekmie removed this from the Blaze 3.0 milestone May 12, 2023
@Grubba27 Grubba27 merged commit 2bf7eb3 into release-2.7 May 12, 2023
2 checks passed
@radekmie radekmie deleted the async-bindings branch May 12, 2023 13:34
radekmie added a commit that referenced this pull request May 15, 2023
@Grubba27 Grubba27 mentioned this pull request Jul 6, 2023
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Handle async code
3 participants