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

SSR transformed named imported functions don't work with debuggers "step-into" #18325

Open
7 tasks done
AriPerkkio opened this issue Oct 10, 2024 · 28 comments
Open
7 tasks done

Comments

@AriPerkkio
Copy link
Contributor

AriPerkkio commented Oct 10, 2024

Describe the bug

Originally reported at vitest-dev/vitest#5569. I think I've seen 2-3 similar reports in the past too.

When a file is SSR transformed in Vite, debuggers like Chrome Dev tools and VSCode are unable to "step into" functions that are imported via named imports. In practice this means stopping debugger in line 3 and pressing "step into" button of debuggers in the example below.

1 | import { add } from "./math.ts";
2 |
3 | add(1, 2);

Debugger should stop at line 1 or 2, but instead it stops at 7.

1 | export function add(a: number, b: number): number {
2 |   if (a === 1) {
3 |     return 1;
4 |   }
5 | 
6 |   return a + b;
7 | }
8 |

Source map of the math.ts in reproduction: https://evanw.github.io/source-map-visualization/#NDI2AGZ1bmN0....

Video demonstrating step-into issue with Vite SSR
vite-step-into.webm

I think this has something to do with the way Vite SSR transforms import into import() and saves the results into single variable. In the reproduction setup I've created "Vite simulation", where SSR transformed file is loaded directly into Node.

In this example "step into" works correctly on lines 11 and 15. If Vite was able to destructure the named imports of __vite_ssr_exports__ into separate variables, I think debuggers would work just fine. But this would break ESM live bindings.

 1 | globalThis.__vite_ssr_exports__ = {};
 2 | debugger;
 3 | 
 4 | await import("./transpiled.js");
 5 | const { add, multiply } = globalThis.__vite_ssr_exports__;
 6 | 
 7 | // Incorrect position when "step into"
 8 | globalThis.__vite_ssr_exports__.add(1, 2);
 9 | 
10 | // Correct position when "stepinto"
11 | add(1, 2);
12 | 
13 | // Same here
14 | globalThis.__vite_ssr_exports__.multiply(1, 2);
15 | multiply(1, 2);
16 |
Video showing how lines 11 and 15 work correctly
node-step-into.webm

Reproduction

https://github.com/AriPerkkio/vite-ssr-transform-sourcemaps-issue

Steps to reproduce

See README https://github.com/AriPerkkio/vite-ssr-transform-sourcemaps-issue/blob/main/README.md.

System Info

System:
    OS: macOS 15.0.1
    CPU: (8) arm64 Apple M2
    Memory: 471.58 MB / 16.00 GB
    Shell: 3.2.57 - /bin/bash
  Binaries:
    Node: 20.17.0 - ~/.nvm/versions/node/v20.17.0/bin/node
    Yarn: 1.22.22 - ~/.nvm/versions/node/v20.17.0/bin/yarn
    npm: 10.8.2 - ~/.nvm/versions/node/v20.17.0/bin/npm
    pnpm: 8.15.9 - ~/.nvm/versions/node/v20.17.0/bin/pnpm
  Browsers:
    Chrome: 129.0.6668.91
    Safari: 18.0.1

Used Package Manager

pnpm

Logs

No response

Validations

@AriPerkkio
Copy link
Contributor Author

In the source map there is a weird mapping into the position where debugger stops. I tested if removing this with magic-string + @ampproject/remapping helps, but debugger is still working the same.

@sapphi-red
Copy link
Member

sapphi-red commented Oct 11, 2024

I guess we need to tell the debugger that the property access of __vite_ssr_import_0__.add should be ignored when stepping. But I'm not sure if there's a way to do that.

One idea would be to avoid the property access with a helper function and map that helper function to somewhere and set that in x_google_ignoreList.

const access = (obj, key) => obj[key] // the helper function

access(__vite_ssr_import_0__, 'add')(1, 2)

I'm not sure if that would work though.

@sheremet-va
Copy link
Member

sheremet-va commented Nov 20, 2024

Can we transform the code into something like this? Instead of letting the getter keep track of the override, we keep track of it ourselves since this is statically analysable.

// source code
export function test() {}
test = function override() {}
// ssr transform
function test() {}
__vite_ssr_exports__.test = test
test = __vite_ssr_exports__.test = function override() {}

