diff --git a/packages/snap-toolbox/src/until/README.md b/packages/snap-toolbox/src/until/README.md new file mode 100644 index 000000000..4d2dac552 --- /dev/null +++ b/packages/snap-toolbox/src/until/README.md @@ -0,0 +1,56 @@ +## until +This utility function resolves the provided parameter when it becomes truthy. + +The function takes a single required parameter containing any function, object, or primative you are awaiting to resolve. + +Typical usage may include waiting for a third party function to become available. + +```typescript +import { until } from '@searchspring/snap-toolbox'; +const result = await until(window.thirdPartyFn); +``` + +## options + +### checkMax +Maximum number of checks to poll for. Default: `20` + +```typescript +const result = await until(window.thirdPartyFn, { checkMax: 20 }); +``` + +### checkCount +Starting count of poll counter. Default: `0` + +```typescript +const result = await until(window.thirdPartyFn, { checkCount: 0 }); +``` + +### checkTime +Polling interval in milliseconds. Default: `50` (ms) + +```typescript +const result = await until(window.thirdPartyFn, { checkTime: 50 }); +``` + +### exponential +Polling interval multiplier to exponentially increase each interval. Default: `1.1` + +```typescript +const result = await until(window.thirdPartyFn, { exponential: 1.1 }); +``` + +### defer +By default, if the provided function parameter is already available it will immediately return. To defer this until after the first iteration of polling, set `defer: true` + +```typescript +const result = await until(window.thirdPartyFn, { defer: true }); +``` + +### executeFunction +By default, if the provided function parameter is a function it will be invoked without any function parameters before its return value is resolved. To instead return a reference to the function, set `executeFunction: false` + +```typescript +const thirdPartyFn = await until(window.thirdPartyFn, { executeFunction: false }); +thirdPartyFn(); +``` diff --git a/packages/snap-toolbox/src/until/until.test.ts b/packages/snap-toolbox/src/until/until.test.ts new file mode 100644 index 000000000..9b5c41907 --- /dev/null +++ b/packages/snap-toolbox/src/until/until.test.ts @@ -0,0 +1,142 @@ +import { until, type UntilOptions } from './until'; + +const wait = (time = 1) => { + return new Promise((resolve) => { + setTimeout(resolve, time); + }); +}; + +describe('until', () => { + const options: Partial = { + checkMax: 5, // limit checks because max jest timer is 5s + checkCount: 0, + checkTime: 60, + exponential: 1.1, + defer: false, + executeFunction: true, + }; + + it('should reject try/catch', async () => { + const thing = undefined; + expect.assertions(1); + + try { + await until(thing, options); + } catch (e) { + expect(e).toBe(undefined); + } + }); + + it('should reject .catch()', async () => { + const thing = undefined; + expect.assertions(1); + + const returnVal = await until(thing, options) + .then(() => 'success') + .catch(() => 'rejected'); + expect(returnVal).toBe('rejected'); + }); + + it('should reject falsy values', async () => { + const things = [false, 0, '', null, undefined, NaN]; + expect.assertions(things.length); + + for (let i = 0; i < things.length; i++) { + const thing = things[i]; + const returnVal = await until(thing, options) + .then(() => 'success') + .catch(() => 'rejected'); + expect(returnVal).toBe('rejected'); + } + }); + + it('should resolve immediate - function', async () => { + const thing = jest.fn(() => 'success'); + expect.assertions(2); + + const returnVal = await until(thing, options); + expect(thing).toHaveBeenCalledTimes(1); + expect(returnVal).toBe('success'); + }); + + it('should not executeFunction', async () => { + const thing = jest.fn(() => 'success'); + expect.assertions(2); + + // return the function reference instead of executing it + const returnVal = await until(thing, { ...options, executeFunction: false }); + expect(thing).toHaveBeenCalledTimes(0); + expect(returnVal).toBe(thing); + }); + + it('should resolve immediate - primative', async () => { + const thing = 'success'; + expect.assertions(1); + + const returnVal = await until(thing, options); + expect(returnVal).toBe(thing); + }); + + it('should resolve truthy values', async () => { + const things = [true, {}, [], jest.fn, 42, -42, 42.4, -42.5, 42n, Infinity, -Infinity, 'false', 'string', new Date()]; + expect.assertions(things.length); + + for (let i = 0; i < things.length; i++) { + const thing = things[i]; + const returnVal = await until(thing, options) + .then(() => 'success') + .catch(() => 'rejected'); + expect(returnVal).toBe('success'); + } + }); + + it('should resolve defer - function', async () => { + const thing = jest.fn(() => 'success'); + expect.assertions(1); + + const returnVal = await until(thing, { ...options, defer: true }) + .then((val) => val) + .catch(() => 'rejected'); + expect(returnVal).toBe('success'); + }); + + it('should resolve defer - primative', async () => { + const thing = 'success'; + expect.assertions(1); + + const returnVal = await until(thing, { ...options, defer: true }) + .then((val) => val) + .catch(() => 'rejected'); + expect(returnVal).toBe('success'); + }); + + it('should resolve eventually', async () => { + let thing = ''; + expect.assertions(2); + + const delay = options.checkTime! * (options.checkCount! - 1); + setTimeout(() => { + thing = 'success'; + }, delay); + + until(() => thing, options); + + expect(thing).toBe(''); + + await wait(delay); + + expect(thing).toBe('success'); + }); + + it('should reject eventually', async () => { + let thing = ''; + expect.assertions(1); + + const delay = options.checkTime! * options.checkCount!; + + until(() => thing, options); + await wait(delay + 200); // add 200ms to ensure rejects + + expect(thing).toBe(''); + }); +}); diff --git a/packages/snap-toolbox/src/until/until.ts b/packages/snap-toolbox/src/until/until.ts new file mode 100644 index 000000000..a15bc7827 --- /dev/null +++ b/packages/snap-toolbox/src/until/until.ts @@ -0,0 +1,65 @@ +export type UntilOptions = { + checkMax: number; + checkCount: number; + checkTime: number; + exponential: number; + defer: boolean; + executeFunction: boolean; +}; + +const defaultUntilOptions: UntilOptions = { + checkMax: 25, + checkCount: 0, + checkTime: 60, + exponential: 1.1, + defer: false, + executeFunction: true, +}; + +export const until = async (thing: unknown, customOptions?: Partial): Promise => { + const options: UntilOptions = { + ...defaultUntilOptions, + ...(customOptions || {}), + }; + + return new Promise(async (resolve, reject) => { + const checkForThing = async (thing: unknown) => { + switch (typeof thing) { + case 'function': { + if (options.executeFunction) { + return thing(); + } + return thing; + } + default: { + if (thing) { + return thing; + } + } + } + }; + const thingCheck = await checkForThing(thing); + if (thingCheck && !options.defer) { + resolve(thingCheck); + } else { + const waiting = () => { + window?.setTimeout(async () => { + const thingCheck = await checkForThing(thing); + if (thingCheck) { + return resolve(thingCheck); + } + options.checkCount++; + options.checkTime *= options.exponential; + + if (options.checkCount < options.checkMax) { + return waiting(); + } + + // timeout reached + return reject(); + }, options.checkTime); + }; + waiting(); + } + }); +};