Skip to content

Commit a729eb8

Browse files
committed
wip: Copy properties onto chained promises
This might be an acceptable compromise, so long as it doesn't blow up our bundle size too much. Copy properties from the original promise onto the chained tracker, so that we can return something that can inspect the error, does not obscure `unhandledrejection`, *and* supports jQuery's extended PromiseLike objects.
1 parent cc40872 commit a729eb8

File tree

4 files changed

+79
-33
lines changed

4 files changed

+79
-33
lines changed

packages/core/src/asyncContext/stackStrategy.ts

Lines changed: 6 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { Client } from '../client';
22
import { getDefaultCurrentScope, getDefaultIsolationScope } from '../defaultScopes';
33
import { Scope } from '../scope';
4+
import { chainAndCopyPromiseLike } from '../utils/chain-and-copy-promiselike';
45
import { isThenable } from '../utils/is';
56
import { getMainCarrier, getSentryCarrier } from './../carrier';
67
import type { AsyncContextStrategy } from './types';
@@ -52,20 +53,11 @@ export class AsyncContextStack {
5253
}
5354

5455
if (isThenable(maybePromiseResult)) {
55-
// Attach handlers but still return original promise
56-
maybePromiseResult.then(
57-
res => {
58-
this._popScope();
59-
return res;
60-
},
61-
_e => {
62-
this._popScope();
63-
// We don't re-throw the error here because the caller already receives the original rejection, and it's being handled by the caller of withScope.
64-
// Re-throwing it here would cause unhandled promise rejections.
65-
},
66-
);
67-
68-
return maybePromiseResult;
56+
return chainAndCopyPromiseLike(
57+
maybePromiseResult as PromiseLike<Awaited<typeof maybePromiseResult>> & Record<string, unknown>,
58+
() => this._popScope(),
59+
() => this._popScope(),
60+
) as T;
6961
}
7062

7163
this._popScope();
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
/**
2+
* Copy the properties from a decorated promiselike object onto its chained
3+
* actual promise.
4+
*/
5+
export const chainAndCopyPromiseLike = <V, T extends PromiseLike<V> & Record<string, unknown>>(
6+
original: T,
7+
onSuccess: (value: V) => void,
8+
onError: (e: unknown) => void,
9+
): T => {
10+
return copyProps(
11+
original,
12+
original.then(
13+
value => {
14+
onSuccess(value);
15+
return value;
16+
},
17+
err => {
18+
onError(err);
19+
throw err;
20+
},
21+
),
22+
) as T;
23+
};
24+
25+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
26+
const copyProps = <T extends Record<string, any>>(original: T, chained: T): T => {
27+
for (const key in original) {
28+
if (key in chained) continue;
29+
const value = original[key];
30+
if (typeof value === 'function') {
31+
Object.defineProperty(chained, key, {
32+
value: (...args: unknown[]) => value.apply(original, args),
33+
enumerable: true,
34+
configurable: true,
35+
writable: true,
36+
});
37+
} else {
38+
(chained as Record<string, unknown>)[key] = value;
39+
}
40+
}
41+
42+
return chained;
43+
};

packages/core/src/utils/handleCallbackErrors.ts

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { chainAndCopyPromiseLike } from '../utils/chain-and-copy-promiselike';
12
import { isThenable } from '../utils/is';
23

34
/* eslint-disable */
@@ -76,23 +77,21 @@ function maybeHandlePromiseRejection<MaybePromise>(
7677
onSuccess: (result: MaybePromise | AwaitedPromise<MaybePromise>) => void,
7778
): MaybePromise {
7879
if (isThenable(value)) {
79-
// Attach handlers but still return original promise
80-
value.then(
81-
res => {
80+
return chainAndCopyPromiseLike(
81+
value as MaybePromise & PromiseLike<Awaited<typeof value>> & Record<string, unknown>,
82+
result => {
8283
onFinally();
83-
onSuccess(res);
84+
onSuccess(result as Awaited<MaybePromise>);
8485
},
8586
err => {
8687
onError(err);
8788
onFinally();
8889
},
89-
);
90-
91-
return value;
90+
) as MaybePromise;
91+
} else {
92+
// Non-thenable value - call callbacks immediately and return as-is
93+
onFinally();
94+
onSuccess(value);
9295
}
93-
94-
// Non-thenable value - call callbacks immediately and return as-is
95-
onFinally();
96-
onSuccess(value);
9796
return value;
9897
}

packages/core/test/lib/tracing/trace.test.ts

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -212,24 +212,36 @@ describe('startSpan', () => {
212212

213213
describe('AsyncContext withScope promise integrity behavior', () => {
214214
it('returns the original promise instance', async () => {
215-
const original = Promise.resolve(42);
216-
const result = startSpan({}, () => original);
217-
expect(result).toBe(original); // New behavior
215+
const original = Object.assign(Promise.resolve(42), {
216+
value: 123,
217+
getValue() {
218+
return this.value;
219+
},
220+
});
221+
const result = startSpan({ name: 'test' }, () => original);
222+
// preserve add-on methods on handled promises
223+
expect(result.getValue()).toBe(original.getValue());
218224
});
219225

220226
it('returns same instance on multiple calls', () => {
221-
const p = Promise.resolve(1);
222-
const result1 = startSpan({}, () => p);
223-
const result2 = startSpan({}, () => p);
224-
expect(result1).toBe(result2);
227+
const p = Object.assign(Promise.resolve(1), {
228+
value: 42,
229+
getValue() {
230+
return this.value;
231+
},
232+
});
233+
const result1 = startSpan({ name: 'test' }, () => p);
234+
const result2 = startSpan({ name: 'test' }, () => p);
235+
expect(result1.getValue()).toBe(42);
236+
expect(result2.getValue()).toBe(42);
225237
});
226238

227239
it('preserves custom thenable methods', async () => {
228240
const jqXHR = {
229241
then: Promise.resolve(1).then.bind(Promise.resolve(1)),
230242
abort: vi.fn(),
231243
};
232-
const result = startSpan({}, () => jqXHR);
244+
const result = startSpan({ name: 'test' }, () => jqXHR);
233245
expect(typeof result.abort).toBe('function');
234246
result.abort();
235247
expect(jqXHR.abort).toHaveBeenCalled();

0 commit comments

Comments
 (0)