Skip to content

Commit 74b1254

Browse files
committed
Support waitFor()-type modifiers in async arrow transform
Update the async arrow task babel transform to allow the task function to be wrapped in any "modifier functions" such as `waitFor` from `@ember/test-waiters`, and preserve that wrapping in the output. Note that this is a little screwy from a types perspective because these modifier functions must by typed to accept an async function, but at runtime are actually passed generator functions. This is fine for `waitFor()` because it accepts both, and maybe this is the only modifier function we'll ever need to support, but it is a kinda funny caveat.
1 parent c6ed562 commit 74b1254

File tree

2 files changed

+142
-30
lines changed

2 files changed

+142
-30
lines changed

lib/babel-plugin-transform-ember-concurrency-async-tasks.js

+64-29
Original file line numberDiff line numberDiff line change
@@ -78,68 +78,103 @@ const TransformAsyncMethodsIntoGeneratorMethods = {
7878
}
7979

8080
// Thus far, we've established that value is `myTask = task(...)`.
81-
// Now we need to check if the last argument is an async ArrowFunctionExpress
81+
// Now we need to check if the last argument is an async ArrowFunctionExpress,
82+
// possibly wrapped in other modifier functions such as `waitFor()`
8283

83-
const maybeAsyncArrowPath = path.get(
84+
// If there are modifier functions applied, this will capture the
85+
// top-level one
86+
let rootModifierPath;
87+
88+
let maybeAsyncArrowPath = path.get(
8489
`value.arguments.${value.arguments.length - 1}`
8590
);
86-
if (!maybeAsyncArrowPath && !maybeAsyncArrowPath.node) {
87-
return;
88-
}
89-
const maybeAsyncArrow = maybeAsyncArrowPath.node;
90-
if (
91-
maybeAsyncArrow &&
92-
maybeAsyncArrow.type === 'ArrowFunctionExpression' &&
93-
maybeAsyncArrow.async
94-
) {
95-
convertFunctionExpressionIntoGenerator(
96-
maybeAsyncArrowPath,
97-
state,
98-
factoryFunctionName
99-
);
91+
while (maybeAsyncArrowPath && maybeAsyncArrowPath.node) {
92+
const maybeAsyncArrow = maybeAsyncArrowPath.node;
93+
94+
if (
95+
maybeAsyncArrow.type === 'ArrowFunctionExpression' &&
96+
maybeAsyncArrow.async
97+
) {
98+
// It's an async arrow function, so convert it
99+
convertFunctionExpressionIntoGenerator(
100+
maybeAsyncArrowPath,
101+
rootModifierPath,
102+
state,
103+
factoryFunctionName
104+
);
105+
break;
106+
} else if (maybeAsyncArrow.type === 'CallExpression') {
107+
// It's a call expression, so save it as the modifier functions root
108+
// if we don't already have one and then traverse into it
109+
rootModifierPath = rootModifierPath || maybeAsyncArrowPath;
110+
maybeAsyncArrowPath = maybeAsyncArrowPath.get('arguments.0');
111+
} else {
112+
break;
113+
}
100114
}
101115
}
102116
},
103117
};
104118

