Skip to content

Commit 5e99593

Browse files
committed
fix: Copy properties onto Sentry-chained promises
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. We only do this if the original PromiseLike object or its chained counterpart are not `instanceof Promise`. So far, we have not encountered such decoration on "normal" Promise objects, so this is a fast way to ensure that we're only providing this affordance where it's needed. This does introduce a limitation that if someone decorates a standard Promise object, and its decorations do not extend to chained results of `then()`, then we will lose them. If and when that happens, we can consider extending this check to something more thorough, even if it's slightly less performant. A guard is added to prevent cases where a chained/copied promise is passed through this function again, so that we know we still have to do the operation even though it's a "normal" Promise.
1 parent 7531ac4 commit 5e99593

File tree

6 files changed

+126
-37
lines changed

6 files changed

+126
-37
lines changed

.size-limit.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,7 @@ module.exports = [
148148
import: createImport('init', 'ErrorBoundary', 'reactRouterV6BrowserTracingIntegration'),
149149
ignore: ['react/jsx-runtime'],
150150
gzip: true,
151-
limit: '45 KB',
151+
limit: '45.1 KB',
152152
},
153153
// Vue SDK (ESM)
154154
{

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: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
const isActualPromise = (p: unknown) =>
2+
p instanceof Promise && !(p as unknown as ChainedPromiseLike<unknown>)[kChainedCopy];
3+
4+
type ChainedPromiseLike<T> = PromiseLike<T> & {
5+
[kChainedCopy]: true;
6+
};
7+
const kChainedCopy = Symbol('chained PromiseLike');
8+
9+
/**
10+
* Copy the properties from a decorated promiselike object onto its chained
11+
* actual promise.
12+
*/
13+
export const chainAndCopyPromiseLike = <V, T extends PromiseLike<V>>(
14+
original: T,
15+
onSuccess: (value: V) => void,
16+
onError: (e: unknown) => void,
17+
): T => {
18+
const chained = original.then(
19+
value => {
20+
onSuccess(value);
21+
return value;
22+
},
23+
err => {
24+
onError(err);
25+
throw err;
26+
},
27+
) as T;
28+
29+
// if we're just dealing with "normal" Promise objects, return the chain
30+
return isActualPromise(chained) && isActualPromise(original) ? chained : copyProps(original, chained);
31+
};
32+
33+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
34+
const copyProps = <T extends Record<string, any>>(original: T, chained: T): T => {
35+
let mutated = false;
36+
//oxlint-disable-next-line guard-for-in
37+
for (const key in original) {
38+
if (key in chained) continue;
39+
mutated = true;
40+
const value = original[key];
41+
if (typeof value === 'function') {
42+
Object.defineProperty(chained, key, {
43+
value: (...args: unknown[]) => value.apply(original, args),
44+
enumerable: true,
45+
configurable: true,
46+
writable: true,
47+
});
48+
} else {
49+
(chained as Record<string, unknown>)[key] = value;
50+
}
51+
}
52+
53+
if (mutated) Object.assign(chained, { [kChainedCopy]: true });
54+
return chained;
55+
};

packages/core/src/utils/handleCallbackErrors.ts

Lines changed: 6 additions & 8 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,21 +77,18 @@ 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;
9291
}
93-
9492
// Non-thenable value - call callbacks immediately and return as-is
9593
onFinally();
9694
onSuccess(value);

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

Lines changed: 2 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -211,25 +211,13 @@ describe('startSpan', () => {
211211
});
212212

213213
describe('AsyncContext withScope promise integrity behavior', () => {
214-
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
218-
});
219-
220-
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);
225-
});
226-
227214
it('preserves custom thenable methods', async () => {
228215
const jqXHR = {
229216
then: Promise.resolve(1).then.bind(Promise.resolve(1)),
230217
abort: vi.fn(),
231218
};
232-
const result = startSpan({}, () => jqXHR);
219+
expect(jqXHR instanceof Promise).toBe(false);
220+
const result = startSpan({ name: 'test' }, () => jqXHR);
233221
expect(typeof result.abort).toBe('function');
234222
result.abort();
235223
expect(jqXHR.abort).toHaveBeenCalled();
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { chainAndCopyPromiseLike } from '../../../src/utils/chain-and-copy-promiselike';
3+
4+
describe('chain and copy promiselike objects', () => {
5+
it('does no copying for normal promises', async () => {
6+
const p = new Promise<number>(res => res(1));
7+
Object.assign(p, { newProperty: true });
8+
let success = false;
9+
let error = false;
10+
const q = chainAndCopyPromiseLike(
11+
p,
12+
() => {
13+
success = true;
14+
},
15+
() => {
16+
error = true;
17+
},
18+
);
19+
expect(await q).toBe(1);
20+
//@ts-expect-error - this is not a normal prop on Promises
21+
expect(q.newProperty).toBe(undefined);
22+
expect(success).toBe(true);
23+
expect(error).toBe(false);
24+
});
25+
26+
it('copies properties of non-Promise then-ables', async () => {
27+
class FakePromise<T extends unknown> {
28+
value: T;
29+
constructor(value: T) {
30+
this.value = value;
31+
}
32+
then(fn: (value: T) => unknown) {
33+
const newVal = fn(this.value);
34+
return new FakePromise(newVal);
35+
}
36+
}
37+
const p = new FakePromise(1) as PromiseLike<number>;
38+
Object.assign(p, { newProperty: true });
39+
let success = false;
40+
let error = false;
41+
const q = chainAndCopyPromiseLike(
42+
p,
43+
() => {
44+
success = true;
45+
},
46+
() => {
47+
error = true;
48+
},
49+
);
50+
expect(await q).toBe(1);
51+
//@ts-expect-error - this is not a normal prop on FakePromises
52+
expect(q.newProperty).toBe(true);
53+
expect(success).toBe(true);
54+
expect(error).toBe(false);
55+
});
56+
});

0 commit comments

Comments
 (0)