Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Idea: Assimilate Native Promises Without Waiting #2

Open
jridgewell opened this issue Jun 7, 2022 · 5 comments
Open

Idea: Assimilate Native Promises Without Waiting #2

jridgewell opened this issue Jun 7, 2022 · 5 comments

Comments

@jridgewell
Copy link
Member

There are two techniques to fulfilling a promise with another promise:

  1. Adoption
  2. Assimilation

Promise adoption works through the public then API. Essentially, we call inner.then() and pass it a settleOuter function that will update the state of the outer promise with whatever it receives:

new Promise(res => {
  const inner = Promise.resolve(1);
  res(inner);
}).then(log);

// becomes
NEXT_TICK(() => {
  inner.then(settleOuter, )
});

// `inner.then(settleOuter)` becomes
NEXT_TICK(() => {
  settleOuter(inner.[[RESULT]]);
});

// `then(log)` becomes
NEXT_TICK(() => {
  log(outer.[[RESULT]]);
});

The approach that I presented just eliminates the first tick. We still call inner.then(), and that then will still wait a tick before calling out settleOuter function, and we still wait a tick before the outer chained log happens. So the log happens during tick 2 at the earliest:

// Presented in 2022-06 plenary
new Promise(res => {
  const inner = Promise.resolve(1);
  res(inner);
}).then(log);

// becomes
inner.then(settleOuter, )

// `inner.then(settleOuter)` becomes
NEXT_TICK(() => {
  settleOuter(inner.[[RESULT]]);
});

NEXT_TICK(() => {
  log(outer.[[RESULT]]);
});

Promise assimilation can eliminate 2 ticks. Essentially, it finds that the inner promise is a native promise, and it inspects its current state. If it's fulfilled, then we steal the fulfilled value. If it's rejected, we steal the rejection reason. And if it's pending, we forward our reactions to the inner promise so that they're fired as soon as the inner promise settles (instead of having the inner settle the outer, then firing after the outer settles).

new Promise(res => {
  const inner = Promise.resolve(1);
  res(inner);
}).then(log);

// becomes
switch (inner.[[STATE]]) {
  case FULFILLED:
    outer.[[STATE]] = FULFILLED;
    outer.[[RESULT]] === inner.[[RESULT]];
    break;

  case REJECTED:
    outer.[[STATE]] = REJECTED;
    outer.[[RESULT]] === inner.[[RESULT]];
    break;

  case PENDING:
    inner.[[REACTIONS]] = inner.[[REACTIONS]].concat(outer.[[REACTIONS]]);
    outer.[[REACTIONS]].push = (x) => inner.[[REACTIONS]].push(x);
    outer.[[REACTIONS]].length = 0;
}

NEXT_TICK(() => {
  log(outer.[[RESULT]]);
});

If the inner is fulfilled/rejected, the outer promise is immediately settled and the log can happen at tick 1. If the inner is pending, then we're able to fire the chained log the tick after the inner finally settles. Either way, it happens 1 tick earlier than what's possible through promise adoption.

This does have some observable differences if you chain fork a promise into multiple reactions, but this isn't super common. Most promises are straight .then().then().then(), and they'll only run through their straight chain faster.

@mhofman
Copy link
Member

mhofman commented Jun 7, 2022

Technically we could also implement full shortening through assimilation, which would detect promise cycles enforced by the spec.

@bakkot
Copy link

bakkot commented Jul 7, 2022

This does have some observable differences if you chain fork a promise into multiple reactions

Are there observable differences other than the number of ticks before certain tasks occur? I'm not seeing why there would be, offhand.

@jridgewell
Copy link
Member Author

The ordering of unresolved, forked promises can change:

var resolveA;
var a = new Promise((r) => {
    resolveA = r;
});

var resolveB;
var b = new Promise((r) => {
    resolveB = r;
});

a.then(() => console.log('first'));

b.then(() => console.log('second'));

resolveA(b); // A now depends on B's settling.

b.then(() => console.log('third'));

resolveB(); // B becomes settled.

With the ES2022 spec (and the proposed removal of the NEXT_TICK(() => then.call(…))), this logs:

  1. second
  2. third
  3. first

This is because A forms a chain on top of B once resolveA(b) happens.

  • first is chained on A.
  • second is chained on B.
  • resolveA(b) adds a settleA chain on B (B's chains are [second, settleA]).
  • third is chained on B (so the chains are [second, settleA, third]).
  • resolveB() settle's B, firing it's chains on the next tick (tick 1).
  • settleA() settles A, firing A's chains on the next tick (tick 2).

With assimilation, it now logs:

  1. second
  2. first
  3. third

This is because A offloads it's chained reactions to B once resolveA(b) happens.

  • first is chained on A.
  • second is chained on B.
  • resolveA(b) offloads A's chain onto B (so the current chains on B is [second, first]).
  • third is chained on B (so the chains are [second, first, third]).
  • resolveB() settle's B, firing it's chains on the next tick (tick 1).

@ggoodman
Copy link

Are there libraries that we know of that depend on the specific, relative ordering of Promise resolutions like that?

I want to believe that libraries 'should' treat Promise settles as something that happens in a future micro tick without having strong opinions about how should have elapsed or what the order should be relative to other micro tasks. That's probably naive so I wonder if there are concrete examples of this sort of implicit dependency in the wild.

@jridgewell
Copy link
Member Author

Are there libraries that we know of that depend on the specific, relative ordering of Promise resolutions like that?

Probably not libraries, but I've seen tests that fail if you remove a then() from a chain because then the test's manually constructed chain of N+1 promises will have different timing. Usually this is because the promise under test isn't publicly exposed and the test is trying to simulate it by inserting a certain amount of ticks before checking some state.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

4 participants