Skip to content

Commit

Permalink
Merge pull request #460 from meteor/async-dynamic-attributes
Browse files Browse the repository at this point in the history
Implemented async dynamic attributes (closes #443).
  • Loading branch information
Grubba27 authored Feb 29, 2024
2 parents 7220765 + a6ebfe4 commit 4297bab
Show file tree
Hide file tree
Showing 7 changed files with 110 additions and 48 deletions.
7 changes: 7 additions & 0 deletions packages/blaze/exceptions.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
44 changes: 34 additions & 10 deletions packages/blaze/materializer.js
Original file line number Diff line number Diff line change
Expand Up @@ -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., `<img {{x}} {{y}}>`.
if (Array.isArray(attrs)) {
const mapped = attrs.map(waitForAllAttributes);
return mapped.some(isPromiseLike) ? Promise.all(mapped) : mapped;
}

// Singular async attributes, e.g., `<img {{x}}>`.
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) {
Expand Down Expand Up @@ -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
Expand Down
10 changes: 6 additions & 4 deletions packages/htmljs/visitors.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
isVoidElement,
} from './html';

const isPromiseLike = x => !!x && typeof x.then === 'function';

var IDENTITY = function (x) { return x; };

Expand Down Expand Up @@ -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++) {
Expand All @@ -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.");
Expand Down
4 changes: 4 additions & 0 deletions packages/spacebars-tests/async_tests.html
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,10 @@
<img {{x}}>
</template>

<template name="spacebars_async_tests_attributes_double">
<img {{x}} {{y}}>
</template>

<template name="spacebars_async_tests_value_direct">
{{x}}
</template>
Expand Down
67 changes: 44 additions & 23 deletions packages/spacebars-tests/async_tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand All @@ -49,27 +49,48 @@ asyncTest('missing2', 'inner', async (test, template, render) => {
});

asyncSuite('attribute', [
['getter', { x: getter }, '<img>', '<img class="foo">'],
['thenable', { x: thenable }, '<img>', '<img class="foo">'],
['value', { x: value }, '<img>', '<img class="foo">'],
['getter', { x: getter('foo') }, '<img>', '<img class="foo">'],
['thenable', { x: thenable('foo') }, '<img>', '<img class="foo">'],
['value', { x: value('foo') }, '<img>', '<img class="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') }) }, '<img>', '<img>'], // Nested getters are NOT evaluated.
['getter in thenable', { x: thenable({ class: getter('foo') }) }, '<img>', '<img>'], // Nested getters are NOT evaluated.
['getter in value', { x: value({ class: getter('foo') }) }, '<img>', '<img>'], // Nested getters are NOT evaluated.
['static in getter', { x: getter({ class: 'foo' }) }, '<img>', '<img class="foo">'],
['static in thenable', { x: thenable({ class: 'foo' }) }, '<img>', '<img class="foo">'],
['static in value', { x: value({ class: 'foo' }) }, '<img>', '<img class="foo">'],
['thenable in getter', { x: getter({ class: thenable('foo') }) }, '<img>', '<img class="foo">'],
['thenable in thenable', { x: thenable({ class: thenable('foo') }) }, '<img>', '<img class="foo">'],
['thenable in value', { x: value({ class: thenable('foo') }) }, '<img>', '<img class="foo">'],
['value in getter', { x: getter({ class: value('foo') }) }, '<img>', '<img class="foo">'],
['value in thenable', { x: thenable({ class: value('foo') }) }, '<img>', '<img class="foo">'],
['value in value', { x: value({ class: value('foo') }) }, '<img>', '<img class="foo">'],
]);

asyncSuite('attributes_double', [
['null lhs getter', { x: getter({ class: null }), y: getter({ class: 'foo' }) }, '<img>', '<img class="foo">'],
['null lhs thenable', { x: thenable({ class: null }), y: thenable({ class: 'foo' }) }, '<img>', '<img class="foo">'],
['null lhs value', { x: value({ class: null }), y: value({ class: 'foo' }) }, '<img>', '<img class="foo">'],
['null rhs getter', { x: getter({ class: 'foo' }), y: getter({ class: null }) }, '<img>', '<img class="foo">'],
['null rhs thenable', { x: thenable({ class: 'foo' }), y: thenable({ class: null }) }, '<img>', '<img class="foo">'],
['null rhs value', { x: value({ class: 'foo' }), y: value({ class: null }) }, '<img>', '<img class="foo">'],
['override getter', { x: getter({ class: 'foo' }), y: getter({ class: 'bar' }) }, '<img>', '<img class="bar">'],
['override thenable', { x: thenable({ class: 'foo' }), y: thenable({ class: 'bar' }) }, '<img>', '<img class="bar">'],
['override value', { x: value({ class: 'foo' }), y: value({ class: 'bar' }) }, '<img>', '<img 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', [
Expand Down
14 changes: 8 additions & 6 deletions packages/spacebars/spacebars-runtime.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
);
}

Expand Down
12 changes: 7 additions & 5 deletions site/source/api/spacebars.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand All @@ -242,10 +242,6 @@ To summarize:
<tr><td><code>{checked: "", 'class': "foo"}</code></td><td><code>checked class=foo</code></td></tr>
<tr><td><code>{checked: false, 'class': "foo"}</code></td><td><code>class=foo</code></td></tr>
<tr><td><code>"checked class=foo"</code></td><td>ERROR, string is not an attribute name</td></tr>
<tr>
<td><code>Promise.resolve({})</code></td>
<td>ERROR, asynchronous dynamic attributes are not supported, see <a href="https://github.com/meteor/blaze/issues/443"><code>#443</code></a></td>
</tr>
</tbody>
</table>

Expand All @@ -262,6 +258,12 @@ specifies a value for the `class` attribute, it will overwrite `{% raw %}{{myCla
As always, Spacebars takes care of recalculating the element's attributes if any
of `myClass`, `attrs1`, or `attrs2` changes reactively.

### Async Dynamic Attributes

The dynamic attributes can be wrapped in a `Promise`. When that happens, they
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`).

## Triple-braced Tags

Expand Down

0 comments on commit 4297bab

Please sign in to comment.