105119
function convertFunctionExpressionIntoGenerator(
106-
path,
120+
taskFnPath,
121+
rootModifierPath,
107122
state,
108123
factoryFunctionName
109124
) {
110-
if (path && path.node.async) {
111-
if (isArrowFunctionExpression(path)) {
125+
if (taskFnPath && taskFnPath.node.async) {
126+
if (isArrowFunctionExpression(taskFnPath)) {
112127
// At this point we have something that looks like
113128
//
114129
// foo = task(this?, {}?, async () => {})
115130
//
131+
// or (if there are modifier functions applied)
132+
//
133+
// foo = task(this?, {}?, modifier1(modifier2(async () => {})))
134+
//
116135
// and we need to convert it to
117136
//
118137
// foo = buildTask(contextFn, options | null, taskName, bufferPolicyName?)
119138
//
120139
// where conextFn is
121140
//
122141
// () => ({ context: this, generator: function * () { ... } })
142+
//
143+
// or (if there are modifier functions applied)
144+
//
145+
// () => ({ context: this, generator: modifier1(modifier2(function * () { ... } })))
146+
147+
// Before we start moving things around, let's save off the task()
148+
// CallExpression path
149+
const taskPath = (rootModifierPath || taskFnPath).parentPath;
123150

124151
// Replace the async arrow fn with a generator fn
125-
let asyncArrowFnBody = path.node.body;
152+
let asyncArrowFnBody = taskFnPath.node.body;
126153
if (asyncArrowFnBody.type !== 'BlockStatement') {
127154
// Need to convert `async () => expr` with `async () => { return expr }`
128155
asyncArrowFnBody = blockStatement([returnStatement(asyncArrowFnBody)]);
129156
}
130157

131158
const taskGeneratorFn = functionExpression(
132-
path.node.id,
133-
path.node.params,
159+
taskFnPath.node.id,
160+
taskFnPath.node.params,
134161
asyncArrowFnBody,
135162
true
136163
);
164+
taskFnPath = taskFnPath.replaceWith(taskGeneratorFn)[0];
137165

138166
const contextFn = arrowFunctionExpression(
139167
[],
140168
objectExpression([
141169
objectProperty(identifier('context'), thisExpression()),
142-
objectProperty(identifier('generator'), taskGeneratorFn),
170+
objectProperty(
171+
identifier('generator'),
172+
// We've swapped out the task fn for a generator function, possibly
173+
// inside some modifier functions. Now we want to move that whole
174+
// tree, including any modifier functions, into this generator
175+
// property.
176+
(rootModifierPath || taskFnPath).node
177+
),
143178
])
144179
);
145180

@@ -152,15 +187,15 @@ function convertFunctionExpressionIntoGenerator(
152187
);
153188
}
154189

155-
const originalArgs = path.parentPath.node.arguments;
190+
const originalArgs = taskPath.node.arguments;
156191

157192
// task(this, async() => {}) was the original API, but we don't actually
158193
// need the `this` arg (we determine the `this` context from the contextFn async arrow fn)
159194
if (originalArgs[0] && originalArgs[0].type === 'ThisExpression') {
160195
originalArgs.shift();
161196
}
162197

163-
const taskName = extractTaskNameFromClassProperty(path);
198+
const taskName = extractTaskNameFromClassProperty(taskPath);
164199
let optionsOrNull;
165200

166201
// remaining args should either be [options, async () => {}] or [async () => {}]
@@ -192,7 +227,7 @@ function convertFunctionExpressionIntoGenerator(
192227
]
193228
);
194229

195-
let newPath = path.parentPath.replaceWith(buildTaskCall)[0];
230+
let newPath = taskPath.replaceWith(buildTaskCall)[0];
196231
newPath.traverse({
197232
FunctionExpression(path) {
198233
if (!path.node.generator) {
@@ -224,11 +259,11 @@ const TransformAwaitIntoYield = {
224259
* in this method we extract the name from the ClassProperty assignment so that we can pass it in
225260
* to the options hash when constructing the Task.
226261
*
227-
* @param {babel.NodePath<babel.types.ArrowFunctionExpression>} asyncArrowFnPath
262+
* @param {babel.NodePath<babel.types.CallExpression>} taskPath
228263
* @returns {string | null}
229264
*/
230-
function extractTaskNameFromClassProperty(asyncArrowFnPath) {
231-
const maybeClassPropertyPath = asyncArrowFnPath.parentPath.parentPath;
265+
function extractTaskNameFromClassProperty(taskPath) {
266+
const maybeClassPropertyPath = taskPath.parentPath;
232267
if (
233268
maybeClassPropertyPath &&
234269
maybeClassPropertyPath.node.type === 'ClassProperty'

tests/integration/async-arrow-task-test.js

+78-1
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,14 @@ import { module, test } from 'qunit';
22
import { setupRenderingTest } from 'ember-qunit';
33
// eslint-disable-next-line ember/no-computed-properties-in-native-classes
44
import { computed, set } from '@ember/object';
5-
import { click, render, settled } from '@ember/test-helpers';
5+
import {
6+
click,
7+
getSettledState,
8+
render,
9+
settled,
10+
waitUntil,
11+
} from '@ember/test-helpers';
12+
import { waitFor } from '@ember/test-waiters';
613
import { hbs } from 'ember-cli-htmlbars';
714
import {
815
task,
@@ -142,6 +149,76 @@ module('Integration | async-arrow-task', function (hooks) {
142149
await finishTest(assert);
143150
});
144151

152+
test('modifiers', async function (assert) {
153+
let modifier1Called = false;
154+
let modifier2Called = false;
155+
156+
function modifier1(fn) {
157+
return function* (...args) {
158+
modifier1Called = true;
159+
yield* fn(args);
160+
};
161+
}
162+
function modifier2(fn) {
163+
return function* (...args) {
164+
modifier2Called = true;
165+
yield* fn(args);
166+
};
167+
}
168+
169+
this.owner.register(
170+
'component:test-async-arrow-task',
171+
class extends TestComponent {
172+
myTask = task(
173+
this,
174+
modifier1(
175+
modifier2(async (arg) => {
176+
return arg;
177+
})
178+
)
179+
);
180+
}
181+
);
182+
183+
await render(hbs`<TestAsyncArrowTask />`);
184+
await click('button#start');
185+
assert.true(modifier1Called);
186+
assert.true(modifier2Called);
187+
});
188+
189+
test('waitFor modifier', async function (assert) {
190+
assert.expect(9);
191+
192+
let { promise, resolve } = defer();
193+
194+
this.owner.register(
195+
'component:test-async-arrow-task',
196+
class extends TestComponent {
197+
myTask = task(
198+
this,
199+
waitFor(async (arg) => {
200+
set(this, 'resolved', await promise);
201+
assert.strictEqual(this.myTask.name, 'myTask');
202+
return arg;
203+
})
204+
);
205+
}
206+
);
207+
208+
await render(hbs`<TestAsyncArrowTask />`);
209+
click('button#start');
210+
await waitUntil(() => this.element.textContent.includes('Running!'));
211+
212+
assert.true(getSettledState().hasPendingWaiters);
213+
214+
resolve('Wow!');
215+
await settled();
216+
217+
assert.false(getSettledState().hasPendingWaiters);
218+
219+
await finishTest(assert);
220+
});
221+
145222
test('dropTask and other shorthand tasks (with `this` arg)', async function (assert) {
146223
assert.expect(13);
147224

0 commit comments

Comments
 (0)