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

imperative imports #368

Closed
dherman opened this issue Feb 5, 2016 · 51 comments
Closed

imperative imports #368

dherman opened this issue Feb 5, 2016 · 51 comments
Labels
discussion normative change Affects behavior required to correctly evaluate some ECMAScript source text

Comments

@dherman
Copy link
Member

dherman commented Feb 5, 2016

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:

  1. the presence of any dependencies on asynchronous modules (i.e., modules that contain top-level await or that import from other asynchronous modules) automatically switches a module's evaluation order to declarative; or
  2. imperative imports from asynchronous modules implicitly 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:

console.log("before");
import "some-async-module"; // implicitly blocks; remainder of module body does not execute in this turn
console.log("after");

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:

// module A
export default class A { ... } // create class A
import B from "B";             // SYNCHRONIZATION POINT
A.B_INSTANCE = new B();        // use class B
// module B
export default class B { ... } // create class B
import A from "A";             // SYNCHRONIZATION POINT
B.A_INSTANCE = new A();        // use class A

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 of importing 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!

@bmeck
Copy link
Member

bmeck commented Feb 5, 2016

imperative imports from asynchronous modules implicitly await.

Does this mean we must wait on the event loop to turn to finish an import sometimes (depending if the dependency is async/sync)?

@creationix
Copy link

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.

@creationix
Copy link

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.

@caridy
Copy link
Contributor

caridy commented Feb 5, 2016

@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.

@allenwb
Copy link
Member

allenwb commented Feb 5, 2016

@dherman
It seems to me that you actually made a stronger argument in favor of the current declarative model then you did for the imperative model.

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.

@dherman
Copy link
Member Author

dherman commented Feb 5, 2016

@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.

@dherman
Copy link
Member Author

dherman commented Feb 5, 2016

@allenwb

It seems to me that you actually made a stronger argument in favor of the current declarative model then you did for the imperative model.

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.

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.

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 import line. Then the second one will run to its import line, which will be a no-op since the first module has already started evaluating (no coroutining here -- just a straightforward, on-the-fly memoizing semantics, just like require in Node). So by the time you get to the end of the "sync point" of either module, you know both modules have executed at least to their respective sync points.

@creationix
Copy link

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.

@dherman
Copy link
Member Author

dherman commented Feb 5, 2016

@creationix

easier refactoring

The refactoring difference is quite minimal, which is why I don't weight that argument heavily. In practice people move around their top-level require statements in Node and don't worry about the different order of evaluation. Not only that, but the relative ordering of imports is still significant even in the declarative semantics (import "foo"; import "bar"; runs "foo" then "bar", whereas import "bar"; import "foo"; runs "bar" then "foo"). So the difference in degree of refactoring freedom is too small to really matter much in practice.

semantics problematic

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."

@dherman
Copy link
Member Author

dherman commented Feb 5, 2016

@bmeck

Does this mean we must wait on the event loop to turn to finish an import sometimes (depending if the dependency is async/sync)?

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, require is an exception to that, because it's important not to violate JS's run-to-completion semantics, and it makes the interop and upgrade path for Node smooth and gradual. But the goal is to create a path to a future where there can be a standardized way to modularly execute asynchronous startup steps that don't lead to race conditions in applications' startup logic.

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.

@allenwb
Copy link
Member

allenwb commented Feb 5, 2016

@dherman

If you believe they're stronger, it would help to explain which arguments you think are stronger than interop with Node and why.

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.

@dherman
Copy link
Member Author

dherman commented Feb 5, 2016

@bmeck

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 Module.evaluate API (and/or some internal API provided by V8 that Node would use internally in the implementation of require for evaluating ES6 modules) is always async and produces a promise, but if there's no actual async top-level blocking in the module graph, it does all initialization synchronously.

@dherman
Copy link
Member Author

dherman commented Feb 6, 2016

@allenwb

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.

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.

Can you explain this a little more? I had trouble unpacking it.

@bmeck
Copy link
Member

bmeck commented Feb 7, 2016

@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 await if we re-enter code synchronously like the circular deps example.

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.

@bmeck
Copy link
Member

bmeck commented Feb 11, 2016

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.

@ajklein
Copy link
Contributor

ajklein commented Feb 11, 2016

@dherman

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:

  1. No change in parsing: import declarations are only allowed at the top level.
  2. No change in loading: imports and exports are still processed ahead-of-time, and the entire import tree is loaded before any module in the tree begins evaluation.

If those two assumptions are correct, it seems the semantic match with Node's require is limited to the timing of evaluation. (1) implies that additional syntax or API would still be needed to support conditional loading. And (2) means that changes to loader configuration above an import have no effect on that import, which is both generally unintuitive and doesn't seem to match require (though Node seems to have fewer degrees of freedom than the current loader spec). Example (forgive my unfamiliarity with the loader API):

