Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
44 changes: 4 additions & 40 deletions docs/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down Expand Up @@ -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"
Expand All @@ -1726,10 +1726,6 @@
{
"label": "useAsyncRateLimiter",
"to": "framework/react/examples/useAsyncRateLimiter"
},
{
"label": "useAsyncRateLimiterWithPersister",
"to": "framework/react/examples/useAsyncRateLimiterWithPersister"
}
]
},
Expand Down Expand Up @@ -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"
Expand All @@ -1796,10 +1788,6 @@
{
"label": "useAsyncRateLimiter",
"to": "framework/preact/examples/useAsyncRateLimiter"
},
{
"label": "useAsyncRateLimiterWithPersister",
"to": "framework/preact/examples/useAsyncRateLimiterWithPersister"
}
]
},
Expand Down Expand Up @@ -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"
}
]
}
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down
73 changes: 73 additions & 0 deletions docs/guides/persistence.md
Original file line number Diff line number Diff line change
@@ -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<string> {
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.
63 changes: 36 additions & 27 deletions examples/angular/asyncBatch/src/app/app.html
Original file line number Diff line number Diff line change
@@ -1,29 +1,38 @@
<div style="padding: 24px; max-width: 480px; margin: 0 auto">
<h1>Async batcher</h1>
<p>Collects items, batches every 3 or after 3s, process takes 1s.</p>
<h1>TanStack Pacer asyncBatch Example</h1>
<h2>Batch Status</h2>
<p>Pending Items: {{ pendingItems().length }} / 5</p>
<p>Wait up to 3000ms, process for 1000ms. Urgent items trigger immediate processing.</p>
<p>Is Processing: {{ isProcessing() }}</p>
<p>Successful Batches: {{ successCount() }}, Failed Batches: {{ errorCount() }}</p>

<div style="display: flex; gap: 12px; margin: 16px 0">
<button (click)="add()">Add item</button>
<button type="button" (click)="flush()">Flush</button>
<button type="button" (click)="reset()">Reset</button>
</div>
<h2>Current Batch Items</h2>
<ul>
@for (item of pendingItems(); track item.id) {
<li>{{ item.value }}</li>
} @empty {
<li>No items pending</li>
}
</ul>
<button (click)="add()">Add Regular Item</button
><button (click)="add(true)">Add Urgent Item (Processes Immediately)</button>

<section>
<h2>Status</h2>
<p>Pending: {{ batcher.state().items }}</p>
<p>Executing: {{ batcher.state().isExecuting }}</p>
</section>

<section>
<h2>Batches</h2>
@if (logs().length === 0) {
<p>No batches yet. Add items to start batching.</p>
} @else {
<ul>
@for (entry of logs(); track $index) {
<li>{{ entry }}</li>
}
</ul>
}
</section>
</div>
<label
><input
type="checkbox"
[checked]="shouldFail()"
(change)="shouldFail.set($any($event.target).checked)"
/>
Simulate random failures (30% chance)</label
>
<h2>Processed Batches ({{ processedBatches().length }})</h2>
@for (batch of processedBatches(); track $index) {
<p>{{ batch.result }}</p>
} @if (errors().length) {
<h2>Errors ({{ errors().length }})</h2>
@for (error of errors(); track $index) {
<p role="alert">{{ error }}</p>
}<button (click)="errors.set([])">Clear Errors</button> }
<details>
<summary>Processed batch data</summary>
<pre>{{ processedBatches() | json }}</pre>
</details>
35 changes: 34 additions & 1 deletion examples/angular/asyncBatch/src/app/app.spec.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { vi } from 'vitest'
import { TestBed } from '@angular/core/testing'
import { App } from './app'

Expand All @@ -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 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')
})
})
Loading
Loading