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

My code breaks from hoisting. Possible to _only_ transform async/await - not const, etc? #108

Closed
ozra opened this issue Apr 18, 2018 · 21 comments

Comments

@ozra
Copy link

ozra commented Apr 18, 2018

I have a loop, in it closures with click-handlers are created with id's from the loop. After running the code through nodent (which would be a saver because of Safari 10.*) it has transformed not only asyncs and awaits, but turned consts in to var, and thereby hoisted them far outside of the loop, thereby making all the click-handlers point to the last item.

This in it self is easily remedied in our source - but there's no telling how many other places this causes a problem!

So — is there no flag to enable only transforming said constructs?

Cool concept and solution BTW! :)

[edit] I'm using the command-line tool in a build-script atm.

@matAtWork
Copy link
Collaborator

There is a need to change const into var because statements like:

const x = await f() ;

get tranformed to

const x ; 
f().then($awaited => { 
    x = $awaited ;  /// ILLEGAL - can't assign to a const.
}) ;

Hoisting to var avoids a bug in Safari (and early nodejs's) where const worked before it was properly defined but had the scope semantics of a var. However, declaring a const twice with the same name will force nodent to use let (and breaks in Safari as does the original re-declaration of a const - so nothing broken/gained), for example:

const x = await f();
{
    const x = await g();
}

becomes:

let x;
return f().then((function ($await_2) {
    x = $await_2;
    {
        let x;
        return g().then((function ($await_3) {
            x = $await_3;
        }).$asyncbind(this, $error), $error);
    }
}).$asyncbind(this, $error), $error);

See http://nodent.mailed.me.uk/#const%20x%20%3D%20await%20f()%3B%0A%7B%0A%20%20%20%20const%20x%20%3D%20await%20g()%3B%0A%7D%0A~options~%7B%22mode%22%3A%22promises%22%2C%22promiseType%22%3A%22Zousan%22%2C%22noRuntime%22%3Afalse%2C%22es6target%22%3Afalse%2C%22wrapAwait%22%3Afalse%2C%22spec%22%3Afalse%7D for a working example.

You should never have a problem in the way you describe - can you provide me with an example of source that doesn't translate correctly?

@ozra
Copy link
Author

ozra commented Apr 19, 2018

Aha, interesting. Well then the "option-flaw" is really an awesome feature, since Safari is what needed to be solved XD

Thanks for the detailed explanation, enlightening! I love awesome transpilations like this! :-) 👍

The problem is very easily remedied, but with a team of coders where not all are 10x, slips can occur, and also, where there already is a lot of code dev'ed and tested with const/let in effect and assumed. That's why it's less fun. My choices, my bad.

It's simply:

for(const item of some_list) {
    const thing = get_some_thing(item.id)
    thing.click.will(_=> do_thing(item.id))
}

[ed: clarified slightly]
So, as you can see the bug in it self doesn't have to do with async or await keywords directly — only the fact that the item value's storage no longer is localized to the loop, and the click-closure thereby goes renegade.

@ozra ozra closed this as completed Apr 19, 2018
@ozra
Copy link
Author

ozra commented Apr 19, 2018

Sidenote, maintaining originally intended semantics would of course be possible by transpiling in iife-closures with scoped values locked in where deemed needed — but I guess that would be a hellish endeavor to implement...

@matAtWork
Copy link
Collaborator

...it would be possible, but the code you have above wasn't actually creating a closure - if you had referenced thing within the callback you would have seen this bug, however you don't and therefore there's no issue with it being hoisted since it's not used 'later'. Later versions of Safari (and V8, although V8 has a different set of pre-ES6 assumptions for const) correctly implement the ES6 semantics and block scope the identifier.

The details can be found at https://github.com/MatAtBread/nodent-transform/blob/master/arboriculture.js#L2222

The issue here is exactly as you initially identified - implement async and await with the minimal (of big!) set of transforms, but don't try to do any thing else like fix Safari 9!

@ozra
Copy link
Author

ozra commented Apr 19, 2018

Wrote it off the top of my head. But, yes, there is a closure. Let me clarify:

// Good
const list = [ {id:1}, {id:2}, {id:3} ];
for(const v of list) {
    setTimeout(_=>console.log(v.id), 0);
}
// => 1,  2, 3
// Bad
const list = [{id:1}, {id:2}, {id:3}];
var v;
for(v of list) {
    setTimeout(_=>console.log(v.id), 0);
}
// => 3, 3, 3

Would've been the same of course if assigned to a const within the loop. This is the minimal example.

Thanks for the link, I'll check it out.

[edit:] Should point out that there is an await in the loop in the real code:

            if (ix % 8 === 0) {
                await a.be_nice()
            }

@ozra
Copy link
Author

ozra commented Apr 19, 2018

Perhaps I should reopen, I'll leave closing to you.

@ozra ozra reopened this Apr 19, 2018
@matAtWork
Copy link
Collaborator

matAtWork commented Apr 20, 2018

I apologise; your initial example did have a closure over item, where as for some reason I was focused on thing. Does you initial code work in Safari 9?

I think you might me onto something, so I'll dig and work out under which conditions this fails. Thanks for persisting with your case :)

EDIT: You're quite correct - const and let declared within a for are incorrect. I know I fixed this months back so at some point I must have introduced a regression.

@matAtWork
Copy link
Collaborator

