remeda
Version:
A utility library for JavaScript and Typescript.
1 lines • 14.2 kB
Source Map (JSON)
{"version":3,"file":"funnel.cjs","names":["burstTimeoutId: ReturnType<typeof setTimeout> | undefined","intervalTimeoutId: ReturnType<typeof setTimeout> | undefined","preparedData: R | undefined","burstStartTimestamp: number | undefined"],"sources":["../src/funnel.ts"],"sourcesContent":["import type { RequireAtLeastOne } from \"type-fest\";\n\n// We use the value provided by the reducer to also determine if a call\n// was done during a timeout period. This means that even when no reducer\n// is provided, we still need a dummy reducer that would return something\n// other than `undefined`. It is safe to cast this to R (which might be\n// anything) because the callback would never use it as it would be typed\n// as a zero-args function.\nconst VOID_REDUCER_SYMBOL = Symbol(\"funnel/voidReducer\");\n// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters -- Intentional, so that it could be used as a default value above.\nconst voidReducer = <R>(): R => VOID_REDUCER_SYMBOL as R;\n\ntype FunnelOptions<Args extends RestArguments, R> = {\n readonly reducer?: (accumulator: R | undefined, ...params: Args) => R;\n} & FunnelTimingOptions;\n\n// Not all combinations of timing options are valid, there are dependencies\n// between them to ensure users can't configure the funnel in a way which would\n// cause it to never trigger.\ntype FunnelTimingOptions =\n | ({ readonly triggerAt?: \"end\" } & (\n | ({ readonly minGapMs: number } & RequireAtLeastOne<{\n readonly minQuietPeriodMs: number;\n readonly maxBurstDurationMs: number;\n }>)\n | {\n readonly minQuietPeriodMs?: number;\n readonly maxBurstDurationMs?: number;\n readonly minGapMs?: never;\n }\n ))\n | {\n readonly triggerAt: \"start\" | \"both\";\n readonly minQuietPeriodMs?: number;\n readonly maxBurstDurationMs?: number;\n readonly minGapMs?: number;\n };\n\n// eslint-disable-next-line @typescript-eslint/no-explicit-any -- TypeScript has some quirks with generic function types, and works best with `any` and not `unknown`. This follows the typing of built-in utilities like `ReturnType` and `Parameters`.\ntype RestArguments = Array<any>;\n\ntype Funnel<Args extends RestArguments = []> = {\n /**\n * Call the function. This might result in the `execute` function being called\n * now or later, depending on it's configuration and it's current state.\n *\n * @param args - The args are defined by the `reducer` function.\n */\n\n readonly call: (...args: Args) => void;\n\n /**\n * Resets the funnel to it's initial state. Any calls made since the last\n * invocation will be discarded.\n */\n readonly cancel: () => void;\n\n /**\n * Triggers an invocation regardless of the current state of the funnel.\n * Like any other invocation, The funnel will also be reset to it's initial\n * state afterwards.\n */\n readonly flush: () => void;\n\n /**\n * The funnel is in it's initial state (there are no active timeouts).\n */\n readonly isIdle: boolean;\n};\n\n/**\n * Creates a funnel that controls the timing and execution of `callback`. Its\n * main purpose is to manage multiple consecutive (usually fast-paced) calls,\n * reshaping them according to a defined batching strategy and timing policy.\n * This is useful when handling uncontrolled call rates, such as DOM events or\n * network traffic. It can implement strategies like debouncing, throttling,\n * batching, and more.\n *\n * An optional `reducer` function can be provided to allow passing data to the\n * callback via calls to `call` (otherwise the signature of `call` takes no\n * arguments).\n *\n * Typing is inferred from `callback`s param, and from the rest params that\n * the optional `reducer` function accepts. Use **explicit** types for these\n * to ensure that everything _else_ is well-typed.\n *\n * Notice that this function constructs a funnel **object**, and does **not**\n * execute anything when called. The returned object should be used to execute\n * the funnel via the its `call` method.\n *\n * - Debouncing: use `minQuietPeriodMs` and any `triggerAt`.\n * - Throttling: use `minGapMs` and `triggerAt: \"start\"` or `\"both\"`.\n * - Batching: See the reference implementation in [`funnel.reference-batch.test.ts`](https://github.com/remeda/remeda/blob/main/packages/remeda/src/funnel.reference-batch.test.ts).\n *\n * @param callback - The main function that would be invoked periodically based\n * on `options`. The function would take the latest result of the `reducer`; if\n * no calls where made since the last time it was invoked it will not be\n * invoked. (If a return value is needed, it should be passed via a reference or\n * via closure to the outer scope of the funnel).\n * @param options - An object that defines when `execute` should be invoked,\n * relative to the calls of `call`. An empty/missing options object is\n * equivalent to setting `minQuietPeriodMs` to `0`.\n * @param options.reducer - Combines the arguments passed to `call` with the\n * value computed on the previous call (or `undefined` on the first time). The\n * goal of the function is to extract and summarize the data needed for\n * `callback`. It should be fast and simple as it is called often and should\n * defer heavy operations to the `execute` function. If the final value\n * is `undefined`, `callback` will not be called.\n * @param options.triggerAt - At what \"edges\" of the funnel's burst window\n * would `execute` invoke:\n * - `start` - the function will be invoked immediately (within the **same**\n * execution frame!), and any subsequent calls would be ignored until the funnel\n * is idle again. During this period `reducer` will also not be called.\n * - `end` - the function will **not** be invoked initially but the timer will\n * be started. Any calls during this time would be passed to the reducer, and\n * when the timers are done, the reduced result would trigger an invocation.\n * - `both` - the function will be invoked immediately, and then the funnel\n * would behave as if it was in the 'end' state. Default: 'end'.\n * @param options.minQuietPeriodMs - The burst timer prevents subsequent calls\n * in short succession to cause excessive invocations (aka \"debounce\"). This\n * duration represents the **minimum** amount of time that needs to pass\n * between calls (the \"quiet\" part) in order for the subsequent call to **not**\n * be considered part of the burst. In other words, as long as calls are faster\n * than this, they are considered part of the burst and the burst is extended.\n * @param options.maxBurstDurationMs - Bursts are extended every time a call is\n * made within the burst period. This means that the burst period could be\n * extended indefinitely. To prevent such cases, a maximum burst duration could\n * be defined. When `minQuietPeriodMs` is not defined and this option is, they\n * will both share the same value.\n * @param options.minGapMs - A minimum duration between calls of `execute`.\n * This is maintained regardless of the shape of the burst and is ensured even\n * if the `maxBurstDurationMs` is reached before it. (aka \"throttle\").\n * @returns A funnel with a `call` function that is used to trigger invocations.\n * In addition to it the funnel also comes with the following functions and\n * properties:\n * - `cancel` - Resets the funnel to it's initial state, discarding the current\n * `reducer` result without calling `execute` on it.\n * - `flush` - Triggers an invocation even if there are active timeouts, and\n * then resets the funnel to it's initial state.\n * - `isIdle` - Checks if there are any active timeouts.\n * @signature\n * R.funnel(callback, options);\n * @example\n * const debouncer = R.funnel(\n * () => {\n * console.log(\"Callback executed!\");\n * },\n * { minQuietPeriodMs: 100 },\n * );\n * debouncer.call();\n * debouncer.call();\n *\n * const throttle = R.funnel(\n * () => {\n * console.log(\"Callback executed!\");\n * },\n * { minGapMs: 100, triggerAt: \"start\" },\n * );\n * throttle.call();\n * throttle.call();\n * @category Function\n */\nexport function funnel<Args extends RestArguments = [], R = never>(\n callback: (data: R) => void,\n {\n triggerAt = \"end\",\n minQuietPeriodMs,\n maxBurstDurationMs,\n minGapMs,\n reducer = voidReducer,\n }: FunnelOptions<Args, R>,\n): Funnel<Args> {\n // We manage execution via 2 timeouts, one to track bursts of calls, and one\n // to track the interval between invocations. Together we refer to the period\n // where any of these are active as a \"cool-down period\".\n let burstTimeoutId: ReturnType<typeof setTimeout> | undefined;\n let intervalTimeoutId: ReturnType<typeof setTimeout> | undefined;\n\n // Until invoked, all calls are reduced into a single value that would be sent\n // to the executor on invocation.\n let preparedData: R | undefined;\n\n // In order to be able to limit the total size of the burst (when\n // `maxBurstDurationMs` is used) we need to track when the burst started.\n let burstStartTimestamp: number | undefined;\n\n const invoke = (): void => {\n const param = preparedData;\n if (param === undefined) {\n // There were no calls during both cool-down periods.\n return;\n }\n\n // Make sure the args aren't accidentally used again\n preparedData = undefined;\n\n if (param === VOID_REDUCER_SYMBOL) {\n // @ts-expect-error [ts2554] -- R is typed as `never` because we hide the\n // symbol that `voidReducer` returns; there's no way to make TypeScript\n // aware of this.\n callback();\n } else {\n callback(param);\n }\n\n if (minGapMs !== undefined) {\n intervalTimeoutId = setTimeout(handleIntervalEnd, minGapMs);\n }\n };\n\n const handleIntervalEnd = (): void => {\n // When called via a timeout the timeout is already cleared, but when called\n // via `flush` we need to manually clear it.\n clearTimeout(intervalTimeoutId);\n intervalTimeoutId = undefined;\n\n if (burstTimeoutId !== undefined) {\n // As long as one of the timeouts is active we don't invoke the function.\n // Each timeout's end event handler has a call to invoke, so we are\n // guaranteed to invoke the function eventually.\n return;\n }\n\n invoke();\n };\n\n const handleBurstEnd = (): void => {\n // When called via a timeout the timeout is already cleared, but when called\n // via `flush` we need to manually clear it.\n clearTimeout(burstTimeoutId);\n burstTimeoutId = undefined;\n burstStartTimestamp = undefined;\n\n if (intervalTimeoutId !== undefined) {\n // As long as one of the timeouts is active we don't invoke the function.\n // Each timeout's end event handler has a call to invoke, so we are\n // guaranteed to invoke the function eventually.\n return;\n }\n\n invoke();\n };\n\n return {\n call: (...args) => {\n // We act based on the initial state of the timeouts before the call is\n // handled and causes the timeouts to change.\n const wasIdle =\n burstTimeoutId === undefined && intervalTimeoutId === undefined;\n\n if (triggerAt !== \"start\" || wasIdle) {\n preparedData = reducer(preparedData, ...args);\n }\n\n if (burstTimeoutId === undefined && !wasIdle) {\n // We are not in an active burst period but in an interval period. We\n // don't start a new burst window until the next invoke.\n return;\n }\n\n if (\n minQuietPeriodMs !== undefined ||\n maxBurstDurationMs !== undefined ||\n minGapMs === undefined\n ) {\n // The timeout tracking the burst period needs to be reset every time\n // another call is made so that it waits the full cool-down duration\n // before it is released.\n clearTimeout(burstTimeoutId);\n\n const now = Date.now();\n\n burstStartTimestamp ??= now;\n\n const burstRemainingMs =\n maxBurstDurationMs === undefined\n ? (minQuietPeriodMs ?? 0)\n : Math.min(\n minQuietPeriodMs ?? maxBurstDurationMs,\n // We need to account for the time already spent so that we\n // don't wait longer than the maxDelay.\n maxBurstDurationMs - (now - burstStartTimestamp),\n );\n\n burstTimeoutId = setTimeout(handleBurstEnd, burstRemainingMs);\n }\n\n if (triggerAt !== \"end\" && wasIdle) {\n invoke();\n }\n },\n\n cancel: () => {\n clearTimeout(burstTimeoutId);\n burstTimeoutId = undefined;\n burstStartTimestamp = undefined;\n\n clearTimeout(intervalTimeoutId);\n intervalTimeoutId = undefined;\n\n preparedData = undefined;\n },\n\n flush: () => {\n handleBurstEnd();\n handleIntervalEnd();\n },\n\n get isIdle() {\n return burstTimeoutId === undefined && intervalTimeoutId === undefined;\n },\n };\n}\n"],"mappings":"AAQA,MAAM,EAAsB,OAAO,qBAAqB,CAElD,MAA0B,EAwJhC,SAAgB,EACd,EACA,CACE,YAAY,MACZ,mBACA,qBACA,WACA,UAAU,GAEE,CAId,IAAIA,EACAC,EAIAC,EAIAC,EAEE,MAAqB,CACzB,IAAM,EAAQ,EACV,IAAU,IAAA,KAMd,EAAe,IAAA,GAEX,IAAU,EAIZ,GAAU,CAEV,EAAS,EAAM,CAGb,IAAa,IAAA,KACf,EAAoB,WAAW,EAAmB,EAAS,IAIzD,MAAgC,CAGpC,aAAa,EAAkB,CAC/B,EAAoB,IAAA,GAEhB,IAAmB,IAAA,IAOvB,GAAQ,EAGJ,MAA6B,CAGjC,aAAa,EAAe,CAC5B,EAAiB,IAAA,GACjB,EAAsB,IAAA,GAElB,IAAsB,IAAA,IAO1B,GAAQ,EAGV,MAAO,CACL,MAAO,GAAG,IAAS,CAGjB,IAAM,EACJ,IAAmB,IAAA,IAAa,IAAsB,IAAA,GAExD,IAAI,IAAc,SAAW,KAC3B,EAAe,EAAQ,EAAc,GAAG,EAAK,EAG3C,MAAmB,IAAA,IAAa,CAAC,GAMrC,IACE,IAAqB,IAAA,IACrB,IAAuB,IAAA,IACvB,IAAa,IAAA,GACb,CAIA,aAAa,EAAe,CAE5B,IAAM,EAAM,KAAK,KAAK,CAEtB,IAAwB,EAExB,IAAM,EACJ,IAAuB,IAAA,GAClB,GAAoB,EACrB,KAAK,IACH,GAAoB,EAGpB,GAAsB,EAAM,GAC7B,CAEP,EAAiB,WAAW,EAAgB,EAAiB,CAG3D,IAAc,OAAS,GACzB,GAAQ,GAIZ,WAAc,CACZ,aAAa,EAAe,CAC5B,EAAiB,IAAA,GACjB,EAAsB,IAAA,GAEtB,aAAa,EAAkB,CAC/B,EAAoB,IAAA,GAEpB,EAAe,IAAA,IAGjB,UAAa,CACX,GAAgB,CAChB,GAAmB,EAGrB,IAAI,QAAS,CACX,OAAO,IAAmB,IAAA,IAAa,IAAsB,IAAA,IAEhE"}