From a1fbcfab5b7f08f86d5e7cb335158332523392b2 Mon Sep 17 00:00:00 2001 From: Kevin Van Cott Date: Thu, 1 Oct 2026 17:50:33 -0500 Subject: [PATCH 1/2] fix(examples): Align framework demos and remove persister examples --- docs/config.json | 44 +-- docs/guides/persistence.md | 73 ++++ examples/angular/asyncBatch/src/app/app.html | 68 ++-- .../angular/asyncBatch/src/app/app.spec.ts | 37 +- examples/angular/asyncBatch/src/app/app.ts | 104 +++-- .../angular/asyncDebounce/src/app/app.html | 56 ++- .../angular/asyncDebounce/src/app/app.spec.ts | 35 +- examples/angular/asyncDebounce/src/app/app.ts | 57 ++- .../angular/asyncRateLimit/src/app/app.html | 70 ++-- .../asyncRateLimit/src/app/app.spec.ts | 35 +- .../angular/asyncRateLimit/src/app/app.ts | 67 ++-- examples/angular/asyncRetry/src/app/app.html | 109 ++--- .../angular/asyncRetry/src/app/app.spec.ts | 40 +- examples/angular/asyncRetry/src/app/app.ts | 194 +++++---- .../angular/asyncThrottle/src/app/app.html | 49 ++- .../angular/asyncThrottle/src/app/app.spec.ts | 35 +- examples/angular/asyncThrottle/src/app/app.ts | 48 ++- examples/angular/batch/src/app/app.html | 60 +-- examples/angular/batch/src/app/app.spec.ts | 28 +- examples/angular/batch/src/app/app.ts | 57 +-- examples/angular/debounce/src/app/app.html | 53 ++- examples/angular/debounce/src/app/app.spec.ts | 36 +- examples/angular/debounce/src/app/app.ts | 46 ++- .../src/app/app.html | 89 +++-- .../src/app/app.spec.ts | 42 +- .../injectAsyncBatchedCallback/src/app/app.ts | 153 ++++--- .../injectAsyncBatcher/src/app/app.html | 75 ++-- .../injectAsyncBatcher/src/app/app.spec.ts | 37 +- .../angular/injectAsyncBatcher/src/app/app.ts | 102 +++-- .../src/app/app.html | 75 ++-- .../src/app/app.spec.ts | 46 ++- .../src/app/app.ts | 130 +++--- .../injectAsyncDebouncer/src/app/app.html | 46 ++- .../injectAsyncDebouncer/src/app/app.spec.ts | 35 +- .../injectAsyncDebouncer/src/app/app.ts | 69 ++-- .../injectAsyncQueuedSignal/src/app/app.html | 75 ++-- .../src/app/app.spec.ts | 38 +- .../injectAsyncQueuedSignal/src/app/app.ts | 83 ++-- .../injectAsyncQueuer/src/app/app.html | 98 ++--- .../injectAsyncQueuer/src/app/app.spec.ts | 38 +- .../angular/injectAsyncQueuer/src/app/app.ts | 96 ++--- .../injectAsyncRateLimiter/src/app/app.html | 85 ++-- .../src/app/app.spec.ts | 51 ++- .../injectAsyncRateLimiter/src/app/app.ts | 103 ++--- .../README.md | 59 --- .../angular.json | 72 ---- .../eslint.config.js | 15 - .../package.json | 43 -- .../public/favicon.ico | Bin 15086 -> 0 bytes .../src/app/app.config.ts | 8 - .../src/app/app.css | 19 - .../src/app/app.html | 162 -------- .../src/app/app.routes.ts | 3 - .../src/app/app.spec.ts | 23 -- .../src/app/app.ts | 81 ---- .../src/index.html | 13 - .../src/main.ts | 5 - .../src/styles.css | 1 - .../tsconfig.app.json | 11 - .../tsconfig.json | 33 -- .../tsconfig.spec.json | 10 - .../src/app/app.html | 78 ++-- .../src/app/app.spec.ts | 47 ++- .../src/app/app.ts | 123 +++--- .../injectAsyncThrottler/src/app/app.html | 53 ++- .../injectAsyncThrottler/src/app/app.spec.ts | 35 +- .../injectAsyncThrottler/src/app/app.ts | 67 ++-- .../injectBatchedCallback/src/app/app.html | 73 ++-- .../injectBatchedCallback/src/app/app.spec.ts | 33 +- .../injectBatchedCallback/src/app/app.ts | 95 ++--- .../angular/injectBatcher/src/app/app.html | 42 +- .../angular/injectBatcher/src/app/app.spec.ts | 28 +- examples/angular/injectBatcher/src/app/app.ts | 64 +-- .../injectDebouncedCallback/src/app/app.html | 51 ++- .../src/app/app.spec.ts | 51 ++- .../injectDebouncedCallback/src/app/app.ts | 53 ++- .../injectDebouncedSignal/src/app/app.html | 77 ++-- .../injectDebouncedSignal/src/app/app.spec.ts | 68 +++- .../injectDebouncedSignal/src/app/app.ts | 72 ++-- .../injectDebouncedValue/src/app/app.html | 72 +++- .../injectDebouncedValue/src/app/app.spec.ts | 80 +++- .../injectDebouncedValue/src/app/app.ts | 67 +++- .../injectDebouncedValue/src/app/inputapp.ts | 13 +- .../angular/injectDebouncer/src/app/app.html | 133 ++++--- .../injectDebouncer/src/app/app.spec.ts | 92 ++++- .../angular/injectDebouncer/src/app/app.ts | 92 +++-- .../injectQueuedSignal/src/app/app.html | 106 +++-- .../injectQueuedSignal/src/app/app.spec.ts | 34 +- .../angular/injectQueuedSignal/src/app/app.ts | 58 ++- .../injectQueuedValue/src/app/app.html | 97 ++++- .../injectQueuedValue/src/app/app.spec.ts | 34 +- .../angular/injectQueuedValue/src/app/app.ts | 46 ++- .../injectQueuedValue/src/app/inputapp.ts | 12 +- .../angular/injectQueuer/src/app/app.html | 115 +++++- .../angular/injectQueuer/src/app/app.spec.ts | 34 +- examples/angular/injectQueuer/src/app/app.ts | 69 ++-- .../injectQueuerWithPersister/README.md | 59 --- .../injectQueuerWithPersister/angular.json | 72 ---- .../eslint.config.js | 15 - .../injectQueuerWithPersister/package.json | 43 -- .../public/favicon.ico | Bin 15086 -> 0 bytes .../src/app/app.config.ts | 8 - .../injectQueuerWithPersister/src/app/app.css | 19 - .../src/app/app.html | 162 -------- .../src/app/app.routes.ts | 3 - .../src/app/app.spec.ts | 23 -- .../injectQueuerWithPersister/src/app/app.ts | 81 ---- .../injectQueuerWithPersister/src/index.html | 13 - .../injectQueuerWithPersister/src/main.ts | 5 - .../injectQueuerWithPersister/src/styles.css | 1 - .../tsconfig.app.json | 11 - .../injectQueuerWithPersister/tsconfig.json | 33 -- .../tsconfig.spec.json | 10 - .../src/app/app.html | 122 ++++-- .../src/app/app.spec.ts | 63 ++- .../injectRateLimitedCallback/src/app/app.ts | 120 +++--- .../injectRateLimitedSignal/src/app/app.html | 140 +++++-- .../src/app/app.spec.ts | 48 ++- .../injectRateLimitedSignal/src/app/app.ts | 127 +++--- .../injectRateLimitedValue/src/app/app.html | 276 ++++++------- .../src/app/app.spec.ts | 60 ++- .../injectRateLimitedValue/src/app/app.ts | 130 +++--- .../injectRateLimiter/src/app/app.html | 110 ++++-- .../injectRateLimiter/src/app/app.spec.ts | 63 ++- .../angular/injectRateLimiter/src/app/app.ts | 91 +++-- .../injectRateLimiterWithPersister/README.md | 59 --- .../angular.json | 72 ---- .../eslint.config.js | 15 - .../package.json | 43 -- .../public/favicon.ico | Bin 15086 -> 0 bytes .../src/app/app.config.ts | 8 - .../src/app/app.css | 19 - .../src/app/app.html | 59 --- .../src/app/app.routes.ts | 3 - .../src/app/app.spec.ts | 23 -- .../src/app/app.ts | 87 ---- .../src/index.html | 13 - .../src/main.ts | 5 - .../src/styles.css | 1 - .../tsconfig.app.json | 11 - .../tsconfig.json | 33 -- .../tsconfig.spec.json | 10 - .../injectThrottledCallback/src/app/app.html | 66 ++-- .../src/app/app.spec.ts | 63 ++- .../injectThrottledCallback/src/app/app.ts | 76 ++-- .../injectThrottledSignal/src/app/app.html | 70 +++- .../injectThrottledSignal/src/app/app.spec.ts | 36 +- .../injectThrottledSignal/src/app/app.ts | 92 ++--- .../injectThrottledValue/src/app/app.html | 80 ++-- .../injectThrottledValue/src/app/app.spec.ts | 48 ++- .../injectThrottledValue/src/app/app.ts | 67 ++-- .../angular/injectThrottler/src/app/app.html | 70 +++- .../injectThrottler/src/app/app.spec.ts | 68 +++- .../angular/injectThrottler/src/app/app.ts | 64 ++- examples/angular/queue/src/app/app.html | 125 +++--- examples/angular/queue/src/app/app.spec.ts | 35 +- examples/angular/queue/src/app/app.ts | 118 +++--- examples/angular/rateLimit/src/app/app.html | 255 +++++------- .../angular/rateLimit/src/app/app.spec.ts | 36 +- examples/angular/rateLimit/src/app/app.ts | 138 ++++--- examples/angular/throttle/src/app/app.html | 55 ++- examples/angular/throttle/src/app/app.spec.ts | 36 +- examples/angular/throttle/src/app/app.ts | 58 +-- examples/preact/asyncBatch/src/index.tsx | 13 +- examples/preact/debounce/src/index.tsx | 2 +- examples/preact/queue/package.json | 2 + examples/preact/queue/src/index.tsx | 14 +- examples/preact/rateLimit/src/index.tsx | 4 +- examples/preact/throttle/src/index.tsx | 2 +- .../useAsyncBatchedCallback/src/index.tsx | 9 +- examples/preact/useAsyncBatcher/src/index.tsx | 60 +-- .../preact/useAsyncDebouncer/src/index.tsx | 33 +- .../preact/useAsyncQueuedState/src/index.tsx | 55 +-- examples/preact/useAsyncQueuer/src/index.tsx | 49 +-- .../preact/useAsyncRateLimiter/src/index.tsx | 37 +- .../README.md | 6 - .../index.html | 15 - .../package.json | 31 -- .../public/emblem-light.svg | 14 - .../src/index.tsx | 200 ---------- .../tsconfig.json | 24 -- .../vite.config.ts | 7 - .../preact/useAsyncThrottler/src/index.tsx | 29 +- examples/preact/useBatcher/src/index.tsx | 21 +- .../preact/useDebouncedCallback/src/index.tsx | 20 +- .../preact/useDebouncedState/src/index.tsx | 32 +- .../preact/useDebouncedValue/src/index.tsx | 50 ++- examples/preact/useDebouncer/src/index.tsx | 92 +++-- examples/preact/useQueuedState/src/index.tsx | 50 +-- examples/preact/useQueuedValue/src/index.tsx | 49 +-- examples/preact/useQueuer/src/index.tsx | 67 ++-- .../preact/useQueuerWithPersister/README.md | 6 - .../preact/useQueuerWithPersister/index.html | 15 - .../useQueuerWithPersister/package.json | 31 -- .../public/emblem-light.svg | 14 - .../useQueuerWithPersister/src/index.tsx | 280 ------------- .../useQueuerWithPersister/tsconfig.json | 24 -- .../useQueuerWithPersister/vite.config.ts | 7 - .../useRateLimitedCallback/src/index.tsx | 20 +- .../preact/useRateLimitedState/src/index.tsx | 32 +- .../preact/useRateLimitedValue/src/index.tsx | 90 +++-- examples/preact/useRateLimiter/src/index.tsx | 95 +++-- .../useRateLimiterWithPersister/README.md | 6 - .../useRateLimiterWithPersister/index.html | 15 - .../useRateLimiterWithPersister/package.json | 31 -- .../public/emblem-light.svg | 14 - .../useRateLimiterWithPersister/src/index.tsx | 372 ------------------ .../useRateLimiterWithPersister/tsconfig.json | 24 -- .../vite.config.ts | 7 - .../preact/useThrottledCallback/src/index.tsx | 20 +- .../preact/useThrottledState/src/index.tsx | 32 +- .../preact/useThrottledValue/src/index.tsx | 48 ++- examples/preact/useThrottler/src/index.tsx | 67 ++-- examples/preact/util-comparison/src/index.tsx | 8 +- examples/react/asyncBatch/src/index.tsx | 13 +- examples/react/asyncBatch/vite.config.ts | 8 +- examples/react/asyncDebounce/vite.config.ts | 8 +- examples/react/asyncRateLimit/vite.config.ts | 8 +- examples/react/asyncRetry/vite.config.ts | 8 +- examples/react/asyncThrottle/vite.config.ts | 8 +- examples/react/batch/vite.config.ts | 8 +- examples/react/debounce/src/index.tsx | 2 +- examples/react/debounce/vite.config.ts | 8 +- examples/react/queue/src/index.tsx | 8 +- examples/react/queue/vite.config.ts | 12 +- examples/react/rateLimit/src/index.tsx | 4 +- examples/react/rateLimit/vite.config.ts | 8 +- .../src/index.tsx | 9 +- .../vite.config.ts | 8 +- .../react-query-queued-prefetch/src/index.tsx | 17 +- .../vite.config.ts | 8 +- .../src/index.tsx | 9 +- .../vite.config.ts | 8 +- examples/react/throttle/src/index.tsx | 2 +- examples/react/throttle/vite.config.ts | 8 +- .../useAsyncBatchedCallback/src/index.tsx | 9 +- examples/react/useAsyncBatcher/src/index.tsx | 34 +- examples/react/useAsyncBatcher/vite.config.ts | 8 +- .../react/useAsyncDebouncer/src/index.tsx | 2 +- .../react/useAsyncDebouncer/vite.config.ts | 8 +- .../react/useAsyncQueuedState/src/index.tsx | 3 +- .../react/useAsyncQueuedState/vite.config.ts | 8 +- examples/react/useAsyncQueuer/vite.config.ts | 8 +- .../react/useAsyncRateLimiter/package.json | 1 - .../react/useAsyncRateLimiter/src/index.tsx | 4 +- .../react/useAsyncRateLimiter/vite.config.ts | 8 +- .../README.md | 24 -- .../index.html | 16 - .../package.json | 35 -- .../public/emblem-light.svg | 13 - .../src/index.tsx | 199 ---------- .../tsconfig.json | 23 -- .../react/useAsyncThrottler/vite.config.ts | 8 +- .../react/useBatchedCallback/vite.config.ts | 8 +- examples/react/useBatcher/src/index.tsx | 2 +- examples/react/useBatcher/vite.config.ts | 8 +- .../react/useDebouncedCallback/src/index.tsx | 20 +- .../react/useDebouncedCallback/vite.config.ts | 8 +- .../react/useDebouncedState/src/index.tsx | 20 +- .../react/useDebouncedState/vite.config.ts | 8 +- .../react/useDebouncedValue/src/index.tsx | 17 +- .../react/useDebouncedValue/vite.config.ts | 8 +- examples/react/useDebouncer/src/index.tsx | 53 ++- examples/react/useDebouncer/vite.config.ts | 8 +- examples/react/useQueuedState/src/index.tsx | 31 +- examples/react/useQueuedState/vite.config.ts | 8 +- examples/react/useQueuedValue/src/index.tsx | 27 +- examples/react/useQueuedValue/vite.config.ts | 8 +- examples/react/useQueuer/package.json | 1 - examples/react/useQueuer/src/index.tsx | 27 +- examples/react/useQueuer/vite.config.ts | 12 +- .../react/useQueuerWithPersister/README.md | 23 -- .../react/useQueuerWithPersister/index.html | 16 - .../react/useQueuerWithPersister/package.json | 35 -- .../public/emblem-light.svg | 13 - .../useQueuerWithPersister/src/index.tsx | 278 ------------- .../useQueuerWithPersister/tsconfig.json | 23 -- .../useQueuerWithPersister/vite.config.ts | 13 - .../useRateLimitedCallback/src/index.tsx | 20 +- .../useRateLimitedCallback/vite.config.ts | 8 +- .../react/useRateLimitedState/src/index.tsx | 20 +- .../react/useRateLimitedState/vite.config.ts | 8 +- .../react/useRateLimitedValue/src/index.tsx | 17 +- .../react/useRateLimitedValue/vite.config.ts | 8 +- examples/react/useRateLimiter/package.json | 1 - examples/react/useRateLimiter/src/index.tsx | 21 +- examples/react/useRateLimiter/vite.config.ts | 8 +- .../useRateLimiterWithPersister/README.md | 23 -- .../useRateLimiterWithPersister/index.html | 16 - .../useRateLimiterWithPersister/package.json | 35 -- .../public/emblem-light.svg | 13 - .../useRateLimiterWithPersister/src/index.tsx | 370 ----------------- .../useRateLimiterWithPersister/tsconfig.json | 23 -- .../react/useThrottledCallback/src/index.tsx | 20 +- .../react/useThrottledCallback/vite.config.ts | 8 +- .../react/useThrottledState/src/index.tsx | 20 +- .../react/useThrottledState/vite.config.ts | 8 +- .../react/useThrottledValue/src/index.tsx | 17 +- .../react/useThrottledValue/vite.config.ts | 8 +- examples/react/useThrottler/src/index.tsx | 20 +- examples/react/useThrottler/vite.config.ts | 8 +- examples/react/util-comparison/src/index.tsx | 2 +- examples/react/util-comparison/vite.config.ts | 12 +- examples/solid/asyncBatch/src/index.tsx | 8 +- examples/solid/asyncRateLimit/src/index.tsx | 50 +-- examples/solid/batch/src/index.tsx | 2 +- .../solid/createAsyncBatcher/src/index.tsx | 38 +- .../solid/createAsyncDebouncer/src/index.tsx | 32 +- .../solid/createAsyncQueuer/src/index.tsx | 22 +- .../createAsyncRateLimiter/src/index.tsx | 124 ++++-- .../solid/createAsyncThrottler/src/index.tsx | 19 +- examples/solid/createBatcher/src/index.tsx | 13 +- .../solid/createDebouncedSignal/src/index.tsx | 55 ++- .../solid/createDebouncedValue/src/index.tsx | 34 +- examples/solid/createDebouncer/src/index.tsx | 55 +-- .../solid/createQueuedSignal/src/index.tsx | 66 ++-- examples/solid/createQueuer/src/index.tsx | 257 ++++-------- .../createRateLimitedSignal/src/index.tsx | 67 +++- .../createRateLimitedValue/src/index.tsx | 61 ++- .../solid/createRateLimiter/src/index.tsx | 124 +++--- .../solid/createThrottledSignal/src/index.tsx | 44 +-- .../solid/createThrottledValue/src/index.tsx | 36 +- examples/solid/createThrottler/src/index.tsx | 46 ++- examples/solid/debounce/src/index.tsx | 16 +- examples/solid/queue/src/index.tsx | 5 +- examples/solid/rateLimit/src/index.tsx | 93 ++--- examples/solid/throttle/src/index.tsx | 11 +- pnpm-lock.yaml | 313 +-------------- 328 files changed, 6844 insertions(+), 8778 deletions(-) create mode 100644 docs/guides/persistence.md delete mode 100644 examples/angular/injectAsyncRateLimiterWithPersister/README.md delete mode 100644 examples/angular/injectAsyncRateLimiterWithPersister/angular.json delete mode 100644 examples/angular/injectAsyncRateLimiterWithPersister/eslint.config.js delete mode 100644 examples/angular/injectAsyncRateLimiterWithPersister/package.json delete mode 100644 examples/angular/injectAsyncRateLimiterWithPersister/public/favicon.ico delete mode 100644 examples/angular/injectAsyncRateLimiterWithPersister/src/app/app.config.ts delete mode 100644 examples/angular/injectAsyncRateLimiterWithPersister/src/app/app.css delete mode 100644 examples/angular/injectAsyncRateLimiterWithPersister/src/app/app.html delete mode 100644 examples/angular/injectAsyncRateLimiterWithPersister/src/app/app.routes.ts delete mode 100644 examples/angular/injectAsyncRateLimiterWithPersister/src/app/app.spec.ts delete mode 100644 examples/angular/injectAsyncRateLimiterWithPersister/src/app/app.ts delete mode 100644 examples/angular/injectAsyncRateLimiterWithPersister/src/index.html delete mode 100644 examples/angular/injectAsyncRateLimiterWithPersister/src/main.ts delete mode 100644 examples/angular/injectAsyncRateLimiterWithPersister/src/styles.css delete mode 100644 examples/angular/injectAsyncRateLimiterWithPersister/tsconfig.app.json delete mode 100644 examples/angular/injectAsyncRateLimiterWithPersister/tsconfig.json delete mode 100644 examples/angular/injectAsyncRateLimiterWithPersister/tsconfig.spec.json delete mode 100644 examples/angular/injectQueuerWithPersister/README.md delete mode 100644 examples/angular/injectQueuerWithPersister/angular.json delete mode 100644 examples/angular/injectQueuerWithPersister/eslint.config.js delete mode 100644 examples/angular/injectQueuerWithPersister/package.json delete mode 100644 examples/angular/injectQueuerWithPersister/public/favicon.ico delete mode 100644 examples/angular/injectQueuerWithPersister/src/app/app.config.ts delete mode 100644 examples/angular/injectQueuerWithPersister/src/app/app.css delete mode 100644 examples/angular/injectQueuerWithPersister/src/app/app.html delete mode 100644 examples/angular/injectQueuerWithPersister/src/app/app.routes.ts delete mode 100644 examples/angular/injectQueuerWithPersister/src/app/app.spec.ts delete mode 100644 examples/angular/injectQueuerWithPersister/src/app/app.ts delete mode 100644 examples/angular/injectQueuerWithPersister/src/index.html delete mode 100644 examples/angular/injectQueuerWithPersister/src/main.ts delete mode 100644 examples/angular/injectQueuerWithPersister/src/styles.css delete mode 100644 examples/angular/injectQueuerWithPersister/tsconfig.app.json delete mode 100644 examples/angular/injectQueuerWithPersister/tsconfig.json delete mode 100644 examples/angular/injectQueuerWithPersister/tsconfig.spec.json delete mode 100644 examples/angular/injectRateLimiterWithPersister/README.md delete mode 100644 examples/angular/injectRateLimiterWithPersister/angular.json delete mode 100644 examples/angular/injectRateLimiterWithPersister/eslint.config.js delete mode 100644 examples/angular/injectRateLimiterWithPersister/package.json delete mode 100644 examples/angular/injectRateLimiterWithPersister/public/favicon.ico delete mode 100644 examples/angular/injectRateLimiterWithPersister/src/app/app.config.ts delete mode 100644 examples/angular/injectRateLimiterWithPersister/src/app/app.css delete mode 100644 examples/angular/injectRateLimiterWithPersister/src/app/app.html delete mode 100644 examples/angular/injectRateLimiterWithPersister/src/app/app.routes.ts delete mode 100644 examples/angular/injectRateLimiterWithPersister/src/app/app.spec.ts delete mode 100644 examples/angular/injectRateLimiterWithPersister/src/app/app.ts delete mode 100644 examples/angular/injectRateLimiterWithPersister/src/index.html delete mode 100644 examples/angular/injectRateLimiterWithPersister/src/main.ts delete mode 100644 examples/angular/injectRateLimiterWithPersister/src/styles.css delete mode 100644 examples/angular/injectRateLimiterWithPersister/tsconfig.app.json delete mode 100644 examples/angular/injectRateLimiterWithPersister/tsconfig.json delete mode 100644 examples/angular/injectRateLimiterWithPersister/tsconfig.spec.json delete mode 100644 examples/preact/useAsyncRateLimiterWithPersister/README.md delete mode 100644 examples/preact/useAsyncRateLimiterWithPersister/index.html delete mode 100644 examples/preact/useAsyncRateLimiterWithPersister/package.json delete mode 100644 examples/preact/useAsyncRateLimiterWithPersister/public/emblem-light.svg delete mode 100644 examples/preact/useAsyncRateLimiterWithPersister/src/index.tsx delete mode 100644 examples/preact/useAsyncRateLimiterWithPersister/tsconfig.json delete mode 100644 examples/preact/useAsyncRateLimiterWithPersister/vite.config.ts delete mode 100644 examples/preact/useQueuerWithPersister/README.md delete mode 100644 examples/preact/useQueuerWithPersister/index.html delete mode 100644 examples/preact/useQueuerWithPersister/package.json delete mode 100644 examples/preact/useQueuerWithPersister/public/emblem-light.svg delete mode 100644 examples/preact/useQueuerWithPersister/src/index.tsx delete mode 100644 examples/preact/useQueuerWithPersister/tsconfig.json delete mode 100644 examples/preact/useQueuerWithPersister/vite.config.ts delete mode 100644 examples/preact/useRateLimiterWithPersister/README.md delete mode 100644 examples/preact/useRateLimiterWithPersister/index.html delete mode 100644 examples/preact/useRateLimiterWithPersister/package.json delete mode 100644 examples/preact/useRateLimiterWithPersister/public/emblem-light.svg delete mode 100644 examples/preact/useRateLimiterWithPersister/src/index.tsx delete mode 100644 examples/preact/useRateLimiterWithPersister/tsconfig.json delete mode 100644 examples/preact/useRateLimiterWithPersister/vite.config.ts delete mode 100644 examples/react/useAsyncRateLimiterWithPersister/README.md delete mode 100644 examples/react/useAsyncRateLimiterWithPersister/index.html delete mode 100644 examples/react/useAsyncRateLimiterWithPersister/package.json delete mode 100644 examples/react/useAsyncRateLimiterWithPersister/public/emblem-light.svg delete mode 100644 examples/react/useAsyncRateLimiterWithPersister/src/index.tsx delete mode 100644 examples/react/useAsyncRateLimiterWithPersister/tsconfig.json delete mode 100644 examples/react/useQueuerWithPersister/README.md delete mode 100644 examples/react/useQueuerWithPersister/index.html delete mode 100644 examples/react/useQueuerWithPersister/package.json delete mode 100644 examples/react/useQueuerWithPersister/public/emblem-light.svg delete mode 100644 examples/react/useQueuerWithPersister/src/index.tsx delete mode 100644 examples/react/useQueuerWithPersister/tsconfig.json delete mode 100644 examples/react/useQueuerWithPersister/vite.config.ts delete mode 100644 examples/react/useRateLimiterWithPersister/README.md delete mode 100644 examples/react/useRateLimiterWithPersister/index.html delete mode 100644 examples/react/useRateLimiterWithPersister/package.json delete mode 100644 examples/react/useRateLimiterWithPersister/public/emblem-light.svg delete mode 100644 examples/react/useRateLimiterWithPersister/src/index.tsx delete mode 100644 examples/react/useRateLimiterWithPersister/tsconfig.json diff --git a/docs/config.json b/docs/config.json index bb20a7b3d..61d8c1d8f 100644 --- a/docs/config.json +++ b/docs/config.json @@ -71,6 +71,10 @@ { "label": "Which Utility Should I Choose?", "to": "guides/which-pacer-utility-should-i-choose" + }, + { + "label": "Persist Pacer State", + "to": "guides/persistence" } ], "frameworks": [ @@ -1707,10 +1711,6 @@ "label": "useRateLimiter", "to": "framework/react/examples/useRateLimiter" }, - { - "label": "useRateLimiterWithPersister", - "to": "framework/react/examples/useRateLimiterWithPersister" - }, { "label": "useRateLimitedCallback", "to": "framework/react/examples/useRateLimitedCallback" @@ -1726,10 +1726,6 @@ { "label": "useAsyncRateLimiter", "to": "framework/react/examples/useAsyncRateLimiter" - }, - { - "label": "useAsyncRateLimiterWithPersister", - "to": "framework/react/examples/useAsyncRateLimiterWithPersister" } ] }, @@ -1777,10 +1773,6 @@ "label": "useRateLimiter", "to": "framework/preact/examples/useRateLimiter" }, - { - "label": "useRateLimiterWithPersister", - "to": "framework/preact/examples/useRateLimiterWithPersister" - }, { "label": "useRateLimitedCallback", "to": "framework/preact/examples/useRateLimitedCallback" @@ -1796,10 +1788,6 @@ { "label": "useAsyncRateLimiter", "to": "framework/preact/examples/useAsyncRateLimiter" - }, - { - "label": "useAsyncRateLimiterWithPersister", - "to": "framework/preact/examples/useAsyncRateLimiterWithPersister" } ] }, @@ -1830,21 +1818,9 @@ "label": "injectRateLimitedValue", "to": "framework/angular/examples/injectRateLimitedValue" }, - { - "label": "injectRateLimiterWithPersister", - "to": "framework/angular/examples/injectRateLimiterWithPersister" - }, { "label": "injectAsyncRateLimiter", "to": "framework/angular/examples/injectAsyncRateLimiter" - }, - { - "label": "injectAsyncRateLimitedCallback", - "to": "framework/angular/examples/injectAsyncRateLimitedCallback" - }, - { - "label": "injectAsyncRateLimiterWithPersister", - "to": "framework/angular/examples/injectAsyncRateLimiterWithPersister" } ] } @@ -1878,10 +1854,6 @@ "label": "useQueuer", "to": "framework/react/examples/useQueuer" }, - { - "label": "useQueuerWithPersister", - "to": "framework/react/examples/useQueuerWithPersister" - }, { "label": "useQueuedState", "to": "framework/react/examples/useQueuedState" @@ -1932,10 +1904,6 @@ "label": "useQueuer", "to": "framework/preact/examples/useQueuer" }, - { - "label": "useQueuerWithPersister", - "to": "framework/preact/examples/useQueuerWithPersister" - }, { "label": "useQueuedState", "to": "framework/preact/examples/useQueuedState" @@ -1973,10 +1941,6 @@ "label": "injectQueuedValue", "to": "framework/angular/examples/injectQueuedValue" }, - { - "label": "injectQueuerWithPersister", - "to": "framework/angular/examples/injectQueuerWithPersister" - }, { "label": "injectAsyncQueuer", "to": "framework/angular/examples/injectAsyncQueuer" diff --git a/docs/guides/persistence.md b/docs/guides/persistence.md new file mode 100644 index 000000000..e991eea02 --- /dev/null +++ b/docs/guides/persistence.md @@ -0,0 +1,73 @@ +--- +title: Persist Pacer state +id: persistence +--- + +Use your application's storage to save the Pacer state you need across reloads. Restore that data when you create the utility instance. + +## Restore waiting queue items + +This example stores a queue of strings in `localStorage`. Call `createPersistentQueue` once from your client lifecycle, after browser storage is available. + +```ts +import { Queuer } from '@tanstack/pacer/queuer' + +function createPersistentQueue(processItem: (item: string) => void) { + const storageKey = 'pacer-waiting-items-v1' + + function loadItems(): Array { + try { + const value: unknown = JSON.parse( + localStorage.getItem(storageKey) ?? '[]', + ) + return Array.isArray(value) && + value.every((item) => typeof item === 'string') + ? value + : [] + } catch { + return [] + } + } + + const queuer = new Queuer(processItem, { + initialItems: loadItems(), + started: false, + wait: 1000, + onItemsChange: (instance) => { + try { + localStorage.setItem( + storageKey, + JSON.stringify(instance.peekAllItems()), + ) + } catch (error) { + console.error('Could not save waiting queue items', error) + } + }, + }) + + queuer.start() + return queuer +} + +const queuer = createPersistentQueue((item) => console.log(item)) +queuer.addItem('Process after reload if still waiting') + +// Call from your component or application cleanup to preserve waiting items. +// queuer.stop() +``` + +The queue removes an item before it calls `processItem`. This saves waiting items only. For work that must survive a crash during processing, use durable job storage with acknowledgements and idempotent handlers. + +In framework adapters, pass restored items through `initialItems` and save changes through `onItemsChange`. Load the items before creating the instance; later option updates do not reinitialize its state. Use the adapter's lifecycle cleanup to stop processing when the owner is destroyed. + +If items expire, preserve their original timestamps as well. Restoring through `initialItems` assigns new timestamps and starts their expiration period again. + +## Restore a rate-limit window + +Save a rate limiter's `store.state.executionTimes` in `onExecute`. Validate that the saved timestamps are finite numbers before restoring them through `initialState: { executionTimes }`. Keep the same `limit`, `window`, and `windowType`, or discard the stored window when that configuration changes. + +Use a storage key scoped to the operation and user. Browser storage does not coordinate quotas across tabs, devices, or servers. Enforce shared API limits on the server. + +For asynchronous utilities, save completed execution timestamps or waiting items. Do not restore active promises, retry timers, abort controllers, or transient running state. + +See the [queuing guide](./queuing.md) and [rate limiting guide](./rate-limiting.md) for framework-specific APIs. diff --git a/examples/angular/asyncBatch/src/app/app.html b/examples/angular/asyncBatch/src/app/app.html index 4ba242e56..3135d87c9 100644 --- a/examples/angular/asyncBatch/src/app/app.html +++ b/examples/angular/asyncBatch/src/app/app.html @@ -1,29 +1,43 @@ -
-

Async batcher

-

Collects items, batches every 3 or after 3s, process takes 1s.

+

TanStack Pacer asyncBatch Example

+

Batch Status

+

Pending Items: {{ pendingItems().length }} / 5

+

+ Wait up to 3000ms, process for 1000ms. Urgent items trigger immediate + processing. +

+

Is Processing: {{ isProcessing() }}

+

+ Successful Batches: {{ successCount() }}, Failed Batches: {{ errorCount() }} +

-
- - - -
+

Current Batch Items

+
    + @for (item of pendingItems(); track item.id) { +
  • {{ item.value }}
  • + } @empty { +
  • No items pending
  • + } +
+ -
-

Status

-

Pending: {{ batcher.state().items }}

-

Executing: {{ batcher.state().isExecuting }}

-
- -
-

Batches

- @if (logs().length === 0) { -

No batches yet. Add items to start batching.

- } @else { -
    - @for (entry of logs(); track $index) { -
  • {{ entry }}
  • - } -
- } -
-
+ +

Processed Batches ({{ processedBatches().length }})

+@for (batch of processedBatches(); track $index) { +

{{ batch.result }}

+} @if (errors().length) { +

Errors ({{ errors().length }})

+@for (error of errors(); track $index) { +

{{ error }}

+} } +
+ Processed batch data +
{{ processedBatches() | json }}
+
diff --git a/examples/angular/asyncBatch/src/app/app.spec.ts b/examples/angular/asyncBatch/src/app/app.spec.ts index f9baf7f75..986c0e236 100644 --- a/examples/angular/asyncBatch/src/app/app.spec.ts +++ b/examples/angular/asyncBatch/src/app/app.spec.ts @@ -1,3 +1,4 @@ +import { vi } from 'vitest' import { TestBed } from '@angular/core/testing' import { App } from './app' @@ -18,6 +19,40 @@ describe('App', () => { const fixture = TestBed.createComponent(App) await fixture.whenStable() const compiled = fixture.nativeElement as HTMLElement - expect(compiled.querySelector('h1')?.textContent).toContain('Hello, basic') + expect(compiled.querySelector('h1')?.textContent).toContain( + 'TanStack Pacer asyncBatch Example', + ) + }) +}) + +// Exercise the real adapter and component with a deterministic clock. +describe('example behavior', () => { + beforeEach(async () => { + await TestBed.configureTestingModule({ imports: [App] }).compileComponents() + }) + afterEach(() => { + TestBed.resetTestingModule() + vi.clearAllTimers() + vi.useRealTimers() + vi.restoreAllMocks() + }) + it('processes urgent batches and handles simulated failures without rejection', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }) + const fixture = TestBed.createComponent(App) + const app = fixture.componentInstance + fixture.detectChanges() + TestBed.tick() + app.add() + expect(app.pendingItems()).toHaveLength(1) + app.add(true) + await vi.advanceTimersByTimeAsync(1000) + expect(app.processedBatches()).toHaveLength(1) + expect(app.successCount()).toBe(1) + vi.spyOn(Math, 'random').mockReturnValue(0) + app.shouldFail.set(true) + app.add(true) + await vi.advanceTimersByTimeAsync(1000) + expect(app.errorCount()).toBe(1) + expect(app.errors()[0]).toContain('Processing failed') }) }) diff --git a/examples/angular/asyncBatch/src/app/app.ts b/examples/angular/asyncBatch/src/app/app.ts index fdc543579..0199e4d47 100644 --- a/examples/angular/asyncBatch/src/app/app.ts +++ b/examples/angular/asyncBatch/src/app/app.ts @@ -1,54 +1,80 @@ import { Component, signal } from '@angular/core' -import { RouterOutlet } from '@angular/router' -import { injectAsyncBatcher } from '@tanstack/angular-pacer' +import { JsonPipe } from '@angular/common' +import { asyncBatch } from '@tanstack/angular-pacer' + +type Item = { id: number; value: string; timestamp: number } @Component({ selector: 'app-root', - imports: [RouterOutlet], templateUrl: './app.html', - styleUrl: './app.css', + imports: [JsonPipe], }) export class App { - protected itemId = 0 - - protected readonly logs = signal>([]) - - protected readonly batcher = injectAsyncBatcher< - number, + private nextId = 0 + readonly processedBatches = signal< + Array<{ items: Array; result: string; timestamp: number }> + >([]) + readonly errors = signal>([]) + readonly pendingItems = signal>([]) + readonly isProcessing = signal(false) + readonly shouldFail = signal(false) + readonly successCount = signal(0) + readonly errorCount = signal(0) + readonly runner = asyncBatch( + (items: Array) => this.processBatch(items), { - items: Array - isExecuting: boolean - } - >( - async (items) => { - // simulate async work taking ~1s - await new Promise((r) => setTimeout(r, 1000)) - - const entry = `${new Date().toLocaleTimeString()} - processed [${items.join(', ')}]` - this.logs.update((l) => [entry, ...l]) - }, - { - maxSize: 3, + maxSize: 5, wait: 3000, + getShouldExecute: (items) => + items.some((item) => item.value.includes('urgent')), + throwOnError: false, + onItemsChange: (batcher) => this.pendingItems.set(batcher.peekAllItems()), + onSuccess: (result, items, batcher) => { + this.successCount.set(batcher.store.state.successCount) + console.log('Batch succeeded:', result, items) + }, + onError: (error, items, batcher) => { + this.errorCount.set(batcher.store.state.errorCount) + this.errors.update((errors) => [ + ...errors, + `${error.message} (${new Date().toLocaleTimeString()})`, + ]) + console.error('Batch failed:', error, items) + }, + onSettled: (items, batcher) => + console.log( + 'Batch settled:', + items, + batcher.store.state.totalItemsProcessed, + ), }, - (state) => ({ - items: state.items, - isExecuting: state.isExecuting, - }), ) - protected add(): void { - this.itemId++ - this.batcher.addItem(this.itemId) - } - - protected flush(): void { - this.batcher.flush() + private async processBatch(items: Array): Promise { + this.isProcessing.set(true) + try { + await new Promise((resolve) => setTimeout(resolve, 1000)) + if (this.shouldFail() && Math.random() < 0.3) { + throw new Error( + `Processing failed for batch with ${items.length} items`, + ) + } + const result = `Processed ${items.length} items: ${items.map((item) => item.value).join(', ')}` + this.processedBatches.update((batches) => [ + ...batches, + { items, result, timestamp: Date.now() }, + ]) + return result + } finally { + this.isProcessing.set(false) + } } - - protected reset(): void { - this.batcher.reset() - this.itemId = 0 - this.logs.set([]) + add(urgent = false): void { + const id = ++this.nextId + this.runner({ + id, + value: `${urgent ? 'urgent' : 'item'}-${id}`, + timestamp: Date.now(), + }) } } diff --git a/examples/angular/asyncDebounce/src/app/app.html b/examples/angular/asyncDebounce/src/app/app.html index 4454ae2a5..cab30c503 100644 --- a/examples/angular/asyncDebounce/src/app/app.html +++ b/examples/angular/asyncDebounce/src/app/app.html @@ -1,35 +1,23 @@ -
-

Async debouncer

-

Waits 500ms after last change, execution takes 1s.

+

TanStack Pacer asyncDebounce Example

- - -
- -
- -
-

Status

-

Pending: {{ debouncer.state().isPending }}

-

Executing: {{ debouncer.state().isExecuting }}

-
- -
-

Events

- @if (logs().length === 0) { -

No events yet. Type in the input to search.

- } @else { -
    - @for (entry of logs(); track $index) { -
  • {{ entry }}
  • - } -
- } -
-
+

500ms wait, 800ms simulated network delay.

+ +@if (loading()) { +

Loading...

+} @if (error()) { +

Error: {{ error() }}

+} +

Instant Search: {{ searchTerm() }}

+

Debounced Search: {{ executedTerm() }}

+

Search Results

+
    + @for (result of results(); track $index) { +
  • {{ result }}
  • + } +
diff --git a/examples/angular/asyncDebounce/src/app/app.spec.ts b/examples/angular/asyncDebounce/src/app/app.spec.ts index f9baf7f75..a62576f52 100644 --- a/examples/angular/asyncDebounce/src/app/app.spec.ts +++ b/examples/angular/asyncDebounce/src/app/app.spec.ts @@ -1,3 +1,4 @@ +import { vi } from 'vitest' import { TestBed } from '@angular/core/testing' import { App } from './app' @@ -18,6 +19,38 @@ describe('App', () => { const fixture = TestBed.createComponent(App) await fixture.whenStable() const compiled = fixture.nativeElement as HTMLElement - expect(compiled.querySelector('h1')?.textContent).toContain('Hello, basic') + expect(compiled.querySelector('h1')?.textContent).toContain( + 'TanStack Pacer asyncDebounce Example', + ) + }) +}) + +// Exercise the real adapter and component with a deterministic clock. +describe('example behavior', () => { + beforeEach(async () => { + await TestBed.configureTestingModule({ imports: [App] }).compileComponents() + }) + afterEach(() => { + TestBed.resetTestingModule() + vi.clearAllTimers() + vi.useRealTimers() + vi.restoreAllMocks() + }) + it('completes a search and renders its results', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }) + const fixture = TestBed.createComponent(App) + const app = fixture.componentInstance + fixture.detectChanges() + TestBed.tick() + void app.onSearch('angular') + await vi.advanceTimersByTimeAsync(2500) + TestBed.tick() + expect(app.results()).toEqual([ + 'Result 1 for angular', + 'Result 2 for angular', + 'Result 3 for angular', + ]) + expect(app.loading()).toBe(false) + expect(fixture.nativeElement.textContent).toContain('Result 1 for angular') }) }) diff --git a/examples/angular/asyncDebounce/src/app/app.ts b/examples/angular/asyncDebounce/src/app/app.ts index cf2ef5b16..bc59dbf35 100644 --- a/examples/angular/asyncDebounce/src/app/app.ts +++ b/examples/angular/asyncDebounce/src/app/app.ts @@ -1,43 +1,32 @@ import { Component, signal } from '@angular/core' -import { injectAsyncDebouncer } from '@tanstack/angular-pacer' +import { asyncDebounce } from '@tanstack/angular-pacer' -@Component({ - selector: 'app-root', - standalone: true, - templateUrl: './app.html', -}) +@Component({ selector: 'app-root', templateUrl: './app.html' }) export class App { - protected readonly query = signal('') - protected readonly logs = signal>([]) - protected readonly debouncer = injectAsyncDebouncer( - async (q: string) => { - await new Promise((resolve) => setTimeout(resolve, 1000)) - return `searched: ${q}` - }, - { wait: 500 }, - (state) => ({ isPending: state.isPending, isExecuting: state.isExecuting }), - ) + readonly searchTerm = signal('') + readonly executedTerm = signal('') + readonly results = signal>([]) + readonly loading = signal(false) + readonly error = signal('') + readonly runner = asyncDebounce((term: string) => this.search(term), { + wait: 500, + }) - protected async search(value: string): Promise { + private async search(term: string): Promise | undefined> { + this.loading.set(true) + this.executedTerm.set(term) try { - const result = await this.debouncer.maybeExecute(value) - if (result !== undefined) { - this.logs.update((entries) => [result, ...entries]) - } - } catch (error) { - this.logs.update((entries) => [`error: ${error}`, ...entries]) + await new Promise((resolve) => setTimeout(resolve, 800)) + const results = [1, 2, 3].map((id) => `Result ${id} for ${term}`) + this.results.set(results) + this.error.set('') + return results + } finally { + this.loading.set(false) } } - - protected onInput(event: Event): void { - const target = event.target as HTMLInputElement - this.query.set(target.value) - void this.search(target.value) - } - - protected reset(): void { - this.debouncer.reset() - this.query.set('') - this.logs.set([]) + async onSearch(value: string): Promise { + this.searchTerm.set(value) + await this.runner(value) } } diff --git a/examples/angular/asyncRateLimit/src/app/app.html b/examples/angular/asyncRateLimit/src/app/app.html index a7776acaa..1d49149c6 100644 --- a/examples/angular/asyncRateLimit/src/app/app.html +++ b/examples/angular/asyncRateLimit/src/app/app.html @@ -1,28 +1,42 @@ -
-

Async rate limiter

-

3 executions per 5 seconds (sliding window).

- -
- - -
- -
-

Status

-

Allowed: {{ rateLimiter.state().executionTimes.length }}

-

Blocked: {{ rateLimiter.state().rejectionCount }}

-
- -
-

Events

- @if (logs().length === 0) { -

No events yet. Press Attempt to generate entries.

- } @else { -
    - @for (entry of logs(); track $index) { -
  • {{ entry }}
  • - } -
- } -
-
+

TanStack Pacer asyncRateLimit Example

+
+ Window type + +
+

5 requests per 5000ms.

+ +@if (loading()) { +

Loading...

+} @if (error()) { +

Error: {{ error() }}

+} +

Instant Search: {{ searchTerm() }}

+

Rate Limited Search: {{ executedTerm() }}

+

Search Results

+
    + @for (result of results(); track $index) { +
  • {{ result }}
  • + } +
diff --git a/examples/angular/asyncRateLimit/src/app/app.spec.ts b/examples/angular/asyncRateLimit/src/app/app.spec.ts index f9baf7f75..f49073e1a 100644 --- a/examples/angular/asyncRateLimit/src/app/app.spec.ts +++ b/examples/angular/asyncRateLimit/src/app/app.spec.ts @@ -1,3 +1,4 @@ +import { vi } from 'vitest' import { TestBed } from '@angular/core/testing' import { App } from './app' @@ -18,6 +19,38 @@ describe('App', () => { const fixture = TestBed.createComponent(App) await fixture.whenStable() const compiled = fixture.nativeElement as HTMLElement - expect(compiled.querySelector('h1')?.textContent).toContain('Hello, basic') + expect(compiled.querySelector('h1')?.textContent).toContain( + 'TanStack Pacer asyncRateLimit Example', + ) + }) +}) + +// Exercise the real adapter and component with a deterministic clock. +describe('example behavior', () => { + beforeEach(async () => { + await TestBed.configureTestingModule({ imports: [App] }).compileComponents() + }) + afterEach(() => { + TestBed.resetTestingModule() + vi.clearAllTimers() + vi.useRealTimers() + vi.restoreAllMocks() + }) + it('completes a search and renders its results', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }) + const fixture = TestBed.createComponent(App) + const app = fixture.componentInstance + fixture.detectChanges() + TestBed.tick() + void app.onSearch('angular') + await vi.advanceTimersByTimeAsync(2500) + TestBed.tick() + expect(app.results()).toEqual([ + 'Result 1 for angular', + 'Result 2 for angular', + 'Result 3 for angular', + ]) + expect(app.loading()).toBe(false) + expect(fixture.nativeElement.textContent).toContain('Result 1 for angular') }) }) diff --git a/examples/angular/asyncRateLimit/src/app/app.ts b/examples/angular/asyncRateLimit/src/app/app.ts index 40173cea6..729bb0979 100644 --- a/examples/angular/asyncRateLimit/src/app/app.ts +++ b/examples/angular/asyncRateLimit/src/app/app.ts @@ -1,41 +1,46 @@ import { Component, signal } from '@angular/core' -import { injectAsyncRateLimiter } from '@tanstack/angular-pacer' +import { asyncRateLimit } from '@tanstack/angular-pacer' -@Component({ - selector: 'app-root', - standalone: true, - templateUrl: './app.html', -}) +@Component({ selector: 'app-root', templateUrl: './app.html' }) export class App { - protected readonly Math = Math - protected readonly logs = signal>([]) - protected readonly rateLimiter = injectAsyncRateLimiter( - async (value: number) => { - await new Promise((resolve) => setTimeout(resolve, 500)) - return `processed ${value}` - }, - { - limit: 3, + readonly searchTerm = signal('') + readonly executedTerm = signal('') + readonly results = signal>([]) + readonly loading = signal(false) + readonly error = signal('') + readonly windowType = signal<'fixed' | 'sliding'>('fixed') + runner = this.createRunner() + private createRunner() { + return asyncRateLimit((term: string) => this.search(term), { + limit: 5, window: 5000, - windowType: 'sliding', - }, - (state) => ({ - rejectionCount: state.rejectionCount, - executionTimes: state.executionTimes, - }), - ) + windowType: this.windowType(), + onReject: (_args, limiter) => + console.log( + `Rate limit reached. Try again in ${limiter.getMsUntilNextWindow()}ms`, + ), + }) + } - protected async attempt(value: number): Promise { + private async search(term: string): Promise | undefined> { + this.loading.set(true) + this.executedTerm.set(term) try { - const result = await this.rateLimiter.maybeExecute(value) - this.logs.update((entries) => [`allowed: ${result}`, ...entries]) - } catch (error) { - this.logs.update((entries) => [`blocked: ${error}`, ...entries]) + await new Promise((resolve) => setTimeout(resolve, 800)) + const results = [1, 2, 3].map((id) => `Result ${id} for ${term}`) + this.results.set(results) + this.error.set('') + return results + } finally { + this.loading.set(false) } } - - protected reset(): void { - this.rateLimiter.reset() - this.logs.set([]) + async onSearch(value: string): Promise { + this.searchTerm.set(value) + await this.runner(value) + } + changeWindow(value: 'fixed' | 'sliding'): void { + this.windowType.set(value) + this.runner = this.createRunner() } } diff --git a/examples/angular/asyncRetry/src/app/app.html b/examples/angular/asyncRetry/src/app/app.html index ae6b122b2..ce001837b 100644 --- a/examples/angular/asyncRetry/src/app/app.html +++ b/examples/angular/asyncRetry/src/app/app.html @@ -1,54 +1,55 @@ -
-

TanStack Pacer - Async Retry Example

- -
-

AsyncRetry

-

- Click "Run" to execute an async task that fails the first N times, then succeeds. The - decorator retries with backoff. -

- -
-

Controls

-
- - - - - -
-
- -
-

Status

-
-
Status: {{ status() }}
-
Attempt: {{ attempt() }}
-
Result: {{ result() ?? '-' }}
-
Error: {{ error() ?? '-' }}
-
-
- -
-

Log

-
- @if (log().length === 0) { - No logs yet. Click "Run" to start. - } @for (entry of log(); track $index) { -
{{ entry.time }} — {{ entry.message }}
- } -
-
-
- - -
+

TanStack Pacer asyncRetry Example

+

+ Compare exponential and linear backoff, jitter, per-attempt timeouts, and + total timeouts. The simulated API fails 60% of requests. +

+

Configuration

+ + + + +

Current Options

+
{{ options() | json }}
+

State

+

+ Status: {{ isLoading() ? (currentAttempt() > 1 ? 'retrying' : 'executing') : + 'idle' }} +

+@if (currentAttempt()) { +

Current Attempt: {{ currentAttempt() }} / {{ options().maxAttempts }}

+} @if (error()) { +

Error: {{ error() }}

+} @if (userData(); as user) { +

User Data

+

ID: {{ user.id }}, Name: {{ user.name }}, Email: {{ user.email }}

+} +

Activity Log

+@for (log of logs(); track $index) { +

{{ log }}

+} @empty { +

No activity yet

+} +

+ Each call to the returned function creates a fresh retry context. The timeout + scenario uses a 3000ms API delay and a 2000ms per-attempt timeout. +

diff --git a/examples/angular/asyncRetry/src/app/app.spec.ts b/examples/angular/asyncRetry/src/app/app.spec.ts index f9baf7f75..92fefb469 100644 --- a/examples/angular/asyncRetry/src/app/app.spec.ts +++ b/examples/angular/asyncRetry/src/app/app.spec.ts @@ -1,3 +1,4 @@ +import { vi } from 'vitest' import { TestBed } from '@angular/core/testing' import { App } from './app' @@ -18,6 +19,43 @@ describe('App', () => { const fixture = TestBed.createComponent(App) await fixture.whenStable() const compiled = fixture.nativeElement as HTMLElement - expect(compiled.querySelector('h1')?.textContent).toContain('Hello, basic') + expect(compiled.querySelector('h1')?.textContent).toContain( + 'TanStack Pacer asyncRetry Example', + ) + }) +}) + +// Exercise the real adapter and component with a deterministic clock. +describe('example behavior', () => { + beforeEach(async () => { + await TestBed.configureTestingModule({ imports: [App] }).compileComponents() + }) + afterEach(() => { + TestBed.resetTestingModule() + vi.clearAllTimers() + vi.useRealTimers() + vi.restoreAllMocks() + }) + it('uses each selected retry configuration and returns successful user data', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }) + const fixture = TestBed.createComponent(App) + const app = fixture.componentInstance + fixture.detectChanges() + TestBed.tick() + app.setScenario('linear') + expect(app.options().backoff).toBe('linear') + expect(app.options().maxAttempts).toBe(4) + app.setScenario('timeout') + expect(app.options().maxExecutionTime).toBe(2000) + app.setScenario('jitter') + expect(app.options().jitter).toBe(0.3) + app.setScenario('default') + vi.spyOn(Math, 'random').mockReturnValue(0.9) + const result = app.fetchUser() + await vi.advanceTimersByTimeAsync(800) + await result + expect(app.userData()?.id).toBe(123) + expect(app.error()).toBe('') + expect(app.isLoading()).toBe(false) }) }) diff --git a/examples/angular/asyncRetry/src/app/app.ts b/examples/angular/asyncRetry/src/app/app.ts index 80803dbe8..833271e86 100644 --- a/examples/angular/asyncRetry/src/app/app.ts +++ b/examples/angular/asyncRetry/src/app/app.ts @@ -1,88 +1,132 @@ -import { Component, signal } from '@angular/core' -import { RouterOutlet } from '@angular/router' +import { Component, computed, signal } from '@angular/core' +import { JsonPipe } from '@angular/common' import { asyncRetry } from '@tanstack/angular-pacer' +type Scenario = 'default' | 'timeout' | 'jitter' | 'linear' +type UserData = { id: number; name: string; email: string } + @Component({ selector: 'app-root', - imports: [RouterOutlet], templateUrl: './app.html', - styleUrl: './app.css', + imports: [JsonPipe], }) export class App { - protected readonly failTimes = signal(2) - protected readonly runId = signal(0) - - protected readonly status = signal<'idle' | 'running' | 'success' | 'error'>('idle') - protected readonly attempt = signal(0) - protected readonly result = signal(null) - protected readonly error = signal(null) - - protected readonly log = signal>([]) - - private logLine(message: string) { - this.log.update((l) => [{ time: new Date().toLocaleTimeString(), message }, ...l].slice(0, 50)) - } - - protected setFailTimes(value: number) { - this.failTimes.set(value) - } - - protected bumpRunId() { - this.runId.update((v) => v + 1) + readonly userId = signal('123') + readonly userData = signal(null) + readonly isLoading = signal(false) + readonly error = signal('') + readonly currentAttempt = signal(0) + readonly scenario = signal('default') + readonly logs = signal>([]) + readonly options = computed(() => { + switch (this.scenario()) { + case 'timeout': + return { + maxAttempts: 3, + backoff: 'exponential' as const, + baseWait: 500, + maxExecutionTime: 2000, + maxTotalExecutionTime: 8000, + jitter: 0, + } + case 'jitter': + return { + maxAttempts: 5, + backoff: 'exponential' as const, + baseWait: 500, + jitter: 0.3, + } + case 'linear': + return { + maxAttempts: 4, + backoff: 'linear' as const, + baseWait: 1000, + jitter: 0, + } + default: + return { + maxAttempts: 5, + backoff: 'exponential' as const, + baseWait: 1000, + jitter: 0, + } + } + }) + private log(message: string): void { + this.logs.update((logs) => [ + ...logs.slice(-9), + `${new Date().toLocaleTimeString()}: ${message}`, + ]) } - - protected reset() { - this.status.set('idle') - this.attempt.set(0) - this.result.set(null) - this.error.set(null) - this.log.set([]) + setScenario(value: string): void { + if ( + value === 'default' || + value === 'timeout' || + value === 'jitter' || + value === 'linear' + ) + this.scenario.set(value) } - - protected async run(runId: number): Promise { - this.status.set('running') - this.result.set(null) - this.error.set(null) - - const attempt = this.attempt() + 1 - this.attempt.set(attempt) - this.logLine(`attempt ${attempt} (runId=${runId})`) - - await new Promise((r) => setTimeout(r, 250)) - - if (attempt <= this.failTimes()) { - const msg = `simulated failure on attempt ${attempt}` - this.error.set(msg) - this.logLine(msg) - throw new Error(msg) + async fetchUser(): Promise { + this.logs.set([]) + this.isLoading.set(true) + this.error.set('') + this.currentAttempt.set(1) + this.log('Starting fetch operation') + const scenario = this.scenario() + const fetchWithRetry = asyncRetry( + async (id: string): Promise => { + this.log(`Attempting to fetch user ${id}`) + await new Promise((resolve) => + setTimeout(resolve, scenario === 'timeout' ? 3000 : 800), + ) + if (Math.random() < 0.6) + throw new Error(`Network error fetching user ${id}`) + return { + id: Number.parseInt(id), + name: `User ${id}`, + email: `user${id}@example.com`, + } + }, + { + ...this.options(), + onRetry: (attempt, error) => { + this.log(`Retry attempt ${attempt} after error: ${error.message}`) + this.currentAttempt.set(attempt + 1) + }, + onError: (error) => this.log(`Request failed: ${error.message}`), + onLastError: (error) => { + this.log(`All retries exhausted: ${error.message}`) + this.error.set(error.message) + this.userData.set(null) + }, + onSuccess: (result) => { + this.log(`Request succeeded for user ${result.id}`) + this.userData.set(result) + this.error.set('') + }, + onSettled: () => { + this.log('Request settled') + this.currentAttempt.set(0) + }, + }, + ) + try { + const result = await fetchWithRetry(this.userId()) + this.log(`Final result: ${result ? `User ${result.id}` : 'undefined'}`) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + this.error.set(message) + this.log(`Caught error: ${message}`) + } finally { + this.isLoading.set(false) } - - const value = `success on attempt ${attempt}` - this.result.set(value) - this.status.set('success') - this.logLine(value) - return value } - - protected async start() { - this.reset() - this.bumpRunId() - const id = this.runId() - - const runWithRetry = asyncRetry(this.run.bind(this), { - key: `asyncRetry-example-${id}`, - maxAttempts: 5, - baseWait: 250, - backoff: 'exponential', - // asyncRetry expects jitter as a number (e.g. 0.1), not a boolean - jitter: 0.1, - }) - - const value = await runWithRetry(id) - - if (value === undefined) { - this.status.set('error') - this.logLine('gave up (maxAttempts reached)') - } + reset(): void { + this.userData.set(null) + this.error.set('') + this.logs.set([]) + this.currentAttempt.set(0) + this.log('State reset') } } diff --git a/examples/angular/asyncThrottle/src/app/app.html b/examples/angular/asyncThrottle/src/app/app.html index 33d48ecda..84b884eab 100644 --- a/examples/angular/asyncThrottle/src/app/app.html +++ b/examples/angular/asyncThrottle/src/app/app.html @@ -1,28 +1,23 @@ -
-

Async throttler

-

Waits 2s between executions, each taking 1s.

+

TanStack Pacer asyncThrottle Example

-
- - -
- -
-

Status

-

Pending: {{ throttler.state().isPending }}

-

Executing: {{ throttler.state().isExecuting }}

-
- -
-

Events

- @if (logs().length === 0) { -

No events yet. Press Attempt to generate entries.

- } @else { -
    - @for (entry of logs(); track $index) { -
  • {{ entry }}
  • - } -
- } -
-
+

1000ms wait, 800ms simulated network delay.

+ +@if (loading()) { +

Loading...

+} @if (error()) { +

Error: {{ error() }}

+} +

Instant Search: {{ searchTerm() }}

+

Throttled Search: {{ executedTerm() }}

+

Search Results

+
    + @for (result of results(); track $index) { +
  • {{ result }}
  • + } +
diff --git a/examples/angular/asyncThrottle/src/app/app.spec.ts b/examples/angular/asyncThrottle/src/app/app.spec.ts index f9baf7f75..750a4073b 100644 --- a/examples/angular/asyncThrottle/src/app/app.spec.ts +++ b/examples/angular/asyncThrottle/src/app/app.spec.ts @@ -1,3 +1,4 @@ +import { vi } from 'vitest' import { TestBed } from '@angular/core/testing' import { App } from './app' @@ -18,6 +19,38 @@ describe('App', () => { const fixture = TestBed.createComponent(App) await fixture.whenStable() const compiled = fixture.nativeElement as HTMLElement - expect(compiled.querySelector('h1')?.textContent).toContain('Hello, basic') + expect(compiled.querySelector('h1')?.textContent).toContain( + 'TanStack Pacer asyncThrottle Example', + ) + }) +}) + +// Exercise the real adapter and component with a deterministic clock. +describe('example behavior', () => { + beforeEach(async () => { + await TestBed.configureTestingModule({ imports: [App] }).compileComponents() + }) + afterEach(() => { + TestBed.resetTestingModule() + vi.clearAllTimers() + vi.useRealTimers() + vi.restoreAllMocks() + }) + it('completes a search and renders its results', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }) + const fixture = TestBed.createComponent(App) + const app = fixture.componentInstance + fixture.detectChanges() + TestBed.tick() + void app.onSearch('angular') + await vi.advanceTimersByTimeAsync(2500) + TestBed.tick() + expect(app.results()).toEqual([ + 'Result 1 for angular', + 'Result 2 for angular', + 'Result 3 for angular', + ]) + expect(app.loading()).toBe(false) + expect(fixture.nativeElement.textContent).toContain('Result 1 for angular') }) }) diff --git a/examples/angular/asyncThrottle/src/app/app.ts b/examples/angular/asyncThrottle/src/app/app.ts index 5dd6260db..99f6009c2 100644 --- a/examples/angular/asyncThrottle/src/app/app.ts +++ b/examples/angular/asyncThrottle/src/app/app.ts @@ -1,34 +1,32 @@ import { Component, signal } from '@angular/core' -import { injectAsyncThrottler } from '@tanstack/angular-pacer' +import { asyncThrottle } from '@tanstack/angular-pacer' -@Component({ - selector: 'app-root', - standalone: true, - templateUrl: './app.html', -}) +@Component({ selector: 'app-root', templateUrl: './app.html' }) export class App { - protected readonly Math = Math - protected readonly logs = signal>([]) - protected readonly throttler = injectAsyncThrottler( - async (value: number) => { - await new Promise((resolve) => setTimeout(resolve, 1000)) - return `processed ${value}` - }, - { wait: 2000 }, - (state) => ({ isExecuting: state.isExecuting, isPending: state.isPending }), - ) + readonly searchTerm = signal('') + readonly executedTerm = signal('') + readonly results = signal>([]) + readonly loading = signal(false) + readonly error = signal('') + readonly runner = asyncThrottle((term: string) => this.search(term), { + wait: 1000, + }) - protected async attempt(value: number): Promise { + private async search(term: string): Promise | undefined> { + this.loading.set(true) + this.executedTerm.set(term) try { - const result = await this.throttler.maybeExecute(value) - this.logs.update((entries) => [`result: ${result}`, ...entries]) - } catch (error) { - this.logs.update((entries) => [`error: ${error}`, ...entries]) + await new Promise((resolve) => setTimeout(resolve, 800)) + const results = [1, 2, 3].map((id) => `Result ${id} for ${term}`) + this.results.set(results) + this.error.set('') + return results + } finally { + this.loading.set(false) } } - - protected reset(): void { - this.throttler.reset() - this.logs.set([]) + async onSearch(value: string): Promise { + this.searchTerm.set(value) + await this.runner(value) } } diff --git a/examples/angular/batch/src/app/app.html b/examples/angular/batch/src/app/app.html index 638e1cf16..0a7a4b60f 100644 --- a/examples/angular/batch/src/app/app.html +++ b/examples/angular/batch/src/app/app.html @@ -1,52 +1,10 @@ -
-

TanStack Pacer - Angular Batcher Example

+

TanStack Pacer batch Example

+

Batch Size: {{ pendingItems().length }} / 5

+

Process after 3000ms, at 5 items, or when the batch includes 42.

+

Batch Items: {{ pendingItems().join(', ') }}

+ -
-

Batcher Example

-

- Click the buttons below to add items to a batch. Items will be processed when 3 items are - collected or after 2 seconds, whichever comes first. -

-
- @for (item of ['Item 1', 'Item 2', 'Item 3', 'Item 4', 'Item 5']; track item) { - - } - -
-
-

Current batch size: {{ batcher.state().size }}

-

Items in batch: {{ batcher.state().items.join(', ') || 'None' }}

-

Is pending: {{ batcher.state().isPending }}

-
-
-

Processed Batches:

- - @if (processedBatches.length === 0) { -
No batches processed yet
- } @for (batch of processedBatches; track batch.timestamp) { -
- {{ batch.timestamp }} - Processed {{ batch.count }} items: {{ - batch.items.join(', ') }} -
- } -
-
- - -
+

Processed Batches

+@for (items of processedBatches(); track $index) { +

[{{ items.join(', ') }}]

+} diff --git a/examples/angular/batch/src/app/app.spec.ts b/examples/angular/batch/src/app/app.spec.ts index f9baf7f75..62e63934a 100644 --- a/examples/angular/batch/src/app/app.spec.ts +++ b/examples/angular/batch/src/app/app.spec.ts @@ -1,3 +1,4 @@ +import { vi } from 'vitest' import { TestBed } from '@angular/core/testing' import { App } from './app' @@ -18,6 +19,31 @@ describe('App', () => { const fixture = TestBed.createComponent(App) await fixture.whenStable() const compiled = fixture.nativeElement as HTMLElement - expect(compiled.querySelector('h1')?.textContent).toContain('Hello, basic') + expect(compiled.querySelector('h1')?.textContent).toContain( + 'TanStack Pacer batch Example', + ) + }) +}) + +// Exercise the real adapter and component with a deterministic clock. +describe('example behavior', () => { + beforeEach(async () => { + await TestBed.configureTestingModule({ imports: [App] }).compileComponents() + }) + afterEach(() => { + TestBed.resetTestingModule() + vi.clearAllTimers() + vi.useRealTimers() + vi.restoreAllMocks() + }) + it('processes five collected numbers as one batch', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }) + const fixture = TestBed.createComponent(App) + const app = fixture.componentInstance + fixture.detectChanges() + TestBed.tick() + for (let index = 0; index < 5; index++) app.add() + expect(app.processedBatches()).toEqual([[1, 2, 3, 4, 5]]) + expect(app.pendingItems()).toEqual([]) }) }) diff --git a/examples/angular/batch/src/app/app.ts b/examples/angular/batch/src/app/app.ts index 7c5550190..65fb468ca 100644 --- a/examples/angular/batch/src/app/app.ts +++ b/examples/angular/batch/src/app/app.ts @@ -1,46 +1,23 @@ -import { Component } from '@angular/core' -import { RouterOutlet } from '@angular/router' -import { injectBatcher } from '@tanstack/angular-pacer' +import { Component, signal } from '@angular/core' +import { batch } from '@tanstack/angular-pacer' -@Component({ - selector: 'app-root', - imports: [RouterOutlet], - templateUrl: './app.html', - styleUrl: './app.css', -}) +@Component({ selector: 'app-root', templateUrl: './app.html' }) export class App { - // Batcher example - protected readonly batcher = injectBatcher< - string, - { items: Array; size: number; isPending: boolean } - >( - (items) => { - console.log('Processing batch:', items) - this.processedBatches.push({ - timestamp: new Date().toLocaleTimeString(), - items: [...items], - count: items.length, - }) + readonly processedBatches = signal>>([]) + readonly pendingItems = signal>([]) + readonly runner = batch( + (items: Array) => { + this.processedBatches.update((batches) => [...batches, items]) + }, + { + maxSize: 5, + wait: 3000, + getShouldExecute: (items) => items.includes(42), + onItemsChange: (batcher) => this.pendingItems.set(batcher.peekAllItems()), }, - { maxSize: 3, wait: 2000 }, - (state) => ({ - items: state.items, - size: state.size, - isPending: state.isPending, - }), ) - - protected readonly processedBatches: Array<{ - timestamp: string - items: Array - count: number - }> = [] - - protected addToBatch(item: string): void { - this.batcher.addItem(item) - } - - protected flushBatch(): void { - this.batcher.flush() + add(): void { + const items = this.pendingItems() + this.runner(items.length ? items[items.length - 1]! + 1 : 1) } } diff --git a/examples/angular/debounce/src/app/app.html b/examples/angular/debounce/src/app/app.html index 2d4f52889..0d6611837 100644 --- a/examples/angular/debounce/src/app/app.html +++ b/examples/angular/debounce/src/app/app.html @@ -1,18 +1,39 @@ -
-

TanStack Pacer - Angular Examples

- -
-

Debouncer Example

-

Type in the input below. The value will update 500ms after you stop typing.

+

TanStack Pacer debounce Example

+
+

Counter

+ +

Instant Count: {{ instantCount() }}

+

Debounced Count: {{ controlledCount() }}

+
+
+

Search

+
- - -
+ /> +

Instant Search: {{ search() }}

+

Debounced Search: {{ controlledSearch() }}

+ +
+

Range

+ +

Current Value: {{ currentValue() }}

+ +

Debounced Value: {{ controlledValue() }}

+

Debounced with a 250 ms wait.

+
diff --git a/examples/angular/debounce/src/app/app.spec.ts b/examples/angular/debounce/src/app/app.spec.ts index f9baf7f75..813ddfebf 100644 --- a/examples/angular/debounce/src/app/app.spec.ts +++ b/examples/angular/debounce/src/app/app.spec.ts @@ -1,3 +1,4 @@ +import { vi } from 'vitest' import { TestBed } from '@angular/core/testing' import { App } from './app' @@ -18,6 +19,39 @@ describe('App', () => { const fixture = TestBed.createComponent(App) await fixture.whenStable() const compiled = fixture.nativeElement as HTMLElement - expect(compiled.querySelector('h1')?.textContent).toContain('Hello, basic') + expect(compiled.querySelector('h1')?.textContent).toContain( + 'TanStack Pacer debounce Example', + ) + }) +}) + +// Exercise the real adapter and component with a deterministic clock. +describe('example behavior', () => { + beforeEach(async () => { + await TestBed.configureTestingModule({ imports: [App] }).compileComponents() + }) + afterEach(() => { + TestBed.resetTestingModule() + vi.clearAllTimers() + vi.useRealTimers() + vi.restoreAllMocks() + }) + it('runs the counter, search, and range scenarios', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }) + const fixture = TestBed.createComponent(App) + const app = fixture.componentInstance + fixture.detectChanges() + TestBed.tick() + app.increment() + app.increment() + app.increment() + app.onSearch('angular') + app.onRange(73) + TestBed.tick() + await vi.advanceTimersByTimeAsync(1100) + TestBed.tick() + expect(app.controlledCount()).toBe(3) + expect(app.controlledSearch()).toBe('angular') + expect(app.controlledValue()).toBe(73) }) }) diff --git a/examples/angular/debounce/src/app/app.ts b/examples/angular/debounce/src/app/app.ts index 4d3b34080..547796294 100644 --- a/examples/angular/debounce/src/app/app.ts +++ b/examples/angular/debounce/src/app/app.ts @@ -1,19 +1,37 @@ -import { Component } from '@angular/core' -import { RouterOutlet } from '@angular/router' -import { injectDebouncedSignal } from '@tanstack/angular-pacer' +import { Component, signal } from '@angular/core' +import { debounce } from '@tanstack/angular-pacer' -@Component({ - selector: 'app-root', - imports: [RouterOutlet], - templateUrl: './app.html', -}) +@Component({ selector: 'app-root', templateUrl: './app.html', imports: [] }) export class App { - // Debouncer example - protected readonly debounced = injectDebouncedSignal( - '', + readonly instantCount = signal(0) + readonly search = signal('') + readonly currentValue = signal(50) + readonly controlledCount = signal(0) + readonly controlledSearch = signal('') + readonly controlledValue = signal(50) + readonly countRunner = debounce( + (value: number) => this.controlledCount.set(value), { wait: 500 }, - (state) => ({ isPending: state.isPending }), ) - protected readonly searchTerm = this.debounced - protected readonly setSearchTerm = this.debounced.set + readonly searchRunner = debounce( + (value: string) => this.controlledSearch.set(value), + { wait: 500 }, + ) + readonly rangeRunner = debounce( + (value: number) => this.controlledValue.set(value), + { wait: 250 }, + ) + increment(): void { + const next = this.instantCount() + 1 + this.instantCount.set(next) + this.countRunner(next) + } + onSearch(value: string): void { + this.search.set(value) + this.searchRunner(value) + } + onRange(value: number): void { + this.currentValue.set(value) + this.rangeRunner(value) + } } diff --git a/examples/angular/injectAsyncBatchedCallback/src/app/app.html b/examples/angular/injectAsyncBatchedCallback/src/app/app.html index 3a17816c3..d238146a0 100644 --- a/examples/angular/injectAsyncBatchedCallback/src/app/app.html +++ b/examples/angular/injectAsyncBatchedCallback/src/app/app.html @@ -1,34 +1,61 @@ -

injectAsyncBatchedCallback

- +

TanStack Pacer injectAsyncBatchedCallback Example 1

+

Batched searches

- Queue items quickly; the async callback runs with an array of items when a batch flushes. This - example batches up to 5 items or flushes after 1 second. + Max 3 queries or 2000ms wait, 800ms API delay. The query "error" fails the + entire batch.

- -
- - - - -
- -

Queued (not yet processed): {{ queuedCount() }}

-

Processing: {{ isProcessing() }}

- -

Processed Batches

- -@if (processedBatches.length === 0) { -

No batches processed yet. Click "Enqueue" a few times.

-} @else { -
    - @for (batch of processedBatches; track $index) { -
  • -
    {{ batch.timestamp }} ({{ batch.items.length }} items)
    -
    {{ batch.items.join(', ') }}
    -
  • - } -
+@for (query of searchChoices; track query) { + } @if +(isSearching()) { +

Processing batch search...

+}@if (searchError()) { +

Error: {{ searchError() }}

+} +

+ Total Searches Made: {{ queries().length }}, Results Found: {{ + results().length }}, Batches Processed: {{ searchBatches() }} +

+@for (result of results(); track $index) { +

{{ result.query }}: {{ result.title }}

+} +
+

TanStack Pacer injectAsyncBatchedCallback Example 2

+

Batched email validation

+

Max 4 emails or 1500ms wait, 600ms API delay.

+@for (email of emailChoices; track email) { + } @if +(isValidating()) { +

Validating email batch...

+} +

+ Emails Submitted: {{ emails().length }}, Emails Validated: {{ + validations().length }}, Batches Processed: {{ validationBatches() }} +

+@for (result of validations(); track $index) { +

+ {{ result.email }}: {{ result.message }} +

+} +
+

TanStack Pacer injectAsyncBatchedCallback Example 3

+

Batched data processing

+

+ Max 5 data points or 2500ms wait, 1000ms API delay. Each value is doubled. +

+@for (category of categories; track category) { + } @if +(isProcessing()) { +

Processing data batch...

+} +

+ Data Points Submitted: {{ points().length }}, Data Points Processed: {{ + processed().length }}, Batches Completed: {{ summaries().length }} +

+

Processed Data

+@for (point of processed(); track point.id) { +

{{ point.category }}: {{ point.value }}

+} +

Batch Summaries

+@for (summary of summaries(); track $index) { +
{{ summary | json }}
} diff --git a/examples/angular/injectAsyncBatchedCallback/src/app/app.spec.ts b/examples/angular/injectAsyncBatchedCallback/src/app/app.spec.ts index f9baf7f75..f26db1b4c 100644 --- a/examples/angular/injectAsyncBatchedCallback/src/app/app.spec.ts +++ b/examples/angular/injectAsyncBatchedCallback/src/app/app.spec.ts @@ -1,3 +1,4 @@ +import { vi } from 'vitest' import { TestBed } from '@angular/core/testing' import { App } from './app' @@ -18,6 +19,45 @@ describe('App', () => { const fixture = TestBed.createComponent(App) await fixture.whenStable() const compiled = fixture.nativeElement as HTMLElement - expect(compiled.querySelector('h1')?.textContent).toContain('Hello, basic') + expect(compiled.querySelector('h1')?.textContent).toContain( + 'TanStack Pacer injectAsyncBatchedCallback Example', + ) + }) +}) + +// Exercise the real adapter and component with a deterministic clock. +describe('example behavior', () => { + beforeEach(async () => { + await TestBed.configureTestingModule({ imports: [App] }).compileComponents() + }) + afterEach(() => { + TestBed.resetTestingModule() + vi.clearAllTimers() + vi.useRealTimers() + vi.restoreAllMocks() + }) + it('handles failed search batches and processes email and data workflows', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }) + const fixture = TestBed.createComponent(App) + const app = fixture.componentInstance + fixture.detectChanges() + TestBed.tick() + void app.addSearch('error') + void app.addSearch('angular') + void app.addSearch('typescript') + await vi.advanceTimersByTimeAsync(800) + expect(app.searchError()).toBe('Simulated batch API error') + expect(app.results()).toEqual([]) + void app.addEmail('user@example.com') + void app.addEmail('invalid-email') + void app.addData('sales') + await vi.advanceTimersByTimeAsync(3600) + expect(app.validations().map((result) => result.isValid)).toEqual([ + true, + false, + ]) + expect(app.processed()).toHaveLength(1) + expect(app.processed()[0]!.value).toBe(app.points()[0]!.value * 2) + expect(app.summaries()[0]!.categories).toBe(1) }) }) diff --git a/examples/angular/injectAsyncBatchedCallback/src/app/app.ts b/examples/angular/injectAsyncBatchedCallback/src/app/app.ts index 6d7822198..1d1ddfb7e 100644 --- a/examples/angular/injectAsyncBatchedCallback/src/app/app.ts +++ b/examples/angular/injectAsyncBatchedCallback/src/app/app.ts @@ -1,68 +1,125 @@ import { Component, signal } from '@angular/core' +import { JsonPipe } from '@angular/common' import { injectAsyncBatchedCallback } from '@tanstack/angular-pacer' -type BatchEntry = { - timestamp: string - items: Array -} +type SearchResult = { title: string; query: string } +type EmailResult = { email: string; isValid: boolean; message: string } +type DataPoint = { id: number; value: number; category: string } +type Summary = { totalItems: number; totalValue: number; categories: number } @Component({ selector: 'app-root', templateUrl: './app.html', - styleUrl: './app.css', + imports: [JsonPipe], }) export class App { - protected readonly value = signal('') - protected readonly queuedCount = signal(0) - protected readonly isProcessing = signal(false) - - protected readonly processedBatches: Array = [] - - private nextId = 1 - - protected readonly addToBatch = injectAsyncBatchedCallback( - async (items) => { - this.isProcessing.set(true) + readonly queries = signal>([]) + readonly results = signal>([]) + readonly isSearching = signal(false) + readonly searchError = signal('') + readonly searchBatches = signal(0) + readonly emails = signal>([]) + readonly validations = signal>([]) + readonly isValidating = signal(false) + readonly validationBatches = signal(0) + readonly points = signal>([]) + readonly processed = signal>([]) + readonly summaries = signal>([]) + readonly isProcessing = signal(false) + readonly searchChoices = ['javascript', 'react', 'typescript', 'error'] + readonly emailChoices = [ + 'user@example.com', + 'invalid-email', + 'test@domain.org', + 'bad@email', + 'good@test.com', + ] + readonly categories = ['sales', 'marketing', 'operations', 'finance'] + readonly searchBatch = injectAsyncBatchedCallback( + async (queries: Array) => { + this.isSearching.set(true) + this.searchError.set('') try { - await new Promise((resolve) => setTimeout(resolve, 500)) - - this.processedBatches.unshift({ - timestamp: new Date().toLocaleTimeString(), - items: [...items], - }) - - this.queuedCount.update((c) => Math.max(0, c - items.length)) + await new Promise((resolve) => setTimeout(resolve, 800)) + if (queries.includes('error')) + throw new Error('Simulated batch API error') + const results = queries.flatMap((query) => + [1, 2].map((id) => ({ query, title: `${query} result ${id}` })), + ) + this.results.update((previous) => [...previous, ...results]) + this.searchBatches.update((count) => count + 1) + return results } finally { - this.isProcessing.set(false) + this.isSearching.set(false) } }, { - maxSize: 5, - wait: 1000, + maxSize: 3, + wait: 2000, + throwOnError: false, + onError: (error) => this.searchError.set(error.message), }, ) - - protected enqueue(): void { - const raw = this.value().trim() - const item = raw.length > 0 ? raw : `item-${this.nextId++}` - - this.queuedCount.update((c) => c + 1) - void this.addToBatch(item) - this.value.set('') + readonly emailBatch = injectAsyncBatchedCallback( + async (emails: Array) => { + this.isValidating.set(true) + try { + await new Promise((resolve) => setTimeout(resolve, 600)) + const results = emails.map((email) => { + const isValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) + return { + email, + isValid, + message: isValid ? 'Email is valid!' : 'Invalid email format', + } + }) + this.validations.update((previous) => [...previous, ...results]) + this.validationBatches.update((count) => count + 1) + return results + } finally { + this.isValidating.set(false) + } + }, + { maxSize: 4, wait: 1500 }, + ) + readonly dataBatch = injectAsyncBatchedCallback( + async (points: Array) => { + this.isProcessing.set(true) + try { + await new Promise((resolve) => setTimeout(resolve, 1000)) + const processed = points.map((point) => ({ + ...point, + value: point.value * 2, + })) + const summary = { + totalItems: processed.length, + totalValue: processed.reduce((sum, point) => sum + point.value, 0), + categories: new Set(processed.map((point) => point.category)).size, + } + this.processed.update((previous) => [...previous, ...processed]) + this.summaries.update((previous) => [...previous, summary]) + return { processed, summary } + } finally { + this.isProcessing.set(false) + } + }, + { maxSize: 5, wait: 2500 }, + ) + async addSearch(query: string): Promise { + this.queries.update((previous) => [...previous, query]) + await this.searchBatch(query) } - - protected enqueueMany(count: number): void { - for (let i = 0; i < count; i++) { - this.value.set(`item-${this.nextId++}`) - this.enqueue() - } + async addEmail(email: string): Promise { + this.emails.update((previous) => [...previous, email]) + await this.emailBatch(email) } - - protected reset(): void { - this.value.set('') - this.queuedCount.set(0) - this.isProcessing.set(false) - this.processedBatches.length = 0 - this.nextId = 1 + async addData(category: string): Promise { + const point = { + id: this.points().length + 1, + value: Math.floor(Math.random() * 100) + 1, + category, + } + this.points.update((previous) => [...previous, point]) + await this.dataBatch(point) } } diff --git a/examples/angular/injectAsyncBatcher/src/app/app.html b/examples/angular/injectAsyncBatcher/src/app/app.html index 25df3a04b..cac60b58f 100644 --- a/examples/angular/injectAsyncBatcher/src/app/app.html +++ b/examples/angular/injectAsyncBatcher/src/app/app.html @@ -1,37 +1,46 @@ -

TanStack Pacer - Async Batcher

- -

Add items. They flush as a batch when max size is hit or the wait timer expires.

- - - - - - - -

Queued items: {{ batcher.state().items.length }}

-

Executing: {{ batcher.state().isExecuting }}

- -

Last Batch

-@if (lastBatch().length === 0) { -

(none yet)

-} @else { +

TanStack Pacer injectAsyncBatcher Example

+

Batch Status

+

Pending Items: {{ pendingItems().length }} / 5

+

+ Wait up to 4000ms, process for 1000ms. Urgent items trigger immediate + processing. +

+

Is Processing: {{ runner.state().isExecuting }}

+

+ Successful Batches: {{ successCount() }}, Failed Batches: {{ errorCount() }} +

+

+ Status: {{ runner.state().status }}, Total Items Processed: {{ + runner.state().totalItemsProcessed }} +

+

Current Batch Items

    - @for (x of lastBatch(); track x) { -
  • {{ x }}
  • + @for (item of pendingItems(); track item.id) { +
  • {{ item.value }}
  • + } @empty { +
  • No items pending
  • }
-} + + -

History

-@if (batchHistory().length === 0) { -

(no batches yet)

-} @else { -
    - @for (batch of batchHistory(); track $index) { -
  1. {{ batch.join(', ') }}
  2. - } -
-} +

Processed Batches ({{ processedBatches().length }})

+@for (batch of processedBatches(); track $index) { +

{{ batch.result }}

+} @if (errors().length) { +

Errors ({{ errors().length }})

+@for (error of errors(); track $index) { +

{{ error }}

+} } +
{{ runner.state() | json }}
diff --git a/examples/angular/injectAsyncBatcher/src/app/app.spec.ts b/examples/angular/injectAsyncBatcher/src/app/app.spec.ts index f9baf7f75..2c060b26d 100644 --- a/examples/angular/injectAsyncBatcher/src/app/app.spec.ts +++ b/examples/angular/injectAsyncBatcher/src/app/app.spec.ts @@ -1,3 +1,4 @@ +import { vi } from 'vitest' import { TestBed } from '@angular/core/testing' import { App } from './app' @@ -18,6 +19,40 @@ describe('App', () => { const fixture = TestBed.createComponent(App) await fixture.whenStable() const compiled = fixture.nativeElement as HTMLElement - expect(compiled.querySelector('h1')?.textContent).toContain('Hello, basic') + expect(compiled.querySelector('h1')?.textContent).toContain( + 'TanStack Pacer injectAsyncBatcher Example', + ) + }) +}) + +// Exercise the real adapter and component with a deterministic clock. +describe('example behavior', () => { + beforeEach(async () => { + await TestBed.configureTestingModule({ imports: [App] }).compileComponents() + }) + afterEach(() => { + TestBed.resetTestingModule() + vi.clearAllTimers() + vi.useRealTimers() + vi.restoreAllMocks() + }) + it('processes urgent batches and flushes pending work', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }) + const fixture = TestBed.createComponent(App) + const app = fixture.componentInstance + fixture.detectChanges() + TestBed.tick() + app.add() + expect(app.pendingItems()).toHaveLength(1) + app.add(true) + await vi.advanceTimersByTimeAsync(1000) + expect(app.processedBatches()).toHaveLength(1) + expect(app.successCount()).toBe(1) + app.add() + const flush = app.runner.flush() + await vi.advanceTimersByTimeAsync(1000) + await flush + expect(app.successCount()).toBe(2) + expect(app.pendingItems()).toEqual([]) }) }) diff --git a/examples/angular/injectAsyncBatcher/src/app/app.ts b/examples/angular/injectAsyncBatcher/src/app/app.ts index 13436d57a..6c0259103 100644 --- a/examples/angular/injectAsyncBatcher/src/app/app.ts +++ b/examples/angular/injectAsyncBatcher/src/app/app.ts @@ -1,56 +1,76 @@ import { Component, signal } from '@angular/core' +import { JsonPipe } from '@angular/common' import { injectAsyncBatcher } from '@tanstack/angular-pacer' -function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)) -} +type Item = { id: number; value: string; timestamp: number } @Component({ selector: 'app-root', templateUrl: './app.html', - styleUrl: './app.css', + imports: [JsonPipe], }) export class App { - protected readonly item = signal('') - protected readonly lastBatch = signal>([]) - protected readonly batchHistory = signal>>([]) - - protected readonly batcher = injectAsyncBatcher( - async (items: Array) => { - await sleep(300) - const batch = items.map((x) => x.trim()).filter(Boolean) - - this.lastBatch.set(batch) - this.batchHistory.update((h) => [batch, ...h]) - - return batch + private nextId = 0 + readonly processedBatches = signal< + Array<{ items: Array; result: string; timestamp: number }> + >([]) + readonly errors = signal>([]) + readonly pendingItems = signal>([]) + readonly isProcessing = signal(false) + readonly successCount = signal(0) + readonly errorCount = signal(0) + readonly runner = injectAsyncBatcher( + (items: Array) => this.processBatch(items), + { + maxSize: 5, + wait: 4000, + getShouldExecute: (items) => + items.some((item) => item.value.includes('urgent')), + throwOnError: false, + onItemsChange: (batcher) => this.pendingItems.set(batcher.peekAllItems()), + onSuccess: (result, items, batcher) => { + this.successCount.set(batcher.store.state.successCount) + console.log('Batch succeeded:', result, items) + }, + onError: (error, items, batcher) => { + this.errorCount.set(batcher.store.state.errorCount) + this.errors.update((errors) => [ + ...errors, + `${error.message} (${new Date().toLocaleTimeString()})`, + ]) + console.error('Batch failed:', error, items) + }, + onSettled: (items, batcher) => + console.log( + 'Batch settled:', + items, + batcher.store.state.totalItemsProcessed, + ), }, - { maxSize: 3, wait: 1000 }, - (state) => ({ items: state.items, isExecuting: state.isExecuting }), + (state) => state, ) - protected onItemInput(value: string): void { - this.item.set(value) - } - - protected addItem(): void { - const value = this.item().trim() - if (!value) return - - this.batcher.addItem(value) - this.item.set('') + private async processBatch(items: Array): Promise { + this.isProcessing.set(true) + try { + await new Promise((resolve) => setTimeout(resolve, 1000)) + // throw new Error(`Processing failed for batch with ${items.length} items`) + const result = `Processed ${items.length} items: ${items.map((item) => item.value).join(', ')}` + this.processedBatches.update((batches) => [ + ...batches, + { items, result, timestamp: Date.now() }, + ]) + return result + } finally { + this.isProcessing.set(false) + } } - - protected addThree(): void { - this.batcher.addItem('one') - this.batcher.addItem('two') - this.batcher.addItem('three') - } - - protected reset(): void { - this.item.set('') - this.lastBatch.set([]) - this.batchHistory.set([]) - this.batcher.reset() + add(urgent = false): void { + const id = ++this.nextId + this.runner.addItem({ + id, + value: `${urgent ? 'urgent' : 'item'}-${id}`, + timestamp: Date.now(), + }) } } diff --git a/examples/angular/injectAsyncDebouncedCallback/src/app/app.html b/examples/angular/injectAsyncDebouncedCallback/src/app/app.html index 0fd6b9abf..a0d5c2d6f 100644 --- a/examples/angular/injectAsyncDebouncedCallback/src/app/app.html +++ b/examples/angular/injectAsyncDebouncedCallback/src/app/app.html @@ -1,38 +1,45 @@ -

injectAsyncDebouncedCallback

- -

- Debounce waits for a pause before executing. Calls made while the timer is pending resolve to - undefined. Only the final call after the pause runs. -

- -
- - - -
- -

Executing: {{ isExecuting() }}

-

Last outcome: {{ lastOutcome() }}

-

Last result: {{ lastResult() ?? 'none' }}

- -

History

- -@if (history.length === 0) { -

No calls yet.

-} @else { +

TanStack Pacer injectAsyncDebouncedCallback Example 1

+

Search

+ +@if (isLoading()) { +

Loading...

+}@if (error()) { +

Error: {{ error() }}

+}
    - @for (entry of history; track $index) { -
  • -
    - {{ entry.timestamp }} - {{ entry.value }} - {{ entry.executed ? 'EXECUTED' : 'DEBOUNCED' }} -
    - @if (entry.executed) { -
    Result: {{ entry.result }}
    - } -
  • + @for (result of results(); track $index) { +
  • {{ result }}
  • }
+

500ms wait, 500ms API delay.

+
+

TanStack Pacer injectAsyncDebouncedCallback Example 2

+

Async counter

+

Current Count: {{ count() }}, API Calls Made: {{ apiCalls() }}

+ +

+ UI increments immediately; the API returns one additional increment after + 300ms. Calls have a 1000ms wait. +

+
+

TanStack Pacer injectAsyncDebouncedCallback Example 3

+

Email validation

+ +@if (isValidating()) { +

Validating email...

+} @if (validation(); as result) { +

{{ result.message }}

} +

Email validation runs 750ms after typing stops, with a 400ms API delay.

diff --git a/examples/angular/injectAsyncDebouncedCallback/src/app/app.spec.ts b/examples/angular/injectAsyncDebouncedCallback/src/app/app.spec.ts index f9baf7f75..0f52e6992 100644 --- a/examples/angular/injectAsyncDebouncedCallback/src/app/app.spec.ts +++ b/examples/angular/injectAsyncDebouncedCallback/src/app/app.spec.ts @@ -1,3 +1,4 @@ +import { vi } from 'vitest' import { TestBed } from '@angular/core/testing' import { App } from './app' @@ -18,6 +19,49 @@ describe('App', () => { const fixture = TestBed.createComponent(App) await fixture.whenStable() const compiled = fixture.nativeElement as HTMLElement - expect(compiled.querySelector('h1')?.textContent).toContain('Hello, basic') + expect(compiled.querySelector('h1')?.textContent).toContain( + 'TanStack Pacer injectAsyncDebouncedCallback Example', + ) + }) +}) + +// Exercise the real adapter and component with a deterministic clock. +describe('example behavior', () => { + beforeEach(async () => { + await TestBed.configureTestingModule({ imports: [App] }).compileComponents() + }) + afterEach(() => { + TestBed.resetTestingModule() + vi.clearAllTimers() + vi.useRealTimers() + vi.restoreAllMocks() + }) + it('displays simulated search failures and then recovers', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }) + const fixture = TestBed.createComponent(App) + const app = fixture.componentInstance + fixture.detectChanges() + TestBed.tick() + void app.onSearch('error') + await vi.advanceTimersByTimeAsync(1600) + expect(app.error()).toBe('Simulated API error') + expect(app.results()).toEqual([]) + void app.onSearch('angular') + await vi.advanceTimersByTimeAsync(1600) + expect(app.results()).toHaveLength(3) + expect(app.error()).toBe('') + }) + it('validates email and clears the result when empty', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }) + const fixture = TestBed.createComponent(App) + const app = fixture.componentInstance + fixture.detectChanges() + TestBed.tick() + void app.onEmail('user@example.com') + await vi.advanceTimersByTimeAsync(1200) + expect(app.validation()?.isValid).toBe(true) + void app.onEmail('') + await vi.advanceTimersByTimeAsync(800) + expect(app.validation()).toBeNull() }) }) diff --git a/examples/angular/injectAsyncDebouncedCallback/src/app/app.ts b/examples/angular/injectAsyncDebouncedCallback/src/app/app.ts index 11ac99316..3318deda2 100644 --- a/examples/angular/injectAsyncDebouncedCallback/src/app/app.ts +++ b/examples/angular/injectAsyncDebouncedCallback/src/app/app.ts @@ -1,76 +1,86 @@ import { Component, signal } from '@angular/core' import { injectAsyncDebouncedCallback } from '@tanstack/angular-pacer' -type HistoryEntry = { - timestamp: string - value: string - executed: boolean - result?: string -} - -@Component({ - selector: 'app-root', - templateUrl: './app.html', - styleUrl: './app.css', -}) +@Component({ selector: 'app-root', templateUrl: './app.html' }) export class App { - protected readonly query = signal('') - protected readonly isExecuting = signal(false) - protected readonly lastOutcome = signal<'idle' | 'executed' | 'debounced'>('idle') - protected readonly lastResult = signal(null) - - protected readonly history: Array = [] - - protected readonly search = injectAsyncDebouncedCallback( + readonly search = signal('') + readonly results = signal>([]) + readonly isLoading = signal(false) + readonly error = signal('') + readonly count = signal(0) + readonly apiCalls = signal(0) + private readonly searchCallback = injectAsyncDebouncedCallback( async (query: string) => { - this.isExecuting.set(true) + if (!query.trim()) { + this.results.set([]) + return + } + this.isLoading.set(true) + this.error.set('') try { - await new Promise((resolve) => setTimeout(resolve, 300)) - const result = `results-for:${query}` - this.lastResult.set(result) - return result + await new Promise((resolve) => setTimeout(resolve, 500)) + if (query === 'error') throw new Error('Simulated API error') + this.results.set([1, 2, 3].map((id) => `${query} result ${id}`)) } finally { - this.isExecuting.set(false) + this.isLoading.set(false) } }, { - wait: 600, + wait: 500, + throwOnError: false, + onError: (error) => { + this.error.set(error.message) + this.results.set([]) + }, }, ) - - protected onQueryInput(value: string): void { - this.query.set(value) - void this.attempt(value) - } - - protected runBurst(): void { - const parts = ['t', 'ta', 'tan', 'tans', 'tanst', 'tansta', 'tanstack'] - for (const q of parts) { - this.query.set(q) - void this.attempt(q) - } + private readonly incrementCallback = injectAsyncDebouncedCallback( + async (value: number) => { + await new Promise((resolve) => setTimeout(resolve, 300)) + this.apiCalls.update((count) => count + 1) + this.count.set(value + 1) + return value + 1 + }, + { wait: 1000, leading: false, trailing: true }, + ) + async onSearch(value: string): Promise { + this.search.set(value) + await this.searchCallback(value) } - - protected reset(): void { - this.query.set('') - this.isExecuting.set(false) - this.lastOutcome.set('idle') - this.lastResult.set(null) - this.history.length = 0 + async increment(): Promise { + const value = this.count() + 1 + this.count.set(value) + await this.incrementCallback(value) } - - private async attempt(query: string): Promise { - const timestamp = new Date().toLocaleTimeString() - - const result = await this.search(query) - - if (result === undefined) { - this.lastOutcome.set('debounced') - this.history.unshift({ timestamp, value: query, executed: false }) - return - } - - this.lastOutcome.set('executed') - this.history.unshift({ timestamp, value: query, executed: true, result }) + readonly email = signal('') + readonly validation = signal<{ isValid: boolean; message: string } | null>( + null, + ) + readonly isValidating = signal(false) + private readonly validate = injectAsyncDebouncedCallback( + async (email: string) => { + if (!email.trim()) { + this.validation.set(null) + return + } + this.isValidating.set(true) + try { + await new Promise((resolve) => setTimeout(resolve, 400)) + const isValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) + this.validation.set({ + isValid, + message: isValid + ? 'Email is valid!' + : 'Please enter a valid email address', + }) + } finally { + this.isValidating.set(false) + } + }, + { wait: 750, leading: false }, + ) + async onEmail(value: string): Promise { + this.email.set(value) + await this.validate(value) } } diff --git a/examples/angular/injectAsyncDebouncer/src/app/app.html b/examples/angular/injectAsyncDebouncer/src/app/app.html index a826ced7a..494819135 100644 --- a/examples/angular/injectAsyncDebouncer/src/app/app.html +++ b/examples/angular/injectAsyncDebouncer/src/app/app.html @@ -1,28 +1,26 @@ -

TanStack Pacer - Async Debouncer

+

TanStack Pacer injectAsyncDebouncer Example

-

Type to trigger a debounced async "search". Only the last value after the wait runs.

- - - - - -

Latest executed query: {{ executedQuery() || '(none yet)' }}

- -

Status: {{ debouncer.state().status }}

-

Pending: {{ debouncer.state().isPending }}

-

Executing: {{ debouncer.state().isExecuting }}

-

Error count: {{ debouncer.state().errorCount }}

- -

Results

-@if (results().length === 0) { -

(no results)

-} @else { +

500ms wait, 1500ms simulated network delay.

+ +@if (loading()) { +

Loading...

+} @if (error()) { +

Error: {{ error() }}

+} +

API calls made: {{ runner.state().successCount }}

+

Is executing: {{ runner.state().isExecuting }}

+ +

Pending: {{ runner.state().isPending }}

+
{{ runner.state() | json }}
+

Search Results

    - @for (item of results(); track item) { -
  • {{ item }}
  • + @for (result of results(); track $index) { +
  • {{ result }}
  • }
-} diff --git a/examples/angular/injectAsyncDebouncer/src/app/app.spec.ts b/examples/angular/injectAsyncDebouncer/src/app/app.spec.ts index f9baf7f75..38843d5b2 100644 --- a/examples/angular/injectAsyncDebouncer/src/app/app.spec.ts +++ b/examples/angular/injectAsyncDebouncer/src/app/app.spec.ts @@ -1,3 +1,4 @@ +import { vi } from 'vitest' import { TestBed } from '@angular/core/testing' import { App } from './app' @@ -18,6 +19,38 @@ describe('App', () => { const fixture = TestBed.createComponent(App) await fixture.whenStable() const compiled = fixture.nativeElement as HTMLElement - expect(compiled.querySelector('h1')?.textContent).toContain('Hello, basic') + expect(compiled.querySelector('h1')?.textContent).toContain( + 'TanStack Pacer injectAsyncDebouncer Example', + ) + }) +}) + +// Exercise the real adapter and component with a deterministic clock. +describe('example behavior', () => { + beforeEach(async () => { + await TestBed.configureTestingModule({ imports: [App] }).compileComponents() + }) + afterEach(() => { + TestBed.resetTestingModule() + vi.clearAllTimers() + vi.useRealTimers() + vi.restoreAllMocks() + }) + it('completes a search and renders its results', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }) + const fixture = TestBed.createComponent(App) + const app = fixture.componentInstance + fixture.detectChanges() + TestBed.tick() + void app.onSearch('angular') + await vi.advanceTimersByTimeAsync(2500) + TestBed.tick() + expect(app.results()).toEqual([ + 'Result 1 for angular', + 'Result 2 for angular', + 'Result 3 for angular', + ]) + expect(app.loading()).toBe(false) + expect(fixture.nativeElement.textContent).toContain('Result 1 for angular') }) }) diff --git a/examples/angular/injectAsyncDebouncer/src/app/app.ts b/examples/angular/injectAsyncDebouncer/src/app/app.ts index dc4729700..4e6685c65 100644 --- a/examples/angular/injectAsyncDebouncer/src/app/app.ts +++ b/examples/angular/injectAsyncDebouncer/src/app/app.ts @@ -1,51 +1,48 @@ import { Component, signal } from '@angular/core' +import { JsonPipe } from '@angular/common' import { injectAsyncDebouncer } from '@tanstack/angular-pacer' -function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)) -} - @Component({ selector: 'app-root', templateUrl: './app.html', - styleUrl: './app.css', + imports: [JsonPipe], }) export class App { - protected readonly query = signal('') - protected readonly executedQuery = signal('') - protected readonly results = signal>([]) - - protected readonly debouncer = injectAsyncDebouncer( - async (q: string) => { - await sleep(300) - const trimmed = q.trim() - if (!trimmed) return [] - - return [`${trimmed} result 1`, `${trimmed} result 2`, `${trimmed} result 3`] - }, - { wait: 400 }, - (state) => ({ - status: state.status, - isPending: state.isPending, - isExecuting: state.isExecuting, - errorCount: state.errorCount, + readonly searchTerm = signal('') + readonly results = signal>([]) + readonly loading = signal(false) + readonly error = signal('') + readonly runner = injectAsyncDebouncer( + (term: string) => this.search(term), + () => ({ + wait: 500, + onError: (error) => { + this.error.set(error.message) + this.results.set([]) + }, + asyncRetryerOptions: { maxAttempts: 3, maxExecutionTime: 3000 }, }), + (state) => state, ) - protected async onQueryInput(value: string): Promise { - this.query.set(value) - - const next = await this.debouncer.maybeExecute(value) - if (next !== undefined) { - this.executedQuery.set(value) - this.results.set(next) + private async search(term: string): Promise | undefined> { + if (!term) { + this.results.set([]) + return + } + this.loading.set(true) + try { + await new Promise((resolve) => setTimeout(resolve, 1500)) + const results = [1, 2, 3].map((id) => `Result ${id} for ${term}`) + this.results.set(results) + this.error.set('') + return results + } finally { + this.loading.set(false) } } - - protected clear(): void { - this.query.set('') - this.executedQuery.set('') - this.results.set([]) - this.debouncer.reset() + async onSearch(value: string): Promise { + this.searchTerm.set(value) + await this.runner.maybeExecute(value) } } diff --git a/examples/angular/injectAsyncQueuedSignal/src/app/app.html b/examples/angular/injectAsyncQueuedSignal/src/app/app.html index bab9b495d..a913cbe7f 100644 --- a/examples/angular/injectAsyncQueuedSignal/src/app/app.html +++ b/examples/angular/injectAsyncQueuedSignal/src/app/app.html @@ -1,42 +1,45 @@ -

injectAsyncQueuedSignal

- +

TanStack Pacer injectAsyncQueuedSignal Example

+

Queue Size: {{ runner.state().size }} / 25

- This helper wires an async queue to Angular signals. queue.items() is reactive and - shows the current items waiting to be processed. + Queue Full: {{ runner.state().isFull }}, Empty: {{ runner.state().isEmpty }}, + Idle: {{ runner.state().isIdle }}

- -
- - - - - - -
- -

Queued Items

- -@if (queue().length === 0) { -

Queue is empty.

-} @else { -
    +

    Queuer Status: {{ runner.state().status }}

    +

    + Items Processed: {{ runner.state().successCount }}, Items Rejected: {{ + runner.state().rejectionCount }} +

    +

    + Active Tasks: {{ runner.state().activeItems.length }}, Pending Tasks: {{ + queue().length }} +

    + +

    Queue Items

    +
      @for (item of queue(); track $index) {
    1. {{ item }}
    2. } -
-} + + + + + + -

Processed

- -@if (processed.length === 0) { -

Nothing processed yet.

-} @else { -
    - @for (entry of processed; track $index) { -
  • {{ entry.timestamp }} - {{ entry.item }}
  • - } -
-} +

Completed values: {{ processed().join(', ') }}

+
{{ runner.state() | json }}
diff --git a/examples/angular/injectAsyncQueuedSignal/src/app/app.spec.ts b/examples/angular/injectAsyncQueuedSignal/src/app/app.spec.ts index f9baf7f75..335bcd396 100644 --- a/examples/angular/injectAsyncQueuedSignal/src/app/app.spec.ts +++ b/examples/angular/injectAsyncQueuedSignal/src/app/app.spec.ts @@ -1,3 +1,4 @@ +import { vi } from 'vitest' import { TestBed } from '@angular/core/testing' import { App } from './app' @@ -18,6 +19,41 @@ describe('App', () => { const fixture = TestBed.createComponent(App) await fixture.whenStable() const compiled = fixture.nativeElement as HTMLElement - expect(compiled.querySelector('h1')?.textContent).toContain('Hello, basic') + expect(compiled.querySelector('h1')?.textContent).toContain( + 'TanStack Pacer injectAsyncQueuedSignal Example', + ) + }) +}) + +// Exercise the real adapter and component with a deterministic clock. +describe('example behavior', () => { + beforeEach(async () => { + await TestBed.configureTestingModule({ imports: [App] }).compileComponents() + }) + afterEach(() => { + TestBed.resetTestingModule() + vi.clearAllTimers() + vi.useRealTimers() + vi.restoreAllMocks() + }) + it('updates concurrency while keeping the stopped queue and processes selected parallel tasks', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }) + const fixture = TestBed.createComponent(App) + const app = fixture.componentInstance + fixture.detectChanges() + TestBed.tick() + const runner = app.runner + expect(runner.state().items).toHaveLength(10) + app.setConcurrency('3') + TestBed.tick() + expect(app.runner).toBe(runner) + expect(runner.options.concurrency).toBe(3) + expect(runner.state().items).toHaveLength(10) + runner.start() + await vi.advanceTimersByTimeAsync(350) + expect(runner.state().activeItems).toHaveLength(3) + await vi.advanceTimersByTimeAsync(500) + expect(runner.state().successCount).toBeGreaterThanOrEqual(3) + runner.stop() }) }) diff --git a/examples/angular/injectAsyncQueuedSignal/src/app/app.ts b/examples/angular/injectAsyncQueuedSignal/src/app/app.ts index f5abc0b6c..fcc3e583e 100644 --- a/examples/angular/injectAsyncQueuedSignal/src/app/app.ts +++ b/examples/angular/injectAsyncQueuedSignal/src/app/app.ts @@ -1,61 +1,48 @@ import { Component, signal } from '@angular/core' +import { JsonPipe } from '@angular/common' import { injectAsyncQueuedSignal } from '@tanstack/angular-pacer' -type ProcessedEntry = { - timestamp: string - item: string -} - @Component({ selector: 'app-root', templateUrl: './app.html', - styleUrl: './app.css', + imports: [JsonPipe], }) export class App { - protected readonly value = signal('') - protected readonly processed: Array = [] - - private nextId = 1 - - protected readonly queue = injectAsyncQueuedSignal( - async (item: string) => { - await new Promise((resolve) => setTimeout(resolve, 500)) - this.processed.unshift({ timestamp: new Date().toLocaleTimeString(), item }) - return item - }, - { - concurrency: 2, - wait: 0, + readonly concurrency = signal(2) + readonly processed = signal>([]) + readonly queue = injectAsyncQueuedSignal( + async (item: number) => { + await new Promise((resolve) => setTimeout(resolve, 500)) + this.processed.update((items) => [...items, item]) + console.log(`Processed ${item}`) }, + () => ({ + maxSize: 25, + initialItems: Array.from({ length: 10 }, (_, index) => index + 1), + concurrency: this.concurrency(), + started: false, + wait: 100, + onReject: (item, queuer) => + console.log( + 'Queue is full, rejecting item', + item, + queuer.store.state.rejectionCount, + ), + onError: (error, item, queuer) => + console.error( + `Error processing item: ${item}`, + error, + queuer.store.state.errorCount, + ), + }), + (state) => state, ) - - protected enqueue(): void { - const raw = this.value().trim() - const item = raw.length > 0 ? raw : `item-${this.nextId++}` - - this.queue.addItem(item) - this.value.set('') + readonly runner = this.queue.queuer + add(): void { + const items = this.runner.state().items + this.runner.addItem(items.length ? Math.max(...items) + 1 : 1) } - - protected enqueueMany(count: number): void { - for (let i = 0; i < count; i++) { - this.queue.addItem(`item-${this.nextId++}`) - } - } - - protected start(): void { - this.queue.queuer.start() - } - - protected stop(): void { - this.queue.queuer.stop() - } - - protected reset(): void { - this.value.set('') - this.processed.length = 0 - this.nextId = 1 - - this.queue.queuer.clear() + setConcurrency(value: string): void { + this.concurrency.set(Math.max(1, Number.parseInt(value) || 1)) } } diff --git a/examples/angular/injectAsyncQueuer/src/app/app.html b/examples/angular/injectAsyncQueuer/src/app/app.html index dfc6e1df2..dd4399af6 100644 --- a/examples/angular/injectAsyncQueuer/src/app/app.html +++ b/examples/angular/injectAsyncQueuer/src/app/app.html @@ -1,58 +1,46 @@ -

injectAsyncQueuer example

- +

TanStack Pacer injectAsyncQueuer Example

+

Queue Size: {{ queue.state().size }} / 25

- Add jobs quickly to see queuing. This example uses concurrency: 2, - wait: 200, and maxSize: 5. + Queue Full: {{ queue.state().isFull }}, Empty: {{ queue.state().isEmpty }}, + Idle: {{ queue.state().isIdle }}

- - - -
- - - - - - -
- -

Last add: {{ lastAdd() }}

- -

Pending

-@if (queuer.state().items.length === 0) { -

(empty)

-} @else { -
    - @for (job of queuer.state().items; track job.id) { -
  • #{{ job.id }}: {{ job.text }}
  • - } -
-} - -

Active

-@if (queuer.state().activeItems.length === 0) { -

(none)

-} @else { -
    - @for (job of queuer.state().activeItems; track job.id) { -
  • #{{ job.id }}: {{ job.text }}
  • - } -
-} - -

Results

-@if (results().length === 0) { -

(none)

-} @else { -
    - @for (line of results(); track $index) { -
  • {{ line }}
  • +

    Queuer Status: {{ queue.state().status }}

    +

    + Items Processed: {{ queue.state().successCount }}, Items Rejected: {{ + queue.state().rejectionCount }} +

    +

    + Active Tasks: {{ queue.state().activeItems.length }}, Pending Tasks: {{ + queue.state().items.length }} +

    + +

    Queue Items

    +
      + @for (item of queue.state().items; track $index) { +
    1. {{ item }}
    2. } -
-} - -

Queuer state

-
{{ queuer.state() }}
+ + + + + + + +

Completed values: {{ processed().join(', ') }}

+
{{ queue.state() | json }}
diff --git a/examples/angular/injectAsyncQueuer/src/app/app.spec.ts b/examples/angular/injectAsyncQueuer/src/app/app.spec.ts index f9baf7f75..7379b6331 100644 --- a/examples/angular/injectAsyncQueuer/src/app/app.spec.ts +++ b/examples/angular/injectAsyncQueuer/src/app/app.spec.ts @@ -1,3 +1,4 @@ +import { vi } from 'vitest' import { TestBed } from '@angular/core/testing' import { App } from './app' @@ -18,6 +19,41 @@ describe('App', () => { const fixture = TestBed.createComponent(App) await fixture.whenStable() const compiled = fixture.nativeElement as HTMLElement - expect(compiled.querySelector('h1')?.textContent).toContain('Hello, basic') + expect(compiled.querySelector('h1')?.textContent).toContain( + 'TanStack Pacer injectAsyncQueuer Example', + ) + }) +}) + +// Exercise the real adapter and component with a deterministic clock. +describe('example behavior', () => { + beforeEach(async () => { + await TestBed.configureTestingModule({ imports: [App] }).compileComponents() + }) + afterEach(() => { + TestBed.resetTestingModule() + vi.clearAllTimers() + vi.useRealTimers() + vi.restoreAllMocks() + }) + it('updates concurrency while keeping the stopped queue and processes selected parallel tasks', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }) + const fixture = TestBed.createComponent(App) + const app = fixture.componentInstance + fixture.detectChanges() + TestBed.tick() + const queue = app.queue + expect(queue.state().items).toHaveLength(10) + app.setConcurrency('3') + TestBed.tick() + expect(app.queue).toBe(queue) + expect(queue.options.concurrency).toBe(3) + expect(queue.state().items).toHaveLength(10) + queue.start() + await vi.advanceTimersByTimeAsync(350) + expect(queue.state().activeItems).toHaveLength(3) + await vi.advanceTimersByTimeAsync(500) + expect(queue.state().successCount).toBeGreaterThanOrEqual(3) + queue.stop() }) }) diff --git a/examples/angular/injectAsyncQueuer/src/app/app.ts b/examples/angular/injectAsyncQueuer/src/app/app.ts index 4be883331..b7bb037c9 100644 --- a/examples/angular/injectAsyncQueuer/src/app/app.ts +++ b/examples/angular/injectAsyncQueuer/src/app/app.ts @@ -1,77 +1,47 @@ import { Component, signal } from '@angular/core' -import { RouterOutlet } from '@angular/router' +import { JsonPipe } from '@angular/common' import { injectAsyncQueuer } from '@tanstack/angular-pacer' -type Job = { - id: number - text: string -} - -function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)) -} - @Component({ selector: 'app-root', - imports: [RouterOutlet], templateUrl: './app.html', - styleUrl: './app.css', + imports: [JsonPipe], }) export class App { - private nextId = 0 - - protected readonly nextText = signal('hello') - protected readonly lastAdd = signal<'added' | 'rejected' | null>(null) - protected readonly results = signal>([]) - - protected readonly queuer = injectAsyncQueuer( - async (job: Job) => { - await sleep(500) - return job.text.toUpperCase() - }, - { - concurrency: 2, - wait: 200, - maxSize: 5, - onSuccess: (result, job) => { - this.results.update((r) => [...r, `#${job.id}: ${String(result)}`]) - }, - onReject: (job) => { - this.results.update((r) => [...r, `#${job.id}: rejected (queue full)`]) - }, - onError: (err, job) => { - this.results.update((r) => [...r, `#${job.id}: error (${err.message})`]) - }, + readonly concurrency = signal(2) + readonly processed = signal>([]) + readonly queue = injectAsyncQueuer( + async (item: number) => { + await new Promise((resolve) => setTimeout(resolve, 500)) + this.processed.update((items) => [...items, item]) + console.log(`Processed ${item}`) }, + () => ({ + maxSize: 25, + initialItems: Array.from({ length: 10 }, (_, index) => index + 1), + concurrency: this.concurrency(), + started: false, + wait: 100, + onReject: (item, queuer) => + console.log( + 'Queue is full, rejecting item', + item, + queuer.store.state.rejectionCount, + ), + onError: (error, item, queuer) => + console.error( + `Error processing item: ${item}`, + error, + queuer.store.state.errorCount, + ), + }), (state) => state, ) - - protected add(): void { - const job: Job = { id: ++this.nextId, text: this.nextText() } - const ok = this.queuer.addItem(job) - this.lastAdd.set(ok ? 'added' : 'rejected') + add(): void { + const items = this.queue.state().items + this.queue.addItem(items.length ? Math.max(...items) + 1 : 1) } - - protected start(): void { - this.queuer.start() - } - - protected stop(): void { - this.queuer.stop() - } - - protected clear(): void { - this.queuer.clear() - } - - protected async flush(): Promise { - await this.queuer.flush() - } - - protected reset(): void { - this.queuer.reset() - this.results.set([]) - this.lastAdd.set(null) - this.nextId = 0 + setConcurrency(value: string): void { + this.concurrency.set(Math.max(1, Number.parseInt(value) || 1)) } } diff --git a/examples/angular/injectAsyncRateLimiter/src/app/app.html b/examples/angular/injectAsyncRateLimiter/src/app/app.html index ab9619d2b..276a82b01 100644 --- a/examples/angular/injectAsyncRateLimiter/src/app/app.html +++ b/examples/angular/injectAsyncRateLimiter/src/app/app.html @@ -1,47 +1,44 @@ -

injectAsyncRateLimiter

- -

- This example limits an async function to 3 executions per 2 seconds. Calls over the limit are - rejected and resolve to undefined. -

- -
- - - - -
- -

Status

- -
status: {{ rateLimiter.state().status }}
-
isExecuting: {{ rateLimiter.state().isExecuting }}
-
remainingInWindow: {{ rateLimiter.getRemainingInWindow() }}
-
msUntilNextWindow: {{ rateLimiter.getMsUntilNextWindow() }}
-
successCount: {{ rateLimiter.state().successCount }}
-
rejectionCount: {{ rateLimiter.state().rejectionCount }}
-
maybeExecuteCount: {{ rateLimiter.state().maybeExecuteCount }}
-
lastOutcome: {{ lastOutcome() }}
-
lastResult: {{ lastResult() ?? 'none' }}
- -

History

- -@if (history.length === 0) { -

No calls yet.

-} @else { +

TanStack Pacer injectAsyncRateLimiter Example

+
+ Window type + +
+

3 requests per 3000ms.

+ +@if (loading()) { +

Loading...

+} @if (error()) { +

Error: {{ error() }}

+} +

API calls made: {{ runner.state().successCount }}

+

Is executing: {{ runner.state().isExecuting }}

+

Rejected calls: {{ runner.state().rejectionCount }}

+
{{ runner.state() | json }}
+

Search Results

    - @for (entry of history; track $index) { -
  • -
    - {{ entry.timestamp }} - {{ entry.value }} - {{ entry.executed ? 'EXECUTED' : 'REJECTED' }} -
    - @if (entry.executed) { -
    Result: {{ entry.result }}
    - } -
  • + @for (result of results(); track $index) { +
  • {{ result }}
  • }
-} diff --git a/examples/angular/injectAsyncRateLimiter/src/app/app.spec.ts b/examples/angular/injectAsyncRateLimiter/src/app/app.spec.ts index f9baf7f75..456740071 100644 --- a/examples/angular/injectAsyncRateLimiter/src/app/app.spec.ts +++ b/examples/angular/injectAsyncRateLimiter/src/app/app.spec.ts @@ -1,3 +1,4 @@ +import { vi } from 'vitest' import { TestBed } from '@angular/core/testing' import { App } from './app' @@ -18,6 +19,54 @@ describe('App', () => { const fixture = TestBed.createComponent(App) await fixture.whenStable() const compiled = fixture.nativeElement as HTMLElement - expect(compiled.querySelector('h1')?.textContent).toContain('Hello, basic') + expect(compiled.querySelector('h1')?.textContent).toContain( + 'TanStack Pacer injectAsyncRateLimiter Example', + ) + }) +}) + +// Exercise the real adapter and component with a deterministic clock. +describe('example behavior', () => { + beforeEach(async () => { + await TestBed.configureTestingModule({ imports: [App] }).compileComponents() + }) + afterEach(() => { + TestBed.resetTestingModule() + vi.clearAllTimers() + vi.useRealTimers() + vi.restoreAllMocks() + }) + it('completes a search and renders its results', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }) + const fixture = TestBed.createComponent(App) + const app = fixture.componentInstance + fixture.detectChanges() + TestBed.tick() + void app.onSearch('angular') + await vi.advanceTimersByTimeAsync(2500) + TestBed.tick() + expect(app.results()).toEqual([ + 'Result 1 for angular', + 'Result 2 for angular', + 'Result 3 for angular', + ]) + expect(app.loading()).toBe(false) + expect(fixture.nativeElement.textContent).toContain('Result 1 for angular') + }) + it('changes window type on the existing instance and records rejected calls', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }) + const fixture = TestBed.createComponent(App) + const app = fixture.componentInstance + fixture.detectChanges() + TestBed.tick() + const runner = app.runner + app.changeWindow('sliding') + TestBed.tick() + expect(app.runner).toBe(runner) + expect(runner.options.windowType).toBe('sliding') + for (let index = 0; index < 4; index++) void app.onSearch(String(index)) + await vi.advanceTimersByTimeAsync(500) + expect(runner.state().successCount).toBe(3) + expect(runner.state().rejectionCount).toBe(1) }) }) diff --git a/examples/angular/injectAsyncRateLimiter/src/app/app.ts b/examples/angular/injectAsyncRateLimiter/src/app/app.ts index 38a32f2c0..1910e6a48 100644 --- a/examples/angular/injectAsyncRateLimiter/src/app/app.ts +++ b/examples/angular/injectAsyncRateLimiter/src/app/app.ts @@ -1,80 +1,57 @@ import { Component, signal } from '@angular/core' +import { JsonPipe } from '@angular/common' import { injectAsyncRateLimiter } from '@tanstack/angular-pacer' -type HistoryEntry = { - timestamp: string - value: string - executed: boolean - result?: string -} - @Component({ selector: 'app-root', templateUrl: './app.html', - styleUrl: './app.css', + imports: [JsonPipe], }) export class App { - protected readonly value = signal('') - protected readonly lastOutcome = signal<'idle' | 'executed' | 'rejected'>('idle') - protected readonly lastResult = signal(null) - - protected readonly history: Array = [] - - private nextId = 1 - - protected readonly rateLimiter = injectAsyncRateLimiter( - async (value: string) => { - await new Promise((resolve) => setTimeout(resolve, 400)) - return `saved:${value}` - }, - { + readonly searchTerm = signal('') + readonly results = signal>([]) + readonly loading = signal(false) + readonly error = signal('') + readonly windowType = signal<'fixed' | 'sliding'>('fixed') + readonly runner = injectAsyncRateLimiter( + (term: string) => this.search(term), + () => ({ limit: 3, - window: 2000, - windowType: 'fixed', - }, - (state) => ({ - status: state.status, - isExecuting: state.isExecuting, - successCount: state.successCount, - rejectionCount: state.rejectionCount, - maybeExecuteCount: state.maybeExecuteCount, + window: 3000, + windowType: this.windowType(), + onReject: (_args, limiter) => + console.log( + `Rate limit reached. Try again in ${limiter.getMsUntilNextWindow()}ms`, + ), + onError: (error) => { + this.error.set(error.message) + this.results.set([]) + }, }), + (state) => state, ) - protected runOnce(): void { - const v = this.value().trim() || `value-${this.nextId++}` - this.value.set(v) - void this.attempt(v) - } - - protected runBurst(count: number): void { - for (let i = 0; i < count; i++) { - void this.attempt(`value-${this.nextId++}`) + private async search(term: string): Promise | undefined> { + if (!term) { + this.results.set([]) + return + } + this.loading.set(true) + try { + await new Promise((resolve) => setTimeout(resolve, 300)) + const results = [1, 2, 3].map((id) => `Result ${id} for ${term}`) + this.results.set(results) + this.error.set('') + return results + } finally { + this.loading.set(false) } } - - protected reset(): void { - this.value.set('') - this.lastOutcome.set('idle') - this.lastResult.set(null) - this.history.length = 0 - this.nextId = 1 - this.rateLimiter.reset() + async onSearch(value: string): Promise { + this.searchTerm.set(value) + await this.runner.maybeExecute(value) } - - private async attempt(value: string): Promise { - const timestamp = new Date().toLocaleTimeString() - - const result = await this.rateLimiter.maybeExecute(value) - - if (result === undefined) { - this.lastOutcome.set('rejected') - this.history.unshift({ timestamp, value, executed: false }) - return - } - - this.lastOutcome.set('executed') - this.lastResult.set(result) - this.history.unshift({ timestamp, value, executed: true, result }) + changeWindow(value: 'fixed' | 'sliding'): void { + this.windowType.set(value) } } diff --git a/examples/angular/injectAsyncRateLimiterWithPersister/README.md b/examples/angular/injectAsyncRateLimiterWithPersister/README.md deleted file mode 100644 index bd58f6985..000000000 --- a/examples/angular/injectAsyncRateLimiterWithPersister/README.md +++ /dev/null @@ -1,59 +0,0 @@ -# Basic - -This project was generated using [Angular CLI](https://github.com/angular/angular-cli) version 21.0.5. - -## Development server - -To start a local development server, run: - -```bash -ng serve -``` - -Once the server is running, open your browser and navigate to `http://localhost:4200/`. The application will automatically reload whenever you modify any of the source files. - -## Code scaffolding - -Angular CLI includes powerful code scaffolding tools. To generate a new component, run: - -```bash -ng generate component component-name -``` - -For a complete list of available schematics (such as `components`, `directives`, or `pipes`), run: - -```bash -ng generate --help -``` - -## Building - -To build the project run: - -```bash -ng build -``` - -This will compile your project and store the build artifacts in the `dist/` directory. By default, the production build optimizes your application for performance and speed. - -## Running unit tests - -To execute unit tests with the [Vitest](https://vitest.dev/) test runner, use the following command: - -```bash -ng test -``` - -## Running end-to-end tests - -For end-to-end (e2e) testing, run: - -```bash -ng e2e -``` - -Angular CLI does not come with an end-to-end testing framework by default. You can choose one that suits your needs. - -## Additional Resources - -For more information on using the Angular CLI, including detailed command references, visit the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page. diff --git a/examples/angular/injectAsyncRateLimiterWithPersister/angular.json b/examples/angular/injectAsyncRateLimiterWithPersister/angular.json deleted file mode 100644 index 06ffc24ef..000000000 --- a/examples/angular/injectAsyncRateLimiterWithPersister/angular.json +++ /dev/null @@ -1,72 +0,0 @@ -{ - "$schema": "./node_modules/@angular/cli/lib/config/schema.json", - "version": 1, - "cli": { - "packageManager": "npm", - "analytics": false - }, - "newProjectRoot": "projects", - "projects": { - "basic": { - "projectType": "application", - "schematics": {}, - "root": "", - "sourceRoot": "src", - "prefix": "app", - "architect": { - "build": { - "builder": "@angular/build:application", - "options": { - "browser": "src/main.ts", - "tsConfig": "tsconfig.app.json", - "assets": [ - { - "glob": "**/*", - "input": "public" - } - ], - "styles": ["src/styles.css"] - }, - "configurations": { - "production": { - "budgets": [ - { - "type": "initial", - "maximumWarning": "500kB", - "maximumError": "1MB" - }, - { - "type": "anyComponentStyle", - "maximumWarning": "4kB", - "maximumError": "8kB" - } - ], - "outputHashing": "all" - }, - "development": { - "optimization": false, - "extractLicenses": false, - "sourceMap": true - } - }, - "defaultConfiguration": "production" - }, - "serve": { - "builder": "@angular/build:dev-server", - "configurations": { - "production": { - "buildTarget": "basic:build:production" - }, - "development": { - "buildTarget": "basic:build:development" - } - }, - "defaultConfiguration": "development" - }, - "test": { - "builder": "@angular/build:unit-test" - } - } - } - } -} diff --git a/examples/angular/injectAsyncRateLimiterWithPersister/eslint.config.js b/examples/angular/injectAsyncRateLimiterWithPersister/eslint.config.js deleted file mode 100644 index 4d9c151cf..000000000 --- a/examples/angular/injectAsyncRateLimiterWithPersister/eslint.config.js +++ /dev/null @@ -1,15 +0,0 @@ -// @ts-check - -import rootConfig from '../../../eslint.config.js' - -/** @type {import('eslint').Linter.Config[]} */ -export default [ - ...rootConfig, - { - languageOptions: { - parserOptions: { - project: './tsconfig.app.json', - }, - }, - }, -] diff --git a/examples/angular/injectAsyncRateLimiterWithPersister/package.json b/examples/angular/injectAsyncRateLimiterWithPersister/package.json deleted file mode 100644 index bb005d1e6..000000000 --- a/examples/angular/injectAsyncRateLimiterWithPersister/package.json +++ /dev/null @@ -1,43 +0,0 @@ -{ - "name": "@tanstack/pacer-example-angular-inject-async-rate-limiter-with-persister", - "scripts": { - "ng": "ng", - "start": "ng serve", - "build": "ng build", - "watch": "ng build --watch --configuration development", - "test": "ng test" - }, - "prettier": { - "printWidth": 100, - "singleQuote": true, - "overrides": [ - { - "files": "*.html", - "options": { - "parser": "angular" - } - } - ] - }, - "private": true, - "packageManager": "pnpm@12.6.0", - "dependencies": { - "@angular/common": "^22.2.0", - "@angular/compiler": "^22.2.0", - "@angular/core": "^22.2.0", - "@angular/forms": "^22.2.0", - "@angular/platform-browser": "^22.2.0", - "@angular/router": "^22.2.0", - "@tanstack/angular-pacer": "^0.25.0", - "rxjs": "~7.8.2", - "tslib": "^2.8.1" - }, - "devDependencies": { - "@angular/build": "^22.2.0", - "@angular/cli": "^22.2.0", - "@angular/compiler-cli": "^22.2.0", - "jsdom": "^30.1.1", - "typescript": "6.0.3", - "vitest": "^5.0.1" - } -} diff --git a/examples/angular/injectAsyncRateLimiterWithPersister/public/favicon.ico b/examples/angular/injectAsyncRateLimiterWithPersister/public/favicon.ico deleted file mode 100644 index 57614f9c967596fad0a3989bec2b1deff33034f6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 15086 zcmd^G33O9Omi+`8$@{|M-I6TH3wzF-p5CV8o}7f~KxR60LK+ApEFB<$bcciv%@SmA zV{n>g85YMFFeU*Uvl=i4v)C*qgnb;$GQ=3XTe9{Y%c`mO%su)noNCCQ*@t1WXn|B(hQ7i~ zrUK8|pUkD6#lNo!bt$6)jR!&C?`P5G(`e((P($RaLeq+o0Vd~f11;qB05kdbAOm?r zXv~GYr_sibQO9NGTCdT;+G(!{4Xs@4fPak8#L8PjgJwcs-Mm#nR_Z0s&u?nDX5^~@ z+A6?}g0|=4e_LoE69pPFO`yCD@BCjgKpzMH0O4Xs{Ahc?K3HC5;l=f zg>}alhBXX&);z$E-wai+9TTRtBX-bWYY@cl$@YN#gMd~tM_5lj6W%8ah4;uZ;jP@Q zVbuel1rPA?2@x9Y+u?e`l{Z4ngfG5q5BLH5QsEu4GVpt{KIp1?U)=3+KQ;%7ec8l* zdV=zZgN5>O3G(3L2fqj3;oBbZZw$Ij@`Juz@?+yy#OPw)>#wsTewVgTK9BGt5AbZ&?K&B3GVF&yu?@(Xj3fR3n+ZP0%+wo)D9_xp>Z$`A4 zfV>}NWjO#3lqumR0`gvnffd9Ka}JJMuHS&|55-*mCD#8e^anA<+sFZVaJe7{=p*oX zE_Uv?1>e~ga=seYzh{9P+n5<+7&9}&(kwqSaz;1aD|YM3HBiy<))4~QJSIryyqp| z8nGc(8>3(_nEI4n)n7j(&d4idW1tVLjZ7QbNLXg;LB ziHsS5pXHEjGJZb59KcvS~wv;uZR-+4qEqow`;JCfB*+b^UL^3!?;-^F%yt=VjU|v z39SSqKcRu_NVvz!zJzL0CceJaS6%!(eMshPv_0U5G`~!a#I$qI5Ic(>IONej@aH=f z)($TAT#1I{iCS4f{D2+ApS=$3E7}5=+y(rA9mM#;Cky%b*Gi0KfFA`ofKTzu`AV-9 znW|y@19rrZ*!N2AvDi<_ZeR3O2R{#dh1#3-d%$k${Rx42h+i&GZo5!C^dSL34*AKp z27mTd>k>?V&X;Nl%GZ(>0s`1UN~Hfyj>KPjtnc|)xM@{H_B9rNr~LuH`Gr5_am&Ep zTjZA8hljNj5H1Ipm-uD9rC}U{-vR!eay5&6x6FkfupdpT*84MVwGpdd(}ib)zZ3Ky z7C$pnjc82(W_y_F{PhYj?o!@3__UUvpX)v69aBSzYj3 zdi}YQkKs^SyXyFG2LTRz9{(w}y~!`{EuAaUr6G1M{*%c+kP1olW9z23dSH!G4_HSK zzae-DF$OGR{ofP*!$a(r^5Go>I3SObVI6FLY)N@o<*gl0&kLo-OT{Tl*7nCz>Iq=? zcigIDHtj|H;6sR?or8Wd_a4996GI*CXGU}o;D9`^FM!AT1pBY~?|4h^61BY#_yIfO zKO?E0 zJ{Pc`9rVEI&$xxXu`<5E)&+m(7zX^v0rqofLs&bnQT(1baQkAr^kEsk)15vlzAZ-l z@OO9RF<+IiJ*O@HE256gCt!bF=NM*vh|WVWmjVawcNoksRTMvR03H{p@cjwKh(CL4 z7_PB(dM=kO)!s4fW!1p0f93YN@?ZSG` z$B!JaAJCtW$B97}HNO9(x-t30&E}Mo1UPi@Av%uHj~?T|!4JLwV;KCx8xO#b9IlUW zI6+{a@Wj|<2Y=U;a@vXbxqZNngH8^}LleE_4*0&O7#3iGxfJ%Id>+sb;7{L=aIic8 z|EW|{{S)J-wr@;3PmlxRXU8!e2gm_%s|ReH!reFcY8%$Hl4M5>;6^UDUUae?kOy#h zk~6Ee_@ZAn48Bab__^bNmQ~+k=02jz)e0d9Z3>G?RGG!65?d1>9}7iG17?P*=GUV-#SbLRw)Hu{zx*azHxWkGNTWl@HeWjA?39Ia|sCi{e;!^`1Oec zb>Z|b65OM*;eC=ZLSy?_fg$&^2xI>qSLA2G*$nA3GEnp3$N-)46`|36m*sc#4%C|h zBN<2U;7k>&G_wL4=Ve5z`ubVD&*Hxi)r@{4RCDw7U_D`lbC(9&pG5C*z#W>8>HU)h z!h3g?2UL&sS!oY5$3?VlA0Me9W5e~V;2jds*fz^updz#AJ%G8w2V}AEE?E^=MK%Xt z__Bx1cr7+DQmuHmzn*|hh%~eEc9@m05@clWfpEFcr+06%0&dZJH&@8^&@*$qR@}o3 z@Tuuh2FsLz^zH+dN&T&?0G3I?MpmYJ;GP$J!EzjeM#YLJ!W$}MVNb0^HfOA>5Fe~UNn%Zk(PT@~9}1dt)1UQ zU*B5K?Dl#G74qmg|2>^>0WtLX#Jz{lO4NT`NYB*(L#D|5IpXr9v&7a@YsGp3vLR7L zHYGHZg7{ie6n~2p$6Yz>=^cEg7tEgk-1YRl%-s7^cbqFb(U7&Dp78+&ut5!Tn(hER z|Gp4Ed@CnOPeAe|N>U(dB;SZ?NU^AzoD^UAH_vamp6Ws}{|mSq`^+VP1g~2B{%N-!mWz<`)G)>V-<`9`L4?3dM%Qh6<@kba+m`JS{Ya@9Fq*m6$$ zA1%Ogc~VRH33|S9l%CNb4zM%k^EIpqY}@h{w(aBcJ9c05oiZx#SK9t->5lSI`=&l~ z+-Ic)a{FbBhXV$Xt!WRd`R#Jk-$+_Z52rS>?Vpt2IK<84|E-SBEoIw>cs=a{BlQ7O z-?{Fy_M&84&9|KM5wt~)*!~i~E=(6m8(uCO)I=)M?)&sRbzH$9Rovzd?ZEY}GqX+~ zFbEbLz`BZ49=2Yh-|<`waK-_4!7`ro@zlC|r&I4fc4oyb+m=|c8)8%tZ-z5FwhzDt zL5kB@u53`d@%nHl0Sp)Dw`(QU&>vujEn?GPEXUW!Wi<+4e%BORl&BIH+SwRcbS}X@ z01Pk|vA%OdJKAs17zSXtO55k!;%m9>1eW9LnyAX4uj7@${O6cfii`49qTNItzny5J zH&Gj`e}o}?xjQ}r?LrI%FjUd@xflT3|7LA|ka%Q3i}a8gVm<`HIWoJGH=$EGClX^C0lysQJ>UO(q&;`T#8txuoQ_{l^kEV9CAdXuU1Ghg8 zN_6hHFuy&1x24q5-(Z7;!poYdt*`UTdrQOIQ!2O7_+AHV2hgXaEz7)>$LEdG z<8vE^Tw$|YwZHZDPM!SNOAWG$?J)MdmEk{U!!$M#fp7*Wo}jJ$Q(=8>R`Ats?e|VU?Zt7Cdh%AdnfyN3MBWw{ z$OnREvPf7%z6`#2##_7id|H%Y{vV^vWXb?5d5?a_y&t3@p9t$ncHj-NBdo&X{wrfJ zamN)VMYROYh_SvjJ=Xd!Ga?PY_$;*L=SxFte!4O6%0HEh%iZ4=gvns7IWIyJHa|hT z2;1+e)`TvbNb3-0z&DD_)Jomsg-7p_Uh`wjGnU1urmv1_oVqRg#=C?e?!7DgtqojU zWoAB($&53;TsXu^@2;8M`#z{=rPy?JqgYM0CDf4v@z=ZD|ItJ&8%_7A#K?S{wjxgd z?xA6JdJojrWpB7fr2p_MSsU4(R7=XGS0+Eg#xR=j>`H@R9{XjwBmqAiOxOL` zt?XK-iTEOWV}f>Pz3H-s*>W z4~8C&Xq25UQ^xH6H9kY_RM1$ch+%YLF72AA7^b{~VNTG}Tj#qZltz5Q=qxR`&oIlW Nr__JTFzvMr^FKp4S3v*( diff --git a/examples/angular/injectAsyncRateLimiterWithPersister/src/app/app.config.ts b/examples/angular/injectAsyncRateLimiterWithPersister/src/app/app.config.ts deleted file mode 100644 index 46a6b4193..000000000 --- a/examples/angular/injectAsyncRateLimiterWithPersister/src/app/app.config.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core' -import { provideRouter } from '@angular/router' - -import { routes } from './app.routes' - -export const appConfig: ApplicationConfig = { - providers: [provideBrowserGlobalErrorListeners(), provideRouter(routes)], -} diff --git a/examples/angular/injectAsyncRateLimiterWithPersister/src/app/app.css b/examples/angular/injectAsyncRateLimiterWithPersister/src/app/app.css deleted file mode 100644 index 296e372b0..000000000 --- a/examples/angular/injectAsyncRateLimiterWithPersister/src/app/app.css +++ /dev/null @@ -1,19 +0,0 @@ -:host { - display: block; - padding: 2rem; - max-width: 600px; - margin: 0 auto; -} - -input { - width: 100%; - padding: 0.5rem; - font-size: 1rem; - border: 1px solid #ccc; - border-radius: 4px; -} - -p { - margin-top: 1rem; - color: #666; -} diff --git a/examples/angular/injectAsyncRateLimiterWithPersister/src/app/app.html b/examples/angular/injectAsyncRateLimiterWithPersister/src/app/app.html deleted file mode 100644 index e35faff59..000000000 --- a/examples/angular/injectAsyncRateLimiterWithPersister/src/app/app.html +++ /dev/null @@ -1,162 +0,0 @@ -
-

TanStack Pacer - Rate Limiter Example

- -
-

Rate Limiter Example

-

- Rate limiting enforces a hard limit: allows executions until the limit is reached (5 per 5 - seconds), then blocks all subsequent calls until the window resets. Click the increment button - rapidly to see the rate limiter in action. -

- -
-

Window Type

-
- - -
-

- Fixed Window: Strict window that resets completely after the period.
- Sliding Window: Rolling window that allows executions as old ones expire. -

-
- -
-

Rate Limiter Status

-
-
Execution Count: {{ rateLimiter.state().executionCount }}
-
Rejection Count: {{ rateLimiter.state().rejectionCount }}
-
- Remaining in Window: {{ rateLimiter.getRemainingInWindow() }} / 5 -
-
Ms Until Next Window: {{ rateLimiter.getMsUntilNextWindow() }}ms
-
-
- -
-

Counters

-
-
- Instant Count: -
- {{ instantCount() }} -
-
- Updates immediately on every click -
-
-
- Rate Limited Count: -
- {{ limitedCount() }} -
-
- Only updates when rate limit allows -
-
-
-
- -
-

Controls

-
- - -
-

- Click "Increment" rapidly to see the rate limiter block executions after 5 clicks within 5 - seconds. -

-
- -
-

Execution History

-
- @if (executionHistory.length === 0) { - No executions yet. Click "Increment" to start. - } @for (entry of executionHistory; track $index) { -
-
-
{{ entry.timestamp }} - Count: {{ entry.count }}
- - {{ entry.rejected ? 'REJECTED' : 'EXECUTED' }} - -
-
- } -
-
-
- - -
diff --git a/examples/angular/injectAsyncRateLimiterWithPersister/src/app/app.routes.ts b/examples/angular/injectAsyncRateLimiterWithPersister/src/app/app.routes.ts deleted file mode 100644 index 9a884b113..000000000 --- a/examples/angular/injectAsyncRateLimiterWithPersister/src/app/app.routes.ts +++ /dev/null @@ -1,3 +0,0 @@ -import type { Routes } from '@angular/router' - -export const routes: Routes = [] diff --git a/examples/angular/injectAsyncRateLimiterWithPersister/src/app/app.spec.ts b/examples/angular/injectAsyncRateLimiterWithPersister/src/app/app.spec.ts deleted file mode 100644 index f9baf7f75..000000000 --- a/examples/angular/injectAsyncRateLimiterWithPersister/src/app/app.spec.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { TestBed } from '@angular/core/testing' -import { App } from './app' - -describe('App', () => { - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [App], - }).compileComponents() - }) - - it('should create the app', () => { - const fixture = TestBed.createComponent(App) - const app = fixture.componentInstance - expect(app).toBeTruthy() - }) - - it('should render title', async () => { - const fixture = TestBed.createComponent(App) - await fixture.whenStable() - const compiled = fixture.nativeElement as HTMLElement - expect(compiled.querySelector('h1')?.textContent).toContain('Hello, basic') - }) -}) diff --git a/examples/angular/injectAsyncRateLimiterWithPersister/src/app/app.ts b/examples/angular/injectAsyncRateLimiterWithPersister/src/app/app.ts deleted file mode 100644 index b54b5da0c..000000000 --- a/examples/angular/injectAsyncRateLimiterWithPersister/src/app/app.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { Component, signal } from '@angular/core' -import { RouterOutlet } from '@angular/router' -import { injectRateLimiter } from '@tanstack/angular-pacer' - -@Component({ - selector: 'app-root', - imports: [RouterOutlet], - templateUrl: './app.html', - styleUrl: './app.css', -}) -export class App { - protected readonly windowType = signal<'fixed' | 'sliding'>('fixed') - protected readonly instantCount = signal(0) - protected readonly limitedCount = signal(0) - protected readonly executionHistory: Array<{ - timestamp: string - count: number - rejected: boolean - }> = [] - - // Rate limiter: allows 5 executions per 5 seconds - protected readonly rateLimiter = injectRateLimiter< - (count: number) => void, - { - executionCount: number - rejectionCount: number - executionTimes: Array - } - >( - (count: number) => { - console.log('Rate-limited execution:', count) - this.limitedCount.set(count) - this.executionHistory.push({ - timestamp: new Date().toLocaleTimeString(), - count, - rejected: false, - }) - }, - { - limit: 5, - window: 5000, // 5 seconds - windowType: this.windowType(), - onReject: () => { - console.log('Rejected by rate limiter', this.rateLimiter.getMsUntilNextWindow()) - this.executionHistory.push({ - timestamp: new Date().toLocaleTimeString(), - count: this.instantCount(), - rejected: true, - }) - }, - }, - (state) => ({ - executionCount: state.executionCount, - rejectionCount: state.rejectionCount, - executionTimes: state.executionTimes, - }), - ) - - protected increment(): void { - // Update instant count immediately - this.instantCount.update((c) => { - const newCount = c + 1 - // Try to execute with rate limiter - this.rateLimiter.maybeExecute(newCount) - return newCount - }) - } - - protected reset(): void { - this.rateLimiter.reset() - this.instantCount.set(0) - this.limitedCount.set(0) - this.executionHistory.length = 0 - } - - protected setWindowType(type: 'fixed' | 'sliding'): void { - this.windowType.set(type) - // Note: windowType change requires recreating the rate limiter in a real app - // For this example, we'll just update the signal - } -} diff --git a/examples/angular/injectAsyncRateLimiterWithPersister/src/index.html b/examples/angular/injectAsyncRateLimiterWithPersister/src/index.html deleted file mode 100644 index 1b3f817b9..000000000 --- a/examples/angular/injectAsyncRateLimiterWithPersister/src/index.html +++ /dev/null @@ -1,13 +0,0 @@ - - - - - Basic - - - - - - - - diff --git a/examples/angular/injectAsyncRateLimiterWithPersister/src/main.ts b/examples/angular/injectAsyncRateLimiterWithPersister/src/main.ts deleted file mode 100644 index 8192dca69..000000000 --- a/examples/angular/injectAsyncRateLimiterWithPersister/src/main.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { bootstrapApplication } from '@angular/platform-browser' -import { appConfig } from './app/app.config' -import { App } from './app/app' - -bootstrapApplication(App, appConfig).catch((err) => console.error(err)) diff --git a/examples/angular/injectAsyncRateLimiterWithPersister/src/styles.css b/examples/angular/injectAsyncRateLimiterWithPersister/src/styles.css deleted file mode 100644 index 90d4ee007..000000000 --- a/examples/angular/injectAsyncRateLimiterWithPersister/src/styles.css +++ /dev/null @@ -1 +0,0 @@ -/* You can add global styles to this file, and also import other style files */ diff --git a/examples/angular/injectAsyncRateLimiterWithPersister/tsconfig.app.json b/examples/angular/injectAsyncRateLimiterWithPersister/tsconfig.app.json deleted file mode 100644 index 90631899c..000000000 --- a/examples/angular/injectAsyncRateLimiterWithPersister/tsconfig.app.json +++ /dev/null @@ -1,11 +0,0 @@ -/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ -/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ -{ - "extends": "./tsconfig.json", - "compilerOptions": { - "outDir": "./out-tsc/app", - "types": [] - }, - "include": ["./src/**/*.ts"], - "exclude": ["./src/**/*.spec.ts"] -} diff --git a/examples/angular/injectAsyncRateLimiterWithPersister/tsconfig.json b/examples/angular/injectAsyncRateLimiterWithPersister/tsconfig.json deleted file mode 100644 index 2ab744275..000000000 --- a/examples/angular/injectAsyncRateLimiterWithPersister/tsconfig.json +++ /dev/null @@ -1,33 +0,0 @@ -/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ -/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ -{ - "compileOnSave": false, - "compilerOptions": { - "strict": true, - "noImplicitOverride": true, - "noPropertyAccessFromIndexSignature": true, - "noImplicitReturns": true, - "noFallthroughCasesInSwitch": true, - "skipLibCheck": true, - "isolatedModules": true, - "experimentalDecorators": true, - "importHelpers": true, - "target": "ES2022", - "module": "preserve" - }, - "angularCompilerOptions": { - "enableI18nLegacyMessageIdFormat": false, - "strictInjectionParameters": true, - "strictInputAccessModifiers": true, - "strictTemplates": true - }, - "files": [], - "references": [ - { - "path": "./tsconfig.app.json" - }, - { - "path": "./tsconfig.spec.json" - } - ] -} diff --git a/examples/angular/injectAsyncRateLimiterWithPersister/tsconfig.spec.json b/examples/angular/injectAsyncRateLimiterWithPersister/tsconfig.spec.json deleted file mode 100644 index 26230b0b3..000000000 --- a/examples/angular/injectAsyncRateLimiterWithPersister/tsconfig.spec.json +++ /dev/null @@ -1,10 +0,0 @@ -/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ -/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ -{ - "extends": "./tsconfig.json", - "compilerOptions": { - "outDir": "./out-tsc/spec", - "types": ["vitest/globals"] - }, - "include": ["src/**/*.d.ts", "src/**/*.spec.ts"] -} diff --git a/examples/angular/injectAsyncThrottledCallback/src/app/app.html b/examples/angular/injectAsyncThrottledCallback/src/app/app.html index a0e601099..c19fdc540 100644 --- a/examples/angular/injectAsyncThrottledCallback/src/app/app.html +++ b/examples/angular/injectAsyncThrottledCallback/src/app/app.html @@ -1,39 +1,47 @@ -

injectAsyncThrottledCallback

- -

- Call the async function rapidly; it executes at most once per wait window. Calls made while - throttled return undefined. -

- -
- - - - -
- -

Executing: {{ isExecuting() }}

-

Last outcome: {{ lastOutcome() }}

-

Last result: {{ lastResult() ?? 'none' }}

- -

History

- -@if (history.length === 0) { -

No calls yet.

-} @else { +

TanStack Pacer injectAsyncThrottledCallback Example 1

+

Search

+ +@if (isLoading()) { +

Loading...

+}@if (error()) { +

Error: {{ error() }}

+}
    - @for (entry of history; track $index) { -
  • -
    - {{ entry.timestamp }} - {{ entry.value }} - {{ entry.executed ? 'EXECUTED' : 'THROTTLED' }} -
    - @if (entry.executed) { -
    Result: {{ entry.result }}
    - } -
  • + @for (result of results(); track $index) { +
  • {{ result }}
  • }
+

1000ms wait, 500ms API delay.

+
+

TanStack Pacer injectAsyncThrottledCallback Example 2

+

Async counter

+

Current Count: {{ count() }}, API Calls Made: {{ apiCalls() }}

+ +

+ UI increments immediately; the API returns one additional increment after + 300ms. Calls have a 1000ms wait. +

+
+

TanStack Pacer injectAsyncThrottledCallback Example 3

+

Save scroll position

+

+ Current Position: {{ scrollPosition() }}px, Last Saved: {{ lastSavedPosition() + }}px, API Saves: {{ saveCount() }} +

+@if (isSaving()) { +

Saving scroll position...

} +
+ @for (row of scrollRows; track row) { +

Scroll content row {{ row }}

+ } +
+

Position saves are throttled to 1000ms with a 300ms API delay.

diff --git a/examples/angular/injectAsyncThrottledCallback/src/app/app.spec.ts b/examples/angular/injectAsyncThrottledCallback/src/app/app.spec.ts index f9baf7f75..73d4df149 100644 --- a/examples/angular/injectAsyncThrottledCallback/src/app/app.spec.ts +++ b/examples/angular/injectAsyncThrottledCallback/src/app/app.spec.ts @@ -1,3 +1,4 @@ +import { vi } from 'vitest' import { TestBed } from '@angular/core/testing' import { App } from './app' @@ -18,6 +19,50 @@ describe('App', () => { const fixture = TestBed.createComponent(App) await fixture.whenStable() const compiled = fixture.nativeElement as HTMLElement - expect(compiled.querySelector('h1')?.textContent).toContain('Hello, basic') + expect(compiled.querySelector('h1')?.textContent).toContain( + 'TanStack Pacer injectAsyncThrottledCallback Example', + ) + }) +}) + +// Exercise the real adapter and component with a deterministic clock. +describe('example behavior', () => { + beforeEach(async () => { + await TestBed.configureTestingModule({ imports: [App] }).compileComponents() + }) + afterEach(() => { + TestBed.resetTestingModule() + vi.clearAllTimers() + vi.useRealTimers() + vi.restoreAllMocks() + }) + it('displays simulated search failures and then recovers', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }) + const fixture = TestBed.createComponent(App) + const app = fixture.componentInstance + fixture.detectChanges() + TestBed.tick() + void app.onSearch('error') + await vi.advanceTimersByTimeAsync(1600) + expect(app.error()).toBe('Simulated API error') + expect(app.results()).toEqual([]) + void app.onSearch('angular') + await vi.advanceTimersByTimeAsync(1600) + expect(app.results()).toHaveLength(3) + expect(app.error()).toBe('') + }) + it('saves the latest scroll position', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }) + const fixture = TestBed.createComponent(App) + const app = fixture.componentInstance + fixture.detectChanges() + TestBed.tick() + void app.onScroll(40) + await vi.advanceTimersByTimeAsync(300) + expect(app.lastSavedPosition()).toBe(40) + void app.onScroll(180) + await vi.advanceTimersByTimeAsync(1400) + expect(app.lastSavedPosition()).toBe(180) + expect(app.saveCount()).toBe(2) }) }) diff --git a/examples/angular/injectAsyncThrottledCallback/src/app/app.ts b/examples/angular/injectAsyncThrottledCallback/src/app/app.ts index 148b4dbcb..e5754bfff 100644 --- a/examples/angular/injectAsyncThrottledCallback/src/app/app.ts +++ b/examples/angular/injectAsyncThrottledCallback/src/app/app.ts @@ -1,78 +1,77 @@ import { Component, signal } from '@angular/core' import { injectAsyncThrottledCallback } from '@tanstack/angular-pacer' -type HistoryEntry = { - timestamp: string - value: string - executed: boolean - result?: string -} - -@Component({ - selector: 'app-root', - templateUrl: './app.html', - styleUrl: './app.css', -}) +@Component({ selector: 'app-root', templateUrl: './app.html' }) export class App { - protected readonly value = signal('') - protected readonly isExecuting = signal(false) - protected readonly lastOutcome = signal<'idle' | 'executed' | 'throttled'>('idle') - protected readonly lastResult = signal(null) - - protected readonly history: Array = [] - - private nextId = 1 - - protected readonly save = injectAsyncThrottledCallback( - async (value: string) => { - this.isExecuting.set(true) + readonly search = signal('') + readonly results = signal>([]) + readonly isLoading = signal(false) + readonly error = signal('') + readonly count = signal(0) + readonly apiCalls = signal(0) + private readonly searchCallback = injectAsyncThrottledCallback( + async (query: string) => { + if (!query.trim()) { + this.results.set([]) + return + } + this.isLoading.set(true) + this.error.set('') try { - await new Promise((resolve) => setTimeout(resolve, 400)) - const result = `saved:${value}` - this.lastResult.set(result) - return result + await new Promise((resolve) => setTimeout(resolve, 500)) + if (query === 'error') throw new Error('Simulated API error') + this.results.set([1, 2, 3].map((id) => `${query} result ${id}`)) } finally { - this.isExecuting.set(false) + this.isLoading.set(false) } }, { wait: 1000, + throwOnError: false, + onError: (error) => { + this.error.set(error.message) + this.results.set([]) + }, }, ) - - protected runOnce(): void { - const v = this.value().trim() || `value-${this.nextId++}` - this.value.set(v) - void this.attempt(v) + private readonly incrementCallback = injectAsyncThrottledCallback( + async (value: number) => { + await new Promise((resolve) => setTimeout(resolve, 300)) + this.apiCalls.update((count) => count + 1) + this.count.set(value + 1) + return value + 1 + }, + { wait: 1000, leading: true, trailing: true }, + ) + async onSearch(value: string): Promise { + this.search.set(value) + await this.searchCallback(value) } - - protected runBurst(count: number): void { - for (let i = 0; i < count; i++) { - void this.attempt(`value-${this.nextId++}`) - } + async increment(): Promise { + const value = this.count() + 1 + this.count.set(value) + await this.incrementCallback(value) } - - protected reset(): void { - this.value.set('') - this.isExecuting.set(false) - this.lastOutcome.set('idle') - this.lastResult.set(null) - this.history.length = 0 - this.nextId = 1 - } - - private async attempt(value: string): Promise { - const timestamp = new Date().toLocaleTimeString() - - const result = await this.save(value) - - if (result === undefined) { - this.lastOutcome.set('throttled') - this.history.unshift({ timestamp, value, executed: false }) - return - } - - this.lastOutcome.set('executed') - this.history.unshift({ timestamp, value, executed: true, result }) + readonly scrollPosition = signal(0) + readonly lastSavedPosition = signal(0) + readonly saveCount = signal(0) + readonly isSaving = signal(false) + readonly scrollRows = Array.from({ length: 50 }, (_, index) => index + 1) + private readonly savePosition = injectAsyncThrottledCallback( + async (position: number) => { + this.isSaving.set(true) + try { + await new Promise((resolve) => setTimeout(resolve, 300)) + this.lastSavedPosition.set(position) + this.saveCount.update((count) => count + 1) + } finally { + this.isSaving.set(false) + } + }, + { wait: 1000, leading: true, trailing: true }, + ) + async onScroll(position: number): Promise { + this.scrollPosition.set(position) + await this.savePosition(position) } } diff --git a/examples/angular/injectAsyncThrottler/src/app/app.html b/examples/angular/injectAsyncThrottler/src/app/app.html index 205bc0767..b9a6eb7dc 100644 --- a/examples/angular/injectAsyncThrottler/src/app/app.html +++ b/examples/angular/injectAsyncThrottler/src/app/app.html @@ -1,29 +1,26 @@ -

injectAsyncThrottler example

+

TanStack Pacer injectAsyncThrottler Example

-

- Click "Run" rapidly. The underlying async function will run at most once per second (wait: 1000). -

- - - -
- - -
- -

Last event: {{ lastEvent() }}

-

Last value: {{ lastValue() }}

- -

Throttler state

-
{{ throttler.state() }}
- - +

1000ms wait, 500ms simulated network delay.

+ +@if (loading()) { +

Loading...

+} @if (error()) { +

Error: {{ error() }}

+} +

API calls made: {{ runner.state().successCount }}

+

Is executing: {{ runner.state().isExecuting }}

+ +

Pending: {{ runner.state().isPending }}

+
{{ runner.state() | json }}
+

Search Results

+
    + @for (result of results(); track $index) { +
  • {{ result }}
  • + } +
diff --git a/examples/angular/injectAsyncThrottler/src/app/app.spec.ts b/examples/angular/injectAsyncThrottler/src/app/app.spec.ts index f9baf7f75..136d4aaa9 100644 --- a/examples/angular/injectAsyncThrottler/src/app/app.spec.ts +++ b/examples/angular/injectAsyncThrottler/src/app/app.spec.ts @@ -1,3 +1,4 @@ +import { vi } from 'vitest' import { TestBed } from '@angular/core/testing' import { App } from './app' @@ -18,6 +19,38 @@ describe('App', () => { const fixture = TestBed.createComponent(App) await fixture.whenStable() const compiled = fixture.nativeElement as HTMLElement - expect(compiled.querySelector('h1')?.textContent).toContain('Hello, basic') + expect(compiled.querySelector('h1')?.textContent).toContain( + 'TanStack Pacer injectAsyncThrottler Example', + ) + }) +}) + +// Exercise the real adapter and component with a deterministic clock. +describe('example behavior', () => { + beforeEach(async () => { + await TestBed.configureTestingModule({ imports: [App] }).compileComponents() + }) + afterEach(() => { + TestBed.resetTestingModule() + vi.clearAllTimers() + vi.useRealTimers() + vi.restoreAllMocks() + }) + it('completes a search and renders its results', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }) + const fixture = TestBed.createComponent(App) + const app = fixture.componentInstance + fixture.detectChanges() + TestBed.tick() + void app.onSearch('angular') + await vi.advanceTimersByTimeAsync(2500) + TestBed.tick() + expect(app.results()).toEqual([ + 'Result 1 for angular', + 'Result 2 for angular', + 'Result 3 for angular', + ]) + expect(app.loading()).toBe(false) + expect(fixture.nativeElement.textContent).toContain('Result 1 for angular') }) }) diff --git a/examples/angular/injectAsyncThrottler/src/app/app.ts b/examples/angular/injectAsyncThrottler/src/app/app.ts index db90a2d8d..0d14a5dc6 100644 --- a/examples/angular/injectAsyncThrottler/src/app/app.ts +++ b/examples/angular/injectAsyncThrottler/src/app/app.ts @@ -1,52 +1,47 @@ import { Component, signal } from '@angular/core' -import { RouterOutlet } from '@angular/router' +import { JsonPipe } from '@angular/common' import { injectAsyncThrottler } from '@tanstack/angular-pacer' -function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)) -} - @Component({ selector: 'app-root', - imports: [RouterOutlet], templateUrl: './app.html', - styleUrl: './app.css', + imports: [JsonPipe], }) export class App { - protected readonly query = signal('hello') - protected readonly lastEvent = signal<'executed' | 'throttled' | 'error' | null>(null) - protected readonly lastValue = signal(null) - - protected readonly throttler = injectAsyncThrottler( - async (q: string) => { - await sleep(600) - return `Response for "${q}" @ ${new Date().toLocaleTimeString()}` - }, - { wait: 1000 }, + readonly searchTerm = signal('') + readonly results = signal>([]) + readonly loading = signal(false) + readonly error = signal('') + readonly runner = injectAsyncThrottler( + (term: string) => this.search(term), + () => ({ + wait: 1000, + onError: (error) => { + this.error.set(error.message) + this.results.set([]) + }, + }), (state) => state, ) - protected async run(): Promise { - this.lastEvent.set(null) + private async search(term: string): Promise | undefined> { + if (!term) { + this.results.set([]) + return + } + this.loading.set(true) try { - const result = await this.throttler.maybeExecute(this.query()) - - if (result === undefined) { - this.lastEvent.set('throttled') - return - } - - this.lastEvent.set('executed') - this.lastValue.set(String(result)) - } catch (err) { - this.lastEvent.set('error') - this.lastValue.set(err instanceof Error ? err.message : String(err)) + await new Promise((resolve) => setTimeout(resolve, 500)) + const results = [1, 2, 3].map((id) => `Result ${id} for ${term}`) + this.results.set(results) + this.error.set('') + return results + } finally { + this.loading.set(false) } } - - protected reset(): void { - this.throttler.reset() - this.lastEvent.set(null) - this.lastValue.set(null) + async onSearch(value: string): Promise { + this.searchTerm.set(value) + await this.runner.maybeExecute(value) } } diff --git a/examples/angular/injectBatchedCallback/src/app/app.html b/examples/angular/injectBatchedCallback/src/app/app.html index ffa77f9e3..cbcb37440 100644 --- a/examples/angular/injectBatchedCallback/src/app/app.html +++ b/examples/angular/injectBatchedCallback/src/app/app.html @@ -1,33 +1,46 @@ -

injectBatchedCallback

- +

TanStack Pacer injectBatchedCallback Example 1

+

Batched logs

+

Max 3 logs or 2000ms wait.

+ +

Total Logs Created: {{ logCount() }}, Logs Processed: {{ logs().length }}

+@for (log of logs(); track log.id) { +

{{ log.timestamp }}: {{ log.message }}

+} +
+

TanStack Pacer injectBatchedCallback Example 2

+

Batched analytics

+

Max 5 events or 3000ms wait.

+

- Queue items quickly; they get delivered to the callback in batches. This example batches up to 5 - items, or flushes after 1 second. + Total Events Created: {{ totalEvents() }}, Events Sent: {{ events().length }}, + Batches Processed: {{ batchesProcessed() }}

- -
- - - - -
- -

Queued (not yet processed): {{ queuedCount() }}

- -

Processed Batches

- -@if (processedBatches.length === 0) { -

No batches processed yet. Click "Enqueue" a few times.

-} @else { -
    - @for (batch of processedBatches; track $index) { -
  • -
    {{ batch.timestamp }} ({{ batch.items.length }} items)
    -
    {{ batch.items.join(', ') }}
    -
  • - } -
+@for (event of events(); track $index) { +

{{ event.timestamp }}: {{ event.type }} - {{ event.target }}

+} +
+

TanStack Pacer injectBatchedCallback Example 3

+

Batched API requests

+

Max 4 requests or 1500ms wait.

+ +

+ Total Requests Made: {{ requests().length }}, Requests Queued: {{ + pendingRequests().length }}, Requests Processed: {{ processedRequests().length + }} +

+

Queued Requests

+@for (request of pendingRequests(); track request.id) { +

{{ request.id }}: {{ request.data | json }}

+} +

Processed Requests

+@for (request of processedRequests(); track request.id) { +

{{ request.id }}: {{ request.data | json }}

} diff --git a/examples/angular/injectBatchedCallback/src/app/app.spec.ts b/examples/angular/injectBatchedCallback/src/app/app.spec.ts index f9baf7f75..8fb604461 100644 --- a/examples/angular/injectBatchedCallback/src/app/app.spec.ts +++ b/examples/angular/injectBatchedCallback/src/app/app.spec.ts @@ -1,3 +1,4 @@ +import { vi } from 'vitest' import { TestBed } from '@angular/core/testing' import { App } from './app' @@ -18,6 +19,36 @@ describe('App', () => { const fixture = TestBed.createComponent(App) await fixture.whenStable() const compiled = fixture.nativeElement as HTMLElement - expect(compiled.querySelector('h1')?.textContent).toContain('Hello, basic') + expect(compiled.querySelector('h1')?.textContent).toContain( + 'TanStack Pacer injectBatchedCallback Example', + ) + }) +}) + +// Exercise the real adapter and component with a deterministic clock. +describe('example behavior', () => { + beforeEach(async () => { + await TestBed.configureTestingModule({ imports: [App] }).compileComponents() + }) + afterEach(() => { + TestBed.resetTestingModule() + vi.clearAllTimers() + vi.useRealTimers() + vi.restoreAllMocks() + }) + it('processes log, analytics, and API batches at their size thresholds', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }) + const fixture = TestBed.createComponent(App) + const app = fixture.componentInstance + fixture.detectChanges() + TestBed.tick() + for (let index = 0; index < 3; index++) app.addLog('Log') + for (let index = 0; index < 5; index++) app.trackEvent('click', 'button-1') + for (let index = 0; index < 4; index++) app.makeRequest('save', 'document') + expect(app.logs()).toHaveLength(3) + expect(app.events()).toHaveLength(5) + expect(app.batchesProcessed()).toBe(1) + expect(app.processedRequests()).toHaveLength(4) + expect(app.pendingRequests()).toEqual([]) }) }) diff --git a/examples/angular/injectBatchedCallback/src/app/app.ts b/examples/angular/injectBatchedCallback/src/app/app.ts index 1916db68a..30bdc00f2 100644 --- a/examples/angular/injectBatchedCallback/src/app/app.ts +++ b/examples/angular/injectBatchedCallback/src/app/app.ts @@ -1,57 +1,64 @@ -import { Component, signal } from '@angular/core' +import { Component, computed, signal } from '@angular/core' +import { JsonPipe } from '@angular/common' import { injectBatchedCallback } from '@tanstack/angular-pacer' +type LogEntry = { id: number; message: string; timestamp: string } +type AnalyticsEvent = { type: string; target: string; timestamp: string } +type ApiRequest = { id: number; data: { action: string; item: string } } + @Component({ selector: 'app-root', templateUrl: './app.html', - styleUrl: './app.css', + imports: [JsonPipe], }) export class App { - protected readonly value = signal('') - protected readonly queuedCount = signal(0) - - protected readonly processedBatches: Array<{ - timestamp: string - items: Array - }> = [] - - private nextId = 1 - - protected readonly addToBatch = injectBatchedCallback( - (items) => { - this.processedBatches.unshift({ - timestamp: new Date().toLocaleTimeString(), - items: [...items], - }) - - this.queuedCount.update((c) => Math.max(0, c - items.length)) - }, - { - maxSize: 5, - wait: 1000, + readonly logs = signal>([]) + readonly logCount = signal(0) + readonly events = signal>([]) + readonly totalEvents = signal(0) + readonly batchesProcessed = signal(0) + readonly requests = signal>([]) + readonly processedRequests = signal>([]) + readonly pendingRequests = computed(() => + this.requests().filter( + (request) => + !this.processedRequests().some( + (processed) => processed.id === request.id, + ), + ), + ) + private readonly logger = injectBatchedCallback( + (entries: Array) => + this.logs.update((logs) => [...logs, ...entries]), + { maxSize: 3, wait: 2000 }, + ) + private readonly tracker = injectBatchedCallback( + (events: Array) => { + this.events.update((previous) => [...previous, ...events]) + this.batchesProcessed.update((count) => count + 1) }, + { maxSize: 5, wait: 3000 }, ) - - protected enqueue(): void { - const raw = this.value().trim() - const item = raw.length > 0 ? raw : `item-${this.nextId++}` - - this.queuedCount.update((c) => c + 1) - this.addToBatch(item) - this.value.set('') + private readonly requestBatch = injectBatchedCallback( + (requests: Array) => + this.processedRequests.update((previous) => [...previous, ...requests]), + { maxSize: 4, wait: 1500 }, + ) + addLog(kind: string): void { + this.logCount.update((count) => count + 1) + this.logger({ + id: this.logCount(), + message: `${kind} ${this.logCount()}`, + timestamp: new Date().toLocaleTimeString(), + }) } - - protected enqueueMany(count: number): void { - for (let i = 0; i < count; i++) { - this.value.set(`item-${this.nextId++}`) - this.enqueue() - } + trackEvent(type: string, target: string): void { + this.totalEvents.update((count) => count + 1) + this.tracker({ type, target, timestamp: new Date().toLocaleTimeString() }) } - - protected reset(): void { - this.value.set('') - this.queuedCount.set(0) - this.processedBatches.length = 0 - this.nextId = 1 + makeRequest(action: string, item: string): void { + const request = { id: this.requests().length + 1, data: { action, item } } + this.requests.update((requests) => [...requests, request]) + this.requestBatch(request) } } diff --git a/examples/angular/injectBatcher/src/app/app.html b/examples/angular/injectBatcher/src/app/app.html index 07cf0aa17..6c97c8745 100644 --- a/examples/angular/injectBatcher/src/app/app.html +++ b/examples/angular/injectBatcher/src/app/app.html @@ -1,27 +1,15 @@ -

TanStack Pacer - injectBatcher (Angular)

- - - -
- - - -
- -

Queue

-
queuedCount: {{ queuedCount() }}
-
items: {{ batcher.state().items.join(', ') }}
- -

Last batch

-
{{ lastBatch().join(', ') }}
- -

State

-
status: {{ batcher.state().status }}
-
isPending: {{ batcher.state().isPending }}
-
execution Count: {{ batcher.state().executionCount }}
-
processedCount: {{ processedCount() }}
- - +

TanStack Pacer injectBatcher Example

+

Batch Size: {{ pendingItems().length }} / 5

+

Process after 3000ms, at 5 items, or when the batch includes 42.

+

Batch Items: {{ pendingItems().join(', ') }}

+ + +

Batches Processed: {{ runner.state().executionCount }}

+

Items Processed: {{ runner.state().totalItemsProcessed }}

+

Processed Batches

+@for (items of processedBatches(); track $index) { +

[{{ items.join(', ') }}]

+} +
{{ runner.state() | json }}
diff --git a/examples/angular/injectBatcher/src/app/app.spec.ts b/examples/angular/injectBatcher/src/app/app.spec.ts index f9baf7f75..10e0132e3 100644 --- a/examples/angular/injectBatcher/src/app/app.spec.ts +++ b/examples/angular/injectBatcher/src/app/app.spec.ts @@ -1,3 +1,4 @@ +import { vi } from 'vitest' import { TestBed } from '@angular/core/testing' import { App } from './app' @@ -18,6 +19,31 @@ describe('App', () => { const fixture = TestBed.createComponent(App) await fixture.whenStable() const compiled = fixture.nativeElement as HTMLElement - expect(compiled.querySelector('h1')?.textContent).toContain('Hello, basic') + expect(compiled.querySelector('h1')?.textContent).toContain( + 'TanStack Pacer injectBatcher Example', + ) + }) +}) + +// Exercise the real adapter and component with a deterministic clock. +describe('example behavior', () => { + beforeEach(async () => { + await TestBed.configureTestingModule({ imports: [App] }).compileComponents() + }) + afterEach(() => { + TestBed.resetTestingModule() + vi.clearAllTimers() + vi.useRealTimers() + vi.restoreAllMocks() + }) + it('processes five collected numbers as one batch', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }) + const fixture = TestBed.createComponent(App) + const app = fixture.componentInstance + fixture.detectChanges() + TestBed.tick() + for (let index = 0; index < 5; index++) app.add() + expect(app.processedBatches()).toEqual([[1, 2, 3, 4, 5]]) + expect(app.pendingItems()).toEqual([]) }) }) diff --git a/examples/angular/injectBatcher/src/app/app.ts b/examples/angular/injectBatcher/src/app/app.ts index d325c6027..5029dd9ac 100644 --- a/examples/angular/injectBatcher/src/app/app.ts +++ b/examples/angular/injectBatcher/src/app/app.ts @@ -1,61 +1,29 @@ import { Component, signal } from '@angular/core' -import { RouterOutlet } from '@angular/router' +import { JsonPipe } from '@angular/common' import { injectBatcher } from '@tanstack/angular-pacer' @Component({ selector: 'app-root', - imports: [RouterOutlet], templateUrl: './app.html', + imports: [JsonPipe], }) export class App { - protected readonly input = signal('') - protected readonly queuedCount = signal(0) - protected readonly processedCount = signal(0) - protected readonly lastBatch = signal>([]) - - protected readonly batcher = injectBatcher< - string, + readonly processedBatches = signal>>([]) + readonly pendingItems = signal>([]) + readonly runner = injectBatcher( + (items: Array) => { + this.processedBatches.update((batches) => [...batches, items]) + }, { - items: Array - isPending: boolean - executionCount: number - status: string - } - >( - (items) => { - this.lastBatch.set(items) - this.processedCount.update((c) => c + items.length) + maxSize: 5, + wait: 3000, + getShouldExecute: (items) => items.includes(42), + onItemsChange: (batcher) => this.pendingItems.set(batcher.peekAllItems()), }, - { maxSize: 5, wait: 1000 }, - (state) => ({ - items: state.items, - isPending: state.isPending, - executionCount: state.executionCount, - status: state.status, - }), + (state) => state, ) - - protected onInput(value: string): void { - this.input.set(value) - } - - protected add(): void { - const value = this.input().trim() - if (!value) return - this.batcher.addItem(value) - this.queuedCount.update((c) => c + 1) - this.input.set('') - } - - protected flush(): void { - this.batcher.flush() - } - - protected reset(): void { - this.input.set('') - this.queuedCount.set(0) - this.processedCount.set(0) - this.lastBatch.set([]) - this.batcher.reset() + add(): void { + const items = this.pendingItems() + this.runner.addItem(items.length ? items[items.length - 1]! + 1 : 1) } } diff --git a/examples/angular/injectDebouncedCallback/src/app/app.html b/examples/angular/injectDebouncedCallback/src/app/app.html index a22ff221b..f45a4edc8 100644 --- a/examples/angular/injectDebouncedCallback/src/app/app.html +++ b/examples/angular/injectDebouncedCallback/src/app/app.html @@ -1,11 +1,40 @@ -

injectDebouncedCallback

- - - - - -

Instant: {{ query() }}

-

Debounced: {{ debouncedQuery() }}

+

TanStack Pacer injectDebouncedCallback Example

+
+

Counter

+ +

Instant Count: {{ instantCount() }}

+

Debounced Count: {{ controlledCount() }}

+
+
+

Search

+

Enabled when the search contains more than 2 characters.

+ +

Instant Search: {{ search() }}

+

Debounced Search: {{ controlledSearch() }}

+
+
+

Range

+ +

Current Value: {{ currentValue() }}

+ +

Debounced Value: {{ controlledValue() }}

+

Debounced with a 250 ms wait.

+
diff --git a/examples/angular/injectDebouncedCallback/src/app/app.spec.ts b/examples/angular/injectDebouncedCallback/src/app/app.spec.ts index f9baf7f75..453b68938 100644 --- a/examples/angular/injectDebouncedCallback/src/app/app.spec.ts +++ b/examples/angular/injectDebouncedCallback/src/app/app.spec.ts @@ -1,3 +1,4 @@ +import { vi } from 'vitest' import { TestBed } from '@angular/core/testing' import { App } from './app' @@ -18,6 +19,54 @@ describe('App', () => { const fixture = TestBed.createComponent(App) await fixture.whenStable() const compiled = fixture.nativeElement as HTMLElement - expect(compiled.querySelector('h1')?.textContent).toContain('Hello, basic') + expect(compiled.querySelector('h1')?.textContent).toContain( + 'TanStack Pacer injectDebouncedCallback Example', + ) + }) +}) + +// Exercise the real adapter and component with a deterministic clock. +describe('example behavior', () => { + beforeEach(async () => { + await TestBed.configureTestingModule({ imports: [App] }).compileComponents() + }) + afterEach(() => { + TestBed.resetTestingModule() + vi.clearAllTimers() + vi.useRealTimers() + vi.restoreAllMocks() + }) + it('runs the counter, search, and range scenarios', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }) + const fixture = TestBed.createComponent(App) + const app = fixture.componentInstance + fixture.detectChanges() + TestBed.tick() + app.increment() + app.increment() + app.increment() + app.onSearch('angular') + app.onRange(73) + TestBed.tick() + await vi.advanceTimersByTimeAsync(1100) + TestBed.tick() + expect(app.controlledCount()).toBe(3) + expect(app.controlledSearch()).toBe('angular') + expect(app.controlledValue()).toBe(73) + }) + it('gates short search text and accepts the same event that passes the condition', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }) + const fixture = TestBed.createComponent(App) + const app = fixture.componentInstance + fixture.detectChanges() + TestBed.tick() + app.onSearch('ab') + TestBed.tick() + await vi.advanceTimersByTimeAsync(1100) + expect(app.controlledSearch()).toBe('') + app.onSearch('abc') + TestBed.tick() + await vi.advanceTimersByTimeAsync(1100) + expect(app.controlledSearch()).toBe('abc') }) }) diff --git a/examples/angular/injectDebouncedCallback/src/app/app.ts b/examples/angular/injectDebouncedCallback/src/app/app.ts index a4a782bf9..800dfdb7f 100644 --- a/examples/angular/injectDebouncedCallback/src/app/app.ts +++ b/examples/angular/injectDebouncedCallback/src/app/app.ts @@ -1,29 +1,42 @@ import { Component, signal } from '@angular/core' import { injectDebouncedCallback } from '@tanstack/angular-pacer' -@Component({ - selector: 'app-root', - templateUrl: './app.html', - styleUrl: './app.css', -}) +@Component({ selector: 'app-root', templateUrl: './app.html', imports: [] }) export class App { - protected readonly query = signal('') - protected readonly debouncedQuery = signal('') - - protected readonly onQueryInput = injectDebouncedCallback( - (value: string) => { - this.debouncedQuery.set(value) + readonly instantCount = signal(0) + readonly search = signal('') + readonly currentValue = signal(50) + readonly controlledCount = signal(0) + readonly controlledSearch = signal('') + readonly controlledValue = signal(50) + readonly countRunner = injectDebouncedCallback( + (value: number) => this.controlledCount.set(value), + () => ({ wait: 500 }), + ) + readonly searchRunner = injectDebouncedCallback( + (value: string) => this.controlledSearch.set(value), + () => { + // Update options when the signal changes so disabling cancels pending work. + // The callback reads the current event value before the next effect runs. + this.search() + return { wait: 500, enabled: () => this.search().length > 2 } }, - { wait: 500 }, ) - - protected onInput(value: string): void { - this.query.set(value) - this.onQueryInput(value) + readonly rangeRunner = injectDebouncedCallback( + (value: number) => this.controlledValue.set(value), + () => ({ wait: 250 }), + ) + increment(): void { + const next = this.instantCount() + 1 + this.instantCount.set(next) + this.countRunner(next) } - - protected clear(): void { - this.query.set('') - this.debouncedQuery.set('') + onSearch(value: string): void { + this.search.set(value) + this.searchRunner(value) + } + onRange(value: number): void { + this.currentValue.set(value) + this.rangeRunner(value) } } diff --git a/examples/angular/injectDebouncedSignal/src/app/app.html b/examples/angular/injectDebouncedSignal/src/app/app.html index a9b2fc8ed..316e28336 100644 --- a/examples/angular/injectDebouncedSignal/src/app/app.html +++ b/examples/angular/injectDebouncedSignal/src/app/app.html @@ -1,22 +1,55 @@ -

TanStack Pacer - injectDebouncedSignal

- -

Type below to update an instant signal and a debounced signal.

- - - -
- - -
- -

Values

-
-
Instant: {{ instantValue() }}
-
Debounced: {{ debounced() }}
-
Pending: {{ debounced.debouncer.state().isPending }}
-
- - +

TanStack Pacer injectDebouncedSignal Example

+
+

Counter

+ +

Instant Count: {{ instantCount() }}

+

Debounced Count: {{ controlledCount() }}

+

Executions: {{ countRunner.state().executionCount }}

+

Pending: {{ countRunner.state().isPending }}

+
{{ countRunner.state() | json }}
+
+
+

Search

+

Enabled when the search contains more than 2 characters.

+ +

Instant Search: {{ search() }}

+

Debounced Search: {{ controlledSearch() }}

+

Executions: {{ searchRunner.state().executionCount }}

+

Pending: {{ searchRunner.state().isPending }}

+
{{ searchRunner.state() | json }}
+
+
+

Range

+ +

Current Value: {{ currentValue() }}

+ +

Debounced Value: {{ controlledValue() }}

+

Executions: {{ rangeRunner.state().executionCount }}

+

Pending: {{ rangeRunner.state().isPending }}

+

Instant Executions: {{ instantExecutions() }}

+

+ Saved Executions: {{ instantExecutions() - + rangeRunner.state().executionCount }} +

+

Reduction: {{ reduction() }}%

+
{{ rangeRunner.state() | json }}
+

Debounced with a 250 ms wait.

+
diff --git a/examples/angular/injectDebouncedSignal/src/app/app.spec.ts b/examples/angular/injectDebouncedSignal/src/app/app.spec.ts index f9baf7f75..f7d4ba1eb 100644 --- a/examples/angular/injectDebouncedSignal/src/app/app.spec.ts +++ b/examples/angular/injectDebouncedSignal/src/app/app.spec.ts @@ -1,3 +1,4 @@ +import { vi } from 'vitest' import { TestBed } from '@angular/core/testing' import { App } from './app' @@ -18,6 +19,71 @@ describe('App', () => { const fixture = TestBed.createComponent(App) await fixture.whenStable() const compiled = fixture.nativeElement as HTMLElement - expect(compiled.querySelector('h1')?.textContent).toContain('Hello, basic') + expect(compiled.querySelector('h1')?.textContent).toContain( + 'TanStack Pacer injectDebouncedSignal Example', + ) + }) +}) + +// Exercise the real adapter and component with a deterministic clock. +describe('example behavior', () => { + beforeEach(async () => { + await TestBed.configureTestingModule({ imports: [App] }).compileComponents() + }) + afterEach(() => { + TestBed.resetTestingModule() + vi.clearAllTimers() + vi.useRealTimers() + vi.restoreAllMocks() + }) + it('runs the counter, search, and range scenarios', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }) + const fixture = TestBed.createComponent(App) + const app = fixture.componentInstance + fixture.detectChanges() + TestBed.tick() + app.increment() + app.increment() + app.increment() + app.onSearch('angular') + app.onRange(73) + TestBed.tick() + await vi.advanceTimersByTimeAsync(1100) + TestBed.tick() + expect(app.controlledCount()).toBe(3) + expect(app.controlledSearch()).toBe('angular') + expect(app.controlledValue()).toBe(73) + }) + it('gates short search text and accepts the same event that passes the condition', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }) + const fixture = TestBed.createComponent(App) + const app = fixture.componentInstance + fixture.detectChanges() + TestBed.tick() + app.onSearch('ab') + TestBed.tick() + await vi.advanceTimersByTimeAsync(1100) + expect(app.controlledSearch()).toBe('') + app.onSearch('abc') + TestBed.tick() + await vi.advanceTimersByTimeAsync(1100) + expect(app.controlledSearch()).toBe('abc') + }) + it('cancels pending search work when the condition becomes false', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }) + const fixture = TestBed.createComponent(App) + const app = fixture.componentInstance + fixture.detectChanges() + TestBed.tick() + app.onSearch('abc') + TestBed.tick() + app.onSearch('abcd') + TestBed.tick() + expect(app.searchRunner.state().isPending).toBe(true) + app.onSearch('ab') + TestBed.tick() + expect(app.searchRunner.state().isPending).toBe(false) + await vi.advanceTimersByTimeAsync(1100) + expect(app.searchRunner.state().isPending).toBe(false) }) }) diff --git a/examples/angular/injectDebouncedSignal/src/app/app.ts b/examples/angular/injectDebouncedSignal/src/app/app.ts index c595781fb..ea755afc1 100644 --- a/examples/angular/injectDebouncedSignal/src/app/app.ts +++ b/examples/angular/injectDebouncedSignal/src/app/app.ts @@ -1,36 +1,60 @@ import { Component, signal } from '@angular/core' -import { RouterOutlet } from '@angular/router' +import { JsonPipe } from '@angular/common' import { injectDebouncedSignal } from '@tanstack/angular-pacer' @Component({ selector: 'app-root', - imports: [RouterOutlet], templateUrl: './app.html', - styleUrl: './app.css', + imports: [JsonPipe], }) export class App { - protected readonly instantValue = signal('') - - protected readonly debounced = injectDebouncedSignal('', { wait: 500 }, (state) => ({ - isPending: state.isPending, - })) - - protected onInput(event: Event): void { - const target = event.target as HTMLInputElement - const value = target.value - - this.instantValue.set(value) - this.debounced.set(value) + readonly instantCount = signal(0) + readonly search = signal('') + readonly currentValue = signal(50) + readonly instantExecutions = signal(0) + readonly controlledCount = injectDebouncedSignal( + 0, + () => ({ wait: 500 }), + (state) => state, + ) + readonly countRunner = this.controlledCount.debouncer + readonly controlledSearch = injectDebouncedSignal( + '', + () => { + // Update options when the signal changes so disabling cancels pending work. + // The callback reads the current event value before the next effect runs. + this.search() + return { wait: 500, enabled: () => this.search().length > 2 } + }, + (state) => state, + ) + readonly searchRunner = this.controlledSearch.debouncer + readonly controlledValue = injectDebouncedSignal( + 50, + () => ({ wait: 250 }), + (state) => state, + ) + readonly rangeRunner = this.controlledValue.debouncer + increment(): void { + const next = this.instantCount() + 1 + this.instantCount.set(next) + this.controlledCount.set(next) } - - protected clear(): void { - this.instantValue.set('') - this.debounced.debouncer.cancel() - this.debounced.set('') - this.debounced.debouncer.flush() + onSearch(value: string): void { + this.search.set(value) + this.controlledSearch.set(value) } - - protected cancelPending(): void { - this.debounced.debouncer.cancel() + onRange(value: number): void { + this.currentValue.set(value) + this.instantExecutions.update((count) => count + 1) + this.controlledValue.set(value) + } + reduction(): number { + const count = this.instantExecutions() + return count + ? Math.round( + ((count - this.rangeRunner.state().executionCount) / count) * 100, + ) + : 0 } } diff --git a/examples/angular/injectDebouncedValue/src/app/app.html b/examples/angular/injectDebouncedValue/src/app/app.html index beba04079..6af679f88 100644 --- a/examples/angular/injectDebouncedValue/src/app/app.html +++ b/examples/angular/injectDebouncedValue/src/app/app.html @@ -1,15 +1,57 @@ -

injectDebouncedValue

- - - - - - -

Instant: {{ query() }}

-

Debounced: {{ debouncedQuery() }}

-

Pending: {{ debouncer.state().isPending | json }}

- - +

TanStack Pacer injectDebouncedValue Example

+
+

Counter

+ +

Instant Count: {{ instantCount() }}

+

Debounced Count: {{ controlledCount() }}

+

Executions: {{ countRunner.state().executionCount }}

+

Pending: {{ countRunner.state().isPending }}

+
{{ countRunner.state() | json }}
+
+
+

Search

+

Enabled when the search contains more than 2 characters.

+ +

Instant Search: {{ search() }}

+

Debounced Search: {{ controlledSearch() }}

+

Executions: {{ searchRunner.state().executionCount }}

+

Pending: {{ searchRunner.state().isPending }}

+
{{ searchRunner.state() | json }}
+
+
+

Range

+ +

Current Value: {{ currentValue() }}

+ +

Debounced Value: {{ controlledValue() }}

+

Executions: {{ rangeRunner.state().executionCount }}

+

Pending: {{ rangeRunner.state().isPending }}

+

Values Submitted: {{ instantExecutions() }}

+

+ Saved Executions: {{ instantExecutions() - + rangeRunner.state().executionCount }} +

+

Reduction: {{ reduction() }}%

+
{{ rangeRunner.state() | json }}
+

Debounced with a 250 ms wait.

+
+
+ diff --git a/examples/angular/injectDebouncedValue/src/app/app.spec.ts b/examples/angular/injectDebouncedValue/src/app/app.spec.ts index f9baf7f75..2d4402313 100644 --- a/examples/angular/injectDebouncedValue/src/app/app.spec.ts +++ b/examples/angular/injectDebouncedValue/src/app/app.spec.ts @@ -1,3 +1,4 @@ +import { vi } from 'vitest' import { TestBed } from '@angular/core/testing' import { App } from './app' @@ -18,6 +19,83 @@ describe('App', () => { const fixture = TestBed.createComponent(App) await fixture.whenStable() const compiled = fixture.nativeElement as HTMLElement - expect(compiled.querySelector('h1')?.textContent).toContain('Hello, basic') + expect(compiled.querySelector('h1')?.textContent).toContain( + 'TanStack Pacer injectDebouncedValue Example', + ) + }) +}) + +// Exercise the real adapter and component with a deterministic clock. +describe('example behavior', () => { + beforeEach(async () => { + await TestBed.configureTestingModule({ imports: [App] }).compileComponents() + }) + afterEach(() => { + TestBed.resetTestingModule() + vi.clearAllTimers() + vi.useRealTimers() + vi.restoreAllMocks() + }) + it('runs the counter, search, and range scenarios', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }) + const fixture = TestBed.createComponent(App) + const app = fixture.componentInstance + fixture.detectChanges() + TestBed.tick() + app.increment() + app.increment() + app.increment() + app.onSearch('angular') + app.onRange(73) + TestBed.tick() + await vi.advanceTimersByTimeAsync(1100) + TestBed.tick() + expect(app.controlledCount()).toBe(3) + expect(app.controlledSearch()).toBe('angular') + expect(app.controlledValue()).toBe(73) + }) + it('gates short search text and accepts the same event that passes the condition', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }) + const fixture = TestBed.createComponent(App) + const app = fixture.componentInstance + fixture.detectChanges() + TestBed.tick() + app.onSearch('ab') + TestBed.tick() + await vi.advanceTimersByTimeAsync(1100) + expect(app.controlledSearch()).toBe('') + app.onSearch('abc') + TestBed.tick() + await vi.advanceTimersByTimeAsync(1100) + expect(app.controlledSearch()).toBe('abc') + }) + it('counts the initial value when displaying saved executions', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }) + const fixture = TestBed.createComponent(App) + const app = fixture.componentInstance + fixture.detectChanges() + TestBed.tick() + await vi.advanceTimersByTimeAsync(1100) + expect( + app.instantExecutions() - app.rangeRunner.state().executionCount, + ).toBe(0) + expect(app.reduction()).toBe(0) + }) + it('cancels pending search work when the condition becomes false', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }) + const fixture = TestBed.createComponent(App) + const app = fixture.componentInstance + fixture.detectChanges() + TestBed.tick() + app.onSearch('abc') + TestBed.tick() + app.onSearch('abcd') + TestBed.tick() + expect(app.searchRunner.state().isPending).toBe(true) + app.onSearch('ab') + TestBed.tick() + expect(app.searchRunner.state().isPending).toBe(false) + await vi.advanceTimersByTimeAsync(1100) + expect(app.searchRunner.state().isPending).toBe(false) }) }) diff --git a/examples/angular/injectDebouncedValue/src/app/app.ts b/examples/angular/injectDebouncedValue/src/app/app.ts index 0abc2deaa..06791a735 100644 --- a/examples/angular/injectDebouncedValue/src/app/app.ts +++ b/examples/angular/injectDebouncedValue/src/app/app.ts @@ -1,30 +1,61 @@ import { Component, signal } from '@angular/core' -import { injectDebouncedValue } from '@tanstack/angular-pacer' import { JsonPipe } from '@angular/common' +import { injectDebouncedValue } from '@tanstack/angular-pacer' import { InputApp } from './inputapp' @Component({ selector: 'app-root', templateUrl: './app.html', - styleUrl: './app.css', - imports: [InputApp, JsonPipe], + imports: [JsonPipe, InputApp], }) export class App { - protected readonly query = signal('') - - private readonly debounced = injectDebouncedValue(this.query, { wait: 500 }, (state) => ({ - isPending: state.isPending, - })) - - protected readonly debouncedQuery = this.debounced - protected readonly debouncer = this.debounced.debouncer - - protected onInput(value: string): void { - this.query.set(value) + readonly instantCount = signal(0) + readonly search = signal('') + readonly currentValue = signal(50) + readonly instantExecutions = signal(1) + readonly controlledCount = injectDebouncedValue( + this.instantCount, + 0, + () => ({ wait: 500 }), + (state) => state, + ) + readonly countRunner = this.controlledCount.debouncer + readonly controlledSearch = injectDebouncedValue( + this.search, + '', + () => { + // Update options when the signal changes so disabling cancels pending work. + // The callback reads the current event value before the next effect runs. + this.search() + return { wait: 500, enabled: () => this.search().length > 2 } + }, + (state) => state, + ) + readonly searchRunner = this.controlledSearch.debouncer + readonly controlledValue = injectDebouncedValue( + this.currentValue, + 50, + () => ({ wait: 250 }), + (state) => state, + ) + readonly rangeRunner = this.controlledValue.debouncer + increment(): void { + const next = this.instantCount() + 1 + this.instantCount.set(next) } - - protected clear(): void { - this.query.set('') - this.debouncer.cancel() + onSearch(value: string): void { + this.search.set(value) + } + onRange(value: number): void { + this.currentValue.set(value) + this.instantExecutions.update((count) => count + 1) + } + reduction(): number { + const count = this.instantExecutions() + return count + ? Math.round( + ((count - this.rangeRunner.state().executionCount) / count) * 100, + ) + : 0 } } diff --git a/examples/angular/injectDebouncedValue/src/app/inputapp.ts b/examples/angular/injectDebouncedValue/src/app/inputapp.ts index 066e77da7..6539989a9 100644 --- a/examples/angular/injectDebouncedValue/src/app/inputapp.ts +++ b/examples/angular/injectDebouncedValue/src/app/inputapp.ts @@ -5,7 +5,7 @@ import { injectDebouncedValue } from '@tanstack/angular-pacer' selector: 'app-input', standalone: true, template: ` -

NG0950

+

Required input

value: {{ value() }}
debounced (no initial): {{ debouncedWithoutInitial() }}
debounced (with initial): {{ debouncedWithInitial() }}
@@ -14,11 +14,14 @@ import { injectDebouncedValue } from '@tanstack/angular-pacer' export class InputApp { readonly value = input.required() - // Example of NG0950 - // with an input signal during field initialization can run before inputs are ready. + // Required inputs are unavailable during field initialization. // This signature avoids reading the input eagerly, so the initial value is undefined. - readonly debouncedWithoutInitial = injectDebouncedValue(this.value, { wait: 500 }) + readonly debouncedWithoutInitial = injectDebouncedValue(this.value, { + wait: 500, + }) // Provide an initial value to avoid an undefined first read. - readonly debouncedWithInitial = injectDebouncedValue(this.value, '', { wait: 500 }) + readonly debouncedWithInitial = injectDebouncedValue(this.value, '', { + wait: 500, + }) } diff --git a/examples/angular/injectDebouncer/src/app/app.html b/examples/angular/injectDebouncer/src/app/app.html index 7866ffa03..8d0a979eb 100644 --- a/examples/angular/injectDebouncer/src/app/app.html +++ b/examples/angular/injectDebouncer/src/app/app.html @@ -1,56 +1,83 @@ -

TanStack Pacer - injectDebouncer (Angular)

- -

Type to trigger immediate updates and a debounced execution.

- -
- Reactive options -
- - - -
- - - -
- -

State

- -
query: {{ query() }}
-
immediateCount: {{ immediateCount() }}
- -
debouncedValue: {{ debouncedValue() }}
-
debouncedCount: {{ debouncedCount() }}
- -
debouncer.status: {{ debouncer.state().status }}
-
debouncer.isPending: {{ debouncer.state().isPending }}
-
debouncer.executionCount: {{ debouncer.state().executionCount }}
- - + max="100" + [value]="currentValue()" + (input)="onRange($any($event.target).valueAsNumber)" + /> +

Current Value: {{ currentValue() }}

+ +

Debounced Value: {{ controlledValue() }}

+

Executions: {{ rangeRunner.state().executionCount }}

+

Pending: {{ rangeRunner.state().isPending }}

+

Instant Executions: {{ instantExecutions() }}

+

+ Saved Executions: {{ instantExecutions() - + rangeRunner.state().executionCount }} +

+

Reduction: {{ reduction() }}%

+ +
{{ rangeRunner.state() | json }}
+

Debounced with a {{ delay() }} ms wait.

+ diff --git a/examples/angular/injectDebouncer/src/app/app.spec.ts b/examples/angular/injectDebouncer/src/app/app.spec.ts index f9baf7f75..3c5933605 100644 --- a/examples/angular/injectDebouncer/src/app/app.spec.ts +++ b/examples/angular/injectDebouncer/src/app/app.spec.ts @@ -1,3 +1,4 @@ +import { vi } from 'vitest' import { TestBed } from '@angular/core/testing' import { App } from './app' @@ -18,6 +19,95 @@ describe('App', () => { const fixture = TestBed.createComponent(App) await fixture.whenStable() const compiled = fixture.nativeElement as HTMLElement - expect(compiled.querySelector('h1')?.textContent).toContain('Hello, basic') + expect(compiled.querySelector('h1')?.textContent).toContain( + 'TanStack Pacer injectDebouncer Example', + ) + }) +}) + +// Exercise the real adapter and component with a deterministic clock. +describe('example behavior', () => { + beforeEach(async () => { + await TestBed.configureTestingModule({ imports: [App] }).compileComponents() + }) + afterEach(() => { + TestBed.resetTestingModule() + vi.clearAllTimers() + vi.useRealTimers() + vi.restoreAllMocks() + }) + it('runs the counter, search, and range scenarios', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }) + const fixture = TestBed.createComponent(App) + const app = fixture.componentInstance + fixture.detectChanges() + TestBed.tick() + app.increment() + app.increment() + app.increment() + app.onSearch('angular') + app.onRange(73) + TestBed.tick() + await vi.advanceTimersByTimeAsync(1100) + TestBed.tick() + expect(app.controlledCount()).toBe(3) + expect(app.controlledSearch()).toBe('angular') + expect(app.controlledValue()).toBe(73) + }) + it('gates short search text and accepts the same event that passes the condition', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }) + const fixture = TestBed.createComponent(App) + const app = fixture.componentInstance + fixture.detectChanges() + TestBed.tick() + app.onSearch('ab') + TestBed.tick() + await vi.advanceTimersByTimeAsync(1100) + expect(app.controlledSearch()).toBe('') + app.onSearch('abc') + TestBed.tick() + await vi.advanceTimersByTimeAsync(1100) + expect(app.controlledSearch()).toBe('abc') + }) + it('updates delay and cancels pending work when disabled', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }) + const fixture = TestBed.createComponent(App) + const app = fixture.componentInstance + fixture.detectChanges() + TestBed.tick() + const runner = app.rangeRunner + app.onRange(61) + expect(runner.state().isPending).toBe(true) + app.enabled.set(false) + TestBed.tick() + await vi.advanceTimersByTimeAsync(300) + expect(app.controlledValue()).toBe(50) + expect(runner.state().isPending).toBe(false) + app.delay.set(50) + app.enabled.set(true) + TestBed.tick() + app.onRange(82) + await vi.advanceTimersByTimeAsync(49) + expect(app.controlledValue()).toBe(50) + await vi.advanceTimersByTimeAsync(1) + expect(app.controlledValue()).toBe(82) + expect(app.rangeRunner).toBe(runner) + }) + it('cancels pending search work when the condition becomes false', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }) + const fixture = TestBed.createComponent(App) + const app = fixture.componentInstance + fixture.detectChanges() + TestBed.tick() + app.onSearch('abc') + TestBed.tick() + app.onSearch('abcd') + TestBed.tick() + expect(app.searchRunner.state().isPending).toBe(true) + app.onSearch('ab') + TestBed.tick() + expect(app.searchRunner.state().isPending).toBe(false) + await vi.advanceTimersByTimeAsync(1100) + expect(app.searchRunner.state().isPending).toBe(false) }) }) diff --git a/examples/angular/injectDebouncer/src/app/app.ts b/examples/angular/injectDebouncer/src/app/app.ts index fbc25bf65..d62e1a3aa 100644 --- a/examples/angular/injectDebouncer/src/app/app.ts +++ b/examples/angular/injectDebouncer/src/app/app.ts @@ -1,55 +1,67 @@ import { Component, signal } from '@angular/core' -import { RouterOutlet } from '@angular/router' +import { JsonPipe } from '@angular/common' import { injectDebouncer } from '@tanstack/angular-pacer' @Component({ selector: 'app-root', - imports: [RouterOutlet], templateUrl: './app.html', + imports: [JsonPipe], }) export class App { - protected readonly query = signal('') - protected readonly immediateCount = signal(0) - protected readonly debouncedCount = signal(0) - protected readonly debouncedValue = signal('') - protected readonly wait = signal(400) - protected readonly enabled = signal(true) - - protected readonly debouncer = injectDebouncer< - (value: string) => void, - { isPending: boolean; executionCount: number; status: string } - >( - (value: string) => { - this.debouncedValue.set(value) - this.debouncedCount.update((c) => c + 1) + readonly instantCount = signal(0) + readonly search = signal('') + readonly currentValue = signal(50) + readonly instantExecutions = signal(0) + readonly controlledCount = signal(0) + readonly controlledSearch = signal('') + readonly controlledValue = signal(50) + readonly delay = signal(250) + readonly enabled = signal(true) + readonly countRunner = injectDebouncer( + (value: number) => this.controlledCount.set(value), + () => { + // Update options when the signal changes so disabling cancels pending work. + // The callback reads the current event value before the next effect runs. + this.instantCount() + return { wait: 800, enabled: () => this.instantCount() > 2 } }, - () => ({ wait: this.wait(), enabled: this.enabled() }), - (state) => ({ - isPending: state.isPending, - executionCount: state.executionCount, - status: state.status, - }), + (state) => state, ) - - protected onQueryInput(value: string): void { - this.query.set(value) - this.immediateCount.update((c) => c + 1) - this.debouncer.maybeExecute(value) + readonly searchRunner = injectDebouncer( + (value: string) => this.controlledSearch.set(value), + () => { + // Update options when the signal changes so disabling cancels pending work. + // The callback reads the current event value before the next effect runs. + this.search() + return { wait: 500, enabled: () => this.search().length > 2 } + }, + (state) => state, + ) + readonly rangeRunner = injectDebouncer( + (value: number) => this.controlledValue.set(value), + () => ({ wait: this.delay(), enabled: this.enabled() }), + (state) => state, + ) + increment(): void { + const next = this.instantCount() + 1 + this.instantCount.set(next) + this.countRunner.maybeExecute(next) } - - protected flush(): void { - this.debouncer.flush() + onSearch(value: string): void { + this.search.set(value) + this.searchRunner.maybeExecute(value) } - - protected cancel(): void { - this.debouncer.cancel() + onRange(value: number): void { + this.currentValue.set(value) + this.instantExecutions.update((count) => count + 1) + this.rangeRunner.maybeExecute(value) } - - protected reset(): void { - this.query.set('') - this.immediateCount.set(0) - this.debouncedCount.set(0) - this.debouncedValue.set('') - this.debouncer.reset() + reduction(): number { + const count = this.instantExecutions() + return count + ? Math.round( + ((count - this.rangeRunner.state().executionCount) / count) * 100, + ) + : 0 } } diff --git a/examples/angular/injectQueuedSignal/src/app/app.html b/examples/angular/injectQueuedSignal/src/app/app.html index a89e31fa7..97ed9a284 100644 --- a/examples/angular/injectQueuedSignal/src/app/app.html +++ b/examples/angular/injectQueuedSignal/src/app/app.html @@ -1,23 +1,83 @@ -

injectQueuedSignal

- -

Next: {{ next() }}

-

Last processed: {{ lastProcessed() }}

- - - - - - -

Queued items: {{ items().length }}

-
    - @for (item of items(); track $index) { -
  • {{ item }}
  • - } -
- -

Processed: {{ processed().length }}

-
    - @for (item of processed(); track $index) { -
  • {{ item }}
  • - } -
+

TanStack Pacer injectQueuedSignal Example

+
+

Number queue

+ +

Queue Size: {{ numberQueue.state().size }}

+

Queue Full: {{ numberQueue.state().isFull }}

+

Queue Empty: {{ numberQueue.state().isEmpty }}

+

Queue Idle: {{ numberQueue.state().isIdle }}

+

Status: {{ numberQueue.state().status }}

+

Items Processed: {{ numberQueue.state().executionCount }}

+

Queue Items: {{ queued().join(", ") }}

+

Queue Peek: {{ numberQueue.peekNextItem() ?? "None" }}

+ + + + + +
{{ numberQueue.state() | json }}
+
+
+

Queued range

+ +

Instant Value: {{ currentValue() }}

+ +

Queued Value: {{ rangeValue() }}

+

Values Submitted: {{ instantExecutions() }}

+

Pending Items: {{ rangeQueue.state().size }}

+

Queue Full: {{ rangeQueue.state().isFull }}

+

Queue Empty: {{ rangeQueue.state().isEmpty }}

+

Queue Idle: {{ rangeQueue.state().isIdle }}

+

Status: {{ rangeQueue.state().status }}

+

Items Processed: {{ rangeQueue.state().executionCount }}

+

Queue Items: {{ rangeQueued().join(", ") }}

+

Queue Peek: {{ rangeQueue.peekNextItem() ?? "None" }}

+
{{ rangeQueue.state() | json }}
+
diff --git a/examples/angular/injectQueuedSignal/src/app/app.spec.ts b/examples/angular/injectQueuedSignal/src/app/app.spec.ts index f9baf7f75..0d2a04636 100644 --- a/examples/angular/injectQueuedSignal/src/app/app.spec.ts +++ b/examples/angular/injectQueuedSignal/src/app/app.spec.ts @@ -1,3 +1,4 @@ +import { vi } from 'vitest' import { TestBed } from '@angular/core/testing' import { App } from './app' @@ -18,6 +19,37 @@ describe('App', () => { const fixture = TestBed.createComponent(App) await fixture.whenStable() const compiled = fixture.nativeElement as HTMLElement - expect(compiled.querySelector('h1')?.textContent).toContain('Hello, basic') + expect(compiled.querySelector('h1')?.textContent).toContain( + 'TanStack Pacer injectQueuedSignal Example', + ) + }) +}) + +// Exercise the real adapter and component with a deterministic clock. +describe('example behavior', () => { + beforeEach(async () => { + await TestBed.configureTestingModule({ imports: [App] }).compileComponents() + }) + afterEach(() => { + TestBed.resetTestingModule() + vi.clearAllTimers() + vi.useRealTimers() + vi.restoreAllMocks() + }) + it('processes a stopped preloaded queue and displays pending items', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }) + const fixture = TestBed.createComponent(App) + const app = fixture.componentInstance + fixture.detectChanges() + TestBed.tick() + expect(app.numberQueue.state().items).toEqual([ + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, + ]) + expect(app.numberQueue.state().isRunning).toBe(false) + app.numberQueue.execute() + expect(app.numberQueue.state().executionCount).toBe(1) + expect(app.numberQueue.state().items).toEqual([2, 3, 4, 5, 6, 7, 8, 9, 10]) + app.numberQueue.clear() + expect(app.numberQueue.state().isEmpty).toBe(true) }) }) diff --git a/examples/angular/injectQueuedSignal/src/app/app.ts b/examples/angular/injectQueuedSignal/src/app/app.ts index 90271a23c..966b8f98b 100644 --- a/examples/angular/injectQueuedSignal/src/app/app.ts +++ b/examples/angular/injectQueuedSignal/src/app/app.ts @@ -1,45 +1,41 @@ import { Component, signal } from '@angular/core' +import { JsonPipe } from '@angular/common' import { injectQueuedSignal } from '@tanstack/angular-pacer' @Component({ selector: 'app-root', templateUrl: './app.html', - styleUrl: './app.css', + imports: [JsonPipe], }) export class App { - protected readonly next = signal(1) - protected readonly lastProcessed = signal(null) - protected readonly processed = signal>([]) - - private readonly queued = injectQueuedSignal( - (item) => { - this.lastProcessed.set(item) - this.processed.update((prev) => [...prev, item]) - }, + readonly currentValue = signal(50) + readonly rangeValue = signal(50) + readonly instantExecutions = signal(0) + readonly processed = signal>([]) + readonly queued = injectQueuedSignal( + (item: number) => this.processed.update((items) => [...items, item]), { - wait: 500, - started: true, + maxSize: 25, + initialItems: Array.from({ length: 10 }, (_, index) => index + 1), + started: false, + wait: 1000, }, + (state) => state, ) - - protected readonly items = this.queued - private readonly addItem = this.queued.addItem - protected readonly queue = this.queued.queuer - - protected enqueue(): void { - const value = this.next() - this.next.set(value + 1) - this.addItem(value) - } - - protected enqueueFive(): void { - for (let i = 0; i < 5; i++) { - this.enqueue() - } + readonly numberQueue = this.queued.queuer + readonly rangeQueued = injectQueuedSignal( + (item: number) => this.rangeValue.set(item), + { maxSize: 100, wait: 100 }, + (state) => state, + ) + readonly rangeQueue = this.rangeQueued.queuer + addNumber(): void { + const items = this.numberQueue.peekAllItems() + this.numberQueue.addItem(items.length ? items[items.length - 1]! + 1 : 1) } - - protected clearProcessed(): void { - this.lastProcessed.set(null) - this.processed.set([]) + onRange(value: number): void { + this.currentValue.set(value) + this.instantExecutions.update((count) => count + 1) + this.rangeQueue.addItem(value) } } diff --git a/examples/angular/injectQueuedValue/src/app/app.html b/examples/angular/injectQueuedValue/src/app/app.html index b445f9c8f..766e0d7ea 100644 --- a/examples/angular/injectQueuedValue/src/app/app.html +++ b/examples/angular/injectQueuedValue/src/app/app.html @@ -1,12 +1,87 @@ -

injectQueuedValue

- - - -

Source (instant): {{ source() }}

-

Value (queued): {{ queued() }}

-

Queue length: {{ queued.queuer.state().items.length }}

- - - - +

TanStack Pacer injectQueuedValue Example

+
+

Queued search value

+ +

Current Value: {{ queued() }}

+

Queue Size: {{ queuer.state().size }}

+

Queue Full: {{ queuer.state().isFull }}

+

Queue Empty: {{ queuer.state().isEmpty }}

+

Queue Idle: {{ queuer.state().isIdle }}

+

Status: {{ queuer.state().status }}

+

Items Processed: {{ queuer.state().executionCount }}

+

Queue Items: {{ queuer.state().items.join(", ") }}

+

Queue Peek: {{ queuer.peekNextItem() ?? "None" }}

+ + + + + +
{{ queuer.state() | json }}
+
+
+

Queued range

+ +

Instant Value: {{ currentValue() }}

+ +

Queued Value: {{ rangeQueued() }}

+

Values Submitted: {{ instantExecutions() }}

+

Pending Items: {{ rangeQueue.state().size }}

+

Queue Full: {{ rangeQueue.state().isFull }}

+

Queue Empty: {{ rangeQueue.state().isEmpty }}

+

Queue Idle: {{ rangeQueue.state().isIdle }}

+

Status: {{ rangeQueue.state().status }}

+

Items Processed: {{ rangeQueue.state().executionCount }}

+

Queue Items: {{ rangeQueue.state().items.join(", ") }}

+

Queue Peek: {{ rangeQueue.peekNextItem() ?? "None" }}

+
{{ rangeQueue.state() | json }}
+
+
diff --git a/examples/angular/injectQueuedValue/src/app/app.spec.ts b/examples/angular/injectQueuedValue/src/app/app.spec.ts index f9baf7f75..556385a67 100644 --- a/examples/angular/injectQueuedValue/src/app/app.spec.ts +++ b/examples/angular/injectQueuedValue/src/app/app.spec.ts @@ -1,3 +1,4 @@ +import { vi } from 'vitest' import { TestBed } from '@angular/core/testing' import { App } from './app' @@ -18,6 +19,37 @@ describe('App', () => { const fixture = TestBed.createComponent(App) await fixture.whenStable() const compiled = fixture.nativeElement as HTMLElement - expect(compiled.querySelector('h1')?.textContent).toContain('Hello, basic') + expect(compiled.querySelector('h1')?.textContent).toContain( + 'TanStack Pacer injectQueuedValue Example', + ) + }) +}) + +// Exercise the real adapter and component with a deterministic clock. +describe('example behavior', () => { + beforeEach(async () => { + await TestBed.configureTestingModule({ imports: [App] }).compileComponents() + }) + afterEach(() => { + TestBed.resetTestingModule() + vi.clearAllTimers() + vi.useRealTimers() + vi.restoreAllMocks() + }) + it('publishes a processed scalar and keeps pending values in queue state', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }) + const fixture = TestBed.createComponent(App) + const app = fixture.componentInstance + fixture.detectChanges() + TestBed.tick() + await vi.advanceTimersByTimeAsync(1000) + app.queuer.stop() + app.source.set('angular') + TestBed.tick() + expect(app.queued()).toBe('') + expect(app.queuer.state().items).toEqual(['angular']) + app.queuer.execute() + expect(app.queued()).toBe('angular') + expect(app.queuer.state().items).toEqual([]) }) }) diff --git a/examples/angular/injectQueuedValue/src/app/app.ts b/examples/angular/injectQueuedValue/src/app/app.ts index 81280f403..9bc042a4a 100644 --- a/examples/angular/injectQueuedValue/src/app/app.ts +++ b/examples/angular/injectQueuedValue/src/app/app.ts @@ -1,35 +1,33 @@ import { Component, signal } from '@angular/core' +import { JsonPipe } from '@angular/common' import { injectQueuedValue } from '@tanstack/angular-pacer' import { InputApp } from './inputapp' @Component({ selector: 'app-root', templateUrl: './app.html', - styleUrl: './app.css', - imports: [InputApp], + imports: [JsonPipe, InputApp], }) export class App { - protected readonly source = signal('') - - // A queued value: changes are applied in-order, with an optional delay between items. - // `queued()` is the processed value; `queued.queuer.state().items` contains pending values. - protected readonly queued = injectQueuedValue(this.source, { wait: 500 }, (state) => ({ - items: state.items, - })) - - protected onInput(value: string): void { - // Updating `source` automatically enqueues the latest value (via injectQueuedValue's internal effect) - this.source.set(value) - } - - protected enqueueRandom(): void { - // You can also enqueue values directly without touching `source` - this.queued.addItem(Math.random().toFixed(4)) - } - - protected clear(): void { - this.source.set('') - // If you only want the queued output to change (without changing source), you could do: - // this.queued.addItem(''); + readonly source = signal('') + readonly currentValue = signal(50) + readonly instantExecutions = signal(1) + readonly queued = injectQueuedValue( + this.source, + '', + { maxSize: 25, wait: 500 }, + (state) => state, + ) + readonly queuer = this.queued.queuer + readonly rangeQueued = injectQueuedValue( + this.currentValue, + 50, + { maxSize: 100, wait: 100 }, + (state) => state, + ) + readonly rangeQueue = this.rangeQueued.queuer + onRange(value: number): void { + this.currentValue.set(value) + this.instantExecutions.update((count) => count + 1) } } diff --git a/examples/angular/injectQueuedValue/src/app/inputapp.ts b/examples/angular/injectQueuedValue/src/app/inputapp.ts index d17b52bcb..3c05a7e35 100644 --- a/examples/angular/injectQueuedValue/src/app/inputapp.ts +++ b/examples/angular/injectQueuedValue/src/app/inputapp.ts @@ -15,12 +15,16 @@ import { injectQueuedValue } from '@tanstack/angular-pacer' export class InputApp { readonly value = input.required() - protected readonly queued = injectQueuedValue(this.value, { wait: 500 }, (state) => ({ - items: state.items, - })) + protected readonly queued = injectQueuedValue( + this.value, + { wait: 500 }, + (state) => ({ + items: state.items, + }), + ) protected enqueueRandom(): void { - // You can also enqueue values directly without touching `source` + // You can also enqueue values directly without touching `value` this.queued.addItem(Math.random().toFixed(4)) } } diff --git a/examples/angular/injectQueuer/src/app/app.html b/examples/angular/injectQueuer/src/app/app.html index a02d55aa0..a36369ed4 100644 --- a/examples/angular/injectQueuer/src/app/app.html +++ b/examples/angular/injectQueuer/src/app/app.html @@ -1,18 +1,97 @@ -

injectQueuer (Angular)

- -

Items are processed one by one with a small delay.

- - - - - - - - -

queue size: {{ queue.state().size }}

-

items: {{ queue.state().items.join(', ') }}

-

isRunning: {{ queue.state().isRunning }}

-

processedCount: {{ processedCount() }}

-

lastProcessed: {{ lastProcessed() }}

- - +

TanStack Pacer injectQueuer Example

+
+

Number queue

+ +

Queue Size: {{ numberQueue.state().size }}

+

Queue Full: {{ numberQueue.state().isFull }}

+

Queue Empty: {{ numberQueue.state().isEmpty }}

+

Queue Idle: {{ numberQueue.state().isIdle }}

+

Status: {{ numberQueue.state().status }}

+

Items Processed: {{ numberQueue.state().executionCount }}

+

Queue Items: {{ numberQueue.state().items.join(", ") }}

+

Queue Peek: {{ numberQueue.peekNextItem() ?? "None" }}

+ + + + + + +
{{ numberQueue.state() | json }}
+
+
+

Queued range

+ +

Instant Value: {{ currentValue() }}

+ +

Queued Value: {{ rangeValue() }}

+

Values Submitted: {{ instantExecutions() }}

+

Pending Items: {{ rangeQueue.state().size }}

+

Queue Full: {{ rangeQueue.state().isFull }}

+

Queue Empty: {{ rangeQueue.state().isEmpty }}

+

Queue Idle: {{ rangeQueue.state().isIdle }}

+

Status: {{ rangeQueue.state().status }}

+

Items Processed: {{ rangeQueue.state().executionCount }}

+

Queue Items: {{ rangeQueue.state().items.join(", ") }}

+

Queue Peek: {{ rangeQueue.peekNextItem() ?? "None" }}

+ +
{{ rangeQueue.state() | json }}
+
diff --git a/examples/angular/injectQueuer/src/app/app.spec.ts b/examples/angular/injectQueuer/src/app/app.spec.ts index f9baf7f75..f12757cc4 100644 --- a/examples/angular/injectQueuer/src/app/app.spec.ts +++ b/examples/angular/injectQueuer/src/app/app.spec.ts @@ -1,3 +1,4 @@ +import { vi } from 'vitest' import { TestBed } from '@angular/core/testing' import { App } from './app' @@ -18,6 +19,37 @@ describe('App', () => { const fixture = TestBed.createComponent(App) await fixture.whenStable() const compiled = fixture.nativeElement as HTMLElement - expect(compiled.querySelector('h1')?.textContent).toContain('Hello, basic') + expect(compiled.querySelector('h1')?.textContent).toContain( + 'TanStack Pacer injectQueuer Example', + ) + }) +}) + +// Exercise the real adapter and component with a deterministic clock. +describe('example behavior', () => { + beforeEach(async () => { + await TestBed.configureTestingModule({ imports: [App] }).compileComponents() + }) + afterEach(() => { + TestBed.resetTestingModule() + vi.clearAllTimers() + vi.useRealTimers() + vi.restoreAllMocks() + }) + it('processes a stopped preloaded queue and displays pending items', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }) + const fixture = TestBed.createComponent(App) + const app = fixture.componentInstance + fixture.detectChanges() + TestBed.tick() + expect(app.numberQueue.state().items).toEqual([ + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, + ]) + expect(app.numberQueue.state().isRunning).toBe(false) + app.numberQueue.execute() + expect(app.numberQueue.state().executionCount).toBe(1) + expect(app.numberQueue.state().items).toEqual([2, 3, 4, 5, 6, 7, 8, 9, 10]) + app.numberQueue.clear() + expect(app.numberQueue.state().isEmpty).toBe(true) }) }) diff --git a/examples/angular/injectQueuer/src/app/app.ts b/examples/angular/injectQueuer/src/app/app.ts index b3d5c7c47..70a5797f8 100644 --- a/examples/angular/injectQueuer/src/app/app.ts +++ b/examples/angular/injectQueuer/src/app/app.ts @@ -1,60 +1,39 @@ import { Component, signal } from '@angular/core' -import { RouterOutlet } from '@angular/router' +import { JsonPipe } from '@angular/common' import { injectQueuer } from '@tanstack/angular-pacer' @Component({ selector: 'app-root', - imports: [RouterOutlet], templateUrl: './app.html', + imports: [JsonPipe], }) export class App { - protected readonly input = signal('') - protected readonly processedCount = signal(0) - protected readonly lastProcessed = signal('') - - protected readonly queue = injectQueuer< - string, - { items: Array; size: number; isRunning: boolean } - >( - (item) => { - this.lastProcessed.set(item) - this.processedCount.update((c) => c + 1) - }, + readonly currentValue = signal(50) + readonly rangeValue = signal(50) + readonly instantExecutions = signal(1) + readonly processed = signal>([]) + readonly numberQueue = injectQueuer( + (item: number) => this.processed.update((items) => [...items, item]), { + maxSize: 25, + initialItems: Array.from({ length: 10 }, (_, index) => index + 1), started: false, - wait: 500, + wait: 1000, }, - (state) => ({ - items: state.items, - size: state.size, - isRunning: state.isRunning, - }), + (state) => state, ) - - protected onInput(value: string): void { - this.input.set(value) - } - - protected add(): void { - const value = this.input().trim() - if (!value) return - this.queue.addItem(value) - this.input.set('') - } - - protected start(): void { - this.queue.start() - } - - protected stop(): void { - this.queue.stop() + readonly rangeQueue = injectQueuer( + (item: number) => this.rangeValue.set(item), + { maxSize: 100, wait: 100, initialItems: [50] }, + (state) => state, + ) + addNumber(): void { + const items = this.numberQueue.peekAllItems() + this.numberQueue.addItem(items.length ? items[items.length - 1]! + 1 : 1) } - - protected reset(): void { - this.input.set('') - this.processedCount.set(0) - this.lastProcessed.set('') - this.queue.reset() - this.queue.stop() + onRange(value: number): void { + this.currentValue.set(value) + this.instantExecutions.update((count) => count + 1) + this.rangeQueue.addItem(value) } } diff --git a/examples/angular/injectQueuerWithPersister/README.md b/examples/angular/injectQueuerWithPersister/README.md deleted file mode 100644 index bd58f6985..000000000 --- a/examples/angular/injectQueuerWithPersister/README.md +++ /dev/null @@ -1,59 +0,0 @@ -# Basic - -This project was generated using [Angular CLI](https://github.com/angular/angular-cli) version 21.0.5. - -## Development server - -To start a local development server, run: - -```bash -ng serve -``` - -Once the server is running, open your browser and navigate to `http://localhost:4200/`. The application will automatically reload whenever you modify any of the source files. - -## Code scaffolding - -Angular CLI includes powerful code scaffolding tools. To generate a new component, run: - -```bash -ng generate component component-name -``` - -For a complete list of available schematics (such as `components`, `directives`, or `pipes`), run: - -```bash -ng generate --help -``` - -## Building - -To build the project run: - -```bash -ng build -``` - -This will compile your project and store the build artifacts in the `dist/` directory. By default, the production build optimizes your application for performance and speed. - -## Running unit tests - -To execute unit tests with the [Vitest](https://vitest.dev/) test runner, use the following command: - -```bash -ng test -``` - -## Running end-to-end tests - -For end-to-end (e2e) testing, run: - -```bash -ng e2e -``` - -Angular CLI does not come with an end-to-end testing framework by default. You can choose one that suits your needs. - -## Additional Resources - -For more information on using the Angular CLI, including detailed command references, visit the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page. diff --git a/examples/angular/injectQueuerWithPersister/angular.json b/examples/angular/injectQueuerWithPersister/angular.json deleted file mode 100644 index 06ffc24ef..000000000 --- a/examples/angular/injectQueuerWithPersister/angular.json +++ /dev/null @@ -1,72 +0,0 @@ -{ - "$schema": "./node_modules/@angular/cli/lib/config/schema.json", - "version": 1, - "cli": { - "packageManager": "npm", - "analytics": false - }, - "newProjectRoot": "projects", - "projects": { - "basic": { - "projectType": "application", - "schematics": {}, - "root": "", - "sourceRoot": "src", - "prefix": "app", - "architect": { - "build": { - "builder": "@angular/build:application", - "options": { - "browser": "src/main.ts", - "tsConfig": "tsconfig.app.json", - "assets": [ - { - "glob": "**/*", - "input": "public" - } - ], - "styles": ["src/styles.css"] - }, - "configurations": { - "production": { - "budgets": [ - { - "type": "initial", - "maximumWarning": "500kB", - "maximumError": "1MB" - }, - { - "type": "anyComponentStyle", - "maximumWarning": "4kB", - "maximumError": "8kB" - } - ], - "outputHashing": "all" - }, - "development": { - "optimization": false, - "extractLicenses": false, - "sourceMap": true - } - }, - "defaultConfiguration": "production" - }, - "serve": { - "builder": "@angular/build:dev-server", - "configurations": { - "production": { - "buildTarget": "basic:build:production" - }, - "development": { - "buildTarget": "basic:build:development" - } - }, - "defaultConfiguration": "development" - }, - "test": { - "builder": "@angular/build:unit-test" - } - } - } - } -} diff --git a/examples/angular/injectQueuerWithPersister/eslint.config.js b/examples/angular/injectQueuerWithPersister/eslint.config.js deleted file mode 100644 index 4d9c151cf..000000000 --- a/examples/angular/injectQueuerWithPersister/eslint.config.js +++ /dev/null @@ -1,15 +0,0 @@ -// @ts-check - -import rootConfig from '../../../eslint.config.js' - -/** @type {import('eslint').Linter.Config[]} */ -export default [ - ...rootConfig, - { - languageOptions: { - parserOptions: { - project: './tsconfig.app.json', - }, - }, - }, -] diff --git a/examples/angular/injectQueuerWithPersister/package.json b/examples/angular/injectQueuerWithPersister/package.json deleted file mode 100644 index 0aa4dd0f9..000000000 --- a/examples/angular/injectQueuerWithPersister/package.json +++ /dev/null @@ -1,43 +0,0 @@ -{ - "name": "@tanstack/pacer-example-angular-inject-queuer-with-persister", - "scripts": { - "ng": "ng", - "start": "ng serve", - "build": "ng build", - "watch": "ng build --watch --configuration development", - "test": "ng test" - }, - "prettier": { - "printWidth": 100, - "singleQuote": true, - "overrides": [ - { - "files": "*.html", - "options": { - "parser": "angular" - } - } - ] - }, - "private": true, - "packageManager": "pnpm@12.6.0", - "dependencies": { - "@angular/common": "^22.2.0", - "@angular/compiler": "^22.2.0", - "@angular/core": "^22.2.0", - "@angular/forms": "^22.2.0", - "@angular/platform-browser": "^22.2.0", - "@angular/router": "^22.2.0", - "@tanstack/angular-pacer": "^0.25.0", - "rxjs": "~7.8.2", - "tslib": "^2.8.1" - }, - "devDependencies": { - "@angular/build": "^22.2.0", - "@angular/cli": "^22.2.0", - "@angular/compiler-cli": "^22.2.0", - "jsdom": "^30.1.1", - "typescript": "6.0.3", - "vitest": "^5.0.1" - } -} diff --git a/examples/angular/injectQueuerWithPersister/public/favicon.ico b/examples/angular/injectQueuerWithPersister/public/favicon.ico deleted file mode 100644 index 57614f9c967596fad0a3989bec2b1deff33034f6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 15086 zcmd^G33O9Omi+`8$@{|M-I6TH3wzF-p5CV8o}7f~KxR60LK+ApEFB<$bcciv%@SmA zV{n>g85YMFFeU*Uvl=i4v)C*qgnb;$GQ=3XTe9{Y%c`mO%su)noNCCQ*@t1WXn|B(hQ7i~ zrUK8|pUkD6#lNo!bt$6)jR!&C?`P5G(`e((P($RaLeq+o0Vd~f11;qB05kdbAOm?r zXv~GYr_sibQO9NGTCdT;+G(!{4Xs@4fPak8#L8PjgJwcs-Mm#nR_Z0s&u?nDX5^~@ z+A6?}g0|=4e_LoE69pPFO`yCD@BCjgKpzMH0O4Xs{Ahc?K3HC5;l=f zg>}alhBXX&);z$E-wai+9TTRtBX-bWYY@cl$@YN#gMd~tM_5lj6W%8ah4;uZ;jP@Q zVbuel1rPA?2@x9Y+u?e`l{Z4ngfG5q5BLH5QsEu4GVpt{KIp1?U)=3+KQ;%7ec8l* zdV=zZgN5>O3G(3L2fqj3;oBbZZw$Ij@`Juz@?+yy#OPw)>#wsTewVgTK9BGt5AbZ&?K&B3GVF&yu?@(Xj3fR3n+ZP0%+wo)D9_xp>Z$`A4 zfV>}NWjO#3lqumR0`gvnffd9Ka}JJMuHS&|55-*mCD#8e^anA<+sFZVaJe7{=p*oX zE_Uv?1>e~ga=seYzh{9P+n5<+7&9}&(kwqSaz;1aD|YM3HBiy<))4~QJSIryyqp| z8nGc(8>3(_nEI4n)n7j(&d4idW1tVLjZ7QbNLXg;LB ziHsS5pXHEjGJZb59KcvS~wv;uZR-+4qEqow`;JCfB*+b^UL^3!?;-^F%yt=VjU|v z39SSqKcRu_NVvz!zJzL0CceJaS6%!(eMshPv_0U5G`~!a#I$qI5Ic(>IONej@aH=f z)($TAT#1I{iCS4f{D2+ApS=$3E7}5=+y(rA9mM#;Cky%b*Gi0KfFA`ofKTzu`AV-9 znW|y@19rrZ*!N2AvDi<_ZeR3O2R{#dh1#3-d%$k${Rx42h+i&GZo5!C^dSL34*AKp z27mTd>k>?V&X;Nl%GZ(>0s`1UN~Hfyj>KPjtnc|)xM@{H_B9rNr~LuH`Gr5_am&Ep zTjZA8hljNj5H1Ipm-uD9rC}U{-vR!eay5&6x6FkfupdpT*84MVwGpdd(}ib)zZ3Ky z7C$pnjc82(W_y_F{PhYj?o!@3__UUvpX)v69aBSzYj3 zdi}YQkKs^SyXyFG2LTRz9{(w}y~!`{EuAaUr6G1M{*%c+kP1olW9z23dSH!G4_HSK zzae-DF$OGR{ofP*!$a(r^5Go>I3SObVI6FLY)N@o<*gl0&kLo-OT{Tl*7nCz>Iq=? zcigIDHtj|H;6sR?or8Wd_a4996GI*CXGU}o;D9`^FM!AT1pBY~?|4h^61BY#_yIfO zKO?E0 zJ{Pc`9rVEI&$xxXu`<5E)&+m(7zX^v0rqofLs&bnQT(1baQkAr^kEsk)15vlzAZ-l z@OO9RF<+IiJ*O@HE256gCt!bF=NM*vh|WVWmjVawcNoksRTMvR03H{p@cjwKh(CL4 z7_PB(dM=kO)!s4fW!1p0f93YN@?ZSG` z$B!JaAJCtW$B97}HNO9(x-t30&E}Mo1UPi@Av%uHj~?T|!4JLwV;KCx8xO#b9IlUW zI6+{a@Wj|<2Y=U;a@vXbxqZNngH8^}LleE_4*0&O7#3iGxfJ%Id>+sb;7{L=aIic8 z|EW|{{S)J-wr@;3PmlxRXU8!e2gm_%s|ReH!reFcY8%$Hl4M5>;6^UDUUae?kOy#h zk~6Ee_@ZAn48Bab__^bNmQ~+k=02jz)e0d9Z3>G?RGG!65?d1>9}7iG17?P*=GUV-#SbLRw)Hu{zx*azHxWkGNTWl@HeWjA?39Ia|sCi{e;!^`1Oec zb>Z|b65OM*;eC=ZLSy?_fg$&^2xI>qSLA2G*$nA3GEnp3$N-)46`|36m*sc#4%C|h zBN<2U;7k>&G_wL4=Ve5z`ubVD&*Hxi)r@{4RCDw7U_D`lbC(9&pG5C*z#W>8>HU)h z!h3g?2UL&sS!oY5$3?VlA0Me9W5e~V;2jds*fz^updz#AJ%G8w2V}AEE?E^=MK%Xt z__Bx1cr7+DQmuHmzn*|hh%~eEc9@m05@clWfpEFcr+06%0&dZJH&@8^&@*$qR@}o3 z@Tuuh2FsLz^zH+dN&T&?0G3I?MpmYJ;GP$J!EzjeM#YLJ!W$}MVNb0^HfOA>5Fe~UNn%Zk(PT@~9}1dt)1UQ zU*B5K?Dl#G74qmg|2>^>0WtLX#Jz{lO4NT`NYB*(L#D|5IpXr9v&7a@YsGp3vLR7L zHYGHZg7{ie6n~2p$6Yz>=^cEg7tEgk-1YRl%-s7^cbqFb(U7&Dp78+&ut5!Tn(hER z|Gp4Ed@CnOPeAe|N>U(dB;SZ?NU^AzoD^UAH_vamp6Ws}{|mSq`^+VP1g~2B{%N-!mWz<`)G)>V-<`9`L4?3dM%Qh6<@kba+m`JS{Ya@9Fq*m6$$ zA1%Ogc~VRH33|S9l%CNb4zM%k^EIpqY}@h{w(aBcJ9c05oiZx#SK9t->5lSI`=&l~ z+-Ic)a{FbBhXV$Xt!WRd`R#Jk-$+_Z52rS>?Vpt2IK<84|E-SBEoIw>cs=a{BlQ7O z-?{Fy_M&84&9|KM5wt~)*!~i~E=(6m8(uCO)I=)M?)&sRbzH$9Rovzd?ZEY}GqX+~ zFbEbLz`BZ49=2Yh-|<`waK-_4!7`ro@zlC|r&I4fc4oyb+m=|c8)8%tZ-z5FwhzDt zL5kB@u53`d@%nHl0Sp)Dw`(QU&>vujEn?GPEXUW!Wi<+4e%BORl&BIH+SwRcbS}X@ z01Pk|vA%OdJKAs17zSXtO55k!;%m9>1eW9LnyAX4uj7@${O6cfii`49qTNItzny5J zH&Gj`e}o}?xjQ}r?LrI%FjUd@xflT3|7LA|ka%Q3i}a8gVm<`HIWoJGH=$EGClX^C0lysQJ>UO(q&;`T#8txuoQ_{l^kEV9CAdXuU1Ghg8 zN_6hHFuy&1x24q5-(Z7;!poYdt*`UTdrQOIQ!2O7_+AHV2hgXaEz7)>$LEdG z<8vE^Tw$|YwZHZDPM!SNOAWG$?J)MdmEk{U!!$M#fp7*Wo}jJ$Q(=8>R`Ats?e|VU?Zt7Cdh%AdnfyN3MBWw{ z$OnREvPf7%z6`#2##_7id|H%Y{vV^vWXb?5d5?a_y&t3@p9t$ncHj-NBdo&X{wrfJ zamN)VMYROYh_SvjJ=Xd!Ga?PY_$;*L=SxFte!4O6%0HEh%iZ4=gvns7IWIyJHa|hT z2;1+e)`TvbNb3-0z&DD_)Jomsg-7p_Uh`wjGnU1urmv1_oVqRg#=C?e?!7DgtqojU zWoAB($&53;TsXu^@2;8M`#z{=rPy?JqgYM0CDf4v@z=ZD|ItJ&8%_7A#K?S{wjxgd z?xA6JdJojrWpB7fr2p_MSsU4(R7=XGS0+Eg#xR=j>`H@R9{XjwBmqAiOxOL` zt?XK-iTEOWV}f>Pz3H-s*>W z4~8C&Xq25UQ^xH6H9kY_RM1$ch+%YLF72AA7^b{~VNTG}Tj#qZltz5Q=qxR`&oIlW Nr__JTFzvMr^FKp4S3v*( diff --git a/examples/angular/injectQueuerWithPersister/src/app/app.config.ts b/examples/angular/injectQueuerWithPersister/src/app/app.config.ts deleted file mode 100644 index 46a6b4193..000000000 --- a/examples/angular/injectQueuerWithPersister/src/app/app.config.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core' -import { provideRouter } from '@angular/router' - -import { routes } from './app.routes' - -export const appConfig: ApplicationConfig = { - providers: [provideBrowserGlobalErrorListeners(), provideRouter(routes)], -} diff --git a/examples/angular/injectQueuerWithPersister/src/app/app.css b/examples/angular/injectQueuerWithPersister/src/app/app.css deleted file mode 100644 index 296e372b0..000000000 --- a/examples/angular/injectQueuerWithPersister/src/app/app.css +++ /dev/null @@ -1,19 +0,0 @@ -:host { - display: block; - padding: 2rem; - max-width: 600px; - margin: 0 auto; -} - -input { - width: 100%; - padding: 0.5rem; - font-size: 1rem; - border: 1px solid #ccc; - border-radius: 4px; -} - -p { - margin-top: 1rem; - color: #666; -} diff --git a/examples/angular/injectQueuerWithPersister/src/app/app.html b/examples/angular/injectQueuerWithPersister/src/app/app.html deleted file mode 100644 index e35faff59..000000000 --- a/examples/angular/injectQueuerWithPersister/src/app/app.html +++ /dev/null @@ -1,162 +0,0 @@ -
-

TanStack Pacer - Rate Limiter Example

- -
-

Rate Limiter Example

-

- Rate limiting enforces a hard limit: allows executions until the limit is reached (5 per 5 - seconds), then blocks all subsequent calls until the window resets. Click the increment button - rapidly to see the rate limiter in action. -

- -
-

Window Type

-
- - -
-

- Fixed Window: Strict window that resets completely after the period.
- Sliding Window: Rolling window that allows executions as old ones expire. -

-
- -
-

Rate Limiter Status

-
-
Execution Count: {{ rateLimiter.state().executionCount }}
-
Rejection Count: {{ rateLimiter.state().rejectionCount }}
-
- Remaining in Window: {{ rateLimiter.getRemainingInWindow() }} / 5 -
-
Ms Until Next Window: {{ rateLimiter.getMsUntilNextWindow() }}ms
-
-
- -
-

Counters

-
-
- Instant Count: -
- {{ instantCount() }} -
-
- Updates immediately on every click -
-
-
- Rate Limited Count: -
- {{ limitedCount() }} -
-
- Only updates when rate limit allows -
-
-
-
- -
-

Controls

-
- - -
-

- Click "Increment" rapidly to see the rate limiter block executions after 5 clicks within 5 - seconds. -

-
- -
-

Execution History

-
- @if (executionHistory.length === 0) { - No executions yet. Click "Increment" to start. - } @for (entry of executionHistory; track $index) { -
-
-
{{ entry.timestamp }} - Count: {{ entry.count }}
- - {{ entry.rejected ? 'REJECTED' : 'EXECUTED' }} - -
-
- } -
-
-
- - -
diff --git a/examples/angular/injectQueuerWithPersister/src/app/app.routes.ts b/examples/angular/injectQueuerWithPersister/src/app/app.routes.ts deleted file mode 100644 index 9a884b113..000000000 --- a/examples/angular/injectQueuerWithPersister/src/app/app.routes.ts +++ /dev/null @@ -1,3 +0,0 @@ -import type { Routes } from '@angular/router' - -export const routes: Routes = [] diff --git a/examples/angular/injectQueuerWithPersister/src/app/app.spec.ts b/examples/angular/injectQueuerWithPersister/src/app/app.spec.ts deleted file mode 100644 index f9baf7f75..000000000 --- a/examples/angular/injectQueuerWithPersister/src/app/app.spec.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { TestBed } from '@angular/core/testing' -import { App } from './app' - -describe('App', () => { - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [App], - }).compileComponents() - }) - - it('should create the app', () => { - const fixture = TestBed.createComponent(App) - const app = fixture.componentInstance - expect(app).toBeTruthy() - }) - - it('should render title', async () => { - const fixture = TestBed.createComponent(App) - await fixture.whenStable() - const compiled = fixture.nativeElement as HTMLElement - expect(compiled.querySelector('h1')?.textContent).toContain('Hello, basic') - }) -}) diff --git a/examples/angular/injectQueuerWithPersister/src/app/app.ts b/examples/angular/injectQueuerWithPersister/src/app/app.ts deleted file mode 100644 index b54b5da0c..000000000 --- a/examples/angular/injectQueuerWithPersister/src/app/app.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { Component, signal } from '@angular/core' -import { RouterOutlet } from '@angular/router' -import { injectRateLimiter } from '@tanstack/angular-pacer' - -@Component({ - selector: 'app-root', - imports: [RouterOutlet], - templateUrl: './app.html', - styleUrl: './app.css', -}) -export class App { - protected readonly windowType = signal<'fixed' | 'sliding'>('fixed') - protected readonly instantCount = signal(0) - protected readonly limitedCount = signal(0) - protected readonly executionHistory: Array<{ - timestamp: string - count: number - rejected: boolean - }> = [] - - // Rate limiter: allows 5 executions per 5 seconds - protected readonly rateLimiter = injectRateLimiter< - (count: number) => void, - { - executionCount: number - rejectionCount: number - executionTimes: Array - } - >( - (count: number) => { - console.log('Rate-limited execution:', count) - this.limitedCount.set(count) - this.executionHistory.push({ - timestamp: new Date().toLocaleTimeString(), - count, - rejected: false, - }) - }, - { - limit: 5, - window: 5000, // 5 seconds - windowType: this.windowType(), - onReject: () => { - console.log('Rejected by rate limiter', this.rateLimiter.getMsUntilNextWindow()) - this.executionHistory.push({ - timestamp: new Date().toLocaleTimeString(), - count: this.instantCount(), - rejected: true, - }) - }, - }, - (state) => ({ - executionCount: state.executionCount, - rejectionCount: state.rejectionCount, - executionTimes: state.executionTimes, - }), - ) - - protected increment(): void { - // Update instant count immediately - this.instantCount.update((c) => { - const newCount = c + 1 - // Try to execute with rate limiter - this.rateLimiter.maybeExecute(newCount) - return newCount - }) - } - - protected reset(): void { - this.rateLimiter.reset() - this.instantCount.set(0) - this.limitedCount.set(0) - this.executionHistory.length = 0 - } - - protected setWindowType(type: 'fixed' | 'sliding'): void { - this.windowType.set(type) - // Note: windowType change requires recreating the rate limiter in a real app - // For this example, we'll just update the signal - } -} diff --git a/examples/angular/injectQueuerWithPersister/src/index.html b/examples/angular/injectQueuerWithPersister/src/index.html deleted file mode 100644 index 1b3f817b9..000000000 --- a/examples/angular/injectQueuerWithPersister/src/index.html +++ /dev/null @@ -1,13 +0,0 @@ - - - - - Basic - - - - - - - - diff --git a/examples/angular/injectQueuerWithPersister/src/main.ts b/examples/angular/injectQueuerWithPersister/src/main.ts deleted file mode 100644 index 8192dca69..000000000 --- a/examples/angular/injectQueuerWithPersister/src/main.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { bootstrapApplication } from '@angular/platform-browser' -import { appConfig } from './app/app.config' -import { App } from './app/app' - -bootstrapApplication(App, appConfig).catch((err) => console.error(err)) diff --git a/examples/angular/injectQueuerWithPersister/src/styles.css b/examples/angular/injectQueuerWithPersister/src/styles.css deleted file mode 100644 index 90d4ee007..000000000 --- a/examples/angular/injectQueuerWithPersister/src/styles.css +++ /dev/null @@ -1 +0,0 @@ -/* You can add global styles to this file, and also import other style files */ diff --git a/examples/angular/injectQueuerWithPersister/tsconfig.app.json b/examples/angular/injectQueuerWithPersister/tsconfig.app.json deleted file mode 100644 index 90631899c..000000000 --- a/examples/angular/injectQueuerWithPersister/tsconfig.app.json +++ /dev/null @@ -1,11 +0,0 @@ -/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ -/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ -{ - "extends": "./tsconfig.json", - "compilerOptions": { - "outDir": "./out-tsc/app", - "types": [] - }, - "include": ["./src/**/*.ts"], - "exclude": ["./src/**/*.spec.ts"] -} diff --git a/examples/angular/injectQueuerWithPersister/tsconfig.json b/examples/angular/injectQueuerWithPersister/tsconfig.json deleted file mode 100644 index 2ab744275..000000000 --- a/examples/angular/injectQueuerWithPersister/tsconfig.json +++ /dev/null @@ -1,33 +0,0 @@ -/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ -/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ -{ - "compileOnSave": false, - "compilerOptions": { - "strict": true, - "noImplicitOverride": true, - "noPropertyAccessFromIndexSignature": true, - "noImplicitReturns": true, - "noFallthroughCasesInSwitch": true, - "skipLibCheck": true, - "isolatedModules": true, - "experimentalDecorators": true, - "importHelpers": true, - "target": "ES2022", - "module": "preserve" - }, - "angularCompilerOptions": { - "enableI18nLegacyMessageIdFormat": false, - "strictInjectionParameters": true, - "strictInputAccessModifiers": true, - "strictTemplates": true - }, - "files": [], - "references": [ - { - "path": "./tsconfig.app.json" - }, - { - "path": "./tsconfig.spec.json" - } - ] -} diff --git a/examples/angular/injectQueuerWithPersister/tsconfig.spec.json b/examples/angular/injectQueuerWithPersister/tsconfig.spec.json deleted file mode 100644 index 26230b0b3..000000000 --- a/examples/angular/injectQueuerWithPersister/tsconfig.spec.json +++ /dev/null @@ -1,10 +0,0 @@ -/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ -/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ -{ - "extends": "./tsconfig.json", - "compilerOptions": { - "outDir": "./out-tsc/spec", - "types": ["vitest/globals"] - }, - "include": ["src/**/*.d.ts", "src/**/*.spec.ts"] -} diff --git a/examples/angular/injectRateLimitedCallback/src/app/app.html b/examples/angular/injectRateLimitedCallback/src/app/app.html index de86f7eff..77931fac3 100644 --- a/examples/angular/injectRateLimitedCallback/src/app/app.html +++ b/examples/angular/injectRateLimitedCallback/src/app/app.html @@ -1,27 +1,95 @@ -
-

Tan Stack Pacer - createRateLimitedCallback Example

- -

- Click the trigger button quickly. The callback executes at most {{ limit }} times within {{ - windowMs / 1000 }} seconds. Extra calls inside the window are blocked. -

- -
-

Counts

-

Total triggers: {{ rawCount() }}

-

Executed callbacks: {{ executedCount() }}

-

Blocked callbacks: {{ blockedCount() }}

-
- -
-

Last execution

-

Value: {{ lastExecutedValue() !== null ? lastExecutedValue() : '(none yet)' }}

-

Time: {{ lastExecutedAt() !== null ? lastExecutedAt() : '(none yet)' }}

-
- -
-

Controls

- - -
-
+

TanStack Pacer injectRateLimitedCallback Example

+
+

Counter

+

Enabled once the instant count exceeds 2.

+
+ Window type + +
+ +

Instant Count: {{ instantCount() }}

+

Rate Limited Count: {{ controlledCount() }}

+
+
+

Search

+

Enabled when the search contains more than 2 characters.

+
+ Window type + +
+ +

Instant Search: {{ search() }}

+

Rate Limited Search: {{ controlledSearch() }}

+
+
+

Range

+
+ Window type + +
+ +

Current Value: {{ currentValue() }}

+ +

Rate Limited Value: {{ controlledValue() }}

+

Rate limited to 20 updates per 2 seconds.

+
diff --git a/examples/angular/injectRateLimitedCallback/src/app/app.spec.ts b/examples/angular/injectRateLimitedCallback/src/app/app.spec.ts index f9baf7f75..509f7e1d6 100644 --- a/examples/angular/injectRateLimitedCallback/src/app/app.spec.ts +++ b/examples/angular/injectRateLimitedCallback/src/app/app.spec.ts @@ -1,3 +1,4 @@ +import { vi } from 'vitest' import { TestBed } from '@angular/core/testing' import { App } from './app' @@ -18,6 +19,66 @@ describe('App', () => { const fixture = TestBed.createComponent(App) await fixture.whenStable() const compiled = fixture.nativeElement as HTMLElement - expect(compiled.querySelector('h1')?.textContent).toContain('Hello, basic') + expect(compiled.querySelector('h1')?.textContent).toContain( + 'TanStack Pacer injectRateLimitedCallback Example', + ) + }) +}) + +// Exercise the real adapter and component with a deterministic clock. +describe('example behavior', () => { + beforeEach(async () => { + await TestBed.configureTestingModule({ imports: [App] }).compileComponents() + }) + afterEach(() => { + TestBed.resetTestingModule() + vi.clearAllTimers() + vi.useRealTimers() + vi.restoreAllMocks() + }) + it('runs the counter, search, and range scenarios', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }) + const fixture = TestBed.createComponent(App) + const app = fixture.componentInstance + fixture.detectChanges() + TestBed.tick() + app.increment() + app.increment() + app.increment() + app.onSearch('angular') + app.onRange(73) + TestBed.tick() + await vi.advanceTimersByTimeAsync(1100) + TestBed.tick() + expect(app.controlledCount()).toBe(3) + expect(app.controlledSearch()).toBe('angular') + expect(app.controlledValue()).toBe(73) + }) + it('enables the third counter event without waiting for a render', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }) + const fixture = TestBed.createComponent(App) + const app = fixture.componentInstance + fixture.detectChanges() + TestBed.tick() + app.increment() + app.increment() + expect(app.controlledCount()).toBe(0) + app.increment() + expect(app.controlledCount()).toBe(3) + }) + it('gates short search text and accepts the same event that passes the condition', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }) + const fixture = TestBed.createComponent(App) + const app = fixture.componentInstance + fixture.detectChanges() + TestBed.tick() + app.onSearch('ab') + TestBed.tick() + await vi.advanceTimersByTimeAsync(1100) + expect(app.controlledSearch()).toBe('') + app.onSearch('abc') + TestBed.tick() + await vi.advanceTimersByTimeAsync(1100) + expect(app.controlledSearch()).toBe('abc') }) }) diff --git a/examples/angular/injectRateLimitedCallback/src/app/app.ts b/examples/angular/injectRateLimitedCallback/src/app/app.ts index f52e7dcb8..b0ace9af9 100644 --- a/examples/angular/injectRateLimitedCallback/src/app/app.ts +++ b/examples/angular/injectRateLimitedCallback/src/app/app.ts @@ -1,58 +1,78 @@ import { Component, signal } from '@angular/core' import { injectRateLimitedCallback } from '@tanstack/angular-pacer' -@Component({ - selector: 'app-root', - standalone: true, - templateUrl: './app.html', -}) +@Component({ selector: 'app-root', templateUrl: './app.html', imports: [] }) export class App { - private run: (value: number) => boolean - - readonly rawCount = signal(0) - readonly executedCount = signal(0) - readonly blockedCount = signal(0) - readonly lastExecutedValue = signal(null) - readonly lastExecutedAt = signal(null) - - readonly limit = 3 - readonly windowMs = 2000 - - constructor() { - this.run = this.createLimiter() - } - - private createLimiter() { - return injectRateLimitedCallback( - (value: number) => { - this.executedCount.update((current) => current + 1) - this.lastExecutedValue.set(value) - this.lastExecutedAt.set(new Date().toLocaleTimeString()) - }, - { - limit: this.limit, - window: this.windowMs, - windowType: 'sliding', - }, - ) + readonly instantCount = signal(0) + readonly search = signal('') + readonly currentValue = signal(50) + readonly controlledCount = signal(0) + readonly controlledSearch = signal('') + readonly controlledValue = signal(50) + readonly countWindow = signal<'fixed' | 'sliding'>('fixed') + readonly searchWindow = signal<'fixed' | 'sliding'>('fixed') + readonly rangeWindow = signal<'fixed' | 'sliding'>('fixed') + readonly countRunner = injectRateLimitedCallback( + (value: number) => this.controlledCount.set(value), + () => { + // Update options when the signal changes so disabling cancels pending work. + // The callback reads the current event value before the next effect runs. + this.instantCount() + return { + limit: 5, + window: 5000, + windowType: this.countWindow(), + onReject: (limiter) => + console.log( + 'Rejected; retry in', + limiter.getMsUntilNextWindow(), + 'ms', + ), + enabled: () => this.instantCount() > 2, + } + }, + ) + readonly searchRunner = injectRateLimitedCallback( + (value: string) => this.controlledSearch.set(value), + () => { + // Update options when the signal changes so disabling cancels pending work. + // The callback reads the current event value before the next effect runs. + this.search() + return { + limit: 5, + window: 5000, + windowType: this.searchWindow(), + onReject: (limiter) => + console.log( + 'Rejected; retry in', + limiter.getMsUntilNextWindow(), + 'ms', + ), + enabled: () => this.search().length > 2, + } + }, + ) + readonly rangeRunner = injectRateLimitedCallback( + (value: number) => this.controlledValue.set(value), + () => ({ + limit: 20, + window: 2000, + windowType: this.rangeWindow(), + onReject: (limiter) => + console.log('Rejected; retry in', limiter.getMsUntilNextWindow(), 'ms'), + }), + ) + increment(): void { + const next = this.instantCount() + 1 + this.instantCount.set(next) + this.countRunner(next) } - - trigger(): void { - const next = this.rawCount() + 1 - this.rawCount.set(next) - - const executed = this.run(next) - if (!executed) { - this.blockedCount.update((current) => current + 1) - } + onSearch(value: string): void { + this.search.set(value) + this.searchRunner(value) } - - reset(): void { - this.rawCount.set(0) - this.executedCount.set(0) - this.blockedCount.set(0) - this.lastExecutedValue.set(null) - this.lastExecutedAt.set(null) - this.run = this.createLimiter() + onRange(value: number): void { + this.currentValue.set(value) + this.rangeRunner(value) } } diff --git a/examples/angular/injectRateLimitedSignal/src/app/app.html b/examples/angular/injectRateLimitedSignal/src/app/app.html index 7a8989b10..91f19d738 100644 --- a/examples/angular/injectRateLimitedSignal/src/app/app.html +++ b/examples/angular/injectRateLimitedSignal/src/app/app.html @@ -1,27 +1,117 @@ -
-

TanStack Pacer - injectRateLimitedSignal Example

- +

TanStack Pacer injectRateLimitedSignal Example

+
+

Counter

+
+ Window type + +
+ +

Instant Count: {{ instantCount() }}

+

Rate Limited Count: {{ controlledCount() }}

+

Executions: {{ countRunner.state().executionCount }}

+

Rejections: {{ countRunner.state().rejectionCount }}

+

Remaining in Window: {{ countRunner.getRemainingInWindow() }}

+

Ms Until Next Window: {{ countRunner.getMsUntilNextWindow() }}

+ +
{{ countRunner.state() | json }}
+
+
+

Search

+
+ Window type + +
+ +

Instant Search: {{ search() }}

+

Rate Limited Search: {{ controlledSearch() }}

+

Executions: {{ searchRunner.state().executionCount }}

+

Rejections: {{ searchRunner.state().rejectionCount }}

+

Remaining in Window: {{ searchRunner.getRemainingInWindow() }}

+

Ms Until Next Window: {{ searchRunner.getMsUntilNextWindow() }}

+ +
{{ searchRunner.state() | json }}
+
+
+

Range

+
+ Window type + +
+ +

Current Value: {{ currentValue() }}

+ +

Rate Limited Value: {{ controlledValue() }}

+

Executions: {{ rangeRunner.state().executionCount }}

+

Rejections: {{ rangeRunner.state().rejectionCount }}

+

Remaining in Window: {{ rangeRunner.getRemainingInWindow() }}

+

Ms Until Next Window: {{ rangeRunner.getMsUntilNextWindow() }}

+

Instant Executions: {{ instantExecutions() }}

- Click the button quickly to see the rate-limited value update. Only three updates are allowed - within any 3 second window; additional updates are ignored until space frees up. + Saved Executions: {{ instantExecutions() - + rangeRunner.state().executionCount }}

- -
-

Counters

-

Raw value (updates every click): {{ rawValue() }}

-

Rate-limited value: {{ limitedValue() }}

-
- -
-

Rate limiter status

-

Remaining executions in window: {{ remainingInWindow }}

-

Total executions: {{ executionCount }}

-

Total rejections: {{ rejectionCount }}

-
- -
-

Controls

- - -
-
+

Reduction: {{ reduction() }}%

+ +
{{ rangeRunner.state() | json }}
+

Rate limited to 20 updates per 2 seconds.

+ diff --git a/examples/angular/injectRateLimitedSignal/src/app/app.spec.ts b/examples/angular/injectRateLimitedSignal/src/app/app.spec.ts index f9baf7f75..414198a25 100644 --- a/examples/angular/injectRateLimitedSignal/src/app/app.spec.ts +++ b/examples/angular/injectRateLimitedSignal/src/app/app.spec.ts @@ -1,3 +1,4 @@ +import { vi } from 'vitest' import { TestBed } from '@angular/core/testing' import { App } from './app' @@ -18,6 +19,51 @@ describe('App', () => { const fixture = TestBed.createComponent(App) await fixture.whenStable() const compiled = fixture.nativeElement as HTMLElement - expect(compiled.querySelector('h1')?.textContent).toContain('Hello, basic') + expect(compiled.querySelector('h1')?.textContent).toContain( + 'TanStack Pacer injectRateLimitedSignal Example', + ) + }) +}) + +// Exercise the real adapter and component with a deterministic clock. +describe('example behavior', () => { + beforeEach(async () => { + await TestBed.configureTestingModule({ imports: [App] }).compileComponents() + }) + afterEach(() => { + TestBed.resetTestingModule() + vi.clearAllTimers() + vi.useRealTimers() + vi.restoreAllMocks() + }) + it('runs the counter, search, and range scenarios', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }) + const fixture = TestBed.createComponent(App) + const app = fixture.componentInstance + fixture.detectChanges() + TestBed.tick() + app.increment() + app.increment() + app.increment() + app.onSearch('angular') + app.onRange(73) + TestBed.tick() + await vi.advanceTimersByTimeAsync(1100) + TestBed.tick() + expect(app.controlledCount()).toBe(3) + expect(app.controlledSearch()).toBe('angular') + expect(app.controlledValue()).toBe(73) + }) + it('changes the window type on the same limiter', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }) + const fixture = TestBed.createComponent(App) + const app = fixture.componentInstance + fixture.detectChanges() + TestBed.tick() + const runner = app.countRunner + app.countWindow.set('sliding') + TestBed.tick() + expect(app.countRunner).toBe(runner) + expect(runner.options.windowType).toBe('sliding') }) }) diff --git a/examples/angular/injectRateLimitedSignal/src/app/app.ts b/examples/angular/injectRateLimitedSignal/src/app/app.ts index 77ef7e336..740b5a61b 100644 --- a/examples/angular/injectRateLimitedSignal/src/app/app.ts +++ b/examples/angular/injectRateLimitedSignal/src/app/app.ts @@ -1,79 +1,76 @@ import { Component, signal } from '@angular/core' +import { JsonPipe } from '@angular/common' import { injectRateLimitedSignal } from '@tanstack/angular-pacer' -import type { RateLimitedSignal } from '@tanstack/angular-pacer' - -type SelectedState = { - executionCount: number - rejectionCount: number -} - -type RateLimitedSetter = (value: number | ((prev: number) => number)) => void - -type RateLimiterHandle = { - reset: () => void - state: () => Readonly - getRemainingInWindow: () => number -} @Component({ selector: 'app-root', - standalone: true, templateUrl: './app.html', + imports: [JsonPipe], }) export class App { - readonly rawValue = signal(0) - readonly limitedValue: RateLimitedSignal - private readonly setLimitedValue: RateLimitedSetter - readonly rateLimiter: RateLimiterHandle - - constructor() { - const limitedValue = injectRateLimitedSignal( - 0, - { - limit: 3, - window: 3000, - windowType: 'sliding', - }, - (state) => ({ - executionCount: state.executionCount, - rejectionCount: state.rejectionCount, - }), - ) - - this.limitedValue = limitedValue - this.setLimitedValue = limitedValue.set - - const rateLimiter = limitedValue.rateLimiter - this.rateLimiter = { - reset: rateLimiter.reset.bind(rateLimiter), - state: rateLimiter.state, - getRemainingInWindow: rateLimiter.getRemainingInWindow.bind(rateLimiter), - } - } - + readonly instantCount = signal(0) + readonly search = signal('') + readonly currentValue = signal(50) + readonly instantExecutions = signal(0) + readonly countWindow = signal<'fixed' | 'sliding'>('fixed') + readonly searchWindow = signal<'fixed' | 'sliding'>('fixed') + readonly rangeWindow = signal<'fixed' | 'sliding'>('fixed') + readonly controlledCount = injectRateLimitedSignal( + 0, + () => ({ + limit: 5, + window: 5000, + windowType: this.countWindow(), + onReject: (limiter) => + console.log('Rejected; retry in', limiter.getMsUntilNextWindow(), 'ms'), + }), + (state) => state, + ) + readonly countRunner = this.controlledCount.rateLimiter + readonly controlledSearch = injectRateLimitedSignal( + '', + () => ({ + limit: 5, + window: 5000, + windowType: this.searchWindow(), + onReject: (limiter) => + console.log('Rejected; retry in', limiter.getMsUntilNextWindow(), 'ms'), + }), + (state) => state, + ) + readonly searchRunner = this.controlledSearch.rateLimiter + readonly controlledValue = injectRateLimitedSignal( + 50, + () => ({ + limit: 20, + window: 2000, + windowType: this.rangeWindow(), + onReject: (limiter) => + console.log('Rejected; retry in', limiter.getMsUntilNextWindow(), 'ms'), + }), + (state) => state, + ) + readonly rangeRunner = this.controlledValue.rateLimiter increment(): void { - this.rawValue.update((current) => { - const next = current + 1 - this.setLimitedValue(next) - return next - }) + const next = this.instantCount() + 1 + this.instantCount.set(next) + this.controlledCount.set(next) } - - reset(): void { - this.rateLimiter.reset() - this.rawValue.set(0) - this.setLimitedValue(0) + onSearch(value: string): void { + this.search.set(value) + this.controlledSearch.set(value) } - - get executionCount(): number { - return this.rateLimiter.state().executionCount + onRange(value: number): void { + this.currentValue.set(value) + this.instantExecutions.update((count) => count + 1) + this.controlledValue.set(value) } - - get rejectionCount(): number { - return this.rateLimiter.state().rejectionCount - } - - get remainingInWindow(): number { - return this.rateLimiter.getRemainingInWindow() + reduction(): number { + const count = this.instantExecutions() + return count + ? Math.round( + ((count - this.rangeRunner.state().executionCount) / count) * 100, + ) + : 0 } } diff --git a/examples/angular/injectRateLimitedValue/src/app/app.html b/examples/angular/injectRateLimitedValue/src/app/app.html index 42571db69..af7a464c2 100644 --- a/examples/angular/injectRateLimitedValue/src/app/app.html +++ b/examples/angular/injectRateLimitedValue/src/app/app.html @@ -1,162 +1,114 @@ -
-

TanStack Pacer - Rate Limiter Example

- -
-

Rate Limiter Example

-

- Rate limiting enforces a hard limit: allows executions until the limit is reached (5 per 5 - seconds), then blocks all subsequent calls until the window resets. Click the increment button - rapidly to see the rate limiter in action. -

- -
-

Window Type

-
- - -
-

- Fixed Window: Strict window that resets completely after the period.
- Sliding Window: Rolling window that allows executions as old ones expire. -

-
- -
-

Rate Limiter Status

-
-
Execution Count: {{ rateLimiter.state().executionCount }}
-
Rejection Count: {{ rateLimiter.state().rejectionCount }}
-
- Remaining in Window: {{ rateLimiter.getRemainingInWindow() }} / 5 -
-
Ms Until Next Window: {{ rateLimiter.getMsUntilNextWindow() }}ms
-
-
- -
-

Counters

-
-
- Instant Count: -
- {{ instantCount() }} -
-
- Updates immediately on every click -
-
-
- Rate Limited Count: -
- {{ rateLimitedCount() }} -
-
- Only updates when rate limit allows -
-
-
-
- -
-

Controls

-
- - -
-

- Click "Increment" rapidly to see the rate limiter block executions after 5 clicks within 5 - seconds. -

-
- -
-

Execution History

-
- @if (executionHistory.length === 0) { - No executions yet. Click "Increment" to start. - } @for (entry of executionHistory; track $index) { -
-
-
{{ entry.timestamp }} - Count: {{ entry.count }}
- - {{ entry.rejected ? 'REJECTED' : 'EXECUTED' }} - -
-
- } -
-
-
- - -
+

TanStack Pacer injectRateLimitedValue Example

+
+

Counter

+
+ Window type + +
+ +

Instant Count: {{ instantCount() }}

+

Rate Limited Count: {{ controlledCount() }}

+

Executions: {{ countRunner.state().executionCount }}

+

Rejections: {{ countRunner.state().rejectionCount }}

+

Remaining in Window: {{ countRunner.getRemainingInWindow() }}

+

Ms Until Next Window: {{ countRunner.getMsUntilNextWindow() }}

+
{{ countRunner.state() | json }}
+
+
+

Search

+
+ Window type + +
+ +

Instant Search: {{ search() }}

+

Rate Limited Search: {{ controlledSearch() }}

+

Executions: {{ searchRunner.state().executionCount }}

+

Rejections: {{ searchRunner.state().rejectionCount }}

+

Remaining in Window: {{ searchRunner.getRemainingInWindow() }}

+

Ms Until Next Window: {{ searchRunner.getMsUntilNextWindow() }}

+
{{ searchRunner.state() | json }}
+
+
+

Range

+
+ Window type + +
+ +

Current Value: {{ currentValue() }}

+ +

Rate Limited Value: {{ controlledValue() }}

+

Executions: {{ rangeRunner.state().executionCount }}

+

Rejections: {{ rangeRunner.state().rejectionCount }}

+

Remaining in Window: {{ rangeRunner.getRemainingInWindow() }}

+

Ms Until Next Window: {{ rangeRunner.getMsUntilNextWindow() }}

+

Values Submitted: {{ instantExecutions() }}

+

+ Saved Executions: {{ instantExecutions() - + rangeRunner.state().executionCount }} +

+

Reduction: {{ reduction() }}%

+
{{ rangeRunner.state() | json }}
+

Rate limited to 20 updates per 2 seconds.

+
diff --git a/examples/angular/injectRateLimitedValue/src/app/app.spec.ts b/examples/angular/injectRateLimitedValue/src/app/app.spec.ts index f9baf7f75..e9d60f13c 100644 --- a/examples/angular/injectRateLimitedValue/src/app/app.spec.ts +++ b/examples/angular/injectRateLimitedValue/src/app/app.spec.ts @@ -1,3 +1,4 @@ +import { vi } from 'vitest' import { TestBed } from '@angular/core/testing' import { App } from './app' @@ -18,6 +19,63 @@ describe('App', () => { const fixture = TestBed.createComponent(App) await fixture.whenStable() const compiled = fixture.nativeElement as HTMLElement - expect(compiled.querySelector('h1')?.textContent).toContain('Hello, basic') + expect(compiled.querySelector('h1')?.textContent).toContain( + 'TanStack Pacer injectRateLimitedValue Example', + ) + }) +}) + +// Exercise the real adapter and component with a deterministic clock. +describe('example behavior', () => { + beforeEach(async () => { + await TestBed.configureTestingModule({ imports: [App] }).compileComponents() + }) + afterEach(() => { + TestBed.resetTestingModule() + vi.clearAllTimers() + vi.useRealTimers() + vi.restoreAllMocks() + }) + it('runs the counter, search, and range scenarios', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }) + const fixture = TestBed.createComponent(App) + const app = fixture.componentInstance + fixture.detectChanges() + TestBed.tick() + app.increment() + app.increment() + app.increment() + app.onSearch('angular') + app.onRange(73) + TestBed.tick() + await vi.advanceTimersByTimeAsync(1100) + TestBed.tick() + expect(app.controlledCount()).toBe(3) + expect(app.controlledSearch()).toBe('angular') + expect(app.controlledValue()).toBe(73) + }) + it('changes the window type on the same limiter', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }) + const fixture = TestBed.createComponent(App) + const app = fixture.componentInstance + fixture.detectChanges() + TestBed.tick() + const runner = app.countRunner + app.countWindow.set('sliding') + TestBed.tick() + expect(app.countRunner).toBe(runner) + expect(runner.options.windowType).toBe('sliding') + }) + it('counts the initial value when displaying saved executions', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }) + const fixture = TestBed.createComponent(App) + const app = fixture.componentInstance + fixture.detectChanges() + TestBed.tick() + await vi.advanceTimersByTimeAsync(1100) + expect( + app.instantExecutions() - app.rangeRunner.state().executionCount, + ).toBe(0) + expect(app.reduction()).toBe(0) }) }) diff --git a/examples/angular/injectRateLimitedValue/src/app/app.ts b/examples/angular/injectRateLimitedValue/src/app/app.ts index 7fbfe97f9..3c999350e 100644 --- a/examples/angular/injectRateLimitedValue/src/app/app.ts +++ b/examples/angular/injectRateLimitedValue/src/app/app.ts @@ -1,86 +1,76 @@ import { Component, signal } from '@angular/core' -import { RouterOutlet } from '@angular/router' +import { JsonPipe } from '@angular/common' import { injectRateLimitedValue } from '@tanstack/angular-pacer' @Component({ selector: 'app-root', - imports: [RouterOutlet], templateUrl: './app.html', - styleUrl: './app.css', + imports: [JsonPipe], }) export class App { - protected readonly windowType = signal<'fixed' | 'sliding'>('fixed') - - // This is the "source" value that changes immediately on every click - protected readonly instantCount = signal(0) - - // We'll display the rate-limited version of `instantCount` in the UI - protected readonly limitedCount = signal(0) - - protected readonly executionHistory: Array<{ - timestamp: string - count: number - rejected: boolean - }> = [] - - // Rate-limited value: allows 5 updates per 5 seconds - protected readonly rateLimited = injectRateLimitedValue< - number, - { - executionCount: number - rejectionCount: number - executionTimes: Array - } - >( + readonly instantCount = signal(0) + readonly search = signal('') + readonly currentValue = signal(50) + readonly instantExecutions = signal(1) + readonly countWindow = signal<'fixed' | 'sliding'>('fixed') + readonly searchWindow = signal<'fixed' | 'sliding'>('fixed') + readonly rangeWindow = signal<'fixed' | 'sliding'>('fixed') + readonly controlledCount = injectRateLimitedValue( this.instantCount, - { + 0, + () => ({ limit: 5, - window: 5000, // 5 seconds - windowType: this.windowType(), - onReject: () => { - // The value update was rejected; log the attempted value - this.executionHistory.push({ - timestamp: new Date().toLocaleTimeString(), - count: this.instantCount(), - rejected: true, - }) - }, - }, - (state) => ({ - executionCount: state.executionCount, - rejectionCount: state.rejectionCount, - executionTimes: state.executionTimes, + window: 5000, + windowType: this.countWindow(), + onReject: (limiter) => + console.log('Rejected; retry in', limiter.getMsUntilNextWindow(), 'ms'), }), + (state) => state, ) - - // Convenience accessors for the template (matches the old names) - protected readonly rateLimiter = this.rateLimited.rateLimiter - protected readonly rateLimitedCount = this.rateLimited - - protected increment(): void { - // Update instant count immediately; rateLimited value tracks it automatically - this.instantCount.update((c) => c + 1) - - // If the update was accepted, reflect the latest rate-limited value + log it - // (Rejected updates are logged via `onReject`) - this.limitedCount.set(this.rateLimitedCount()) - this.executionHistory.push({ - timestamp: new Date().toLocaleTimeString(), - count: this.rateLimitedCount(), - rejected: false, - }) + readonly countRunner = this.controlledCount.rateLimiter + readonly controlledSearch = injectRateLimitedValue( + this.search, + '', + () => ({ + limit: 5, + window: 5000, + windowType: this.searchWindow(), + onReject: (limiter) => + console.log('Rejected; retry in', limiter.getMsUntilNextWindow(), 'ms'), + }), + (state) => state, + ) + readonly searchRunner = this.controlledSearch.rateLimiter + readonly controlledValue = injectRateLimitedValue( + this.currentValue, + 50, + () => ({ + limit: 20, + window: 2000, + windowType: this.rangeWindow(), + onReject: (limiter) => + console.log('Rejected; retry in', limiter.getMsUntilNextWindow(), 'ms'), + }), + (state) => state, + ) + readonly rangeRunner = this.controlledValue.rateLimiter + increment(): void { + const next = this.instantCount() + 1 + this.instantCount.set(next) } - - protected reset(): void { - this.rateLimiter.reset() - this.instantCount.set(0) - this.limitedCount.set(0) - this.executionHistory.length = 0 + onSearch(value: string): void { + this.search.set(value) } - - protected setWindowType(type: 'fixed' | 'sliding'): void { - this.windowType.set(type) - // Note: windowType change requires recreating the rate limiter in a real app. - // For this example, we just update the signal. + onRange(value: number): void { + this.currentValue.set(value) + this.instantExecutions.update((count) => count + 1) + } + reduction(): number { + const count = this.instantExecutions() + return count + ? Math.round( + ((count - this.rangeRunner.state().executionCount) / count) * 100, + ) + : 0 } } diff --git a/examples/angular/injectRateLimiter/src/app/app.html b/examples/angular/injectRateLimiter/src/app/app.html index 2353a1482..3a274502e 100644 --- a/examples/angular/injectRateLimiter/src/app/app.html +++ b/examples/angular/injectRateLimiter/src/app/app.html @@ -1,28 +1,82 @@ -
-

Rate limiter

-

3 executions per 5 seconds (sliding window).

- -
- - -
- -
-

Status

-

Allowed: {{ rateLimiter.state().executionCount }}

-

Blocked: {{ rateLimiter.state().rejectionCount }}

-
- -
-

Events

- @if (logs().length === 0) { -

No events yet. Press Attempt to generate entries.

- } @else { -
    - @for (entry of logs(); track $index) { -
  • {{ entry }}
  • - } -
- } -
-
+

TanStack Pacer injectRateLimiter Example

+
+

Counter

+
+ Window type + +
+ +

Instant Count: {{ instantCount() }}

+

Rate Limited Count: {{ controlledCount() }}

+

Executions: {{ countRunner.state().executionCount }}

+

Rejections: {{ countRunner.state().rejectionCount }}

+

Remaining in Window: {{ countRunner.getRemainingInWindow() }}

+

Ms Until Next Window: {{ countRunner.getMsUntilNextWindow() }}

+ +
{{ countRunner.state() | json }}
+
+
+

Search

+

Enabled when the search contains more than 2 characters.

+ +

Instant Search: {{ search() }}

+

Rate Limited Search: {{ controlledSearch() }}

+

Executions: {{ searchRunner.state().executionCount }}

+

Rejections: {{ searchRunner.state().rejectionCount }}

+

Remaining in Window: {{ searchRunner.getRemainingInWindow() }}

+

Ms Until Next Window: {{ searchRunner.getMsUntilNextWindow() }}

+ +
{{ searchRunner.state() | json }}
+
+
+

Range

+ +

Current Value: {{ currentValue() }}

+ +

Rate Limited Value: {{ controlledValue() }}

+

Executions: {{ rangeRunner.state().executionCount }}

+

Rejections: {{ rangeRunner.state().rejectionCount }}

+

Remaining in Window: {{ rangeRunner.getRemainingInWindow() }}

+

Ms Until Next Window: {{ rangeRunner.getMsUntilNextWindow() }}

+

Instant Executions: {{ instantExecutions() }}

+

+ Saved Executions: {{ instantExecutions() - + rangeRunner.state().executionCount }} +

+

Reduction: {{ reduction() }}%

+ +
{{ rangeRunner.state() | json }}
+

Rate limited to 20 updates per 2 seconds.

+
diff --git a/examples/angular/injectRateLimiter/src/app/app.spec.ts b/examples/angular/injectRateLimiter/src/app/app.spec.ts index f9baf7f75..e98b1cc6f 100644 --- a/examples/angular/injectRateLimiter/src/app/app.spec.ts +++ b/examples/angular/injectRateLimiter/src/app/app.spec.ts @@ -1,3 +1,4 @@ +import { vi } from 'vitest' import { TestBed } from '@angular/core/testing' import { App } from './app' @@ -18,6 +19,66 @@ describe('App', () => { const fixture = TestBed.createComponent(App) await fixture.whenStable() const compiled = fixture.nativeElement as HTMLElement - expect(compiled.querySelector('h1')?.textContent).toContain('Hello, basic') + expect(compiled.querySelector('h1')?.textContent).toContain( + 'TanStack Pacer injectRateLimiter Example', + ) + }) +}) + +// Exercise the real adapter and component with a deterministic clock. +describe('example behavior', () => { + beforeEach(async () => { + await TestBed.configureTestingModule({ imports: [App] }).compileComponents() + }) + afterEach(() => { + TestBed.resetTestingModule() + vi.clearAllTimers() + vi.useRealTimers() + vi.restoreAllMocks() + }) + it('runs the counter, search, and range scenarios', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }) + const fixture = TestBed.createComponent(App) + const app = fixture.componentInstance + fixture.detectChanges() + TestBed.tick() + app.increment() + app.increment() + app.increment() + app.onSearch('angular') + app.onRange(73) + TestBed.tick() + await vi.advanceTimersByTimeAsync(1100) + TestBed.tick() + expect(app.controlledCount()).toBe(3) + expect(app.controlledSearch()).toBe('angular') + expect(app.controlledValue()).toBe(73) + }) + it('gates short search text and accepts the same event that passes the condition', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }) + const fixture = TestBed.createComponent(App) + const app = fixture.componentInstance + fixture.detectChanges() + TestBed.tick() + app.onSearch('ab') + TestBed.tick() + await vi.advanceTimersByTimeAsync(1100) + expect(app.controlledSearch()).toBe('') + app.onSearch('abc') + TestBed.tick() + await vi.advanceTimersByTimeAsync(1100) + expect(app.controlledSearch()).toBe('abc') + }) + it('changes the window type on the same limiter', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }) + const fixture = TestBed.createComponent(App) + const app = fixture.componentInstance + fixture.detectChanges() + TestBed.tick() + const runner = app.countRunner + app.countWindow.set('sliding') + TestBed.tick() + expect(app.countRunner).toBe(runner) + expect(runner.options.windowType).toBe('sliding') }) }) diff --git a/examples/angular/injectRateLimiter/src/app/app.ts b/examples/angular/injectRateLimiter/src/app/app.ts index a4cac1e8f..369118af8 100644 --- a/examples/angular/injectRateLimiter/src/app/app.ts +++ b/examples/angular/injectRateLimiter/src/app/app.ts @@ -1,41 +1,82 @@ import { Component, signal } from '@angular/core' +import { JsonPipe } from '@angular/common' import { injectRateLimiter } from '@tanstack/angular-pacer' @Component({ selector: 'app-root', - standalone: true, templateUrl: './app.html', + imports: [JsonPipe], }) export class App { - protected readonly logs = signal>([]) - protected readonly rateLimiter = injectRateLimiter( - () => { - this.logs.update((entries) => [`allowed @ ${new Date().toLocaleTimeString()}`, ...entries]) - }, - { - limit: 3, + readonly instantCount = signal(0) + readonly search = signal('') + readonly currentValue = signal(50) + readonly instantExecutions = signal(0) + readonly controlledCount = signal(0) + readonly controlledSearch = signal('') + readonly controlledValue = signal(50) + readonly countWindow = signal<'fixed' | 'sliding'>('fixed') + readonly countRunner = injectRateLimiter( + (value: number) => this.controlledCount.set(value), + () => ({ + limit: 5, window: 5000, - windowType: 'sliding', - onReject: () => { - this.logs.update((entries) => [`blocked @ ${new Date().toLocaleTimeString()}`, ...entries]) - }, + windowType: this.countWindow(), + onReject: (limiter) => + console.log('Rejected; retry in', limiter.getMsUntilNextWindow(), 'ms'), + }), + (state) => state, + ) + readonly searchRunner = injectRateLimiter( + (value: string) => this.controlledSearch.set(value), + () => { + // Update options when the signal changes so disabling cancels pending work. + // The callback reads the current event value before the next effect runs. + this.search() + return { + limit: 5, + window: 5000, + onReject: (limiter) => + console.log( + 'Rejected; retry in', + limiter.getMsUntilNextWindow(), + 'ms', + ), + enabled: () => this.search().length > 2, + } }, - (state) => ({ - executionCount: state.executionCount, - rejectionCount: state.rejectionCount, + (state) => state, + ) + readonly rangeRunner = injectRateLimiter( + (value: number) => this.controlledValue.set(value), + () => ({ + limit: 20, + window: 2000, + onReject: (limiter) => + console.log('Rejected; retry in', limiter.getMsUntilNextWindow(), 'ms'), }), + (state) => state, ) - - protected attempt(): void { - this.rateLimiter.maybeExecute() + increment(): void { + const next = this.instantCount() + 1 + this.instantCount.set(next) + this.countRunner.maybeExecute(next) } - - protected reset(): void { - this.rateLimiter.reset() - this.logs.set([]) + onSearch(value: string): void { + this.search.set(value) + this.searchRunner.maybeExecute(value) } - - protected remaining(): number { - return this.rateLimiter.getRemainingInWindow() + onRange(value: number): void { + this.currentValue.set(value) + this.instantExecutions.update((count) => count + 1) + this.rangeRunner.maybeExecute(value) + } + reduction(): number { + const count = this.instantExecutions() + return count + ? Math.round( + ((count - this.rangeRunner.state().executionCount) / count) * 100, + ) + : 0 } } diff --git a/examples/angular/injectRateLimiterWithPersister/README.md b/examples/angular/injectRateLimiterWithPersister/README.md deleted file mode 100644 index bd58f6985..000000000 --- a/examples/angular/injectRateLimiterWithPersister/README.md +++ /dev/null @@ -1,59 +0,0 @@ -# Basic - -This project was generated using [Angular CLI](https://github.com/angular/angular-cli) version 21.0.5. - -## Development server - -To start a local development server, run: - -```bash -ng serve -``` - -Once the server is running, open your browser and navigate to `http://localhost:4200/`. The application will automatically reload whenever you modify any of the source files. - -## Code scaffolding - -Angular CLI includes powerful code scaffolding tools. To generate a new component, run: - -```bash -ng generate component component-name -``` - -For a complete list of available schematics (such as `components`, `directives`, or `pipes`), run: - -```bash -ng generate --help -``` - -## Building - -To build the project run: - -```bash -ng build -``` - -This will compile your project and store the build artifacts in the `dist/` directory. By default, the production build optimizes your application for performance and speed. - -## Running unit tests - -To execute unit tests with the [Vitest](https://vitest.dev/) test runner, use the following command: - -```bash -ng test -``` - -## Running end-to-end tests - -For end-to-end (e2e) testing, run: - -```bash -ng e2e -``` - -Angular CLI does not come with an end-to-end testing framework by default. You can choose one that suits your needs. - -## Additional Resources - -For more information on using the Angular CLI, including detailed command references, visit the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page. diff --git a/examples/angular/injectRateLimiterWithPersister/angular.json b/examples/angular/injectRateLimiterWithPersister/angular.json deleted file mode 100644 index 06ffc24ef..000000000 --- a/examples/angular/injectRateLimiterWithPersister/angular.json +++ /dev/null @@ -1,72 +0,0 @@ -{ - "$schema": "./node_modules/@angular/cli/lib/config/schema.json", - "version": 1, - "cli": { - "packageManager": "npm", - "analytics": false - }, - "newProjectRoot": "projects", - "projects": { - "basic": { - "projectType": "application", - "schematics": {}, - "root": "", - "sourceRoot": "src", - "prefix": "app", - "architect": { - "build": { - "builder": "@angular/build:application", - "options": { - "browser": "src/main.ts", - "tsConfig": "tsconfig.app.json", - "assets": [ - { - "glob": "**/*", - "input": "public" - } - ], - "styles": ["src/styles.css"] - }, - "configurations": { - "production": { - "budgets": [ - { - "type": "initial", - "maximumWarning": "500kB", - "maximumError": "1MB" - }, - { - "type": "anyComponentStyle", - "maximumWarning": "4kB", - "maximumError": "8kB" - } - ], - "outputHashing": "all" - }, - "development": { - "optimization": false, - "extractLicenses": false, - "sourceMap": true - } - }, - "defaultConfiguration": "production" - }, - "serve": { - "builder": "@angular/build:dev-server", - "configurations": { - "production": { - "buildTarget": "basic:build:production" - }, - "development": { - "buildTarget": "basic:build:development" - } - }, - "defaultConfiguration": "development" - }, - "test": { - "builder": "@angular/build:unit-test" - } - } - } - } -} diff --git a/examples/angular/injectRateLimiterWithPersister/eslint.config.js b/examples/angular/injectRateLimiterWithPersister/eslint.config.js deleted file mode 100644 index 4d9c151cf..000000000 --- a/examples/angular/injectRateLimiterWithPersister/eslint.config.js +++ /dev/null @@ -1,15 +0,0 @@ -// @ts-check - -import rootConfig from '../../../eslint.config.js' - -/** @type {import('eslint').Linter.Config[]} */ -export default [ - ...rootConfig, - { - languageOptions: { - parserOptions: { - project: './tsconfig.app.json', - }, - }, - }, -] diff --git a/examples/angular/injectRateLimiterWithPersister/package.json b/examples/angular/injectRateLimiterWithPersister/package.json deleted file mode 100644 index d981daf16..000000000 --- a/examples/angular/injectRateLimiterWithPersister/package.json +++ /dev/null @@ -1,43 +0,0 @@ -{ - "name": "@tanstack/pacer-example-angular-inject-rate-limiter-with-persister", - "scripts": { - "ng": "ng", - "start": "ng serve", - "build": "ng build", - "watch": "ng build --watch --configuration development", - "test": "ng test" - }, - "prettier": { - "printWidth": 100, - "singleQuote": true, - "overrides": [ - { - "files": "*.html", - "options": { - "parser": "angular" - } - } - ] - }, - "private": true, - "packageManager": "pnpm@12.6.0", - "dependencies": { - "@angular/common": "^22.2.0", - "@angular/compiler": "^22.2.0", - "@angular/core": "^22.2.0", - "@angular/forms": "^22.2.0", - "@angular/platform-browser": "^22.2.0", - "@angular/router": "^22.2.0", - "@tanstack/angular-pacer": "^0.25.0", - "rxjs": "~7.8.2", - "tslib": "^2.8.1" - }, - "devDependencies": { - "@angular/build": "^22.2.0", - "@angular/cli": "^22.2.0", - "@angular/compiler-cli": "^22.2.0", - "jsdom": "^30.1.1", - "typescript": "6.0.3", - "vitest": "^5.0.1" - } -} diff --git a/examples/angular/injectRateLimiterWithPersister/public/favicon.ico b/examples/angular/injectRateLimiterWithPersister/public/favicon.ico deleted file mode 100644 index 57614f9c967596fad0a3989bec2b1deff33034f6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 15086 zcmd^G33O9Omi+`8$@{|M-I6TH3wzF-p5CV8o}7f~KxR60LK+ApEFB<$bcciv%@SmA zV{n>g85YMFFeU*Uvl=i4v)C*qgnb;$GQ=3XTe9{Y%c`mO%su)noNCCQ*@t1WXn|B(hQ7i~ zrUK8|pUkD6#lNo!bt$6)jR!&C?`P5G(`e((P($RaLeq+o0Vd~f11;qB05kdbAOm?r zXv~GYr_sibQO9NGTCdT;+G(!{4Xs@4fPak8#L8PjgJwcs-Mm#nR_Z0s&u?nDX5^~@ z+A6?}g0|=4e_LoE69pPFO`yCD@BCjgKpzMH0O4Xs{Ahc?K3HC5;l=f zg>}alhBXX&);z$E-wai+9TTRtBX-bWYY@cl$@YN#gMd~tM_5lj6W%8ah4;uZ;jP@Q zVbuel1rPA?2@x9Y+u?e`l{Z4ngfG5q5BLH5QsEu4GVpt{KIp1?U)=3+KQ;%7ec8l* zdV=zZgN5>O3G(3L2fqj3;oBbZZw$Ij@`Juz@?+yy#OPw)>#wsTewVgTK9BGt5AbZ&?K&B3GVF&yu?@(Xj3fR3n+ZP0%+wo)D9_xp>Z$`A4 zfV>}NWjO#3lqumR0`gvnffd9Ka}JJMuHS&|55-*mCD#8e^anA<+sFZVaJe7{=p*oX zE_Uv?1>e~ga=seYzh{9P+n5<+7&9}&(kwqSaz;1aD|YM3HBiy<))4~QJSIryyqp| z8nGc(8>3(_nEI4n)n7j(&d4idW1tVLjZ7QbNLXg;LB ziHsS5pXHEjGJZb59KcvS~wv;uZR-+4qEqow`;JCfB*+b^UL^3!?;-^F%yt=VjU|v z39SSqKcRu_NVvz!zJzL0CceJaS6%!(eMshPv_0U5G`~!a#I$qI5Ic(>IONej@aH=f z)($TAT#1I{iCS4f{D2+ApS=$3E7}5=+y(rA9mM#;Cky%b*Gi0KfFA`ofKTzu`AV-9 znW|y@19rrZ*!N2AvDi<_ZeR3O2R{#dh1#3-d%$k${Rx42h+i&GZo5!C^dSL34*AKp z27mTd>k>?V&X;Nl%GZ(>0s`1UN~Hfyj>KPjtnc|)xM@{H_B9rNr~LuH`Gr5_am&Ep zTjZA8hljNj5H1Ipm-uD9rC}U{-vR!eay5&6x6FkfupdpT*84MVwGpdd(}ib)zZ3Ky z7C$pnjc82(W_y_F{PhYj?o!@3__UUvpX)v69aBSzYj3 zdi}YQkKs^SyXyFG2LTRz9{(w}y~!`{EuAaUr6G1M{*%c+kP1olW9z23dSH!G4_HSK zzae-DF$OGR{ofP*!$a(r^5Go>I3SObVI6FLY)N@o<*gl0&kLo-OT{Tl*7nCz>Iq=? zcigIDHtj|H;6sR?or8Wd_a4996GI*CXGU}o;D9`^FM!AT1pBY~?|4h^61BY#_yIfO zKO?E0 zJ{Pc`9rVEI&$xxXu`<5E)&+m(7zX^v0rqofLs&bnQT(1baQkAr^kEsk)15vlzAZ-l z@OO9RF<+IiJ*O@HE256gCt!bF=NM*vh|WVWmjVawcNoksRTMvR03H{p@cjwKh(CL4 z7_PB(dM=kO)!s4fW!1p0f93YN@?ZSG` z$B!JaAJCtW$B97}HNO9(x-t30&E}Mo1UPi@Av%uHj~?T|!4JLwV;KCx8xO#b9IlUW zI6+{a@Wj|<2Y=U;a@vXbxqZNngH8^}LleE_4*0&O7#3iGxfJ%Id>+sb;7{L=aIic8 z|EW|{{S)J-wr@;3PmlxRXU8!e2gm_%s|ReH!reFcY8%$Hl4M5>;6^UDUUae?kOy#h zk~6Ee_@ZAn48Bab__^bNmQ~+k=02jz)e0d9Z3>G?RGG!65?d1>9}7iG17?P*=GUV-#SbLRw)Hu{zx*azHxWkGNTWl@HeWjA?39Ia|sCi{e;!^`1Oec zb>Z|b65OM*;eC=ZLSy?_fg$&^2xI>qSLA2G*$nA3GEnp3$N-)46`|36m*sc#4%C|h zBN<2U;7k>&G_wL4=Ve5z`ubVD&*Hxi)r@{4RCDw7U_D`lbC(9&pG5C*z#W>8>HU)h z!h3g?2UL&sS!oY5$3?VlA0Me9W5e~V;2jds*fz^updz#AJ%G8w2V}AEE?E^=MK%Xt z__Bx1cr7+DQmuHmzn*|hh%~eEc9@m05@clWfpEFcr+06%0&dZJH&@8^&@*$qR@}o3 z@Tuuh2FsLz^zH+dN&T&?0G3I?MpmYJ;GP$J!EzjeM#YLJ!W$}MVNb0^HfOA>5Fe~UNn%Zk(PT@~9}1dt)1UQ zU*B5K?Dl#G74qmg|2>^>0WtLX#Jz{lO4NT`NYB*(L#D|5IpXr9v&7a@YsGp3vLR7L zHYGHZg7{ie6n~2p$6Yz>=^cEg7tEgk-1YRl%-s7^cbqFb(U7&Dp78+&ut5!Tn(hER z|Gp4Ed@CnOPeAe|N>U(dB;SZ?NU^AzoD^UAH_vamp6Ws}{|mSq`^+VP1g~2B{%N-!mWz<`)G)>V-<`9`L4?3dM%Qh6<@kba+m`JS{Ya@9Fq*m6$$ zA1%Ogc~VRH33|S9l%CNb4zM%k^EIpqY}@h{w(aBcJ9c05oiZx#SK9t->5lSI`=&l~ z+-Ic)a{FbBhXV$Xt!WRd`R#Jk-$+_Z52rS>?Vpt2IK<84|E-SBEoIw>cs=a{BlQ7O z-?{Fy_M&84&9|KM5wt~)*!~i~E=(6m8(uCO)I=)M?)&sRbzH$9Rovzd?ZEY}GqX+~ zFbEbLz`BZ49=2Yh-|<`waK-_4!7`ro@zlC|r&I4fc4oyb+m=|c8)8%tZ-z5FwhzDt zL5kB@u53`d@%nHl0Sp)Dw`(QU&>vujEn?GPEXUW!Wi<+4e%BORl&BIH+SwRcbS}X@ z01Pk|vA%OdJKAs17zSXtO55k!;%m9>1eW9LnyAX4uj7@${O6cfii`49qTNItzny5J zH&Gj`e}o}?xjQ}r?LrI%FjUd@xflT3|7LA|ka%Q3i}a8gVm<`HIWoJGH=$EGClX^C0lysQJ>UO(q&;`T#8txuoQ_{l^kEV9CAdXuU1Ghg8 zN_6hHFuy&1x24q5-(Z7;!poYdt*`UTdrQOIQ!2O7_+AHV2hgXaEz7)>$LEdG z<8vE^Tw$|YwZHZDPM!SNOAWG$?J)MdmEk{U!!$M#fp7*Wo}jJ$Q(=8>R`Ats?e|VU?Zt7Cdh%AdnfyN3MBWw{ z$OnREvPf7%z6`#2##_7id|H%Y{vV^vWXb?5d5?a_y&t3@p9t$ncHj-NBdo&X{wrfJ zamN)VMYROYh_SvjJ=Xd!Ga?PY_$;*L=SxFte!4O6%0HEh%iZ4=gvns7IWIyJHa|hT z2;1+e)`TvbNb3-0z&DD_)Jomsg-7p_Uh`wjGnU1urmv1_oVqRg#=C?e?!7DgtqojU zWoAB($&53;TsXu^@2;8M`#z{=rPy?JqgYM0CDf4v@z=ZD|ItJ&8%_7A#K?S{wjxgd z?xA6JdJojrWpB7fr2p_MSsU4(R7=XGS0+Eg#xR=j>`H@R9{XjwBmqAiOxOL` zt?XK-iTEOWV}f>Pz3H-s*>W z4~8C&Xq25UQ^xH6H9kY_RM1$ch+%YLF72AA7^b{~VNTG}Tj#qZltz5Q=qxR`&oIlW Nr__JTFzvMr^FKp4S3v*( diff --git a/examples/angular/injectRateLimiterWithPersister/src/app/app.config.ts b/examples/angular/injectRateLimiterWithPersister/src/app/app.config.ts deleted file mode 100644 index 46a6b4193..000000000 --- a/examples/angular/injectRateLimiterWithPersister/src/app/app.config.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core' -import { provideRouter } from '@angular/router' - -import { routes } from './app.routes' - -export const appConfig: ApplicationConfig = { - providers: [provideBrowserGlobalErrorListeners(), provideRouter(routes)], -} diff --git a/examples/angular/injectRateLimiterWithPersister/src/app/app.css b/examples/angular/injectRateLimiterWithPersister/src/app/app.css deleted file mode 100644 index 296e372b0..000000000 --- a/examples/angular/injectRateLimiterWithPersister/src/app/app.css +++ /dev/null @@ -1,19 +0,0 @@ -:host { - display: block; - padding: 2rem; - max-width: 600px; - margin: 0 auto; -} - -input { - width: 100%; - padding: 0.5rem; - font-size: 1rem; - border: 1px solid #ccc; - border-radius: 4px; -} - -p { - margin-top: 1rem; - color: #666; -} diff --git a/examples/angular/injectRateLimiterWithPersister/src/app/app.html b/examples/angular/injectRateLimiterWithPersister/src/app/app.html deleted file mode 100644 index 1d28611af..000000000 --- a/examples/angular/injectRateLimiterWithPersister/src/app/app.html +++ /dev/null @@ -1,59 +0,0 @@ -
-

TanStack Pacer - injectRateLimiter with Persister

- -

- This example uses injectRateLimiter together with the default Pacer context. When a - persister is configured in the context, the rate limiter state (execution and rejection counts) - can be kept across reloads. -

- -

Window type

-
- - -
- -

Limiter status

-
-

Execution count (selected state): {{ rateLimiter.state().executionCount }}

-

Rejection count (selected state): {{ rateLimiter.state().rejectionCount }}

-

Remaining in window: {{ rateLimiter.getRemainingInWindow() }}

-

Milliseconds until next window: {{ rateLimiter.getMsUntilNextWindow() }}

-
- -

Persisted snapshot

-
-

Persisted execution count: {{ persistedExecutionCount() }}

-

Persisted rejection count: {{ persistedRejectionCount() }}

-
- -

Counters

-
-

Instant count (increments on every click): {{ instantCount() }}

-

Rate-limited count (only when limiter allows): {{ limitedCount() }}

-
- -

Controls

-
- - -
-
diff --git a/examples/angular/injectRateLimiterWithPersister/src/app/app.routes.ts b/examples/angular/injectRateLimiterWithPersister/src/app/app.routes.ts deleted file mode 100644 index 9a884b113..000000000 --- a/examples/angular/injectRateLimiterWithPersister/src/app/app.routes.ts +++ /dev/null @@ -1,3 +0,0 @@ -import type { Routes } from '@angular/router' - -export const routes: Routes = [] diff --git a/examples/angular/injectRateLimiterWithPersister/src/app/app.spec.ts b/examples/angular/injectRateLimiterWithPersister/src/app/app.spec.ts deleted file mode 100644 index f9baf7f75..000000000 --- a/examples/angular/injectRateLimiterWithPersister/src/app/app.spec.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { TestBed } from '@angular/core/testing' -import { App } from './app' - -describe('App', () => { - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [App], - }).compileComponents() - }) - - it('should create the app', () => { - const fixture = TestBed.createComponent(App) - const app = fixture.componentInstance - expect(app).toBeTruthy() - }) - - it('should render title', async () => { - const fixture = TestBed.createComponent(App) - await fixture.whenStable() - const compiled = fixture.nativeElement as HTMLElement - expect(compiled.querySelector('h1')?.textContent).toContain('Hello, basic') - }) -}) diff --git a/examples/angular/injectRateLimiterWithPersister/src/app/app.ts b/examples/angular/injectRateLimiterWithPersister/src/app/app.ts deleted file mode 100644 index b64c82648..000000000 --- a/examples/angular/injectRateLimiterWithPersister/src/app/app.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { Component, effect, inject, signal } from '@angular/core' -import { injectRateLimiter } from '@tanstack/angular-pacer' - -/** - * Example: injectRateLimiter with a persister from the Pacer context. - * - * This demo: - * - Uses the default rateLimiter options from the context (including a persister if configured) - * - Shows how executionCount and rejectionCount survive page reloads when a persister is used - * - Lets you switch between fixed and sliding windows - */ -@Component({ - selector: 'app-root', - standalone: true, - templateUrl: './app.html', -}) -export class App { - // Window type selector (not persisted here, only affects the limiter instance) - windowType = signal<'fixed' | 'sliding'>('fixed') - - // Local UI state - instantCount = signal(0) - limitedCount = signal(0) - - // Snapshot of persisted state (executionCount, rejectionCount) - persistedExecutionCount = signal(0) - persistedRejectionCount = signal(0) - - // Rate limiter with selected reactive state - readonly rateLimiter = injectRateLimiter< - (count: number) => void, - { executionCount: number; rejectionCount: number } - >( - (count: number) => { - this.limitedCount.set(count) - this.updatePersistedSnapshot() - }, - { - // These override or complement whatever is in the context defaults - limit: 5, - window: 5000, - windowType: this.windowType(), - onReject: () => { - this.updatePersistedSnapshot() - }, - }, - (state) => ({ - executionCount: state.executionCount, - rejectionCount: state.rejectionCount, - }), - ) - - constructor() { - // Whenever the selected state changes, refresh our persisted snapshot. - // This makes it easy to see that the numbers survive a reload when a persister is configured. - effect(() => { - const { executionCount, rejectionCount } = this.rateLimiter.state() - this.persistedExecutionCount.set(executionCount) - this.persistedRejectionCount.set(rejectionCount) - }) - } - - private updatePersistedSnapshot(): void { - const { executionCount, rejectionCount } = this.rateLimiter.state() - this.persistedExecutionCount.set(executionCount) - this.persistedRejectionCount.set(rejectionCount) - } - - increment(): void { - this.instantCount.update((c) => { - const next = c + 1 - this.rateLimiter.maybeExecute(next) - return next - }) - } - - resetWindow(): void { - this.rateLimiter.reset() - this.instantCount.set(0) - this.limitedCount.set(0) - this.updatePersistedSnapshot() - } - - setWindowType(type: 'fixed' | 'sliding'): void { - this.windowType.set(type) - } -} diff --git a/examples/angular/injectRateLimiterWithPersister/src/index.html b/examples/angular/injectRateLimiterWithPersister/src/index.html deleted file mode 100644 index 1b3f817b9..000000000 --- a/examples/angular/injectRateLimiterWithPersister/src/index.html +++ /dev/null @@ -1,13 +0,0 @@ - - - - - Basic - - - - - - - - diff --git a/examples/angular/injectRateLimiterWithPersister/src/main.ts b/examples/angular/injectRateLimiterWithPersister/src/main.ts deleted file mode 100644 index 8192dca69..000000000 --- a/examples/angular/injectRateLimiterWithPersister/src/main.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { bootstrapApplication } from '@angular/platform-browser' -import { appConfig } from './app/app.config' -import { App } from './app/app' - -bootstrapApplication(App, appConfig).catch((err) => console.error(err)) diff --git a/examples/angular/injectRateLimiterWithPersister/src/styles.css b/examples/angular/injectRateLimiterWithPersister/src/styles.css deleted file mode 100644 index 90d4ee007..000000000 --- a/examples/angular/injectRateLimiterWithPersister/src/styles.css +++ /dev/null @@ -1 +0,0 @@ -/* You can add global styles to this file, and also import other style files */ diff --git a/examples/angular/injectRateLimiterWithPersister/tsconfig.app.json b/examples/angular/injectRateLimiterWithPersister/tsconfig.app.json deleted file mode 100644 index 90631899c..000000000 --- a/examples/angular/injectRateLimiterWithPersister/tsconfig.app.json +++ /dev/null @@ -1,11 +0,0 @@ -/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ -/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ -{ - "extends": "./tsconfig.json", - "compilerOptions": { - "outDir": "./out-tsc/app", - "types": [] - }, - "include": ["./src/**/*.ts"], - "exclude": ["./src/**/*.spec.ts"] -} diff --git a/examples/angular/injectRateLimiterWithPersister/tsconfig.json b/examples/angular/injectRateLimiterWithPersister/tsconfig.json deleted file mode 100644 index 2ab744275..000000000 --- a/examples/angular/injectRateLimiterWithPersister/tsconfig.json +++ /dev/null @@ -1,33 +0,0 @@ -/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ -/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ -{ - "compileOnSave": false, - "compilerOptions": { - "strict": true, - "noImplicitOverride": true, - "noPropertyAccessFromIndexSignature": true, - "noImplicitReturns": true, - "noFallthroughCasesInSwitch": true, - "skipLibCheck": true, - "isolatedModules": true, - "experimentalDecorators": true, - "importHelpers": true, - "target": "ES2022", - "module": "preserve" - }, - "angularCompilerOptions": { - "enableI18nLegacyMessageIdFormat": false, - "strictInjectionParameters": true, - "strictInputAccessModifiers": true, - "strictTemplates": true - }, - "files": [], - "references": [ - { - "path": "./tsconfig.app.json" - }, - { - "path": "./tsconfig.spec.json" - } - ] -} diff --git a/examples/angular/injectRateLimiterWithPersister/tsconfig.spec.json b/examples/angular/injectRateLimiterWithPersister/tsconfig.spec.json deleted file mode 100644 index 26230b0b3..000000000 --- a/examples/angular/injectRateLimiterWithPersister/tsconfig.spec.json +++ /dev/null @@ -1,10 +0,0 @@ -/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ -/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ -{ - "extends": "./tsconfig.json", - "compilerOptions": { - "outDir": "./out-tsc/spec", - "types": ["vitest/globals"] - }, - "include": ["src/**/*.d.ts", "src/**/*.spec.ts"] -} diff --git a/examples/angular/injectThrottledCallback/src/app/app.html b/examples/angular/injectThrottledCallback/src/app/app.html index 57c266398..08cbeb9d2 100644 --- a/examples/angular/injectThrottledCallback/src/app/app.html +++ b/examples/angular/injectThrottledCallback/src/app/app.html @@ -1,25 +1,41 @@ -
-

TanStack Pacer - injectThrottledCallback Example

- -

- Type quickly in the input below. The raw change count increases on every keystroke, but the - throttled handler only runs at most once every 500ms. -

- -
- -
- -
-

Raw changes: {{ rawChanges }}

-

Throttled changes: {{ throttledChanges }}

-

Last throttled value: {{ lastThrottledValue || '(none yet)' }}

-
- -
- -
-
+

TanStack Pacer injectThrottledCallback Example

+
+

Counter

+

Enabled once the instant count exceeds 2.

+ +

Instant Count: {{ instantCount() }}

+

Throttled Count: {{ controlledCount() }}

+
+
+

Search

+

Enabled when the search contains more than 2 characters.

+ +

Instant Search: {{ search() }}

+

Throttled Search: {{ controlledSearch() }}

+
+
+

Range

+ +

Current Value: {{ currentValue() }}

+ +

Throttled Value: {{ controlledValue() }}

+

Throttled with a 250 ms wait.

+
diff --git a/examples/angular/injectThrottledCallback/src/app/app.spec.ts b/examples/angular/injectThrottledCallback/src/app/app.spec.ts index f9baf7f75..ac5a02fcd 100644 --- a/examples/angular/injectThrottledCallback/src/app/app.spec.ts +++ b/examples/angular/injectThrottledCallback/src/app/app.spec.ts @@ -1,3 +1,4 @@ +import { vi } from 'vitest' import { TestBed } from '@angular/core/testing' import { App } from './app' @@ -18,6 +19,66 @@ describe('App', () => { const fixture = TestBed.createComponent(App) await fixture.whenStable() const compiled = fixture.nativeElement as HTMLElement - expect(compiled.querySelector('h1')?.textContent).toContain('Hello, basic') + expect(compiled.querySelector('h1')?.textContent).toContain( + 'TanStack Pacer injectThrottledCallback Example', + ) + }) +}) + +// Exercise the real adapter and component with a deterministic clock. +describe('example behavior', () => { + beforeEach(async () => { + await TestBed.configureTestingModule({ imports: [App] }).compileComponents() + }) + afterEach(() => { + TestBed.resetTestingModule() + vi.clearAllTimers() + vi.useRealTimers() + vi.restoreAllMocks() + }) + it('runs the counter, search, and range scenarios', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }) + const fixture = TestBed.createComponent(App) + const app = fixture.componentInstance + fixture.detectChanges() + TestBed.tick() + app.increment() + app.increment() + app.increment() + app.onSearch('angular') + app.onRange(73) + TestBed.tick() + await vi.advanceTimersByTimeAsync(1100) + TestBed.tick() + expect(app.controlledCount()).toBe(3) + expect(app.controlledSearch()).toBe('angular') + expect(app.controlledValue()).toBe(73) + }) + it('enables the third counter event without waiting for a render', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }) + const fixture = TestBed.createComponent(App) + const app = fixture.componentInstance + fixture.detectChanges() + TestBed.tick() + app.increment() + app.increment() + expect(app.controlledCount()).toBe(0) + app.increment() + expect(app.controlledCount()).toBe(3) + }) + it('gates short search text and accepts the same event that passes the condition', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }) + const fixture = TestBed.createComponent(App) + const app = fixture.componentInstance + fixture.detectChanges() + TestBed.tick() + app.onSearch('ab') + TestBed.tick() + await vi.advanceTimersByTimeAsync(1100) + expect(app.controlledSearch()).toBe('') + app.onSearch('abc') + TestBed.tick() + await vi.advanceTimersByTimeAsync(1100) + expect(app.controlledSearch()).toBe('abc') }) }) diff --git a/examples/angular/injectThrottledCallback/src/app/app.ts b/examples/angular/injectThrottledCallback/src/app/app.ts index 19d343e3a..5039f61bd 100644 --- a/examples/angular/injectThrottledCallback/src/app/app.ts +++ b/examples/angular/injectThrottledCallback/src/app/app.ts @@ -1,47 +1,47 @@ -import { Component } from '@angular/core' +import { Component, signal } from '@angular/core' import { injectThrottledCallback } from '@tanstack/angular-pacer' -@Component({ - selector: 'app-root', - standalone: true, - templateUrl: './app.html', -}) +@Component({ selector: 'app-root', templateUrl: './app.html', imports: [] }) export class App { - // Raw event count (increments on every input change) - rawChanges = 0 - - // Throttled count (only increments at most once per 500ms) - throttledChanges = 0 - - // Last value that the throttled callback received - lastThrottledValue = '' - - // Input value bound from the template - value = '' - - // Throttled callback for handling "expensive" work - private readonly handleChangeThrottled = injectThrottledCallback( - (next: string) => { - this.throttledChanges++ - this.lastThrottledValue = next + readonly instantCount = signal(0) + readonly search = signal('') + readonly currentValue = signal(50) + readonly controlledCount = signal(0) + readonly controlledSearch = signal('') + readonly controlledValue = signal(50) + readonly countRunner = injectThrottledCallback( + (value: number) => this.controlledCount.set(value), + () => { + // Update options when the signal changes so disabling cancels pending work. + // The callback reads the current event value before the next effect runs. + this.instantCount() + return { wait: 1000, enabled: () => this.instantCount() > 2 } }, - { - wait: 500, - leading: true, - trailing: true, + ) + readonly searchRunner = injectThrottledCallback( + (value: string) => this.controlledSearch.set(value), + () => { + // Update options when the signal changes so disabling cancels pending work. + // The callback reads the current event value before the next effect runs. + this.search() + return { wait: 1000, enabled: () => this.search().length > 2 } }, ) - - onInput(value: string) { - this.value = value - this.rawChanges++ - this.handleChangeThrottled(value) + readonly rangeRunner = injectThrottledCallback( + (value: number) => this.controlledValue.set(value), + () => ({ wait: 250 }), + ) + increment(): void { + const next = this.instantCount() + 1 + this.instantCount.set(next) + this.countRunner(next) } - - reset() { - this.value = '' - this.rawChanges = 0 - this.throttledChanges = 0 - this.lastThrottledValue = '' + onSearch(value: string): void { + this.search.set(value) + this.searchRunner(value) + } + onRange(value: number): void { + this.currentValue.set(value) + this.rangeRunner(value) } } diff --git a/examples/angular/injectThrottledSignal/src/app/app.html b/examples/angular/injectThrottledSignal/src/app/app.html index 8a1a62db4..a1b4f345f 100644 --- a/examples/angular/injectThrottledSignal/src/app/app.html +++ b/examples/angular/injectThrottledSignal/src/app/app.html @@ -1,20 +1,54 @@ -
-

TanStack Pacer - injectThrottledSignal Example

- +

TanStack Pacer injectThrottledSignal Example

+
+

Counter

+ +

Instant Count: {{ instantCount() }}

+

Throttled Count: {{ controlledCount() }}

+

Executions: {{ countRunner.state().executionCount }}

+

Pending: {{ countRunner.state().isPending }}

+
{{ countRunner.state() | json }}
+
+
+

Search

+ +

Instant Search: {{ search() }}

+

Throttled Search: {{ controlledSearch() }}

+

Executions: {{ searchRunner.state().executionCount }}

+

Pending: {{ searchRunner.state().isPending }}

+
{{ searchRunner.state() | json }}
+
+
+

Range

+ +

Current Value: {{ currentValue() }}

+ +

Throttled Value: {{ controlledValue() }}

+

Executions: {{ rangeRunner.state().executionCount }}

+

Pending: {{ rangeRunner.state().isPending }}

+

Instant Executions: {{ instantExecutions() }}

- Clicks are counted immediately, but the visible count is throttled to update at most once every - 500ms. This shows how injectThrottledSignal can limit how often state updates. + Saved Executions: {{ instantExecutions() - + rangeRunner.state().executionCount }}

- -
-

Raw clicks: {{ rawCount }}

-

Throttled count: {{ count }}

-

Executions: {{ executionCount }}

-

Is pending: {{ isPending }}

-
- -
- - -
-
+

Reduction: {{ reduction() }}%

+
{{ rangeRunner.state() | json }}
+

Throttled with a 250 ms wait.

+ diff --git a/examples/angular/injectThrottledSignal/src/app/app.spec.ts b/examples/angular/injectThrottledSignal/src/app/app.spec.ts index f9baf7f75..381a398af 100644 --- a/examples/angular/injectThrottledSignal/src/app/app.spec.ts +++ b/examples/angular/injectThrottledSignal/src/app/app.spec.ts @@ -1,3 +1,4 @@ +import { vi } from 'vitest' import { TestBed } from '@angular/core/testing' import { App } from './app' @@ -18,6 +19,39 @@ describe('App', () => { const fixture = TestBed.createComponent(App) await fixture.whenStable() const compiled = fixture.nativeElement as HTMLElement - expect(compiled.querySelector('h1')?.textContent).toContain('Hello, basic') + expect(compiled.querySelector('h1')?.textContent).toContain( + 'TanStack Pacer injectThrottledSignal Example', + ) + }) +}) + +// Exercise the real adapter and component with a deterministic clock. +describe('example behavior', () => { + beforeEach(async () => { + await TestBed.configureTestingModule({ imports: [App] }).compileComponents() + }) + afterEach(() => { + TestBed.resetTestingModule() + vi.clearAllTimers() + vi.useRealTimers() + vi.restoreAllMocks() + }) + it('runs the counter, search, and range scenarios', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }) + const fixture = TestBed.createComponent(App) + const app = fixture.componentInstance + fixture.detectChanges() + TestBed.tick() + app.increment() + app.increment() + app.increment() + app.onSearch('angular') + app.onRange(73) + TestBed.tick() + await vi.advanceTimersByTimeAsync(1100) + TestBed.tick() + expect(app.controlledCount()).toBe(3) + expect(app.controlledSearch()).toBe('angular') + expect(app.controlledValue()).toBe(73) }) }) diff --git a/examples/angular/injectThrottledSignal/src/app/app.ts b/examples/angular/injectThrottledSignal/src/app/app.ts index 90741d7bf..52c8040d6 100644 --- a/examples/angular/injectThrottledSignal/src/app/app.ts +++ b/examples/angular/injectThrottledSignal/src/app/app.ts @@ -1,61 +1,55 @@ -import { Component, effect } from '@angular/core' +import { Component, signal } from '@angular/core' +import { JsonPipe } from '@angular/common' import { injectThrottledSignal } from '@tanstack/angular-pacer' @Component({ selector: 'app-root', - standalone: true, templateUrl: './app.html', + imports: [JsonPipe], }) export class App { - // Throttled counter value - count = 0 - - // Raw click count (not throttled) - rawCount = 0 - - // Throttled state signals - readonly throttledState: ReturnType< - typeof injectThrottledSignal - > - - constructor() { - this.throttledState = injectThrottledSignal( - 0, - { - wait: 500, - leading: true, - trailing: true, - }, - (state) => ({ - executionCount: state.executionCount, - isPending: state.isPending, - }), - ) - - const throttledCount = this.throttledState - - effect(() => { - this.count = throttledCount() - }) + readonly instantCount = signal(0) + readonly search = signal('') + readonly currentValue = signal(50) + readonly instantExecutions = signal(0) + readonly controlledCount = injectThrottledSignal( + 0, + () => ({ wait: 1000 }), + (state) => state, + ) + readonly countRunner = this.controlledCount.throttler + readonly controlledSearch = injectThrottledSignal( + '', + () => ({ wait: 1000 }), + (state) => state, + ) + readonly searchRunner = this.controlledSearch.throttler + readonly controlledValue = injectThrottledSignal( + 50, + () => ({ wait: 250 }), + (state) => state, + ) + readonly rangeRunner = this.controlledValue.throttler + increment(): void { + const next = this.instantCount() + 1 + this.instantCount.set(next) + this.controlledCount.set(next) } - - onClick() { - this.rawCount++ - this.throttledState.set((prev) => prev + 1) + onSearch(value: string): void { + this.search.set(value) + this.controlledSearch.set(value) } - - reset() { - this.rawCount = 0 - this.throttledState.set(0) + onRange(value: number): void { + this.currentValue.set(value) + this.instantExecutions.update((count) => count + 1) + this.controlledValue.set(value) } - - get executionCount() { - const throttler = this.throttledState.throttler - return throttler.state().executionCount - } - - get isPending() { - const throttler = this.throttledState.throttler - return throttler.state().isPending + reduction(): number { + const count = this.instantExecutions() + return count + ? Math.round( + ((count - this.rangeRunner.state().executionCount) / count) * 100, + ) + : 0 } } diff --git a/examples/angular/injectThrottledValue/src/app/app.html b/examples/angular/injectThrottledValue/src/app/app.html index 3b4d11e3a..e9260cc1a 100644 --- a/examples/angular/injectThrottledValue/src/app/app.html +++ b/examples/angular/injectThrottledValue/src/app/app.html @@ -1,26 +1,54 @@ -
-

injectThrottledValue — Angular

- - - - -
-
Instant: {{ instantInput() }}
-
Throttled: {{ throttled() }}
-
Pending: {{ throttler.state().isPending ? 'Yes' : 'No' }}
-
- -
- - - -
-
+

TanStack Pacer injectThrottledValue Example

+
+

Counter

+ +

Instant Count: {{ instantCount() }}

+

Throttled Count: {{ controlledCount() }}

+

Executions: {{ countRunner.state().executionCount }}

+

Pending: {{ countRunner.state().isPending }}

+
{{ countRunner.state() | json }}
+
+
+

Search

+ +

Instant Search: {{ search() }}

+

Throttled Search: {{ controlledSearch() }}

+

Executions: {{ searchRunner.state().executionCount }}

+

Pending: {{ searchRunner.state().isPending }}

+
{{ searchRunner.state() | json }}
+
+
+

Range

+ +

Current Value: {{ currentValue() }}

+ +

Throttled Value: {{ controlledValue() }}

+

Executions: {{ rangeRunner.state().executionCount }}

+

Pending: {{ rangeRunner.state().isPending }}

+

Values Submitted: {{ instantExecutions() }}

+

+ Saved Executions: {{ instantExecutions() - + rangeRunner.state().executionCount }} +

+

Reduction: {{ reduction() }}%

+
{{ rangeRunner.state() | json }}
+

Throttled with a 250 ms wait.

+
diff --git a/examples/angular/injectThrottledValue/src/app/app.spec.ts b/examples/angular/injectThrottledValue/src/app/app.spec.ts index f9baf7f75..2e822e35b 100644 --- a/examples/angular/injectThrottledValue/src/app/app.spec.ts +++ b/examples/angular/injectThrottledValue/src/app/app.spec.ts @@ -1,3 +1,4 @@ +import { vi } from 'vitest' import { TestBed } from '@angular/core/testing' import { App } from './app' @@ -18,6 +19,51 @@ describe('App', () => { const fixture = TestBed.createComponent(App) await fixture.whenStable() const compiled = fixture.nativeElement as HTMLElement - expect(compiled.querySelector('h1')?.textContent).toContain('Hello, basic') + expect(compiled.querySelector('h1')?.textContent).toContain( + 'TanStack Pacer injectThrottledValue Example', + ) + }) +}) + +// Exercise the real adapter and component with a deterministic clock. +describe('example behavior', () => { + beforeEach(async () => { + await TestBed.configureTestingModule({ imports: [App] }).compileComponents() + }) + afterEach(() => { + TestBed.resetTestingModule() + vi.clearAllTimers() + vi.useRealTimers() + vi.restoreAllMocks() + }) + it('runs the counter, search, and range scenarios', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }) + const fixture = TestBed.createComponent(App) + const app = fixture.componentInstance + fixture.detectChanges() + TestBed.tick() + app.increment() + app.increment() + app.increment() + app.onSearch('angular') + app.onRange(73) + TestBed.tick() + await vi.advanceTimersByTimeAsync(1100) + TestBed.tick() + expect(app.controlledCount()).toBe(3) + expect(app.controlledSearch()).toBe('angular') + expect(app.controlledValue()).toBe(73) + }) + it('counts the initial value when displaying saved executions', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }) + const fixture = TestBed.createComponent(App) + const app = fixture.componentInstance + fixture.detectChanges() + TestBed.tick() + await vi.advanceTimersByTimeAsync(1100) + expect( + app.instantExecutions() - app.rangeRunner.state().executionCount, + ).toBe(0) + expect(app.reduction()).toBe(0) }) }) diff --git a/examples/angular/injectThrottledValue/src/app/app.ts b/examples/angular/injectThrottledValue/src/app/app.ts index 2496a1574..6560f0455 100644 --- a/examples/angular/injectThrottledValue/src/app/app.ts +++ b/examples/angular/injectThrottledValue/src/app/app.ts @@ -1,40 +1,55 @@ import { Component, signal } from '@angular/core' +import { JsonPipe } from '@angular/common' import { injectThrottledValue } from '@tanstack/angular-pacer' @Component({ selector: 'app-root', templateUrl: './app.html', + imports: [JsonPipe], }) export class App { - // Immediate input - protected readonly instantInput = signal('') - - // Throttled value (500ms) + throttler state - protected readonly throttled = injectThrottledValue( - this.instantInput, - { wait: 500 }, - (state) => ({ - isPending: state.isPending, - }), + readonly instantCount = signal(0) + readonly search = signal('') + readonly currentValue = signal(50) + readonly instantExecutions = signal(1) + readonly controlledCount = injectThrottledValue( + this.instantCount, + 0, + () => ({ wait: 1000 }), + (state) => state, ) - protected readonly throttler = this.throttled.throttler - - protected onInput(e: Event): void { - const v = (e.target as HTMLInputElement).value - this.instantInput.set(v) + readonly countRunner = this.controlledCount.throttler + readonly controlledSearch = injectThrottledValue( + this.search, + '', + () => ({ wait: 1000 }), + (state) => state, + ) + readonly searchRunner = this.controlledSearch.throttler + readonly controlledValue = injectThrottledValue( + this.currentValue, + 50, + () => ({ wait: 250 }), + (state) => state, + ) + readonly rangeRunner = this.controlledValue.throttler + increment(): void { + const next = this.instantCount() + 1 + this.instantCount.set(next) } - - protected clear(): void { - this.instantInput.set('') + onSearch(value: string): void { + this.search.set(value) } - - protected hammerInput(): void { - for (let i = 0; i < 12; i++) { - this.instantInput.set(`${Date.now()}-${i}`) - } + onRange(value: number): void { + this.currentValue.set(value) + this.instantExecutions.update((count) => count + 1) } - - protected cancelPending(): void { - this.throttler.cancel() + reduction(): number { + const count = this.instantExecutions() + return count + ? Math.round( + ((count - this.rangeRunner.state().executionCount) / count) * 100, + ) + : 0 } } diff --git a/examples/angular/injectThrottler/src/app/app.html b/examples/angular/injectThrottler/src/app/app.html index d512d4386..e23179425 100644 --- a/examples/angular/injectThrottler/src/app/app.html +++ b/examples/angular/injectThrottler/src/app/app.html @@ -1,12 +1,58 @@ -

TanStack Pacer - injectThrottler

- -

Click rapidly. "Instant" updates every click; "Throttled" updates at most once per second.

- - - -

Instant: {{ instant() }}

-

Throttled: {{ throttled() }}

- -

Status: {{ throttler.state().status }}

-

Pending: {{ throttler.state().isPending }}

-

Executions: {{ throttler.state().executionCount }}

+

TanStack Pacer injectThrottler Example

+
+

Counter

+ +

Instant Count: {{ instantCount() }}

+

Throttled Count: {{ controlledCount() }}

+

Executions: {{ countRunner.state().executionCount }}

+

Pending: {{ countRunner.state().isPending }}

+ +
{{ countRunner.state() | json }}
+
+
+

Search

+

Enabled when the search contains more than 2 characters.

+ +

Instant Search: {{ search() }}

+

Throttled Search: {{ controlledSearch() }}

+

Executions: {{ searchRunner.state().executionCount }}

+

Pending: {{ searchRunner.state().isPending }}

+ +
{{ searchRunner.state() | json }}
+
+
+

Range

+ +

Current Value: {{ currentValue() }}

+ +

Throttled Value: {{ controlledValue() }}

+

Executions: {{ rangeRunner.state().executionCount }}

+

Pending: {{ rangeRunner.state().isPending }}

+

Instant Executions: {{ instantExecutions() }}

+

+ Saved Executions: {{ instantExecutions() - + rangeRunner.state().executionCount }} +

+

Reduction: {{ reduction() }}%

+ +
{{ rangeRunner.state() | json }}
+

Throttled with a 250 ms wait.

+
diff --git a/examples/angular/injectThrottler/src/app/app.spec.ts b/examples/angular/injectThrottler/src/app/app.spec.ts index f9baf7f75..13dd288e8 100644 --- a/examples/angular/injectThrottler/src/app/app.spec.ts +++ b/examples/angular/injectThrottler/src/app/app.spec.ts @@ -1,3 +1,4 @@ +import { vi } from 'vitest' import { TestBed } from '@angular/core/testing' import { App } from './app' @@ -18,6 +19,71 @@ describe('App', () => { const fixture = TestBed.createComponent(App) await fixture.whenStable() const compiled = fixture.nativeElement as HTMLElement - expect(compiled.querySelector('h1')?.textContent).toContain('Hello, basic') + expect(compiled.querySelector('h1')?.textContent).toContain( + 'TanStack Pacer injectThrottler Example', + ) + }) +}) + +// Exercise the real adapter and component with a deterministic clock. +describe('example behavior', () => { + beforeEach(async () => { + await TestBed.configureTestingModule({ imports: [App] }).compileComponents() + }) + afterEach(() => { + TestBed.resetTestingModule() + vi.clearAllTimers() + vi.useRealTimers() + vi.restoreAllMocks() + }) + it('runs the counter, search, and range scenarios', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }) + const fixture = TestBed.createComponent(App) + const app = fixture.componentInstance + fixture.detectChanges() + TestBed.tick() + app.increment() + app.increment() + app.increment() + app.onSearch('angular') + app.onRange(73) + TestBed.tick() + await vi.advanceTimersByTimeAsync(1100) + TestBed.tick() + expect(app.controlledCount()).toBe(3) + expect(app.controlledSearch()).toBe('angular') + expect(app.controlledValue()).toBe(73) + }) + it('gates short search text and accepts the same event that passes the condition', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }) + const fixture = TestBed.createComponent(App) + const app = fixture.componentInstance + fixture.detectChanges() + TestBed.tick() + app.onSearch('ab') + TestBed.tick() + await vi.advanceTimersByTimeAsync(1100) + expect(app.controlledSearch()).toBe('') + app.onSearch('abc') + TestBed.tick() + await vi.advanceTimersByTimeAsync(1100) + expect(app.controlledSearch()).toBe('abc') + }) + it('cancels pending search work when the condition becomes false', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }) + const fixture = TestBed.createComponent(App) + const app = fixture.componentInstance + fixture.detectChanges() + TestBed.tick() + app.onSearch('abc') + TestBed.tick() + app.onSearch('abcd') + TestBed.tick() + expect(app.searchRunner.state().isPending).toBe(true) + app.onSearch('ab') + TestBed.tick() + expect(app.searchRunner.state().isPending).toBe(false) + await vi.advanceTimersByTimeAsync(1100) + expect(app.searchRunner.state().isPending).toBe(false) }) }) diff --git a/examples/angular/injectThrottler/src/app/app.ts b/examples/angular/injectThrottler/src/app/app.ts index b75f4e4e8..a29991cf6 100644 --- a/examples/angular/injectThrottler/src/app/app.ts +++ b/examples/angular/injectThrottler/src/app/app.ts @@ -1,26 +1,60 @@ import { Component, signal } from '@angular/core' +import { JsonPipe } from '@angular/common' import { injectThrottler } from '@tanstack/angular-pacer' @Component({ selector: 'app-root', templateUrl: './app.html', + imports: [JsonPipe], }) export class App { - protected readonly instant = signal(0) - protected readonly throttled = signal(0) - - protected readonly throttler = injectThrottler( - () => this.throttled.set(this.instant()), - { wait: 1000 }, - (s) => ({ - status: s.status, - isPending: s.isPending, - executionCount: s.executionCount, - }), + readonly instantCount = signal(0) + readonly search = signal('') + readonly currentValue = signal(50) + readonly instantExecutions = signal(0) + readonly controlledCount = signal(0) + readonly controlledSearch = signal('') + readonly controlledValue = signal(50) + readonly countRunner = injectThrottler( + (value: number) => this.controlledCount.set(value), + () => ({ wait: 1000 }), + (state) => state, ) - - protected increment(): void { - this.instant.update((v) => v + 1) - this.throttler.maybeExecute() + readonly searchRunner = injectThrottler( + (value: string) => this.controlledSearch.set(value), + () => { + // Update options when the signal changes so disabling cancels pending work. + // The callback reads the current event value before the next effect runs. + this.search() + return { wait: 1000, enabled: () => this.search().length > 2 } + }, + (state) => state, + ) + readonly rangeRunner = injectThrottler( + (value: number) => this.controlledValue.set(value), + () => ({ wait: 250 }), + (state) => state, + ) + increment(): void { + const next = this.instantCount() + 1 + this.instantCount.set(next) + this.countRunner.maybeExecute(next) + } + onSearch(value: string): void { + this.search.set(value) + this.searchRunner.maybeExecute(value) + } + onRange(value: number): void { + this.currentValue.set(value) + this.instantExecutions.update((count) => count + 1) + this.rangeRunner.maybeExecute(value) + } + reduction(): number { + const count = this.instantExecutions() + return count + ? Math.round( + ((count - this.rangeRunner.state().executionCount) / count) * 100, + ) + : 0 } } diff --git a/examples/angular/queue/src/app/app.html b/examples/angular/queue/src/app/app.html index d263103fc..91b419a98 100644 --- a/examples/angular/queue/src/app/app.html +++ b/examples/angular/queue/src/app/app.html @@ -1,74 +1,51 @@ -
-

TanStack Pacer - Queuer Example

- -
-

Queuer Example

-

- The queuer processes items synchronously in order, with a 1 second delay between each item. - Items are processed one at a time when the queue is started. -

- -
-

Queue Status

-
-
Queue Size: {{ queuer.state().size }} / 10
-
Status: {{ queuer.state().status }}
-
Is Empty: {{ queuer.state().isEmpty ? 'Yes' : 'No' }}
-
Is Full: {{ queuer.state().isFull ? 'Yes' : 'No' }}
-
Is Running: {{ queuer.state().isRunning ? 'Yes' : 'No' }}
-
Items Processed: {{ queuer.state().executionCount }}
-
- -
- Next Item to Process: - {{ queuer.peekNextItem() ?? 'None' }} -
- -
- Queue Items: -
- @if (queuer.state().items.length === 0) { - Queue is empty - } @for (item of queuer.state().items; track item; let i = $index) { - - {{ item }} @if (i < queuer.state().items.length - 1) { - , - } - - } -
-
-
- -
-

Controls

-
- @for (item of ['Task 1', 'Task 2', 'Task 3', 'Task 4', 'Task 5']; track item) { - - } - - - - - -
-
- -
-

Processed Items

-
- @if (processedItems.length === 0) { - No items processed yet - } @for (item of processedItems; track item; let i = $index) { -
{{ i + 1 }}. {{ item }}
- } -
-
-
- - -
+

TanStack Pacer queue Example

+
+

Number queue

+ +

Queue Size: {{ numberItems().length }}

+

Items Processed: {{ numberProcessed() }}

+

Queue Items: {{ numberItems().join(", ") }}

+
+
+

Text changes

+ +

Current Value: {{ queuedText() }}

+

Queue Size: {{ textItems().length }}

+

Items Processed: {{ textProcessed() }}

+

Queue Items: {{ textItems().join(", ") }}

+
+
+

Queued range

+ +

Instant Value: {{ currentValue() }}

+ +

Queued Value: {{ rangeValue() }}

+

Values Submitted: {{ instantExecutions() }}

+

Queue Size: {{ rangeItems().length }}

+

Items Processed: {{ rangeProcessed() }}

+

Queue Items: {{ rangeItems().join(", ") }}

+
diff --git a/examples/angular/queue/src/app/app.spec.ts b/examples/angular/queue/src/app/app.spec.ts index f9baf7f75..f3b6ec56d 100644 --- a/examples/angular/queue/src/app/app.spec.ts +++ b/examples/angular/queue/src/app/app.spec.ts @@ -1,3 +1,4 @@ +import { vi } from 'vitest' import { TestBed } from '@angular/core/testing' import { App } from './app' @@ -18,6 +19,38 @@ describe('App', () => { const fixture = TestBed.createComponent(App) await fixture.whenStable() const compiled = fixture.nativeElement as HTMLElement - expect(compiled.querySelector('h1')?.textContent).toContain('Hello, basic') + expect(compiled.querySelector('h1')?.textContent).toContain( + 'TanStack Pacer queue Example', + ) + }) +}) + +// Exercise the real adapter and component with a deterministic clock. +describe('example behavior', () => { + beforeEach(async () => { + await TestBed.configureTestingModule({ imports: [App] }).compileComponents() + }) + afterEach(() => { + TestBed.resetTestingModule() + vi.clearAllTimers() + vi.useRealTimers() + vi.restoreAllMocks() + }) + it('reports completed executions after processing each queue', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }) + const fixture = TestBed.createComponent(App) + const app = fixture.componentInstance + fixture.detectChanges() + TestBed.tick() + app.addNumber() + app.onSearch('angular') + app.onRange(72) + await vi.advanceTimersByTimeAsync(1100) + expect(app.numberProcessed()).toBe(1) + expect(app.textProcessed()).toBe(1) + expect(app.rangeProcessed()).toBe(1) + expect(app.queuedText()).toBe('angular') + expect(app.rangeValue()).toBe(72) + expect(app.rangeItems()).toEqual([]) }) }) diff --git a/examples/angular/queue/src/app/app.ts b/examples/angular/queue/src/app/app.ts index 18de47560..49bb42d33 100644 --- a/examples/angular/queue/src/app/app.ts +++ b/examples/angular/queue/src/app/app.ts @@ -1,76 +1,66 @@ -import { Component } from '@angular/core' -import { RouterOutlet } from '@angular/router' -import { injectQueuer } from '@tanstack/angular-pacer' +import { Component, signal } from '@angular/core' +import { queue } from '@tanstack/angular-pacer' -@Component({ - selector: 'app-root', - imports: [RouterOutlet], - templateUrl: './app.html', - styleUrl: './app.css', -}) +@Component({ selector: 'app-root', templateUrl: './app.html' }) export class App { - protected readonly processedItems: Array = [] - - // Queuer example - processes items one at a time with a 1 second delay - protected readonly queuer = injectQueuer< - string, + readonly source = signal('') + readonly currentValue = signal(50) + readonly rangeValue = signal(50) + readonly instantExecutions = signal(0) + readonly numberItems = signal>([]) + readonly textItems = signal>([]) + readonly rangeItems = signal>([]) + readonly numberProcessed = signal(0) + readonly textProcessed = signal(0) + readonly rangeProcessed = signal(0) + readonly queuedText = signal('') + private readonly numberRunner = queue( + (item) => { + console.log('Processed', item) + }, { - items: Array - size: number - status: string - executionCount: number - isEmpty: boolean - isFull: boolean - isRunning: boolean - } - >( + maxSize: 25, + wait: 1000, + onItemsChange: (queuer) => this.numberItems.set(queuer.peekAllItems()), + onExecute: (_item, queuer) => + this.numberProcessed.set(queuer.store.state.executionCount), + }, + ) + private readonly textRunner = queue( (item) => { - console.log('Processing item:', item) - this.processedItems.push(item) + this.queuedText.set(item) }, { - started: false, - wait: 1000, // Wait 1 second between processing items - maxSize: 10, + maxSize: 100, + wait: 500, + onItemsChange: (queuer) => this.textItems.set(queuer.peekAllItems()), + onExecute: (_item, queuer) => + this.textProcessed.set(queuer.store.state.executionCount), }, - (state) => ({ - items: state.items, - size: state.size, - status: state.status, - executionCount: state.executionCount, - isEmpty: state.isEmpty, - isFull: state.isFull, - isRunning: state.isRunning, - }), ) - - protected addItem(item: string): void { - if (!this.queuer.state().isFull) { - this.queuer.addItem(item) - } - } - - protected processNext(): void { - const item = this.queuer.getNextItem() - if (item !== undefined) { - console.log('Manually processed item:', item) - } - } - - protected startProcessing(): void { - this.queuer.start() - } - - protected stopProcessing(): void { - this.queuer.stop() + private readonly rangeRunner = queue( + (item) => { + this.rangeValue.set(item) + }, + { + maxSize: 100, + wait: 100, + onItemsChange: (queuer) => this.rangeItems.set(queuer.peekAllItems()), + onExecute: (_item, queuer) => + this.rangeProcessed.set(queuer.store.state.executionCount), + }, + ) + addNumber(): void { + const items = this.numberItems() + this.numberRunner(items.length ? items[items.length - 1]! + 1 : 1) } - - protected clearQueue(): void { - this.queuer.clear() + onSearch(value: string): void { + this.source.set(value) + this.textRunner(value) } - - protected resetQueue(): void { - this.queuer.reset() - this.processedItems.length = 0 + onRange(value: number): void { + this.currentValue.set(value) + this.instantExecutions.update((count) => count + 1) + this.rangeRunner(value) } } diff --git a/examples/angular/rateLimit/src/app/app.html b/examples/angular/rateLimit/src/app/app.html index e35faff59..46242a50f 100644 --- a/examples/angular/rateLimit/src/app/app.html +++ b/examples/angular/rateLimit/src/app/app.html @@ -1,162 +1,93 @@ -
-

TanStack Pacer - Rate Limiter Example

- -
-

Rate Limiter Example

-

- Rate limiting enforces a hard limit: allows executions until the limit is reached (5 per 5 - seconds), then blocks all subsequent calls until the window resets. Click the increment button - rapidly to see the rate limiter in action. -

- -
-

Window Type

-
- - -
-

- Fixed Window: Strict window that resets completely after the period.
- Sliding Window: Rolling window that allows executions as old ones expire. -

-
- -
-

Rate Limiter Status

-
-
Execution Count: {{ rateLimiter.state().executionCount }}
-
Rejection Count: {{ rateLimiter.state().rejectionCount }}
-
- Remaining in Window: {{ rateLimiter.getRemainingInWindow() }} / 5 -
-
Ms Until Next Window: {{ rateLimiter.getMsUntilNextWindow() }}ms
-
-
- -
-

Counters

-
-
- Instant Count: -
- {{ instantCount() }} -
-
- Updates immediately on every click -
-
-
- Rate Limited Count: -
- {{ limitedCount() }} -
-
- Only updates when rate limit allows -
-
-
-
- -
-

Controls

-
- - -
-

- Click "Increment" rapidly to see the rate limiter block executions after 5 clicks within 5 - seconds. -

-
- -
-

Execution History

-
- @if (executionHistory.length === 0) { - No executions yet. Click "Increment" to start. - } @for (entry of executionHistory; track $index) { -
-
-
{{ entry.timestamp }} - Count: {{ entry.count }}
- - {{ entry.rejected ? 'REJECTED' : 'EXECUTED' }} - -
-
- } -
-
-
- - -
+

TanStack Pacer rateLimit Example

+
+

Counter

+
+ Window type + +
+ +

Instant Count: {{ instantCount() }}

+

Rate Limited Count: {{ controlledCount() }}

+
+
+

Search

+
+ Window type + +
+ +

Instant Search: {{ search() }}

+

Rate Limited Search: {{ controlledSearch() }}

+
+
+

Range

+
+ Window type + +
+ +

Current Value: {{ currentValue() }}

+ +

Rate Limited Value: {{ controlledValue() }}

+

Rate limited to 30 updates per 2 seconds.

+
diff --git a/examples/angular/rateLimit/src/app/app.spec.ts b/examples/angular/rateLimit/src/app/app.spec.ts index f9baf7f75..67176eb6f 100644 --- a/examples/angular/rateLimit/src/app/app.spec.ts +++ b/examples/angular/rateLimit/src/app/app.spec.ts @@ -1,3 +1,4 @@ +import { vi } from 'vitest' import { TestBed } from '@angular/core/testing' import { App } from './app' @@ -18,6 +19,39 @@ describe('App', () => { const fixture = TestBed.createComponent(App) await fixture.whenStable() const compiled = fixture.nativeElement as HTMLElement - expect(compiled.querySelector('h1')?.textContent).toContain('Hello, basic') + expect(compiled.querySelector('h1')?.textContent).toContain( + 'TanStack Pacer rateLimit Example', + ) + }) +}) + +// Exercise the real adapter and component with a deterministic clock. +describe('example behavior', () => { + beforeEach(async () => { + await TestBed.configureTestingModule({ imports: [App] }).compileComponents() + }) + afterEach(() => { + TestBed.resetTestingModule() + vi.clearAllTimers() + vi.useRealTimers() + vi.restoreAllMocks() + }) + it('runs the counter, search, and range scenarios', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }) + const fixture = TestBed.createComponent(App) + const app = fixture.componentInstance + fixture.detectChanges() + TestBed.tick() + app.increment() + app.increment() + app.increment() + app.onSearch('angular') + app.onRange(73) + TestBed.tick() + await vi.advanceTimersByTimeAsync(1100) + TestBed.tick() + expect(app.controlledCount()).toBe(3) + expect(app.controlledSearch()).toBe('angular') + expect(app.controlledValue()).toBe(73) }) }) diff --git a/examples/angular/rateLimit/src/app/app.ts b/examples/angular/rateLimit/src/app/app.ts index b54b5da0c..8068cef96 100644 --- a/examples/angular/rateLimit/src/app/app.ts +++ b/examples/angular/rateLimit/src/app/app.ts @@ -1,81 +1,75 @@ import { Component, signal } from '@angular/core' -import { RouterOutlet } from '@angular/router' -import { injectRateLimiter } from '@tanstack/angular-pacer' +import { rateLimit } from '@tanstack/angular-pacer' -@Component({ - selector: 'app-root', - imports: [RouterOutlet], - templateUrl: './app.html', - styleUrl: './app.css', -}) +@Component({ selector: 'app-root', templateUrl: './app.html', imports: [] }) export class App { - protected readonly windowType = signal<'fixed' | 'sliding'>('fixed') - protected readonly instantCount = signal(0) - protected readonly limitedCount = signal(0) - protected readonly executionHistory: Array<{ - timestamp: string - count: number - rejected: boolean - }> = [] - - // Rate limiter: allows 5 executions per 5 seconds - protected readonly rateLimiter = injectRateLimiter< - (count: number) => void, - { - executionCount: number - rejectionCount: number - executionTimes: Array - } - >( - (count: number) => { - console.log('Rate-limited execution:', count) - this.limitedCount.set(count) - this.executionHistory.push({ - timestamp: new Date().toLocaleTimeString(), - count, - rejected: false, - }) - }, - { + readonly instantCount = signal(0) + readonly search = signal('') + readonly currentValue = signal(50) + readonly controlledCount = signal(0) + readonly controlledSearch = signal('') + readonly controlledValue = signal(50) + readonly countWindow = signal<'fixed' | 'sliding'>('fixed') + readonly searchWindow = signal<'fixed' | 'sliding'>('fixed') + readonly rangeWindow = signal<'fixed' | 'sliding'>('fixed') + private createCountRunner() { + return rateLimit((value: number) => this.controlledCount.set(value), { limit: 5, - window: 5000, // 5 seconds - windowType: this.windowType(), - onReject: () => { - console.log('Rejected by rate limiter', this.rateLimiter.getMsUntilNextWindow()) - this.executionHistory.push({ - timestamp: new Date().toLocaleTimeString(), - count: this.instantCount(), - rejected: true, - }) - }, - }, - (state) => ({ - executionCount: state.executionCount, - rejectionCount: state.rejectionCount, - executionTimes: state.executionTimes, - }), - ) - - protected increment(): void { - // Update instant count immediately - this.instantCount.update((c) => { - const newCount = c + 1 - // Try to execute with rate limiter - this.rateLimiter.maybeExecute(newCount) - return newCount + window: 5000, + windowType: this.countWindow(), + onReject: (limiter) => + console.log('Rejected; retry in', limiter.getMsUntilNextWindow(), 'ms'), }) } - - protected reset(): void { - this.rateLimiter.reset() - this.instantCount.set(0) - this.limitedCount.set(0) - this.executionHistory.length = 0 + private countRunner = this.createCountRunner() + private createSearchRunner() { + return rateLimit((value: string) => this.controlledSearch.set(value), { + limit: 5, + window: 5000, + windowType: this.searchWindow(), + onReject: (limiter) => + console.log('Rejected; retry in', limiter.getMsUntilNextWindow(), 'ms'), + }) } - - protected setWindowType(type: 'fixed' | 'sliding'): void { - this.windowType.set(type) - // Note: windowType change requires recreating the rate limiter in a real app - // For this example, we'll just update the signal + private searchRunner = this.createSearchRunner() + private createRangeRunner() { + return rateLimit((value: number) => this.controlledValue.set(value), { + limit: 30, + window: 2000, + windowType: this.rangeWindow(), + onReject: (limiter) => + console.log('Rejected; retry in', limiter.getMsUntilNextWindow(), 'ms'), + }) + } + private rangeRunner = this.createRangeRunner() + increment(): void { + const next = this.instantCount() + 1 + this.instantCount.set(next) + this.countRunner(next) + } + onSearch(value: string): void { + this.search.set(value) + this.searchRunner(value) + } + onRange(value: number): void { + this.currentValue.set(value) + this.rangeRunner(value) + } + changeWindow( + scenario: 'count' | 'search' | 'range', + value: 'fixed' | 'sliding', + ): void { + if (scenario === 'count') { + this.countWindow.set(value) + this.countRunner = this.createCountRunner() + } + if (scenario === 'search') { + this.searchWindow.set(value) + this.searchRunner = this.createSearchRunner() + } + if (scenario === 'range') { + this.rangeWindow.set(value) + this.rangeRunner = this.createRangeRunner() + } } } diff --git a/examples/angular/throttle/src/app/app.html b/examples/angular/throttle/src/app/app.html index 5c5d728ab..1e6730b94 100644 --- a/examples/angular/throttle/src/app/app.html +++ b/examples/angular/throttle/src/app/app.html @@ -1,16 +1,39 @@ -

TanStack Pacer - Throttler Example

- -

- Throttling executes the function at most once per second. Click the button rapidly to see - throttling in action. -

- - - -

Instant count: {{ instantCount() }}

- -

Throttled count: {{ throttledCount() }}

- -

Is pending: {{ throttler.state().isPending }}

- -

Execution count: {{ throttler.state().executionCount }}

+

TanStack Pacer throttle Example

+
+

Counter

+ +

Instant Count: {{ instantCount() }}

+

Throttled Count: {{ controlledCount() }}

+
+
+

Search

+ +

Instant Search: {{ search() }}

+

Throttled Search: {{ controlledSearch() }}

+
+
+

Range

+ +

Current Value: {{ currentValue() }}

+ +

Throttled Value: {{ controlledValue() }}

+

Throttled with a 250 ms wait.

+
diff --git a/examples/angular/throttle/src/app/app.spec.ts b/examples/angular/throttle/src/app/app.spec.ts index f9baf7f75..ab8444bbe 100644 --- a/examples/angular/throttle/src/app/app.spec.ts +++ b/examples/angular/throttle/src/app/app.spec.ts @@ -1,3 +1,4 @@ +import { vi } from 'vitest' import { TestBed } from '@angular/core/testing' import { App } from './app' @@ -18,6 +19,39 @@ describe('App', () => { const fixture = TestBed.createComponent(App) await fixture.whenStable() const compiled = fixture.nativeElement as HTMLElement - expect(compiled.querySelector('h1')?.textContent).toContain('Hello, basic') + expect(compiled.querySelector('h1')?.textContent).toContain( + 'TanStack Pacer throttle Example', + ) + }) +}) + +// Exercise the real adapter and component with a deterministic clock. +describe('example behavior', () => { + beforeEach(async () => { + await TestBed.configureTestingModule({ imports: [App] }).compileComponents() + }) + afterEach(() => { + TestBed.resetTestingModule() + vi.clearAllTimers() + vi.useRealTimers() + vi.restoreAllMocks() + }) + it('runs the counter, search, and range scenarios', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }) + const fixture = TestBed.createComponent(App) + const app = fixture.componentInstance + fixture.detectChanges() + TestBed.tick() + app.increment() + app.increment() + app.increment() + app.onSearch('angular') + app.onRange(73) + TestBed.tick() + await vi.advanceTimersByTimeAsync(1100) + TestBed.tick() + expect(app.controlledCount()).toBe(3) + expect(app.controlledSearch()).toBe('angular') + expect(app.controlledValue()).toBe(73) }) }) diff --git a/examples/angular/throttle/src/app/app.ts b/examples/angular/throttle/src/app/app.ts index e15e35c45..5f5901988 100644 --- a/examples/angular/throttle/src/app/app.ts +++ b/examples/angular/throttle/src/app/app.ts @@ -1,33 +1,37 @@ import { Component, signal } from '@angular/core' +import { throttle } from '@tanstack/angular-pacer' -import { injectThrottler } from '@tanstack/angular-pacer' - -@Component({ - selector: 'app-root', - imports: [], - templateUrl: './app.html', -}) +@Component({ selector: 'app-root', templateUrl: './app.html', imports: [] }) export class App { - protected readonly instantCount = signal(0) - protected readonly throttledCount = signal(0) - - // Throttler: executes at most once per second - protected readonly throttler = injectThrottler( - () => { - console.log('Throttled execution') - this.throttledCount.set(this.instantCount()) - }, - { - wait: 1000, // 1 second - }, - (state) => ({ - isPending: state.isPending, - executionCount: state.executionCount, - }), + readonly instantCount = signal(0) + readonly search = signal('') + readonly currentValue = signal(50) + readonly controlledCount = signal(0) + readonly controlledSearch = signal('') + readonly controlledValue = signal(50) + readonly countRunner = throttle( + (value: number) => this.controlledCount.set(value), + { wait: 1000 }, ) - - protected increment(): void { - this.instantCount.update((c) => c + 1) - this.throttler.maybeExecute() + readonly searchRunner = throttle( + (value: string) => this.controlledSearch.set(value), + { wait: 1000 }, + ) + readonly rangeRunner = throttle( + (value: number) => this.controlledValue.set(value), + { wait: 250 }, + ) + increment(): void { + const next = this.instantCount() + 1 + this.instantCount.set(next) + this.countRunner(next) + } + onSearch(value: string): void { + this.search.set(value) + this.searchRunner(value) + } + onRange(value: number): void { + this.currentValue.set(value) + this.rangeRunner(value) } } diff --git a/examples/preact/asyncBatch/src/index.tsx b/examples/preact/asyncBatch/src/index.tsx index 45af67a9c..23639e975 100644 --- a/examples/preact/asyncBatch/src/index.tsx +++ b/examples/preact/asyncBatch/src/index.tsx @@ -1,4 +1,4 @@ -import { useCallback, useState } from 'preact/hooks' +import { useCallback, useRef, useState } from 'preact/hooks' import { render } from 'preact' import { asyncBatch } from '@tanstack/preact-pacer/async-batcher' @@ -18,6 +18,8 @@ function App() { const [pendingItems, setPendingItems] = useState>([]) const [isProcessing, setIsProcessing] = useState(false) const [shouldFail, setShouldFail] = useState(false) + const shouldFailRef = useRef(shouldFail) + shouldFailRef.current = shouldFail const [successCount, setSuccessCount] = useState(0) const [errorCount, setErrorCount] = useState(0) @@ -32,7 +34,7 @@ function App() { await new Promise((resolve) => setTimeout(resolve, fakeProcessingTime)) // Simulate occasional failures for demo purposes - if (shouldFail && Math.random() < 0.3) { + if (shouldFailRef.current && Math.random() < 0.3) { throw new Error( `Processing failed for batch with ${items.length} items`, ) @@ -62,11 +64,11 @@ function App() { setIsProcessing(false) } }, - [shouldFail], + [], ) - // Create the async batcher function using useCallback - const addToBatch = useCallback( + // Keep one batcher while the processing callback reads the current failure flag. + const [addToBatch] = useState(() => asyncBatch(processBatch, { maxSize: 5, wait: 3000, @@ -97,7 +99,6 @@ function App() { ) }, }), - [], // must be memoized to avoid re-creating the batcher on every render (consider using useAsyncBatcher instead in preact) ) const addItem = (isUrgent = false) => { diff --git a/examples/preact/debounce/src/index.tsx b/examples/preact/debounce/src/index.tsx index 9d6449e2c..3544eaef0 100644 --- a/examples/preact/debounce/src/index.tsx +++ b/examples/preact/debounce/src/index.tsx @@ -138,7 +138,7 @@ function App3() { min="0" max="100" value={debouncedValue} - readOnly + disabled style={{ width: '100%' }} /> {debouncedValue} diff --git a/examples/preact/queue/package.json b/examples/preact/queue/package.json index 8b525e9d1..a424eb3eb 100644 --- a/examples/preact/queue/package.json +++ b/examples/preact/queue/package.json @@ -9,7 +9,9 @@ "test:types": "tsc" }, "dependencies": { + "@tanstack/preact-devtools": "^0.10.13", "@tanstack/preact-pacer": "^0.24.0", + "@tanstack/preact-pacer-devtools": "workspace:*", "preact": "^10.29.8" }, "devDependencies": { diff --git a/examples/preact/queue/src/index.tsx b/examples/preact/queue/src/index.tsx index eb9c26789..1941e51f9 100644 --- a/examples/preact/queue/src/index.tsx +++ b/examples/preact/queue/src/index.tsx @@ -2,6 +2,8 @@ import { useCallback, useState } from 'preact/hooks' import { render } from 'preact' import type { JSX } from 'preact' import { queue } from '@tanstack/preact-pacer/queuer' +import { pacerDevtoolsPlugin } from '@tanstack/preact-pacer-devtools' +import { TanStackDevtools } from '@tanstack/preact-devtools' function App1() { const [queueItems, setQueueItems] = useState>([]) @@ -19,6 +21,8 @@ function App1() { wait: 1000, onItemsChange: (queue) => { setQueueItems(queue.peekAllItems()) + }, + onExecute: (_item, queue) => { setProcessedCount(queue.store.state.executionCount) }, }), @@ -77,6 +81,8 @@ function App2() { wait: 500, onItemsChange: (queue) => { setQueueItems(queue.peekAllItems()) + }, + onExecute: (_item, queue) => { setProcessedCount(queue.store.state.executionCount) }, }), @@ -144,6 +150,8 @@ function App3() { wait: 100, onItemsChange: (queue) => { setQueueItems(queue.peekAllItems()) + }, + onExecute: (_item, queue) => { setProcessedCount(queue.store.state.executionCount) }, }), @@ -181,7 +189,7 @@ function App3() { min="0" max="100" value={queuedValue} - readOnly + disabled style={{ width: '100%' }} /> {queuedValue} @@ -215,6 +223,10 @@ render(
+ , root, ) diff --git a/examples/preact/rateLimit/src/index.tsx b/examples/preact/rateLimit/src/index.tsx index 36ee91787..7265dc4ad 100644 --- a/examples/preact/rateLimit/src/index.tsx +++ b/examples/preact/rateLimit/src/index.tsx @@ -134,7 +134,7 @@ function App2() { type="search" value={text} onInput={handleTextChange} - placeholder="Type text (rate limited to 3 updates per 5 seconds)..." + placeholder="Type text (rate limited to 5 updates per 5 seconds)..." style={{ width: '100%' }} /> @@ -227,7 +227,7 @@ function App3() { min="0" max="100" value={rateLimitedValue} - readOnly + disabled style={{ width: '100%' }} /> {rateLimitedValue} diff --git a/examples/preact/throttle/src/index.tsx b/examples/preact/throttle/src/index.tsx index 9b4634a53..cb0b6c47f 100644 --- a/examples/preact/throttle/src/index.tsx +++ b/examples/preact/throttle/src/index.tsx @@ -139,7 +139,7 @@ function App3() { min="0" max="100" value={throttledValue} - readOnly + disabled style={{ width: '100%' }} /> {throttledValue} diff --git a/examples/preact/useAsyncBatchedCallback/src/index.tsx b/examples/preact/useAsyncBatchedCallback/src/index.tsx index 5c256ab06..9715b8a83 100644 --- a/examples/preact/useAsyncBatchedCallback/src/index.tsx +++ b/examples/preact/useAsyncBatchedCallback/src/index.tsx @@ -42,11 +42,6 @@ function App1() { setResults((current) => [...current, ...data]) setBatchesProcessed((count) => count + 1) return data - } catch (err) { - const errorMessage = - err instanceof Error ? err.message : 'Unknown error' - setError(errorMessage) - throw err } finally { setIsLoading(false) } @@ -54,6 +49,10 @@ function App1() { { maxSize: 3, // Process when 3 queries collected wait: 2000, // Or after 2 seconds + throwOnError: false, + onError: (error) => { + setError(error instanceof Error ? error.message : 'Unknown error') + }, }, ) diff --git a/examples/preact/useAsyncBatcher/src/index.tsx b/examples/preact/useAsyncBatcher/src/index.tsx index f40d032fb..542a3ebf3 100644 --- a/examples/preact/useAsyncBatcher/src/index.tsx +++ b/examples/preact/useAsyncBatcher/src/index.tsx @@ -40,34 +40,40 @@ function App() { } // No selector needed - we'll use Subscribe HOC to subscribe to state in the component tree - const asyncBatcher = useAsyncBatcher(processBatch, { - maxSize: 5, // Process in batches of 5 (if reached before wait time) - wait: 4000, // Wait up to 4 seconds before processing a batch - getShouldExecute: (items) => - items.some((item) => item.value.includes('urgent')), // Process immediately if any item is marked urgent - throwOnError: false, // Don't throw errors, handle them via onError - onSuccess: (result, batch, batcher) => { - console.log('Batch succeeded:', result) - console.log('Processed batch:', batch) - console.log('Total successful batches:', batcher.store.state.successCount) + const asyncBatcher = useAsyncBatcher( + processBatch, + { + maxSize: 5, // Process in batches of 5 (if reached before wait time) + wait: 4000, // Wait up to 4 seconds before processing a batch + getShouldExecute: (items) => + items.some((item) => item.value.includes('urgent')), // Process immediately if any item is marked urgent + throwOnError: false, // Don't throw errors, handle them via onError + onSuccess: (result, batch, batcher) => { + console.log('Batch succeeded:', result) + console.log('Processed batch:', batch) + console.log( + 'Total successful batches:', + batcher.store.state.successCount, + ) + }, + onError: (error: any, _batcher) => { + console.error('Batch failed:', error) + setErrors((prev) => [ + ...prev, + `Error: ${error} (${new Date().toLocaleTimeString()})`, + ]) + }, + onSettled: (batch, batcher) => { + console.log('Batch settled:', batch) + console.log( + 'Total processed items:', + batcher.store.state.totalItemsProcessed, + ) + }, }, - onError: (error: any, _batcher) => { - console.error('Batch failed:', error) - setErrors((prev) => [ - ...prev, - `Error: ${error} (${new Date().toLocaleTimeString()})`, - ]) - }, - onSettled: (batch, batcher) => { - console.log('Batch settled:', batch) - console.log( - 'Total processed items:', - batcher.store.state.totalItemsProcessed, - ) - }, - }) - // Alternative to asyncBatcher.Subscribe: pass a selector as 3rd arg to cause re-renders and subscribe to state - // (state) => state, + // Alternative to asyncBatcher.Subscribe: pass a selector as 3rd arg to cause re-renders and subscribe to state + // (state) => state, + ) const addItem = (isUrgent = false) => { const nextId = Date.now() diff --git a/examples/preact/useAsyncDebouncer/src/index.tsx b/examples/preact/useAsyncDebouncer/src/index.tsx index 1c7154bf9..92ed1a1ce 100644 --- a/examples/preact/useAsyncDebouncer/src/index.tsx +++ b/examples/preact/useAsyncDebouncer/src/index.tsx @@ -39,22 +39,25 @@ function App() { // hook that gives you an async debouncer instance // No selector needed - we'll use Subscribe HOC to subscribe to state in the component tree - const asyncDebouncer = useAsyncDebouncer(handleSearch, { - // leading: true, // optional leading execution - wait: 500, // Wait 500ms between API calls - onError: (error) => { - // optional error handler - console.error('Search failed:', error) - setResults([]) - }, - // throwOnError: true, - asyncRetryerOptions: { - maxAttempts: 3, - maxExecutionTime: 1000, + const asyncDebouncer = useAsyncDebouncer( + handleSearch, + { + // leading: true, // optional leading execution + wait: 500, // Wait 500ms between API calls + onError: (error) => { + // optional error handler + console.error('Search failed:', error) + setResults([]) + }, + // throwOnError: true, + asyncRetryerOptions: { + maxAttempts: 3, + maxExecutionTime: 3000, + }, }, - }) - // Alternative to asyncDebouncer.Subscribe: pass a selector as 3rd arg to cause re-renders and subscribe to state - // (state) => state, + // Alternative to asyncDebouncer.Subscribe: pass a selector as 3rd arg to cause re-renders and subscribe to state + // (state) => state, + ) // get and name our debounced function const handleSearchDebounced = asyncDebouncer.maybeExecute diff --git a/examples/preact/useAsyncQueuedState/src/index.tsx b/examples/preact/useAsyncQueuedState/src/index.tsx index 36217f991..be846122c 100644 --- a/examples/preact/useAsyncQueuedState/src/index.tsx +++ b/examples/preact/useAsyncQueuedState/src/index.tsx @@ -16,31 +16,32 @@ function App() { console.log(`Processed ${item}`) } - // Note: useAsyncQueuedState requires items in selector, but we'll use Subscribe for reactive rendering - const [queueItems, asyncQueuer] = useAsyncQueuedState(processItem, { - // your function to queue/process items - maxSize: 25, - initialItems: Array.from({ length: 10 }, (_, i) => i + 1), - concurrency, // Process 2 items concurrently - started: false, - wait: 100, // for demo purposes - usually you would not want extra wait time if you are also throttling with concurrency - onReject: (item: Item, asyncQueuer) => { - console.log( - 'Queue is full, rejecting item', - item, - asyncQueuer.store.state.rejectionCount, - ) + const [queueItems, asyncQueuer] = useAsyncQueuedState( + processItem, + { + // your function to queue/process items + maxSize: 25, + initialItems: Array.from({ length: 10 }, (_, i) => i + 1), + concurrency, // Process 2 items concurrently + started: false, + wait: 100, // for demo purposes - usually you would not want extra wait time if you are also throttling with concurrency + onReject: (item: Item, asyncQueuer) => { + console.log( + 'Queue is full, rejecting item', + item, + asyncQueuer.store.state.rejectionCount, + ) + }, + onError: (error, item: Item, asyncQueuer) => { + console.error( + `Error processing item: ${item}`, + error, + asyncQueuer.store.state.errorCount, + ) // optionally, handle errors here instead of your own try/catch + }, }, - onError: (error, item: Item, asyncQueuer) => { - console.error( - `Error processing item: ${item}`, - error, - asyncQueuer.store.state.errorCount, - ) // optionally, handle errors here instead of your own try/catch - }, - // Alternative to asyncQueuer.Subscribe: pass a selector as 3rd arg to cause re-renders and subscribe to state - // (state) => state, - }) + (state) => ({ items: state.items }), + ) return (
@@ -54,6 +55,7 @@ function App() { status: state.status, successCount: state.successCount, rejectionCount: state.rejectionCount, + activeItems: state.activeItems, items: state.items, isRunning: state.isRunning, })} @@ -66,6 +68,7 @@ function App() { status, successCount, rejectionCount, + activeItems, isRunning, }) => ( <> @@ -78,8 +81,8 @@ function App() {
Queuer Status: {status}
Items Processed: {successCount}
Items Rejected: {rejectionCount}
-
Active Tasks: {asyncQueuer.peekActiveItems().length}
-
Pending Tasks: {asyncQueuer.peekPendingItems().length}
+
Active Tasks: {activeItems.length}
+
Pending Tasks: {queueItems.length}
Concurrency:{' '} i + 1), - concurrency, // Process 2 items concurrently - started: false, - wait: 100, // for demo purposes - usually you would not want extra wait time if you are also throttling with concurrency - onReject: (item, asyncQueuer) => { - console.log( - 'Queue is full, rejecting item', - item, - asyncQueuer.store.state.rejectionCount, - ) + const asyncQueuer = useAsyncQueuer( + processItem, + { + // your function to queue/process items + maxSize: 25, + initialItems: Array.from({ length: 10 }, (_, i) => i + 1), + concurrency, // Process 2 items concurrently + started: false, + wait: 100, // for demo purposes - usually you would not want extra wait time if you are also throttling with concurrency + onReject: (item, asyncQueuer) => { + console.log( + 'Queue is full, rejecting item', + item, + asyncQueuer.store.state.rejectionCount, + ) + }, + onError: (error, item: Item, asyncQueuer) => { + console.error( + `Error processing item: ${item}`, + error, + asyncQueuer.store.state.errorCount, + ) // optionally, handle errors here instead of your own try/catch + }, }, - onError: (error, item: Item, asyncQueuer) => { - console.error( - `Error processing item: ${item}`, - error, - asyncQueuer.store.state.errorCount, - ) // optionally, handle errors here instead of your own try/catch - }, - }) - // Alternative to asyncQueuer.Subscribe: pass a selector as 3rd arg to cause re-renders and subscribe to state - // (state) => state, + // Alternative to asyncQueuer.Subscribe: pass a selector as 3rd arg to cause re-renders and subscribe to state + // (state) => state, + ) return (
diff --git a/examples/preact/useAsyncRateLimiter/src/index.tsx b/examples/preact/useAsyncRateLimiter/src/index.tsx index 1e8eccac7..eede437a7 100644 --- a/examples/preact/useAsyncRateLimiter/src/index.tsx +++ b/examples/preact/useAsyncRateLimiter/src/index.tsx @@ -41,24 +41,27 @@ function App() { // hook that gives you an async rate limiter instance // No selector needed - we'll use Subscribe HOC to subscribe to state in the component tree - const setSearchAsyncRateLimiter = useAsyncRateLimiter(handleSearch, { - windowType: windowType, - limit: 3, // Maximum 2 requests - window: 3000, // per 1 second - onReject: (_args, rateLimiter) => { - console.log( - `Rate limit reached. Try again in ${rateLimiter.getMsUntilNextWindow()}ms`, - ) + const setSearchAsyncRateLimiter = useAsyncRateLimiter( + handleSearch, + { + windowType: windowType, + limit: 3, // Maximum 3 requests + window: 3000, // per 3 seconds + onReject: (_args, rateLimiter) => { + console.log( + `Rate limit reached. Try again in ${rateLimiter.getMsUntilNextWindow()}ms`, + ) + }, + onError: (error) => { + // optional error handler + console.error('Search failed:', error) + setError(error as Error) + setResults([]) + }, }, - onError: (error) => { - // optional error handler - console.error('Search failed:', error) - setError(error as Error) - setResults([]) - }, - }) - // Alternative to setSearchAsyncRateLimiter.Subscribe: pass a selector as 3rd arg to cause re-renders and subscribe to state - // (state) => state, + // Alternative to setSearchAsyncRateLimiter.Subscribe: pass a selector as 3rd arg to cause re-renders and subscribe to state + // (state) => state, + ) // get and name our rate limited function const handleSearchRateLimited = setSearchAsyncRateLimiter.maybeExecute diff --git a/examples/preact/useAsyncRateLimiterWithPersister/README.md b/examples/preact/useAsyncRateLimiterWithPersister/README.md deleted file mode 100644 index 1cf889265..000000000 --- a/examples/preact/useAsyncRateLimiterWithPersister/README.md +++ /dev/null @@ -1,6 +0,0 @@ -# Example - -To run this example: - -- `npm install` -- `npm run dev` diff --git a/examples/preact/useAsyncRateLimiterWithPersister/index.html b/examples/preact/useAsyncRateLimiterWithPersister/index.html deleted file mode 100644 index 88e31073a..000000000 --- a/examples/preact/useAsyncRateLimiterWithPersister/index.html +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - - TanStack Pacer Example - - - -
- - - diff --git a/examples/preact/useAsyncRateLimiterWithPersister/package.json b/examples/preact/useAsyncRateLimiterWithPersister/package.json deleted file mode 100644 index e4483cead..000000000 --- a/examples/preact/useAsyncRateLimiterWithPersister/package.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "name": "@tanstack/pacer-example-preact-async-rate-limiter-with-persister", - "private": true, - "type": "module", - "scripts": { - "dev": "vite --port=3005", - "build": "vite build", - "preview": "vite preview", - "test:types": "tsc" - }, - "dependencies": { - "@tanstack/preact-pacer": "^0.24.0", - "preact": "^10.29.8" - }, - "devDependencies": { - "@preact/preset-vite": "^2.10.6", - "vite": "^8.3.1" - }, - "browserslist": { - "production": [ - ">0.2%", - "not dead", - "not op_mini all" - ], - "development": [ - "last 1 chrome version", - "last 1 firefox version", - "last 1 safari version" - ] - } -} diff --git a/examples/preact/useAsyncRateLimiterWithPersister/public/emblem-light.svg b/examples/preact/useAsyncRateLimiterWithPersister/public/emblem-light.svg deleted file mode 100644 index a1b982cf9..000000000 --- a/examples/preact/useAsyncRateLimiterWithPersister/public/emblem-light.svg +++ /dev/null @@ -1,14 +0,0 @@ - - - - emblem-light - Created with Sketch. - - - - - - - - - diff --git a/examples/preact/useAsyncRateLimiterWithPersister/src/index.tsx b/examples/preact/useAsyncRateLimiterWithPersister/src/index.tsx deleted file mode 100644 index b78562d58..000000000 --- a/examples/preact/useAsyncRateLimiterWithPersister/src/index.tsx +++ /dev/null @@ -1,200 +0,0 @@ -import { useEffect, useState } from 'preact/hooks' -import { render } from 'preact' -import type { JSX } from 'preact' -import { useAsyncRateLimiter } from '@tanstack/preact-pacer/async-rate-limiter' -// import { useStoragePersister } from '@tanstack/preact-persister/storage-persister' -// import type { AsyncRateLimiterState } from '@tanstack/preact-pacer/async-rate-limiter' - -interface SearchResult { - id: number - title: string -} - -// Simulate API call with fake data -const fakeApi = async (term: string): Promise> => { - await new Promise((resolve) => setTimeout(resolve, 300)) // Simulate network delay - return [ - { id: 1, title: `${term} result ${Math.floor(Math.random() * 100)}` }, - { id: 2, title: `${term} result ${Math.floor(Math.random() * 100)}` }, - { id: 3, title: `${term} result ${Math.floor(Math.random() * 100)}` }, - ] -} - -function App() { - const [windowType, setWindowType] = useState<'fixed' | 'sliding'>('fixed') - const [searchTerm, setSearchTerm] = useState('') - const [results, setResults] = useState>([]) - const [error, setError] = useState(null) - - // The function that will become rate limited - const handleSearch = async (term: string) => { - if (!term) { - setResults([]) - return - } - - // throw new Error('Test error') // you don't have to catch errors here (though you still can). The onError optional handler will catch it - - const data = await fakeApi(term) - setResults(data) - setError(null) - } - - // const rateLimiterPersister = useStoragePersister< - // AsyncRateLimiterState - // >({ - // key: 'my-async-rate-limiter', - // storage: localStorage, - // maxAge: 1000 * 60, // 1 minute - // buster: 'v1', - // }) - - // hook that gives you an async rate limiter instance - const setSearchAsyncRateLimiter = useAsyncRateLimiter( - handleSearch, - { - windowType: windowType, - limit: 3, // Maximum 2 requests - window: 3000, // per 1 second - onReject: (_args, rateLimiter) => { - console.log( - `Rate limit reached. Try again in ${rateLimiter.getMsUntilNextWindow()}ms`, - ) - }, - onError: (error) => { - // optional error handler - console.error('Search failed:', error) - setError(error as Error) - setResults([]) - }, - // optionally, you can persist the rate limiter state to localStorage - // initialState: rateLimiterPersister.loadState(), - }, - // Alternative to setSearchAsyncRateLimiter.Subscribe: pass a selector as 3rd arg to cause re-renders and subscribe to state - // (state) => state, - ) - - // useEffect(() => { - // rateLimiterPersister.saveState(setSearchAsyncRateLimiter.store.state) - // }, [setSearchAsyncRateLimiter.store.state]) - - // get and name our rate limited function - const handleSearchRateLimited = setSearchAsyncRateLimiter.maybeExecute - - useEffect(() => { - console.log('mount') - return () => { - console.log('unmount') - setSearchAsyncRateLimiter.reset() // cancel any pending async calls when the component unmounts - } - }, []) - - // instant event handler that calls both the instant local state setter and the rate limited function - async function onSearchChange(e: JSX.TargetedEvent) { - const newTerm = e.currentTarget.value - setSearchTerm(newTerm) - await handleSearchRateLimited(newTerm) // optionally await if you need to - } - - return ( -
-

TanStack Pacer useAsyncRateLimiter Example (with persister)

-
- - -
-
- -
- {error &&
Error: {error.message}
} - ({ - successCount: state.successCount, - rejectionCount: state.rejectionCount, - isExecuting: state.isExecuting, - })} - > - {({ successCount, rejectionCount, isExecuting }) => ( -
- - - - - - - - - - - - - - - - - - - -
API calls made:{successCount}
Rejected calls:{rejectionCount}
Is executing:{isExecuting ? 'Yes' : 'No'}
Results: - {results.length > 0 ? ( -
    - {results.map((item) => ( -
  • {item.title}
  • - ))} -
- ) : ( - 'No results' - )} -
-
- )} -
- state}> - {(state) => ( -
-            {JSON.stringify(state, null, 2)}
-          
- )} -
-
- ) -} - -const root = document.getElementById('root')! - -let mounted = true -render(, root) - -// demo unmounting and cancellation -document.addEventListener('keydown', (e) => { - if (e.key === 'Enter') { - mounted = !mounted - render(mounted ? : null, root) - } -}) diff --git a/examples/preact/useAsyncRateLimiterWithPersister/tsconfig.json b/examples/preact/useAsyncRateLimiterWithPersister/tsconfig.json deleted file mode 100644 index 529505428..000000000 --- a/examples/preact/useAsyncRateLimiterWithPersister/tsconfig.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "lib": ["DOM", "DOM.Iterable", "ESNext"], - "module": "ESNext", - "skipLibCheck": true, - - /* Bundler mode */ - "moduleResolution": "Bundler", - "allowImportingTsExtensions": true, - "resolveJsonModule": true, - "isolatedModules": true, - "noEmit": true, - "jsx": "react-jsx", - "jsxImportSource": "preact", - - /* Linting */ - "strict": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "noFallthroughCasesInSwitch": true - }, - "include": ["src", "vite.config.ts"] -} diff --git a/examples/preact/useAsyncRateLimiterWithPersister/vite.config.ts b/examples/preact/useAsyncRateLimiterWithPersister/vite.config.ts deleted file mode 100644 index b995c32c5..000000000 --- a/examples/preact/useAsyncRateLimiterWithPersister/vite.config.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { defineConfig } from 'vite' -import preact from '@preact/preset-vite' - -// https://vite.dev/config/ -export default defineConfig({ - plugins: [preact()], -}) diff --git a/examples/preact/useAsyncThrottler/src/index.tsx b/examples/preact/useAsyncThrottler/src/index.tsx index 63589e36c..229b108b4 100644 --- a/examples/preact/useAsyncThrottler/src/index.tsx +++ b/examples/preact/useAsyncThrottler/src/index.tsx @@ -42,20 +42,23 @@ function App() { // hook that gives you an async throttler instance // No selector needed - we'll use Subscribe HOC to subscribe to state in the component tree - const setSearchAsyncThrottler = useAsyncThrottler(handleSearch, { - // leading: true, // default - // trailing: true, // default - wait: 1000, // Wait 1 second between API calls - onError: (error) => { - // optional error handler - console.error('Search failed:', error) - setError(error as Error) - setResults([]) + const setSearchAsyncThrottler = useAsyncThrottler( + handleSearch, + { + // leading: true, // default + // trailing: true, // default + wait: 1000, // Wait 1 second between API calls + onError: (error) => { + // optional error handler + console.error('Search failed:', error) + setError(error as Error) + setResults([]) + }, + // throwOnError: true, }, - // throwOnError: true, - }) - // Alternative to setSearchAsyncThrottler.Subscribe: pass a selector as 3rd arg to cause re-renders and subscribe to state - // (state) => state, + // Alternative to setSearchAsyncThrottler.Subscribe: pass a selector as 3rd arg to cause re-renders and subscribe to state + // (state) => state, + ) // get and name our throttled function const handleSearchThrottled = setSearchAsyncThrottler.maybeExecute diff --git a/examples/preact/useBatcher/src/index.tsx b/examples/preact/useBatcher/src/index.tsx index db4aae53c..26e066817 100644 --- a/examples/preact/useBatcher/src/index.tsx +++ b/examples/preact/useBatcher/src/index.tsx @@ -16,14 +16,17 @@ function App1() { } // No selector needed - we'll use Subscribe HOC to subscribe to state in the component tree - const batcher = useBatcher(processBatch, { - // started: false, // true by default - maxSize: 5, // Process in batches of 5 (if comes before wait time) - wait: 3000, // wait up to 3 seconds before processing a batch (if time elapses before maxSize is reached) - getShouldExecute: (items, _batcher) => items.includes(42), // or pass in a custom function to determine if the batch should be processed - }) - // Alternative to batcher.Subscribe: pass a selector as 3rd arg to cause re-renders and subscribe to state - // (state) => state, + const batcher = useBatcher( + processBatch, + { + // started: false, // true by default + maxSize: 5, // Process in batches of 5 (if comes before wait time) + wait: 3000, // wait up to 3 seconds before processing a batch (if time elapses before maxSize is reached) + getShouldExecute: (items, _batcher) => items.includes(42), // or pass in a custom function to determine if the batch should be processed + }, + // Alternative to batcher.Subscribe: pass a selector as 3rd arg to cause re-renders and subscribe to state + // (state) => state, + ) return (
@@ -38,7 +41,7 @@ function App1() { {({ size, executionCount, totalItemsProcessed }) => ( <>
Batch Size: {size}
-
Batch Max Size: {3}
+
Batch Max Size: {5}
Batch Items: {batcher.peekAllItems().join(', ')}
Batches Processed: {executionCount}
Items Processed: {totalItemsProcessed}
diff --git a/examples/preact/useDebouncedCallback/src/index.tsx b/examples/preact/useDebouncedCallback/src/index.tsx index 8fdf11a66..6f4079a1a 100644 --- a/examples/preact/useDebouncedCallback/src/index.tsx +++ b/examples/preact/useDebouncedCallback/src/index.tsx @@ -1,4 +1,4 @@ -import { useState } from 'preact/hooks' +import { useRef, useState } from 'preact/hooks' import { render } from 'preact' import type { JSX } from 'preact' import { useDebouncedCallback } from '@tanstack/preact-pacer/debouncer' @@ -6,22 +6,20 @@ import { useDebouncedCallback } from '@tanstack/preact-pacer/debouncer' function App1() { // Use your state management library of choice const [instantCount, setInstantCount] = useState(0) + const instantCountRef = useRef(0) const [debouncedCount, setDebouncedCount] = useState(0) // Create debounced setter function - Stable reference provided by useDebouncedCallback const debouncedSetCount = useDebouncedCallback(setDebouncedCount, { wait: 500, - // enabled: () => instantCount > 2, // optional, defaults to true + // enabled: () => instantCountRef.current > 2, // optional, defaults to true // leading: true, // optional, defaults to false }) function increment() { - // this pattern helps avoid common bugs with stale closures and state - setInstantCount((c) => { - const newInstantCount = c + 1 // common new value for both - debouncedSetCount(newInstantCount) // debounced state update - return newInstantCount // instant state update - }) + const nextCount = ++instantCountRef.current + setInstantCount(nextCount) + debouncedSetCount(nextCount) } return ( @@ -48,16 +46,18 @@ function App1() { function App2() { const [searchText, setSearchText] = useState('') + const searchTextRef = useRef('') const [debouncedSearchText, setDebouncedSearchText] = useState('') // Create debounced setter function - Stable reference provided by useDebouncedCallback const debouncedSetSearch = useDebouncedCallback(setDebouncedSearchText, { wait: 500, - enabled: () => searchText.length > 2, + enabled: () => searchTextRef.current.length > 2, }) function handleSearchChange(e: JSX.TargetedEvent) { const newValue = e.currentTarget.value + searchTextRef.current = newValue setSearchText(newValue) debouncedSetSearch(newValue) } @@ -131,7 +131,7 @@ function App3() { min="0" max="100" value={debouncedValue} - readOnly + disabled style={{ width: '100%' }} /> {debouncedValue} diff --git a/examples/preact/useDebouncedState/src/index.tsx b/examples/preact/useDebouncedState/src/index.tsx index 9aa7323c7..ef715cbd2 100644 --- a/examples/preact/useDebouncedState/src/index.tsx +++ b/examples/preact/useDebouncedState/src/index.tsx @@ -1,10 +1,11 @@ -import { useState } from 'preact/hooks' +import { useRef, useState } from 'preact/hooks' import { render } from 'preact' import type { JSX } from 'preact' import { useDebouncedState } from '@tanstack/preact-pacer/debouncer' function App1() { const [instantCount, setInstantCount] = useState(0) + const instantCountRef = useRef(0) // higher-level hook that uses Preact.useState with the state setter automatically debounced // optionally, grab the debouncer from the last index of the returned array @@ -12,20 +13,17 @@ function App1() { instantCount, { wait: 500, - // enabled: () => instantCount > 2, // optional, defaults to true + // enabled: () => instantCountRef.current > 2, // optional, defaults to true // leading: true, // optional, defaults to false - // Alternative to debouncer.Subscribe: pass a selector as 3rd arg to cause re-renders and subscribe to state - // (state) => state, }, + // Alternative to debouncer.Subscribe: pass a selector as 3rd arg to cause re-renders and subscribe to state + // (state) => state, ) function increment() { - // this pattern helps avoid common bugs with stale closures and state - setInstantCount((c) => { - const newInstantCount = c + 1 // common new value for both - setDebouncedCount(newInstantCount) // debounced state update - return newInstantCount // instant state update - }) + const nextCount = ++instantCountRef.current + setInstantCount(nextCount) + setDebouncedCount(nextCount) } return ( @@ -83,20 +81,22 @@ function App1() { function App2() { const [instantSearch, setInstantSearch] = useState('') + const instantSearchRef = useRef('') // higher-level hook that uses Preact.useState with the state setter automatically debounced const [debouncedSearch, setDebouncedSearch, debouncer] = useDebouncedState( instantSearch, { wait: 500, - enabled: instantSearch.length > 2, // optional, defaults to true - // Alternative to debouncer.Subscribe: pass a selector as 3rd arg to cause re-renders and subscribe to state - // (state) => state, + enabled: () => instantSearchRef.current.length > 2, // optional, defaults to true }, + // Alternative to debouncer.Subscribe: pass a selector as 3rd arg to cause re-renders and subscribe to state + // (state) => state, ) function handleSearchChange(e: JSX.TargetedEvent) { const newValue = e.currentTarget.value + instantSearchRef.current = newValue setInstantSearch(newValue) setDebouncedSearch(newValue) } @@ -170,9 +170,9 @@ function App3() { currentValue, { wait: 250, - // Alternative to debouncer.Subscribe: pass a selector as 3rd arg to cause re-renders and subscribe to state - // (state) => state, }, + // Alternative to debouncer.Subscribe: pass a selector as 3rd arg to cause re-renders and subscribe to state + // (state) => state, ) function handleRangeChange(e: JSX.TargetedEvent) { @@ -207,7 +207,7 @@ function App3() { min="0" max="100" value={debouncedValue} - readOnly + disabled style={{ width: '100%' }} /> {debouncedValue} diff --git a/examples/preact/useDebouncedValue/src/index.tsx b/examples/preact/useDebouncedValue/src/index.tsx index e57b63a73..713a6f6b1 100644 --- a/examples/preact/useDebouncedValue/src/index.tsx +++ b/examples/preact/useDebouncedValue/src/index.tsx @@ -11,13 +11,16 @@ function App1() { } // highest-level hook that watches an instant local state value and returns a debounced value - const [debouncedCount] = useDebouncedValue(instantCount, { - wait: 500, - // enabled: () => instantCount > 2, // optional, defaults to true - // leading: true, // optional, defaults to false + const [debouncedCount] = useDebouncedValue( + instantCount, + { + wait: 500, + // enabled: () => instantCount > 2, // optional, defaults to true + // leading: true, // optional, defaults to false + }, // Alternative to debouncer.Subscribe: pass a selector as 3rd arg to cause re-renders and subscribe to state // (state) => state, - }) + ) return (
@@ -46,12 +49,15 @@ function App2() { // highest-level hook that watches an instant local state value and returns a debounced value // optionally, grab the debouncer from the last index of the returned array - const [debouncedSearch] = useDebouncedValue(instantSearch, { - wait: 500, - enabled: instantSearch.length > 2, // optional, defaults to true + const [debouncedSearch] = useDebouncedValue( + instantSearch, + { + wait: 500, + enabled: instantSearch.length > 2, // optional, defaults to true + }, // Alternative to debouncer.Subscribe: pass a selector as 3rd arg to cause re-renders and subscribe to state // (state) => state, - }) + ) function handleSearchChange(e: JSX.TargetedEvent) { setInstantSearch(e.currentTarget.value) @@ -88,19 +94,22 @@ function App2() { function App3() { const [currentValue, setCurrentValue] = useState(50) - const [instantExecutionCount, setInstantExecutionCount] = useState(0) + const [submittedCount, setSubmittedCount] = useState(1) // highest-level hook that watches an instant local state value and returns a debounced value - const [debouncedValue, debouncer] = useDebouncedValue(currentValue, { - wait: 250, + const [debouncedValue, debouncer] = useDebouncedValue( + currentValue, + { + wait: 250, + }, // Alternative to debouncer.Subscribe: pass a selector as 3rd arg to cause re-renders and subscribe to state // (state) => state, - }) + ) function handleRangeChange(e: JSX.TargetedEvent) { const newValue = parseInt(e.currentTarget.value, 10) setCurrentValue(newValue) - setInstantExecutionCount((c) => c + 1) + setSubmittedCount((c) => c + 1) } return ( @@ -128,7 +137,7 @@ function App3() { min="0" max="100" value={debouncedValue} - readOnly + disabled style={{ width: '100%' }} /> {debouncedValue} @@ -149,8 +158,8 @@ function App3() { {isPending.toString()} - Instant Executions: - {instantExecutionCount} + Values Submitted: + {submittedCount} Debounced Executions: @@ -158,16 +167,15 @@ function App3() { Saved Executions: - {instantExecutionCount - executionCount} + {submittedCount - executionCount} % Reduction: - {instantExecutionCount === 0 + {submittedCount === 0 ? '0' : Math.round( - ((instantExecutionCount - executionCount) / - instantExecutionCount) * + ((submittedCount - executionCount) / submittedCount) * 100, )} % diff --git a/examples/preact/useDebouncer/src/index.tsx b/examples/preact/useDebouncer/src/index.tsx index a0cbc5dab..37f255445 100644 --- a/examples/preact/useDebouncer/src/index.tsx +++ b/examples/preact/useDebouncer/src/index.tsx @@ -1,4 +1,4 @@ -import { useState } from 'preact/hooks' +import { useRef, useState } from 'preact/hooks' import { render } from 'preact' import type { JSX } from 'preact' import { useDebouncer } from '@tanstack/preact-pacer/debouncer' @@ -7,25 +7,26 @@ import { PacerProvider } from '@tanstack/preact-pacer/provider' function App1() { // Use your state management library of choice const [instantCount, setInstantCount] = useState(0) + const instantCountRef = useRef(0) const [debouncedCount, setDebouncedCount] = useState(0) // Lower-level useDebouncer hook - requires you to manage your own state // No selector needed - we'll use Subscribe HOC to subscribe to state in the component tree - const debouncer = useDebouncer(setDebouncedCount, { - wait: 800, - enabled: () => instantCount > 2, // optional, defaults to true - // leading: true, // optional, defaults to false - }) - // Alternative to debouncer.Subscribe: pass a selector as 3rd arg to cause re-renders and subscribe to state - // (state) => ({ status: state.status, executionCount: state.executionCount }), + const debouncer = useDebouncer( + setDebouncedCount, + { + wait: 800, + enabled: () => instantCountRef.current > 2, // optional, defaults to true + // leading: true, // optional, defaults to false + }, + // Alternative to debouncer.Subscribe: pass a selector as 3rd arg to cause re-renders and subscribe to state + // (state) => ({ status: state.status, executionCount: state.executionCount }), + ) function increment() { - // this pattern helps avoid common bugs with stale closures and state - setInstantCount((c) => { - const newInstantCount = c + 1 // common new value for both - debouncer.maybeExecute(newInstantCount) // debounced state update - return newInstantCount // instant state update - }) + const nextCount = ++instantCountRef.current + setInstantCount(nextCount) + debouncer.maybeExecute(nextCount) } return ( @@ -89,19 +90,24 @@ function App1() { function App2() { const [searchText, setSearchText] = useState('') + const searchTextRef = useRef('') const [debouncedSearchText, setDebouncedSearchText] = useState('') // Lower-level useDebouncer hook - requires you to manage your own state // No selector needed - we'll use Subscribe HOC to subscribe to state in the component tree - const setSearchDebouncer = useDebouncer(setDebouncedSearchText, { - wait: 500, - enabled: () => searchText.length > 2, // optional, defaults to true - }) - // Alternative to setSearchDebouncer.Subscribe: pass a selector as 3rd arg to cause re-renders and subscribe to state - // (state) => ({ isPending: state.isPending, executionCount: state.executionCount }), + const setSearchDebouncer = useDebouncer( + setDebouncedSearchText, + { + wait: 500, + enabled: () => searchTextRef.current.length > 2, // optional, defaults to true + }, + // Alternative to setSearchDebouncer.Subscribe: pass a selector as 3rd arg to cause re-renders and subscribe to state + // (state) => ({ isPending: state.isPending, executionCount: state.executionCount }), + ) function handleSearchChange(e: JSX.TargetedEvent) { const newValue = e.currentTarget.value + searchTextRef.current = newValue setSearchText(newValue) setSearchDebouncer.maybeExecute(newValue) } @@ -173,14 +179,20 @@ function App3() { const [currentValue, setCurrentValue] = useState(50) const [debouncedValue, setDebouncedValue] = useState(50) const [instantExecutionCount, setInstantExecutionCount] = useState(0) + const [wait, setWait] = useState(250) + const [enabled, setEnabled] = useState(true) // Lower-level useDebouncer hook - requires you to manage your own state // No selector needed - we'll use Subscribe HOC to subscribe to state in the component tree - const setValueDebouncer = useDebouncer(setDebouncedValue, { - wait: 250, - }) - // Alternative to setValueDebouncer.Subscribe: pass a selector as 3rd arg to cause re-renders and subscribe to state - // (state) => ({ isPending: state.isPending, executionCount: state.executionCount }), + const setValueDebouncer = useDebouncer( + setDebouncedValue, + { + wait, + enabled, + }, + // Alternative to setValueDebouncer.Subscribe: pass a selector as 3rd arg to cause re-renders and subscribe to state + // (state) => ({ isPending: state.isPending, executionCount: state.executionCount }), + ) function handleRangeChange(e: JSX.TargetedEvent) { const newValue = parseInt(e.currentTarget.value, 10) @@ -192,6 +204,32 @@ function App3() { return (

TanStack Pacer useDebouncer Example 3

+
+ Reactive options + + +

+ Changing the delay affects the next scheduled call. Disabling cancels + pending work. +

+