@yanfoo/suspense
TypeScript icon, indicating that this package has built-in type declarations

1.3.5 • Public • Published

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.

Package Sidebar

Install

npm i @yanfoo/suspense

Weekly Downloads

0

Version

1.3.5

License

MIT

Unpacked Size

24.6 kB

Total Files

6

Last publish

Collaborators

  • yanickrochon