Incorrect comment caused me to close this issue (it's open), not the actual issue I was working on!

@ozra
Copy link
Author

ozra commented Apr 20, 2018

In all fairness my example was not far off from Perl-obfuscation XD

@ozra
Copy link
Author

ozra commented Apr 20, 2018

Oh, have not tried Safari 9, it was reported by a dude running 10. Saying it worked on his mobile devices, but not desktop. I just assumed all <11 was foul!?

@matAtWork
Copy link
Collaborator

I think I've got this fixed in nodent-transform@3.2.2. The semantics are pretty tricky - updates to the loop variable within the body are (of course) reflected by any subsequent callback references, but not updates in the "update" step (eg the i++ in for(let i=0; i<5; i++)... - which was a lot of fun to work out.

Please let me know if it fixes your issue.

@ozra
Copy link
Author

ozra commented Apr 28, 2018

Sorry I didn't see this 'til now - I'll check it out!

@ozra
Copy link
Author

ozra commented Apr 29, 2018

Good news, and bad. It works with a for, unfortunately a major piece in the UX uses a while loop (it "circulates" around a list that is updated piece by piece from the network, so continues from the index where it last aborted b/c of new updates, in order to deliver the fullest picture to the user as swiftly as possible [GoogleMap markers] — redundant information, for you, though)

Minimal example (the a below is an async-util namespace):

const li = [{a:1}, {a:2}]

// Now works fine
for (const v of li) {
    await a.be_nice()
    setTimeout(() => console.log("good_for:", v.a))
}

// Still no go
let ix = 0
while (ix < li.length) {
    await a.be_nice()
    const item = li[ix]  // <-- [edit]
    setTimeout(() => console.log("bad_while:", item.a))
    if (ix === li.length - 1) break // or it will error because the index used will be the "overflown"
    ix++
}

Impressive AST-juggling!

@MatAtBread
Copy link
Owner

I thought about that case, but it doesn't happen as ix is declared outside the loop body. Forget the async/await, just the synchronous code

let ix = 0
while (ix < 5) {
    setTimeout(() => console.log(ix))
    ix++
}

Produces the output

5
5
5
5
5

@matAtWork
Copy link
Collaborator

@ozra - I'm going to close this for now. If I've misunderstood your while example please re-open and have another go at explaining it to me!

@ozra
Copy link
Author

ozra commented May 1, 2018

Sorry, I reduced the example too much in my effort to make it minimal. I edited the example above to reflect the const which is there in reality. It "locks it in" within the block scope of the while.
Should I still open it as a new issue?

@MatAtBread
Copy link
Owner

Here's fine. It's actually a different cause; const incorrectly not block scoped - if you change it to a let it works as expected.

Nodent works out how far to hoist consts using quite a complicated set of tests since noted elsewhere (my original reply) const exists in a pre-ES6 implementation that differs from the real ES6 one.

In this case, nodent is (incorrectly) selecting the pre-ES6 definition, which is that it has function (not block) scope and it is only assigned to once. You can fool nodent into realising it must be block scope by declaring it in the outer-block as well (see here)

The problem for nodent is that it's trying to support the pre-ES6 and real ES6 semantics, which of course a browser doesn't have to do - it's either one or the other.

Does you code work on Safari 9 (and maybe 10)?

Either way, it's a bug. I'll see if I can find a fix that doesn't break something else, or at least assume ES6 semantics as the default.

@MatAtBread MatAtBread reopened this May 1, 2018
@ozra
Copy link
Author

ozra commented May 1, 2018

It gets complicated quickly when trying to please deviating definitions XD
It sounds more than reasonable to assume ES6 semantics rather than earlier behaviours imo.

matAtWork added a commit to MatAtBread/nodent-transform that referenced this issue May 2, 2018
This change assumes loop blocks have scope (they do in ES6), which means
it "fixes" (i.e. changes) the pre-ES6 behaviour of `const` declarations
within loops. Whilst this is "correct", it differs from the previous
behaviour and so early JS engines that previously returned "incorrect"
results will now return "correct" results: specifically `const` in a
loop is local and can be deferenced in a callback with it's loop value
rather than it's terminal value.

This change affects NodeJS versions before 6.x, Chrome until approx v48,
Safari 9 and possibly 10 (I've not verified which browser versions are
actually affected). These engine will run generate "correct" ES6 results
for loops like:

```
async function nop(x) { return x }
var resolve,p = new Promise(function(r){resolve = r}) ;
var i = 0, x = 0, s = 1 ;
while (i<5) {
	const j = i+1 ;
	await nop() ;
	setTimeout(function(){
		x += 1 ;
		s *= j ; // REFERS to "j"
		if (x===5) {
			resolve(s) ;
		}
	}, 0);
	i++ ;
}

return await p ;
```
matAtWork added a commit to MatAtBread/nodent-compiler that referenced this issue May 2, 2018
@matAtWork
Copy link
Collaborator

NB! The last 'fix' worked for the case identified above, but sadly broke another case (loops containing a var in "use strict" mode), so I'll be revising this again shortly, or reverting.

@matAtWork
Copy link
Collaborator

I really hope this is now fixed. If you click on the link above it would appear to now work.

Please re-open if I'm mistaken (again!)

@ozra
Copy link
Author

ozra commented May 3, 2018

It's working all fine and dandy now! 👍

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

3 participants