tc39 / proposal-defer-import-eval

A proposal for introducing a way to defer evaluate of a module
https://tc39.es/proposal-defer-import-eval
MIT License
208 stars 12 forks source link

Handling of top-level await #27

Closed ethanresnick closed 10 months ago

ethanresnick commented 11 months ago

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 commented 11 months ago

As you pointed out, there are two possible alternatives:

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 commented 11 months ago

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 commented 10 months ago

@ethanresnick I'm closing this issue, but let's keep discussing the name/keyword in https://github.com/tc39/proposal-defer-import-eval/issues/6 :)