diff --git a/src/generators/Generator.ts b/src/generators/Generator.ts index a8775d41b2..284f96b2c5 100644 --- a/src/generators/Generator.ts +++ b/src/generators/Generator.ts @@ -732,7 +732,7 @@ export default class Generator { } } - if (node.type === 'IfBlock') { + if (node.type === 'IfBlock' || node.type === 'AwaitBlock') { node.metadata = contextualise(node.expression, contextDependencies, indexes); } diff --git a/src/generators/dom/preprocess.ts b/src/generators/dom/preprocess.ts index 5778258155..7e0056b0ae 100644 --- a/src/generators/dom/preprocess.ts +++ b/src/generators/dom/preprocess.ts @@ -108,6 +108,48 @@ const preprocessors = { node.var = block.getUniqueName(`text`); }, + AwaitBlock: ( + generator: DomGenerator, + block: Block, + state: State, + node: Node, + inEachBlock: boolean, + elementStack: Node[], + componentStack: Node[], + stripWhitespace: boolean, + nextSibling: Node + ) => { + cannotUseInnerHTML(node); + + node.var = block.getUniqueName('await_block'); + block.addDependencies(node.metadata.dependencies); + + [ + ['pending', null], + ['then', node.value], + ['catch', node.error] + ].forEach(([status, arg]) => { + const child = node[status]; + + const context = block.getUniqueName(arg || '_'); + const contexts = new Map(block.contexts); + contexts.set(arg, context); + + child._block = block.child({ + comment: createDebuggingComment(child, generator), + name: generator.getUniqueName(`create_${status}_block`), + params: block.params.concat(context), + context, + contexts + }); + + child._state = getChildState(state); + + preprocessChildren(generator, child._block, child._state, child, inEachBlock, elementStack, componentStack, stripWhitespace, nextSibling); + generator.blocks.push(child._block); + }); + }, + IfBlock: ( generator: DomGenerator, block: Block, diff --git a/src/generators/dom/visitors/AwaitBlock.ts b/src/generators/dom/visitors/AwaitBlock.ts new file mode 100644 index 0000000000..2ebca07831 --- /dev/null +++ b/src/generators/dom/visitors/AwaitBlock.ts @@ -0,0 +1,133 @@ +import deindent from '../../../utils/deindent'; +import visit from '../visit'; +import { DomGenerator } from '../index'; +import Block from '../Block'; +import isDomNode from './shared/isDomNode'; +import { Node } from '../../../interfaces'; +import { State } from '../interfaces'; + +export default function visitAwaitBlock( + generator: DomGenerator, + block: Block, + state: State, + node: Node, + elementStack: Node[], + componentStack: Node[] +) { + const name = node.var; + + const needsAnchor = node.next ? !isDomNode(node.next, generator) : !state.parentNode || !isDomNode(node.parent, generator); + const anchor = needsAnchor + ? block.getUniqueName(`${name}_anchor`) + : (node.next && node.next.var) || 'null'; + + const params = block.params.join(', '); + + block.contextualise(node.expression); + const { snippet } = node.metadata; + + if (needsAnchor) { + block.addElement( + anchor, + `@createComment()`, + `@createComment()`, + state.parentNode + ); + } + + const promise = block.getUniqueName(`promise`); + const resolved = block.getUniqueName(`resolved`); + const status = block.getUniqueName(`status`); + const select_block_type = block.getUniqueName(`select_block_type`); + const await_block = block.getUniqueName(`await_block`); + const await_block_type = block.getUniqueName(`await_block_type`); + const token = block.getUniqueName(`token`); + const await_token = block.getUniqueName(`await_token`); + const update = block.getUniqueName(`update`); + const handle_promise = block.getUniqueName(`handle_promise`); + const value = block.getUniqueName(`value`); + const error = block.getUniqueName(`error`); + const create_pending_block = node.pending._block.name; + const create_then_block = node.then._block.name; + const create_catch_block = node.catch._block.name; + + const conditions = []; + if (node.metadata.dependencies) { + conditions.push( + `(${node.metadata.dependencies.map(dep => `'${dep}' in changed`).join(' || ')})` + ); + } + + conditions.push(`${promise} !== (${promise} = ${snippet})`); + + block.addVariable(await_block); + block.addVariable(await_block_type); + block.addVariable(await_token); + block.addVariable(promise); + block.addVariable(resolved); + + block.builders.init.addBlock(deindent` + function ${handle_promise}(${promise}, ${params}) { + var ${token} = ${await_token} = {}; + + if (@isPromise(${promise})) { + ${promise}.then(function(${value}) { + if (${token} !== ${await_token}) return; + ${await_block}.u(); + ${await_block}.d(); + ${await_block} = (${await_block_type} = ${create_then_block})(${params}, ${resolved} = ${value}, #component); + ${await_block}.c(); + ${await_block}.m(${anchor}.parentNode, ${anchor}); + }, function (${error}) { + if (${token} !== ${await_token}) return; + ${await_block}.u(); + ${await_block}.d(); + ${await_block} = (${await_block_type} = ${create_catch_block})(${params}, ${resolved} = ${error}, #component); + ${await_block}.c(); + ${await_block}.m(${anchor}.parentNode, ${anchor}); + }); + + // if we previously had a then/catch block, destroy it + if (${await_block_type} !== ${create_pending_block}) { + if (${await_block}) ${await_block}.d(); + ${await_block} = (${await_block_type} = ${create_pending_block})(${params}, ${resolved} = null, #component); + return true; + } + } else { + if (${await_block_type} !== ${create_then_block}) { + if (${await_block}) ${await_block}.d(); + ${await_block} = (${await_block_type} = ${create_then_block})(${params}, ${resolved} = promise, #component); + return true; + } + } + } + + ${handle_promise}(${promise} = ${snippet}, ${params}); + `); + + block.builders.create.addBlock(deindent` + ${await_block}.c(); + `); + + block.builders.claim.addBlock(deindent` + ${await_block}.l(${state.parentNodes}); + `); + + const targetNode = state.parentNode || '#target'; + const anchorNode = state.parentNode ? 'null' : 'anchor'; + + block.builders.mount.addBlock(deindent` + ${await_block}.m(${targetNode}, ${anchor}); + `); + + block.builders.destroy.addBlock(deindent` + ${await_token} = null; + ${await_block}.d(); + `); + + [node.pending, node.then, node.catch].forEach(status => { + status.children.forEach(child => { + visit(generator, status._block, status._state, child, elementStack, componentStack); + }); + }); +} \ No newline at end of file diff --git a/src/generators/dom/visitors/index.ts b/src/generators/dom/visitors/index.ts index 74a272d1dd..fb0da9e0bc 100644 --- a/src/generators/dom/visitors/index.ts +++ b/src/generators/dom/visitors/index.ts @@ -1,3 +1,4 @@ +import AwaitBlock from './AwaitBlock'; import EachBlock from './EachBlock'; import Element from './Element/Element'; import IfBlock from './IfBlock'; @@ -7,6 +8,7 @@ import Text from './Text'; import { Visitor } from '../interfaces'; const visitors: Record = { + AwaitBlock, EachBlock, Element, IfBlock, diff --git a/src/parse/state/mustache.ts b/src/parse/state/mustache.ts index 49d22322b1..10b6751af3 100644 --- a/src/parse/state/mustache.ts +++ b/src/parse/state/mustache.ts @@ -6,6 +6,8 @@ import { Parser } from '../index'; import { Node } from '../../interfaces'; function trimWhitespace(block: Node, trimBefore: boolean, trimAfter: boolean) { + if (!block.children) return; // AwaitBlock + const firstChild = block.children[0]; const lastChild = block.children[block.children.length - 1]; @@ -39,7 +41,7 @@ export default function mustache(parser: Parser) { let block = parser.current(); let expected; - if (block.type === 'ElseBlock' || block.type === 'ThenBlock' || block.type === 'CatchBlock') { + if (block.type === 'ElseBlock' || block.type === 'PendingBlock' || block.type === 'ThenBlock' || block.type === 'CatchBlock') { block.end = start; parser.stack.pop(); block = parser.current(); @@ -72,7 +74,7 @@ export default function mustache(parser: Parser) { } // strip leading/trailing whitespace as necessary - if (!block.children.length) parser.error(`Empty block`, block.start); + if (block.children && !block.children.length) parser.error(`Empty block`, block.start); const charBefore = parser.template[block.start - 1]; const charAfter = parser.template[parser.index]; @@ -134,10 +136,13 @@ export default function mustache(parser: Parser) { parser.stack.push(block.else); } else if (parser.eat('then')) { - // {{then}} is valid by itself — we need to check that a) - // we're inside an await block, and b) there's an expression - const awaitBlock = parser.current(); - if (awaitBlock.type === 'AwaitBlock') { + // TODO DRY out this and the next section + const pendingBlock = parser.current(); + if (pendingBlock.type === 'PendingBlock') { + pendingBlock.end = start; + parser.stack.pop(); + const awaitBlock = parser.current(); + parser.requireWhitespace(); awaitBlock.value = parser.readIdentifier(); @@ -195,13 +200,40 @@ export default function mustache(parser: Parser) { const expression = readExpression(parser); - const block: Node = { - start, - end: null, - type, - expression, - children: [], - }; + const block: Node = type === 'AwaitBlock' ? + { + start, + end: null, + type, + expression, + value: null, + error: null, + pending: { + start, + end: null, + type: 'PendingBlock', + children: [] + }, + then: { + start: null, + end: null, + type: 'ThenBlock', + children: [] + }, + catch: { + start: null, + end: null, + type: 'CatchBlock', + children: [] + }, + } : + { + start, + end: null, + type, + expression, + children: [], + }; parser.allowWhitespace(); @@ -255,6 +287,10 @@ export default function mustache(parser: Parser) { parser.current().children.push(block); parser.stack.push(block); + + if (type === 'AwaitBlock') { + parser.stack.push(block.pending); + } } else if (parser.eat('yield')) { // {{yield}} // TODO deprecate diff --git a/src/shared/index.js b/src/shared/index.js index f9d0f91998..7f01391128 100644 --- a/src/shared/index.js +++ b/src/shared/index.js @@ -187,6 +187,14 @@ export function _unmount() { this._fragment.u(); } +export function isPromise(value) { + return value && typeof value.then === 'function'; +} + +export var PENDING = {}; +export var SUCCESS = {}; +export var FAILURE = {}; + export var proto = { destroy: destroy, get: get, diff --git a/test/parser/samples/await-then-catch/output.json b/test/parser/samples/await-then-catch/output.json index baf2df5fdf..4f149e6a0c 100644 --- a/test/parser/samples/await-then-catch/output.json +++ b/test/parser/samples/await-then-catch/output.json @@ -15,24 +15,42 @@ "end": 19, "name": "thePromise" }, - "children": [ - { - "start": 23, - "end": 40, - "type": "Element", - "name": "p", - "attributes": [], - "children": [ - { - "start": 26, - "end": 36, - "type": "Text", - "data": "loading..." - } - ] - } - ], "value": "theValue", + "error": "theError", + "pending": { + "start": 21, + "end": 41, + "type": "PendingBlock", + "children": [ + { + "start": 21, + "end": 23, + "type": "Text", + "data": "\n\t" + }, + { + "start": 23, + "end": 40, + "type": "Element", + "name": "p", + "attributes": [], + "children": [ + { + "start": 26, + "end": 36, + "type": "Text", + "data": "loading..." + } + ] + }, + { + "start": 40, + "end": 41, + "type": "Text", + "data": "\n" + } + ] + }, "then": { "start": 41, "end": 93, @@ -78,7 +96,6 @@ } ] }, - "error": "theError", "catch": { "start": 93, "end": 148, diff --git a/test/runtime/index.js b/test/runtime/index.js index 122fe645b6..b4f0b6b5ea 100644 --- a/test/runtime/index.js +++ b/test/runtime/index.js @@ -51,7 +51,7 @@ describe("runtime", () => { throw new Error("Forgot to remove `solo: true` from test"); } - (config.skip ? it.skip : config.solo ? it.only : it)(`${dir} (${shared ? 'shared' : 'inline'} helpers)`, () => { + (config.skip ? it.skip : config.solo ? it.only : it)(`${dir} (${shared ? 'shared' : 'inline'} helpers)`, async () => { if (failed.has(dir)) { // this makes debugging easier, by only printing compiled output once throw new Error('skipping test, already failed'); @@ -178,7 +178,7 @@ describe("runtime", () => { } if (config.test) { - config.test(assert, component, target, window, raf); + await config.test(assert, component, target, window, raf); } else { component.destroy(); assert.equal(target.innerHTML, ""); diff --git a/test/runtime/samples/await-then-catch/_config.js b/test/runtime/samples/await-then-catch/_config.js new file mode 100644 index 0000000000..3c60bd64c9 --- /dev/null +++ b/test/runtime/samples/await-then-catch/_config.js @@ -0,0 +1,47 @@ +let fulfil; + +let thePromise = new Promise(f => { + fulfil = f; +}); + +export default { + solo: true, + + data: { + thePromise + }, + + html: ` +

loading...

+ `, + + async test(assert, component, target) { + fulfil(42); + await thePromise; + + assert.htmlEqual(target.innerHTML, ` +

the value is 42

+ `); + + let reject; + + thePromise = new Promise((f, r) => { + reject = r; + }); + + component.set({ + thePromise + }); + + assert.htmlEqual(target.innerHTML, ` +

loading...

+ `); + + reject(new Error('something broke')); + await thePromise; + + assert.htmlEqual(target.innerHTML, ` +

oh no! something broke

+ `); + } +}; \ No newline at end of file diff --git a/test/runtime/samples/await-then-catch/main.html b/test/runtime/samples/await-then-catch/main.html new file mode 100644 index 0000000000..36489b9043 --- /dev/null +++ b/test/runtime/samples/await-then-catch/main.html @@ -0,0 +1,7 @@ +{{#await thePromise}} +

loading...

+{{then theValue}} +

the value is {{theValue}}

+{{catch theError}} +

oh no! {{theError.message}}

+{{/await}} \ No newline at end of file