Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

This changelog records changes to stable releases since 1.50.2. "TBA" changes here may be available in the [nightly release](https://github.com/microsoft/vscode-js-debug/#nightly-extension) before they're in stable. Note that the minor version (`v1.X.0`) corresponds to the VS Code version js-debug is shipped in, but the patch version (`v1.50.X`) is not meaningful.

## Unreleased

- feat: add `Symbol.for("debug.properties")` for custom property replacement in debugger ([vscode#102181](https://github.com/microsoft/vscode/issues/102181))

## 1.105 (September 2025)

- fix: slow sourcemap parsing for minified code ([#2265](https://github.com/microsoft/vscode-js-debug/issues/2265))
Expand Down
38 changes: 37 additions & 1 deletion src/adapter/templates/getStringyProps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ const enum DescriptionSymbols {
Generic = 'debug.description',
// Node.js-specific symbol that is used for some Node types https://nodejs.org/api/util.html#utilinspectcustom
Node = 'nodejs.util.inspect.custom',
// Symbol for custom property replacement
Properties = 'debug.properties',

// Depth for `nodejs.util.inspect.custom`
Depth = 2,
Expand All @@ -20,7 +22,11 @@ const enum DescriptionSymbols {
* use them inside the description functions.
*/
export const getDescriptionSymbols = remoteFunction(function() {
return [Symbol.for(DescriptionSymbols.Generic), Symbol.for(DescriptionSymbols.Node)];
return [
Symbol.for(DescriptionSymbols.Generic),
Symbol.for(DescriptionSymbols.Node),
Symbol.for(DescriptionSymbols.Properties),
];
});

declare const runtimeArgs: [symbol[]];
Expand Down Expand Up @@ -133,3 +139,33 @@ export const getToStringIfCustom = templateFunction(function(
}
}
});

/**
* Checks if the object has a custom properties function via Symbol.for("debug.properties")
* and returns the replacement object if it does. Returns undefined otherwise.
* The symbols array is passed via runtimeArgs[0], with properties symbol at index 2.
*/
export const getCustomProperties = templateFunction(function(this: unknown) {
const propertiesSymbol: symbol = (runtimeArgs as unknown as [symbol[]])[0][2];

if (typeof this !== 'object' || !this) {
return undefined;
}

// Check if the object has the debug.properties symbol
if (typeof (this as Record<symbol, () => unknown>)[propertiesSymbol] !== 'function') {
return undefined;
}

try {
const result = (this as Record<symbol, () => unknown>)[propertiesSymbol]();
// Only return if we got a valid object back
if (typeof result === 'object' && result !== null) {
return result;
}
} catch {
// If the function throws, we'll just return undefined and use default properties
}

return undefined;
});
150 changes: 123 additions & 27 deletions src/adapter/variableStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { getArrayProperties } from './templates/getArrayProperties';
import { getArraySlots } from './templates/getArraySlots';
import { getNodeChildren } from './templates/getNodeChildren';
import {
getCustomProperties,
getDescriptionSymbols,
getStringyProps,
getToStringIfCustom,
Expand Down Expand Up @@ -312,14 +313,49 @@ class VariableContext {
return await this.settings.descriptionSymbols;
}

/**
* Fetches properties for an object, including accessors, own properties, and stringy props.
*/
private async fetchObjectProperties(objectId: string) {
return Promise.all([
this.cdp.Runtime.getProperties({
objectId,
accessorPropertiesOnly: true,
ownProperties: false,
generatePreview: true,
}),
this.cdp.Runtime.getProperties({
objectId,
ownProperties: true,
generatePreview: true,
}),
this.cdp.Runtime.callFunctionOn({
functionDeclaration: getStringyProps.decl(
`${customStringReprMaxLength}`,
this.settings.customDescriptionGenerator || 'null',
),
arguments: [await this.getDescriptionSymbols(objectId)],
objectId,
throwOnSideEffect: true,
returnByValue: true,
})
.then(r => r?.result.value || {})
.catch(() => ({} as Record<string, string>)),
]);
}

/**
* Creates Variables for each property on the RemoteObject.
* @param skipSymbolBasedCustomProperties - If true, skips checking for Symbol.for("debug.properties")
*/
public async createObjectPropertyVars(
object: Cdp.Runtime.RemoteObject,
evaluationOptions?: Dap.EvaluationOptions,
skipSymbolBasedCustomProperties = false,
): Promise<Variable[]> {
const properties: (Promise<Variable[]> | Variable[])[] = [];
let originalObject: Cdp.Runtime.RemoteObject | undefined;
let hasCustomProperties = false;

if (this.settings.customPropertiesGenerator) {
const { result, errorDescription } = await this.evaluateCodeForObject(
Expand Down Expand Up @@ -353,31 +389,49 @@ class VariableContext {
});
}

const [accessorsProperties, ownProperties, stringyProps] = await Promise.all([
this.cdp.Runtime.getProperties({
objectId: object.objectId,
accessorPropertiesOnly: true,
ownProperties: false,
generatePreview: true,
}),
this.cdp.Runtime.getProperties({
objectId: object.objectId,
ownProperties: true,
generatePreview: true,
}),
this.cdp.Runtime.callFunctionOn({
functionDeclaration: getStringyProps.decl(
`${customStringReprMaxLength}`,
this.settings.customDescriptionGenerator || 'null',
),
arguments: [await this.getDescriptionSymbols(object.objectId)],
objectId: object.objectId,
throwOnSideEffect: true,
returnByValue: true,
})
.then(r => r?.result.value || {})
.catch(() => ({} as Record<string, string>)),
]);
let [accessorsProperties, ownProperties, stringyProps] = await this.fetchObjectProperties(
object.objectId,
);
if (!accessorsProperties || !ownProperties) return [];

// Check for Symbol.for("debug.properties") custom property replacement
// Only do this if we haven't already applied customPropertiesGenerator and if not explicitly skipped
if (!this.settings.customPropertiesGenerator && !skipSymbolBasedCustomProperties) {
// Check if the object has Symbol.for("debug.properties") by looking for it in the properties
const hasDebugPropertiesSymbol = [...accessorsProperties.result, ...ownProperties.result]
.some(
p => p.symbol?.description === 'Symbol(debug.properties)',
);

if (hasDebugPropertiesSymbol) {
try {
const customPropsResult = await this.cdp.Runtime.callFunctionOn({
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

there's a lot of duplication here, you can do better @copilot

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Refactored to eliminate duplication. Extracted the property fetching logic into a fetchObjectProperties helper method that's now used in both the initial fetch and when re-fetching for custom properties. (3773fc2)

functionDeclaration: getCustomProperties.decl(),
arguments: [await this.getDescriptionSymbols(object.objectId)],
objectId: object.objectId,
throwOnSideEffect: true,
});

if (customPropsResult && customPropsResult.result.objectId) {
// Store the original object for the escape hatch
originalObject = object;
hasCustomProperties = true;
// Replace with the custom properties object and re-fetch its properties
object = customPropsResult.result;

// Re-fetch properties for the custom properties object
[accessorsProperties, ownProperties, stringyProps] = await this.fetchObjectProperties(
object.objectId!,
);
if (!accessorsProperties || !ownProperties) return [];
}
} catch {
// If anything goes wrong, just use the original object
}
}
}

// TypeScript doesn't track the check inside the try-catch, so verify again
if (!accessorsProperties || !ownProperties) return [];

// Merge own properties and all accessors.
Expand Down Expand Up @@ -408,22 +462,29 @@ class VariableContext {
// Push own properties & accessors and symbols
for (const propertiesCollection of [propertiesMap.values(), propertySymbols.values()]) {
for (const p of propertiesCollection) {
const contextInit = hasCustomProperties
? { presentationHint: { kind: 'virtual' as const } }
: undefined;
properties.push(
this.createPropertyVar(
p,
object,
stringyProps?.hasOwnProperty(p.name)
? localizeIndescribable(stringyProps[p.name])
: undefined,
contextInit,
),
);
}
}

for (const property of ownProperties.privateProperties ?? []) {
const presentationHint = hasCustomProperties
? { kind: 'virtual' as const, visibility: 'private' as const }
: { visibility: 'private' as const };
properties.push(
this.createPropertyVar(property, object, undefined, {
presentationHint: { visibility: 'private' },
presentationHint,
sortOrder: SortOrder.Private,
}),
);
Expand Down Expand Up @@ -454,6 +515,25 @@ class VariableContext {
}
}

// Add escape hatch property if we used custom properties
if (originalObject) {
properties.push([
this.createVariable(
PropertiesGeneratorEscapeHatchVariable,
{
name: '...',
presentationHint: {
kind: 'virtual',
attributes: ['readOnly'],
visibility: 'internal',
},
sortOrder: SortOrder.Internal,
},
originalObject,
),
]);
}

return flatten(await Promise.all(properties));
}

Expand Down Expand Up @@ -481,9 +561,11 @@ class VariableContext {

const ctx: Required<IContextInit> = {
name: p.name,
presentationHint: {},
sortOrder: SortOrder.Default,
...contextInit,
presentationHint: {
...contextInit?.presentationHint,
},
};

if (!contextInit) {
Expand Down Expand Up @@ -866,6 +948,20 @@ class ObjectVariable extends Variable implements IMemoryReadable {
}
}

/**
* A variable that shows the original object properties without Symbol.for("debug.properties") processing.
* Used as the "..." escape hatch to view raw object properties.
*/
class PropertiesGeneratorEscapeHatchVariable extends ObjectVariable {
public override getChildren(_params: Dap.VariablesParamsExtended) {
return this.context.createObjectPropertyVars(
this.remoteObject,
_params.evaluationOptions,
true,
);
}
}

class NodeAttributes extends ObjectVariable {
public readonly id = getVariableId();

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
> result: BrokenClass {prop1: 'value1', prop2: 'value2'}
prop1: 'value1'
prop2: 'value2'
> [[Prototype]]: Object
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
> result: Observable {_value: 'test value', _observers: Set(3), _scheduler: {…}, _isDisposed: false}
subscriberCount: 3
value: 'test value'
> ...: Observable {_value: 'test value', _observers: Set(3), _scheduler: {…}, _isDisposed: false}
> [[Prototype]]: Object
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
> result: MyClass {internal1: 'hidden1', internal2: 'hidden2', internal3: 'hidden3'} // type=MyClass
public: 'visible' // type=string
> ...: MyClass {internal1: 'hidden1', internal2: 'hidden2', internal3: 'hidden3'} // type=MyClass
> [[Prototype]]: Object // type=Object
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
> result: Counter(42)
count: 42
listenerCount: 2
> ...: Counter {_count: 42, _listeners: Array(2)}
> [[Prototype]]: Object
Loading