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
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
Next Next commit
Implemented async dynamic attributes (closes #443).
  • Loading branch information
radekmie committed Jan 13, 2024
commit a08373c5e60f9fde9e8268e5ba748b4f76d3c0b5
38 changes: 30 additions & 8 deletions packages/blaze/materializer.js
Original file line number Diff line number Diff line change
@@ -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)) {
@@ -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) {
@@ -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
10 changes: 6 additions & 4 deletions packages/htmljs/visitors.js
Original file line number Diff line number Diff line change
@@ -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.");
4 changes: 4 additions & 0 deletions packages/spacebars-tests/async_tests.html
Original file line number Diff line number Diff line change
@@ -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>
67 changes: 44 additions & 23 deletions packages/spacebars-tests/async_tests.js
Original file line number Diff line number Diff line change
@@ -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 }, '<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', [
12 changes: 7 additions & 5 deletions site/source/api/spacebars.md
Original file line number Diff line number Diff line change
@@ -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:
<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>

@@ -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