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

Extensions to the constructor #29

Closed
legendecas opened this issue Mar 8, 2023 · 14 comments · Fixed by #54
Closed

Extensions to the constructor #29

legendecas opened this issue Mar 8, 2023 · 14 comments · Fixed by #54

Comments

@legendecas
Copy link
Member

I've been thinking about 2 extensions to the constructor:

  1. A name string
  2. A defaultValue value

The name string is intended to aid debugging in dev-tools. I imagine they might want to display the global context mapping, and it'd be more helpful if we could annotate that list in some way. This could be stored on the symbol's description.

The defaultValue would be returned instead of undefined whenever the context hasn't been run in this call stack. This is sometimes useful in React.

Originally posted by @jridgewell in #28 (comment)

@kamilogorek
Copy link
Member

+1 on the name property, which I think could be optional.
However, I agree that defaultValue can be a foot gun. And because AsyncContext is extensible, it could be achieved by subclassing the constructor in case someone decides that they want to override the default value.

@jamiebuilds
Copy link
Member

One use case for having a default value is for testing "emptiness"

Often for shared APIs where a caller can give me any value (including undefined), I will have the default set to something like Symbol("empty") so that I know for certain what the caller was doing. For example, if they explicitly pass me undefined I may report that as a bug.

let empty = Symbol('empty')
let context = new AsyncContext("context", empty)

function fn() {
  let value = context.get()
  if (value === empty) {
    throw new Error("Must be inside context") // More specific error
  }
  if (typeof value === "undefined") { 
    throw new Error("Context value is undefined")
  }
  // ...
}

let dict = {}
context.run(dict[nonExistentKey], () => {
  fn()
})

@hax
Copy link
Member

hax commented Mar 23, 2023

If name is only for debugging, I think devtools could introduce some magic to automatically get the name from declaration?

let foo = new AsyncContext() // magically have the name "foo"

@hax
Copy link
Member

hax commented Mar 23, 2023

However, I agree that defaultValue can be a foot gun.

Not clear why it's footgun?

And because AsyncContext is extensible, it could be achieved by subclassing the constructor in case someone decides that they want to override the default value.

It's complex to use subclassing to achieve that. We at least need a method/getter (or field?) to make it easy:

let x = new class extends AsyncContext { get initValue() { return xxx } }

@jridgewell
Copy link
Member

It's complex to use subclassing to achieve that. We at least need a method/getter (or field?) to make it easy:

That's a good point. It's a little complex to detect whether a value is implicitly undefined, or explicitly undefined, and needs to handle nested calls:

class AsyncContextWithDefault {
  #count = 0;
  #defaultValue;
  constructor(defaultValue) {
    this.#defaultValue = defaultValue;
  }

  run(...args) {
    try {
      this.#count++;
      return super.run(...args);
    } finally {
      this.#count--;
    }
  }

  get() {
    if (this.#count === 0) {
      return this.#defaultValue;
    }

    return super.get();
  }
}

@hax
Copy link
Member

hax commented Mar 24, 2023

It's a little complex to detect whether a value is implicitly undefined, or explicitly undefined

Yeah, I really hope we can go back to the early days of JS, and make the language don't differentiate implicitly undefined or explicitly undefined, which would make our life much easier 🤓

@ljharb
Copy link
Member

ljharb commented Mar 24, 2023

that's been differentiable since the beginning :-)

@hax
Copy link
Member

hax commented Mar 25, 2023

I found that Python equivalent API contextvar also have name and defaultValue. One notable behavior is if no defaultValue provided, get() would throw LookupError instead of give None. In JS, it will be throwing ReferenceError instead of undefined.

@kamilogorek
Copy link
Member

Not clear why it's footgun?

I said it can be a foot gun, but it's all about the end user's understanding of shared references in this case.

const defaultValue = {
  transaction: new Tracing.Transaction({ op: 'logger' })
};
const context = new AsyncContext({ name: 'tracingContext', defaultValue });

function logWithTracing() {
  context.run(async () => {
    const ctx = context.get();

    const id = await getTransactionId(); // do some async stuff
    ctx.transaction.setTag('id', id);

    await someAsyncOp(ctx.transaction); // do some async stuff
    transaction.finish();
  });
}

logWithTracing(1);
logWithTracing(2);

This would override the id tag, as the transaction is shared across all runs, and when finalized, it's not deterministic which value will be sent alongside the transaction itself.

@mhofman
Copy link
Member

mhofman commented Apr 18, 2023

For the default value, should there be a .has() method to test whether we are in a .run() or not? That would make implementing a default value on top more straightforward.

@jridgewell
Copy link
Member

During today's meeting, we decided to accept both name and default via an options bag to the constructor.

name will have an getter to fetch the name value, but default will not be directly exposed with an accessor (it's exposed by .get() when in the default state).

We discussed whether a .has() method would suffice instead of a default value, but we don't have any other usecases for .has() besides manually implementing default. Because it's more ergonomic to just implement default, we decided to do that.

@mhofman
Copy link
Member

mhofman commented May 2, 2023

Default would be problematic in the idea I have to track both sides of async operations (resolution side in addition of the subscription side like today). I suppose however that we can always add a .has() later in that case.

@jridgewell
Copy link
Member

Default would be problematic in the idea I have to track both sides of async operations (resolution side in addition of the subscription side like today).

Can you explain what you mean? I don't understand what resolution and subscription are.

@mhofman
Copy link
Member

mhofman commented May 3, 2023

I don't understand what resolution and subscription are.

There are 3 phases to a promise:

  • init when the promise is created
  • resolution when the resolvers are invoked
  • subscriptions, when the then is called on the promise to register a reaction

I'm interested in learning about context data associated to the resolution phase along the context data of the subscription phase we currently have.

However I retract my observation that a default value would be incompatible with my use case, since I expect having to introduce an alternative/extension to .get() to learn about the multiple context data anyway.

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

Successfully merging a pull request may close this issue.

7 participants