@yanfoo/suspense
v1.3.5
Published
Wait until something happens
Downloads
9
Maintainers
Readme
Suspense
Extending asynchronous programming with an encapsulated stateful Promise
helper.
Rationale
As useful as the Promise
implementation is for asynchronous programming, the current
interface lacks certain features that are essential in order to have better control over
what's waiting what, and how to manage various asynchronous states. For example :
1. A Promise
does not handle waiting interruption
Use case
// will wait as long as it takes until valueManager() returns something
// Ex: how can this be aborted from the current context?
const someValue = await valueManager();
// the program might never reach here and there's nothing we can do about it
Solution
const foo = new Suspense();
// ...
// 1. will wait at most 3000 ms for a value before returning with an error,
// but without changing the state of the suspense or interrupting it's
// pending execution
try {
const value = await foo.wait({ timeout: 3000 });
// NOTE : if foo.resolve("foo") is called somewhre,
// then the following conditions will be true :
// foo.pending === false
// foo.value === value === "foo"
} catch (error) {
console.error(error); // 'Suspense timeout (3000 ms)'
// foo.reject("too long!"); /* optionally reject this Suspense with an error message */
}
// 2. will wait until it is manually aborted externally without changing the
// state of the suspense. This is useful to manually abort waiting before
// the specified timeout
try {
const value = await foo.wait({ onWaiting: abort => setTimeout(abort, 200) })
} catch (error) {
console.error(error); // 'AbortError' | 'RejectedError'
// NOTE : if foo.reject() was called, then fo.wait() will throw a RejectedError
}
console.log(foo.pending);
// -> true (or false if either foo.reject() or foo.resolve() was called)
2. A Promise
cannot abort another Promise
Use case
const searchServices = [
searchServerA(query), // returns a Promise
searchServerB(query), // returns a Promise
];
// if any search resolves, then the other searche won't be aware of it
const result = await Promise.race(searchServices);
// At this point, how do we know wich Promise has resolved, and how
// to cancel the other? The interface does not expose the pending
// states.
Solution
const searchServices = [
searchServerA(query), // returns a Suspense
searchServerB(query), // returns a Suspense
];
// if any search resolve, all the others waiting will be aborted
const result = await Suspense.race(searchServices);
// notify other searches to stop and abort.
searchServices.forEach(search => search.pending && search.reject());
3. A Promise
does not expose it's internal pending state
Use case
const p = new Promise(promiseHandler);
// display a waiting timer periodically while we wait
const timer = setInterval(() => {
// there may be a race condition, here, because we cannot
// guarantee that p has been resolved, but has not actually
// returned, yet.
console.log("Still waiting...");
}, 1000);
try {
const value = await p;
console.log("Promise was resolved with", value);
} catch (error) {
// why are we throwing, now??
console.error(error);
} finally {
clearInterval(timer);
}
Solution
const s = new Suspense({ timeout: 1000 }); // do not wait for more than 1 sec each time
// we can use a normal loop now!
while (s.pending) {
try {
const value = await s.wait(); // will wait for 1 second
console.log("Suspense was resolved with", value);
} catch (error) {
// Proper state management. We could event have a counter
// and manually call s.reject() to break out of the loop.
if (error instanceof TimeoutError) {
console.log("Still waiting...");
} else if (error instanceof AbortError) {
console.log("Waiting has been aborted!");
break;
} else if (error instanceof RejectedError) {
console.log("Rejected!");
break;
}
}
}
4. A Promise
does not expose it's resolve
or reject
callbacks
Use case
const EMPTY_QUEUE = [];
// react example
const ConfirmProvider = ({ children }) => {
const [ confirmQueue, setConfirmQueue ] = useState(EMPTY_QUEUE);
const contextValue = useMemo(() => ({
confirm: async (message, { timeout }) => new Promise(resolve => {
setConfirmQueue(confirmQueue => [ ...confirmQueue, { message, timeout, resolve }]);
// At this point, we return from this anonymous function and lose
// reference to the Promise. We could also keep a reference to the
// reject callback, but the Promise instance is lost. Exposing a
// function argument to an external control is bad practice.
})
}), []);
const { message, timeout, resolve } = confirmQueue?.[0] || {};
const open = !!message;
const handleConfirm = useCallback(event => {
setConfirmQueue(confirmQueue => confirmQueue.length ? confirmQueue.slice(1) : confirmQueue);
resolve(event.confirmed);
}, [resolve]);
return (
<ConfirmContext.Provider={ contextValue }>
{ children }
<ConfirmDialog
open={ open }
message={ message }
timeout={ timeout }
onConfirm={ handleConfirm }
/>
</ConfirmContext.Provider>
);
}
Solution or alternative implementation
const EMPTY_QUEUE = [];
// react example
const ConfirmProvider = ({ children }) => {
const [ confirmQueue, setConfirmQueue ] = useState(EMPTY_QUEUE);
const contextValue = useMemo(() => ({
confirm: async options => {
const suspense = new Suspense({ name:'Confirm', ...options })
setConfirmQueue(confirmQueue => [ ...confirmQueue, { message, suspense }]);
// We do not lose the reference to the Promise since it is encapsulated within
// the wait method. The resolve or reject methods are external to the Suspense,
// so we do not need to break these functions to external controls.
return suspense.wait();
}
}), []);
const { message, suspense } = confirmQueue?.[0] || {};
const open = !!message;
const handleConfirm = useCallback(event => {
setConfirmQueue(confirmQueue => confirmQueue.length ? confirmQueue.slice(1) : confirmQueue);
suspense.resolve(event.confirmed);
}, [suspense]);
return (
<ConfirmContext.Provider={ contextValue }>
{ children }
<ConfirmDialog
open={ open }
message={ message }
timeout={ suspense?.timeout }
onConfirm={ handleConfirm }
/>
</ConfirmContext.Provider>
);
}
Usage
Exemple 1
Suspenses are useful when asynchronously initializing a module while still exporting a public API.
import Suspense, { TimeoutError } from '@yanfoo/suspense';
const cache = {}; // a map of Suspense instances
const setValue = (key, value) => {
if ((key in cache) && cache[key].pending) {
cache[key].resolve(value);
} else {
cache[key] = Suspense.resolved(value);
}
};
const getValue = async (key, defaultValue) => {
if (!(key in cache)) {
cache[key] = new Suspense({ timeout: 1000 });
}
return cache[key].wait().catch(error => {
if (error instanceof TimeoutError) {
console.log("Timeout, returning default");
return defaultValue; // ingore timeout, return default value
} else {
throw error; // re-throw so getValue() will throw
}
});
};
// simulate async iniitalization process...
setTimeout(() => setValue('foo', 'Hello!'), 500);
// preset value
setValue('bar', 'Awesome!');
getValue('missing', 'fallback').then(value => console.log("1.", value));
// wait for initialization to complete
getValue('foo').then(value => console.log("2.", value));
getValue('foo').then(value => console.log("3.", value));
getValue('foo').then(value => console.log("4.", value));
getValue('bar').then(value => console.log("5.", value));
console.log("Waiting...");
// -> Waiting...
// -> 5. Awesome!
// -> 2. Hello!
// -> 3. Hello!
// -> 4. Hello!
// -> Timeout, returning default
// -> 1. fallback
Example 2
Synchronize independent asynchronous objects by exposing the resources and properly disponsing them.
const res = [
new Suspense({ name: 'dbInstanceA' }),
new Suspense({ name: 'dbInstanceB' }),
// ...
];
const lock = new Suspense({ name: 'transactions' });
// NOTE : this function passes a connection used to execute query.
// When the function returns, the connection is released. If the
// function throws, the transaction is rolled back to ensure data
// integrity.
getTransaction('dbInstanceA', async connectionA => {
if (res[0].pending) {
res[0].resolve(connectionA);
await lock.wait(); // will throw if lock.reject() is called
}
}).catch(err => res[0].pending && res[0].reject(err));
// NOTE : the catch, here, is to prevent waiting if getTransaction
// errors and never calls the function, passing the connection
getTransaction('dbInstanceB', async connectionB => {
if (res[1].pending) {
res[1].resolve(connectionB);
await lock.wait(); // will throw if lock.reject() is called
}
}).catch(err => res[1].pending && res[1].reject(err));
// will jump to the catch section if either getTransaction fails
Suspense.all(res).thne(async ([ connectionA, connectionB ]) => {
// ... execute queries on both connectionA and connectionB ...
lock.resolve(); // release both connections now
}).catch(err => {
lock.reject(err); // trigger a rollback for any acquired connection
});
API
See TypeScript definitions for more information.