-
-
Notifications
You must be signed in to change notification settings - Fork 6.2k
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
Comments
In the source map there is a weird mapping into the position where debugger stops. I tested if removing this with |
I guess we need to tell the debugger that the property access of One idea would be to avoid the property access with a helper function and map that helper function to somewhere and set that in 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. |
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 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:
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? 🤔 |
Oh, sorry, I wrote |
I tested Ari's reproduction with 6.0.0-beta.10 and now it steps back and forth once more due to It looks like, in principle, using
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 😄 |
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 |
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 function foo() {
console.log(this)
return this
}
Object.defineProperty(__vite_ssr_exports__, "foo", { enumerable: true, configurable: true, value: foo.bind(undefined) });
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:
```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', () => {
|
A note about ESM sealing - ES Module Namespace is sealed ( (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) |
What if we use a different object inside the original module, and the public 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 Playground: https://stackblitz.com/edit/node-lrku7j?file=index.js Issues with this approach:
All of these are fixable with a proxy that doesn't have a 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 - Playground with a proxy: https://stackblitz.com/edit/node-7j9afi?file=index.js |
What we can definitely do right now without any breaking change is to have |
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:
Benchmark-wise it seems to be the slowest one 😄 (Even slower than getters, even though we don't define a In real-world scenarios using the module runner, it makes it around ~4 mil operations slower: 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 |
Ah, I wasn't understanding that.
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;
}
I avoided this because I didn't know when I need to inject
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... |
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.
Debugger should stop at line 1 or 2, but instead it stops at 7.
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
intoimport()
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.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
Used Package Manager
pnpm
Logs
No response
Validations
The text was updated successfully, but these errors were encountered: