Join GitHub today
GitHub is home to over 28 million developers working together to host and review code, manage projects, and build software together.
Sign upimperative imports #368
Comments
This comment has been minimized.
Show comment
Hide comment
This comment has been minimized.
bmeck
Feb 5, 2016
Member
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)?
Does this mean we must wait on the event loop to turn to finish an import sometimes (depending if the dependency is async/sync)? |
This comment has been minimized.
Show comment
Hide comment
This comment has been minimized.
creationix
Feb 5, 2016
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
commented
Feb 5, 2016
|
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. |
This comment has been minimized.
Show comment
Hide comment
This comment has been minimized.
creationix
Feb 5, 2016
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.
creationix
commented
Feb 5, 2016
|
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. |
This comment has been minimized.
Show comment
Hide comment
This comment has been minimized.
caridy
Feb 5, 2016
Contributor
@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.
|
@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. |
This comment has been minimized.
Show comment
Hide comment
This comment has been minimized.
allenwb
Feb 5, 2016
Member
@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 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. |
This comment has been minimized.
Show comment
Hide comment
This comment has been minimized.
dherman
Feb 5, 2016
Member
@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.
|
@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. |
This comment has been minimized.
Show comment
Hide comment
This comment has been minimized.
dherman
Feb 5, 2016
Member
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.
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 |
This comment has been minimized.
Show comment
Hide comment
This comment has been minimized.
creationix
Feb 5, 2016
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.
creationix
commented
Feb 5, 2016
|
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. |
This comment has been minimized.
Show comment
Hide comment
This comment has been minimized.
dherman
Feb 5, 2016
Member
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."
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." |
This comment has been minimized.
Show comment
Hide comment
This comment has been minimized.
dherman
Feb 5, 2016
Member
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.
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. |
This comment has been minimized.
Show comment
Hide comment
This comment has been minimized.
allenwb
Feb 5, 2016
Member
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.
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. |
This comment has been minimized.
Show comment
Hide comment
This comment has been minimized.
dherman
Feb 5, 2016
Member
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.
|
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 |
This comment has been minimized.
Show comment
Hide comment
This comment has been minimized.
dherman
Feb 6, 2016
Member
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 bNow, 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.
|
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 bNow, 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. |
This comment has been minimized.
Show comment
Hide comment
This comment has been minimized.
bmeck
Feb 7, 2016
Member
@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.
|
@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. |
This comment has been minimized.
Show comment
Hide comment
This comment has been minimized.
bmeck
Feb 11, 2016
Member
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.
|
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. |
This comment has been minimized.
Show comment
Hide comment
This comment has been minimized.
ajklein
Feb 11, 2016
Contributor
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:
- No change in parsing:
importdeclarations are only allowed at the top level. - 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.
|
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. |
This comment has been minimized.
Show comment
Hide comment
This comment has been minimized.
bterlson
Feb 11, 2016
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 :)
|
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 |
This comment has been minimized.
Show comment
Hide comment
This comment has been minimized.
bmeck
Feb 11, 2016
Member
@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.
|
@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. |
This comment has been minimized.
Show comment
Hide comment
This comment has been minimized.
caridy
Feb 12, 2016
Contributor
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.
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 |
This comment has been minimized.
Show comment
Hide comment
This comment has been minimized.
bmeck
Feb 12, 2016
Member
@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 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. |
This comment has been minimized.
Show comment
Hide comment
This comment has been minimized.
caridy
Feb 12, 2016
Contributor
@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.
|
@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 |
This comment has been minimized.
Show comment
Hide comment
This comment has been minimized.
concavelenz
Feb 12, 2016
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).
concavelenz
commented
Feb 12, 2016
|
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 notifications@github.com
|
This comment has been minimized.
Show comment
Hide comment
This comment has been minimized.
bmeck
Feb 12, 2016
Member
@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 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. |
This comment has been minimized.
Show comment
Hide comment
This comment has been minimized.
bmeck
Feb 12, 2016
Member
@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.
|
@concavelenz forgot to ask you to clarify:
|
This comment has been minimized.
Show comment
Hide comment
This comment has been minimized.
caridy
Feb 12, 2016
Contributor
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.
|
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. |
This comment has been minimized.
Show comment
Hide comment
This comment has been minimized.
bmeck
Feb 12, 2016
Member
@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.
|
@caridy maybe I am just misreading it a bunch, but evaluation as I understand occurs once |
This comment has been minimized.
Show comment
Hide comment
This comment has been minimized.
concavelenz
Feb 12, 2016
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).
concavelenz
commented
Feb 12, 2016
|
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
|
This comment has been minimized.
Show comment
Hide comment
This comment has been minimized.
dherman
Feb 12, 2016
Member
(grrr, committed the comment too soon... still working on my reply so I deleted it and will repost)
|
(grrr, committed the comment too soon... still working on my reply so I deleted it and will repost) |
This comment has been minimized.
Show comment
Hide comment
This comment has been minimized.
dherman
Feb 12, 2016
Member
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:
- 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.
- Because of the previous point, allowing early execution of complete subgraphs doesn't violate the contract with module authors.
- Declarative vs imperative imports is an orthogonal question to early execution of subgraphs. They can both be decided independently.
- 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
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 |
This comment has been minimized.
Show comment
Hide comment
This comment has been minimized.
domenic
Feb 12, 2016
Member
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.
|
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, |
This comment has been minimized.
Show comment
Hide comment
This comment has been minimized.
dherman
Feb 12, 2016
Member
@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.
|
@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) |
This comment has been minimized.
Show comment
Hide comment
This comment has been minimized.
allenwb
Feb 12, 2016
Member
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.
|
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. |
This comment has been minimized.
Show comment
Hide comment
This comment has been minimized.
This comment has been minimized.
Show comment
Hide comment
This comment has been minimized.
bmeck
Feb 12, 2016
Member
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.
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. |
This comment has been minimized.
Show comment
Hide comment
This comment has been minimized.
dherman
Feb 12, 2016
Member
@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.
|
@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. |
This comment has been minimized.
Show comment
Hide comment
This comment has been minimized.
dherman
Feb 12, 2016
Member
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.
|
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. |
This comment has been minimized.
Show comment
Hide comment
This comment has been minimized.
dherman
Feb 12, 2016
Member
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…
|
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… |
This comment has been minimized.
Show comment
Hide comment
This comment has been minimized.
allenwb
Feb 12, 2016
Member
@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.
|
@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. |
This comment has been minimized.
Show comment
Hide comment
This comment has been minimized.
dherman
Feb 12, 2016
Member
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. :)
|
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. :) |
This comment has been minimized.
Show comment
Hide comment
This comment has been minimized.
allenwb
Feb 12, 2016
Member
@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.
|
@dherman |
This comment has been minimized.
Show comment
Hide comment
This comment has been minimized.
bmeck
Feb 12, 2016
Member
@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.
|
@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. |
This comment has been minimized.
Show comment
Hide comment
This comment has been minimized.
|
To be clear, if it always failed, that would be fine by me. |
This comment has been minimized.
Show comment
Hide comment
This comment has been minimized.
ajklein
Feb 12, 2016
Contributor
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.
|
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. |
This comment has been minimized.
Show comment
Hide comment
This comment has been minimized.
ljharb
Feb 12, 2016
Member
@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.
|
@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. |
This comment has been minimized.
Show comment
Hide comment
This comment has been minimized.
dherman
Feb 13, 2016
Member
@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 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? |
This comment has been minimized.
Show comment
Hide comment
This comment has been minimized.
|
@dherman Yup, that's the idea. |
bterlson
added
discussion
normative change
labels
Feb 17, 2016
This comment has been minimized.
Show comment
Hide comment
This comment has been minimized.
msikma
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.
msikma
commented
Feb 26, 2016
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. |
This comment has been minimized.
Show comment
Hide comment
This comment has been minimized.
erights
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;
}
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 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;
} |
This comment has been minimized.
Show comment
Hide comment
This comment has been minimized.
Fishrock123
May 10, 2016
@dherman Could you give a summary of why this never got anywhere so far? That would be greatly appreciated from node's end.
Fishrock123
commented
May 10, 2016
|
@dherman Could you give a summary of why this never got anywhere so far? That would be greatly appreciated from node's end. |
This comment has been minimized.
Show comment
Hide comment
This comment has been minimized.
|
@dherman can we close this? |
bmeck
referenced this issue
Mar 22, 2018
Closed
ES module access to global or process.env improvements #19529
This comment has been minimized.
Show comment
Hide comment
This comment has been minimized.
bmeck
Mar 22, 2018
Member
closing since multiple browsers have shipped this and I do not see this as a possible change anymore.
|
closing since multiple browsers have shipped this and I do not see this as a possible change anymore. |
dherman commentedFeb 5, 2016
ES2015 specifies a sequentialized evaluation order of modules, where the particular syntactic location of an
importstatement 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
importstatement. 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
awaitpoint: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 ofimporting 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!