Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implemented async dynamic attributes (closes #443). #460

Merged
merged 3 commits into from
Feb 29, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 30 additions & 8 deletions packages/blaze/materializer.js
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,32 @@ 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);
radekmie marked this conversation as resolved.
Show resolved Hide resolved
} else {
fn(maybePromise);
}
}

function waitForAllAttributes(attrs) {
if (attrs !== Object(attrs)) {
console.log(attrs)
radekmie marked this conversation as resolved.
Show resolved Hide resolved
return attrs;
}

// 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);
}

// Singular sync attributes, with potentially async properties.
const promises = [];
for (const [key, value] of Object.entries(attrs)) {
if (isPromiseLike(value)) {
Expand All @@ -113,11 +138,8 @@ function waitForAllAttributesAndContinue(attrs, fn) {
}
}

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) : attrs;
}

const materializeTag = function (tag, parentView, workStack) {
Expand Down Expand Up @@ -156,8 +178,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
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