diff --git a/packages/blaze/builtins.js b/packages/blaze/builtins.js index 8992c5280..06d68fec7 100644 --- a/packages/blaze/builtins.js +++ b/packages/blaze/builtins.js @@ -352,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/spacebars-tests/async_tests.js b/packages/spacebars-tests/async_tests.js index 2fa3a593f..52caa589b 100644 --- a/packages/spacebars-tests/async_tests.js +++ b/packages/spacebars-tests/async_tests.js @@ -22,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) => { @@ -44,11 +48,11 @@ asyncTest('missing2', 'inner', async (test, template, render) => { test.throws(render, 'Binding for "b" was not found.'); }); -asyncTest('attribute', '', async (test, template, render) => { - Blaze._throwNextException = true; - template.helpers({ x: Promise.resolve() }); - test.throws(render, 'Asynchronous values are not serializable. Use #let to unwrap them first.'); -}); +asyncSuite('attribute', [ + ['getter', { x: getter }, '', ''], + ['thenable', { x: thenable }, '', ''], + ['value', { x: value }, '', ''], +]); asyncTest('attributes', '', async (test, template, render) => { Blaze._throwNextException = true; @@ -56,17 +60,17 @@ asyncTest('attributes', '', async (test, template, render) => { test.throws(render, 'Asynchronous attributes are not supported. Use #let to unwrap them first.'); }); -asyncTest('value_direct', '', async (test, template, render) => { - Blaze._throwNextException = true; - template.helpers({ x: Promise.resolve() }); - test.throws(render, 'Asynchronous values are not serializable. Use #let to unwrap them first.'); -}); +asyncSuite('value_direct', [ + ['getter', { x: getter }, '', 'foo'], + ['thenable', { x: thenable }, '', 'foo'], + ['value', { x: value }, '', 'foo'], +]); -asyncTest('value_raw', '', async (test, template, render) => { - Blaze._throwNextException = true; - template.helpers({ x: Promise.resolve() }); - test.throws(render, 'Asynchronous values are not serializable. Use #let to unwrap them first.'); -}); +asyncSuite('value_raw', [ + ['getter', { x: getter }, '', 'foo'], + ['thenable', { x: thenable }, '', 'foo'], + ['value', { x: value }, '', 'foo'], +]); asyncSuite('if', [ ['false', { x: Promise.resolve(false) }, '', '2'], diff --git a/packages/spacebars/spacebars-runtime.js b/packages/spacebars/spacebars-runtime.js index 9c2b3d1b6..9f7f958b2 100644 --- a/packages/spacebars/spacebars-runtime.js +++ b/packages/spacebars/spacebars-runtime.js @@ -75,8 +75,8 @@ Spacebars.mustache = function (value/*, args*/) { if (result instanceof Spacebars.SafeString) return HTML.Raw(result.toString()); - else if (isPromiseLike(value)) - throw new Error('Asynchronous values are not serializable. Use #let to unwrap them first.'); + 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. @@ -113,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); @@ -175,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`. @@ -233,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 21adb16b1..d658ccdf9 100644 --- a/packages/spacebars/spacebars_tests.js +++ b/packages/spacebars/spacebars_tests.js @@ -81,6 +81,10 @@ Tinytest.addAsync("spacebars - async - Spacebars.dot", async test => { 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 b35f339c0..f652ec463 100644 --- a/site/source/api/spacebars.md +++ b/site/source/api/spacebars.md @@ -168,12 +168,22 @@ and not all tags are allowed at all locations. 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 string, unless the value is `null`, `undefined`, or `false`, which results in -nothing being displayed. `Promise`s are not supported and will throw an error. +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 @@ -256,6 +276,16 @@ insert `"