-
Notifications
You must be signed in to change notification settings - Fork 9
/
tracked-async-data.ts
320 lines (280 loc) · 9.69 KB
/
tracked-async-data.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
import { tracked } from '@glimmer/tracking';
import { dependentKeyCompat } from '@ember/object/compat';
import { deprecate } from '@ember/debug';
import { buildWaiter } from '@ember/test-waiters';
const waiter = buildWaiter('ember-async-data');
/** A very cheap representation of the of a promise. */
type StateRepr<T> =
| [tag: 'PENDING']
| [tag: 'RESOLVED', value: T]
| [tag: 'REJECTED', error: unknown];
// We only need a single instance of the pending state in our system, since it
// is otherwise unparameterized (unlike the resolved and rejected states).
const PENDING = ['PENDING'] as [tag: 'PENDING'];
// This class exists so that the state can be *wholly* private to outside
// consumers, but its tracked internals can be both read and written directly by
// `TrackedAsyncData` itself. The initial state of every `TrackedAsyncData` is
// `PENDING`, though it may immediately become resolved for some `Promise`
// instances (e.g. with a `Promise.resolve`).
class State<T> {
@tracked data: StateRepr<T> = PENDING;
}
// NOTE: this class is the implementation behind the types; the public types
// layer on additional safety. See below! Additionally, the docs for the class
// itself are applied to the export, not to the class, so that they will appear
// when users refer to *that*.
class _TrackedAsyncData<T> {
#token: unknown;
/**
@param promise The promise to load.
*/
constructor(data: T | Promise<T>) {
if (this.constructor !== _TrackedAsyncData) {
throw new Error('tracked-async-data cannot be subclassed');
}
if (!isPromiseLike(data)) {
this.#state.data = ['RESOLVED', data];
return;
}
const promise = data;
this.#token = waiter.beginAsync();
// Otherwise, we know that haven't yet handled that promise anywhere in the
// system, so we continue creating a new instance.
promise.then(
(value) => {
this.#state.data = ['RESOLVED', value];
waiter.endAsync(this.#token);
},
(error) => {
this.#state.data = ['REJECTED', error];
waiter.endAsync(this.#token);
},
);
}
/**
The internal state management for the promise.
- `readonly` so it cannot be mutated by the class itself after instantiation
- uses true native privacy so it cannot even be read (and therefore *cannot*
be depended upon) by consumers.
*/
readonly #state = new State<T>();
/**
* The resolution state of the promise.
*/
@dependentKeyCompat
get state(): State<T>['data'][0] {
return this.#state.data[0];
}
/**
The value of the resolved promise.
@note It is only valid to access `error` when `.isError` is true, that is,
when `TrackedAsyncData.state` is `"ERROR"`.
@warning You should not rely on this returning `T | null`! In a future
breaking change which drops support for pre-Octane idioms, it will *only*
return `T` and will *throw* if you access it when the state is wrong.
*/
@dependentKeyCompat
get value(): T | null {
deprecate(
"Accessing `value` when TrackedAsyncData is not in the resolved state is not supported and will throw an error in the future. Always check that `.state` is `'RESOLVED'` or that `.isResolved` is `true` before accessing this property.",
this.#state.data[0] === 'RESOLVED',
{
id: 'tracked-async-data::invalid-value-access',
for: 'ember-async-data',
since: {
available: '1.0.0',
enabled: '1.0.0',
},
until: '2.0.0',
},
);
return this.#state.data[0] === 'RESOLVED' ? this.#state.data[1] : null;
}
/**
The error of the rejected promise.
@note It is only valid to access `error` when `.isError` is true, that is,
when `TrackedAsyncData.state` is `"ERROR"`.
@warning You should not rely on this returning `null` when the state is not
`"ERROR"`! In a future breaking change which drops support for pre-Octane
idioms, it will *only* return `E` and will *throw* if you access it when
the state is wrong.
*/
@dependentKeyCompat
get error(): unknown {
deprecate(
"Accessing `error` when TrackedAsyncData is not in the rejected state is not supported and will throw an error in the future. Always check that `.state` is `'REJECTED'` or that `.isRejected` is `true` before accessing this property.",
this.#state.data[0] === 'REJECTED',
{
id: 'tracked-async-data::invalid-error-access',
for: 'ember-async-data',
since: {
available: '1.0.0',
enabled: '1.0.0',
},
until: '2.0.0',
},
);
return this.#state.data[0] === 'REJECTED' ? this.#state.data[1] : null;
}
/**
Is the state `"PENDING"`.
*/
@dependentKeyCompat
get isPending(): boolean {
return this.state === 'PENDING';
}
/** Is the state `"RESOLVED"`? */
@dependentKeyCompat
get isResolved(): boolean {
return this.state === 'RESOLVED';
}
/** Is the state `"REJECTED"`? */
@dependentKeyCompat
get isRejected(): boolean {
return this.state === 'REJECTED';
}
// SAFETY: casts are safe because we uphold these invariants elsewhere in the
// class. It would be great if we could guarantee them statically, but getters
// do not return information about the state of the class well.
toJSON(): JSONRepr<T> {
const { isPending, isResolved, isRejected } = this;
if (isPending) {
return { isPending, isResolved, isRejected } as JSONRepr<T>;
} else if (isResolved) {
return {
isPending,
isResolved,
value: this.value,
isRejected,
} as JSONRepr<T>;
} else {
return {
isPending,
isResolved,
isRejected,
error: this.error,
} as JSONRepr<T>;
}
}
toString(): string {
return JSON.stringify(this.toJSON(), null, 2);
}
}
/**
The JSON representation of a `TrackedAsyncData`, useful for e.g. logging.
Note that you cannot reconstruct a `TrackedAsyncData` *from* this, because it
is impossible to get the original promise when in a pending state!
*/
export type JSONRepr<T> =
| { isPending: true; isResolved: false; isRejected: false }
| { isPending: false; isResolved: true; isRejected: false; value: T }
| { isPending: false; isResolved: false; isRejected: true; error: unknown };
// The exported type is the intersection of three narrowed interfaces. Doing it
// this way has two nice benefits:
//
// 1. It allows narrowing to work. For example:
//
// ```ts
// let data = new TrackedAsyncData(Promise.resolve("hello"));
// if (data.isPending) {
// data.value; // null
// data.error; // null
// } else if (data.isPending) {
// data.value; // null
// data.error; // null
// } else if (data.isRejected) {
// data.value; // null
// data.error; // unknown, can now be narrowed
// }
// ```
//
// This dramatically improves the usability of the type in type-aware
// contexts (including with templates when using Glint!)
//
// 2. Using `interface extends` means that (a) it is guaranteed to be a subtype
// of the `_TrackedAsyncData` type, (b) that the docstrings applied to the
// base type still work, and (c) that the types which are *common* to the
// shared implementations (i.e. `.toJSON()` and `.toString()`) are shared
// automatically.
interface Pending<T> extends _TrackedAsyncData<T> {
state: 'PENDING';
isPending: true;
isResolved: false;
isRejected: false;
value: null;
error: null;
}
interface Resolved<T> extends _TrackedAsyncData<T> {
state: 'RESOLVED';
isPending: false;
isResolved: true;
isRejected: false;
value: T;
error: null;
}
interface Rejected<T> extends _TrackedAsyncData<T> {
state: 'REJECTED';
isPending: false;
isResolved: false;
isRejected: true;
value: null;
error: unknown;
}
/**
An autotracked `Promise` handler, representing asynchronous data.
Given a `Promise` instance, a `TrackedAsyncData` behaves exactly lik the
original `Promise`, except that it makes the state of the `Promise` visible
via tracked state, so you can check whether the promise is pending, resolved,
or rejected; and so you can get the value if it has resolved or the error if
it has rejected.
Every `Promise` in the system is guaranteed to be associated with at most a
single `TrackedAsyncData`.
## Example
```ts
import Component from '@glimmer/component';
import { cached } from '@glimmer/tracking';
import { inject as service } from '@ember/service';
import TrackedAsyncData from 'ember-async-data/tracked-async-data';
export default class SmartProfile extends Component<{ id: number }> {
@service store;
@cached
get someData() {
let recordPromise = this.store.findRecord('user', this.args.id);
return new TrackedAsyncData(recordPromise);
}
}
```
And a corresponding template:
```hbs
{{#if this.someData.isResolved}}
<PresentTheData @data={{this.someData.data}} />
{{else if this.someData.isPending}}
<LoadingSpinner />
{{else if this.someData.isRejected}}
<p>
Whoops! Looks like something went wrong!
{{this.someData.error.message}}
</p>
{{/if}}
```
*/
type TrackedAsyncData<T> = Pending<T> | Resolved<T> | Rejected<T>;
const TrackedAsyncData = _TrackedAsyncData as new <T>(
data: T | Promise<T>,
) => TrackedAsyncData<T>;
export default TrackedAsyncData;
/** Utility type to check whether the string `key` is a property on an object */
function has<K extends PropertyKey, T extends object>(
key: K,
t: T,
): t is T & Record<K, unknown> {
return key in t;
}
function isPromiseLike(data: unknown): data is PromiseLike<unknown> {
return (
typeof data === 'object' &&
data !== null &&
has('then', data) &&
typeof data.then === 'function'
);
}