feat(persistence): client-side generation resume snapshot - #997
feat(persistence): client-side generation resume snapshot#997tombeckenham wants to merge 17 commits into
Conversation
📝 WalkthroughWalkthroughGeneration clients now persist validated, lightweight resume snapshots without generated media bytes. The feature is wired through React, Vue, Solid, Svelte, and Angular APIs, documented, demonstrated in examples, and covered by unit, integration, and browser-refresh tests. ChangesGeneration persistence
Estimated code review effort: 5 (Critical) | ~120 minutes Sequence Diagram(s)sequenceDiagram
participant User
participant GenerationHook
participant GenerationClient
participant StorageAdapter
User->>GenerationHook: generate(input)
GenerationHook->>GenerationClient: start generation
GenerationClient->>GenerationClient: reduce stream chunks
GenerationClient->>StorageAdapter: persist generation:<id> snapshot
StorageAdapter-->>GenerationClient: stored snapshot
User->>GenerationHook: reload application
GenerationHook->>StorageAdapter: hydrate snapshot
StorageAdapter-->>GenerationHook: resume metadata
Possibly related PRs
Suggested labels: Suggested reviewers: 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
🚀 Changeset Version Preview16 package(s) bumped directly, 34 bumped as dependents. 🟥 Major bumps
🟨 Minor bumps
🟩 Patch bumps
|
|
View your CI Pipeline Execution ↗ for commit a1fb436
☁️ Nx Cloud last updated this comment at |
@tanstack/ai
@tanstack/ai-acp
@tanstack/ai-angular
@tanstack/ai-anthropic
@tanstack/ai-bedrock
@tanstack/ai-claude-code
@tanstack/ai-client
@tanstack/ai-code-mode
@tanstack/ai-code-mode-skills
@tanstack/ai-codex
@tanstack/ai-devtools-core
@tanstack/ai-durable-stream
@tanstack/ai-elevenlabs
@tanstack/ai-event-client
@tanstack/ai-fal
@tanstack/ai-gemini
@tanstack/ai-grok
@tanstack/ai-grok-build
@tanstack/ai-groq
@tanstack/ai-isolate-cloudflare
@tanstack/ai-isolate-node
@tanstack/ai-isolate-quickjs
@tanstack/ai-mcp
@tanstack/ai-memory
@tanstack/ai-mistral
@tanstack/ai-ollama
@tanstack/ai-openai
@tanstack/ai-opencode
@tanstack/ai-openrouter
@tanstack/ai-persistence
@tanstack/ai-preact
@tanstack/ai-react
@tanstack/ai-react-ui
@tanstack/ai-sandbox
@tanstack/ai-sandbox-cloudflare
@tanstack/ai-sandbox-daytona
@tanstack/ai-sandbox-docker
@tanstack/ai-sandbox-local-process
@tanstack/ai-sandbox-sprites
@tanstack/ai-sandbox-vercel
@tanstack/ai-solid
@tanstack/ai-solid-ui
@tanstack/ai-svelte
@tanstack/ai-utils
@tanstack/ai-vue
@tanstack/ai-vue-ui
@tanstack/openai-base
@tanstack/preact-ai-devtools
@tanstack/react-ai-devtools
@tanstack/solid-ai-devtools
commit: |
Layer a lightweight, read-only resume snapshot onto media generation. As a run streams, the client builds a GenerationResumeSnapshot (run identity, status, errors, result metadata + artifact refs — never media bytes) and writes it to an optional GenerationServerPersistence store. - ai-client: GenerationResumeSnapshot types + updateGenerationResumeSnapshot reducer; GenerationClient/VideoGenerationClient observe chunks, persist snapshots (serialized queue, warn-not-throw), expose getResumeSnapshot(); disposed guard. No resume() action (stream re-attach is PR #955). - ai-event-client: optional threadId/runId on generation events. - react/solid/vue/svelte/angular hooks: persistence + initialResumeSnapshot options; expose resumeSnapshot/resumeState (+ pending/result artifacts). - example: Persisted mode on the image generation route. - docs: persistence/generation-persistence.md + nav entry. Pairs with the existing withGenerationPersistence server middleware.
Drop the bespoke `GenerationServerPersistence` type and the `{ server }`
option wrapper. The `persistence` option is now a bare storage adapter
reusing the shared `ChatStorageAdapter` contract (aliased as
`GenerationPersistence`), so `localStoragePersistence` /
`sessionStoragePersistence` / `indexedDBPersistence` work for generations
exactly as they do for chat — matching main's ergonomics.
…istence (no call-site generic)
Default `localStoragePersistence` / `sessionStoragePersistence` /
`indexedDBPersistence` to a value-agnostic `TValue` so a bare, unannotated
call works for BOTH chat and generation persistence — the consuming
`persistence` option constrains the stored value. Generation docs/example now
use `localStoragePersistence({ keyPrefix })` with no type declaration.
PR #955 (resumable streams) is merged, so delivery durability is available today — it was wrongly described as an unlanded future feature. Rewrite the generation-persistence doc: the server example now wires a durability adapter + GET handler, and the delivery section explains that a dropped mid-generation connection re-attaches through the same adapters useChat uses. Clarify that the read-only snapshot carries run state (incl. runId) across reloads, while hooks do not auto-resume on mount.
34c8b69 to
765bcfd
Compare
…e revival - kiira: replace phantom @tanstack/ai-persistence-drizzle import with the hand-rolled adapter from build-your-own-adapter (CI was red on this) - hydrate the resume snapshot from persistence.getItem on construction, validated via new parseGenerationResumeSnapshot(unknown) export; initialResumeSnapshot seed takes precedence - namespace storage keys as generation:<id> so chat and generation clients sharing an id and adapter no longer collide - write terminal snapshots on stop() (idle) and transport-level errors (error); reset() clears memory + removeItem; RUN_STARTED drops stale result/error/pendingArtifacts from the previous run; plain-fetcher runs now record a complete snapshot built from the fetcher result - capture video jobId into the snapshot from video:job:created - add schemaVersion: 1 to persisted snapshots - gate persistence writes on material change (ignore lastEvent-only churn), warn once per failure transition, clear resumePersistenceError on success - mountDevtools() revives a disposed client (React StrictMode replay); generate() checks disposed before mounting devtools - onResumeSnapshotChange now receives undefined when reset() clears - fix mojibake em dashes in 12 hook files
…t coverage - rewrite docs/persistence/generation-persistence.md around the implemented behavior: hydration on mount, generation:<id> keys, resumeState vs resumeSnapshot semantics, honest reconnect story, no media-URL claim; drop the inert threadId/runId spreads from the server sample - fix the example's Persisted panel: distinguish in-flight run from last-run outcome; reload now actually shows the persisted record - revert ai-event-client: BaseEventContext already carries threadId/runId, the 36 added lines were redundant redeclarations; changeset no longer bumps that package and now describes hydration + lifecycle accurately - normalize wrong hook JSDoc (Server-side → client-side storage; read-only seed claims; run/cursor wording) and mark artifact fields dormant - React hooks: post-dispose guards on callbacks/setters, StrictMode revive via mount effect, stable empty artifact arrays, re-export persistence types (+ PersistedArtifactRef) - tests: replace the two vacuous reducer tests with real externalUrl positive/negative and stop coverage; add reducer seed-merge, RUN_STARTED stale-field-drop, video jobId capture, parseGenerationResumeSnapshot suite; add client lifecycle suite (hydration, seed precedence, corrupt storage, stop/reset/transport-error, write gating, StrictMode revive); add React hydration/StrictMode/artifact-exposure hook tests
…hots Provider-free harness (api.generation-persistence streams a fixed AG-UI sequence; aimock-exempt) + page using useGenerateImage with localStoragePersistence. Proves: snapshot written under tanstack-ai:generation:<id> with no media bytes, hydrated after reload with no auto-run, and removed by reset().
… + media-generation skills
…ration ordering - solid: build the client outside reactive tracking (untrack) — the old createMemo second-arg was a seed, not deps, so option reads were tracked and a change orphaned an undisposed client; stable empty artifact arrays - svelte: explicit generate() now revives a disposed client (mountDevtools) since Svelte has no remount effect; reactive bindings revive with it - vue: stable empty artifact array constants (shallowRef identity) - angular: JSDoc for persistence/initialResumeSnapshot on inject-generate-video - all four: re-export GenerationPersistence/GenerationResumeSnapshot/ GenerationResumeState/GenerationResumeStatus/GenerationPendingArtifact + PersistedArtifactRef from package index; hydration + reset()/removeItem tests against Map-backed adapters - ai-client: kick off snapshot hydration only after callbacksRef is assigned (removes a sync-adapter ordering hazard)
…enerate casts UseGenerationReturn gains a defaulted second generic (TInput extends Record<string, any> = Record<string, any>) so generate is typed (input: TInput) => Promise<void>. useGeneration returns the type it actually builds — the unsound internal narrow-to-wide cast and the five wrapper-level casts back down to the concrete input type all disappear, and direct useGeneration consumers get a precisely typed generate. Existing UseGenerationReturn<MyOutput> references keep compiling via the default.
…svelte/angular Same fix as fbc3dc3 for the remaining four frameworks: the base return interface (UseGenerationReturn / CreateGenerationReturn / InjectGenerationResult) gains a defaulted second generic (TInput extends Record<string, any> = Record<string, any>) so generate is typed (input: TInput) => Promise<void>. The base hooks return the type they actually build and the internal narrow-to-wide casts plus every wrapper-level 'generate as' cast are deleted. Video hooks were already cast-free (they build their own client). Defaults keep existing single-generic references compiling.
…Value = any) Revert the web-storage factory defaults to TValue = ChatPersistedState, as shipped in #984. The any default erased type safety on every direct adapter use (getItem returned any; a store built for one domain assigned silently to the other's hook) and carried three oxlint suppressions — while buying nothing for inline usage, where contextual typing infers the value type from the persistence option regardless of the default. The one affected pattern, a standalone store for generations, now states its type: localStoragePersistence<GenerationResumeSnapshot>(). Doc, example, and e2e call sites updated; runtime behavior unchanged.
There was a problem hiding this comment.
Actionable comments posted: 5
🧹 Nitpick comments (10)
packages/ai-react/src/use-generate-audio.ts (1)
73-80: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winReact wrappers hand-copy the resume/artifact surface instead of inheriting it. The same four fields (
resumeSnapshot,resumeState,pendingArtifacts,resultArtifacts) and their caveat JSDoc are duplicated across four React return types, while the Vue and Angular wrappers in this PR derive them viaextends Omit<UseGenerationReturn<TOutput>, 'generate'>/Omit<InjectGenerationResult<TOutput>, 'generate'>. Any future change to the surface now needs four synchronized edits, and the "always empty until the artifact pipeline ships" note will drift.
packages/ai-react/src/use-generate-audio.ts#L73-L80: replace the four copied fields withextends Omit<UseGenerationReturn<TOutput, AudioGenerateInput>, 'generate'>and keep only the narrowedgenerate.packages/ai-react/src/use-generate-image.ts#L73-L80: same, inheriting fromUseGenerationReturn<TOutput, ImageGenerateInput>.packages/ai-react/src/use-summarize.ts#L73-L80: same, inheriting fromUseGenerationReturn<TOutput, SummarizeGenerateInput>.packages/ai-react/src/use-generate-video.ts#L83-L90:useGenerateVideobuilds its own client, so extract the four field declarations into a sharedGenerationResumeSurfaceinterface and extend that here instead of re-declaring them.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/ai-react/src/use-generate-audio.ts` around lines 73 - 80, Replace the duplicated resume/artifact field declarations with shared inherited types: in packages/ai-react/src/use-generate-audio.ts lines 73-80, packages/ai-react/src/use-generate-image.ts lines 73-80, and packages/ai-react/src/use-summarize.ts lines 73-80, extend Omit<UseGenerationReturn<TOutput, respective input type>, 'generate'> while retaining only the narrowed generate member. In packages/ai-react/src/use-generate-video.ts lines 83-90, extract those four fields and their caveat JSDoc into a shared GenerationResumeSurface interface, then extend it instead of re-declaring them.packages/ai-client/src/video-generation-client.ts (1)
641-828: 📐 Maintainability & Code Quality | 🔵 Trivial | 🏗️ Heavy liftExtract resume-snapshot persistence into a shared helper.
GenerationClientandVideoGenerationClientboth contain near-identical hydration/persist/dedupe/queue/clear/error-warn resume-snapshot lifecycle code. Move that implementation to a sharedResumeSnapshotStore(constructed with the storage adapter and key) so the two clients share the same semantics and avoid prefix-only naming drift likeresumeSnapshotSignaturevsvideoResumeSnapshotSignature.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/ai-client/src/video-generation-client.ts` around lines 641 - 828, Extract the resume snapshot lifecycle currently implemented by methods such as maybeHydrateResumeSnapshot, hydrateResumeSnapshot, persistResumeSnapshot, writeResumeSnapshot, removePersistedResumeSnapshot, and clearResumeSnapshot into a shared ResumeSnapshotStore that accepts the storage adapter and snapshot key. Update GenerationClient and VideoGenerationClient to delegate hydration, persistence, deduplication, queued writes, clearing, and warning/error state to this store, and replace the client-specific videoResumeSnapshotSignature with the shared implementation so both clients retain identical semantics.packages/ai-client/src/generation-client.ts (1)
439-464: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueConsider extracting a snapshot-clone/carry helper.
This nested conditional-spread clone is repeated in shape at Lines 574-587 (
completePlainFetcherResumeSnapshot) and Lines 602-612 (recordResumeSnapshotError). A singlecloneResumeSnapshot(snapshot)/carryForward(previous)helper would remove the duplication and keep the three sites from drifting when a new snapshot field is added.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/ai-client/src/generation-client.ts` around lines 439 - 464, Extract the repeated resume-snapshot cloning logic from getResumeSnapshot, completePlainFetcherResumeSnapshot, and recordResumeSnapshotError into a shared cloneResumeSnapshot or carryForward helper. Update all three call sites to use it, preserving the existing shallow-copy behavior for nested pendingArtifacts, result.artifacts, error, and lastEvent fields so future snapshot fields remain consistent.packages/ai-client/tests/generation-client.test.ts (1)
1329-1361: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winAlso assert the new
getResumePersistenceError()surface.This case covers the
console.warnside effect but not the public getter that exposes the failure (and its reset back toundefinedafter a later successful write). Adding those assertions here would lock in the only user-visible signal of persistence failure.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/ai-client/tests/generation-client.test.ts` around lines 1329 - 1361, Extend the rejected-write test around GenerationClient.generate to assert getResumePersistenceError() returns persistenceError after the failed setItem call. Then make a subsequent successful persistence write and assert the getter resets to undefined, preserving the existing warning assertion and generation resolution behavior.packages/ai-react/src/use-generate-speech.ts (1)
33-36: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winReact wrapper hooks hand-copy the resume surface instead of deriving it. Both hooks re-declare
persistence/initialResumeSnapshotand the four resume/artifact return fields verbatim, while the Vue and Svelte wrappers now derive them (Pick<...Options, 'persistence' | 'initialResumeSnapshot'>,Omit<...Return, 'generate'>). The next field added touseGenerationwill silently miss these public types.
packages/ai-react/src/use-generate-speech.ts#L33-L36: haveUseGenerateSpeechOptionsextendPick<UseGenerationOptions<SpeechGenerateInput, TTSResult, TOutput>, 'persistence' | 'initialResumeSnapshot'>andUseGenerateSpeechReturnextendOmit<UseGenerationReturn<TOutput, SpeechGenerateInput>, 'generate'>.packages/ai-react/src/use-transcription.ts#L33-L36: apply the same derivation withTranscriptionGenerateInput/TranscriptionResult.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/ai-react/src/use-generate-speech.ts` around lines 33 - 36, Derive the React wrapper types from the shared generation types instead of hand-copying resume and artifact fields. In packages/ai-react/src/use-generate-speech.ts at lines 33-36, make UseGenerateSpeechOptions extend Pick<UseGenerationOptions<SpeechGenerateInput, TTSResult, TOutput>, 'persistence' | 'initialResumeSnapshot'> and UseGenerateSpeechReturn extend Omit<UseGenerationReturn<TOutput, SpeechGenerateInput>, 'generate'>; apply the same change in packages/ai-react/src/use-transcription.ts at lines 33-36 using TranscriptionGenerateInput and TranscriptionResult.packages/ai-svelte/tests/create-generation.test.ts (1)
248-273: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winUse
flushAsync()instead of a singleawait Promise.resolve()in these negative-assertion tests.Hydration and persistence work run through detached promise queues (that's why
flushAsyncexists). One microtask isn't enough to provegetItem/connectwere never called — a regression that hydrates a macrotask later would still pass.💚 Suggested change (both tests)
- await Promise.resolve() + await flushAsync()Also applies to: 808-828
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/ai-svelte/tests/create-generation.test.ts` around lines 248 - 273, Update both negative-assertion tests in create-generation.test.ts, including the test near the referenced additional range, to await the existing flushAsync() helper instead of a single Promise.resolve() microtask. Keep the assertions verifying that getItem and connect were not called, so detached hydration and persistence queues are fully drained before checking behavior.packages/ai-svelte/src/create-generate-video.svelte.ts (1)
311-322: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick winEmpty-artifact getters allocate a new array per read.
packages/ai-vue/src/use-generate-video.tsdeliberately uses sharedEMPTY_*constants so empty lists keep a stable identity for downstream watchers; these getters return a fresh[]on every access, which defeats reference comparison in$derived/$effectconsumers.♻️ Use shared fallbacks
get pendingArtifacts() { - return resumeSnapshot?.pendingArtifacts ?? [] + return resumeSnapshot?.pendingArtifacts ?? EMPTY_PENDING_ARTIFACTS }, get resultArtifacts() { - return resumeSnapshot?.result?.artifacts ?? [] + return resumeSnapshot?.result?.artifacts ?? EMPTY_RESULT_ARTIFACTS },Add at module scope:
const EMPTY_PENDING_ARTIFACTS: Array<GenerationPendingArtifact> = [] const EMPTY_RESULT_ARTIFACTS: Array<PersistedArtifactRef> = []🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/ai-svelte/src/create-generate-video.svelte.ts` around lines 311 - 322, Update the module containing the resumeSnapshot getters to define shared EMPTY_PENDING_ARTIFACTS and EMPTY_RESULT_ARTIFACTS constants at module scope, then use them as the nullish fallbacks in pendingArtifacts and resultArtifacts instead of allocating [] on each read. Keep the existing non-empty artifact values unchanged.packages/ai-angular/tests/inject-generation.test.ts (2)
73-103: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valuePersistence/adapter test helpers copy-pasted per framework suite and already diverging. The same
createMapPersistence/createRunContextCaptureAdapter/flushPromisestrio is redefined in each framework test file with different return shapes, so the persistence contract is asserted slightly differently in each.
packages/ai-angular/tests/inject-generation.test.ts#L73-L103: import a sharedGenerationPersistencemock util instead of the local wrapper-object variant.packages/ai-vue/tests/use-generation.test.ts#L126-L142: replace the adapter-with-storevariant with the same shared util.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/ai-angular/tests/inject-generation.test.ts` around lines 73 - 103, Replace the local createMapPersistence helper in packages/ai-angular/tests/inject-generation.test.ts (lines 73-103) and packages/ai-vue/tests/use-generation.test.ts (lines 126-142) with the shared GenerationPersistence mock utility, importing it wherever needed and updating call sites to its return shape. Remove the duplicated framework-specific wrapper/store variants so both suites assert the same persistence contract.
105-111: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winPrefer
vi.waitForover a fixed microtask count.Hydration and removal both go through chained promise queues inside the client; a hard-coded 5-tick drain will silently become insufficient if another
.thenlink is added, producing intermittent failures rather than a clear one.♻️ Suggested alternative
-// Hydration and snapshot removal both run through awaited promise chains, so -// drain the microtask queue rather than awaiting a single tick. -async function flushPromises(): Promise<void> { - for (let i = 0; i < 5; i++) { - await Promise.resolve() - } -} +// Hydration and snapshot removal both run through awaited promise chains; +// poll for the expected state instead of guessing a microtask count. +async function flushPromises(): Promise<void> { + await vi.waitFor(() => Promise.resolve()) +}Or assert with
await vi.waitFor(() => expect(getItem).toHaveBeenCalledTimes(1))at each call site.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/ai-angular/tests/inject-generation.test.ts` around lines 105 - 111, Replace the fixed five-iteration flushPromises helper with vi.waitFor-based synchronization at the hydration and snapshot-removal assertions, waiting for the expected client call or state before continuing. Remove the hard-coded microtask drain while preserving the existing assertions and behavior.packages/ai-angular/src/inject-generate-video.ts (1)
95-117: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueOptional: extract the resume-signal wiring into a shared internal helper.
This block (signals seeded from
initialResumeSnapshot+setResumeSnapshotState) is byte-for-byte identical topackages/ai-angular/src/inject-generation.tsLines 127-149. A smallinternal/create-resume-signals.tsreturning the four signals plus the setter (and taking a() => booleandisposed accessor) would keep the two clients from drifting.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/ai-angular/src/inject-generate-video.ts` around lines 95 - 117, Extract the duplicated resume-signal initialization and update logic from injectGenerateVideo and injectGeneration into a shared internal create-resume-signals helper. Have the helper accept the initial snapshot and a disposed-state accessor, return the four signals plus setResumeSnapshotState, and update both clients to use it while preserving current initialization and disposal behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@examples/ts-react-chat/src/routes/generations.image.tsx`:
- Around line 119-125: Update the ImageGenerationUI reset-button visibility
logic so the “Clear” action remains available when a hydrated resumeSnapshot
exists, even if result is empty. Ensure the button invokes the existing reset()
behavior and preserve the current visibility behavior for non-hydrated states.
In `@packages/ai-client/src/generation-types.ts`:
- Around line 602-607: Update the expiresAt handling in observeResumeSnapshot to
validate Date values before calling toISOString, so invalid dates are ignored
without throwing. Preserve the existing string handling and valid Date
serialization behavior.
In `@packages/ai-react/src/use-generation.ts`:
- Around line 193-195: Update the onResumeSnapshotChange callback in the React
hook and the corresponding Vue/Svelte integrations to suppress state updates
when the resume snapshot has not materially changed. Reuse the existing
resumeSnapshotSignature comparison or equivalent logic so lastEvent-only changes
are ignored while meaningful snapshot changes still call setResumeSnapshot and
update the reactive state.
In `@packages/ai-svelte/src/create-generation.svelte.ts`:
- Around line 298-303: Define module-level EMPTY_PENDING_ARTIFACTS and
EMPTY_RESULT_ARTIFACTS constants with the appropriate artifact types, then
update the pendingArtifacts and resultArtifacts getters to reuse these stable
fallbacks instead of allocating a new empty array on each read.
In `@packages/ai-vue/src/use-generate-video.ts`:
- Around line 93-100: The artifact JSDoc incorrectly claims pendingArtifacts and
resultArtifacts are always empty; update the documentation in
packages/ai-vue/src/use-generate-video.ts lines 93-100 and
packages/ai-svelte/src/create-generate-video.svelte.ts lines 88-95 to describe
when each list is populated, and mirror the correction in other framework hooks
with the copied text. Keep packages/ai-react/tests/use-generation.test.ts lines
475-493 as the behavioral source of truth and make no test change unless the
corrected documentation reveals the expectation is wrong.
---
Nitpick comments:
In `@packages/ai-angular/src/inject-generate-video.ts`:
- Around line 95-117: Extract the duplicated resume-signal initialization and
update logic from injectGenerateVideo and injectGeneration into a shared
internal create-resume-signals helper. Have the helper accept the initial
snapshot and a disposed-state accessor, return the four signals plus
setResumeSnapshotState, and update both clients to use it while preserving
current initialization and disposal behavior.
In `@packages/ai-angular/tests/inject-generation.test.ts`:
- Around line 73-103: Replace the local createMapPersistence helper in
packages/ai-angular/tests/inject-generation.test.ts (lines 73-103) and
packages/ai-vue/tests/use-generation.test.ts (lines 126-142) with the shared
GenerationPersistence mock utility, importing it wherever needed and updating
call sites to its return shape. Remove the duplicated framework-specific
wrapper/store variants so both suites assert the same persistence contract.
- Around line 105-111: Replace the fixed five-iteration flushPromises helper
with vi.waitFor-based synchronization at the hydration and snapshot-removal
assertions, waiting for the expected client call or state before continuing.
Remove the hard-coded microtask drain while preserving the existing assertions
and behavior.
In `@packages/ai-client/src/generation-client.ts`:
- Around line 439-464: Extract the repeated resume-snapshot cloning logic from
getResumeSnapshot, completePlainFetcherResumeSnapshot, and
recordResumeSnapshotError into a shared cloneResumeSnapshot or carryForward
helper. Update all three call sites to use it, preserving the existing
shallow-copy behavior for nested pendingArtifacts, result.artifacts, error, and
lastEvent fields so future snapshot fields remain consistent.
In `@packages/ai-client/src/video-generation-client.ts`:
- Around line 641-828: Extract the resume snapshot lifecycle currently
implemented by methods such as maybeHydrateResumeSnapshot,
hydrateResumeSnapshot, persistResumeSnapshot, writeResumeSnapshot,
removePersistedResumeSnapshot, and clearResumeSnapshot into a shared
ResumeSnapshotStore that accepts the storage adapter and snapshot key. Update
GenerationClient and VideoGenerationClient to delegate hydration, persistence,
deduplication, queued writes, clearing, and warning/error state to this store,
and replace the client-specific videoResumeSnapshotSignature with the shared
implementation so both clients retain identical semantics.
In `@packages/ai-client/tests/generation-client.test.ts`:
- Around line 1329-1361: Extend the rejected-write test around
GenerationClient.generate to assert getResumePersistenceError() returns
persistenceError after the failed setItem call. Then make a subsequent
successful persistence write and assert the getter resets to undefined,
preserving the existing warning assertion and generation resolution behavior.
In `@packages/ai-react/src/use-generate-audio.ts`:
- Around line 73-80: Replace the duplicated resume/artifact field declarations
with shared inherited types: in packages/ai-react/src/use-generate-audio.ts
lines 73-80, packages/ai-react/src/use-generate-image.ts lines 73-80, and
packages/ai-react/src/use-summarize.ts lines 73-80, extend
Omit<UseGenerationReturn<TOutput, respective input type>, 'generate'> while
retaining only the narrowed generate member. In
packages/ai-react/src/use-generate-video.ts lines 83-90, extract those four
fields and their caveat JSDoc into a shared GenerationResumeSurface interface,
then extend it instead of re-declaring them.
In `@packages/ai-react/src/use-generate-speech.ts`:
- Around line 33-36: Derive the React wrapper types from the shared generation
types instead of hand-copying resume and artifact fields. In
packages/ai-react/src/use-generate-speech.ts at lines 33-36, make
UseGenerateSpeechOptions extend Pick<UseGenerationOptions<SpeechGenerateInput,
TTSResult, TOutput>, 'persistence' | 'initialResumeSnapshot'> and
UseGenerateSpeechReturn extend Omit<UseGenerationReturn<TOutput,
SpeechGenerateInput>, 'generate'>; apply the same change in
packages/ai-react/src/use-transcription.ts at lines 33-36 using
TranscriptionGenerateInput and TranscriptionResult.
In `@packages/ai-svelte/src/create-generate-video.svelte.ts`:
- Around line 311-322: Update the module containing the resumeSnapshot getters
to define shared EMPTY_PENDING_ARTIFACTS and EMPTY_RESULT_ARTIFACTS constants at
module scope, then use them as the nullish fallbacks in pendingArtifacts and
resultArtifacts instead of allocating [] on each read. Keep the existing
non-empty artifact values unchanged.
In `@packages/ai-svelte/tests/create-generation.test.ts`:
- Around line 248-273: Update both negative-assertion tests in
create-generation.test.ts, including the test near the referenced additional
range, to await the existing flushAsync() helper instead of a single
Promise.resolve() microtask. Keep the assertions verifying that getItem and
connect were not called, so detached hydration and persistence queues are fully
drained before checking behavior.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: b2e31f65-38c8-47c9-8113-258d0c9c7aec
📒 Files selected for processing (62)
.changeset/generation-persistence.mddocs/config.jsondocs/persistence/generation-persistence.mdexamples/ts-react-chat/src/routes/generations.image.tsxpackages/ai-angular/src/index.tspackages/ai-angular/src/inject-generate-audio.tspackages/ai-angular/src/inject-generate-image.tspackages/ai-angular/src/inject-generate-speech.tspackages/ai-angular/src/inject-generate-video.tspackages/ai-angular/src/inject-generation.tspackages/ai-angular/src/inject-summarize.tspackages/ai-angular/src/inject-transcription.tspackages/ai-angular/tests/inject-generation.test.tspackages/ai-client/src/generation-client.tspackages/ai-client/src/generation-types.tspackages/ai-client/src/index.tspackages/ai-client/src/storage-adapters.tspackages/ai-client/src/video-generation-client.tspackages/ai-client/tests/generation-client.test.tspackages/ai-client/tests/generation-resume-state.test.tspackages/ai-react/src/index.tspackages/ai-react/src/use-generate-audio.tspackages/ai-react/src/use-generate-image.tspackages/ai-react/src/use-generate-speech.tspackages/ai-react/src/use-generate-video.tspackages/ai-react/src/use-generation.tspackages/ai-react/src/use-summarize.tspackages/ai-react/src/use-transcription.tspackages/ai-react/tests/use-generation.test.tspackages/ai-solid/src/index.tspackages/ai-solid/src/use-generate-audio.tspackages/ai-solid/src/use-generate-image.tspackages/ai-solid/src/use-generate-speech.tspackages/ai-solid/src/use-generate-video.tspackages/ai-solid/src/use-generation.tspackages/ai-solid/src/use-summarize.tspackages/ai-solid/src/use-transcription.tspackages/ai-solid/tests/use-generation.test.tspackages/ai-svelte/src/create-generate-audio.svelte.tspackages/ai-svelte/src/create-generate-image.svelte.tspackages/ai-svelte/src/create-generate-speech.svelte.tspackages/ai-svelte/src/create-generate-video.svelte.tspackages/ai-svelte/src/create-generation.svelte.tspackages/ai-svelte/src/create-summarize.svelte.tspackages/ai-svelte/src/create-transcription.svelte.tspackages/ai-svelte/src/index.tspackages/ai-svelte/tests/create-generation.test.tspackages/ai-vue/src/index.tspackages/ai-vue/src/use-generate-audio.tspackages/ai-vue/src/use-generate-image.tspackages/ai-vue/src/use-generate-speech.tspackages/ai-vue/src/use-generate-video.tspackages/ai-vue/src/use-generation.tspackages/ai-vue/src/use-summarize.tspackages/ai-vue/src/use-transcription.tspackages/ai-vue/tests/use-generation.test.tspackages/ai/skills/ai-core/client-persistence/SKILL.mdpackages/ai/skills/ai-core/media-generation/SKILL.mdtesting/e2e/src/routeTree.gen.tstesting/e2e/src/routes/api.generation-persistence.tstesting/e2e/src/routes/generation-persistence.tsxtesting/e2e/tests/generation-persistence.spec.ts
| <ImageGenerationUI | ||
| {...hookReturn} | ||
| prompt={prompt} | ||
| setPrompt={setPrompt} | ||
| numberOfImages={numberOfImages} | ||
| setNumberOfImages={setNumberOfImages} | ||
| /> |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Keep “Clear” available for hydrated snapshots.
After reload, resumeSnapshot is restored but result is intentionally empty, so ImageGenerationUI hides its only reset() button. Users cannot clear the persisted record from this mode.
Proposed fix
function ImageGenerationUI({
prompt,
setPrompt,
numberOfImages,
setNumberOfImages,
generate,
result,
+ resumeSnapshot,
isLoading,
error,
reset,
}: UseGenerateImageReturn & {
@@
- {result && (
+ {(result || resumeSnapshot) && (🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@examples/ts-react-chat/src/routes/generations.image.tsx` around lines 119 -
125, Update the ImageGenerationUI reset-button visibility logic so the “Clear”
action remains available when a hydrated resumeSnapshot exists, even if result
is empty. Ensure the button invokes the existing reset() behavior and preserve
the current visibility behavior for non-hydrated states.
| const expiresAt = Reflect.get(value, 'expiresAt') | ||
| if (typeof expiresAt === 'string') { | ||
| snapshot.expiresAt = expiresAt | ||
| } else if (expiresAt instanceof Date) { | ||
| snapshot.expiresAt = expiresAt.toISOString() | ||
| } |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win
Guard against an invalid Date in expiresAt.
toISOString() throws RangeError on an invalid Date. This runs inside observeResumeSnapshot on live chunk values, so a malformed provider expiresAt would throw out of the stream loop rather than just being dropped like every other unusable field here.
🛡️ Proposed fix
} else if (expiresAt instanceof Date) {
- snapshot.expiresAt = expiresAt.toISOString()
+ if (!Number.isNaN(expiresAt.getTime())) {
+ snapshot.expiresAt = expiresAt.toISOString()
+ }
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const expiresAt = Reflect.get(value, 'expiresAt') | |
| if (typeof expiresAt === 'string') { | |
| snapshot.expiresAt = expiresAt | |
| } else if (expiresAt instanceof Date) { | |
| snapshot.expiresAt = expiresAt.toISOString() | |
| } | |
| const expiresAt = Reflect.get(value, 'expiresAt') | |
| if (typeof expiresAt === 'string') { | |
| snapshot.expiresAt = expiresAt | |
| } else if (expiresAt instanceof Date) { | |
| if (!Number.isNaN(expiresAt.getTime())) { | |
| snapshot.expiresAt = expiresAt.toISOString() | |
| } | |
| } |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@packages/ai-client/src/generation-types.ts` around lines 602 - 607, Update
the expiresAt handling in observeResumeSnapshot to validate Date values before
calling toISOString, so invalid dates are ignored without throwing. Preserve the
existing string handling and valid Date serialization behavior.
| onResumeSnapshotChange: (snapshot) => { | ||
| if (!disposedRef.current) setResumeSnapshot(snapshot) | ||
| }, |
There was a problem hiding this comment.
🚀 Performance & Scalability | 🟠 Major | ⚡ Quick win
Per-chunk setResumeSnapshot re-renders the component on every stream event.
GenerationClient.observeResumeSnapshot invokes onResumeSnapshotChange for every chunk with a freshly built object, so this always schedules a state update — including for lastEvent-only changes that the persistence layer deliberately dedupes (resumeSnapshotSignature). For a long streaming run that is one React render per chunk, on top of the existing progress/result renders.
Consider suppressing the notification when the snapshot has not materially changed (reuse the signature comparison, or skip when only lastEvent differs) so the reactive surfaces stay as cheap as the writes. This also affects the Vue and Svelte hooks that wire the same callback.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@packages/ai-react/src/use-generation.ts` around lines 193 - 195, Update the
onResumeSnapshotChange callback in the React hook and the corresponding
Vue/Svelte integrations to suppress state updates when the resume snapshot has
not materially changed. Reuse the existing resumeSnapshotSignature comparison or
equivalent logic so lastEvent-only changes are ignored while meaningful snapshot
changes still call setResumeSnapshot and update the reactive state.
| get pendingArtifacts() { | ||
| return resumeSnapshot?.pendingArtifacts ?? [] | ||
| }, | ||
| get resultArtifacts() { | ||
| return resumeSnapshot?.result?.artifacts ?? [] | ||
| }, |
There was a problem hiding this comment.
🚀 Performance & Scalability | 🟡 Minor | ⚡ Quick win
Use stable empty-array fallbacks here for parity with React/Vue.
Each read allocates a fresh [], so any $derived/$effect depending on pendingArtifacts/resultArtifacts sees a new reference on every access. packages/ai-react/src/use-generation.ts and packages/ai-vue/src/use-generation.ts deliberately hoist EMPTY_PENDING_ARTIFACTS / EMPTY_RESULT_ARTIFACTS constants for exactly this reason.
♻️ Proposed fix
get pendingArtifacts() {
- return resumeSnapshot?.pendingArtifacts ?? []
+ return resumeSnapshot?.pendingArtifacts ?? EMPTY_PENDING_ARTIFACTS
},
get resultArtifacts() {
- return resumeSnapshot?.result?.artifacts ?? []
+ return resumeSnapshot?.result?.artifacts ?? EMPTY_RESULT_ARTIFACTS
},Add module-level constants:
const EMPTY_PENDING_ARTIFACTS: Array<GenerationPendingArtifact> = []
const EMPTY_RESULT_ARTIFACTS: Array<PersistedArtifactRef> = []📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| get pendingArtifacts() { | |
| return resumeSnapshot?.pendingArtifacts ?? [] | |
| }, | |
| get resultArtifacts() { | |
| return resumeSnapshot?.result?.artifacts ?? [] | |
| }, | |
| const EMPTY_PENDING_ARTIFACTS: Array<GenerationPendingArtifact> = [] | |
| const EMPTY_RESULT_ARTIFACTS: Array<PersistedArtifactRef> = [] | |
| get pendingArtifacts() { | |
| return resumeSnapshot?.pendingArtifacts ?? EMPTY_PENDING_ARTIFACTS | |
| }, | |
| get resultArtifacts() { | |
| return resumeSnapshot?.result?.artifacts ?? EMPTY_RESULT_ARTIFACTS | |
| }, |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@packages/ai-svelte/src/create-generation.svelte.ts` around lines 298 - 303,
Define module-level EMPTY_PENDING_ARTIFACTS and EMPTY_RESULT_ARTIFACTS constants
with the appropriate artifact types, then update the pendingArtifacts and
resultArtifacts getters to reuse these stable fallbacks instead of allocating a
new empty array on each read.
| /** Lightweight generation resume snapshot, if one is available */ | ||
| resumeSnapshot: DeepReadonly<ShallowRef<GenerationResumeSnapshot | undefined>> | ||
| /** Identity of the in-flight run while one is streaming, or null after it ends */ | ||
| resumeState: DeepReadonly<ShallowRef<GenerationResumeState | null>> | ||
| /** Pending persisted artifact refs observed mid-run. Currently always empty: nothing emits `generation:artifacts` until the server-side artifact pipeline ships in a follow-up */ | ||
| pendingArtifacts: DeepReadonly<ShallowRef<Array<GenerationPendingArtifact>>> | ||
| /** Persisted artifact refs from the final result. Currently always empty: results carry no artifacts until the server-side artifact pipeline ships in a follow-up */ | ||
| resultArtifacts: DeepReadonly<ShallowRef<Array<PersistedArtifactRef>>> |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Stale "Currently always empty" artifact docs contradict the new tests. The shared root cause is JSDoc copied across framework return types asserting that pendingArtifacts/resultArtifacts stay empty until the server-side artifact pipeline ships, while the client already surfaces artifacts from generation:result.
packages/ai-vue/src/use-generate-video.ts#L93-L100: drop the "Currently always empty" claim and describe when the lists are populated.packages/ai-svelte/src/create-generate-video.svelte.ts#L88-L95: apply the same doc correction (and mirror it in the other framework hooks carrying the copied text).packages/ai-react/tests/use-generation.test.ts#L475-L493: keep as the behavioral source of truth; if the docs are actually correct, this expectation needs revisiting instead.
📍 Affects 3 files
packages/ai-vue/src/use-generate-video.ts#L93-L100(this comment)packages/ai-svelte/src/create-generate-video.svelte.ts#L88-L95packages/ai-react/tests/use-generation.test.ts#L475-L493
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@packages/ai-vue/src/use-generate-video.ts` around lines 93 - 100, The
artifact JSDoc incorrectly claims pendingArtifacts and resultArtifacts are
always empty; update the documentation in
packages/ai-vue/src/use-generate-video.ts lines 93-100 and
packages/ai-svelte/src/create-generate-video.svelte.ts lines 88-95 to describe
when each list is populated, and mirror the correction in other framework hooks
with the copied text. Keep packages/ai-react/tests/use-generation.test.ts lines
475-493 as the behavioral source of truth and make no test change unless the
corrected documentation reveals the expectation is wrong.
🎯 Changes
Stack 1 of 2. Split out of #987, rebuilt on the current
feat/persistence-core(which dropped the packaged backends in62c99754d). Stacked follow-up: the durable media-byte half.Client-side generation persistence: a lightweight resume snapshot for media generation activities.
As a run streams, the client builds a
GenerationResumeSnapshot— run identity, status, errors, and result metadata, but never the generated media bytes — and writes it to apersistencestorage adapter under the keygeneration:<id>(namespaced so a chat client sharing the id and adapter can't collide). On construction the client reads the snapshot back, validated through the newparseGenerationResumeSnapshot(unknown)export, so the last run's outcome survives a full page reload; an explicitinitialResumeSnapshotseed skips the read for apps that manage storage themselves. The option reuses the sameChatStorageAdaptercontract as chat, solocalStoragePersistence/sessionStoragePersistence/indexedDBPersistencework for generations too — the factory defaults stay typed to the chat shape as shipped in #984; inline usage infers the value type contextually, and a standalone generation store states its type explicitly.Generation hooks (
useGenerateImage,useGenerateVideo,useGenerateAudio,useGenerateSpeech,useGeneration,useSummarize,useTranscription, plus the Solid/Vue/Svelte/Angular equivalents) acceptpersistenceandinitialResumeSnapshot, and exposeresumeSnapshot/resumeState/pendingArtifacts/resultArtifacts.The base generation return types (
UseGenerationReturn/CreateGenerationReturn/InjectGenerationResult) also gained a defaultedTInputgeneric across all five frameworks, sogenerateis precisely typed(input: TInput) => Promise<void>— this deleted the unsound internal narrow-to-wide casts and every wrapper-levelgenerate ascast (twenty-five in total), with existing single-generic references unaffected.Snapshot lifecycle is fully specified:
stop()writes a terminal (no longer resumable) record, transport-level failures write anerrorrecord,reset()deletes the persisted record, a newRUN_STARTEDdrops stale result/error/artifacts from the previous run, plain (non-Response) fetcher runs record acompletesnapshot built from the fetcher result, and video runs capturejobIdfromvideo:job:createdmid-run. Persisted snapshots carryschemaVersion: 1; writes are skipped when nothing material changed (no more one-write-per-chunk), and persistence failures warn once per failure transition.There is no
resume()action and no restart of provider work — generation still only begins whengenerate(...)is called. Reconnecting to an in-flight stream is the delivery layer's job (resumable streams, #955), wired via adurabilityadapter +GEThandler exactly as for chat.PersistedArtifactRefalready exists on the base branch as dormant wire surface, so this half consumes it and is independently mergeable — thegeneration:artifactsevent simply never fires until the stacked byte-storage PR lands. The hook JSDoc now says so explicitly.Packages
@tanstack/ai-client— the resume-snapshot reducer,parseGenerationResumeSnapshot, hydration, thepersistenceoption, artifact refs.docs/persistence/generation-persistence.md(kiira-verified) + agent-skill updates (ai-core/client-persistence,ai-core/media-generation).testing/e2e/tests/generation-persistence.spec.ts: provider-free reload-and-rehydrate spec (write → reload → hydrate → reset).✅ Resolution of the previously-listed known issues
All items from the earlier revision of this PR body have been addressed:
mountDevtools()now revives a disposed client (the hooks call it from their mount effects);generate()checksdisposedbefore mounting devtools. Covered by a<StrictMode>hook test and client-level revive tests.persistence.getItem('generation:<id>')on construction, validated; docs/example/E2E all exercise the reload path for real.lastEvent-only churn is skipped); failures warn once per transition andgetResumePersistenceError()self-clears on the next successful write.completesnapshot built from the fetcher result (no seed required).generation:<id>.BaseEventContextalready declares both fields, the additions were redundant redeclarations.@tanstack/ai-event-clientis untouched and dropped from the changeset.TValue = anywidening — reverted: the factory defaults are back toChatPersistedStateas shipped in feat(persistence): server persistence + client browser-refresh durability #984. Inline usage infers the value type contextually for both chat and generation, so nothing is lost; a standalone generation store states its type explicitly (localStoragePersistence<GenerationResumeSnapshot>()), and the three oxlint suppressions are gone. Hydrated reads remain runtime-validated regardless.error/resultcarried into the next run) — fixed in the reducer withRUN_STARTEDboundaries;stop()/reset()/transport errors leave consistent durable records.getResumePersistenceError()remains client-only observability (now self-clearing); exposing it through the hooks is deliberately deferred.✅ Checklist
pnpm run test:pr. — full run green (287 tasks incl. sherif/knip/docs/kiira/oxlint/lib/types/build), plus the complete Playwright E2E suite.🚀 Release Impact
Summary by CodeRabbit
New Features
Documentation
Bug Fixes