// 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 'C' before running the registry.set() call.

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.

@bterlson
Copy link
Member

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 require still available?). Maybe missing something obvious :)

@bmeck
Copy link
Member

bmeck commented Feb 11, 2016

@ajklein 2 is false, this does change the order of operations. System.loader.registry.set would predictably execute prior to import "B"

@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 require will continue to exist.

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.

@caridy
Copy link
Contributor

caridy commented Feb 12, 2016

@ajklein:

If those two assumptions are correct, it seems the semantic match with Node's require is limited to the timing of evaluation.

correct.

(1) implies that additional syntax or API would still be needed to support conditional loading.

correct, we will be working on that additional syntax soon.

(2) means that changes to loader configuration above an import have no effect on that import, which is both generally unintuitive and doesn't seem to match require (though Node seems to have fewer degrees of freedom than the current loader spec).

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 HostResolveImportedModule() relies on the pre-computed [[Dependencies]] per ModuleStatus entry, which means A's ModuleStatus will contain the wired up set of dependencies, already resolved, and ready to be evaluated. Of course, all that can change if needed.

@bmeck
Copy link
Member

bmeck commented Feb 12, 2016

@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.

@caridy
Copy link
Contributor

caridy commented Feb 12, 2016

@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 System.loader.registry.set('C', { ... /* some module record */ }); in the example above does nothing.

@concavelenz
Copy link

If (2) is not true, this means that a browser implementation could not
begin any work on the dependencies until it actually reaches that import
statement execution in the module. This seems like enough to kill the
proposal. The module system MUST work well for web, if it doesn't you
might as well abandon it and stick with CommonJS and "require" as that
works perfectly well for Node.

Additionally, because some other module may have already forced the load of
the module in question, any changes system state (loader or otherwise), may
not have any effect on the intended module.

Put me in the declarative camp.

On Fri, Feb 12, 2016 at 7:11 AM, Caridy Patiño notifications@github.com
wrote:

@bmeck https://github.com/bmeck I'm not suggesting change resolution
and linking, I'm stating the state of the spec today where everything,
absolutely everything happens declarative, and System.loader.registry.set('C',
{ ... /* some module record */ }); in the example above does nothing.


Reply to this email directly or view it on GitHub
#368 (comment).

@bmeck
Copy link
Member

bmeck commented Feb 12, 2016

@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.

@bmeck
Copy link
Member

bmeck commented Feb 12, 2016

@concavelenz forgot to ask you to clarify:

Additionally, because some other module may have already forced the load of
the module in question, any changes system state (loader or otherwise), may
not have any effect on the intended module.

@caridy
Copy link
Contributor

caridy commented Feb 12, 2016

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.

@bmeck
Copy link
Member

bmeck commented Feb 12, 2016

@caridy maybe I am just misreading it a bunch, but evaluation as I understand occurs once import is encountered. All the fetch/parse/link is done prior to any evaluation. I thought 2 was stating that evaluation would resolve the linking when import is encountered.

@concavelenz
Copy link

Great, then I'll just +1 @allenwb and @ajklien then.

On Fri, Feb 12, 2016 at 9:14 AM, Caridy Patiño notifications@github.com
wrote:

Let me be absolute clear here because @bmeck https://github.com/bmeck
and @concavelenz https://github.com/concavelenz seem to be confused,
(2) IS TRUE today, and this proposal does not affects that in any way.
The reasoning from @ajklein https://github.com/ajklein is correct.


Reply to this email directly or view it on GitHub
#368 (comment).

@dherman
Copy link
Member Author

dherman commented Feb 12, 2016

(grrr, committed the comment too soon... still working on my reply so I deleted it and will repost)

@dherman
Copy link
Member Author

dherman commented Feb 12, 2016

@bmeck

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?

Oh, not at all! All the issues here are limited to a constrained set of conditions, namely:

  • within a cycle...
  • top-level, boot-time code...
  • invokes definitions (i.e. call functions or instantiate classes)...
  • that were defined within the cycle.

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 requires in exactly the right place (in the middle of the modules, not the beginning or end!) you'll get a runtime error. What I hear from Node programmers is they shy away from doing funny things with boot-time logic in cycles because it's so confusing. So it's not clear that these use cases are important.

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 wouldn't characterize this as any more racey than the normal contract a module system has with its authors. Some points of clarification:

  1. There's no module system that allows you to predict exactly when an import triggers its dependency's side effects. The most you can predict, even with imperative imports, is that those side effects will have happened by the time the import statement completes. Since modules are only evaluated once, you can't know that someone won't have already evaluated the dependency by the time your module starts its evaluation process, in which case it will not trigger the dependency's evaluation again.
  2. Because of the previous point, allowing early execution of complete subgraphs doesn't violate the contract with module authors.
  3. Declarative vs imperative imports is an orthogonal question to early execution of subgraphs. They can both be decided independently.
  4. Declarative imports result in strictly fewer possible interleavings of effectful code, since they are less sensitive to reorderings of code within modules than imperative imports. So they are strictly more deterministic than imperative imports.

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