There are some edge cases like ([test] = [function name(){}]), we can also support those. The number of reassignment operations is finite 😄

Or just have a helper that syncs the value:

function test() {}
__vite_ssr_define_export__('test', test)
test = function override() {}
__vite_ssr_define_export__('test', test)

This should remove the debugger issue and the getter access overhead that we see in benchmarks:

Screenshot 2024-11-20 at 11 34 48
  • "import" is the usual Vite SSR import
  • "import-reassign" is storing the Vite SSR import in a local variable so getter it not invoked every time
  • "import-getter" is actually accessing a property on the object, not a getter

https://stackblitz.com/edit/vitest-dev-vitest-icireq?file=src%2Fbasic.bench.ts

The main issue with this approach is how do we seal the namespace object then? 🤔

@sapphi-red
Copy link
Member

sapphi-red commented Nov 21, 2024

Oh, sorry, I wrote __vite_ssr_exports__ where I should write __vite_ssr_import_0__ in the previous comment. I updated the comment.
I think the problem here is on the importer side rather than the importee side. Even if we change the exports as you proposed, I guess we still need to access the exported values from other files with __vite_ssr_import_0__.add to make live bindings work. In that case, we still have a property access and I guess the debugger stops there.

@hi-ogawa
Copy link
Collaborator

hi-ogawa commented Nov 21, 2024

I tested Ari's reproduction with 6.0.0-beta.10 and now it steps back and forth once more due to __vite_ssr_identity__. I wonder if this also adds up during bench 🤔

It looks like, in principle, using Object.defineProperty(... value: ...) instead of getter and removing __vite_ssr_identity__ is enough to make debugger step-into properly. I just tested with local build of #18725

The main issue with this approach is how do we seal the namespace object then? 🤔

If we use a plain object without sealing, would that make it faster than normal esm? That can maybe skew benchmark in an opposite way 😄

@hi-ogawa
Copy link
Collaborator

hi-ogawa commented Nov 21, 2024

