-
Notifications
You must be signed in to change notification settings - Fork 1.3k
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
imperative imports #368
Comments
Does this mean we must wait on the event loop to turn to finish an import sometimes (depending if the dependency is async/sync)? |
The more I think about this, the more I'm concerned that top-level await would actually make interop with node harder. And if this makes node interop harder instead of easier, the declarative imports comes out a much bigger win. |
Also as a bit of anecdote, I had added top-level yield to luvit modules (a lua clone of node.js) by re-implementing the require system in lua. After several years, only a tiny fraction of users actually took advantage of the edge case you mentioned (namely my lit project). Eventually I decided to drop the hack to regain the more valuable interop with native lua require. The change to my code was painful, but quite straightforward luvit/lit@9b7ad7a. If I could go back, I would have never added the ability to yield at the top level during require. |
@bmeck this doesn't add considerable implications on the proposal to solve the interop with node, we talked about it, we should be good either way. |
@dherman The top-level await issues seem particular concerning as not really fully worked out. Regarding, Node familiarity for the imperative model. Are the sorts of circularites where it makes a difference really very common? Aren't we really talking about initialization dependencies that involve multiple export/import bindings which really isn't something that the current node modules doesn't actually support. Finally, I assume that in you imperative examples where you say "SYNCHRONIZATION POINT" all you really mean is run the other modules initialization (to completion) if it hasn't already run. Rather than some sort of corountine-like back and forth transfer of control. If so, then you can still get ordering related TDZ initialization failures. |
@creationix We already have a good interop story for top-level await with Node. (Interop is a key constraint and not something to casually drop!) As @caridy says, the interop questions with top-level await are orthogonal to this issue. |
I was definitely trying to be balanced and bring out all the arguments I could think of, but I explained why I considered the arguments for declarative to be weak. If you believe they're stronger, it would help to explain which arguments you think are stronger than interop with Node and why.
Hm, no, not if I understand what you're saying -- if you step through the semantics of imperative import, you'll see that whichever module starts executing first, it will start executing the other one when it hits the |
Assuming the interop story is solved with top-level await: As a long-time node user, I would much prefer to gain the advantages noted for declarative imports. I place very little value on familiarity with the problematic semantics of node's module system. I'm glad interop is top priority, but aside from that, easier refactoring and simpler spec are much more important than keeping semantics problematic. |
The refactoring difference is quite minimal, which is why I don't weight that argument heavily. In practice people move around their top-level
Hm, but how is the semantics "problematic"? The extra spec complexity is really not large here (it's probably not much more than adding an evaluation semantics to ImportStatement and coordinating that with ModuleEvaluation), and regardless, spec complexity is often not at all in direct correlation with cognitive complexity for programmers. Consider teaching a newbie how the semantics works: it's a lot easier to say "everything runs top to bottom" than to say "most things run top to bottom but imports are actually hoisted and executed early." |
Well yes, that's really what top-level await is for: it's a mechanism for standardizing modular, asynchronous, blocking startup logic. It's intended to block its dependents. Of course, Of course, if you want to kick off some asynchronous logic at startup and you don't want to block the world, you just don't use top-level await; you can do all the same things you can do today. But this is getting a bit off-topic -- I should probably do a gist to explain the story of top-level await as I see it so far. |
I think all of the arguments in favor to the declarative approach are strong. It's the imperative arguments that I found weak. You didn't actually identify any actual interop problems with node. You just postulated that there is a likelhood of such problems because of the difference in semantics. But you don't provide any actual examples of such. In particular, AFAIK, node doesn't have any way to declarative define circular import/export dependencies. If that is correct, then we aren't really talking about actual interop relating to existing code. Are we? Presumably, techniques used by node programmer to imperatively establish non-declarative dependencies between such modules would continue to work with the ES6 declarative model, as long as those techniques are operating at the property level (like in node) rather than the binding level. Is your concern that node programmers will try to write new ES6 modules but won't fully understand them and run into unexpected initialization issues because they continue to follow Node patterns? If so, that's not an actual interop issue but rather a usability issue. With thinking about, but a new system does require new learning. Finally, as you show in your "increased impressibility" example, it takes really careful thought to construct such examples where it actually makes a difference. I shudder at what it would take to meaning try to do this with more than two imported/exported symbols or more than two modules. |
Just to add another point of clarification: the asynchronous API for executing a graph of module dependencies always returns a promise, and if there's any top-level await, it may not complete in a single turn, but, if there isn't any top-level await, everything evaluates to completion immediately, and the promise is immediately resolved. The |
OK, thanks, that's fair. It's true that I only have an intuition here and haven't really nailed the interop concerns. I mean, I think we both agree there's a learnability cost and a surprise for people used to the Node behavior. But my concern is more that code will break, and I should try to identify why. So there's definitely an upgrade hazard. For example, you can implement the example I gave above in Node today: // a.js
function A() { ... }
exports.A = A;
var B = require('./b').B;
A.B_INSTANCE = new B(); // b.js
function B() { ... }
exports.B = B;
var A = require('./a').A;
B.A_INSTANCE = new A(); This works because Node has the imperative semantics. As far as I can tell, the declarative semantics provides no way to implement this without a third module that updates the bindings. Something like: // a.js
export default class A { ... } // b.js
export default class B { ... } // init.js
import A from "./a";
import B from "./b";
A.B_INSTANCE = new B();
B.A_INSTANCE = new A(); And then to ensure that init.js is always executed you would probably have to wrap them with "facade modules": // a.js
import "./internal/a"; // ensure a is initialized
import "./internal/b"; // ensure b is initialized
import "./internal/init"; // ensure their statics are initialized
export { default } from "./internal/a"; // re-export a // b.js
import "./internal/a"; // ensure a is initialized
import "./internal/b"; // ensure b is initialized
import "./internal/init"; // ensure their statics are initialized
export { default } from "./internal/b"; // re-export b Now, it's actually easier to understand the control flow here, so you could argue people ought to do that anyway. And given how subtle the semantics of cyclic evaluation order is, maybe they'll want to. But it's a thing that works in Node today that would not work when upgrading. I feel like there could be actual interop hazards too, but I have to think about it harder. Since we don't really expect cycles between CJS and ES6 to work anyway, maybe that diminishes the potential pitfalls. I'll have to think some more.
Can you explain this a little more? I had trouble unpacking it. |
@dherman So are you saying there is no way for circular deps to work in Declarative modules synchronously without using a 3rd module to provide the synchronization point? Also, pretty neutral on this whole topic except I don't want it to be called an I do think it is nicer for circular dependencies; but side of the parent module could affect children, which I think is a con. Both are pretty small to me though. |
After rereading this several times I can say this is a +1 from my end. It makes circular dependencies easier to deal with, and stabilizes the execution order more. |
First, I'd like to better understand the scope of the change you're proposing. Here's what I've understood so far, correct me if I'm wrong:
If those two assumptions are correct, it seems the semantic match with Node's // module A
System.loader.registry.set('C', { ... /* some module record */ });
import B from 'B'; // module B
import C from 'C'; If (2) is correct, than this would try to resolve and load My overall initial reaction is similar to @allenwb's: your arguments in favor of the current declarative semantics seem stronger than those for the imperative. Combine that with the above caveats and I'm inclined to stick with declarative. |
Put me in @allenwb's camp too. I find the arguments in favor of declarative stronger. The node compatibility argument is interesting but I don't find it compelling as I can't think of when this compatibility would be important (especially without knowing what the eventual Node module system would look like, eg. is |
@ajklein 2 is false, this does change the order of operations. @bterlson right now circular dependencies are pretty broken with declarative semantics, see the example above about why you need a 3rd module to create a synchronization point ( #368 (comment) ). Also see the interop proposal from Node ( nodejs/node-eps#3 ), yes I would also like to point out without specifying the order in which dependencies load effects from the dependencies may be racey ( whatwg/loader#85 ). I think we can at least nail down order of dependency loading even if we don't go for imperative loading. Imperative makes the effects of modules tied to a specific time and fixes the circular dep brokneness. |
correct.
correct, we will be working on that additional syntax soon.
correct. by the time A gets evaluated, B and C were already satisfied, wired up and instantiated, but not evaluated. As today, that call to replace C (which already exists) in the registry, does nothing, because |
@caridy I don't think resolution/linking should be done imperatively, just evaluation. If we want to gain the advantage of reliably knowing at parse time what files will be interacting we must do fetch/link declaratively. If we do fetch/link during evaluation we lose some of the advantages of ES modules. |
@bmeck I'm not suggesting change resolution and linking, I'm stating the current situation we have in place today where everything, absolutely everything, is happening declaratively, and |
If (2) is not true, this means that a browser implementation could not Additionally, because some other module may have already forced the load of Put me in the declarative camp. On Fri, Feb 12, 2016 at 7:11 AM, Caridy Patiño [email protected]
|
@concavelenz it only determines when evaluation occurs, fetch/parse/link all occur prior to evaluation. Thats a lot of the work. As it stands currently module evaluation order is not mandated but we would still want a predictable evaluation order to avoid races, and once we get to that point we already have to completely linearize the dependencies after fetch/parse/link prior to evaluation. |
@concavelenz forgot to ask you to clarify:
|
Let me be absolute clear here because @bmeck and @concavelenz seem to be confused, (2) IS TRUE today, and this proposal does not affects that in any way. The reasoning from @ajklein is correct. |
@caridy maybe I am just misreading it a bunch, but evaluation as I understand occurs once |
Great, then I'll just +1 @allenwb and @ajklien then. On Fri, Feb 12, 2016 at 9:14 AM, Caridy Patiño [email protected]
|
(grrr, committed the comment too soon... still working on my reply so I deleted it and will repost) |
Oh, not at all! All the issues here are limited to a constrained set of conditions, namely:
Doing that is already confusing in CommonJS, and is really pretty confusing and unreliable in any system that supports mutually recursive modules, since a) it's sensitive to the particular order of evaluation, but b) a cyclic module graph makes it hard for the programmer to understand what the order of evaluation should be. Now, in these relatively esoteric cases, imperative imports do allow some code to execute successfully that declarative imports do not, such as my example in the issue description, but that's only with a very subtle and fragile ordering of the code. In Node, if you don't put the
I wouldn't characterize this as any more racey than the normal contract a module system has with its authors. Some points of clarification:
BTW, I should say that I'm at least persuaded that the arguments for imperative imports have been weakened, since I haven't really demonstrated that there's any serious Node interop issues. :) I don't quite feel confident that we've put the concerns to rest, but if I can't articulate it better than that I could probably be coaxed over to the declarative camp. :P |
At first I found declarative imports bizarre, but the arguments in this thread have convinced me that they're the less-bad option. I still think it's weird that in the declarative world, |
@ajklein Others have already answered your questions (@caridy is 100% right, as usual :P), but I just want to point out that no matter what semantics we went with, you would never be able to write something like your module A example and guarantee that B won't evaluate before you modify the registry, because you can never be sure that someone wouldn't have already installed and evaluated B earlier. Put differently, the contract of a module system doesn't allow you to rely on not having yet evaluated a dependency. Instead, if you want your imports to depend on some customization of the registry, you want to do that customization in a different execution phase. For example, in the browser, you could modify the registry in a previous (blocking) |
BTW, if you have the sort of inter-module dependencies that would trip up either the declarative or imperative initialization ordering it's probably time to refactor your modules. IMO, modules with such interdependecies are too tightly couple to be treated as independent modules. They probably should be merged into a single module whose internal initialization can be explicitly orchestrated. Or, if for some reason they can'tbe merged (size, x-organizational ownership, etc.) then the three module solution is actually preferable. |
I don't care/want people to predict module load order I just want order to be consistent. // a.js
import './b.js';
import './c.js'; // b.js
window.$ = () => {} // c.js
$(); Should always succeed or always fail. If it works sometimes... its bad. Imperative makes it predictable, but I care more about consistent than predictable. |
@bmeck With both declarative and imperative imports, that code works. With declarative, the relative ordering of imports in the file still matters, it's just that they are hoisted to happen before any other kinds of statements in the file. Just as in node, in this example, if C has not yet been executed, you can depend on it being executed after B. Of course (also just like node), if someone else already executed them and they wrote: import './c.js';
import './b.js'; then C would've executed before B. But that's what they asked for. Predictable and consistent. |
Ah, what you're saying is that the whatwg/loader#85 desideratum is incompatible with the guaranteed relative ordering. I think that may be true. That thread actually ended up dropping some of the context we'd discussed in a prior TC39 meeting. I thought we'd come up with an intermediate degree of early execution that maintained that constraint. Let me go dig through TC39 minutes. |
Yeah, now that I recall this is the exact same issue I brought up in the Portland TC39 meeting. Will dig up the semantics we came up with in that conversation as soon as I can… |
@bmeck // forcedToLoadBefore_a_or_b.js
import './c.js'; and that failure only occurs because c.js is buggy. c.js has an implicit dependency upon b.js that its author forgot to declare. If it is properly written as: // c.js
import './b.js';
$(); the failure will go away. |
No, it's actually important not to require everyone to explicitly state the state dependency. This is the key constraint of polyfills: if you have a polyfill that, say, adds @bmeck is correctly stating that if we just allowed host environments to do arbitrary early evaluation, then we've broken this contract with module authors. (This probably means there's a logical flaw in my earlier comment about the contract with module authors.) We talked about this in the Portland TC39 meeting, which was why we came up with what we illuminatingly called at the time the "2a" semantics, I just have to have the time to dig through the minutes and/or my memory to recall exactly what that was. :) |
@dherman |
@allenwb agreed on smell, but imagine web pages working sometimes. Imagine if python/ruby/etc. worked sometimes, depending on a race condition in the loader. Race conditions in the language are bad. |
To be clear, if it always failed, that would be fine by me. |
The "2a" semantics, roughly, are that we allow fully-resolved modules to evaluate, but still require that execution be in "import tree" order. So in the example above, b.js is guaranteed to execute before c.js (except as @dherman points out, if another earlier module in the tree has already loaded c.js). The main point of "2a" is precisely that the ordering is deterministic. |
@allenwb i think that's having a bit too much faith in the reliability of implementations. There still doesn't yet exist an engine that fully complies with ES5 (albeit in a few edge cases). Polyfills will likely be necessary for a long time for those that want those guarantees. Also consider SES, which needs to guarantee that it runs first. |
@ajklein Thank you! It's been a busy day, you saved me some digging. So if I've got this right, the idea is that, say, if your application root has: <script type=module>
import "really-massive-download"; // takes 10s to load
import "really-tiny-download"; // takes 50ms to load
</script> It's not going to be able to execute <script type=module>
import "really-tiny-download"; // takes 50ms to load
import "really-massive-download"; // takes 10s to load
</script> it can start executing Yes? |
@dherman Yup, that's the idea. |
I think this is a nice characterization. Declarative imports seem to be the more "JS-like" of the two, in my opinion. I'm still a bit biased against it because I'm from a Python background, where imports are imperative and the mutually recursive imports example works exactly as in the example. Personally I think the advantages of the imperative method are nice, but they can also lead to questionable practices such as importing things and relying on the side effects. I also think that if an imperative style is chosen, it seems more consistent to also permit import statements outside of the top level, although I don't think that would be a good practice. |
Interleaving points need to be explicit. console.log("before");
import "some-async-module"; // static error
console.log("after"); vs console.log("before");
await import "some-async-module"; // implicitly "blocks";
// remainder of module body does not execute in this turn
console.log("after"); Importing from async modules should compose like async function someAsyncFunction() {
return 8;
}
function badCaller() {
console.log("before");
const result = await someAsyncFunction(); // dynamic error
console.log("after");
return result;
} vs async function goodCaller() {
console.log("before");
const result = 1 + await someAsyncFunction(); // implicitly "blocks";
// remainder of function body does not execute in this turn
console.log("after");
return result;
} |
@dherman Could you give a summary of why this never got anywhere so far? That would be greatly appreciated from node's end. |
@dherman can we close this? |
closing since multiple browsers have shipped this and I do not see this as a possible change anymore. |
ES2015 specifies a sequentialized evaluation order of modules, where the particular syntactic location of an
import
statement in a module body has no effect on the order of evaluation. Instead, the semantics simply determines a module's list of direct dependencies and recursively calls ModuleEvaluation() on each of them in order before evaluating the body. I'll refer to this semantics as declarative imports.An alternative semantics would be an interleaved evaluation order. In this semantics, the evaluation order is sensitive to the particular syntactic location of an
import
statement. The evaluation semantics would be simply to execute the module body in order, and each ImportStatement would recursively call ModuleEvaluation() on that dependency. I'll refer to this semantics as imperative imports.It's not too late to change
Existing implementations of module systems in production use today vary between these two semantics. As I understand it, AMD and YUI have declarative imports, CJS And Node have imperative imports, some ES6 transpilers follow the currently specified declarative semantics, and others such as Babel and TypeScript can configurably target AMD or CJS and inherit the semantics of the target system depending on that configuration. Meanwhile, no browsers are yet shipping native support for ES6 modules.
So my conclusion is that it's not too late to change this part of the spec.
Impact on top-level await
One consideration we would have to work out is what becomes of the semantics of top-level
await
. The only two options I can think of are:await
.(1) is pretty silly and a refactoring hazard to boot. I think (2) is natural and reasonable, but it does have the consequence of being a slightly nonobvious
await
point:The case for imperative imports
Compatibility with Node
Node is clearly the largest ecosystem, and consequently the most likely one to hit on compatibility problems with declarative imports. This is IMO probably the strongest argument for imperative imports.
Connecting side effects to statements
Since module initialization is effectful, imperative imports arguably give programmers clearer control over those side effects. I don't find this argument quite as compelling, since memoization means you can't actually predict whether the side effects are occurring now or have already occurred some time earlier. You can at least claim that it's more consistent with the familiar precedent of
require()
-- although this is roughly equivalent to the previous argument.Increased expressivity
In the presence of cycles, imperative imports are technically more expressive. For example, two mutually recursive classes can coordinate to initialize their definitions and then add static instances of one another, without requiring a third module to orchestrate the initialization:
That said, this is an extremely subtle and fragile dependence on the execution order. In practice, most developers do not have a good mental model for the execution order of cycles and are unlikely to want to depend on these kinds of subtleties. So in that sense this argument is somewhat weak. But I suspect this means fewer cyclic dependency graphs will break.
The case for declarative imports
Simpler top-level await story
The story of top-level await is arguably more straightforward with declarative imports: the evaluation of the entire module body is simply blocked on completion of its dependencies. This doesn't introduce an implicit
await
, whereas imperative imports implicitly await at the point ofimport
ing an asynchronous module.Refactoring
Since declarative imports do not depend on the order of imports, you can reorder them without changing the behavior of your program. This gives programmers more flexibility to group their module bodies the way feels best to them.
No harm done
Large ecosystems like the Ember community have been using the ES6 semantics for years without trouble, so it does not appear to have been a problem in practice.
Simpler spec
Declarative evaluation is simpler to spec than imperative evaluation, which requires the evaluation semantics of import statements to coordinate with the rest of the module semantics. I reject this argument entirely -- if @bterlson has to suffer to make JS developers' lives better, so be it! ;P
Conclusion
IMO, imperative imports come out ahead here: in particular, the compatibility and familiarity from existing module systems is the strongest argument I see. Of the arguments I've enumerated, the only one I can see that I find a bit concerning about imperative imports is the implicit
await
.But I may well have missed arguments, so let's talk!
The text was updated successfully, but these errors were encountered: