Why do we get an uncaught error, when a promise is rejected before the rejection handler is locked in?

I am trying to catch the rejection reason of an already rejected promise, but I am getting an uncaught error.

I have spent some time studying promises, but still do not understand why the error occurs.

The purpose of this question is to understand the technical (ECMAScript spec) reason for why the uncaught error happens.

Consider the following code:

const getSlowPromise = () => {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve('Slow promise fulfillment value');
    }, 1000);
  });
};

const getFastPromise = () => {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      reject('Fast promise rejection reason');
    }, 200);
  });
};

const slowP = getSlowPromise();
const fastP = getFastPromise();

slowP
  .then((v) => {
    console.log(v);
    console.dir(fastP);
    return fastP;
  }, null)
  .then(
    (v) => {
      console.log(v);
    },
    (err) => {
      console.log('Caught error >>> ', err);
    },
  );

In the above playground, the rejected promise is caught by the last then-handler (the rejection handler).

Inspecting the playground with DevTools will reveal that an error is thrown when fastP rejects. I am not sure why that is the case.

This is my current understanding of what is going on under the hood, in the JS engine:

  1. When the JS engine executes the script, it synchronously creates promise objects for each chained then(onFulfilled, onRejected). Let’s call the promises then-promises, with onFulfilled and onRejected then-handlers.
  2. The onFulfilled and onRejected then-handlers are saved in each then-promise’s internal [[PromiseFulfillReactions]] and [[PromiseRejectReactions]] slots. That still happens synchronously, without touching the macrotask or microtask queues
  3. When fastP rejects, reject('Fast promise rejection reason') is added to the macrotask queue, which is passed to the call stack once empty and executed (after global execution context has finished)
  4. When reject('Fast promise rejection reason') runs on the call stack, it will set fastP‘s internal [[PromiseState]] slot to rejected and the [[PromiseResult]] to 'Fast promise rejection reason'
  5. It will then add any onRejected handler it has saved in [[PromiseRejectReactions]] to the microtask queue, which will be passed to the call stack and executed once the call stack is empty
  6. Here is where I fall off. The then-promises we discussed previously do have handlers saved in their [[PromiseFulfillReactions]] and [[PromiseRejectReactions]] slots, as far as I understand. However, the first then-promise in the code snippet (created by the first then()) does not yet know that it should be locked in to the fastP promise, because that onFulfilled then-handler has not run yet (then-promise mirrors promise returned from the corresponding then-handler).

What am I missing?