Closed ethanresnick closed 10 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" }
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 await
s 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?
@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 :)
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 adefer
red 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?