tc39/proposal-import-sync

Broaden the proposal

theScottyJam opened this issue · 5 comments

I can't help but wonder if there's a broader issue that could be solved here instead. This proposal gives the end-user the power to run what would otherwise be an asynchronous operation in a synchronous fashion, assuming it can actually happen synchronously. It limits this power to module-imports specifically, but what if we granted this power on everything?

What if we added to Promise.prototype a new method, now(). now() will attempt to synchronously extract the value of the promise. If the promise has not yet settled, an error will be thrown. If the promise has rejected, the error it was rejected with will be thrown.

console.log(Promise.resolve(2).now()); // => 2

Promise.reject(new Error('Whoops!')).now(); // => Synchronously throws the provided error

const wait = ms => new Promise(resolve => setTimeout(resolve, ms));
wait(1000).now(); // => Error: Promise not yet settled.

Why is this valuable?

  1. any function that caches the results of expensive calculations (not just import()) can now be used synchronously after the result has been cached. In fact, any task that is sometimes asynchronous, sometimes not, can be used synchronously with .now().
// Works
const myModule = import('./aModuleThatHasBeenImportedPreviously.js').now();

// Works
async function doThatThing({ fakeIt }) {
  if (fakeIt) {
    return calcFakeResult();
  } else {
    return await calcRealResult();
  }
}
doThatThing.now({ fakeIt: true }); // Works
  1. (You can ignore this one if you don't like it, but I think it's interesting) Heavy CPU-bounded tasks are often implemented with an asynchronous API so as to allow other tasks to run while it is processing. This seems to be especially common with parsers, such as an XML parsers. This makes a lot of sense, but it can also be quite abnoxious from an end-uesrs perspective if you know your XML document is very small. Perhaps as a wishful-thinking bonus item would be to introduce a new giveOthersATurn() type function that inserts an event into the macro-task queue, and when that event fires, the promise that giveOthersATurn() returns will settle. This giveOthersATurn() function would be the new standard way of breaking up large CPU-bound tasks. When now() gets called on a promise, and that promise isn't fulfilled because it's waiting on a giveOthersATurn() event, then some special behavior will happen - the event will be moved up in line and be fired immedietally (and any further giveOthersATurn() events that it may get stuck on will likewise be synchronously fired), until the promise eventually settles, or gets stuck on a different, actually asynchronous event (in which case, the call to .now() will throw).

Allowing a Promise to be synchronously introspectable directly seems hugely problematic.

Why's that?

That would allow you to react to an already-resolved promise in the same tick, which seems like it would unleash z̲̗̼͙̥͚͛͑̏a̦̟̳͋̄̅ͬ̌͒͟ļ̟̉͌ͪ͌̃̚g͔͇̯̜ͬ̒́o̢̹ͧͥͪͬ - see https://blog.izs.me/2013/08/designing-apis-for-asynchrony/ for more info

Gotcha.

The simple workaround could be to just throw if you attempt to extract a value from a just-settled promise - it could be the same error that you get when you extract a value from a promise that hasn't yet settled.

Promise.resolve(2).now(); // => Error

const p = Promise.resolve(2);
setTimeout(() => {
  console.log(p.now()); // => 2
});

This will ensure that the callstack always has a chance to be cleared up, releasing any potential locks.

@theScottyJam
Although this approach is possible, it is best to limit its use to reducing the number of times queueMicrotask is called internally.

{
  const {status, now, resolve} = withStatusResolvers();
  resolve(2);
  status(); // => "completed"
  now(); // => 2
}
{
  const {status, now, resolve, promise} = withStatusResolvers();
  setTimeout(() => resolve(3));
  status(); // => "pending"
  now(); // => Error
  await promise; // wait ... => 3
  now(); // => 3
}

/**
 * @template T
 */
function withStatusResolvers() {
  const {
    promise,
    resolve: resolve_,
    reject: reject_,
  } = Promise.withResolvers();
  let status = undefined;
  /** @type {T} */
  let result = undefined;
  /** @type {never} */
  let reason = undefined;
  /**
   * @param {T} r 
   */
  function resolve(r) {
    if (status) return;
    status = "fulfilled";
    result = r;
    resolve_(r);
  }
  /**
   * @param {never} r 
   */
  function reject(r) {
    if (status) return;
    status = "rejected";
    reason = r;
    reject_(r);
  }
  return {
    status() {
      return status ? "completed" : "pending";
    },
    now() {
      if (status === "fulfilled") return result;
      if (status === "rejected") throw reason;
      throw new Error("Not completed yet");
    },
    promise,
    resolve,
    reject,
  };
}