tc39 / source-map

Source map specification, RFCs and new proposals.
https://tc39.es/source-map/
Other
114 stars 16 forks source link

Scopes: support for hiding new injected scopes from the stack trace #113

Open nicolo-ribaudo opened 1 week ago

nicolo-ribaudo commented 1 week ago

While we focused a lot on function inlining, sometimes Babel does the opposite. For example, given this input:

for (let x of arr) {
  console.trace(x);
  run(() => x);
}

Babel generates this code:

var _iterator = _createForOfIteratorHelper(arr),
  _step;
try {
  var _loop = function _loop() {
    var x = _step.value;
    console.trace(x);
    run(function () {
      return x;
    });
  };
  for (_iterator.s(); !(_step = _iterator.n()).done;) {
    _loop();
  }
} catch (err) {
  _iterator.e(err);
} finally {
  _iterator.f();
}

With the scopes proposal, is it possible to somehow mark _loop so that it does not appear in the stack when placing a breakpoint at console.trace(x)?

szuend commented 1 week ago

Intuitively I would have said we should emit a generated range for _loop with no definition. That is it does not point to any authored scope back. This is not implemented yet but I can give it a go and see if such an approach is sufficient.

jridgewell commented 1 week ago

I agree with @szuend, that's a generated range without an original scope. This should already be implemented in my beta scopes branch.

szuend commented 6 days ago

I didn't implement anything yet but just thinking out loud. I modified the example a bit so we have the following input:

function run(x) {
  x();
}

function foo() {
  for (let x of [1, 2, 3]) {
    console.trace(x);
    run(() => x);
  }
}

foo();

And Babel generates:

function run(x) {
  x();
}
function foo() {
  var _loop = function _loop() {
    var x = _arr[_i];
    console.trace(x);
    run(function () {
      return x;
    });
  };
  for (var _i = 0, _arr = [1, 2, 3]; _i < _arr.length; _i++) {
    _loop();
  }
}
foo();

Now I would expect the following generated ranges (among others): One for foo, that points back the the original foo function scope. A generated range for

    var x = _arr[_i];
    console.trace(x);
    run(function () {
      return x;
    });

that points back to the original block scope for the for loop. And a generated range for the _loop(); call site (or the whole generated for loop body) with no original scope definition.

The stack trace for pausing on the console.trace line would be:

_loop (script.js 7:5)
foo (script.js 13:5)
<anonymous> (script.js 17:1)

For _loop (script.js 7:5) we are inside a generated range with a definition. So we look at that original block scope and follow the scope chain outwards to the original function scope (foo). Using the name of that original function scope and standard "mappings" we would translate _loop (script.js 7:5) to foo (original.js 7:5) because the console.trace call is actually on the same position in both original and authored.

foo (script.js 13:5) is inside a generated range with no definition so we drop the stack frame.

<anonymous> (script.js 17:1) would probably be in a generated range that maps to the script/global scope so we keep and map it.

The resulting stack trace would be:

foo (original.js 7:5)
<anonymous> (origianl.js 12:1)

Note that this approach uses the current proposal only. It would require generators to hide functions from stack traces by "masking" their call-sites with generated ranges that have no definition.

Second note: Emitting a generated range for the whole var _loop = function _loop() ... with no definition doesn't help us since you actually want to hide calls to this function not the actual function body as that corresponds to actual authored code.

Hope all of this makes sense. Please keep more examples coming, this is a great way to think through if the proposal actually works for the various transformations babel and other generators are doing.

szuend commented 4 days ago

Alternative implementation:

If we add a GeneratedRange.isFunctionScope flag (or rename the existing isScope flag, then we can also implement this differently without generators having to "mask" call-sites to hidden functions.

Then processing a stack trace would look something like this:

For the above example this would mean: Generators would emit a range for var _loop = function _loop() ... with no definition and isFunctionScope: true. When we process the second frame foo (script.js 13:5) we look at the previous frame _loop (script.js 7:5) and find the closest isFunctionScope: true generated range from 7:5 going outwards. We find one and it doesn't have a definition so we can omit the second frame from the stack trace.

Why a isHidden flag won't work

Just annotating generated ranges with isHidden, is not enough. There could be multiple generated ranges in the chain that correspond to JS functions, but we have no way to tell. For example:

function foo() {
   // ...
   function bar() {
     fnThatThrows();
   }
}

bar should be visible but foo should be hidden. With just an isHidden flag this won't since we would find foo as a hidden range when doing the check for the fnThatThrows call-site in bar.