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

Handling of top-level await #27

Closed
ethanresnick opened this issue Sep 18, 2023 · 3 comments
Closed

Handling of top-level await #27

ethanresnick opened this issue Sep 18, 2023 · 3 comments

Comments

@ethanresnick
Copy link

I'm not a modules expert by any means, and I know this is a very complex space, so I'm guessing there are good reasons for the current handling of top-level await within the dependency graph of a deferred import's module.

However, as a JS user, I find the current semantics pretty unexpected and error prone, assuming I'm understanding them correctly. In particular, it seems like an import marked with defer can still immediately trigger side effects, via this backdoor for eagerly evaluating the async portion of the dependency graph. To me, that is deeply unintuitive because, well, I'd expect a deferred import's evaluation to be (completely) deferred.

Is there anywhere I could read about the rationale, or alternatives considered, for the current handling of top-level await? How does the current approach compare to, say, throwing an error upon encountering an async module within the dependency graph of a deferred import?

@nicolo-ribaudo
Copy link
Member

As you pointed out, there are two possible alternatives:

  • eagerly evaluate async modules even in deferred modules subgraph
  • throw when using an async modules in a deferred modules subgraph

We picked the first option because it should be possible to add top-level await to a module without it being a breaking change (modulo global side effects, that with top-level await would obviously get delayed). With the current design, using top-level await has the only effect of "disabling" part of an optimization, for a specific module. If import defer threw whenever there is an async module, libraries would never be able to introduce new top-level awaits in non-major releases.

To me, that is deeply unintuitive because, well, I'd expect a deferred import's evaluation to be (completely) deferred.

Unfortunately this cannot be guaranteed even ignoring top-level await. When you do import defer * as b from "./b.js", there cannot be any guarantee that ./b.js is not evaluated because it might also be imported by some other module in your modules graph. This is a footgun we are aware of: adding defer to an import statement might be completely a no-op, and the language doesn't provide an easy way of debugging why: you need a separate tool that visualizes your modules graph and shows all the modules that import ./b.js.

For this reason, defer should be considered as a "best effort optimization" and not as a deferral guarantee: this also fits with the choice of eagerly evaluating asynchronous modules.

That said, now that we have import attributes I could see some platforms (or maybe the language itself one day) introducing something like

import defer * as b from "./b.js" with { ifAlreadyEvaluated: "throw" }

or

import defer * as b from "./b.js" with { onTopLevelAwait: "throw" }

@ethanresnick
Copy link
Author

We picked the first option because it should be possible to add top-level await to a module without it being a breaking change

Yeah, I see this. Of course, with the throw behavior, a module would be able to go from 1 to n top-level awaits without a breaking change; only adding the first top-level await would have to be considered breaking, and maybe that wouldn't be such a burdensome requirement?

However, on reflection, I'm convinced that the throw behavior is probably the wrong one anyway, because it limits the composability of different language features. Devs would now need to know that defer can't be used with an async module graph, even though it could still offer some benefit in those cases.

When you do import defer * as b from "./b.js", there cannot be any guarantee that ./b.js is not evaluated because it might also be imported by some other module in your modules graph.

I don't consider this unexpected. I think a natural mental model for devs to bring to import defer is that adding a deferred import shouldn't cause any evaluation/side effects, until the deferred import is used. If I'm understanding your scenario correctly, though, adding import defer * as b from "./b.js" wouldn't have caused b.js to be evaluated. Rather, it's simply that one deferred import can't prevent some other code from asking for b.js's immediate evaluation. To me, I think it's pretty unlikely that devs would expect a defer in one import to have that effect.

However, the partial immediate evaluation due to top-level await does mean that an import defer can actually cause some new/immediate side effects. So, if the current behavior is desirable (and I think I'm convinced), then maybe my discomfort here has more to do with the naming of the defer keyword.

Perhaps a keyword like partialDefer or deferSync or something along those lines would be clearer, and justify the extra length?

@nicolo-ribaudo
Copy link
Member

@ethanresnick I'm closing this issue, but let's keep discussing the name/keyword in #6 :)

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

2 participants