From bff787d5740850f9ab06d8b95d5c4083c482f440 Mon Sep 17 00:00:00 2001 From: Patrick Steele-Idem Date: Wed, 11 Oct 2017 10:18:44 -0600 Subject: [PATCH] Fixes #886 - Write component initialization code when async out and all of its nested async outs finish Store async tracking information independently on each out. Completion now trickles up the tree of outs --- package.json | 1 - src/runtime/html/AsyncStream.js | 193 +++++------ src/runtime/html/Template.js | 6 +- src/runtime/renderable.js | 2 +- src/runtime/vdom/AsyncVDOMBuilder.js | 124 ++++--- src/runtime/vdom/index.js | 4 +- src/taglibs/async/AsyncValue.js | 121 +++++++ src/taglibs/async/await-reorderer-tag.js | 76 ++--- src/taglibs/async/await-tag.js | 305 ++++++++---------- .../async-render/await-arg/template.marko | 32 +- test/autotests/async-render/await-arg/test.js | 30 -- .../expected-events-vdom.json | 8 +- .../expected-events.json | 8 +- .../expected-events-vdom.json | 22 +- .../expected-events.json | 14 +- .../expected-events-vdom.json | 34 +- .../expected-events.json | 20 +- .../expected-events-vdom.json | 26 -- .../expected-events.json | 10 +- .../await-client-reorder-sync/expected.html | 2 +- .../await-client-reorder-sync/test.js | 4 +- .../expected-events-vdom.json | 8 +- .../await-client-reorder/expected-events.json | 8 +- .../await-data-provider-method/expected.html | 1 + .../await-data-provider-method/template.marko | 20 ++ .../await-data-provider-method/test.js | 0 .../await-timeout/expected-events.json | 54 ++-- .../components/beginAsync/renderer.js | 7 + .../components/hello/index.marko | 3 + .../components-await-beginAsync/expected.html | 1 + .../template.marko | 22 ++ .../components-await-beginAsync/test.js | 1 + .../components/hello/index.marko | 3 + .../components-await/expected.html | 1 + .../components-await/template.marko | 22 ++ .../async-render/components-await/test.js | 1 + test/util/runRenderTest.js | 8 +- 37 files changed, 683 insertions(+), 519 deletions(-) create mode 100644 src/taglibs/async/AsyncValue.js delete mode 100644 test/autotests/async-render/await-client-reorder-sync/expected-events-vdom.json create mode 100644 test/autotests/async-render/await-data-provider-method/expected.html create mode 100644 test/autotests/async-render/await-data-provider-method/template.marko create mode 100644 test/autotests/async-render/await-data-provider-method/test.js create mode 100644 test/autotests/async-render/components-await-beginAsync/components/beginAsync/renderer.js create mode 100644 test/autotests/async-render/components-await-beginAsync/components/hello/index.marko create mode 100644 test/autotests/async-render/components-await-beginAsync/expected.html create mode 100644 test/autotests/async-render/components-await-beginAsync/template.marko create mode 100644 test/autotests/async-render/components-await-beginAsync/test.js create mode 100644 test/autotests/async-render/components-await/components/hello/index.marko create mode 100644 test/autotests/async-render/components-await/expected.html create mode 100644 test/autotests/async-render/components-await/template.marko create mode 100644 test/autotests/async-render/components-await/test.js diff --git a/package.json b/package.json index d0c677f5f2..03896b43d8 100644 --- a/package.json +++ b/package.json @@ -75,7 +75,6 @@ "minimatch": "^3.0.2", "object-assign": "^4.1.0", "property-handlers": "^1.0.0", - "raptor-async": "^1.1.2", "raptor-json": "^1.0.1", "raptor-polyfill": "^1.0.0", "raptor-promises": "^1.0.1", diff --git a/src/runtime/html/AsyncStream.js b/src/runtime/html/AsyncStream.js index 42c83cfaca..d1237b8aff 100644 --- a/src/runtime/html/AsyncStream.js +++ b/src/runtime/html/AsyncStream.js @@ -15,19 +15,20 @@ function State(root, stream, writer, events) { this.writer = writer; this.events = events; - this.remaining = 0; - this.lastCount = 0; - this.last = undefined; // Array - this.ended = false; this.finished = false; - this.ids = 0; } -function AsyncStream(global, writer, state, shouldBuffer) { +function AsyncStream(global, writer, parentOut, shouldBuffer) { + + if (parentOut === null) { + throw new Error('illegal state'); + } var finalGlobal = this.attributes = global || {}; var originalStream; + var state; - if (state) { + if (parentOut) { + state = parentOut._state; originalStream = state.stream; } else { var events = finalGlobal.events /* deprecated */ = writer && writer.on ? writer : new EventEmitter(); @@ -48,6 +49,12 @@ function AsyncStream(global, writer, state, shouldBuffer) { this.stream = originalStream; this._state = state; + this._ended = false; + this._remaining = 1; + this._lastCount = 0; + this._last = undefined; // Array + this._parentOut = parentOut; + this.data = {}; this.writer = writer; writer.stream = this; @@ -137,7 +144,7 @@ var proto = AsyncStream.prototype = { ┗━━━━━┛ prevWriter → currentWriter → nextWriter */ var newWriter = new StringWriter(); - var newStream = new AsyncStream(this.global, currentWriter, state); + var newStream = new AsyncStream(this.global, currentWriter, this); this.writer = newWriter; newWriter.stream = this; @@ -152,7 +159,7 @@ var proto = AsyncStream.prototype = { var timeout; var name; - state.remaining++; + this._remaining++; if (options != null) { if (typeof options === 'number') { @@ -167,7 +174,7 @@ var proto = AsyncStream.prototype = { timeout = 0; } - state.lastCount++; + this._lastCount++; } name = options.name; @@ -194,11 +201,31 @@ var proto = AsyncStream.prototype = { parentOut: this }); - return newStream; + return newStream; + }, + + _doFinish: function() { + var state = this._state; + + state.finished = true; + + if (state.writer.end) { + state.writer.end(); + } else { + state.events.emit('finish', this.___getResult()); + } }, end: function(data) { - if (data) { + if (this._ended === true) { + return; + } + + this._ended = true; + + var remaining = --this._remaining; + + if (data != null) { this.write(data); } @@ -213,7 +240,7 @@ var proto = AsyncStream.prototype = { currentWriter.stream = null; // Flush the contents of nextWriter to the currentWriter - this.flushNext(currentWriter); + this._flushNext(currentWriter); /* ┏━━━━━┓ this ╵ nextStream ┃ ┃ ↓ ╵ ↓↑ @@ -221,18 +248,14 @@ var proto = AsyncStream.prototype = { ┃ ┃ ──────────────┴──────────────────────────────── ┗━━━━━┛ Flushed & garbage collected: nextWriter */ + var parentOut = this._parentOut; - var state = this._state; - - if (state.finished) { - return; - } - - var remaining; - - if (this === state.root) { - remaining = state.remaining; - state.ended = true; + if (parentOut === undefined) { + if (remaining === 0) { + this._doFinish(); + } else if (remaining - this._lastCount === 0) { + this._emitLast(); + } } else { var timeoutId = this._timeoutId; @@ -240,61 +263,32 @@ var proto = AsyncStream.prototype = { clearTimeout(timeoutId); } - remaining = --state.remaining; - } - - if (state.ended) { - if (!state.lastFired && (state.remaining - state.lastCount === 0)) { - state.lastFired = true; - state.lastCount = 0; - state.events.emit('last'); - } - if (remaining === 0) { - state.finished = true; - - if (state.writer.end) { - state.writer.end(); - } else { - state.events.emit('finish', this.___getResult()); - } + parentOut._handleChildDone(); + } else if (remaining - this._lastCount === 0) { + this._emitLast(); } } return this; }, - // flushNextOld: function(currentWriter) { - // if (currentWriter === this._state.writer) { - // var nextStream; - // var nextWriter = currentWriter.next; - // - // // flush until there is no nextWriter - // // or the nextWriter is still attached - // // to a branch. - // while(nextWriter) { - // currentWriter.write(nextWriter.toString()); - // nextStream = nextWriter.stream; - // - // if(nextStream) break; - // else nextWriter = nextWriter.next; - // } - // - // // Orphan the nextWriter and everything that - // // came before it. They have been flushed. - // currentWriter.next = nextWriter && nextWriter.next; - // - // // If there is a nextStream, - // // set its writer to currentWriter - // // (which is the state.writer) - // if(nextStream) { - // nextStream.writer = currentWriter; - // currentWriter.stream = nextStream; - // } - // } - // }, - - flushNext: function(currentWriter) { + _handleChildDone: function() { + var remaining = --this._remaining; + + if (remaining === 0) { + var parentOut = this._parentOut; + if (parentOut === undefined) { + this._doFinish(); + } else { + parentOut._handleChildDone(); + } + } else if (remaining - this._lastCount === 0) { + this._emitLast(); + } + }, + + _flushNext: function(currentWriter) { // It is possible that currentWriter is the // last writer in the chain, so let's make // sure there is a nextWriter to flush. @@ -324,50 +318,61 @@ var proto = AsyncStream.prototype = { on: function(event, callback) { var state = this._state; - if (event === 'finish' && state.finished) { + if (event === 'finish' && state.finished === true) { callback(this.___getResult()); - return this; + } else if (event === 'last') { + this.onLast(callback); + } else { + state.events.on(event, callback); } - state.events.on(event, callback); return this; }, once: function(event, callback) { var state = this._state; - if (event === 'finish' && state.finished) { + if (event === 'finish' && state.finished === true) { callback(this.___getResult()); - return this; + } else if (event === 'last') { + this.onLast(callback); + } else { + state.events.once(event, callback); } - state.events.once(event, callback); return this; }, onLast: function(callback) { - var state = this._state; + var lastArray = this._last; - var lastArray = state.last; + if (lastArray === undefined) { + this._last = [callback]; + } else { + lastArray.push(callback); + } - if (!lastArray) { - lastArray = state.last = []; - var i = 0; - var next = function next() { - if (i === lastArray.length) { - return; - } - var _next = lastArray[i++]; - _next(next); - }; + return this; + }, + + _emitLast: function() { + var lastArray = this._last; + + var i = 0; - this.once('last', function() { + function next() { + if (i === lastArray.length) { + return; + } + var lastCallback = lastArray[i++]; + lastCallback(next); + + if (lastCallback.length === 0) { next(); - }); + } } - lastArray.push(callback); - return this; + next(); }, emit: function(type, arg) { diff --git a/src/runtime/html/Template.js b/src/runtime/html/Template.js index 2c5aff7d3f..31d8d11c3b 100644 --- a/src/runtime/html/Template.js +++ b/src/runtime/html/Template.js @@ -33,7 +33,7 @@ class Readable extends stream.Readable { var data = this._d; var globalData = data && data.$global; var shouldBuffer = this._shouldBuffer; - var out = new AsyncStream(globalData, this, null, shouldBuffer); + var out = new AsyncStream(globalData, this, undefined, shouldBuffer); template.render(data, out); out.end(); } @@ -46,8 +46,8 @@ function Template(path, renderFunc, options) { this.meta = undefined; } -function createOut(globalData, parent, state, buffer) { - return new AsyncStream(globalData, parent, state, buffer); +function createOut(globalData, writer, parentOut, buffer) { + return new AsyncStream(globalData, writer, parentOut, buffer); } Template.prototype = { diff --git a/src/runtime/renderable.js b/src/runtime/renderable.js index 8f9e133646..e69d883a0f 100644 --- a/src/runtime/renderable.js +++ b/src/runtime/renderable.js @@ -116,7 +116,7 @@ module.exports = function(target, renderer) { finalOut = createOut( globalData, // global out, // writer(AsyncStream) or parentNode(AsyncVDOMBuilder) - null, // state + undefined, // parentOut shouldBuffer // ignored by AsyncVDOMBuilder ); } diff --git a/src/runtime/vdom/AsyncVDOMBuilder.js b/src/runtime/vdom/AsyncVDOMBuilder.js index 9c5e82a2fa..dbee0d62e0 100644 --- a/src/runtime/vdom/AsyncVDOMBuilder.js +++ b/src/runtime/vdom/AsyncVDOMBuilder.js @@ -10,31 +10,35 @@ var RenderResult = require('../RenderResult'); var defaultDocument = vdom.___defaultDocument; var morphdom = require('../../morphdom'); -var FLAG_FINISHED = 1; -var FLAG_LAST_FIRED = 2; var EVENT_UPDATE = 'update'; var EVENT_FINISH = 'finish'; function State(tree) { - this.___remaining = 1; + this.___events = new EventEmitter(); this.___tree = tree; - this.___last = null; - this.___lastCount = 0; - this.___flags = 0; + this.___finished = false; } -function AsyncVDOMBuilder(globalData, parentNode, state) { +function AsyncVDOMBuilder(globalData, parentNode, parentOut) { if (!parentNode) { parentNode = new VDocumentFragment(); } - if (state) { - state.___remaining++; + var state; + + if (parentOut) { + state = parentOut.___state; } else { state = new State(parentNode); } + this.___remaining = 1; + this.___lastCount = 0; + this.___last = null; + this.___parentOut = parentOut; + + this.data = {}; this.___state = state; this.___parent = parentNode; @@ -148,24 +152,63 @@ var proto = AsyncVDOMBuilder.prototype = { }, end: function() { - var state = this.___state; - this.___parent = undefined; - var remaining = --state.___remaining; + var remaining = --this.___remaining; + var parentOut = this.___parentOut; - if (!(state.___flags & FLAG_LAST_FIRED) && (remaining - state.___lastCount === 0)) { - state.___flags |= FLAG_LAST_FIRED; - state.___lastCount = 0; - state.___events.emit('last'); + if (remaining === 0) { + if (parentOut) { + parentOut.___handleChildDone(); + } else { + this.___doFinish(); + } + } else if (remaining - this.___lastCount === 0) { + this.___emitLast(); } + return this; + }, + + ___handleChildDone: function() { + var remaining = --this.___remaining; + if (remaining === 0) { - state.___flags |= FLAG_FINISHED; - state.___events.emit(EVENT_FINISH, this.___getResult()); + var parentOut = this.___parentOut; + if (parentOut) { + parentOut.___handleChildDone(); + } else { + this.___doFinish(); + } + } else if (remaining - this.___lastCount === 0) { + this.___emitLast(); } + }, - return this; + ___doFinish: function() { + var state = this.___state; + state.___finished = true; + state.___events.emit(EVENT_FINISH, this.___getResult()); + }, + + ___emitLast: function() { + var lastArray = this._last; + + var i = 0; + + function next() { + if (i === lastArray.length) { + return; + } + var lastCallback = lastArray[i++]; + lastCallback(next); + + if (!lastCallback.length) { + next(); + } + } + + next(); }, error: function(e) { @@ -191,12 +234,14 @@ var proto = AsyncVDOMBuilder.prototype = { if (options) { if (options.last) { - state.___lastCount++; + this.___lastCount++; } } + this.___remaining++; + var documentFragment = this.___parent.___appendDocumentFragment(); - var asyncOut = new AsyncVDOMBuilder(this.global, documentFragment, state); + var asyncOut = new AsyncVDOMBuilder(this.global, documentFragment, this); state.___events.emit('beginAsync', { out: asyncOut, @@ -206,7 +251,7 @@ var proto = AsyncVDOMBuilder.prototype = { return asyncOut; }, - createOut: function(callback) { + createOut: function() { return new AsyncVDOMBuilder(this.global); }, @@ -229,8 +274,10 @@ var proto = AsyncVDOMBuilder.prototype = { on: function(event, callback) { var state = this.___state; - if (event === EVENT_FINISH && (state.___flags & FLAG_FINISHED)) { + if (event === EVENT_FINISH && state.___finished) { callback(this.___getResult()); + } else if (event === 'last') { + this.onLast(callback); } else { state.___events.on(event, callback); } @@ -241,12 +288,14 @@ var proto = AsyncVDOMBuilder.prototype = { once: function(event, callback) { var state = this.___state; - if (event === EVENT_FINISH && (state.___flags & FLAG_FINISHED)) { + if (event === EVENT_FINISH && (state.___finished)) { callback(this.___getResult()); - return this; + } else if (event === 'last') { + this.onLast(callback); + } else { + state.___events.once(event, callback); } - state.___events.once(event, callback); return this; }, @@ -281,27 +330,14 @@ var proto = AsyncVDOMBuilder.prototype = { }, onLast: function(callback) { - var state = this.___state; + var lastArray = this._last; - var lastArray = state.___last; - - if (!lastArray) { - lastArray = state.___last = []; - var i = 0; - var next = function() { - if (i === lastArray.length) { - return; - } - var _next = lastArray[i++]; - _next(next); - }; - - this.once('last', function() { - next(); - }); + if (lastArray === undefined) { + this._last = [callback]; + } else { + lastArray.push(callback); } - lastArray.push(callback); return this; }, diff --git a/src/runtime/vdom/index.js b/src/runtime/vdom/index.js index 4f5bccfba5..dd77fbb704 100644 --- a/src/runtime/vdom/index.js +++ b/src/runtime/vdom/index.js @@ -22,8 +22,8 @@ function Template(path, func) { this.meta = undefined; } -function createOut(globalData, parent, state) { - return new AsyncVDOMBuilder(globalData, parent, state); +function createOut(globalData, parent, parentOut) { + return new AsyncVDOMBuilder(globalData, parent, parentOut); } var Template_prototype = Template.prototype = { diff --git a/src/taglibs/async/AsyncValue.js b/src/taglibs/async/AsyncValue.js new file mode 100644 index 0000000000..f7d3355615 --- /dev/null +++ b/src/taglibs/async/AsyncValue.js @@ -0,0 +1,121 @@ +var nextTick = require('../../runtime/nextTick'); + +function AsyncValue(options) { + /** + * The data that was provided via call to resolve(data). + * This property is assumed to be public and available for inspection. + */ + this.___value = undefined; + + /** + * The data that was provided via call to reject(err) + * This property is assumed to be public and available for inspection. + */ + this.___error = undefined; + + /** + * The queue of callbacks that are waiting for data + */ + this.___callbacks = undefined; + + /** + * The state of the data holder (STATE_INITIAL, STATE_RESOLVED, or STATE_REJECTED) + */ + this.___settled = false; +} + +function notifyCallbacks(asyncValue, err, value) { + var callbacks = asyncValue.___callbacks; + if (callbacks) { + // clear out the registered callbacks (we still have reference to the original value) + asyncValue.___callbacks = undefined; + + // invoke all of the callbacks and use their scope + for (var i = 0; i < callbacks.length; i++) { + // each callback is actually an object with "scope and "callback" properties + var callback = callbacks[i]; + callback(err, value); + } + } +} + +AsyncValue.prototype = { + /** + * Adds a callback to the queue. If there is not a pending request to load data + * and we have a "loader" then we will use that loader to request the data. + * The given callback will be invoked when there is an error or resolved data + * available. + */ + ___done: function(callback) { + + // Do we already have data or error? + if (this.___settled) { + // invoke the callback immediately + return callback(this.___error, this.___value); + } + + var callbacks = this.___callbacks || (this.___callbacks = []); + callbacks.push(callback); + }, + + /** + * This method will trigger any callbacks to be notified of rejection (error). + * If this data holder has a loader then the data holder will be returned to + * its initial state so that any future requests to load data will trigger a + * new load call. + */ + ___reject: function(err) { + if (this.___settled) { + return; + } + + // remember the error + this.___error = err; + + // Go to the rejected state if we don't have a loader. + // If we do have a loader then return to the initial state + // (we do this so that next call to done() will trigger load + // again in case the error was transient). + this.___settled = true; + + // always notify callbacks regardless of whether or not we return to the initial state + notifyCallbacks(this, err, null); + }, + + /** + * This method will trigger any callbacks to be notified of data. + */ + ___resolve: function (value) { + if (this.___settled) { + return; + } + + if (value && typeof value.then === 'function') { + var asyncValue = this; + + var finalPromise = value + .then( + function onFulfilled(value) { + nextTick(asyncValue.___resolve.bind(asyncValue, value)); + }, + function onRejected(err) { + nextTick(asyncValue.___reject.bind(asyncValue, err)); + }); + + if (finalPromise.done) { + finalPromise.done(); + } + } else { + // remember the state + this.___value = value; + + // go to the resolved state + this.___settled = true; + + // notify callbacks + notifyCallbacks(this, null, value); + } + } +}; + +module.exports = AsyncValue; diff --git a/src/taglibs/async/await-reorderer-tag.js b/src/taglibs/async/await-reorderer-tag.js index 55669a53e2..4d7efe2f9c 100644 --- a/src/taglibs/async/await-reorderer-tag.js +++ b/src/taglibs/async/await-reorderer-tag.js @@ -21,58 +21,50 @@ module.exports = function(input, out) { global.__awaitReordererInvoked = true; - - var asyncOut = out.beginAsync({ last: true, timeout: -1, name: 'await-reorderer' }); + out.onLast(function(next) { - var awaitContext = global.__awaitContext; + var awaitContext = global.___clientReorderContext; var remaining; // Validate that we have remaining instances that need handled - if (!awaitContext || !awaitContext.instances || !(remaining = awaitContext.instances.length)) { + if (!awaitContext || + !awaitContext.instances || + !(remaining = awaitContext.instances.length)) { asyncOut.end(); next(); return; } - var done = false; - function handleAwait(awaitInfo) { - awaitInfo.asyncValue.done(function(err, html) { - if (done) { - return; - } - - if (err) { - done = true; - return asyncOut.error(err); - } - - if (!global._afRuntime) { - asyncOut.write(clientReorder.getCode()); - global._afRuntime = true; - } - - asyncOut.write('' + - ''); - - awaitInfo.out.writer = asyncOut.writer; - - out.emit('await:finish', awaitInfo); - - out.flush(); - - if (--remaining === 0) { - done = true; - asyncOut.end(); - next(); - } - }); + awaitInfo.out.on('finish', function(result) { + if (!global._afRuntime) { + asyncOut.write(clientReorder.getCode()); + global._afRuntime = true; + } + + asyncOut.write('' + + ''); + + awaitInfo.out.writer = asyncOut.writer; + + out.emit('await:finish', awaitInfo); + + out.flush(); + + if (--remaining === 0) { + asyncOut.end(); + next(); + } + }) + .on('error', function(err) { + asyncOut.error(err); + }); } awaitContext.instances.forEach(handleAwait); @@ -86,4 +78,4 @@ module.exports = function(input, out) { // out-of-sync instances via an event delete awaitContext.instances; }); -}; \ No newline at end of file +}; diff --git a/src/taglibs/async/await-tag.js b/src/taglibs/async/await-tag.js index ed69ccc1b9..23b172cd14 100644 --- a/src/taglibs/async/await-tag.js +++ b/src/taglibs/async/await-tag.js @@ -1,12 +1,6 @@ 'use strict'; - -var AsyncValue = require('raptor-async/AsyncValue'); var isClientReorderSupported = require('./client-reorder').isSupported; -var nextTick = require('../../runtime/nextTick'); - -function isPromise(o) { - return o && typeof o.then === 'function'; -} +var AsyncValue = require('./AsyncValue'); function safeRenderBody(renderBody, targetOut, data) { try { @@ -16,87 +10,157 @@ function safeRenderBody(renderBody, targetOut, data) { } } -function promiseToCallback(promise, callback, thisObj) { - if (callback) { - var finalPromise = promise - .then(function(data) { - nextTick(callback.bind(this, null, data)); - }) - .then(null, function(err) { - nextTick(callback.bind(this, err)); - }); - - if (finalPromise.done) { - finalPromise.done(); - } - } - - return promise; -} - -function requestData(provider, args, callback, thisObj) { - if (isPromise(provider)) { - // promises don't support a scope so we can ignore thisObj - promiseToCallback(provider, callback); - return; - } +function requestData(provider, args, thisObj, timeout) { + var asyncValue = new AsyncValue(); if (typeof provider === 'function') { - var data = (provider.length === 1) ? + var callback = function(err, data) { + if (err) { + asyncValue.___reject(err); + } else { + asyncValue.___resolve(data); + } + }; + + var value = (provider.length === 1) ? // one argument so only provide callback to function call provider.call(thisObj, callback) : // two arguments so provide args and callback to function call provider.call(thisObj, args, callback); - if (data !== undefined) { - if (isPromise(data)) { - promiseToCallback(data, callback); - } - else { - callback(null, data); - } + if (value !== undefined) { + asyncValue.___resolve(value); } } else { // Assume the provider is a data object... - callback(null, provider); + asyncValue.___resolve(provider); + } + + if (timeout == null) { + timeout = 10000; } + + if (timeout > 0) { + let timeoutId = setTimeout(function() { + timeoutId = null; + var error = new Error('Timed out after ' + timeout + 'ms'); + error.code = 'ERR_AWAIT_TIMEDOUT'; + asyncValue.___reject(error); + }, timeout); + + asyncValue.___done(function(err, data) { + if (timeoutId != null) { + clearTimeout(timeoutId); + } + }); + } + + return asyncValue; } +const LAST_OPTIONS = { last: true, name: 'await:finish' }; + module.exports = function awaitTag(input, out) { - var dataProvider = input._dataProvider; + var arg = input.arg || {}; arg.out = out; var clientReorder = isClientReorderSupported && input.clientReorder === true && !out.isVDOM; - var asyncOut; - var timeoutId = null; + var name = input.name || input._name; var scope = input.scope || this; var method = input.method; - + var timeout = input.timeout; + var dataProvider = input._dataProvider; if (method) { dataProvider = dataProvider[method].bind(dataProvider); } + var asyncValue = requestData(dataProvider, arg, scope, timeout); + + if (asyncValue.___settled) { + // No point in using client-reordering if the data was fetched + // synchronously + clientReorder = false; + } + + var asyncOut; + var clientReorderContext; + var awaitInfo = { name: name, clientReorder: clientReorder, dataProvider: dataProvider }; + if (clientReorder) { + awaitInfo.after = input.showAfter; + + clientReorderContext = out.global.___clientReorderContext || + (out.global.___clientReorderContext = { + instances: [], + nextId: 0 + }); + + var id = awaitInfo.id = input.name || (clientReorderContext.nextId++); + var placeholderIdAttrValue = 'afph' + id; + + if (input.renderPlaceholder) { + out.write(''); + input.renderPlaceholder(out); + out.write(''); + } else { + out.write(''); + } + + // If `client-reorder` is enabled then we asynchronously render the await instance to a new + // "out" instance so that we can Write to a temporary in-memory buffer. + asyncOut = awaitInfo.out = out.createOut(); + + var oldEmit = asyncOut.emit; + + // Since we are rendering the await instance to a new and separate out, + // we want to proxy any child events to the main AsyncWriter in case anyone is interested + // in those events. This is also needed for the following events to be handled correctly: + // + // - await:begin + // - await:beforeRender + // - await:finish + // + asyncOut.emit = function(event) { + if (event !== 'finish' && event !== 'error') { + // We don't want to proxy the finish and error events since those are + // very specific to the AsyncWriter associated with the await instance + out.emit.apply(out, arguments); + } + + oldEmit.apply(asyncOut, arguments); + }; + + if (clientReorderContext.instances) { + clientReorderContext.instances.push(awaitInfo); + } + + out.emit('await:clientReorder', awaitInfo); + } else { + asyncOut = awaitInfo.out = out.beginAsync({ + timeout: 0, // We will use our code for controlling timeout + name: name + }); + } + var beforeRenderEmitted = false; out.emit('await:begin', awaitInfo); - function renderBody(err, data, renderTimeout) { - if (awaitInfo.finished) return; - - if (timeoutId) { - clearTimeout(timeoutId); - timeoutId = null; + function renderBody(err, data) { + if (awaitInfo.finished) { + return; } - var targetOut = awaitInfo.out = asyncOut || out; + if (err) { + awaitInfo.error = err; + } if (!beforeRenderEmitted) { beforeRenderEmitted = true; @@ -104,18 +168,18 @@ module.exports = function awaitTag(input, out) { } if (err) { - if (input.renderError) { + if (err.code === 'ERR_AWAIT_TIMEDOUT' && input.renderTimeout) { + input.renderTimeout(asyncOut); + } else if (input.renderError) { console.error('Await (' + name + ') failed. Error:', (err.stack || err)); - input.renderError(targetOut); + input.renderError(asyncOut); } else { - targetOut.error(err); + asyncOut.error(err); } - } else if (renderTimeout) { - renderTimeout(targetOut); } else { var renderBodyFunc = input.renderBody; if (renderBodyFunc) { - var renderBodyErr = safeRenderBody(renderBodyFunc, targetOut, data); + var renderBodyErr = safeRenderBody(renderBodyFunc, asyncOut, data); if (renderBodyErr) { return renderBody(renderBodyErr); } @@ -124,116 +188,31 @@ module.exports = function awaitTag(input, out) { awaitInfo.finished = true; - if (!clientReorder) { - out.emit('await:finish', awaitInfo); - } - - if (asyncOut) { + if (clientReorder) { asyncOut.end(); - - // Only flush if we rendered asynchronously and we aren't using - // client-reordering - if (!clientReorder) { + out.flush(); + } else { + // When using client reordering we want to delay + // this event until after the code to move + // the async fragment into place has been written + let asyncLastOut = asyncOut.beginAsync(LAST_OPTIONS); + asyncOut.onLast(function() { + var oldWriter = asyncOut.writer; + // We swap out the writer so that writing will happen to our `asyncLastOut` + // even though we are still passing along the original `asyncOut`. We have + // to pass along the original `asyncOut` because that has contextual + // information (such as the rendered UI components) + asyncOut.writer = asyncLastOut.writer; + out.emit('await:finish', awaitInfo); + asyncOut.writer = oldWriter; + asyncLastOut.end(); out.flush(); - } - } - } - - requestData(dataProvider, arg, renderBody, scope); - - if (!awaitInfo.finished) { - var timeout = input.timeout; - var renderTimeout = input.renderTimeout; - var renderPlaceholder = input.renderPlaceholder; - - if (timeout == null) { - timeout = 10000; - } else if (timeout <= 0) { - timeout = null; - } - - if (timeout != null) { - timeoutId = setTimeout(function() { - var message = 'Await (' + name + ') timed out after ' + timeout + 'ms'; - - awaitInfo.timedout = true; - - if (renderTimeout) { - console.error(message); - renderBody(null, null, renderTimeout); - } else { - renderBody(new Error(message)); - } - }, timeout); - } - - if (clientReorder) { - var awaitContext = out.global.__awaitContext || (awaitContext = out.global.__awaitContext = { - instances: [], - nextId: 0 }); - var id = awaitInfo.id = input.name || (awaitContext.nextId++); - var placeholderIdAttrValue = 'afph' + id; - - if (renderPlaceholder) { - out.write(''); - renderPlaceholder(out); - out.write(''); - } else { - out.write(''); - } - - var asyncValue = awaitInfo.asyncValue = new AsyncValue(); - - // If `client-reorder` is enabled then we asynchronously render the await instance to a new - // AsyncWriter instance so that we can Write to a temporary in-memory buffer. - asyncOut = awaitInfo.out = out.createOut(); - - awaitInfo.after = input.showAfter; - - var oldEmit = asyncOut.emit; - - // Since we are rendering the await instance to a new and separate out, - // we want to proxy any child events to the main AsyncWriter in case anyone is interested - // in those events. This is also needed for the following events to be handled correctly: - // - // - await:begin - // - await:beforeRender - // - await:finish - // - asyncOut.emit = function(event) { - if (event !== 'finish' && event !== 'error') { - // We don't want to proxy the finish and error events since those are - // very specific to the AsyncWriter associated with the await instance - out.emit.apply(out, arguments); - } - - oldEmit.apply(asyncOut, arguments); - }; - - asyncOut - .on('finish', function(result) { - asyncValue.resolve(result.getOutput()); - }) - .on('error', function(err) { - asyncValue.reject(err); - }); - - if (awaitContext.instances) { - awaitContext.instances.push(awaitInfo); - } - - out.emit('await:clientReorder', awaitInfo); - } else { - out.flush(); // Flush everything up to this await instance - asyncOut = awaitInfo.out = out.beginAsync({ - timeout: 0, // We will use our code for controlling timeout - name: name - }); + asyncOut.end(); } - } else if (clientReorder) { - // If the async fragment has finished synchronously then we still need to emit the `await:finish` event - out.emit('await:finish', awaitInfo); } + + + asyncValue.___done(renderBody); }; diff --git a/test/autotests/async-render/await-arg/template.marko b/test/autotests/async-render/await-arg/template.marko index b82ffa87df..7f5c945fd0 100644 --- a/test/autotests/async-render/await-arg/template.marko +++ b/test/autotests/async-render/await-arg/template.marko @@ -1,6 +1,36 @@ + +static var users = { + "0": { + name: "John B. Flowers", + occupation: "Clock repairer", + gender: "Male" + }, + "1": { + name: "Pamela R. Rice", + occupation: "Cartographer", + gender: "Female" + }, + "2": { + name: "Barbara C. Rigsby", + occupation: "Enrollment specialist", + gender: "Female" + }, + "3": { + name: "Anthony J. Ward", + occupation: "Clinical laboratory technologist", + gender: "Male" + } +}; + +static function userInfo(arg, done) { + setTimeout(function() { + done(null, users[arg.userId]); + }, 100); +}; +