Search code examples
javascriptpromisethenable

In JavaScript, is there a way to create a custom thenable that automatically triggers some code after it has been awaited?


Consider the following code:

async function test(label) {
    console.log(`BEFORE: ${label}`);
    await waitForSomething(label);
    console.log(`AFTER: ${label}`);
}

function waitForSomething(label) {
    return {
        then(onFulfilled) {
            console.log(`FULFILL: ${label}`);
            onFulfilled();
        }
    }
}

console.log('START');
test('A');
test('B');
console.log('END');

It gives the following output:

START
BEFORE: A
BEFORE: B
END
FULFILL: A
FULFILL: B
AFTER: A
AFTER: B

I would like AFTER A to trigger immediately after FULFILL A, in a synchronous way, which would give:

START
BEFORE: A
BEFORE: B
END
FULFILL: A
AFTER: A
FULFILL: B
AFTER: B

The idea is that I'm writting a library with a global state that I want to stay consistent accross a chain of asynchronous calls written by the user. Because multiple chains may be running at the same time, I need to run some code before a call chain "resumes". I understand it may seem like a weird or bad idea at first glance, but this is a separate question. Is there a way to do that?

I've thought of returning an object with something like an unwrap method that would trigger the code, like:

(await waitForSomething(label)).unwrap();

But this forces the user to use unwrap() on all relevant calls, which is tedious. Moreover they may forget to do it and run into unexpected issues without understanding why.

EDIT: I will expand on the motivation behind this a bit further. Basically, the idea is to avoid having to pass a state parameter all the way down to the chain and have it accessible as a global variable.

Instead of doing:

async function test(state) {
    let input = await waitForUserInput();
    doSomethingWith(state, input);
}

function doSomethingWith(state, input) {
    // ...
}

I could do:

async function test() {
    let input = await waitForUserInput();
    doSomethingWith(input);
}

function doSomethingWith(input) {
    let state = MyLib.state;
    // ...
}

This allows to avoid having to carry state accross every single function call triggered by test. However since some of these functions are async, and since multiple of them can run at the same time, and since there is a different state for each root function (such as test), I need to set MyLib.state to the right value when an async function returns. Again, this is in the context of a library: the library provides the state to the test function and runs it, but test itself is written by the user.

EDIT 2: As pointed by user3840170, it turns out what I need is https://github.com/tc39/proposal-async-context/, which is only at stage 2 of the proposal. So I guess that's just not possible for now.

EDIT 3 (SOLUTION): Alexander Nenashev suggested using queueMicrotask, which seems to do the trick! I've implemented it in my lib and so far it works as expected. Below the final code that produces the expected output:

async function test(label) {
    console.log(`BEFORE: ${label}`);
    await waitForSomething(label);
    console.log(`AFTER: ${label}`);
}

function waitForSomething(label) {
    return {
        then(onFulfilled) {
            queueMicrotask(() => console.log(`FULFILL: ${label}`));
            onFulfilled();
        }
    }
}

console.log('START');
test('A');
test('B');
console.log('END');


Solution

  • I guess the goal isn't making the log in your desired order, but rather to preserve a state in an async call chain. You can queue a microtask restoring the state right after await. I guess a more elaborated solution is needed to cover all your needs:

    async function test(label) {
        console.log(`BEFORE: ${state}`);
        await waitForSomething();
        console.log(`AFTER: ${label} => ${state}`);
        await waitForSomething();
        console.log(`AFTER 2: ${label} => ${state}`);
    }
    
    function waitForSomething() {
        let cached = state;
        return {
            then(onFulfilled) {
                setTimeout(() => {
                  console.log(`FULFILL: ${cached}`);
                  // call this before the callback, so the code after `await` would receive the proper state
                  queueMicrotask(() => state = cached);
                  onFulfilled();
                }, Math.random()*1000);
            }
        }
    }
    
    console.log('START');
    let state = 'A'; // your MyLib.state 
    test('A');
    state = 'B';
    test('B');
    state = 'C';
    test('C');
    console.log('END');
    .as-console-wrapper{max-height:100% !important}