@domenic
Copy link
Member

domenic commented Feb 12, 2016

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, import ends up being less of a statement and more of a piece of metadata you can sprinkle throughout your module, between other top-level statements and declarations. Maybe if we'd realized all the complexities here years ago we would have added some syntactic restriction that imports all be together at the top of the file and nowhere else. As-is I think it'll just be worthwhile to make an eslint rule that reprimands you for any other pattern.

@dherman
Copy link
Member Author

dherman commented Feb 12, 2016

@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) script tag, or you could perform a loader.import after having performed the registry modifications.

@allenwb
Copy link
Member

allenwb commented Feb 12, 2016

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.

@ajklein
Copy link
Contributor

ajklein commented Feb 12, 2016

@dherman Yes, @domenic had the same reaction to my example. To clarify, I didn't mean to imply that that example would have worked with declarative imports, but that the analogy to require doesn't hold in this case.

@bmeck
Copy link
Member

bmeck commented Feb 12, 2016

@dherman

There's no module system that allows you to predict exactly when an import triggers its dependency's side effects. The most you can predict, even with imperative imports, is that those side effects will have happened by the time the import statement completes. Since modules are only evaluated once, you can't know that someone won't have already evaluated the dependency by the time your module starts its evaluation process, in which case it will not trigger the dependency's evaluation again.

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.

@dherman
Copy link
Member Author

dherman commented Feb 12, 2016

@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.

@dherman
Copy link
Member Author

dherman commented Feb 12, 2016

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.

@dherman
Copy link
Member Author

dherman commented Feb 12, 2016

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…

@allenwb
Copy link
Member

allenwb commented Feb 12, 2016

@bmeck
The failure only occurs if somebody latter adds:

// 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.

@dherman
Copy link
Member Author

dherman commented Feb 12, 2016

@allenwb

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 Array.prototype.includes, you want to force it to be installed before any of the rest of your app, and you want none of the rest of your app's modules to mention a dependency on it. That's not buggy, that's the way it's supposed to be written.

@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. :)

@allenwb
Copy link
Member

allenwb commented Feb 12, 2016

@dherman
I get your point for polyfills. But, in general, implicit dependence upon such ambient global state is pretty smelly. I would hope that best practices would discourage such dependencies in new modules written using ES6 module syntax.

@bmeck
Copy link
Member

bmeck commented Feb 12, 2016

@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.

@bmeck
Copy link
Member

bmeck commented Feb 12, 2016

To be clear, if it always failed, that would be fine by me.

@ajklein
Copy link
Contributor

ajklein commented Feb 12, 2016

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.

@ljharb
Copy link
Member

ljharb commented Feb 12, 2016

@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.

@dherman
Copy link
Member Author

dherman commented Feb 13, 2016

@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 "really-tiny-download" early even if it's fully ready, because it has to be sure of the full dependency graph of "really-massive-download" before it knows that it's safe to execute. By contrast, if your application root has:

<script type=module>
import "really-tiny-download";    // takes 50ms to load
import "really-massive-download"; // takes 10s to load
</script>

it can start executing "really-tiny-download" early because it has fully explored the dependency graph of that subgraph and knows that there are no other evaluation dependencies that precede it.

Yes?

@ajklein
Copy link
Contributor

ajklein commented Feb 13, 2016

@dherman Yup, that's the idea.

@bterlson bterlson added discussion normative change Affects behavior required to correctly evaluate some ECMAScript source text labels Feb 17, 2016
@msikma
Copy link

msikma commented Feb 26, 2016

I still think it's weird that in the declarative world, import ends up being less of a statement and more of a piece of metadata you can sprinkle throughout your module, between other top-level statements and declarations. (@domenic)

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.

@erights
Copy link

erights commented Feb 27, 2016

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 await calls to async functions compose to emulate deep interleaving points, i.e. co-routine-like suspended stacks:

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;
}

@Fishrock123
Copy link

@dherman Could you give a summary of why this never got anywhere so far? That would be greatly appreciated from node's end.

@bmeck
Copy link
Member

bmeck commented Nov 17, 2016

@dherman can we close this?

@bmeck
Copy link
Member

bmeck commented Mar 22, 2018

closing since multiple browsers have shipped this and I do not see this as a possible change anymore.

@bmeck bmeck closed this as completed Mar 22, 2018
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
discussion normative change Affects behavior required to correctly evaluate some ECMAScript source text
Projects
None yet
Development

No branches or pull requests