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 attributes and content. #428

Merged
merged 2 commits into from
Nov 16, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
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
13 changes: 13 additions & 0 deletions packages/blaze/builtins.js
Original file line number Diff line number Diff line change
Expand Up @@ -352,6 +352,19 @@ Blaze.Each = function (argFunc, contentFunc, elseFunc) {
return eachView;
};

/**
* Create a new `Blaze.Let` view that unwraps the given value.
* @param {unknown} value
* @returns {Blaze.View}
*/
Blaze._Await = function (value) {
return Blaze.Let({ value }, Blaze._AwaitContent);
};

Blaze._AwaitContent = function () {
return Blaze.currentView._scopeBindings.value.get()?.value;
};

Blaze._TemplateWith = function (arg, contentFunc) {
var w;

Expand Down
65 changes: 49 additions & 16 deletions packages/blaze/materializer.js
Original file line number Diff line number Diff line change
Expand Up @@ -75,10 +75,14 @@ var materializeDOMInner = function (htmljs, intoArray, parentView, workStack) {
}
return;
} else {
if (htmljs instanceof Blaze.Template) {
// Try to construct a `Blaze.View` out of the object. If it works...
if (isPromiseLike(htmljs)) {
htmljs = Blaze._Await(htmljs);
} else if (htmljs instanceof Blaze.Template) {
htmljs = htmljs.constructView();
// fall through to Blaze.View case below
}

// ...materialize it.
if (htmljs instanceof Blaze.View) {
Blaze._materializeView(htmljs, parentView, workStack, intoArray);
return;
Expand All @@ -89,6 +93,33 @@ var materializeDOMInner = function (htmljs, intoArray, parentView, workStack) {
throw new Error("Unexpected object in htmljs: " + htmljs);
};

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

function waitForAllAttributesAndContinue(attrs, fn) {
const promises = [];
for (const [key, value] of Object.entries(attrs)) {
if (isPromiseLike(value)) {
promises.push(value.then(value => {
attrs[key] = value;
}));
} else if (Array.isArray(value)) {
value.forEach((element, index) => {
if (isPromiseLike(element)) {
promises.push(element.then(element => {
value[index] = element;
}));
}
});
}
}

if (promises.length) {
Promise.all(promises).then(fn);
} else {
fn();
}
}

var materializeTag = function (tag, parentView, workStack) {
var tagName = tag.tagName;
var elem;
Expand Down Expand Up @@ -125,20 +156,22 @@ var materializeTag = function (tag, parentView, workStack) {
var attrUpdater = new ElementAttributesUpdater(elem);
var updateAttributes = function () {
var expandedAttrs = Blaze._expandAttributes(rawAttrs, parentView);
var flattenedAttrs = HTML.flattenAttributes(expandedAttrs);
var stringAttrs = {};
for (var attrName in flattenedAttrs) {
// map `null`, `undefined`, and `false` to null, which is important
// so that attributes with nully values are considered absent.
// stringify anything else (e.g. strings, booleans, numbers including 0).
if (flattenedAttrs[attrName] == null || flattenedAttrs[attrName] === false)
stringAttrs[attrName] = null;
else
stringAttrs[attrName] = Blaze._toText(flattenedAttrs[attrName],
parentView,
HTML.TEXTMODE.STRING);
}
attrUpdater.update(stringAttrs);
waitForAllAttributesAndContinue(expandedAttrs, () => {
var flattenedAttrs = HTML.flattenAttributes(expandedAttrs);
var stringAttrs = {};
for (var attrName in flattenedAttrs) {
// map `null`, `undefined`, and `false` to null, which is important
// so that attributes with nully values are considered absent.
// stringify anything else (e.g. strings, booleans, numbers including 0).
if (flattenedAttrs[attrName] == null || flattenedAttrs[attrName] === false)
stringAttrs[attrName] = null;
else
stringAttrs[attrName] = Blaze._toText(flattenedAttrs[attrName],
parentView,
HTML.TEXTMODE.STRING);
}
attrUpdater.update(stringAttrs);
});
};
var updaterComputation;
if (parentView) {
Expand Down
46 changes: 25 additions & 21 deletions packages/spacebars-tests/async_tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,16 +22,20 @@ function asyncSuite(templateName, cases) {
}
}

const getter = async () => 'foo';
const thenable = { then: resolve => Promise.resolve().then(() => resolve('foo')) };
const value = Promise.resolve('foo');

asyncSuite('access', [
['getter', { x: { y: async () => 'foo' } }, '', 'foo'],
['thenable', { x: { y: { then: resolve => { Promise.resolve().then(() => resolve('foo')) } } } }, '', 'foo'],
['value', { x: { y: Promise.resolve('foo') } }, '', 'foo'],
['getter', { x: { y: getter } }, '', 'foo'],
['thenable', { x: { y: thenable } }, '', 'foo'],
['value', { x: { y: value } }, '', 'foo'],
]);

asyncSuite('direct', [
['getter', { x: async () => 'foo' }, '', 'foo'],
['thenable', { x: { then: resolve => { Promise.resolve().then(() => resolve('foo')) } } }, '', 'foo'],
['value', { x: Promise.resolve('foo') }, '', 'foo'],
['getter', { x: getter }, '', 'foo'],
['thenable', { x: thenable }, '', 'foo'],
['value', { x: value }, '', 'foo'],
]);

asyncTest('missing1', 'outer', async (test, template, render) => {
Expand All @@ -44,29 +48,29 @@ asyncTest('missing2', 'inner', async (test, template, render) => {
test.throws(render, 'Binding for "b" was not found.');
});

asyncTest('attribute', '', async (test, template, render) => {
Blaze._throwNextException = true;
template.helpers({ x: Promise.resolve() });
test.throws(render, 'Asynchronous values are not serializable. Use #let to unwrap them first.');
});
asyncSuite('attribute', [
['getter', { x: getter }, '<img>', '<img class="foo">'],
['thenable', { x: thenable }, '<img>', '<img class="foo">'],
['value', { x: value }, '<img>', '<img class="foo">'],
]);

asyncTest('attributes', '', async (test, template, render) => {
Blaze._throwNextException = true;
template.helpers({ x: Promise.resolve() });
test.throws(render, 'Asynchronous attributes are not supported. Use #let to unwrap them first.');
});

asyncTest('value_direct', '', async (test, template, render) => {
Blaze._throwNextException = true;
template.helpers({ x: Promise.resolve() });
test.throws(render, 'Asynchronous values are not serializable. Use #let to unwrap them first.');
});
asyncSuite('value_direct', [
['getter', { x: getter }, '', 'foo'],
['thenable', { x: thenable }, '', 'foo'],
['value', { x: value }, '', 'foo'],
]);

asyncTest('value_raw', '', async (test, template, render) => {
Blaze._throwNextException = true;
template.helpers({ x: Promise.resolve() });
test.throws(render, 'Asynchronous values are not serializable. Use #let to unwrap them first.');
});
asyncSuite('value_raw', [
['getter', { x: getter }, '', 'foo'],
['thenable', { x: thenable }, '', 'foo'],
['value', { x: value }, '', 'foo'],
]);

asyncSuite('if', [
['false', { x: Promise.resolve(false) }, '', '2'],
Expand Down
10 changes: 5 additions & 5 deletions packages/spacebars/spacebars-runtime.js
Original file line number Diff line number Diff line change
Expand Up @@ -75,8 +75,8 @@ Spacebars.mustache = function (value/*, args*/) {

if (result instanceof Spacebars.SafeString)
return HTML.Raw(result.toString());
else if (isPromiseLike(value))
throw new Error('Asynchronous values are not serializable. Use #let to unwrap them first.');
else if (isPromiseLike(result))
return result;
else
// map `null`, `undefined`, and `false` to null, which is important
// so that attributes with nully values are considered absent.
Expand Down Expand Up @@ -113,7 +113,7 @@ Spacebars.dataMustache = function (value/*, args*/) {
Spacebars.makeRaw = function (value) {
if (value == null) // null or undefined
return null;
else if (value instanceof HTML.Raw)
else if (value instanceof HTML.Raw || isPromiseLike(value))
return value;
else
return HTML.Raw(value);
Expand Down Expand Up @@ -175,7 +175,7 @@ Spacebars.call = function (value/*, args*/) {
}
};

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

// Call this as `Spacebars.kw({ ... })`. The return value
// is `instanceof Spacebars.kw`.
Expand Down Expand Up @@ -233,7 +233,7 @@ Spacebars.dot = function (value, id1/*, id2, ...*/) {
return Spacebars.dot.apply(null, argsForRecurse);
}

if (typeof value === 'function')
while (typeof value === 'function')
value = value();

if (! value)
Expand Down
4 changes: 4 additions & 0 deletions packages/spacebars/spacebars_tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,10 @@ Tinytest.addAsync("spacebars - async - Spacebars.dot", async test => {
test.equal(await Spacebars.dot(Promise.resolve({ x: async () => o }), 'x', 'y'), 1);
test.equal(await Spacebars.dot({ x: { then: resolve => resolve(o) } }, 'x', 'y'), 1);
test.equal(await Spacebars.dot({ x: Promise.resolve(o) }, 'x', 'y'), 1);
test.equal(await Spacebars.dot({ x: () => () => o }, 'x', 'y'), 1);
test.equal(await Spacebars.dot({ x: () => async () => o }, 'x', 'y'), 1);
test.equal(await Spacebars.dot({ x: async () => () => o }, 'x', 'y'), 1);
test.equal(await Spacebars.dot({ x: async () => async () => o }, 'x', 'y'), 1);
test.equal(await Spacebars.dot({ x: async () => o }, 'x', 'y'), 1);
test.equal(await Spacebars.dot(() => ({ x: async () => o }), 'x', 'y'), 1);
test.equal(await Spacebars.dot(async () => ({ x: async () => o }), 'x', 'y'), 1);
Expand Down
32 changes: 31 additions & 1 deletion site/source/api/spacebars.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,12 +168,22 @@ and not all tags are allowed at all locations.
A double-braced tag at element level or in an attribute value typically evalutes
to a string. If it evalutes to something else, the value will be cast to a
string, unless the value is `null`, `undefined`, or `false`, which results in
nothing being displayed. `Promise`s are not supported and will throw an error.
nothing being displayed. `Promise`s are also supported -- see below.

Values returned from helpers must be pure text, not HTML. (That is, strings
should have `<`, not `&lt;`.) Spacebars will perform any necessary escaping if
a template is rendered to HTML.

### Async content

> This functionality is considered experimental and a subject to change. For
> details please refer to [#424](https://github.com/meteor/blaze/pull/428).

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

### SafeString

If a double-braced tag at element level evalutes to an object created with
Expand All @@ -193,6 +203,16 @@ An attribute value that consists entirely of template tags that return `null`,
`undefined`, or `false` is considered absent; otherwise, the attribute is
considered present, even if its value is empty.

### Async attributes

> This functionality is considered experimental and a subject to change. For
> details please refer to [#424](https://github.com/meteor/blaze/pull/428).

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

### Dynamic Attributes

A double-braced tag can be used in an HTML start tag to specify an arbitrary set
Expand Down Expand Up @@ -256,6 +276,16 @@ insert `"</div><div>"` to close an existing div and open a new one.

This template tag cannot be used in attributes or in an HTML start tag.

### Async content

> This functionality is considered experimental and a subject to change. For
> details please refer to [#424](https://github.com/meteor/blaze/pull/428).

The raw HTML can be wrapped in a `Promise`. When that happens, it will not
render anything if 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`).

## Inclusion Tags

An inclusion tag takes the form `{% raw %}{{> templateName}}{% endraw %}` or `{% raw %}{{> templateName
Expand Down