diff --git a/.nx/version-plans/version-plan-1785777985382.md b/.nx/version-plans/version-plan-1785777985382.md new file mode 100644 index 00000000..63ab081a --- /dev/null +++ b/.nx/version-plans/version-plan-1785777985382.md @@ -0,0 +1,5 @@ +--- +__default__: patch +--- + +Harness tracks pending work while preserving native Promise instances so native modules return their settled values on Hermes. diff --git a/packages/runtime/src/__tests__/promise-tracker.test.ts b/packages/runtime/src/__tests__/promise-tracker.test.ts index 3764397b..f7200bb9 100644 --- a/packages/runtime/src/__tests__/promise-tracker.test.ts +++ b/packages/runtime/src/__tests__/promise-tracker.test.ts @@ -35,6 +35,49 @@ describe('promise tracker', () => { expect(pending[0].stack).toContain('Promise created'); }); + it('preserves native Promise instances created through the global constructor', () => { + const NativePromise = globalThis.Promise; + installPromiseTracker(); + + const promise = new Promise(() => undefined); + + expect(Object.getPrototypeOf(promise)).toBe(NativePromise.prototype); + expect(promise).toBeInstanceOf(Promise); + expect(promise.constructor).toBe(Promise); + expect(Promise.resolve(promise)).toBe(promise); + expect(Object.keys(promise)).toEqual([]); + }); + + it('preserves custom Promise subclass behavior', async () => { + installPromiseTracker(); + + let usedCustomThen = false; + class CustomPromise extends Promise { + override then( + onfulfilled?: + | ((value: T) => TResult1 | PromiseLike) + | undefined + | null, + onrejected?: + | ((reason: unknown) => TResult2 | PromiseLike) + | undefined + | null, + ): Promise { + usedCustomThen = true; + return super.then(onfulfilled, onrejected); + } + } + + const promise = new CustomPromise((resolve) => resolve(55)); + + expect(Object.getPrototypeOf(promise)).toBe(CustomPromise.prototype); + expect(promise).toBeInstanceOf(CustomPromise); + expect(promise.constructor).toBe(CustomPromise); + expect(CustomPromise.resolve(promise)).toBe(promise); + await expect(promise.then((value) => value)).resolves.toBe(55); + expect(usedCustomThen).toBe(true); + }); + it('removes promises when they resolve', async () => { installPromiseTracker(); @@ -43,6 +86,86 @@ describe('promise tracker', () => { expect(getPendingPromises()).toHaveLength(0); }); + it('preserves native Promise static method behavior', async () => { + const NativePromise = globalThis.Promise; + installPromiseTracker(); + + const resolvedValue = Promise.resolve(55); + const resolvedUndefined = Promise.resolve(); + const rejected = Promise.reject('failed'); + const all = Promise.all([55]); + const allSettled = Promise.allSettled([55]); + const any = Promise.any([55]); + const race = Promise.race([55]); + const nativeWithResolvers: unknown = Reflect.get( + NativePromise, + 'withResolvers', + ); + const trackedWithResolvers: unknown = Reflect.get( + Promise, + 'withResolvers', + ); + const withResolvers = + typeof nativeWithResolvers === 'function' && + typeof trackedWithResolvers === 'function' + ? (Reflect.apply(trackedWithResolvers, Promise, []) as { + promise: Promise; + resolve: (value: number) => void; + }) + : null; + const nativeTry: unknown = Reflect.get(NativePromise, 'try'); + const trackedTry: unknown = Reflect.get(Promise, 'try'); + const tried = + typeof nativeTry === 'function' && typeof trackedTry === 'function' + ? (Reflect.apply(trackedTry, Promise, [() => 55]) as Promise) + : null; + const rejectedAssertion = expect(rejected).rejects.toBe('failed'); + + for (const promise of [ + resolvedValue, + resolvedUndefined, + rejected, + all, + allSettled, + any, + race, + ...(withResolvers ? [withResolvers.promise] : []), + ...(tried ? [tried] : []), + ]) { + expect(Object.getPrototypeOf(promise)).toBe(NativePromise.prototype); + } + + withResolvers?.resolve(55); + + await expect(resolvedValue).resolves.toBe(55); + await expect(resolvedUndefined).resolves.toBeUndefined(); + await rejectedAssertion; + await expect(all).resolves.toEqual([55]); + await expect(allSettled).resolves.toEqual([ + { status: 'fulfilled', value: 55 }, + ]); + await expect(any).resolves.toBe(55); + await expect(race).resolves.toBe(55); + if (withResolvers) { + await expect(withResolvers.promise).resolves.toBe(55); + } + if (tried) { + await expect(tried).resolves.toBe(55); + } + }); + + it('resolves values when Promise.resolve is invoked without a receiver', async () => { + installPromiseTracker(); + + const resolve = Promise.resolve; + + await expect(Reflect.apply(resolve, undefined, [55])).resolves.toBe(55); + await expect(Reflect.apply(resolve, undefined, [])).resolves.toBeUndefined(); + await expect( + Reflect.apply(resolve, undefined, [undefined]), + ).resolves.toBeUndefined(); + }); + it('keeps promises pending while their resolved thenable is pending', () => { installPromiseTracker(); diff --git a/packages/runtime/src/promise-tracker.ts b/packages/runtime/src/promise-tracker.ts index 382d7705..2f526fa2 100644 --- a/packages/runtime/src/promise-tracker.ts +++ b/packages/runtime/src/promise-tracker.ts @@ -15,10 +15,6 @@ export type TrackedPromiseRecord = { type PromiseResolve = (value: T | PromiseLike) => void; type PromiseReject = (reason?: unknown) => void; -type PromiseExecutor = ( - resolve: PromiseResolve, - reject: PromiseReject -) => void; // Backstop against unbounded growth if GC can't keep up: never-settling // promises created in a hot loop would otherwise OOM the heap. Diagnostics only @@ -151,120 +147,229 @@ const wrapPromiseCallback = ( const createTrackedPromiseConstructor = (): PromiseConstructor => { const NativePromise = getOriginalPromise(); - class TrackedPromise extends NativePromise { - constructor(executor: PromiseExecutor) { - const registration = registerPromise(); + const staticPromiseMethods = new Set([ + 'resolve', + 'reject', + 'all', + 'allSettled', + 'any', + 'race', + 'try', + 'withResolvers', + ]); + const staticMethodWrappers = new Map< + PropertyKey, + { method: unknown; wrapper: unknown } + >(); + + function propagatePromiseContext( + promise: Promise, + context: PromiseTrackerTestContext | undefined, + decorate = false, + ): Promise { + if (context) { + promiseContexts.set(promise, context); + } - super((resolve, reject) => { - try { - executor( - (value: T | PromiseLike) => { - if (isThenable(value)) { - runWithoutPromiseTracking(() => { - NativePromise.resolve(value).then( - () => markPromiseSettled(registration.id), - () => markPromiseSettled(registration.id) - ); - }); - } else { - markPromiseSettled(registration.id); - } + if ((context || decorate) && Object.isExtensible(promise)) { + const properties: PropertyDescriptorMap = {}; - resolve(value); - }, - (reason?: unknown) => { - markPromiseSettled(registration.id); - reject(reason); - } - ); - } catch (error) { - markPromiseSettled(registration.id); - throw error; - } - }); + if (promise.then === NativePromise.prototype.then) { + properties.then = { + configurable: true, + writable: true, + value: trackedThen, + }; + } - if (registration.id !== null) { - promiseIds.set(this, registration.id); - promiseFinalization?.register(this, registration.id); + if (promise.catch === NativePromise.prototype.catch) { + properties.catch = { + configurable: true, + writable: true, + value: trackedCatch, + }; } - if (registration.test) { - promiseContexts.set(this, registration.test); + if (promise.finally === NativePromise.prototype.finally) { + properties.finally = { + configurable: true, + writable: true, + value: trackedFinally, + }; } + + Object.defineProperties(promise, properties); } - override then( - onfulfilled?: - | ((value: T) => TResult1 | PromiseLike) - | undefined - | null, - onrejected?: - | ((reason: unknown) => TResult2 | PromiseLike) - | undefined - | null - ): Promise { - const context = promiseContexts.get(this); - const result = runWithoutPromiseTracking(() => - super.then( - wrapPromiseCallback(context, onfulfilled), - wrapPromiseCallback(context, onrejected) - ) - ) as Promise; - - if (context && typeof result === 'object') { - promiseContexts.set(result, context); - } + return promise; + } - return result; + function trackedThen( + this: Promise, + onfulfilled?: + | ((value: T) => TResult1 | PromiseLike) + | undefined + | null, + onrejected?: + | ((reason: unknown) => TResult2 | PromiseLike) + | undefined + | null, + ): Promise { + const context = promiseContexts.get(this); + const result = runWithoutPromiseTracking(() => + NativePromise.prototype.then.call( + this, + wrapPromiseCallback(context, onfulfilled), + wrapPromiseCallback(context, onrejected), + ), + ) as Promise; + + return propagatePromiseContext(result, context, true); + } + + function trackedCatch( + this: Promise, + onrejected?: + | ((reason: unknown) => TResult | PromiseLike) + | undefined + | null, + ): Promise { + return trackedThen.call(this, undefined, onrejected) as Promise; + } + + function trackedFinally( + this: Promise, + onfinally?: (() => void) | undefined | null, + ): Promise { + const context = promiseContexts.get(this); + + if (onfinally == null) { + return trackedThen.call(this) as Promise; } - override catch( - onrejected?: - | ((reason: unknown) => TResult | PromiseLike) - | undefined - | null - ): Promise { - const context = promiseContexts.get(this); - const result = runWithoutPromiseTracking(() => - super.catch(wrapPromiseCallback(context, onrejected)) - ) as Promise; - - if (context && typeof result === 'object') { - promiseContexts.set(result, context); + return trackedThen.call( + this, + (value: unknown) => { + const result = runWithPromiseTrackerTestContext(context, onfinally); + + return runWithoutPromiseTracking(() => + NativePromise.resolve(result).then(() => value as T), + ); + }, + (reason: unknown) => { + const result = runWithPromiseTrackerTestContext(context, onfinally); + + return runWithoutPromiseTracking(() => + NativePromise.resolve(result).then(() => { + throw reason; + }), + ); + }, + ) as Promise; + } + + const handler: ProxyHandler = { + construct(target, args, newTarget) { + const executor = args[0]; + + if (typeof executor !== 'function') { + return Reflect.construct(target, args, newTarget); } - return result; - } + const registration = registerPromise(); + let promise: Promise; + + try { + promise = Reflect.construct( + target, + [ + (resolve: PromiseResolve, reject: PromiseReject) => { + try { + executor( + (value: unknown | PromiseLike) => { + if (isThenable(value)) { + runWithoutPromiseTracking(() => { + NativePromise.resolve(value).then( + () => markPromiseSettled(registration.id), + () => markPromiseSettled(registration.id), + ); + }); + } else { + markPromiseSettled(registration.id); + } + + resolve(value); + }, + (reason?: unknown) => { + markPromiseSettled(registration.id); + reject(reason); + }, + ); + } catch (error) { + markPromiseSettled(registration.id); + throw error; + } + }, + ], + newTarget, + ) as Promise; + } catch (error) { + markPromiseSettled(registration.id); + throw error; + } + + if (newTarget === TrackedPromise && Object.isExtensible(promise)) { + Object.defineProperty(promise, 'constructor', { + configurable: true, + writable: true, + value: TrackedPromise, + }); + } - override finally(onfinally?: (() => void) | undefined | null): Promise { - const context = promiseContexts.get(this); + if (registration.id !== null) { + promiseIds.set(promise, registration.id); + promiseFinalization?.register(promise, registration.id); + } - if (onfinally == null) { - return this.then(); + return propagatePromiseContext(promise, registration.test, true); + }, + get(target, property, receiver) { + const value: unknown = Reflect.get(target, property, receiver); + + if ( + receiver !== TrackedPromise || + !staticPromiseMethods.has(property) || + typeof value !== 'function' + ) { + return value; } - return this.then( - (value) => { - const result = runWithPromiseTrackerTestContext(context, onfinally); - - return runWithoutPromiseTracking(() => - NativePromise.resolve(result).then(() => value) - ); - }, - (reason: unknown) => { - const result = runWithPromiseTrackerTestContext(context, onfinally); - - return runWithoutPromiseTracking(() => - NativePromise.resolve(result).then(() => { - throw reason; - }) - ); + const cached = staticMethodWrappers.get(property); + if (cached?.method === value) { + return cached.wrapper; + } + + const wrapper = function (this: unknown, ...args: unknown[]) { + const methodReceiver = this == null ? NativePromise : this; + const result: unknown = Reflect.apply(value, methodReceiver, args); + const context = getCurrentPromiseContext(); + + if (property === 'withResolvers') { + const capability = result as { promise: Promise }; + propagatePromiseContext(capability.promise, context); + return capability; } - ); - } - } - return TrackedPromise as PromiseConstructor; + return propagatePromiseContext(result as Promise, context); + }; + + staticMethodWrappers.set(property, { method: value, wrapper }); + return wrapper; + }, + }; + + const TrackedPromise = new Proxy(NativePromise, handler); + return TrackedPromise; }; export const installPromiseTracker = (): void => {