diff --git a/packages/blaze/builtins.js b/packages/blaze/builtins.js index 365d7053f..06d68fec7 100644 --- a/packages/blaze/builtins.js +++ b/packages/blaze/builtins.js @@ -48,6 +48,56 @@ function _isEqualBinding(x, y) { } } +/** + * @template T + * @param {T} x + * @returns {T} + */ +function _identity(x) { + return x; +} + +/** + * Attaches a single binding to the instantiated view. + * @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, mapper = _identity) { + if (value && typeof value.then === 'function') { + value.then( + value => reactiveVar.set({ value: mapper(value) }), + error => reactiveVar.set({ error }), + ); + } else { + reactiveVar.set({ value: mapper(value) }); + } +} + +/** + * @template T, U + * @param {Blaze.View} view Target view. + * @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, mapper) { + const reactiveVar = new ReactiveVar(undefined, _isEqualBinding); + if (typeof binding === 'function') { + view.autorun( + () => _setBindingValue(reactiveVar, binding(), mapper), + view.parentView, + displayName, + ); + } else { + _setBindingValue(reactiveVar, binding, mapper); + } + + return reactiveVar; +} + /** * Attaches bindings to the instantiated view. * @param {Object} bindings A dictionary of bindings, each binding name @@ -55,25 +105,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 +135,26 @@ 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 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', + // Store only the actual condition. + value => !Blaze._calculateCondition(value) !== !_not, + ); }); return view; @@ -167,7 +209,7 @@ Blaze.Each = function (argFunc, contentFunc, elseFunc) { eachView.stopHandle = null; eachView.contentFunc = contentFunc; eachView.elseFunc = elseFunc; - eachView.argVar = new ReactiveVar; + eachView.argVar = undefined; eachView.variableName = null; // update the @index value in the scope of all subviews in the range @@ -183,23 +225,24 @@ 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; - } - - eachView.argVar.set(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(); + return eachView.argVar.get()?.value; }, { addedAt: function (id, item, index) { Tracker.nonreactive(function () { @@ -309,6 +352,19 @@ Blaze.Each = function (argFunc, contentFunc, elseFunc) { return eachView; }; +/** + * Create a new `Blaze.Let` view that unwraps the given value. + * @param {unknown} value + * @returns {Blaze.View} + */ +Blaze._Await = function (value) { + return Blaze.Let({ value }, Blaze._AwaitContent); +}; + +Blaze._AwaitContent = function () { + return Blaze.currentView._scopeBindings.value.get()?.value; +}; + Blaze._TemplateWith = function (arg, contentFunc) { var w; diff --git a/packages/blaze/materializer.js b/packages/blaze/materializer.js index dcd84b143..0a05ea85a 100644 --- a/packages/blaze/materializer.js +++ b/packages/blaze/materializer.js @@ -75,10 +75,14 @@ var materializeDOMInner = function (htmljs, intoArray, parentView, workStack) { } return; } else { - if (htmljs instanceof Blaze.Template) { + // Try to construct a `Blaze.View` out of the object. If it works... + if (isPromiseLike(htmljs)) { + htmljs = Blaze._Await(htmljs); + } else if (htmljs instanceof Blaze.Template) { htmljs = htmljs.constructView(); - // fall through to Blaze.View case below } + + // ...materialize it. if (htmljs instanceof Blaze.View) { Blaze._materializeView(htmljs, parentView, workStack, intoArray); return; @@ -89,6 +93,33 @@ var materializeDOMInner = function (htmljs, intoArray, parentView, workStack) { throw new Error("Unexpected object in htmljs: " + htmljs); }; +const isPromiseLike = x => !!x && typeof x.then === 'function'; + +function waitForAllAttributesAndContinue(attrs, fn) { + const promises = []; + for (const [key, value] of Object.entries(attrs)) { + if (isPromiseLike(value)) { + promises.push(value.then(value => { + attrs[key] = value; + })); + } else if (Array.isArray(value)) { + value.forEach((element, index) => { + if (isPromiseLike(element)) { + promises.push(element.then(element => { + value[index] = element; + })); + } + }); + } + } + + if (promises.length) { + Promise.all(promises).then(fn); + } else { + fn(); + } +} + var materializeTag = function (tag, parentView, workStack) { var tagName = tag.tagName; var elem; @@ -125,20 +156,22 @@ var materializeTag = function (tag, parentView, workStack) { var attrUpdater = new ElementAttributesUpdater(elem); var updateAttributes = function () { var expandedAttrs = Blaze._expandAttributes(rawAttrs, parentView); - var flattenedAttrs = HTML.flattenAttributes(expandedAttrs); - var stringAttrs = {}; - for (var attrName in flattenedAttrs) { - // map `null`, `undefined`, and `false` to null, which is important - // so that attributes with nully values are considered absent. - // stringify anything else (e.g. strings, booleans, numbers including 0). - if (flattenedAttrs[attrName] == null || flattenedAttrs[attrName] === false) - stringAttrs[attrName] = null; - else - stringAttrs[attrName] = Blaze._toText(flattenedAttrs[attrName], - parentView, - HTML.TEXTMODE.STRING); - } - attrUpdater.update(stringAttrs); + waitForAllAttributesAndContinue(expandedAttrs, () => { + var flattenedAttrs = HTML.flattenAttributes(expandedAttrs); + var stringAttrs = {}; + for (var attrName in flattenedAttrs) { + // map `null`, `undefined`, and `false` to null, which is important + // so that attributes with nully values are considered absent. + // stringify anything else (e.g. strings, booleans, numbers including 0). + if (flattenedAttrs[attrName] == null || flattenedAttrs[attrName] === false) + stringAttrs[attrName] = null; + else + stringAttrs[attrName] = Blaze._toText(flattenedAttrs[attrName], + parentView, + HTML.TEXTMODE.STRING); + } + attrUpdater.update(stringAttrs); + }); }; var updaterComputation; if (parentView) { 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 f590218df..8aca4ba62 100644 --- a/packages/spacebars-tests/async_tests.html +++ b/packages/spacebars-tests/async_tests.html @@ -67,3 +67,37 @@ {{/let}} {{/let}} + + + + + + + + + + + + + + + + diff --git a/packages/spacebars-tests/async_tests.js b/packages/spacebars-tests/async_tests.js index 84f2b5519..52caa589b 100644 --- a/packages/spacebars-tests/async_tests.js +++ b/packages/spacebars-tests/async_tests.js @@ -1,5 +1,6 @@ function asyncTest(templateName, testName, fn) { - Tinytest.addAsync(`spacebars-tests - async - ${templateName} ${testName}`, test => { + const name = [templateName, testName].filter(Boolean).join(' '); + Tinytest.addAsync(`spacebars-tests - async - ${name}`, test => { const template = Blaze.Template[`spacebars_async_tests_${templateName}`]; const templateCopy = new Blaze.Template(template.viewName, template.renderFunction); return fn(test, templateCopy, () => { @@ -21,16 +22,20 @@ function asyncSuite(templateName, cases) { } } +const getter = async () => 'foo'; +const thenable = { then: resolve => Promise.resolve().then(() => resolve('foo')) }; +const value = Promise.resolve('foo'); + asyncSuite('access', [ - ['getter', { x: { y: async () => 'foo' } }, '', 'foo'], - ['thenable', { x: { y: { then: resolve => { Promise.resolve().then(() => resolve('foo')) } } } }, '', 'foo'], - ['value', { x: { y: Promise.resolve('foo') } }, '', 'foo'], + ['getter', { x: { y: getter } }, '', 'foo'], + ['thenable', { x: { y: thenable } }, '', 'foo'], + ['value', { x: { y: value } }, '', 'foo'], ]); asyncSuite('direct', [ - ['getter', { x: async () => 'foo' }, '', 'foo'], - ['thenable', { x: { then: resolve => { Promise.resolve().then(() => resolve('foo')) } } }, '', 'foo'], - ['value', { x: Promise.resolve('foo') }, '', 'foo'], + ['getter', { x: getter }, '', 'foo'], + ['thenable', { x: thenable }, '', 'foo'], + ['value', { x: value }, '', 'foo'], ]); asyncTest('missing1', 'outer', async (test, template, render) => { @@ -43,6 +48,54 @@ asyncTest('missing2', 'inner', async (test, template, render) => { test.throws(render, 'Binding for "b" was not found.'); }); +asyncSuite('attribute', [ + ['getter', { x: getter }, '', ''], + ['thenable', { x: thenable }, '', ''], + ['value', { x: value }, '', ''], +]); + +asyncTest('attributes', '', async (test, template, render) => { + Blaze._throwNextException = true; + template.helpers({ x: Promise.resolve() }); + test.throws(render, 'Asynchronous attributes are not supported. Use #let to unwrap them first.'); +}); + +asyncSuite('value_direct', [ + ['getter', { x: getter }, '', 'foo'], + ['thenable', { x: thenable }, '', 'foo'], + ['value', { x: value }, '', 'foo'], +]); + +asyncSuite('value_raw', [ + ['getter', { x: getter }, '', 'foo'], + ['thenable', { x: thenable }, '', 'foo'], + ['value', { x: value }, '', 'foo'], +]); + +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(); diff --git a/packages/spacebars/spacebars-runtime.js b/packages/spacebars/spacebars-runtime.js index af0a17a6d..9f7f958b2 100644 --- a/packages/spacebars/spacebars-runtime.js +++ b/packages/spacebars/spacebars-runtime.js @@ -75,6 +75,8 @@ Spacebars.mustache = function (value/*, args*/) { if (result instanceof Spacebars.SafeString) return HTML.Raw(result.toString()); + else if (isPromiseLike(result)) + return result; else // map `null`, `undefined`, and `false` to null, which is important // so that attributes with nully values are considered absent. @@ -111,7 +113,7 @@ Spacebars.dataMustache = function (value/*, args*/) { Spacebars.makeRaw = function (value) { if (value == null) // null or undefined return null; - else if (value instanceof HTML.Raw) + else if (value instanceof HTML.Raw || isPromiseLike(value)) return value; else return HTML.Raw(value); @@ -173,7 +175,7 @@ Spacebars.call = function (value/*, args*/) { } }; -const isPromiseLike = x => typeof x?.then === 'function'; +const isPromiseLike = x => !!x && typeof x.then === 'function'; // Call this as `Spacebars.kw({ ... })`. The return value // is `instanceof Spacebars.kw`. @@ -231,7 +233,7 @@ Spacebars.dot = function (value, id1/*, id2, ...*/) { return Spacebars.dot.apply(null, argsForRecurse); } - if (typeof value === 'function') + while (typeof value === 'function') value = value(); if (! value) diff --git a/packages/spacebars/spacebars_tests.js b/packages/spacebars/spacebars_tests.js index 9444ca2e5..d658ccdf9 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,19 @@ 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: () => () => 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({ x: async () => async () => 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); }); diff --git a/site/source/api/spacebars.md b/site/source/api/spacebars.md index e73455884..f652ec463 100644 --- a/site/source/api/spacebars.md +++ b/site/source/api/spacebars.md @@ -166,14 +166,24 @@ and not all tags are allowed at all locations. ### Double-braced Tags A double-braced tag at element level or in an attribute value typically evalutes -to a string. If it evalutes to something else, the value will be cast to a +to a string. If it evalutes to something else, the value will be cast to a string, unless the value is `null`, `undefined`, or `false`, which results in -nothing being displayed. +nothing being displayed. `Promise`s are also supported -- see below. Values returned from helpers must be pure text, not HTML. (That is, strings should have `<`, not `<`.) Spacebars will perform any necessary escaping if a template is rendered to HTML. +### Async content + +> This functionality is considered experimental and a subject to change. For +> details please refer to [#424](https://github.com/meteor/blaze/pull/428). + +The values can be wrapped in a `Promise`. When that happens, it will be treated +as `undefined` while 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`). + ### SafeString If a double-braced tag at element level evalutes to an object created with @@ -193,6 +203,16 @@ An attribute value that consists entirely of template tags that return `null`, `undefined`, or `false` is considered absent; otherwise, the attribute is considered present, even if its value is empty. +### Async attributes + +> This functionality is considered experimental and a subject to change. For +> details please refer to [#424](https://github.com/meteor/blaze/pull/428). + +The values can be wrapped in a `Promise`. When that happens, it will be treated +as `undefined` while 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`). + ### Dynamic Attributes A double-braced tag can be used in an HTML start tag to specify an arbitrary set @@ -205,11 +225,11 @@ of attributes: ``` The tag must evaluate to an object that serves as a dictionary of attribute name -and value strings. For convenience, the value may also be a string or null. An -empty string or null expands to `{}`. A non-empty string must be an attribute +and value strings. For convenience, the value may also be a string or null. An +empty string or null expands to `{}`. A non-empty string must be an attribute name, and expands to an attribute with an empty value; for example, `"checked"` expands to `{checked: ""}` (which, as far as HTML is concerned, means the -checkbox is checked). +checkbox is checked). `Promise`s are not supported and will throw an error. To summarize: @@ -223,6 +243,7 @@ To summarize: {checked: "", 'class': "foo"}checked class=foo {checked: false, 'class': "foo"}class=foo "checked class=foo"ERROR, string is not an attribute name + Promise.resolve({})ERROR, asynchronous attributes are not supported @@ -255,6 +276,16 @@ insert `"
"` to close an existing div and open a new one. This template tag cannot be used in attributes or in an HTML start tag. +### Async content + +> This functionality is considered experimental and a subject to change. For +> details please refer to [#424](https://github.com/meteor/blaze/pull/428). + +The raw HTML can be wrapped in a `Promise`. When that happens, it 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`). + ## Inclusion Tags An inclusion tag takes the form `{% raw %}{{> templateName}}{% endraw %}` or `{% raw %}{{> templateName @@ -357,6 +388,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 +464,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