From e37b7861a714cf2cf7b6b09221a462440c71bd24 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Mon, 3 Aug 2026 19:47:21 +0200 Subject: [PATCH 1/3] fix(runtime): preserve native Promise static methods --- .../version-plan-1785777985382.md | 5 + .../src/__tests__/promise-tracker.test.ts | 68 +++++++++++++ packages/runtime/src/promise-tracker.ts | 98 +++++++++++++++++-- 3 files changed, 161 insertions(+), 10 deletions(-) create mode 100644 .nx/version-plans/version-plan-1785777985382.md diff --git a/.nx/version-plans/version-plan-1785777985382.md b/.nx/version-plans/version-plan-1785777985382.md new file mode 100644 index 00000000..e6b518f5 --- /dev/null +++ b/.nx/version-plans/version-plan-1785777985382.md @@ -0,0 +1,5 @@ +--- +__default__: patch +--- + +Harness preserves native Promise static-method behavior so native-module promises 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..318a25c7 100644 --- a/packages/runtime/src/__tests__/promise-tracker.test.ts +++ b/packages/runtime/src/__tests__/promise-tracker.test.ts @@ -43,6 +43,74 @@ 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('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..03624cbc 100644 --- a/packages/runtime/src/promise-tracker.ts +++ b/packages/runtime/src/promise-tracker.ts @@ -211,11 +211,7 @@ const createTrackedPromiseConstructor = (): PromiseConstructor => { ) ) as Promise; - if (context && typeof result === 'object') { - promiseContexts.set(result, context); - } - - return result; + return propagatePromiseContext(result, context); } override catch( @@ -229,11 +225,7 @@ const createTrackedPromiseConstructor = (): PromiseConstructor => { super.catch(wrapPromiseCallback(context, onrejected)) ) as Promise; - if (context && typeof result === 'object') { - promiseContexts.set(result, context); - } - - return result; + return propagatePromiseContext(result, context); } override finally(onfinally?: (() => void) | undefined | null): Promise { @@ -264,6 +256,92 @@ const createTrackedPromiseConstructor = (): PromiseConstructor => { } } + // Keep static Promise factories on Hermes' native Promise path. Inheriting + // these methods makes them construct TrackedPromise instances, which can + // expose Hermes' internal promise state instead of the settled value. + const nativePromiseMethods = [ + 'resolve', + 'reject', + 'all', + 'allSettled', + 'any', + 'race', + 'try', + ] as const; + + for (const methodName of nativePromiseMethods) { + const method: unknown = Reflect.get(NativePromise, methodName); + + if (typeof method !== 'function') { + continue; + } + + Object.defineProperty(TrackedPromise, methodName, { + configurable: true, + writable: true, + value: (...args: unknown[]) => + propagatePromiseContext( + Reflect.apply(method, NativePromise, args) as Promise, + getCurrentPromiseContext(), + ), + }); + } + + const withResolvers: unknown = Reflect.get(NativePromise, 'withResolvers'); + + if (typeof withResolvers === 'function') { + Object.defineProperty(TrackedPromise, 'withResolvers', { + configurable: true, + writable: true, + value: () => { + const capability = Reflect.apply(withResolvers, NativePromise, []) as { + promise: Promise; + }; + propagatePromiseContext( + capability.promise, + getCurrentPromiseContext(), + ); + return capability; + }, + }); + } + + function propagatePromiseContext( + promise: Promise, + context: PromiseTrackerTestContext | undefined, + ): Promise { + if (!context) { + return promise; + } + + promiseContexts.set(promise, context); + + if ( + !(promise instanceof TrackedPromise) && + Object.isExtensible(promise) + ) { + Object.defineProperties(promise, { + then: { + configurable: true, + writable: true, + value: TrackedPromise.prototype.then, + }, + catch: { + configurable: true, + writable: true, + value: TrackedPromise.prototype.catch, + }, + finally: { + configurable: true, + writable: true, + value: TrackedPromise.prototype.finally, + }, + }); + } + + return promise; + } + return TrackedPromise as PromiseConstructor; }; From b391a2369f4ae75aecf7a1559bfb19fde9b0de26 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Mon, 3 Aug 2026 20:31:51 +0200 Subject: [PATCH 2/3] fix(runtime): track native Promise instances --- .../version-plan-1785777985382.md | 2 +- .../src/__tests__/promise-tracker.test.ts | 43 +++ packages/runtime/src/promise-tracker.ts | 360 ++++++++++-------- 3 files changed, 237 insertions(+), 168 deletions(-) diff --git a/.nx/version-plans/version-plan-1785777985382.md b/.nx/version-plans/version-plan-1785777985382.md index e6b518f5..63ab081a 100644 --- a/.nx/version-plans/version-plan-1785777985382.md +++ b/.nx/version-plans/version-plan-1785777985382.md @@ -2,4 +2,4 @@ __default__: patch --- -Harness preserves native Promise static-method behavior so native-module promises return their settled values on Hermes. +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 318a25c7..088be66d 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(); diff --git a/packages/runtime/src/promise-tracker.ts b/packages/runtime/src/promise-tracker.ts index 03624cbc..fbac6d04 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,198 +147,228 @@ 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 } + >(); - 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); - } + function propagatePromiseContext( + promise: Promise, + context: PromiseTrackerTestContext | undefined, + decorate = false, + ): Promise { + if (context) { + promiseContexts.set(promise, context); + } - resolve(value); - }, - (reason?: unknown) => { - markPromiseSettled(registration.id); - reject(reason); - } - ); - } catch (error) { - markPromiseSettled(registration.id); - throw error; - } - }); + if ((context || decorate) && Object.isExtensible(promise)) { + const properties: PropertyDescriptorMap = {}; - if (registration.id !== null) { - promiseIds.set(this, registration.id); - promiseFinalization?.register(this, registration.id); + if (promise.then === NativePromise.prototype.then) { + properties.then = { + configurable: true, + writable: true, + value: trackedThen, + }; } - if (registration.test) { - promiseContexts.set(this, registration.test); + if (promise.catch === NativePromise.prototype.catch) { + properties.catch = { + configurable: true, + writable: true, + value: trackedCatch, + }; } - } - 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; - - return propagatePromiseContext(result, context); - } + if (promise.finally === NativePromise.prototype.finally) { + properties.finally = { + configurable: true, + writable: true, + value: trackedFinally, + }; + } - 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; - - return propagatePromiseContext(result, context); + Object.defineProperties(promise, properties); } - override finally(onfinally?: (() => void) | undefined | null): Promise { - const context = promiseContexts.get(this); - - if (onfinally == null) { - return this.then(); - } + return promise; + } - 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; - }) - ); - } - ); - } + 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); } - // Keep static Promise factories on Hermes' native Promise path. Inheriting - // these methods makes them construct TrackedPromise instances, which can - // expose Hermes' internal promise state instead of the settled value. - const nativePromiseMethods = [ - 'resolve', - 'reject', - 'all', - 'allSettled', - 'any', - 'race', - 'try', - ] as const; + function trackedCatch( + this: Promise, + onrejected?: + | ((reason: unknown) => TResult | PromiseLike) + | undefined + | null, + ): Promise { + return trackedThen.call(this, undefined, onrejected) as Promise; + } - for (const methodName of nativePromiseMethods) { - const method: unknown = Reflect.get(NativePromise, methodName); + function trackedFinally( + this: Promise, + onfinally?: (() => void) | undefined | null, + ): Promise { + const context = promiseContexts.get(this); - if (typeof method !== 'function') { - continue; + if (onfinally == null) { + return trackedThen.call(this) as Promise; } - Object.defineProperty(TrackedPromise, methodName, { - configurable: true, - writable: true, - value: (...args: unknown[]) => - propagatePromiseContext( - Reflect.apply(method, NativePromise, args) as Promise, - getCurrentPromiseContext(), - ), - }); - } + return trackedThen.call( + this, + (value: unknown) => { + const result = runWithPromiseTrackerTestContext(context, onfinally); - const withResolvers: unknown = Reflect.get(NativePromise, 'withResolvers'); + return runWithoutPromiseTracking(() => + NativePromise.resolve(result).then(() => value as T), + ); + }, + (reason: unknown) => { + const result = runWithPromiseTrackerTestContext(context, onfinally); - if (typeof withResolvers === 'function') { - Object.defineProperty(TrackedPromise, 'withResolvers', { - configurable: true, - writable: true, - value: () => { - const capability = Reflect.apply(withResolvers, NativePromise, []) as { - promise: Promise; - }; - propagatePromiseContext( - capability.promise, - getCurrentPromiseContext(), + return runWithoutPromiseTracking(() => + NativePromise.resolve(result).then(() => { + throw reason; + }), ); - return capability; }, - }); + ) as Promise; } - function propagatePromiseContext( - promise: Promise, - context: PromiseTrackerTestContext | undefined, - ): Promise { - if (!context) { - return promise; - } + const handler: ProxyHandler = { + construct(target, args, newTarget) { + const executor = args[0]; - promiseContexts.set(promise, context); + if (typeof executor !== 'function') { + return Reflect.construct(target, args, newTarget); + } - if ( - !(promise instanceof TrackedPromise) && - Object.isExtensible(promise) - ) { - Object.defineProperties(promise, { - then: { - configurable: true, - writable: true, - value: TrackedPromise.prototype.then, - }, - catch: { - configurable: true, - writable: true, - value: TrackedPromise.prototype.catch, - }, - finally: { + 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.prototype.finally, - }, - }); - } + value: TrackedPromise, + }); + } - return promise; - } + if (registration.id !== null) { + promiseIds.set(promise, registration.id); + promiseFinalization?.register(promise, registration.id); + } + + 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; + } + + const cached = staticMethodWrappers.get(property); + if (cached?.method === value) { + return cached.wrapper; + } + + const wrapper = function (this: unknown, ...args: unknown[]) { + const result: unknown = Reflect.apply(value, this, args); + const context = getCurrentPromiseContext(); + + if (property === 'withResolvers') { + const capability = result as { promise: Promise }; + propagatePromiseContext(capability.promise, context); + return capability; + } + + return propagatePromiseContext(result as Promise, context); + }; + + staticMethodWrappers.set(property, { method: value, wrapper }); + return wrapper; + }, + }; - return TrackedPromise as PromiseConstructor; + const TrackedPromise = new Proxy(NativePromise, handler); + return TrackedPromise; }; export const installPromiseTracker = (): void => { From ccb241d7d14b88b7adfc3abaede44895ea3404d5 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Mon, 3 Aug 2026 21:16:17 +0200 Subject: [PATCH 3/3] fix(runtime): support receiverless Promise resolve --- .../runtime/src/__tests__/promise-tracker.test.ts | 12 ++++++++++++ packages/runtime/src/promise-tracker.ts | 3 ++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/packages/runtime/src/__tests__/promise-tracker.test.ts b/packages/runtime/src/__tests__/promise-tracker.test.ts index 088be66d..f7200bb9 100644 --- a/packages/runtime/src/__tests__/promise-tracker.test.ts +++ b/packages/runtime/src/__tests__/promise-tracker.test.ts @@ -154,6 +154,18 @@ describe('promise tracker', () => { } }); + 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 fbac6d04..2f526fa2 100644 --- a/packages/runtime/src/promise-tracker.ts +++ b/packages/runtime/src/promise-tracker.ts @@ -350,7 +350,8 @@ const createTrackedPromiseConstructor = (): PromiseConstructor => { } const wrapper = function (this: unknown, ...args: unknown[]) { - const result: unknown = Reflect.apply(value, this, args); + const methodReceiver = this == null ? NativePromise : this; + const result: unknown = Reflect.apply(value, methodReceiver, args); const context = getCurrentPromiseContext(); if (property === 'withResolvers') {