diff --git a/web/docs/dependencies.md b/web/docs/dependencies.md index 280da4aea8..5638facfd4 100644 --- a/web/docs/dependencies.md +++ b/web/docs/dependencies.md @@ -46,7 +46,7 @@ The root `package.json` also has a convenience dev dependency: - [concurrently](https://github.com/open-cli-tools/concurrently) for spawning parallel tasks when we invoke various yarn scripts. -## Crypto +## Cryptography We use [libsodium](https://libsodium.gitbook.io/doc/) for encryption, key generation etc. Specifically, we use its WebAssembly and JS wrappers made using @@ -85,9 +85,8 @@ our apps are regular React SPAs, and are not particularly tied to Next. ### Vite For some of our newer code, we have started to use [Vite](https://vitejs.dev). -It is more lower level than Next, but the bells and whistles it doesn't have are -the bells and whistles (and the accompanying complexity) that we don't need in -some cases. +It is likely the future (both generally, and for our code) since Next is +becoming less suitable for SSR and SPAs with their push towards SSG. ## UI @@ -179,6 +178,11 @@ For more details, see [translations.md](translations.md). - [zod](https://github.com/colinhacks/zod) is used for runtime typechecking (e.g. verifying that API responses match the expected TypeScript shape). +- [debounce](https://github.com/sindresorhus/debounce) and its + promise-supporting sibling + [pDebounce](https://github.com/sindresorhus/p-debounce) are used for + debouncing operations (See also: `[Note: Throttle and debounce]`). + ## Media - [jszip](https://github.com/Stuk/jszip) is used for reading zip files in diff --git a/web/packages/new/photos/services/ml/index.ts b/web/packages/new/photos/services/ml/index.ts index 64571f1de0..6115e294d8 100644 --- a/web/packages/new/photos/services/ml/index.ts +++ b/web/packages/new/photos/services/ml/index.ts @@ -9,6 +9,7 @@ import { blobCache } from "@/next/blob-cache"; import { ensureElectron } from "@/next/electron"; import log from "@/next/log"; import { ComlinkWorker } from "@/next/worker/comlink-worker"; +import { throttled } from "@/utils/promise"; import { proxy } from "comlink"; import { isBetaUser, isInternalUser } from "../feature-flags"; import { getRemoteFlag, updateRemoteFlag } from "../remote-store"; @@ -421,7 +422,7 @@ const setInterimScheduledStatus = () => { setMLStatusSnapshot({ phase: "scheduled", nSyncedFiles, nTotalFiles }); }; -const workerDidProcessFile = triggerStatusUpdate; +const workerDidProcessFile = throttled(updateMLStatusSnapshot, 2000); /** * Return the IDs of all the faces in the given {@link enteFile} that are not diff --git a/web/packages/utils/promise.ts b/web/packages/utils/promise.ts index 34f821b6dd..9dfdc2bb5f 100644 --- a/web/packages/utils/promise.ts +++ b/web/packages/utils/promise.ts @@ -7,6 +7,82 @@ export const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); +/** + * Throttle invocations of an underlying function, coalescing pending calls. + * + * Take a function that returns a promise, and return a new function that can be + * called an any number of times while still ensuring that the underlying + * function is only called a maximum of once per the specified period. + * + * The underlying function is immediately called if there were no calls to the + * throttled function in the last period. + * + * Otherwise we start waiting. Multiple calls to the throttled function while + * we're waiting (either for the original promise to resolve, or after that, for + * the specified cooldown period to elapse) will all be coalesced into a single + * call to the underlying function when we're done waiting. + * + * --- + * + * [Note: Throttle and debounce] + * + * There are many throttle/debounce libraries, and ideally I'd have liked to + * just use one of them instead of reinventing such a basic and finicky wheel. + * Then why write a bespoke one? + * + * - "debounce" means that the underlying function will only be called when a + * particular wait time has elapsed since the last call to the _debounced_ + * function. + * + * - This behaviour, while useful sometimes, is not what we want always. If + * the debounced function is continuously being called, then the underlying + * function might never get called (since the wait time does not elapse). + * + * - To avoid this starvation, some debounce implementations like lodash + * provide a "maxWait" option, which tells the debounced function to always + * call the underlying function if maxWait has elapsed. + * + * - The debounced functions can trigger the underlying in two ways: leading + * (aka immediate) and trailing which control if the underlying should be + * called at the leading or the trailing edge of the time period. + * + * - "throttle" can be conceptually thought of as just maxWait + leading. In + * fact, this is how lodash actually implements it too. So we could've used + * lodash, except that is a big dependency to pull for a small function. + * + * - Alternatively, pThrottle is a micro-library that provide such a + * "throttle" primitive. However, its implementation enqueues all incoming + * requests to the throttled function: it still calls the underlying once + * per period, but eventually underlying will get called once for each call + * to the throttled function. + * + * - There are circumstances where that would be the appropriate behaviour, + * but that's not what we want. We wish to trigger an async action, + * coalescing multiple triggers into a single one, one per period. + * + * - Perhaps there are other focused and standard library that'd have what we + * want, but instead of spending more time searching I just wrote it from + * scratch for now. Indeed, I've spent more time writing about the function + * than the function itself. + */ +export const throttled = (underlying: () => Promise, period: number) => { + let pending = 0; + + const f = () => { + pending += 1; + if (pending > 1) return; + void underlying() + .then(() => wait(period)) + .then(() => { + const retrigger = pending > 1; + pending = 0; + if (retrigger) f(); + }); + }; + + return f; +}; + /** * Await the given {@link promise} for {@link timeoutMS} milliseconds. If it * does not resolve within {@link timeoutMS}, then reject with a timeout error.