From d6a53aa6951d5d4c97eb1be2dc72373ca56043c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rados=C5=82aw=20Miernik?= Date: Wed, 21 Jun 2023 16:55:40 +0200 Subject: [PATCH 1/9] Added support for Promises in #if and #each. --- packages/blaze/builtins.js | 79 +++++++++++++++++++++++--------------- 1 file changed, 49 insertions(+), 30 deletions(-) diff --git a/packages/blaze/builtins.js b/packages/blaze/builtins.js index 365d7053f..7f732936a 100644 --- a/packages/blaze/builtins.js +++ b/packages/blaze/builtins.js @@ -48,6 +48,39 @@ function _isEqualBinding(x, y) { } } +/** + * Attaches a single binding to the instantiated view. + * @param {Blaze.View} view Target view. + * @param {string} name Binding name. + * @param {unknown} value Bound value. + */ +function _setBindingValue(reactiveVar, value) { + if (value && typeof value.then === 'function') { + value.then( + value => reactiveVar.set({ value }), + error => reactiveVar.set({ error }), + ); + } else { + reactiveVar.set({ value }); + } +} + +/** + * @param {Blaze.View} view Target view. + * @param {unknown} binding Binding value or its getter. + * @param {string} [displayName] Autorun's display name. + */ +function _createBinding(view, binding, displayName) { + const reactiveVar = new ReactiveVar(undefined, _isEqualBinding); + if (typeof binding === 'function') { + view.autorun(() => _setBindingValue(reactiveVar, binding()), view.parentView, displayName); + } else { + _setBindingValue(reactiveVar, binding); + } + + return reactiveVar; +} + /** * Attaches bindings to the instantiated view. * @param {Object} bindings A dictionary of bindings, each binding name @@ -55,25 +88,9 @@ function _isEqualBinding(x, y) { * @param {Blaze.View} view The target. */ Blaze._attachBindingsToView = function (bindings, view) { - function setBindingValue(name, value) { - if (value && typeof value.then === 'function') { - value.then( - value => view._scopeBindings[name].set({ value }), - error => view._scopeBindings[name].set({ error }), - ); - } else { - view._scopeBindings[name].set({ value }); - } - } - view.onViewCreated(function () { Object.entries(bindings).forEach(function ([name, binding]) { - view._scopeBindings[name] = new ReactiveVar(undefined, _isEqualBinding); - if (typeof binding === 'function') { - view.autorun(() => setBindingValue(name, binding()), view.parentView); - } else { - setBindingValue(name, binding); - } + view._scopeBindings[name] = _createBinding(view, binding); }); }); }; @@ -101,18 +118,20 @@ Blaze.Let = function (bindings, contentFunc) { * `elseFunc` is supplied, no content is shown in the "else" case. */ Blaze.If = function (conditionFunc, contentFunc, elseFunc, _not) { - var conditionVar = new ReactiveVar; + const view = Blaze.View(_not ? 'unless' : 'if', function () { + // Render only if the binding has a value, i.e., it's either synchronous or + // has resolved. Rejected `Promise`s are NOT rendered. + const condition = view.__conditionVar.get(); + if (condition && 'value' in condition) { + return Blaze._calculateCondition(condition.value) ? contentFunc() : (elseFunc ? elseFunc() : null); + } - var view = Blaze.View(_not ? 'unless' : 'if', function () { - return conditionVar.get() ? contentFunc() : - (elseFunc ? elseFunc() : null); + return null; }); - view.__conditionVar = conditionVar; - view.onViewCreated(function () { - this.autorun(function () { - var cond = Blaze._calculateCondition(conditionFunc()); - conditionVar.set(_not ? (! cond) : cond); - }, this.parentView, 'condition'); + + view.__conditionVar = null; + view.onViewCreated(() => { + view.__conditionVar = _createBinding(view, conditionFunc, 'condition'); }); return view; @@ -167,7 +186,7 @@ Blaze.Each = function (argFunc, contentFunc, elseFunc) { eachView.stopHandle = null; eachView.contentFunc = contentFunc; eachView.elseFunc = elseFunc; - eachView.argVar = new ReactiveVar; + eachView.argVar = _createBinding(eachView, []); eachView.variableName = null; // update the @index value in the scope of all subviews in the range @@ -195,11 +214,11 @@ Blaze.Each = function (argFunc, contentFunc, elseFunc) { arg = arg._sequence; } - eachView.argVar.set(arg); + _setBindingValue(eachView.argVar, arg); }, eachView.parentView, 'collection'); eachView.stopHandle = ObserveSequence.observe(function () { - return eachView.argVar.get(); + return eachView.argVar.get()?.value; }, { addedAt: function (id, item, index) { Tracker.nonreactive(function () { From 5abf7a7796b49fa2b35d7857e4f2b48149613fd2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rados=C5=82aw=20Miernik?= Date: Wed, 2 Aug 2023 09:21:43 +0200 Subject: [PATCH 2/9] Fixed #unless. --- packages/blaze/builtins.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/blaze/builtins.js b/packages/blaze/builtins.js index 7f732936a..9cf67faba 100644 --- a/packages/blaze/builtins.js +++ b/packages/blaze/builtins.js @@ -123,7 +123,8 @@ Blaze.If = function (conditionFunc, contentFunc, elseFunc, _not) { // has resolved. Rejected `Promise`s are NOT rendered. const condition = view.__conditionVar.get(); if (condition && 'value' in condition) { - return Blaze._calculateCondition(condition.value) ? contentFunc() : (elseFunc ? elseFunc() : null); + const result = !Blaze._calculateCondition(condition.value) !== !_not; + return result ? contentFunc() : (elseFunc ? elseFunc() : null); } return null; From f63a57a5d98f5b86ce76d7889b9db5e1b77f95ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rados=C5=82aw=20Miernik?= Date: Mon, 7 Aug 2023 17:23:11 +0200 Subject: [PATCH 3/9] Improved #each and #if handling. --- packages/blaze/builtins.js | 79 ++++++++++++++++++++++++-------------- 1 file changed, 51 insertions(+), 28 deletions(-) diff --git a/packages/blaze/builtins.js b/packages/blaze/builtins.js index 9cf67faba..8992c5280 100644 --- a/packages/blaze/builtins.js +++ b/packages/blaze/builtins.js @@ -48,34 +48,51 @@ function _isEqualBinding(x, y) { } } +/** + * @template T + * @param {T} x + * @returns {T} + */ +function _identity(x) { + return x; +} + /** * Attaches a single binding to the instantiated view. - * @param {Blaze.View} view Target view. - * @param {string} name Binding name. - * @param {unknown} value Bound value. + * @template T, U + * @param {ReactiveVar} reactiveVar Target view. + * @param {Promise | T} value Bound value. + * @param {(value: T) => U} [mapper] Maps the computed value before store. */ -function _setBindingValue(reactiveVar, value) { +function _setBindingValue(reactiveVar, value, mapper = _identity) { if (value && typeof value.then === 'function') { value.then( - value => reactiveVar.set({ value }), + value => reactiveVar.set({ value: mapper(value) }), error => reactiveVar.set({ error }), ); } else { - reactiveVar.set({ value }); + reactiveVar.set({ value: mapper(value) }); } } /** + * @template T, U * @param {Blaze.View} view Target view. - * @param {unknown} binding Binding value or its getter. + * @param {Promise | T | (() => Promise | T)} binding Binding value or its getter. * @param {string} [displayName] Autorun's display name. + * @param {(value: T) => U} [mapper] Maps the computed value before store. + * @returns {ReactiveVar} */ -function _createBinding(view, binding, displayName) { +function _createBinding(view, binding, displayName, mapper) { const reactiveVar = new ReactiveVar(undefined, _isEqualBinding); if (typeof binding === 'function') { - view.autorun(() => _setBindingValue(reactiveVar, binding()), view.parentView, displayName); + view.autorun( + () => _setBindingValue(reactiveVar, binding(), mapper), + view.parentView, + displayName, + ); } else { - _setBindingValue(reactiveVar, binding); + _setBindingValue(reactiveVar, binding, mapper); } return reactiveVar; @@ -123,8 +140,7 @@ Blaze.If = function (conditionFunc, contentFunc, elseFunc, _not) { // has resolved. Rejected `Promise`s are NOT rendered. const condition = view.__conditionVar.get(); if (condition && 'value' in condition) { - const result = !Blaze._calculateCondition(condition.value) !== !_not; - return result ? contentFunc() : (elseFunc ? elseFunc() : null); + return condition.value ? contentFunc() : (elseFunc ? elseFunc() : null); } return null; @@ -132,7 +148,13 @@ Blaze.If = function (conditionFunc, contentFunc, elseFunc, _not) { view.__conditionVar = null; view.onViewCreated(() => { - view.__conditionVar = _createBinding(view, conditionFunc, 'condition'); + view.__conditionVar = _createBinding( + view, + conditionFunc, + 'condition', + // Store only the actual condition. + value => !Blaze._calculateCondition(value) !== !_not, + ); }); return view; @@ -187,7 +209,7 @@ Blaze.Each = function (argFunc, contentFunc, elseFunc) { eachView.stopHandle = null; eachView.contentFunc = contentFunc; eachView.elseFunc = elseFunc; - eachView.argVar = _createBinding(eachView, []); + eachView.argVar = undefined; eachView.variableName = null; // update the @index value in the scope of all subviews in the range @@ -203,20 +225,21 @@ Blaze.Each = function (argFunc, contentFunc, elseFunc) { }; eachView.onViewCreated(function () { - // We evaluate argFunc in an autorun to make sure - // Blaze.currentView is always set when it runs (rather than - // passing argFunc straight to ObserveSequence). - eachView.autorun(function () { - // argFunc can return either a sequence as is or a wrapper object with a - // _sequence and _variable fields set. - var arg = argFunc(); - if (isObject(arg) && has(arg, '_sequence')) { - eachView.variableName = arg._variable || null; - arg = arg._sequence; - } - - _setBindingValue(eachView.argVar, arg); - }, eachView.parentView, 'collection'); + // We evaluate `argFunc` in `Tracker.autorun` to ensure `Blaze.currentView` + // is always set when it runs. + eachView.argVar = _createBinding( + eachView, + // Unwrap a sequence reactively (`{{#each x in xs}}`). + () => { + let maybeSequence = argFunc(); + if (isObject(maybeSequence) && has(maybeSequence, '_sequence')) { + eachView.variableName = maybeSequence._variable || null; + maybeSequence = maybeSequence._sequence; + } + return maybeSequence; + }, + 'collection', + ); eachView.stopHandle = ObserveSequence.observe(function () { return eachView.argVar.get()?.value; From 97bce489162eaf516ddd01d5c5c94584636a375b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rados=C5=82aw=20Miernik?= Date: Mon, 7 Aug 2023 17:23:26 +0200 Subject: [PATCH 4/9] Added tests. --- packages/spacebars-tests/async_tests.html | 18 +++++++++++++++++ packages/spacebars-tests/async_tests.js | 24 +++++++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/packages/spacebars-tests/async_tests.html b/packages/spacebars-tests/async_tests.html index f590218df..e478a7489 100644 --- a/packages/spacebars-tests/async_tests.html +++ b/packages/spacebars-tests/async_tests.html @@ -67,3 +67,21 @@ {{/let}} {{/let}} + + + + + + + + diff --git a/packages/spacebars-tests/async_tests.js b/packages/spacebars-tests/async_tests.js index 84f2b5519..a53a2d6b1 100644 --- a/packages/spacebars-tests/async_tests.js +++ b/packages/spacebars-tests/async_tests.js @@ -43,6 +43,30 @@ asyncTest('missing2', 'inner', async (test, template, render) => { test.throws(render, 'Binding for "b" was not found.'); }); +asyncSuite('if', [ + ['false', { x: Promise.resolve(false) }, '', '2'], + ['true', { x: Promise.resolve(true) }, '', '1 1'], +]); + +asyncSuite('unless', [ + ['false', { x: Promise.resolve(false) }, '', '1 1'], + ['true', { x: Promise.resolve(true) }, '', '2'], +]); + +asyncSuite('each_old', [ + ['null', { x: Promise.resolve(null) }, '0', '0'], + ['empty', { x: Promise.resolve([]) }, '0', '0'], + ['one', { x: Promise.resolve([1]) }, '0', '1'], + ['two', { x: Promise.resolve([1, 2]) }, '0', '12'], +]); + +asyncSuite('each_new', [ + ['null', { x: Promise.resolve(null) }, '0', '0'], + ['empty', { x: Promise.resolve([]) }, '0', '0'], + ['one', { x: Promise.resolve([1]) }, '0', '1'], + ['two', { x: Promise.resolve([1, 2]) }, '0', '12'], +]); + // In the following tests pending=1, rejected=2, resolved=3. const pending = new Promise(() => {}); const rejected = Promise.reject(); From fe80e616bf5bfe17540d8e2a73760f02bb9f3c6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rados=C5=82aw=20Miernik?= Date: Mon, 7 Aug 2023 17:23:40 +0200 Subject: [PATCH 5/9] Fixed Spacebars.call and Spacebars.dot tests. --- packages/spacebars/spacebars_tests.js | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/packages/spacebars/spacebars_tests.js b/packages/spacebars/spacebars_tests.js index 9444ca2e5..21adb16b1 100644 --- a/packages/spacebars/spacebars_tests.js +++ b/packages/spacebars/spacebars_tests.js @@ -58,7 +58,7 @@ Tinytest.add("spacebars - Spacebars.dot", function (test) { }); -Tinytest.add("spacebars - async - Spacebars.call", async test => { +Tinytest.addAsync("spacebars - async - Spacebars.call", async test => { const add = (x, y) => x + y; test.equal(await Spacebars.call(add, 1, Promise.resolve(2)), 3); test.equal(await Spacebars.call(add, Promise.resolve(1), 2), 3); @@ -73,14 +73,15 @@ Tinytest.add("spacebars - async - Spacebars.call", async test => { test.equal(await Spacebars.call(add, Promise.reject(1), Promise.reject(2)).catch(x => x), 1); }); -Tinytest.add("spacebars - async - Spacebars.dot", async test => { - test.equal(await Spacebars.dot(Promise.resolve(null), 'foo'), null); - test.equal(await Spacebars.dot(Promise.resolve({ foo: 1 }), 'foo'), 1); - test.equal(await Spacebars.dot(Promise.resolve({ foo: () => 1 }), 'foo'), 1); - test.equal(await Spacebars.dot(Promise.resolve({ foo: async () => 1 }), 'foo'), 1); - test.equal(await Spacebars.dot({ foo: { then: resolve => resolve(1) } }, 'foo'), 1); - test.equal(await Spacebars.dot({ foo: Promise.resolve(1) }, 'foo'), 1); - test.equal(await Spacebars.dot({ foo: async () => 1 }, 'foo'), 1); - test.equal(await Spacebars.dot(() => ({ foo: async () => 1 }), 'foo'), 1); - test.equal(await Spacebars.dot(async () => ({ foo: async () => 1 }), 'foo'), 1); +Tinytest.addAsync("spacebars - async - Spacebars.dot", async test => { + const o = { y: 1 }; + test.equal(await Spacebars.dot(Promise.resolve(null), 'x', 'y'), null); + test.equal(await Spacebars.dot(Promise.resolve({ x: o }), 'x', 'y'), 1); + test.equal(await Spacebars.dot(Promise.resolve({ x: () => o }), 'x', 'y'), 1); + test.equal(await Spacebars.dot(Promise.resolve({ x: async () => o }), 'x', 'y'), 1); + test.equal(await Spacebars.dot({ x: { then: resolve => resolve(o) } }, 'x', 'y'), 1); + test.equal(await Spacebars.dot({ x: Promise.resolve(o) }, 'x', 'y'), 1); + test.equal(await Spacebars.dot({ x: async () => o }, 'x', 'y'), 1); + test.equal(await Spacebars.dot(() => ({ x: async () => o }), 'x', 'y'), 1); + test.equal(await Spacebars.dot(async () => ({ x: async () => o }), 'x', 'y'), 1); }); From f993508dbc360c95d36d7a91c8aaeffadf071d89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rados=C5=82aw=20Miernik?= Date: Mon, 7 Aug 2023 17:23:51 +0200 Subject: [PATCH 6/9] Added documentation. --- site/source/api/spacebars.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/site/source/api/spacebars.md b/site/source/api/spacebars.md index e73455884..8a56eaf0b 100644 --- a/site/source/api/spacebars.md +++ b/site/source/api/spacebars.md @@ -357,6 +357,16 @@ well as the empty array, while any other value is considered true. `#unless` is just `#if` with the condition inverted. +### Async conditions + +> This functionality is considered experimental and a subject to change. For +> details please refer to [#424](https://github.com/meteor/blaze/pull/424). + +The condition can be wrapped in a `Promise`. When that happens, both `#if` and +`#unless` will not render anything if it's pending or rejected. Once resolved, +the resulting value is used. To have more fine-grained handling of non-resolved +states, use `#let` and the async state helpers (e.g., `@pending`). + ## With A `#with` template tag establishes a new data context object for its contents. @@ -423,6 +433,16 @@ context) if there are zero items in the sequence at any time. You can use a special variable `@index` in the body of `#each` to get the 0-based index of the currently rendered value in the sequence. +### Async sequences + +> This functionality is considered experimental and a subject to change. For +> details please refer to [#424](https://github.com/meteor/blaze/pull/424). + +The sequence argument can be wrapped in a `Promise`. When that happens, `#each` +will render the "else" if it's pending or rejected. Once resolved, the resulting +sequence is used. To have more fine-grained handling of non-resolved states, use +`#let` and the async state helpers (e.g., `@pending`). + ### Reactivity Model for Each When the argument to `#each` changes, the DOM is always updated to reflect the From 02d1524f299c4313e31cbe9beee945a27653544c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rados=C5=82aw=20Miernik?= Date: Thu, 7 Sep 2023 12:47:40 +0200 Subject: [PATCH 7/9] Added and documented detection of unexpected Promise values. --- packages/htmljs/visitors.js | 4 ++++ packages/spacebars-tests/async_tests.html | 16 ++++++++++++++ packages/spacebars-tests/async_tests.js | 27 ++++++++++++++++++++++- packages/spacebars/spacebars-runtime.js | 2 ++ site/source/api/spacebars.md | 11 ++++----- 5 files changed, 54 insertions(+), 6 deletions(-) diff --git a/packages/htmljs/visitors.js b/packages/htmljs/visitors.js index f5a94623d..467fb4929 100644 --- a/packages/htmljs/visitors.js +++ b/packages/htmljs/visitors.js @@ -172,6 +172,10 @@ TransformingVisitor.def({ } if (attrs && isConstructedObject(attrs)) { + if (typeof attrs.then === 'function') { + throw new Error('Asynchronous attributes are not supported. Use #let to unwrap them first.'); + } + throw new Error("The basic TransformingVisitor does not support " + "foreign objects in attributes. Define a custom " + "visitAttributes for this case."); diff --git a/packages/spacebars-tests/async_tests.html b/packages/spacebars-tests/async_tests.html index e478a7489..8aca4ba62 100644 --- a/packages/spacebars-tests/async_tests.html +++ b/packages/spacebars-tests/async_tests.html @@ -68,6 +68,22 @@ {{/let}} + + + + + + + +