From 0af876fb087f76a8144730969bb88b13403d02db Mon Sep 17 00:00:00 2001 From: Mathieu Hofman <86499+mhofman@users.noreply.github.com> Date: Fri, 21 Jun 2024 21:42:27 -0700 Subject: [PATCH] fix(vow): watcher args instead of context (#9556) closes: #9555 ## Description This PR updates the internal watcher exo to handle an args list, and updates the `watch` tool to check the arity and realize if a 3rd argument has been provided or not. It does not currently accept a regular rest argument like `watchPromise` does. ### Security Considerations None ### Scaling Considerations None ### Documentation Considerations Updated type definition ### Testing Considerations I didn't find any unit test coverage for this but some integration tests have shape on the watcher checking the arity. ### Upgrade Considerations Need to settle the internal handling of arguments before we commit to them in state. --- packages/vow/src/types.js | 6 +-- packages/vow/src/watch-utils.js | 2 +- packages/vow/src/watch.js | 34 ++++++------ packages/vow/test/watch.test.js | 92 +++++++++++++++++++++++++++++++-- 4 files changed, 107 insertions(+), 27 deletions(-) diff --git a/packages/vow/src/types.js b/packages/vow/src/types.js index 2a381f174d7..e70011277ce 100644 --- a/packages/vow/src/types.js +++ b/packages/vow/src/types.js @@ -81,8 +81,8 @@ export {}; * @template [T=any] * @template [TResult1=T] * @template [TResult2=never] - * @template [C=any] watcher context + * @template {any[]} [C=any[]] watcher args * @typedef {object} Watcher - * @property {(value: T, context?: C) => Vow | PromiseVow | TResult1} [onFulfilled] - * @property {(reason: any) => Vow | PromiseVow | TResult2} [onRejected] + * @property {(value: T, ...args: C) => Vow | PromiseVow | TResult1} [onFulfilled] + * @property {(reason: any, ...args: C) => Vow | PromiseVow | TResult2} [onRejected] */ diff --git a/packages/vow/src/watch-utils.js b/packages/vow/src/watch-utils.js index 7d9334b143f..a198906555d 100644 --- a/packages/vow/src/watch-utils.js +++ b/packages/vow/src/watch-utils.js @@ -111,7 +111,7 @@ export const prepareWatchUtils = (zone, watch, makeVowKit) => { } resolver.resolve(harden(results)); }, - onRejected(value, { id }) { + onRejected(value, { id, index: _index }) { const { idToVowState } = this.state; if (!idToVowState.has(id)) { // First rejection wins. diff --git a/packages/vow/src/watch.js b/packages/vow/src/watch.js index 1f6597cd3dd..c1f87f82095 100644 --- a/packages/vow/src/watch.js +++ b/packages/vow/src/watch.js @@ -38,14 +38,14 @@ const makeWatchNextStep = * @param {Watcher | undefined} watcher * @param {keyof Required} wcb * @param {unknown} value - * @param {unknown} [watcherContext] + * @param {unknown[]} [watcherArgs] */ -const settle = (resolver, watcher, wcb, value, watcherContext) => { +const settle = (resolver, watcher, wcb, value, watcherArgs = []) => { try { let chainedValue = value; const w = watcher && watcher[wcb]; if (w) { - chainedValue = apply(w, watcher, [value, watcherContext]); + chainedValue = apply(w, watcher, [value, ...watcherArgs]); } else if (wcb === 'onRejected') { throw value; } @@ -75,22 +75,22 @@ const preparePromiseWatcher = (zone, isRetryableReason, watchNextStep) => * @template [TResult2=never] * @param {VowResolver} resolver * @param {Watcher} [watcher] - * @param {unknown} [watcherContext] + * @param {unknown[]} [watcherArgs] */ - (resolver, watcher, watcherContext) => { + (resolver, watcher, watcherArgs) => { const state = { vow: /** @type {unknown} */ (undefined), priorRetryValue: /** @type {any} */ (undefined), resolver, watcher, - watcherContext: harden(watcherContext), + watcherArgs: harden(watcherArgs), }; return /** @type {Partial} */ (state); }, { /** @type {Required['onFulfilled']} */ onFulfilled(value) { - const { watcher, watcherContext, resolver } = this.state; + const { watcher, watcherArgs, resolver } = this.state; if (getVowPayload(value)) { // We've been shortened, so reflect our state accordingly, and go again. this.state.vow = value; @@ -100,11 +100,11 @@ const preparePromiseWatcher = (zone, isRetryableReason, watchNextStep) => this.state.priorRetryValue = undefined; this.state.watcher = undefined; this.state.resolver = undefined; - settle(resolver, watcher, 'onFulfilled', value, watcherContext); + settle(resolver, watcher, 'onFulfilled', value, watcherArgs); }, /** @type {Required['onRejected']} */ onRejected(reason) { - const { vow, watcher, watcherContext, resolver, priorRetryValue } = + const { vow, watcher, watcherArgs, resolver, priorRetryValue } = this.state; if (vow) { const retryValue = isRetryableReason(reason, priorRetryValue); @@ -118,7 +118,7 @@ const preparePromiseWatcher = (zone, isRetryableReason, watchNextStep) => this.state.priorRetryValue = undefined; this.state.resolver = undefined; this.state.watcher = undefined; - settle(resolver, watcher, 'onRejected', reason, watcherContext); + settle(resolver, watcher, 'onRejected', reason, watcherArgs); }, }, ); @@ -144,12 +144,12 @@ export const prepareWatch = ( * @template [T=any] * @template [TResult1=T] * @template [TResult2=never] - * @template [C=any] watcher context + * @template {any[]} [C=any[]] watcher args * @param {ERef>} specimenP - * @param {Watcher} [watcher] - * @param {C} [watcherContext] + * @param {Watcher} [watcher] + * @param {C} watcherArgs */ - const watch = (specimenP, watcher, watcherContext) => { + const watch = (specimenP, watcher, ...watcherArgs) => { /** @typedef {Exclude | Exclude} Voidless */ /** @typedef {Voidless extends never ? TResult1 : Voidless} Narrowest */ /** @type {VowKit} */ @@ -157,11 +157,7 @@ export const prepareWatch = ( // Create a promise watcher to track vows, retrying upon rejection as // controlled by `isRetryableReason`. - const promiseWatcher = makePromiseWatcher( - resolver, - watcher, - watcherContext, - ); + const promiseWatcher = makePromiseWatcher(resolver, watcher, watcherArgs); // Coerce the specimen to a promise, and start the watcher cycle. zone.watchPromise(basicE.resolve(specimenP), promiseWatcher); diff --git a/packages/vow/test/watch.test.js b/packages/vow/test/watch.test.js index 3bc7812f31c..9d9239733ba 100644 --- a/packages/vow/test/watch.test.js +++ b/packages/vow/test/watch.test.js @@ -31,6 +31,28 @@ const prepareAckWatcher = (zone, t) => { }); }; +/** + * @param {Zone} zone + * @param {ExecutionContext} t + */ +const prepareArityCheckWatcher = (zone, t) => { + return zone.exoClass( + 'ArityCheckWatcher', + undefined, + expectedArgs => ({ expectedArgs }), + { + onFulfilled(value, ...args) { + t.deepEqual(args, this.state.expectedArgs); + return 'fulfilled'; + }, + onRejected(reason, ...args) { + t.deepEqual(args, this.state.expectedArgs); + return 'rejected'; + }, + }, + ); +}; + /** * @param {Zone} zone * @param {ExecutionContext} t @@ -79,14 +101,76 @@ test('ack watcher - shim', async t => { resolver3.reject(Error('disco2')); resolver3.resolve(vow2); t.is( - await when( - // @ts-expect-error intentional extra argument - watch(connVow3P, makeAckWatcher(packet), 'watcher context', 'unexpected'), - ), + await when(watch(connVow3P, makeAckWatcher(packet), 'watcher context')), 'rejected', ); }); +/** + * @param {Zone} zone + * @param {ExecutionContext} t + */ +test('watcher args arity - shim', async t => { + const zone = makeHeapZone(); + const { watch, when, makeVowKit } = prepareVowTools(zone); + const makeArityCheckWatcher = prepareArityCheckWatcher(zone, t); + + const testCases = /** @type {const} */ ({ + noArgs: [], + 'single arg': ['testArg'], + 'multiple args': ['testArg1', 'testArg2'], + }); + + for (const [name, args] of Object.entries(testCases)) { + const fulfillTesterP = Promise.resolve('test'); + t.is( + await when(watch(fulfillTesterP, makeArityCheckWatcher(args), ...args)), + 'fulfilled', + `fulfilled promise ${name}`, + ); + + const rejectTesterP = Promise.reject(Error('reason')); + t.is( + await when(watch(rejectTesterP, makeArityCheckWatcher(args), ...args)), + 'rejected', + `rejected promise ${name}`, + ); + + const { vow: vow1, resolver: resolver1 } = makeVowKit(); + const vow1P = Promise.resolve(vow1); + resolver1.resolve('test'); + t.is( + await when(watch(vow1, makeArityCheckWatcher(args), ...args)), + 'fulfilled', + `fulfilled vow ${name}`, + ); + t.is( + await when(watch(vow1P, makeArityCheckWatcher(args), ...args)), + 'fulfilled', + `promise to fulfilled vow ${name}`, + ); + + const { vow: vow2, resolver: resolver2 } = makeVowKit(); + const vow2P = Promise.resolve(vow2); + resolver2.resolve(vow1); + t.is( + await when(watch(vow2P, makeArityCheckWatcher(args), ...args)), + 'fulfilled', + `promise to vow to fulfilled vow ${name}`, + ); + + const { vow: vow3, resolver: resolver3 } = makeVowKit(); + const vow3P = Promise.resolve(vow3); + resolver3.reject(Error('disco2')); + resolver3.resolve(vow2); + t.is( + await when(watch(vow3P, makeArityCheckWatcher(args), ...args)), + 'rejected', + `promise to rejected vow before also resolving to vow ${name}`, + ); + } +}); + test('disconnection of non-vow informs watcher', async t => { const zone = makeHeapZone(); const { watch, when } = prepareVowTools(zone, {