diff --git a/HISTORY.md b/HISTORY.md index e49c97f33..bfcf3b547 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -1,3 +1,11 @@ + +## v2.9.0 2024-Mar-XX + +* [#460](https://github.com/meteor/blaze/pull/460) Implemented async dynamic attributes. +* [#458](https://github.com/meteor/blaze/pull/458) Blaze._expandAttributes returns empty object, if null. + + + ## v2.8.0 2023-Dec-28 * [#431](https://github.com/meteor/blaze/pull/431) Depracate Ui package. diff --git a/packages/blaze/.versions b/packages/blaze/.versions index 1b878a249..c29a8a96e 100644 --- a/packages/blaze/.versions +++ b/packages/blaze/.versions @@ -3,7 +3,7 @@ babel-compiler@7.10.5 babel-runtime@1.5.1 base64@1.0.12 binary-heap@1.0.11 -blaze@2.8.0 +blaze@2.9.0 blaze-tools@1.1.4 boilerplate-generator@1.7.2 caching-compiler@1.2.2 @@ -24,13 +24,13 @@ ejson@1.1.3 fetch@0.1.4 geojson-utils@1.0.11 html-tools@1.1.4 -htmljs@1.2.0 +htmljs@1.2.1 id-map@1.1.1 inter-process-messaging@0.1.1 jquery@1.11.10 -local-test:blaze@2.8.0 +local-test:blaze@2.9.0 logging@1.3.3 -meteor@1.11.4 +meteor@1.11.5 minimongo@1.9.3 modern-browsers@0.1.10 modules@0.20.0 @@ -60,6 +60,6 @@ test-helpers@1.3.1 tinytest@1.2.3 tracker@1.3.3 typescript@4.9.5 -underscore@1.0.13 -webapp@1.13.6 +underscore@1.6.0 +webapp@1.13.8 webapp-hashing@1.1.1 diff --git a/packages/blaze/exceptions.js b/packages/blaze/exceptions.js index 2e62a8e67..b99ce4c95 100644 --- a/packages/blaze/exceptions.js +++ b/packages/blaze/exceptions.js @@ -42,6 +42,13 @@ Blaze._reportException = function (e, msg) { debugFunc()(msg || 'Exception caught in template:', e.stack || e.message || e); }; +// It's meant to be used in `Promise` chains to report the error while not +// "swallowing" it (i.e., the chain will still reject). +Blaze._reportExceptionAndThrow = function (error) { + Blaze._reportException(error); + throw error; +}; + Blaze._wrapCatchingExceptions = function (f, where) { if (typeof f !== 'function') return f; diff --git a/packages/blaze/materializer.js b/packages/blaze/materializer.js index b1f2182b2..d81547f13 100644 --- a/packages/blaze/materializer.js +++ b/packages/blaze/materializer.js @@ -95,29 +95,53 @@ const materializeDOMInner = function (htmljs, intoArray, parentView, workStack) const isPromiseLike = x => !!x && typeof x.then === 'function'; -function waitForAllAttributesAndContinue(attrs, fn) { +function then(maybePromise, fn) { + if (isPromiseLike(maybePromise)) { + maybePromise.then(fn, Blaze._reportException); + } else { + fn(maybePromise); + } +} + +function waitForAllAttributes(attrs) { + // Non-object attrs (e.g., `null`) are ignored. + if (!attrs || attrs !== Object(attrs)) { + return {}; + } + + // Combined attributes, e.g., ``. + if (Array.isArray(attrs)) { + const mapped = attrs.map(waitForAllAttributes); + return mapped.some(isPromiseLike) ? Promise.all(mapped) : mapped; + } + + // Singular async attributes, e.g., ``. + if (isPromiseLike(attrs)) { + return attrs.then(waitForAllAttributes, Blaze._reportExceptionAndThrow); + } + + // Singular sync attributes, with potentially async properties. const promises = []; for (const [key, value] of Object.entries(attrs)) { if (isPromiseLike(value)) { promises.push(value.then(value => { attrs[key] = value; - })); + }, Blaze._reportExceptionAndThrow)); } else if (Array.isArray(value)) { value.forEach((element, index) => { if (isPromiseLike(element)) { promises.push(element.then(element => { value[index] = element; - })); + }, Blaze._reportExceptionAndThrow)); } }); } } - if (promises.length) { - Promise.all(promises).then(fn); - } else { - fn(); - } + // If any of the properties were async, lift the `Promise`. + return promises.length + ? Promise.all(promises).then(() => attrs, Blaze._reportExceptionAndThrow) + : attrs; } const materializeTag = function (tag, parentView, workStack) { @@ -156,8 +180,8 @@ const materializeTag = function (tag, parentView, workStack) { const attrUpdater = new ElementAttributesUpdater(elem); const updateAttributes = function () { const expandedAttrs = Blaze._expandAttributes(rawAttrs, parentView); - waitForAllAttributesAndContinue(expandedAttrs, () => { - const flattenedAttrs = HTML.flattenAttributes(expandedAttrs); + then(waitForAllAttributes(expandedAttrs), awaitedAttrs => { + const flattenedAttrs = HTML.flattenAttributes(awaitedAttrs); const stringAttrs = {}; Object.keys(flattenedAttrs).forEach((attrName) => { // map `null`, `undefined`, and `false` to null, which is important diff --git a/packages/blaze/package.js b/packages/blaze/package.js index 13ba51422..61fb2401a 100644 --- a/packages/blaze/package.js +++ b/packages/blaze/package.js @@ -1,7 +1,7 @@ Package.describe({ name: 'blaze', summary: "Meteor Reactive Templating library", - version: '2.8.0', + version: '2.9.0', git: 'https://github.com/meteor/blaze.git' }); @@ -27,8 +27,8 @@ Package.onUse(function (api) { 'Handlebars' ]); - api.use('htmljs@1.2.0'); - api.imply('htmljs@1.2.0'); + api.use('htmljs@1.2.1'); + api.imply('htmljs@1.2.1'); api.addFiles([ 'preamble.js' diff --git a/packages/blaze/view.js b/packages/blaze/view.js index 625bcc288..97f83388c 100644 --- a/packages/blaze/view.js +++ b/packages/blaze/view.js @@ -490,8 +490,9 @@ Blaze._expand = function (htmljs, parentView) { Blaze._expandAttributes = function (attrs, parentView) { parentView = parentView || currentViewIfRendering(); - return (new Blaze._HTMLJSExpander( + const expanded = (new Blaze._HTMLJSExpander( {parentView: parentView})).visitAttributes(attrs); + return expanded || {}; }; Blaze._destroyView = function (view, _skipNodes) { diff --git a/packages/htmljs/.versions b/packages/htmljs/.versions index ce98fd388..ae54dc62e 100644 --- a/packages/htmljs/.versions +++ b/packages/htmljs/.versions @@ -19,12 +19,12 @@ ecmascript-runtime-server@0.11.0 ejson@1.1.3 fetch@0.1.4 geojson-utils@1.0.11 -htmljs@1.2.0 +htmljs@1.2.1 id-map@1.1.1 inter-process-messaging@0.1.1 -local-test:htmljs@1.2.0 +local-test:htmljs@1.2.1 logging@1.3.3 -meteor@1.11.4 +meteor@1.11.5 minimongo@1.9.3 modern-browsers@0.1.10 modules@0.20.0 @@ -45,6 +45,6 @@ socket-stream-client@0.5.2 tinytest@1.2.3 tracker@1.3.3 typescript@4.9.5 -underscore@1.0.13 -webapp@1.13.6 +underscore@1.6.0 +webapp@1.13.8 webapp-hashing@1.1.1 diff --git a/packages/htmljs/package.js b/packages/htmljs/package.js index 2c2d250f1..82a00c713 100644 --- a/packages/htmljs/package.js +++ b/packages/htmljs/package.js @@ -1,7 +1,7 @@ Package.describe({ name: 'htmljs', summary: "Small library for expressing HTML trees", - version: '1.2.0', + version: '1.2.1', git: 'https://github.com/meteor/blaze.git' }); diff --git a/packages/htmljs/visitors.js b/packages/htmljs/visitors.js index 51e6fd2f5..f846eaf0a 100644 --- a/packages/htmljs/visitors.js +++ b/packages/htmljs/visitors.js @@ -10,6 +10,7 @@ import { isVoidElement, } from './html'; +const isPromiseLike = x => !!x && typeof x.then === 'function'; var IDENTITY = function (x) { return x; }; @@ -156,6 +157,11 @@ TransformingVisitor.def({ // an array, or in some uses, a foreign object (such as // a template tag). visitAttributes: function (attrs, ...args) { + // Allow Promise-like values here; these will be handled in materializer. + if (isPromiseLike(attrs)) { + return attrs; + } + if (isArray(attrs)) { var result = attrs; for (var i = 0; i < attrs.length; i++) { @@ -172,10 +178,6 @@ TransformingVisitor.def({ } if (attrs && isConstructedObject(attrs)) { - if (typeof attrs.then === 'function') { - throw new Error('Asynchronous dynamic 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/.versions b/packages/spacebars-tests/.versions index 811d09ef8..6079a4f81 100644 --- a/packages/spacebars-tests/.versions +++ b/packages/spacebars-tests/.versions @@ -3,7 +3,7 @@ babel-compiler@7.10.5 babel-runtime@1.5.1 base64@1.0.12 binary-heap@1.0.11 -blaze@2.8.0 +blaze@2.9.0 blaze-tools@1.1.4 boilerplate-generator@1.7.2 caching-compiler@1.2.2 @@ -25,14 +25,14 @@ es5-shim@4.8.0 fetch@0.1.4 geojson-utils@1.0.11 html-tools@1.1.4 -htmljs@1.2.0 +htmljs@1.2.1 id-map@1.1.1 inter-process-messaging@0.1.1 jquery@1.11.10 -local-test:spacebars-tests@1.3.4 +local-test:spacebars-tests@1.4.0 logging@1.3.3 markdown@1.0.14 -meteor@1.11.4 +meteor@1.11.5 minimongo@1.9.3 modern-browsers@0.1.10 modules@0.20.0 @@ -56,7 +56,7 @@ session@1.2.1 socket-stream-client@0.5.2 spacebars@1.4.0 spacebars-compiler@1.3.2 -spacebars-tests@1.3.4 +spacebars-tests@1.4.0 templating@1.4.3 templating-compiler@1.4.2 templating-runtime@1.6.4 @@ -65,6 +65,6 @@ test-helpers@1.3.1 tinytest@1.2.3 tracker@1.3.3 typescript@4.9.5 -underscore@1.0.13 -webapp@1.13.6 +underscore@1.6.0 +webapp@1.13.8 webapp-hashing@1.1.1 diff --git a/packages/spacebars-tests/async_tests.html b/packages/spacebars-tests/async_tests.html index 8aca4ba62..4f8f8641d 100644 --- a/packages/spacebars-tests/async_tests.html +++ b/packages/spacebars-tests/async_tests.html @@ -76,6 +76,10 @@ + + + + {{x}} diff --git a/packages/spacebars-tests/async_tests.js b/packages/spacebars-tests/async_tests.js index 8a328beb7..4f859076d 100644 --- a/packages/spacebars-tests/async_tests.js +++ b/packages/spacebars-tests/async_tests.js @@ -22,20 +22,20 @@ function asyncSuite(templateName, cases) { } } -const getter = async () => 'foo'; -const thenable = { then: resolve => Promise.resolve().then(() => resolve('foo')) }; -const value = Promise.resolve('foo'); +const getter = v => async () => v; +const thenable = v => ({ then: resolve => Promise.resolve().then(() => resolve(v)) }); +const value = v => Promise.resolve(v); asyncSuite('access', [ - ['getter', { x: { y: getter } }, '', 'foo'], - ['thenable', { x: { y: thenable } }, '', 'foo'], - ['value', { x: { y: value } }, '', 'foo'], + ['getter', { x: { y: getter('foo') } }, '', 'foo'], + ['thenable', { x: { y: thenable('foo') } }, '', 'foo'], + ['value', { x: { y: value('foo') } }, '', 'foo'], ]); asyncSuite('direct', [ - ['getter', { x: getter }, '', 'foo'], - ['thenable', { x: thenable }, '', 'foo'], - ['value', { x: value }, '', 'foo'], + ['getter', { x: getter('foo') }, '', 'foo'], + ['thenable', { x: thenable('foo') }, '', 'foo'], + ['value', { x: value('foo') }, '', 'foo'], ]); asyncTest('missing1', 'outer', async (test, template, render) => { @@ -49,27 +49,48 @@ asyncTest('missing2', 'inner', async (test, template, render) => { }); asyncSuite('attribute', [ - ['getter', { x: getter }, '', ''], - ['thenable', { x: thenable }, '', ''], - ['value', { x: value }, '', ''], + ['getter', { x: getter('foo') }, '', ''], + ['thenable', { x: thenable('foo') }, '', ''], + ['value', { x: value('foo') }, '', ''], ]); -asyncTest('attributes', '', async (test, template, render) => { - Blaze._throwNextException = true; - template.helpers({ x: Promise.resolve() }); - test.throws(render, 'Asynchronous dynamic attributes are not supported. Use #let to unwrap them first.'); -}); +asyncSuite('attributes', [ + ['getter in getter', { x: getter({ class: getter('foo') }) }, '', ''], // Nested getters are NOT evaluated. + ['getter in thenable', { x: thenable({ class: getter('foo') }) }, '', ''], // Nested getters are NOT evaluated. + ['getter in value', { x: value({ class: getter('foo') }) }, '', ''], // Nested getters are NOT evaluated. + ['static in getter', { x: getter({ class: 'foo' }) }, '', ''], + ['static in thenable', { x: thenable({ class: 'foo' }) }, '', ''], + ['static in value', { x: value({ class: 'foo' }) }, '', ''], + ['thenable in getter', { x: getter({ class: thenable('foo') }) }, '', ''], + ['thenable in thenable', { x: thenable({ class: thenable('foo') }) }, '', ''], + ['thenable in value', { x: value({ class: thenable('foo') }) }, '', ''], + ['value in getter', { x: getter({ class: value('foo') }) }, '', ''], + ['value in thenable', { x: thenable({ class: value('foo') }) }, '', ''], + ['value in value', { x: value({ class: value('foo') }) }, '', ''], +]); + +asyncSuite('attributes_double', [ + ['null lhs getter', { x: getter({ class: null }), y: getter({ class: 'foo' }) }, '', ''], + ['null lhs thenable', { x: thenable({ class: null }), y: thenable({ class: 'foo' }) }, '', ''], + ['null lhs value', { x: value({ class: null }), y: value({ class: 'foo' }) }, '', ''], + ['null rhs getter', { x: getter({ class: 'foo' }), y: getter({ class: null }) }, '', ''], + ['null rhs thenable', { x: thenable({ class: 'foo' }), y: thenable({ class: null }) }, '', ''], + ['null rhs value', { x: value({ class: 'foo' }), y: value({ class: null }) }, '', ''], + ['override getter', { x: getter({ class: 'foo' }), y: getter({ class: 'bar' }) }, '', ''], + ['override thenable', { x: thenable({ class: 'foo' }), y: thenable({ class: 'bar' }) }, '', ''], + ['override value', { x: value({ class: 'foo' }), y: value({ class: 'bar' }) }, '', ''], +]); asyncSuite('value_direct', [ - ['getter', { x: getter }, '', 'foo'], - ['thenable', { x: thenable }, '', 'foo'], - ['value', { x: value }, '', 'foo'], + ['getter', { x: getter('foo') }, '', 'foo'], + ['thenable', { x: thenable('foo') }, '', 'foo'], + ['value', { x: value('foo') }, '', 'foo'], ]); asyncSuite('value_raw', [ - ['getter', { x: getter }, '', 'foo'], - ['thenable', { x: thenable }, '', 'foo'], - ['value', { x: value }, '', 'foo'], + ['getter', { x: getter('foo') }, '', 'foo'], + ['thenable', { x: thenable('foo') }, '', 'foo'], + ['value', { x: value('foo') }, '', 'foo'], ]); asyncSuite('if', [ diff --git a/packages/spacebars-tests/package.js b/packages/spacebars-tests/package.js index cd4a4abd5..f3b399cda 100644 --- a/packages/spacebars-tests/package.js +++ b/packages/spacebars-tests/package.js @@ -1,7 +1,7 @@ Package.describe({ name: 'spacebars-tests', summary: "Additional tests for Spacebars", - version: '1.3.4', + version: '1.4.0', git: 'https://github.com/meteor/blaze.git' }); @@ -24,7 +24,7 @@ Package.onTest(function (api) { api.use([ 'spacebars@1.4.0', - 'blaze@2.8.0' + 'blaze@2.9.0' ]); api.use('templating@1.4.3', 'client'); diff --git a/packages/spacebars-tests/template_tests.html b/packages/spacebars-tests/template_tests.html index 1788c86d6..34322ade9 100644 --- a/packages/spacebars-tests/template_tests.html +++ b/packages/spacebars-tests/template_tests.html @@ -430,6 +430,14 @@
+ + + + + + + + {{> foo}} diff --git a/packages/spacebars-tests/template_tests.js b/packages/spacebars-tests/template_tests.js index 903357c9c..295e11749 100644 --- a/packages/spacebars-tests/template_tests.js +++ b/packages/spacebars-tests/template_tests.js @@ -1545,6 +1545,54 @@ Tinytest.add( } ); +// The attribute object could be disabled or null, which +// should be handled, as if an empty object is passed +Tinytest.add( + 'spacebars-tests - template_tests - attribute object helpers are disabled', + function (test) { + const tmpl = + Template.spacebars_template_test_attr_object_helpers_are_disabled; + tmpl.helpers({ + disabled: function () { + return undefined; + }, + }); + + // should not throw + const div = renderToDiv(tmpl); + + // button should not be affected + const pElement = div.querySelector('button'); + test.equal(pElement.getAttribute('title'), null); + const text = pElement.firstChild.textContent; + test.equal(text, 'test'); + } +); + +// The attribute object could be disabled or null, which +// should be handled, as if an empty object is passed +Tinytest.add( + 'spacebars-tests - template_tests - attribute object helpers are disabled should not affect existing atts', + function (test) { + const tmpl = + Template.spacebars_template_test_attr_object_helpers_are_disabled2; + tmpl.helpers({ + disabled: function () { + return undefined; + }, + }); + + // should not throw + const div = renderToDiv(tmpl); + + // existing atts should not be affected + const pElement = div.querySelector('button'); + test.equal(pElement.getAttribute('title'), 'foo'); + const text = pElement.firstChild.textContent + test.equal(text, 'test'); + } +); + // Test that when a helper in an inclusion directive (`{{> foo }}`) // re-runs due to a dependency changing but the return value is the // same, the template is not re-rendered. diff --git a/packages/spacebars/.versions b/packages/spacebars/.versions index d41ded539..39b6d60b6 100644 --- a/packages/spacebars/.versions +++ b/packages/spacebars/.versions @@ -3,7 +3,7 @@ babel-compiler@7.10.5 babel-runtime@1.5.1 base64@1.0.12 binary-heap@1.0.11 -blaze@2.8.0 +blaze@2.9.0 boilerplate-generator@1.7.2 callback-hook@1.5.1 check@1.3.2 @@ -20,12 +20,12 @@ ecmascript-runtime-server@0.11.0 ejson@1.1.3 fetch@0.1.4 geojson-utils@1.0.11 -htmljs@1.2.0 +htmljs@1.2.1 id-map@1.1.1 inter-process-messaging@0.1.1 -local-test:spacebars@1.5.0 +local-test:spacebars@1.6.0 logging@1.3.3 -meteor@1.11.4 +meteor@1.11.5 minimongo@1.9.3 modern-browsers@0.1.10 modules@0.20.0 @@ -45,10 +45,10 @@ reload@1.3.1 retry@1.1.0 routepolicy@1.1.1 socket-stream-client@0.5.2 -spacebars@1.5.0 +spacebars@1.6.0 tinytest@1.2.3 tracker@1.3.3 typescript@4.9.5 -underscore@1.0.13 -webapp@1.13.6 +underscore@1.6.0 +webapp@1.13.8 webapp-hashing@1.1.1 diff --git a/packages/spacebars/package.js b/packages/spacebars/package.js index ca0503843..3442a4303 100644 --- a/packages/spacebars/package.js +++ b/packages/spacebars/package.js @@ -1,7 +1,7 @@ Package.describe({ name: 'spacebars', summary: "Handlebars-like template language for Meteor", - version: '1.5.0', + version: '1.6.0', git: 'https://github.com/meteor/blaze.git' }); @@ -19,8 +19,8 @@ Package.onUse(function (api) { api.export('Spacebars'); - api.use('htmljs@1.2.0'); - api.use('blaze@2.8.0'); + api.use('htmljs@1.2.1'); + api.use('blaze@2.9.0'); api.addFiles([ 'spacebars-runtime.js' diff --git a/packages/spacebars/spacebars-runtime.js b/packages/spacebars/spacebars-runtime.js index 9f7f958b2..3bc3b993f 100644 --- a/packages/spacebars/spacebars-runtime.js +++ b/packages/spacebars/spacebars-runtime.js @@ -131,12 +131,14 @@ Spacebars.makeRaw = function (value) { function _thenWithContext(promise, fn) { const computation = Tracker.currentComputation; const view = Blaze.currentView; - return promise.then(value => - Blaze._withCurrentView(view, () => - Tracker.withComputation(computation, () => - fn(value) - ) - ) + return promise.then( + value => + Blaze._withCurrentView(view, () => + Tracker.withComputation(computation, () => + fn(value) + ) + ), + Blaze._reportExceptionAndThrow ); } diff --git a/site/source/api/spacebars.md b/site/source/api/spacebars.md index 7a9fd9188..e59f322a1 100644 --- a/site/source/api/spacebars.md +++ b/site/source/api/spacebars.md @@ -228,7 +228,7 @@ 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). `Promise`s are not supported and will throw an error. +checkbox is checked). To summarize: @@ -242,10 +242,6 @@ To summarize:{checked: "", 'class': "foo"}
checked class=foo
{checked: false, 'class': "foo"}
class=foo
"checked class=foo"
Promise.resolve({})
#443