From 542aaaa35470a2719517849260ba4e20d22c8e78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rados=C5=82aw=20Miernik?= Date: Fri, 5 May 2023 11:54:01 +0200 Subject: [PATCH 1/3] Implemented async bindings in #let. --- packages/blaze/builtins.js | 35 +++++++++++++++++++++++++-------- packages/blaze/lookup.js | 40 +++++++++++++++++++++++++++++++------- packages/blaze/view.js | 9 +++++++++ 3 files changed, 69 insertions(+), 15 deletions(-) diff --git a/packages/blaze/builtins.js b/packages/blaze/builtins.js index 044e68a45..93e6706a7 100644 --- a/packages/blaze/builtins.js +++ b/packages/blaze/builtins.js @@ -32,22 +32,41 @@ Blaze.With = function (data, contentFunc) { return view; }; + +/** + * @summary Shallow compare of two bindings. + * @param {Binding} x + * @param {Binding} y + */ +function _isEqualBinding(x, y) { + return x && y ? x.error === y.error && x.value === y.value : x === y; +} + /** * Attaches bindings to the instantiated view. * @param {Object} bindings A dictionary of bindings, each binding name * corresponds to a value or a function that will be reactively re-run. - * @param {View} view The target. + * @param {Blaze.View} view The target. */ Blaze._attachBindingsToView = function (bindings, view) { + function setBindingValue(name, value) { + if (value instanceof Promise) { + 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(); + view._scopeBindings[name] = new ReactiveVar(undefined, _isEqualBinding); if (typeof binding === 'function') { - view.autorun(function () { - view._scopeBindings[name].set(binding()); - }, view.parentView); + view.autorun(() => setBindingValue(name, binding()), view.parentView); } else { - view._scopeBindings[name].set(binding); + setBindingValue(name, binding); } }); }); @@ -149,7 +168,7 @@ Blaze.Each = function (argFunc, contentFunc, elseFunc) { for (var i = from; i <= to; i++) { var view = eachView._domrange.members[i].view; - view._scopeBindings['@index'].set(i); + view._scopeBindings['@index'].set({ value: i }); } }; @@ -240,7 +259,7 @@ Blaze.Each = function (argFunc, contentFunc, elseFunc) { itemView = eachView.initialSubviews[index]; } if (eachView.variableName) { - itemView._scopeBindings[eachView.variableName].set(newItem); + itemView._scopeBindings[eachView.variableName].set({ value: newItem }); } else { itemView.dataVar.set(newItem); } diff --git a/packages/blaze/lookup.js b/packages/blaze/lookup.js index 87d427238..2929c4e9f 100644 --- a/packages/blaze/lookup.js +++ b/packages/blaze/lookup.js @@ -1,6 +1,31 @@ import has from 'lodash.has'; -Blaze._globalHelpers = {}; +/** @param {(binding: Binding) => boolean} fn */ +function _createBindingsHelper(fn) { + /** @param {string[]} names */ + return (...names) => { + const view = Blaze.currentView; + + // There's either zero arguments (i.e., check all bindings) or an additional + // "hash" argument that we have to ignore. + names = names.length === 0 + // TODO: Should we walk up the bindings here? + ? Object.keys(view._scopeBindings) + : names.slice(0, -1); + + // TODO: What should happen if there's no such binding? + return names.some(name => fn(_lexicalBindingLookup(view, name).get())); + }; +} + +Blaze._globalHelpers = { + /** @summary Check whether any of the given bindings (or all if none given) is still pending. */ + '@pending': _createBindingsHelper(binding => binding === undefined), + /** @summary Check whether any of the given bindings (or all if none given) has rejected. */ + '@rejected': _createBindingsHelper(binding => !!binding && 'error' in binding), + /** @summary Check whether any of the given bindings (or all if none given) has resolved. */ + '@resolved': _createBindingsHelper(binding => !!binding && 'value' in binding), +}; // Documented as Template.registerHelper. // This definition also provides back-compat for `UI.registerHelper`. @@ -103,9 +128,8 @@ function _lexicalKeepGoing(currentView) { return undefined; } -Blaze._lexicalBindingLookup = function (view, name) { +function _lexicalBindingLookup(view, name) { var currentView = view; - var blockHelpersStack = []; // walk up the views stopping at a Spacebars.include or Template view that // doesn't have an InOuterTemplateScope view as a parent @@ -113,14 +137,16 @@ Blaze._lexicalBindingLookup = function (view, name) { // skip block helpers views // if we found the binding on the scope, return it if (has(currentView._scopeBindings, name)) { - var bindingReactiveVar = currentView._scopeBindings[name]; - return function () { - return bindingReactiveVar.get(); - }; + return currentView._scopeBindings[name]; } } while (currentView = _lexicalKeepGoing(currentView)); return null; +} + +Blaze._lexicalBindingLookup = function (view, name) { + const binding = _lexicalBindingLookup(view, name); + return binding && (() => binding.get()?.value); }; // templateInstance argument is provided to be available for possible diff --git a/packages/blaze/view.js b/packages/blaze/view.js index 185197e62..7a9f33fe4 100644 --- a/packages/blaze/view.js +++ b/packages/blaze/view.js @@ -33,6 +33,14 @@ /// general it's good for functions that create Views to set the name. /// Views associated with templates have names of the form "Template.foo". +/** + * A binding is 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 `Promise`s). + * @typedef {{ error: unknown } | { value: unknown } | undefined} Binding + */ + /** * @class * @summary Constructor for a View, which represents a reactive region of DOM. @@ -81,6 +89,7 @@ Blaze.View = function (name, render) { this._hasGeneratedParent = false; // Bindings accessible to children views (via view.lookup('name')) within the // closest template view. + /** @type {Record>} */ this._scopeBindings = {}; this.renderCount = 0; From 5bb27086a8855e770245c5ec5ee88c46fa11eaca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rados=C5=82aw=20Miernik?= Date: Fri, 12 May 2023 09:09:49 +0200 Subject: [PATCH 2/3] Improved binding resolution error message. --- packages/blaze/lookup.js | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/blaze/lookup.js b/packages/blaze/lookup.js index 2929c4e9f..9111cd120 100644 --- a/packages/blaze/lookup.js +++ b/packages/blaze/lookup.js @@ -13,8 +13,14 @@ function _createBindingsHelper(fn) { ? Object.keys(view._scopeBindings) : names.slice(0, -1); - // TODO: What should happen if there's no such binding? - return names.some(name => fn(_lexicalBindingLookup(view, name).get())); + return names.some(name => { + const binding = _lexicalBindingLookup(view, name); + if (!binding) { + throw new Error(`Binding for "${name}" was not found.`); + } + + return fn(binding.get()); + }); }; } From 53a68baa874f7ca0e01c471c1cb69f10fe4bda38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rados=C5=82aw=20Miernik?= Date: Fri, 12 May 2023 09:10:05 +0200 Subject: [PATCH 3/3] Added async tests. --- packages/spacebars-tests/async_tests.html | 69 +++++++++++++++++++ packages/spacebars-tests/async_tests.js | 80 +++++++++++++++++++++++ packages/spacebars-tests/package.js | 2 + 3 files changed, 151 insertions(+) create mode 100644 packages/spacebars-tests/async_tests.html create mode 100644 packages/spacebars-tests/async_tests.js diff --git a/packages/spacebars-tests/async_tests.html b/packages/spacebars-tests/async_tests.html new file mode 100644 index 000000000..f590218df --- /dev/null +++ b/packages/spacebars-tests/async_tests.html @@ -0,0 +1,69 @@ + + + + + + + + + + + + + diff --git a/packages/spacebars-tests/async_tests.js b/packages/spacebars-tests/async_tests.js new file mode 100644 index 000000000..93521b7bb --- /dev/null +++ b/packages/spacebars-tests/async_tests.js @@ -0,0 +1,80 @@ +function asyncTest(templateName, testName, fn) { + Tinytest.addAsync(`spacebars-tests - async - ${templateName} ${testName}`, test => { + const template = Blaze.Template[`spacebars_async_tests_${templateName}`]; + const templateCopy = new Blaze.Template(template.viewName, template.renderFunction); + return fn(test, templateCopy, () => { + const div = renderToDiv(templateCopy); + return () => canonicalizeHtml(div.innerHTML); + }); + }); +} + +function asyncSuite(templateName, cases) { + for (const [testName, helpers, before, after] of cases) { + asyncTest(templateName, testName, async (test, template, render) => { + template.helpers(helpers); + const readHTML = render(); + test.equal(readHTML(), before); + await new Promise(Tracker.afterFlush); + test.equal(readHTML(), after); + }); + } +} + +asyncSuite('access', [ + ['getter', { x: { y: async () => 'foo' } }, '', 'foo'], + ['value', { x: { y: Promise.resolve('foo') } }, '', 'foo'], +]); + +asyncSuite('direct', [ + ['getter', { x: async () => 'foo' }, '', 'foo'], + ['value', { x: Promise.resolve('foo') }, '', 'foo'], +]); + +asyncTest('missing1', 'outer', async (test, template, render) => { + Blaze._throwNextException = true; + test.throws(render, 'Binding for "b" was not found.'); +}); + +asyncTest('missing2', 'inner', async (test, template, render) => { + Blaze._throwNextException = true; + test.throws(render, 'Binding for "b" was not found.'); +}); + +// In the following tests pending=1, rejected=2, resolved=3. +const pending = new Promise(() => {}); +const rejected = Promise.reject(); +const resolved = Promise.resolve(); + +// Ignore unhandled rejection error. +rejected.catch(() => {}); + +asyncSuite('state1', [ + ['pending', { x: pending }, '1 a1', '1 a1'], + ['rejected', { x: rejected }, '1 a1', '2 a2'], + ['resolved', { x: resolved }, '1 a1', '3 a3'], +]); + +asyncSuite('state2flat', [ + ['pending pending', { x: pending, y: pending }, '1 a1 b1 ab1', '1 a1 b1 ab1'], + ['pending rejected', { x: pending, y: rejected }, '1 a1 b1 ab1', '1 2 a1 b2 ab1 ab2'], + ['pending resolved', { x: pending, y: resolved }, '1 a1 b1 ab1', '1 3 a1 b3 ab1 ab3'], + ['rejected pending', { x: rejected, y: pending }, '1 a1 b1 ab1', '1 2 a2 b1 ab1 ab2'], + ['rejected rejected', { x: rejected, y: rejected }, '1 a1 b1 ab1', '2 a2 b2 ab2'], + ['rejected resolved', { x: rejected, y: resolved }, '1 a1 b1 ab1', '2 3 a2 b3 ab2 ab3'], + ['resolved pending', { x: resolved, y: pending }, '1 a1 b1 ab1', '1 3 a3 b1 ab1 ab3'], + ['resolved rejected', { x: resolved, y: rejected }, '1 a1 b1 ab1', '2 3 a3 b2 ab2 ab3'], + ['resolved resolved', { x: resolved, y: resolved }, '1 a1 b1 ab1', '3 a3 b3 ab3'], +]); + +asyncSuite('state2nested', [ + ['pending pending', { x: pending, y: pending }, '1 a1 b1 ab1', '1 a1 b1 ab1'], + ['pending rejected', { x: pending, y: rejected }, '1 a1 b1 ab1', '2 a1 b2 ab1 ab2'], + ['pending resolved', { x: pending, y: resolved }, '1 a1 b1 ab1', '3 a1 b3 ab1 ab3'], + ['rejected pending', { x: rejected, y: pending }, '1 a1 b1 ab1', '1 a2 b1 ab1 ab2'], + ['rejected rejected', { x: rejected, y: rejected }, '1 a1 b1 ab1', '2 a2 b2 ab2'], + ['rejected resolved', { x: rejected, y: resolved }, '1 a1 b1 ab1', '3 a2 b3 ab2 ab3'], + ['resolved pending', { x: resolved, y: pending }, '1 a1 b1 ab1', '1 a3 b1 ab1 ab3'], + ['resolved rejected', { x: resolved, y: rejected }, '1 a1 b1 ab1', '2 a3 b2 ab2 ab3'], + ['resolved resolved', { x: resolved, y: resolved }, '1 a1 b1 ab1', '3 a3 b3 ab3'], +]); diff --git a/packages/spacebars-tests/package.js b/packages/spacebars-tests/package.js index 856bd2b26..94924f9cc 100644 --- a/packages/spacebars-tests/package.js +++ b/packages/spacebars-tests/package.js @@ -29,6 +29,8 @@ Package.onTest(function (api) { api.use('templating@1.4.1', 'client'); api.addFiles([ + 'async_tests.html', + 'async_tests.js', 'template_tests.html', 'template_tests.js', 'templating_tests.html',