Webpack also uses getter between modules during dev and they seem to have a same issue (though I couldn't find a report on their repo). I made a repro here https://github.com/hi-ogawa/reproductions/tree/main/webpack-debugger-step-into
(Maybe it can happen on production too for modules between chunks since module concatenation/hoisting is only within a same chunk.)

@sheremet-va
Copy link
Member

sheremet-va commented Nov 21, 2024

It looks like, in principle, using Object.defineProperty(... value: ...) instead of getter and removing __vite_ssr_identity__ is enough to make debugger step-into properly. I just tested with local build of #18725

That is what I was implying in my message. The issue is not with how it's accessed (local variable or property access), but with the getter. And now __vite_ssr_identity__ adds additional overhead. Do we really need to use __vite_ssr_identity__? Can't we just bind the context?

function foo() {
  console.log(this)
  return this
}
Object.defineProperty(__vite_ssr_exports__, "foo", { enumerable: true, configurable: true, value: foo.bind(undefined) });

If we use a plain object without sealing, would that make it faster than normal esm? That can maybe skew benchmark in an opposite way 😄

I don't think we can match the performance of normal ESM, to be honest, it has access to the native APIs that can speed things up with static imports. All we need to do is make this use case consistent:

import { Link } from 'module'
console.log(Link)

import { Dependency } from 'module'
console.log(Dependency.link)

Also, just a note, but it seems like it should generally take ~30,000,000 ops/sec:

┌─────────┬──────────────┬──────────────┬───────────────────┬──────────┬─────────┐
│ (index) │ Task Name    │ ops/sec      │ Average Time (ns) │ Margin   │ Samples │
├─────────┼──────────────┼──────────────┼───────────────────┼──────────┼─────────┤
│ 0       │ 'esm import' │ '30,056,562' │ 33.27060399999566 │ '±0.81%' │ 1000000 │
└─────────┴──────────────┴──────────────┴───────────────────┴──────────┴─────────┘

direct-access is a bit slower, and import-reassign is a bit faster than that 😄

```js import { Bench } from 'tinybench' import { squared } from './basic.mjs'

const bench = new Bench({ name: 'esm import', time: 0, iterations: 1000_000 })

bench.add('esm import', () => {
squared(2)
})
await bench.run()
console.table(bench.table())

</details>

@sheremet-va
Copy link
Member

sheremet-va commented Nov 21, 2024

A note about ESM sealing - ES Module Namespace is sealed (Object.seal), not frozen (Object.freeze), meaning we can't add new properties or delete existing ones, but we can override the values (https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/import#module_namespace_object). But all properties are also read-only 😄

(I also just noticed that the keys are not sorted, and the descriptor is not correct - it should say writable, but still throw an error on writing)

@sheremet-va
Copy link
Member

sheremet-va commented Nov 21, 2024

What if we use a different object inside the original module, and the public namespace object just inherits it as a prototype?

const __vite_ssr_exports__ = Object.create(null) // private module
function foo() {}
Object.defineProperty(__vite_ssr_exports__, 'foo', { configurable: false, writable: true, value: foo })
foo = function newFoo() {}
Object.defineProperty(__vite_ssr_exports__, 'foo', { configurable: false, writable: true, value: foo })
const __vite_ssr_import_0__ = await __vite_ssr_import__('./foo.js')
// this return `__vite_ssr_public_exports__` instead of `__vite_ssr_exports__`
const __vite_ssr_public_exports__ = Object.freeze(Object.create(__vite_ssr_exports__))

That way we can modify the values on __vite_ssr_exports__ but users still can't change it on __vite_ssr_public_exports__ because the object is frozen

Playground: https://stackblitz.com/edit/node-lrku7j?file=index.js

Issues with this approach:

  • Object.getPrototypeOf(module) is an object __vite_ssr_exports__ instead of null
  • Object.keys() doesn't return anything
  • __vite_ssr_public_exports__.reassignedValue = 1 returns a different error than if it was read-only
  • Couldn't benchmark this reliably - sometimes the prototype approach is much faster than the rest, but sometimes it's two times slower 😄

All of these are fixable with a proxy that doesn't have a get handler to avoid stepping into:

const originalModule = Object.create(__vite_ssr_exports__)
Object.defineProperty(originalModule, Symbol.for('nodejs.util.inspect.custom'), {
  writable: false,
  enumerable: false,
  value: function () {
    const utils = require('node:util');
    return utils.format(__vite_ssr_exports__);
  },
});
const mod = new Proxy(originalModule, {
  set(_, p) {
    const key = String(p);
    if (!Reflect.has(__vite_ssr_exports__, p)) {
      throw new TypeError(
        `Cannot add property ${key}, object is not extensible`
      );
    }
    throw new TypeError(
      `Cannot assign to read only property '${key}' of object 'Module'`
    );
  },
  defineProperty(_, p) {
    const key = String(p);
    if (!Reflect.has(__vite_ssr_exports__, k)) {
      throw new TypeError(
        `Cannot define property ${key}, object is not extensible`
      );
    }
    throw new TypeError(`Cannot redefine property: ${key}`);
  },
  setPrototypeOf() {
    throw new TypeError('Module is not extensible');
  },
  getPrototypeOf() {
    return null;
  },
  getOwnPropertyDescriptor(_, p) {
    return Reflect.getOwnPropertyDescriptor(__vite_ssr_exports__, p);
  },
  has(_, p) {
    return Reflect.has(__vite_ssr_exports__, p);
  },
  ownKeys() {
    return [...Refect.ownKeys(__vite_ssr_exports__), Symbol.for('nodejs.util.inspect.custom')];
  },
  isExtensible() {
    return false;
  },
  preventExtensions() {
    return true;
  },
});

Another problem arises - Object.isSealed(mod) returns false 😞

Playground with a proxy: https://stackblitz.com/edit/node-7j9afi?file=index.js

@sheremet-va
Copy link
Member

sheremet-va commented Nov 21, 2024

What we can definitely do right now without any breaking change is to have Object.defineProperty(... value: ...) for any export that we know is not reassigned and doesn't need live-binding. This would at least improve the situation for most users since live-binding is not a very popular feature.

@sheremet-va
Copy link
Member

sheremet-va commented Nov 21, 2024

I think I found solution that works identical to ESM in Node.js (not in stackblitz for some reason - looks like they are using a vite-node like wrapper to import 😄 )

https://stackblitz.com/edit/node-7j9afi?file=test.js

Running this code in Node.js shows the same values for the native ESM module and the proxy+wrapper. One caveat - to make this work, we need to know all exports before we execute the code (we can just return them alongside dependencies)

// exports are statically known, fill the object before executing the code
const __vite_ssr_exports__ = {
  a: undefined,
  __proto__: null,
};
Object.defineProperty(__vite_ssr_exports__, Symbol.toStringTag, {
  enumerable: false,
  writable: false,
  configurable: false,
  value: 'Module',
});

const __vite_ssr_namespace__ = new Proxy(__vite_ssr_exports__, {
  set(target, p) {
    const key = String(p);
    if (!Reflect.has(target, p)) {
      throw new TypeError(
        `Cannot add property ${key}, object is not extensible`
      );
    }
    throw new TypeError(
      `Cannot assign to read only property '${key}' of object 'Module'`
    );
  },
});

// this also seals the __vite_ssr_exports__, but sealed objects CAN override values
Object.seal(__vite_ssr_namespace__);
// this is LEGAL and the only LEGAL way to reassign values
// we can call this INSIDE the module
__vite_ssr_exports__.a = 123;
// this is NOT LEGAL (because of the proxy),
// this is the module exposed to the user
__vite_ssr_namespace__.a = 123;

// this returns descriptors ACCORDING to the MDN (writable: true)
console.log(Object.getOwnPropertyDescriptors(__vite_ssr_namespace__))

The new algorithm would be something like this:

  • Parse and transform the file as usual, but
    • Keep a record of all exports
    • Use = assignment to assign values instead of Object.defineProperty because the module will be sealed before the values are assigned at runtime
    • Add reassignments of __vite_ssr_exports__.${method} when the export is reassigned
    • Instead of using __vite_ssr_identity__ use (0, method)()
  • Create the __vite_ssr_exports__ objects with all exported properties assigned to undefined (we kept the record of all exports) and sort them
  • Create a public proxy __vite_ssr_namespace__ that throws an error if someone attempts to assign a value
  • __vite_ssr_import__ now returns __vite_ssr_namespace__ instead of __vite_ssr_exports__ for public usage

Benchmark-wise it seems to be the slowest one 😄 (Even slower than getters, even though we don't define a get method on a proxy). Property access seems to be on par with reassignment (I recommend running benchmarks locally and not on stackblitz because it messes with imports):

Screenshot 2024-11-21 at 18 43 19

In real-world scenarios using the module runner, it makes it around ~4 mil operations slower:

Screenshot 2024-11-21 at 20 45 56

const server = await createServer({
  root: process.cwd(),
})

const AsyncFunction = async function () {}.constructor as typeof Function

class MyEvaluator {
  startOffset: number
  runExternalModule: (url: string) => Promise<any>

  constructor() {
    const evaluator = new ESModulesEvaluator()
    this.runExternalModule = evaluator.runExternalModule.bind(evaluator)
    this.startOffset = evaluator.startOffset
  }

  async runInlinedModule(context: ModuleRunnerContext, code: string, module: EvaluatedModuleNode): Promise<any> {
    context[ssrModuleExportsKey].squared = undefined
    const __vite_ssr_namespace__ = new Proxy(context[ssrModuleExportsKey], {
      set(target, p) {
        const key = String(p);
        if (!Reflect.has(target, p)) {
          throw new TypeError(
            `Cannot add property ${key}, object is not extensible`
          );
        }
        throw new TypeError(
          `Cannot assign to read only property '${key}' of object 'Module'`
        );
      },
    });
    module.exports = __vite_ssr_namespace__
    Object.seal(__vite_ssr_namespace__)
    const transform = code.replace('Object.defineProperty(__vite_ssr_exports__, "squared", { enumerable: true, configurable: true, get(){ return squared }});', '__vite_ssr_exports__.squared = squared;')
    console.log(transform)
    // use AsyncFunction instead of vm module to support broader array of environments out of the box
    const initModule = new AsyncFunction(
      ssrModuleExportsKey,
      ssrImportMetaKey,
      ssrImportKey,
      ssrDynamicImportKey,
      ssrExportAllKey,
      // source map should already be inlined by Vite
      '"use strict";' + transform,
    )

    await initModule(
      context[ssrModuleExportsKey],
      context[ssrImportMetaKey],
      context[ssrImportKey],
      context[ssrDynamicImportKey],
      context[ssrExportAllKey],
    )
  }
}

class OverrideModuleRunner extends ModuleRunner {
  override async directRequest(
    url: string,
    mod: EvaluatedModuleNode,
    _callstack: string[],
  ) {
    await super.directRequest(url, mod, _callstack)
    return mod.exports
  }
}

const createServerModuleRunnerTransport = (options: {
  channel: any
}): ModuleRunnerTransport => {
  const hmrClient: HotChannelClient = {
    send: (payload: HotPayload) => {
      if (payload.type !== 'custom') {
        throw new Error(
          'Cannot send non-custom events from the client to the server.',
        )
      }
      options.channel.send!(payload)
    },
  }

  let handler: ((data: HotPayload) => void) | undefined

  return {
    connect({ onMessage }) {
      options.channel.api!.outsideEmitter.on('send', onMessage)
      onMessage({ type: 'connected' })
      handler = onMessage
    },
    disconnect() {
      if (handler) {
        options.channel.api!.outsideEmitter.off('send', handler)
      }
    },
    send(payload) {
      if (payload.type !== 'custom') {
        throw new Error(
          'Cannot send non-custom events from the server to the client.',
        )
      }
      options.channel.api!.innerEmitter.emit(
        payload.event,
        payload.data,
        hmrClient,
      )
    },
  }
}

const runner = new OverrideModuleRunner(
  {
    root: server.config.root,
    transport: createServerModuleRunnerTransport({
      channel: server.environments.ssr.hot
    })
  },
  new MyEvaluator()
)

const defaultRunner = createServerModuleRunner(server.environments.ssr)

const module = await runner.import('/basic.mjs')

bench(
  'proxy-access',
  () => {
    module.squared(2)
  },
  config
);

const module2 = await defaultRunner.import('/basic.mjs')

bench(
  'direct-access',
  () => {
    module2.squared(2);
  },
  config
);

Just a note: internally, module namespace exports are defined as getters/setters: https://github.com/nodejs/node/blob/main/deps/v8/src/builtins/accessors.cc#L265

@sapphi-red
Copy link
Member

That is what I was implying in my message. The issue is not with how it's accessed (local variable or property access), but with the getter.

Ah, I wasn't understanding that.

Do we really need to use __vite_ssr_identity__? Can't we just bind the context?

I didn't come up with that. I guess it needs to be like:

Object.defineProperty(__vite_ssr_exports__, "foo", {
  enumerable: true,
  configurable: true,
  // use Function.prototype.bind to make functions with null prototype work
  // check if it's a function in runtime (as we cannot statically analyze it, see the code below)
  value: typeof foo === 'function' ? Function.prototype.bind.call(foo, undefined) : foo;
});
// foo.mjs
import { a, update } from './bar.mjs';

console.log(a); // function
update();
console.log(a); // 123

// bar.mjs
export function a() {}

export function update() {
  a = 123;
}
  • Instead of using __vite_ssr_identity__ use (0, method)()

I avoided this because I didn't know when I need to inject ; before (0, method)() to prevent foo(0, method)() from happening.

Benchmark-wise it seems to be the slowest one 😄

It'd be nice to compare the real world case. For example, running vitest in some repo and check the time it takes. If it has big difference, maybe we can only use proxy if debugger is enabled 🤔 That could make the behavior confusing though...

@sheremet-va
Copy link
Member

One caveat - to make this work, we need to know all exports before we execute the code (we can just return them alongside dependencies)

We can't just return them because users can do export * from another dependency 😞

@hi-ogawa
Copy link
Collaborator

I started to tinker with your idea. One thing I'm stuck is how to transform renamed re-export?

let p = 0;
export { p as q };
p = 1;

For the algorithm #18325 (comment), if the replacement is simply based on identifier, this becomes a following and loses local assignment of p.

let p = 0;
__vite_ssr_exports__["q"] = p
__vite_ssr_exports__["q"] = 1;

@hi-ogawa
Copy link
Collaborator

hi-ogawa commented Nov 25, 2024

Oh wait, I guess it's same for simple export too, so we don't replace identifier, but repeat same assignment like this?

// input
export let x = 0;
x = 1;
// output
let x = 0;
__vite_ssr_exports__["x"] = x;

x = 1; // keep this but detect assignment to x
__vite_ssr_exports__["x"]  = x; // then repeat

Maybe then this can generalize as:

// input
let p = 0;
export { p as q };
p = 1;
// output
let p = 0;
__vite_ssr_exports__["q"] = p

p = 1
__vite_ssr_exports__["q"] = p;

Now re-reading the algorithm #18325 (comment), I can see that's what's explained 😄

@hi-ogawa
Copy link
Collaborator

Another question, is there a way to handle assignment in expression like this?

// input
export let x = 0;
someFn(x = 1);
// output
let x = 0;
__vite_ssr_exports__["x"] = x;

someFn(x = 1);
__vite_ssr_exports__["x"] = x; // this is technically too late

Also more complicated pattern is possible here too.

export let x = 0;
someFn([x] = [1]);

@sheremet-va
Copy link
Member

// output
let x = 0;
__vite_ssr_exports__["x"] = x;

someFn(x = 1);
__vite_ssr_exports__["x"] = x; // this is technically too late

Can't you do this?

someFn((x = 1, __vite_ssr_exports__["x"] = x));

@hi-ogawa
Copy link
Collaborator

hi-ogawa commented Nov 26, 2024

Can't you do this?

someFn((x = 1, __vite_ssr_exports__["x"] = x));

I was thinking (,) expression, but I wasn't sure if that can generalize to arbitrary pattern. Rather too artificial but these are valid code.

export let x = 1;
someFn(x = 1);
someFn([x] = [1, 2]);
someFn({x} = { x: 1, y: 2 });

// output?
someFn((x = 1, __vite_ssr_exports__["x"] = x))
someFn(([x] = [1, 2], __vite_ssr_exports__["x"] = x, [1, 2]))
someFn(({x} = { x: 1, y: 2 }, __vite_ssr_exports__["x"] = x, { x: 1, y: 2 }))

@sheremet-va
Copy link
Member

sheremet-va commented Nov 26, 2024

I wasn't sure if that can generalize to arbitrary pattern

Maybe like this?

- someFn(([x] = [1, 2], __vite_ssr_exports__["x"] = x, [1, 2]))
+ someFn((__vite_ssr_locals__['a0'] = [1, 2], [x] = __a0, __vite_ssr_exports__["x"] = x, __vite_ssr_locals__['a0']))

@hi-ogawa
Copy link
Collaborator

__vite_ssr_locals__ might work. Another idea is to wrap expression with helper function __vite_ssr_wrap_assign_expr__. Again helper function can introduce bad "step-into" jump, but I guess we can say such assignment is really, really rare.

// input
let x = 0;
let p = 0;
export { x, p as q };

someFn(x = 1)
someFn([x, p] = [1, 2])
someFn({x, y: p} = { x: 1, y: 2 })

// output
const __vite_ssr_wrap_assign_expr__ = (value) => {
  __vite_ssr_exports__["x"] = x;
  __vite_ssr_exports__["q"] = p;
  return value;
}

someFn(__vite_ssr_wrap_assign_expr__(x = 1));
someFn(__vite_ssr_wrap_assign_expr__([x, p] = [1, 2]));
someFn(__vite_ssr_wrap_assign_expr__({x, y: p} = { x: 1, y: 2 }));

@sheremet-va
Copy link
Member

Again helper function can introduce bad "step-into" jump, but I guess we can say such assignment is really, really rare.

Does it actually introduce a jump? 🤔 Although, I think I see... Right now, the jump is happening when we access the export here it will happen when we go into a function, I guess, which is very unfortunate

I was also thinking about a similar assignment, I guess it will be generated uniquely for every assertion. Or do you just want to list every export in this function and always reassign it?

@hi-ogawa
Copy link
Collaborator

I guess it will be generated uniquely for every assertion. Or do you just want to list every export in this function and always reassign it?

I didn't see a problem with reassign everything. Another variant would be chaining them by variables?

// output
const __vite_ssr_assign_x_ = (value) => {
  __vite_ssr_exports__["x"] = x;
  return value;
}
const __vite_ssr_assign_p_ = (value) => {
  __vite_ssr_exports__["q"] = p;
  return value;
}

someFn(__vite_ssr_wrap_assign_expr_x_(x = 1));
someFn(__vite_ssr_wrap_assign_expr_x__(__vite_ssr_wrap_assign_expr_p_([x, p] = [1, 2])));

Also I remember there can be one-to-many mapping like

let p = 0;
export { p as q, p as r }

so this will be:

const __vite_ssr_assign_p_ = (value) => {
  __vite_ssr_exports__["q"] = p;
  __vite_ssr_exports__["r"] = p;
  return value;
}

@sheremet-va
Copy link
Member

If we go with functions, maybe they can be generated per assignment/variable? If there is a single assignment, it can reuse it. If there are multiple assignments which is very rare, it can generate a new one:

// output
const __vite_ssr_assign_x_ = (value) => {
  __vite_ssr_exports__["x"] = x;
  return value;
}
const __vite_ssr_assign_xp_ = (value) => {
  __vite_ssr_exports__["x"] = x;
  __vite_ssr_exports__["q"] = p;
  return value;
}

someFn(__vite_ssr_assign_x_(x = 1));
someFn(__vite_ssr_assign_xp_([x, p] = [1, 2])));

Although if we do it like this, I don't see why we can't just inline them 🤷🏻

@sheremet-va
Copy link
Member

But honestly, before we even go with either approach, we need to figure out if the Proxy approach is good enough. I feel like losing 30% in benchmarks affects the execution too much, maybe?

As a note, Vitest doesn't even seal the module, but this goes against the standard. Users will eat us alive if Vite allows it for performance, I feel like 😞

@sheremet-va
Copy link
Member

A note: with the __vite_ssr_locals__ approach, we need to release resources at one point 🤔

@hi-ogawa
Copy link
Collaborator

But honestly, before we even go with either approach, we need to figure out if the Proxy approach is good enough. I feel like losing 30% in benchmarks affects the execution too much, maybe?

As a note, Vitest doesn't even seal the module, but this goes against the standard. Users will eat us alive if Vite allows it for performance, I feel like 😞

Yeah, going against the spec for the sake of "step-into" and perf doesn't feel right.

But just one more try, if we loosen esm alignment by replacing Object.seal with Object.preventExtensions, then is this possible to not rely on __vite_ssr_namespace__ proxy?

// input
export let x = 0;
x = 1

// output
let __vite_ssr_exports__ = {};

let x = 0;
Object.defineProperty(__vite_ssr_exports__, "x", { configurable: true, enumerable: true, value: x })

const __vite_ssr_assign_x_ = (value) => {
  Object.defineProperty(__vite_ssr_exports__, "x", { configurable: true, enumerable: true, value: x })
  return value;
}
__vite_ssr_assign_x_(x = 1);

Object.preventExtensions(x);

// after this, normal assignment still errors
// __vite_ssr_exports__.x = 5678
// __vite_ssr_exports__.p = 5678

// but there's a loop hole
// Object.defineProperty(__vite_ssr_exports__, "x", { ... })

@sheremet-va
Copy link
Member

sheremet-va commented Nov 27, 2024

I don’t see how this is different from sealing it, to be honest. There is still a way to override properties, and you can also delete them!

Every approach has a downside, I guess we just have to pick the poison. Although I wonder if we can just use vm.SyntheticModule to create and fill the namespace when running in Node.js and Deno 🤔 And we can fallback to the Proxy for other usages. It requires the experimental flag. Maybe we can use C++ API directly 🤔

@sapphi-red
Copy link
Member

sapphi-red commented Nov 28, 2024

Maybe we can use C++ API directly 🤔

I guess no. That feature is not exposed in Node API (I'm not sure which feature you are referring to, but probably) and other APIs exposed from Node does not have a stable ABI. That means we'll have to compile the lib for each Node version, otherwise the users has to compile it. Even if we compile it for each version, AFAIK there's no way to do optional dependencies based on Node version, so all versions will have to be downloaded (unless we do it on our own with postinstall script).

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

No branches or pull requests

4 participants