diff --git a/.changeset/ai-client-store-snapshot.md b/.changeset/ai-client-store-snapshot.md new file mode 100644 index 0000000000..8fa61cb5b7 --- /dev/null +++ b/.changeset/ai-client-store-snapshot.md @@ -0,0 +1,14 @@ +--- +'@tanstack/ai-client': minor +'@tanstack/ai-react': patch +'@tanstack/ai-solid': patch +'@tanstack/ai-vue': patch +'@tanstack/ai-svelte': patch +'@tanstack/ai-angular': patch +'@tanstack/ai-preact': patch +'@tanstack/ai-octane': patch +--- + +Hold client UI state in a TanStack Store atom. Framework hooks read `getSnapshot()` instead of copying fields through change callbacks. React `useChat()` first commits `initialMessages` and `initialResumeSnapshot`, then applies browser persistence after attach. This timing applies to client-only rendering and keeps the server snapshot stable during SSR hydration. The `useChat()` return shape stays the same. Frozen snapshot `messages` and `queue` arrays throw on in-place `push` in every adapter. Use `setMessages`. + +`ChatClient` adds `subscribeSnapshot` / `getSnapshot`. The live connection method stays `subscribe()`. Generation, video, realtime, and BYOK clients expose `subscribe` / `getSnapshot`. `AudioRecorder.subscribe` still passes the new state value. diff --git a/docs/api/ai-client.md b/docs/api/ai-client.md index 2ae79139a1..3783965218 100644 --- a/docs/api/ai-client.md +++ b/docs/api/ai-client.md @@ -273,6 +273,47 @@ Stop tailing and drop the connection. Keeps messages, the run pointer and the ru id, so a later `attach()` continues where it left off. See [Lifecycle](#lifecycle-attach-and-detach). +#### `getSnapshot()` + +Returns the current frozen `ChatClientSnapshot`. The field names are +`messages`, `status`, `isLoading`, `error`, `queue`, `runId`, `isSubscribed`, +`connectionStatus`, `sessionGenerating`, and `interruptState`. Nested +`messages` and `queue` entries are copies. In-place `messages.push` throws. +Use `setMessages`. + +`subscribeSnapshot(listener)` returns an unsubscribe function. It does not +fire with the current value. Read `getSnapshot()` first. This is not +`subscribe()`, which starts the live connection loop. + +```typescript +import { ChatClient, fetchServerSentEvents } from "@tanstack/ai-client"; + +const client = new ChatClient({ + connection: fetchServerSentEvents("/api/chat"), +}); + +function logSnapshot() { + const snapshot = client.getSnapshot(); + console.log(snapshot.status, snapshot.messages.length); +} + +logSnapshot(); + +const unsubscribe = client.subscribeSnapshot(() => { + logSnapshot(); +}); + +client.attach(); +client.sendMessage("Hello"); +``` + +`GenerationClient`, `VideoGenerationClient`, `RealtimeClient`, and +`ByokClient` use `subscribe(listener)` / `getSnapshot()`. The listener takes +no argument. Call `getSnapshot()` inside it. + +`AudioRecorder.subscribe` still passes the new state: +`subscribe((state) => ...)`. `getSnapshot()` returns that same state value. + #### `stop()` Stops the current response generation. It aborts the in-flight request. diff --git a/docs/config.json b/docs/config.json index f2e7b13277..bfa93357e5 100644 --- a/docs/config.json +++ b/docs/config.json @@ -486,7 +486,7 @@ "label": "Client Persistence", "to": "persistence/client-persistence", "addedAt": "2026-08-04", - "updatedAt": "2026-08-20" + "updatedAt": "2026-08-24" }, { "label": "Generation Persistence", diff --git a/docs/persistence/client-persistence.md b/docs/persistence/client-persistence.md index b38673ea1c..e2d3e79366 100644 --- a/docs/persistence/client-persistence.md +++ b/docs/persistence/client-persistence.md @@ -56,10 +56,13 @@ to pick one and how it differs from the `runId` the hook reports. The client stores one record per `threadId`, the transcript plus a small resume pointer. On the next load `useChat` reads it and: -- **Repaints the transcript** from storage with no network. Sync adapters - (`localStorage` / `sessionStorage`) hydrate during construction; IndexedDB - hydrates asynchronously after the database opens (so the first paint may be - empty for a tick). +- **Repaints the transcript** from storage with no network. In React, the first + committed render uses `initialMessages` and `initialResumeSnapshot`. After the + hook attaches, it applies browser persistence from either a synchronous or an + asynchronous adapter. This timing also prevents SSR hydration mismatches. A + direct `ChatClient` and non-React framework wrappers still apply synchronous + stores during construction. IndexedDB always hydrates asynchronously after + the database opens. - **Rehydrates a pending interrupt**, so an approval prompt comes back exactly as it was. - **Rejoins an in-flight run**, if a reply was still streaming when the page diff --git a/examples/sandbox-cloudflare/package.json b/examples/sandbox-cloudflare/package.json index b6038d8cf8..1375ec6d77 100644 --- a/examples/sandbox-cloudflare/package.json +++ b/examples/sandbox-cloudflare/package.json @@ -22,8 +22,8 @@ "@tanstack/ai-sandbox": "workspace:*", "@tanstack/ai-sandbox-cloudflare": "workspace:*", "@tanstack/ai-sandbox-local-process": "workspace:*", - "@tanstack/react-router": "^1.158.4", - "@tanstack/react-start": "^1.159.0", + "@tanstack/react-router": "^1.170.32", + "@tanstack/react-start": "^1.168.49", "lucide-react": "^0.561.0", "react": "^19.2.3", "react-dom": "^19.2.3", @@ -37,7 +37,7 @@ "devDependencies": { "@cloudflare/vite-plugin": "^1.42.1", "@tailwindcss/vite": "^4.1.18", - "@tanstack/router-plugin": "^1.158.4", + "@tanstack/router-plugin": "^1.168.35", "@types/node": "^24.10.1", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", diff --git a/examples/sandbox-web/package.json b/examples/sandbox-web/package.json index 75f96ab13e..941ff6df61 100644 --- a/examples/sandbox-web/package.json +++ b/examples/sandbox-web/package.json @@ -17,8 +17,8 @@ "@tanstack/ai-react": "workspace:*", "@tanstack/ai-sandbox": "workspace:*", "@tanstack/ai-sandbox-docker": "workspace:*", - "@tanstack/react-router": "^1.158.4", - "@tanstack/react-start": "^1.159.0", + "@tanstack/react-router": "^1.170.32", + "@tanstack/react-start": "^1.168.49", "lucide-react": "^0.561.0", "react": "^19.2.3", "react-dom": "^19.2.3", @@ -31,7 +31,7 @@ }, "devDependencies": { "@tailwindcss/vite": "^4.1.18", - "@tanstack/router-plugin": "^1.158.4", + "@tanstack/router-plugin": "^1.168.35", "@types/node": "^24.10.1", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", diff --git a/examples/ts-code-mode-web/package.json b/examples/ts-code-mode-web/package.json index 6ffbec8b6e..c829b45cf6 100644 --- a/examples/ts-code-mode-web/package.json +++ b/examples/ts-code-mode-web/package.json @@ -32,10 +32,10 @@ "@tanstack/ai-ollama": "workspace:*", "@tanstack/ai-openai": "workspace:*", "@tanstack/ai-react": "workspace:*", - "@tanstack/react-router": "^1.158.4", - "@tanstack/react-start": "^1.159.0", - "@tanstack/router-plugin": "^1.158.4", - "@tanstack/store": "^0.8.0", + "@tanstack/react-router": "^1.170.32", + "@tanstack/react-start": "^1.168.49", + "@tanstack/router-plugin": "^1.168.35", + "@tanstack/store": "^0.11.1", "framer-motion": "^11.15.0", "highlight.js": "^11.11.1", "isolated-vm": "^6.0.2", diff --git a/examples/ts-code-mode-web/src/routes/_banking-demo/api.banking-demo.ts b/examples/ts-code-mode-web/src/routes/_banking-demo/api.banking-demo.ts index ff14d96999..335115b4be 100644 --- a/examples/ts-code-mode-web/src/routes/_banking-demo/api.banking-demo.ts +++ b/examples/ts-code-mode-web/src/routes/_banking-demo/api.banking-demo.ts @@ -199,7 +199,7 @@ async function getCodeModeTools() { return codeModeCache } -export const Route = createFileRoute('/_banking-demo/api/banking-demo' as any)({ +export const Route = createFileRoute('/_banking-demo/api/banking-demo')({ server: { handlers: { POST: async ({ request }) => { diff --git a/examples/ts-code-mode-web/src/routes/_banking-demo/api.banking-init.ts b/examples/ts-code-mode-web/src/routes/_banking-demo/api.banking-init.ts index 30630aaa38..1275b6aca7 100644 --- a/examples/ts-code-mode-web/src/routes/_banking-demo/api.banking-init.ts +++ b/examples/ts-code-mode-web/src/routes/_banking-demo/api.banking-init.ts @@ -10,7 +10,7 @@ import { getMockBalances } from '@/lib/reports/create-handler-bindings' const DASHBOARD_ID = 'dashboard' -export const Route = createFileRoute('/_banking-demo/api/banking-init' as any)({ +export const Route = createFileRoute('/_banking-demo/api/banking-init')({ server: { handlers: { POST: async () => { diff --git a/examples/ts-code-mode-web/src/routes/_banking-demo/banking-demo.tsx b/examples/ts-code-mode-web/src/routes/_banking-demo/banking-demo.tsx index d0ab65e1db..6dd38976e6 100644 --- a/examples/ts-code-mode-web/src/routes/_banking-demo/banking-demo.tsx +++ b/examples/ts-code-mode-web/src/routes/_banking-demo/banking-demo.tsx @@ -28,7 +28,7 @@ import type { UIUpdate, } from '@/lib/reports/types' -export const Route = createFileRoute('/_banking-demo/banking-demo' as any)({ +export const Route = createFileRoute('/_banking-demo/banking-demo')({ component: BankingDemoPage, }) diff --git a/examples/ts-code-mode-web/src/routes/_database-demo/api.database-demo.ts b/examples/ts-code-mode-web/src/routes/_database-demo/api.database-demo.ts index 10d7a12a85..4258f9ceb3 100644 --- a/examples/ts-code-mode-web/src/routes/_database-demo/api.database-demo.ts +++ b/examples/ts-code-mode-web/src/routes/_database-demo/api.database-demo.ts @@ -226,9 +226,7 @@ function wrapWithTimingEvents( })() } -export const Route = createFileRoute( - '/_database-demo/api/database-demo' as any, -)({ +export const Route = createFileRoute('/_database-demo/api/database-demo')({ server: { handlers: { POST: async ({ request }) => { diff --git a/examples/ts-code-mode-web/src/routes/_database-demo/api.db-snippets.ts b/examples/ts-code-mode-web/src/routes/_database-demo/api.db-snippets.ts index a7f0cdcac0..a04274deb9 100644 --- a/examples/ts-code-mode-web/src/routes/_database-demo/api.db-snippets.ts +++ b/examples/ts-code-mode-web/src/routes/_database-demo/api.db-snippets.ts @@ -14,7 +14,7 @@ const snippetStorage = createFileSnippetStorage({ trustStrategy, }) -export const Route = createFileRoute('/_database-demo/api/db-snippets' as any)({ +export const Route = createFileRoute('/_database-demo/api/db-snippets')({ server: { handlers: { GET: async () => { diff --git a/examples/ts-code-mode-web/src/routes/_database-demo/api.judge.ts b/examples/ts-code-mode-web/src/routes/_database-demo/api.judge.ts index 3b31b0a3e3..b231857928 100644 --- a/examples/ts-code-mode-web/src/routes/_database-demo/api.judge.ts +++ b/examples/ts-code-mode-web/src/routes/_database-demo/api.judge.ts @@ -29,7 +29,7 @@ Rate the candidate on two dimensions, each on a scale from 1 to 10 (10 = best): Provide a two sentence summary of your evaluation.` -export const Route = createFileRoute('/_database-demo/api/judge' as any)({ +export const Route = createFileRoute('/_database-demo/api/judge')({ server: { handlers: { POST: async ({ request }) => { diff --git a/examples/ts-code-mode-web/src/routes/_database-demo/database-demo.tsx b/examples/ts-code-mode-web/src/routes/_database-demo/database-demo.tsx index c4a05fab1e..d4d9c966e2 100644 --- a/examples/ts-code-mode-web/src/routes/_database-demo/database-demo.tsx +++ b/examples/ts-code-mode-web/src/routes/_database-demo/database-demo.tsx @@ -25,7 +25,7 @@ import ChatInput from '@/components/ChatInput' import { formatDuration } from '@/lib/efficiency' import { toolResultContentToString } from '@/lib/tool-result-content' -export const Route = createFileRoute('/_database-demo/database-demo' as any)({ +export const Route = createFileRoute('/_database-demo/database-demo')({ component: DatabaseDemoPage, }) diff --git a/examples/ts-code-mode-web/src/routes/_execute-prompt/api.execute-prompt.ts b/examples/ts-code-mode-web/src/routes/_execute-prompt/api.execute-prompt.ts index f5c83f3f42..f49ecf5a4f 100644 --- a/examples/ts-code-mode-web/src/routes/_execute-prompt/api.execute-prompt.ts +++ b/examples/ts-code-mode-web/src/routes/_execute-prompt/api.execute-prompt.ts @@ -33,9 +33,7 @@ async function getDriver(): Promise { return cachedDriver } -export const Route = createFileRoute( - '/_execute-prompt/api/execute-prompt' as any, -)({ +export const Route = createFileRoute('/_execute-prompt/api/execute-prompt')({ server: { handlers: { POST: async ({ request }) => { diff --git a/examples/ts-code-mode-web/src/routes/_execute-prompt/api.realtime-token.ts b/examples/ts-code-mode-web/src/routes/_execute-prompt/api.realtime-token.ts index 9b4b38cafe..5b7ab96876 100644 --- a/examples/ts-code-mode-web/src/routes/_execute-prompt/api.realtime-token.ts +++ b/examples/ts-code-mode-web/src/routes/_execute-prompt/api.realtime-token.ts @@ -2,9 +2,7 @@ import { createFileRoute } from '@tanstack/react-router' import { realtimeToken } from '@tanstack/ai' import { openaiRealtimeToken } from '@tanstack/ai-openai' -export const Route = createFileRoute( - '/_execute-prompt/api/realtime-token' as any, -)({ +export const Route = createFileRoute('/_execute-prompt/api/realtime-token')({ server: { handlers: { POST: async () => { diff --git a/examples/ts-code-mode-web/src/routes/_execute-prompt/execute-prompt.tsx b/examples/ts-code-mode-web/src/routes/_execute-prompt/execute-prompt.tsx index e44c07563d..d42bebb877 100644 --- a/examples/ts-code-mode-web/src/routes/_execute-prompt/execute-prompt.tsx +++ b/examples/ts-code-mode-web/src/routes/_execute-prompt/execute-prompt.tsx @@ -29,7 +29,7 @@ import type { CodeExecution, } from '@/lib/execute-prompt-realtime-tools' -export const Route = createFileRoute('/_execute-prompt/execute-prompt' as any)({ +export const Route = createFileRoute('/_execute-prompt/execute-prompt')({ component: DashboardDemoPage, }) diff --git a/examples/ts-code-mode-web/src/routes/_reporting/api.invalidate.ts b/examples/ts-code-mode-web/src/routes/_reporting/api.invalidate.ts index f7100859e5..f473ebd017 100644 --- a/examples/ts-code-mode-web/src/routes/_reporting/api.invalidate.ts +++ b/examples/ts-code-mode-web/src/routes/_reporting/api.invalidate.ts @@ -4,7 +4,7 @@ import { getReportState, getSSEController } from '@/lib/reports/report-storage' import { refreshComponent } from '@/lib/reports/refresh-component' import { evaluateWatchersForSignals } from '@/lib/reports/evaluate-watchers' -export const Route = createFileRoute('/_reporting/api/invalidate' as any)({ +export const Route = createFileRoute('/_reporting/api/invalidate')({ server: { handlers: { POST: async ({ request }) => { diff --git a/examples/ts-code-mode-web/src/routes/_reporting/api.report-demo.ts b/examples/ts-code-mode-web/src/routes/_reporting/api.report-demo.ts index dcf599641b..2ef92fdd4d 100644 --- a/examples/ts-code-mode-web/src/routes/_reporting/api.report-demo.ts +++ b/examples/ts-code-mode-web/src/routes/_reporting/api.report-demo.ts @@ -4,7 +4,7 @@ import { simulateDeposit, } from '@/lib/reports/create-handler-bindings' -export const Route = createFileRoute('/_reporting/api/report-demo' as any)({ +export const Route = createFileRoute('/_reporting/api/report-demo')({ server: { handlers: { POST: async ({ request }) => { diff --git a/examples/ts-code-mode-web/src/routes/_reporting/api.report-event.ts b/examples/ts-code-mode-web/src/routes/_reporting/api.report-event.ts index 90bf68b2f5..11d97742ee 100644 --- a/examples/ts-code-mode-web/src/routes/_reporting/api.report-event.ts +++ b/examples/ts-code-mode-web/src/routes/_reporting/api.report-event.ts @@ -16,7 +16,7 @@ import { import { refreshComponent } from '@/lib/reports/refresh-component' import { evaluateWatchersForSignals } from '@/lib/reports/evaluate-watchers' -export const Route = createFileRoute('/_reporting/api/report-event' as any)({ +export const Route = createFileRoute('/_reporting/api/report-event')({ server: { handlers: { POST: async ({ request }) => { diff --git a/examples/ts-code-mode-web/src/routes/_reporting/api.report-sse.ts b/examples/ts-code-mode-web/src/routes/_reporting/api.report-sse.ts index d53bf55080..97a429b400 100644 --- a/examples/ts-code-mode-web/src/routes/_reporting/api.report-sse.ts +++ b/examples/ts-code-mode-web/src/routes/_reporting/api.report-sse.ts @@ -5,7 +5,7 @@ import { getSSEController, } from '@/lib/reports/report-storage' -export const Route = createFileRoute('/_reporting/api/report-sse' as any)({ +export const Route = createFileRoute('/_reporting/api/report-sse')({ server: { handlers: { GET: async ({ request }) => { diff --git a/examples/ts-code-mode-web/src/routes/_reporting/api.reports.ts b/examples/ts-code-mode-web/src/routes/_reporting/api.reports.ts index 4ff06ad390..4a64e80fa1 100644 --- a/examples/ts-code-mode-web/src/routes/_reporting/api.reports.ts +++ b/examples/ts-code-mode-web/src/routes/_reporting/api.reports.ts @@ -49,7 +49,7 @@ async function getCodeModeTools() { return codeModeCache } -export const Route = createFileRoute('/_reporting/api/reports' as any)({ +export const Route = createFileRoute('/_reporting/api/reports')({ server: { handlers: { POST: async ({ request }) => { diff --git a/examples/ts-group-chat/package.json b/examples/ts-group-chat/package.json index 8b0e844330..66edb51124 100644 --- a/examples/ts-group-chat/package.json +++ b/examples/ts-group-chat/package.json @@ -14,10 +14,10 @@ "@tanstack/ai": "workspace:*", "@tanstack/ai-anthropic": "workspace:*", "@tanstack/react-devtools": "^0.9.10", - "@tanstack/react-router": "^1.158.4", - "@tanstack/react-router-devtools": "^1.158.4", - "@tanstack/react-start": "^1.159.0", - "@tanstack/router-plugin": "^1.158.4", + "@tanstack/react-router": "^1.170.32", + "@tanstack/react-router-devtools": "^1.167.1", + "@tanstack/react-start": "^1.168.49", + "@tanstack/router-plugin": "^1.168.35", "capnweb": "^0.10.0", "nitro": "3.0.260610-beta", "react": "^19.2.3", diff --git a/examples/ts-react-chat/package.json b/examples/ts-react-chat/package.json index c0502d9761..2c3c924468 100644 --- a/examples/ts-react-chat/package.json +++ b/examples/ts-react-chat/package.json @@ -49,13 +49,13 @@ "@tanstack/ai-sandbox-local-process": "workspace:*", "@tanstack/ai-sandbox-vercel": "workspace:*", "@tanstack/react-devtools": "^0.9.10", - "@tanstack/react-router": "^1.158.4", - "@tanstack/react-router-devtools": "^1.158.4", - "@tanstack/react-router-ssr-query": "^1.158.4", - "@tanstack/react-start": "^1.159.0", - "@tanstack/react-store": "^0.8.0", - "@tanstack/router-plugin": "^1.158.4", - "@tanstack/store": "^0.8.0", + "@tanstack/react-router": "^1.170.32", + "@tanstack/react-router-devtools": "^1.167.1", + "@tanstack/react-router-ssr-query": "^1.167.2", + "@tanstack/react-start": "^1.168.49", + "@tanstack/react-store": "^0.11.1", + "@tanstack/router-plugin": "^1.168.35", + "@tanstack/store": "^0.11.1", "dockerode": "^4.0.2", "highlight.js": "^11.11.1", "lucide-react": "^0.561.0", diff --git a/examples/ts-react-media/package.json b/examples/ts-react-media/package.json index 89f5d0b74f..1f832eb91c 100644 --- a/examples/ts-react-media/package.json +++ b/examples/ts-react-media/package.json @@ -18,9 +18,9 @@ "@tanstack/ai-grok": "workspace:*", "@tanstack/ai-openrouter": "workspace:*", "@tanstack/ai-react": "workspace:*", - "@tanstack/react-router": "^1.158.4", - "@tanstack/react-start": "^1.159.0", - "@tanstack/router-plugin": "^1.158.4", + "@tanstack/react-router": "^1.170.32", + "@tanstack/react-start": "^1.168.49", + "@tanstack/router-plugin": "^1.168.35", "lucide-react": "^0.561.0", "nitro": "3.0.260610-beta", "react": "^19.2.3", diff --git a/examples/ts-react-rerank/package.json b/examples/ts-react-rerank/package.json index 83785b9b13..045fd110d7 100644 --- a/examples/ts-react-rerank/package.json +++ b/examples/ts-react-rerank/package.json @@ -14,9 +14,9 @@ "@tanstack/ai": "workspace:*", "@tanstack/ai-cohere": "workspace:*", "@tanstack/ai-openrouter": "workspace:*", - "@tanstack/react-router": "^1.158.4", - "@tanstack/react-start": "^1.159.0", - "@tanstack/router-plugin": "^1.158.4", + "@tanstack/react-router": "^1.170.32", + "@tanstack/react-start": "^1.168.49", + "@tanstack/router-plugin": "^1.168.35", "lucide-react": "^0.561.0", "nitro": "3.0.260610-beta", "react": "^19.2.3", diff --git a/examples/ts-react-search/package.json b/examples/ts-react-search/package.json index 26b915c7df..d901fb4b1b 100644 --- a/examples/ts-react-search/package.json +++ b/examples/ts-react-search/package.json @@ -19,11 +19,11 @@ "@tanstack/react-db": "^0.1.55", "@tanstack/react-devtools": "^0.9.10", "@tanstack/react-query": "^5.90.12", - "@tanstack/react-router": "^1.158.4", - "@tanstack/react-router-devtools": "^1.158.4", - "@tanstack/react-router-ssr-query": "^1.158.4", - "@tanstack/react-start": "^1.159.0", - "@tanstack/router-plugin": "^1.158.4", + "@tanstack/react-router": "^1.170.32", + "@tanstack/react-router-devtools": "^1.167.1", + "@tanstack/react-router-ssr-query": "^1.167.2", + "@tanstack/react-start": "^1.168.49", + "@tanstack/router-plugin": "^1.168.35", "@tanstack/zod-adapter": "^1.140.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/examples/ts-react-ui-chatbot/package.json b/examples/ts-react-ui-chatbot/package.json index a33bc233fd..4c6f33f742 100644 --- a/examples/ts-react-ui-chatbot/package.json +++ b/examples/ts-react-ui-chatbot/package.json @@ -16,10 +16,10 @@ "@tanstack/ai-openai": "workspace:*", "@tanstack/ai-react": "workspace:*", "@tanstack/react-devtools": "^0.9.10", - "@tanstack/react-router": "^1.158.4", - "@tanstack/react-router-devtools": "^1.158.4", - "@tanstack/react-start": "^1.159.0", - "@tanstack/router-plugin": "^1.158.4", + "@tanstack/react-router": "^1.170.32", + "@tanstack/react-router-devtools": "^1.167.1", + "@tanstack/react-start": "^1.168.49", + "@tanstack/router-plugin": "^1.168.35", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^0.561.0", diff --git a/examples/ts-solid-chat/package.json b/examples/ts-solid-chat/package.json index bccf27dce9..15bf5276a3 100644 --- a/examples/ts-solid-chat/package.json +++ b/examples/ts-solid-chat/package.json @@ -20,15 +20,15 @@ "@tanstack/ai-openai": "workspace:*", "@tanstack/ai-solid": "workspace:*", "@tanstack/nitro-v2-vite-plugin": "^1.155.0", - "@tanstack/router-plugin": "^1.158.4", + "@tanstack/router-plugin": "^1.168.35", "@tanstack/solid-ai-devtools": "workspace:*", "@tanstack/solid-devtools": "^0.7.15", - "@tanstack/solid-router": "^1.139.10", - "@tanstack/solid-router-devtools": "^1.139.10", - "@tanstack/solid-router-ssr-query": "^1.139.10", - "@tanstack/solid-start": "^1.139.10", - "@tanstack/solid-store": "^0.8.0", - "@tanstack/store": "^0.8.0", + "@tanstack/solid-router": "^1.170.30", + "@tanstack/solid-router-devtools": "^1.167.1", + "@tanstack/solid-router-ssr-query": "^1.167.2", + "@tanstack/solid-start": "^1.168.47", + "@tanstack/solid-store": "^0.11.1", + "@tanstack/store": "^0.11.1", "highlight.js": "^11.11.1", "lucide-solid": "^0.554.0", "solid-js": "^1.9.10", diff --git a/packages/ai-angular/src/inject-chat.ts b/packages/ai-angular/src/inject-chat.ts index 58a4a13502..58eec12139 100644 --- a/packages/ai-angular/src/inject-chat.ts +++ b/packages/ai-angular/src/inject-chat.ts @@ -74,7 +74,7 @@ export function injectChat< const isSubscribed = signal(false) const connectionStatus = signal('disconnected') const sessionGenerating = signal(false) - const queue = signal>([]) + const queue = signal>([]) const runId = signal(null) const interruptState = signal>({ interrupts: EMPTY_INTERRUPTS, @@ -133,13 +133,7 @@ export function injectChat< onChunk: (chunk: StreamChunk) => options.onChunk?.(chunk), onFinish: (message) => options.onFinish?.(message), onError: (err) => options.onError?.(err), - onRunIdChange: (nextRunId) => runId.set(nextRunId), - // No `onResumeStateChange`: the run identity is surfaced as the `runId` - // signal (via `onRunIdChange`) and pending interrupts arrive through - // `onInterruptStateChange`, so there is nothing left for it to do — and it - // is not a public option here, matching the other framework packages. onInterruptStateChange: (nextInterruptState, context) => { - interruptState.set(nextInterruptState) options.onInterruptStateChange?.(nextInterruptState, context) }, tools: options.tools, @@ -151,19 +145,24 @@ export function injectChat< ...(options.streamProcessor !== undefined && { streamProcessor: options.streamProcessor, }), - onMessagesChange: (m: Array>) => messages.set(m), - onLoadingChange: (v: boolean) => isLoading.set(v), - onStatusChange: (v: ChatClientState) => status.set(v), - onErrorChange: (v: Error | undefined) => error.set(v), - onSubscriptionChange: (v: boolean) => isSubscribed.set(v), - onConnectionStatusChange: (v: ConnectionStatus) => connectionStatus.set(v), - onSessionGeneratingChange: (v: boolean) => sessionGenerating.set(v), ...(options.queue !== undefined && { queue: options.queue }), - onQueueChange: (nextQueue: Array) => queue.set(nextQueue), }) - messages.set(client.getMessages()) - interruptState.set(client.getInterruptState()) + const applySnapshot = () => { + const next = client.getSnapshot() + messages.set(next.messages as Array>) + isLoading.set(next.isLoading) + error.set(next.error) + status.set(next.status) + isSubscribed.set(next.isSubscribed) + connectionStatus.set(next.connectionStatus) + sessionGenerating.set(next.sessionGenerating) + queue.set(next.queue) + runId.set(next.runId) + interruptState.set(next.interruptState) + } + applySnapshot() + const unsubscribeSnapshot = client.subscribeSnapshot(applySnapshot) // START TAILING HERE, not in the constructor. A client is idle until something // attaches it, so a client that gets built and thrown away never opens a @@ -217,6 +216,7 @@ export function injectChat< ) destroyRef.onDestroy(() => { + unsubscribeSnapshot() // Release the connection first: the counterpart of the `attach` above. client.detach() if (liveSource?.()) { diff --git a/packages/ai-angular/src/inject-generate-video.ts b/packages/ai-angular/src/inject-generate-video.ts index 3287fbea29..43f19d7afb 100644 --- a/packages/ai-angular/src/inject-generate-video.ts +++ b/packages/ai-angular/src/inject-generate-video.ts @@ -181,27 +181,6 @@ export function injectGenerateVideo( onStatusUpdate: (s: VideoStatusInfo) => { if (!disposed) options.onStatusUpdate?.(s) }, - onResultChange: (r: TOutput | null) => { - if (!disposed) result.set(r) - }, - onLoadingChange: (l: boolean) => { - if (!disposed) isLoading.set(l) - }, - onErrorChange: (e: Error | undefined) => { - if (!disposed) error.set(e) - }, - onStatusChange: (s: GenerationClientState) => { - if (!disposed) status.set(s) - }, - onJobIdChange: (id: string | null) => { - if (!disposed) jobId.set(id) - }, - onVideoStatusChange: (s: VideoStatusInfo | null) => { - if (!disposed) videoStatus.set(s) - }, - onResumeStateChange: (rs: { runId: string } | null) => { - if (!disposed) runId.set(rs?.runId ?? null) - }, } let client: VideoGenerationClient @@ -221,6 +200,19 @@ export function injectGenerateVideo( ) } + const applySnapshot = () => { + const next = client.getSnapshot() + result.set(next.result) + isLoading.set(next.isLoading) + error.set(next.error) + status.set(next.status) + runId.set(next.runId) + jobId.set(next.jobId) + videoStatus.set(next.videoStatus) + } + applySnapshot() + const unsubscribeSnapshot = client.subscribe(applySnapshot) + if (bodySource) { effect( () => { @@ -242,6 +234,7 @@ export function injectGenerateVideo( ) destroyRef.onDestroy(() => { disposed = true + unsubscribeSnapshot() client.dispose() }) diff --git a/packages/ai-angular/src/inject-generation.ts b/packages/ai-angular/src/inject-generation.ts index dfd095e079..a103f38744 100644 --- a/packages/ai-angular/src/inject-generation.ts +++ b/packages/ai-angular/src/inject-generation.ts @@ -205,21 +205,6 @@ export function injectGeneration< onChunk: (c: StreamChunk) => { if (!disposed) options.onChunk?.(c) }, - onResultChange: (r: TOutput | null) => { - if (!disposed) result.set(r) - }, - onLoadingChange: (l: boolean) => { - if (!disposed) isLoading.set(l) - }, - onErrorChange: (e: Error | undefined) => { - if (!disposed) error.set(e) - }, - onStatusChange: (s: GenerationClientState) => { - if (!disposed) status.set(s) - }, - onResumeStateChange: (rs) => { - if (!disposed) runId.set(rs?.runId ?? null) - }, } const persistenceProps = @@ -253,6 +238,17 @@ export function injectGeneration< ) } + const applySnapshot = () => { + const next = client.getSnapshot() + result.set(next.result) + isLoading.set(next.isLoading) + error.set(next.error) + status.set(next.status) + runId.set(next.runId) + } + applySnapshot() + const unsubscribeSnapshot = client.subscribe(applySnapshot) + if (bodySource) { effect( () => { @@ -274,6 +270,7 @@ export function injectGeneration< ) destroyRef.onDestroy(() => { disposed = true + unsubscribeSnapshot() client.dispose() }) diff --git a/packages/ai-client/package.json b/packages/ai-client/package.json index 008f648567..aaec33c27d 100644 --- a/packages/ai-client/package.json +++ b/packages/ai-client/package.json @@ -71,7 +71,8 @@ "dependencies": { "@tanstack/ai": "workspace:^", "@tanstack/ai-event-client": "workspace:^", - "@tanstack/ai-utils": "workspace:^" + "@tanstack/ai-utils": "workspace:^", + "@tanstack/store": "^0.11.1" }, "devDependencies": { "@standard-schema/spec": "^1.1.0", diff --git a/packages/ai-client/src/audio-recorder.ts b/packages/ai-client/src/audio-recorder.ts index 0f2b79737f..070ff95f58 100644 --- a/packages/ai-client/src/audio-recorder.ts +++ b/packages/ai-client/src/audio-recorder.ts @@ -1,5 +1,7 @@ import { arrayBufferToBase64 } from '@tanstack/ai-utils' import type { AudioPart } from '@tanstack/ai/client' +import { createAtom } from './snapshot-atom' +import type { Atom } from './snapshot-atom' /** Lifecycle state of an {@link AudioRecorder}. */ export type AudioRecorderState = 'idle' | 'recording' | 'stopping' @@ -62,13 +64,14 @@ export class AudioRecorder { private stream: MediaStream | null = null private chunks: Array = [] private startedAt = 0 - private _state: AudioRecorderState = 'idle' + private readonly snapshotAtom: Atom = + createAtom('idle') + private readonly listeners = new Set<(state: AudioRecorderState) => void>() // True while start() is awaiting getUserMedia (state is still 'idle' then). private starting = false // Set by cancel()/teardown during that window so start() releases the // freshly acquired stream instead of beginning a leaked recording. private pendingCancel = false - private readonly listeners = new Set<(state: AudioRecorderState) => void>() private stopResolve: ((recording: AudioRecording) => void) | null = null private stopReject: ((error: Error) => void) | null = null @@ -87,25 +90,24 @@ export class AudioRecorder { } get state(): AudioRecorderState { - return this._state + return this.snapshotAtom.get() } subscribe(cb: (state: AudioRecorderState) => void): () => void { this.listeners.add(cb) - return () => { - this.listeners.delete(cb) - } + return () => this.listeners.delete(cb) } + /** Current recorder lifecycle state. */ + getSnapshot = (): AudioRecorderState => this.snapshotAtom.get() + private setState(state: AudioRecorderState): void { - this._state = state - for (const cb of this.listeners) { - cb(state) - } + this.snapshotAtom.set(state) + for (const listener of this.listeners) listener(state) } async start(): Promise { - if (this._state !== 'idle' || this.starting) { + if (this.state !== 'idle' || this.starting) { return } this.starting = true @@ -168,7 +170,7 @@ export class AudioRecorder { } stop(): Promise { - if (this._state !== 'recording' || !this.recorder) { + if (this.state !== 'recording' || !this.recorder) { return Promise.reject( new Error('AudioRecorder.stop() called while not recording'), ) @@ -179,7 +181,7 @@ export class AudioRecorder { // Some browsers/codecs never fire onstop; this watchdog unwedges the // recorder instead of leaking this promise forever. const watchdog = setTimeout(() => { - if (this._state !== 'stopping') { + if (this.state !== 'stopping') { return } // Detach handlers so a late onstop/onerror from the stalled recorder @@ -215,7 +217,7 @@ export class AudioRecorder { this.pendingCancel = true return } - if (this._state === 'idle') { + if (this.state === 'idle') { return } const recorder = this.recorder diff --git a/packages/ai-client/src/byok/client.ts b/packages/ai-client/src/byok/client.ts index 5e2fde4522..d1012936e6 100644 --- a/packages/ai-client/src/byok/client.ts +++ b/packages/ai-client/src/byok/client.ts @@ -4,6 +4,8 @@ import { isProviderId, maskKey, } from '@tanstack/ai/byok' +import { createAtom } from '../snapshot-atom' +import type { Atom } from '../snapshot-atom' import { memoryStorage } from './storage' import type { ByokProvider, ProviderId } from '@tanstack/ai/byok' import type { Keyring, KeyringStorage } from './storage' @@ -91,9 +93,9 @@ export class ByokClient { #coverageAll = false #coverage: Record = {} readonly #companions: Record> = {} - readonly #listeners = new Set<() => void>() - #snapshot: ByokSnapshot #storageError: string | null = null + readonly #snapshotAtom: Atom + readonly #listeners = new Set<() => void>() readonly #ready: Promise constructor(options: DefineByokOptions = {}) { @@ -106,7 +108,7 @@ export class ByokClient { } } this.#locked = Boolean(this.storage.unlockable) - this.#snapshot = this.#buildSnapshot() + this.#snapshotAtom = createAtom(this.#buildSnapshot()) this.#ready = this.#hydrate() } @@ -115,20 +117,25 @@ export class ByokClient { subscribe = (listener: () => void): (() => void) => { this.#listeners.add(listener) - return () => { - this.#listeners.delete(listener) - } + return () => this.#listeners.delete(listener) } - getSnapshot = (): ByokSnapshot => this.#snapshot + getSnapshot = (): ByokSnapshot => this.#snapshotAtom.get() #buildSnapshot(): ByokSnapshot { - return { - status: { ...this.#statuses }, + return Object.freeze({ + status: Object.freeze( + Object.fromEntries( + Object.entries(this.#statuses).map(([key, value]) => [ + key, + value ? Object.freeze({ ...value }) : value, + ]), + ), + ), locked: this.#locked, - prompt: this.#prompt, + prompt: this.#prompt ? Object.freeze({ ...this.#prompt }) : null, storageError: this.#storageError, - } + }) } keys(): Keyring { @@ -371,7 +378,7 @@ export class ByokClient { } #emit(): void { - this.#snapshot = this.#buildSnapshot() + this.#snapshotAtom.set(this.#buildSnapshot()) for (const listener of this.#listeners) listener() } } diff --git a/packages/ai-client/src/chat-client.ts b/packages/ai-client/src/chat-client.ts index 5c5f4cc5c8..6139584a17 100644 --- a/packages/ai-client/src/chat-client.ts +++ b/packages/ai-client/src/chat-client.ts @@ -52,9 +52,12 @@ import type { ChatDevtoolsBridge, ChatDevtoolsBridgeOptions, } from './devtools' +import { createAtom, patchAtom, subscribeAtom } from './snapshot-atom' +import type { Atom } from './snapshot-atom' import type { BoundInterrupts, ChatClientOptions, + ChatClientSnapshot, ChatClientState, ChatFetcher, ChatInterruptState, @@ -348,6 +351,24 @@ export class ChatClient< private readonly persistor?: ChatPersistor private readonly clearedStreamTracker = new ClearedStreamTracker() private currentRunId: string | null = null + private readonly snapshotAtom: Atom> = + createAtom>({ + messages: [], + status: 'ready', + isLoading: false, + error: undefined, + isSubscribed: false, + connectionStatus: 'disconnected', + sessionGenerating: false, + queue: [], + runId: null, + interruptState: { + interrupts: Object.freeze([]), + pendingInterrupts: Object.freeze([]), + interruptErrors: Object.freeze([]), + resuming: false, + } as ChatInterruptState, + }) // Interrupt-resume tracking: the run/thread of the most recent interrupted // run, so approvals/client-tool results can be sent back. Cleared when the // run terminates. This is STATE (interrupt) resume, not delivery/cursor. @@ -649,6 +670,11 @@ export class ChatClient< events: { onMessagesChange: (messages: Array) => { this.persistor?.notifyMessagesChanged(messages) + this.patchSnapshot({ + messages: this.freezeSnapshotMessages( + messages as Array>, + ), + }) this.callbacksRef.current.onMessagesChange(messages) }, onStreamStart: () => { @@ -866,6 +892,7 @@ export class ChatClient< }, }) + this.snapshotAtom.set(this.readSnapshot()) this.persistor?.hydrateAsync(persistedState) this.rejoinRunId = rejoinRunId @@ -1289,6 +1316,7 @@ export class ChatClient< private setCurrentRunId(runId: string | null): void { if (this.currentRunId === runId) return this.currentRunId = runId + this.patchSnapshot({ runId }) this.callbacksRef.current.onRunIdChange(runId) } @@ -1480,26 +1508,134 @@ export class ChatClient< return `${prefix}-${Date.now()}-${Math.random().toString(36).substring(7)}` } + private freezeSnapshotPart>( + part: TPart, + ): TPart { + return Object.freeze({ + ...part, + ...('source' in part && + typeof part.source === 'object' && + part.source !== null + ? { source: Object.freeze({ ...part.source }) } + : {}), + ...(part.type === 'tool-call' && 'approval' in part && part.approval + ? { approval: Object.freeze({ ...part.approval }) } + : {}), + ...(part.type === 'tool-result' && Array.isArray(part.content) + ? { + content: Object.freeze( + part.content.map((contentPart) => + this.freezeSnapshotPart(contentPart), + ), + ), + } + : {}), + }) as TPart + } + + private freezeSnapshotMessages( + messages: Array>, + ): ReadonlyArray> { + return Object.freeze( + messages.map((message) => + Object.freeze({ + ...message, + parts: Object.freeze( + message.parts.map((part) => this.freezeSnapshotPart(part)), + ), + }), + ), + ) as ReadonlyArray> + } + + private freezeSnapshotQueue( + queue: Array, + ): ReadonlyArray { + return Object.freeze( + queue.map((item) => + Object.freeze({ + ...item, + ...(typeof item.content === 'object' + ? { + content: Object.freeze({ + ...item.content, + ...(Array.isArray(item.content.content) + ? { + content: Object.freeze( + item.content.content.map((part) => + this.freezeSnapshotPart(part), + ), + ), + } + : {}), + }), + } + : {}), + }), + ), + ) as ReadonlyArray + } + + private readSnapshot(): ChatClientSnapshot { + return Object.freeze({ + messages: this.freezeSnapshotMessages( + this.processor.getMessages() as Array>, + ), + status: this.status, + isLoading: this.isLoading, + error: this.error, + isSubscribed: this.isSubscribed, + connectionStatus: this.connectionStatus, + sessionGenerating: this.sessionGenerating, + queue: this.freezeSnapshotQueue(this.getQueue()), + runId: this.currentRunId, + interruptState: this.interruptManager.getState(), + }) + } + + private patchSnapshot( + patch: Partial>, + ): void { + patchAtom(this.snapshotAtom, patch) + } + + /** + * Subscribe to UI snapshot changes. Does not fire with the current value; + * read {@link getSnapshot} first. + * + * Named separately from {@link subscribe} (the live connection loop). + */ + subscribeSnapshot = (listener: () => void): (() => void) => + subscribeAtom(this.snapshotAtom, listener) + + /** Current UI snapshot. Frozen. Nested messages and queue entries are copies. */ + getSnapshot = (): ChatClientSnapshot => + this.snapshotAtom.get() + private setIsLoading(isLoading: boolean): void { this.isLoading = isLoading + this.patchSnapshot({ isLoading }) this.callbacksRef.current.onLoadingChange(isLoading) this.events.loadingChanged(isLoading) } private setStatus(status: ChatClientState): void { this.status = status + this.patchSnapshot({ status }) this.callbacksRef.current.onStatusChange(status) this.devtoolsBridge.emitSnapshot() } private setIsSubscribed(isSubscribed: boolean): void { this.isSubscribed = isSubscribed + this.patchSnapshot({ isSubscribed }) this.callbacksRef.current.onSubscriptionChange(isSubscribed) this.devtoolsBridge.emitSnapshot() } private setConnectionStatus(status: ConnectionStatus): void { this.connectionStatus = status + this.patchSnapshot({ connectionStatus: status }) this.callbacksRef.current.onConnectionStatusChange(status) this.devtoolsBridge.emitSnapshot() } @@ -1507,6 +1643,7 @@ export class ChatClient< private setSessionGenerating(isGenerating: boolean): void { if (this.sessionGenerating === isGenerating) return this.sessionGenerating = isGenerating + this.patchSnapshot({ sessionGenerating: isGenerating }) this.callbacksRef.current.onSessionGeneratingChange(isGenerating) this.devtoolsBridge.emitSnapshot() } @@ -1516,6 +1653,7 @@ export class ChatClient< // Capture state before invoking callbacks so a synchronous nested change // cannot pair this publication's source with a later manager snapshot. const interruptState = this.interruptManager.getState() + this.patchSnapshot({ interruptState }) // Persist (or clear) the durable resume snapshot so a full page reload can // rehydrate pending interrupts and rejoin the run. Folded into the same // persistence adapter that stores messages (one record per chat). @@ -1558,6 +1696,7 @@ export class ChatClient< private setError(error: Error | undefined): void { this.error = error + this.patchSnapshot({ error }) this.callbacksRef.current.onErrorChange(error) this.events.errorChanged(error?.message || null) } @@ -2973,7 +3112,9 @@ export class ChatClient< } private emitQueueChange(): void { - this.callbacksRef.current.onQueueChange(this.getQueue()) + const queue = this.getQueue() + this.patchSnapshot({ queue: this.freezeSnapshotQueue(queue) }) + this.callbacksRef.current.onQueueChange(queue) this.devtoolsBridge.emitSnapshot() } diff --git a/packages/ai-client/src/generation-client.ts b/packages/ai-client/src/generation-client.ts index b61634bd9b..394852262e 100644 --- a/packages/ai-client/src/generation-client.ts +++ b/packages/ai-client/src/generation-client.ts @@ -29,8 +29,16 @@ import type { GenerationDevtoolsBridge, GenerationDevtoolsBridgeOptions, } from './devtools' +import { + cloneSnapshotValue, + createAtom, + patchAtom, + subscribeAtom, +} from './snapshot-atom' +import type { Atom } from './snapshot-atom' import type { GenerationClientOptions, + GenerationClientSnapshot, GenerationClientState, GenerationFetcher, GenerationPersistenceOptions, @@ -134,6 +142,14 @@ export class GenerationClient< private isLoading = false private error: Error | undefined = undefined private status: GenerationClientState = 'idle' + private readonly snapshotAtom: Atom> = + createAtom>({ + result: null, + isLoading: false, + error: undefined, + status: 'idle', + runId: null, + }) private resumeSnapshot: GenerationResumeSnapshot | undefined private lastEmittedResumeState: string | undefined private abortController: AbortController | null = null @@ -265,14 +281,19 @@ export class GenerationClient< this.input = input this.progress = null - const runId = this.devtoolsBridge.beginRun(input) - this.setIsLoading(true) - this.setStatus('generating') - this.setError(undefined) - const abortController = new AbortController() this.abortController = abortController const { signal } = abortController + const runId = this.devtoolsBridge.beginRun(input) + patchAtom(this.snapshotAtom, { runId }) + if (!this.ownsRun(abortController)) return + + this.setIsLoading(true, abortController) + if (!this.ownsRun(abortController)) return + this.setStatus('generating', abortController) + if (!this.ownsRun(abortController)) return + this.setError(undefined, abortController) + if (!this.ownsRun(abortController)) return try { let headers: Record | undefined @@ -282,6 +303,7 @@ export class GenerationClient< this.body.provider, ) headers = await prepareResolvedByokHeaders(this.byok, provider) + if (!this.ownsRun(abortController)) return } if (this.fetcher) { @@ -290,19 +312,21 @@ export class GenerationClient< input, headers === undefined ? { signal } : { signal, headers }, ) - if (signal.aborted) return + if (!this.ownsRun(abortController)) return if (result instanceof Response) { // Server function returned SSE Response — parse stream await this.processStream( parseSSEResponse(result, signal), runId, - signal, + abortController, ) } else { this.devtoolsBridge.ensureRunStarted(runId) - this.setResult(result) - this.setStatus('success') - this.completePlainFetcherResumeSnapshot(result) + this.setResult(result, abortController) + if (!this.ownsRun(abortController)) return + this.setStatus('success', abortController) + if (!this.ownsRun(abortController)) return + this.completePlainFetcherResumeSnapshot(result, abortController) } } else if (this.connection) { // Streaming adapter path @@ -313,13 +337,13 @@ export class GenerationClient< signal, this.createRunContext(runId, headers), ) - await this.processStream(stream, runId, signal) + await this.processStream(stream, runId, abortController) } else { throw new Error( 'GenerationClient requires either a connection or fetcher option', ) } - if (!signal.aborted && this.status === 'success') { + if (this.ownsRun(abortController) && this.status === 'success') { // Bump progress to 100 on successful completion so devtools // snapshots reflect the final state. The bridge mirrors this in // the run's recorded progress, but the snapshot reads `progress` @@ -332,17 +356,22 @@ export class GenerationClient< ) } } catch (err: unknown) { - if (signal.aborted) return + if (!this.ownsRun(abortController)) return const error = err instanceof Error ? err : new Error(String(err)) if (error instanceof ByokMissingError) { this.byok?.request(error.provider, 'missing') + if (!this.ownsRun(abortController)) return } if (error instanceof ByokBlockedError && error.reason === 'locked') { this.byok?.request(error.provider, 'locked') + if (!this.ownsRun(abortController)) return } - this.setError(error) - this.setStatus('error') - this.recordResumeSnapshotError(error) + this.setError(error, abortController) + if (!this.ownsRun(abortController)) return + this.setStatus('error', abortController) + if (!this.ownsRun(abortController)) return + this.recordResumeSnapshotError(error, abortController) + if (!this.ownsRun(abortController)) return this.devtoolsBridge.finishRun( this.devtoolsBridge.getActiveRunId() ?? runId, 'run:errored', @@ -352,8 +381,11 @@ export class GenerationClient< this.callbacksRef.onError?.(error) } finally { if (this.abortController === abortController) { - this.abortController = null - this.setIsLoading(false) + patchAtom(this.snapshotAtom, { runId: null }) + if (this.ownsRun(abortController)) { + this.setIsLoading(false, abortController) + if (this.ownsRun(abortController)) this.abortController = null + } } } } @@ -373,8 +405,9 @@ export class GenerationClient< private async processStream( source: AsyncIterable, fallbackRunId: string, - signal: AbortSignal, + abortController: AbortController, ): Promise { + const { signal } = abortController let streamRunId: string | undefined let sawTerminalChunk = false @@ -383,7 +416,9 @@ export class GenerationClient< const chunk = restoreInboundChunk(raw) this.callbacksRef.onChunk?.(chunk) - this.observeResumeSnapshot(chunk) + if (!this.ownsRun(abortController)) return + this.observeResumeSnapshot(chunk, abortController) + if (!this.ownsRun(abortController)) return const chunkRunId = 'runId' in chunk && typeof chunk.runId === 'string' ? chunk.runId @@ -393,19 +428,23 @@ export class GenerationClient< switch (chunk.type) { case 'RUN_STARTED': { streamRunId = chunk.runId + patchAtom(this.snapshotAtom, { runId: chunk.runId }) + if (!this.ownsRun(abortController)) return this.devtoolsBridge.ensureRunStarted(chunk.runId) break } case 'CUSTOM': { this.devtoolsBridge.ensureRunStarted(streamRunId ?? fallbackRunId) if (chunk.name === GENERATION_EVENTS.RESULT) { - this.setResult(chunk.value as TResult) + this.setResult(chunk.value as TResult, abortController) + if (!this.ownsRun(abortController)) return } else if (chunk.name === GENERATION_EVENTS.PROGRESS) { const { progress, message } = chunk.value as { progress: number message?: string } - this.setProgress(progress, message) + this.setProgress(progress, message, abortController) + if (!this.ownsRun(abortController)) return } break } @@ -413,7 +452,8 @@ export class GenerationClient< streamRunId = chunk.runId sawTerminalChunk = true this.devtoolsBridge.ensureRunStarted(chunk.runId) - this.setStatus('success') + this.setStatus('success', abortController) + if (!this.ownsRun(abortController)) return break } case 'RUN_ERROR': { @@ -441,17 +481,29 @@ export class GenerationClient< */ stop(): void { const runId = this.devtoolsBridge.getActiveRunId() + const stoppedStatus = this.status if (this.abortController) { this.abortController.abort() this.abortController = null } - this.setIsLoading(false) - if (this.status === 'generating') { - this.setStatus('idle') - if (runId) { + if (runId) { + if (stoppedStatus === 'success') { + this.devtoolsBridge.finishRun(runId, 'run:completed', 'completed') + } else if (stoppedStatus === 'error') { + this.devtoolsBridge.finishRun( + runId, + 'run:errored', + 'errored', + this.error?.message, + ) + } else { this.devtoolsBridge.finishRun(runId, 'run:cancelled', 'cancelled') } } + patchAtom(this.snapshotAtom, { runId: null }) + if (stoppedStatus === 'generating') { + this.setStatus('idle') + } // A stopped run is no longer resumable. Without this the in-memory // snapshot stays `running`, and a remount's `maybeResumeInFlight` would // rejoin a run the user just cancelled. @@ -463,6 +515,7 @@ export class GenerationClient< } this.notifyResumeSnapshotChanged() } + this.setIsLoading(false) } /** @@ -473,6 +526,7 @@ export class GenerationClient< */ reset(): void { this.stop() + if (this.abortController || this.isLoading) return this.setResult(null) this.input = null this.progress = null @@ -537,7 +591,9 @@ export class GenerationClient< this.abortController.abort() this.abortController = null } - this.setIsLoading(false) + if (this.resumeSnapshot?.status !== 'running') { + patchAtom(this.snapshotAtom, { runId: null }) + } this.devtoolsBridge.dispose() this.devtoolsMounted = false // Re-arm mount hydration + rejoin so a remount resumes from the (preserved) @@ -545,12 +601,23 @@ export class GenerationClient< // `maybeResumeInFlight`, both individually guarded. this.serverHydrationStarted = false this.rejoinedRunId = undefined + this.setIsLoading(false) } // =========================== // Getters // =========================== + /** + * Subscribe to UI snapshot changes. Does not fire with the current value; + * read {@link getSnapshot} first. + */ + subscribe = (listener: () => void): (() => void) => + subscribeAtom(this.snapshotAtom, listener) + + /** Current UI snapshot. Frozen envelope. `result` is a shallow copy. */ + getSnapshot = (): GenerationClientSnapshot => this.snapshotAtom.get() + getResult(): TOutput | null { return this.result } @@ -598,16 +665,23 @@ export class GenerationClient< // Private state setters // =========================== - private setResult(rawResult: TResult | null): void { + private setResult( + rawResult: TResult | null, + controller?: AbortController, + ): void { if (rawResult === null) { this.result = null + patchAtom(this.snapshotAtom, { result: null }) + if (controller && !this.ownsRun(controller)) return this.callbacksRef.onResultChange?.(null) + if (controller && !this.ownsRun(controller)) return this.devtoolsBridge.recordResultChange() return } if (this.callbacksRef.onResult) { const transformed = this.callbacksRef.onResult(rawResult) + if (controller && !this.ownsRun(controller)) return if (transformed === null) { // null return → keep previous result unchanged, just re-emit this.devtoolsBridge.emitState() @@ -616,7 +690,12 @@ export class GenerationClient< if (transformed !== undefined) { // Non-null, non-undefined → use transformed value this.result = transformed + patchAtom(this.snapshotAtom, { + result: cloneSnapshotValue(this.result), + }) + if (controller && !this.ownsRun(controller)) return this.callbacksRef.onResultChange?.(this.result) + if (controller && !this.ownsRun(controller)) return this.devtoolsBridge.recordResultChange() return } @@ -627,29 +706,51 @@ export class GenerationClient< // `TOutput` defaults to `TResult`, so the runtime cast is sound. // oxlint-disable-next-line eslint-js/no-restricted-syntax -- TOutput defaults to TResult when no onResult transform is supplied this.result = rawResult as unknown as TOutput + patchAtom(this.snapshotAtom, { result: cloneSnapshotValue(this.result) }) + if (controller && !this.ownsRun(controller)) return this.callbacksRef.onResultChange?.(this.result) + if (controller && !this.ownsRun(controller)) return this.devtoolsBridge.recordResultChange() } - private setIsLoading(isLoading: boolean): void { + private setIsLoading(isLoading: boolean, controller?: AbortController): void { this.isLoading = isLoading + patchAtom(this.snapshotAtom, { isLoading }) + if (controller && !this.ownsRun(controller)) return this.callbacksRef.onLoadingChange?.(isLoading) + if (controller && !this.ownsRun(controller)) return this.devtoolsBridge.recordLoadingChange() } - private setError(error: Error | undefined): void { + private setError( + error: Error | undefined, + controller?: AbortController, + ): void { this.error = error + patchAtom(this.snapshotAtom, { error }) + if (controller && !this.ownsRun(controller)) return this.callbacksRef.onErrorChange?.(error) + if (controller && !this.ownsRun(controller)) return this.devtoolsBridge.recordErrorChange(error) } - private setStatus(status: GenerationClientState): void { + private setStatus( + status: GenerationClientState, + controller?: AbortController, + ): void { this.status = status + patchAtom(this.snapshotAtom, { status }) + if (controller && !this.ownsRun(controller)) return this.callbacksRef.onStatusChange?.(status) + if (controller && !this.ownsRun(controller)) return this.devtoolsBridge.recordStatusChange(status) } - private setProgress(value: number, message?: string): void { + private setProgress( + value: number, + message?: string, + controller?: AbortController, + ): void { this.progress = { value, ...(message ? { message } : {}), @@ -659,6 +760,7 @@ export class GenerationClient< } else { this.callbacksRef.onProgress?.(value, message) } + if (controller && !this.ownsRun(controller)) return this.devtoolsBridge.recordProgressChange() } @@ -685,6 +787,10 @@ export class GenerationClient< return `${prefix}-${Date.now()}-${Math.random().toString(36).substring(7)}` } + private ownsRun(controller: AbortController): boolean { + return !controller.signal.aborted && this.abortController === controller + } + private createRunContext( runId: string, headers?: Record, @@ -696,12 +802,15 @@ export class GenerationClient< } } - private observeResumeSnapshot(chunk: StreamChunk): void { + private observeResumeSnapshot( + chunk: StreamChunk, + controller: AbortController, + ): void { this.resumeSnapshot = updateGenerationResumeSnapshot( this.resumeSnapshot, chunk, ) - this.notifyResumeSnapshotChanged() + this.notifyResumeSnapshotChanged(controller) } /** @@ -709,9 +818,10 @@ export class GenerationClient< * The snapshot stays internal (persistence + devtools); the hook consumes * `resumeState`, mirroring the chat client. */ - private notifyResumeSnapshotChanged(): void { + private notifyResumeSnapshotChanged(controller?: AbortController): void { this.callbacksRef.onResumeSnapshotChange?.(this.resumeSnapshot) - this.emitResumeState() + if (controller && !this.ownsRun(controller)) return + this.emitResumeState(controller) } /** @@ -725,7 +835,7 @@ export class GenerationClient< * boundaries and when artifacts land, so skip the notification unless it * materially changed — same gate the persistence writes use. */ - private emitResumeState(): void { + private emitResumeState(controller?: AbortController): void { const snapshot = this.resumeSnapshot const state = snapshot?.resumeState const resumeState: GenerationResumeState | null = state @@ -741,6 +851,12 @@ export class GenerationClient< return } this.lastEmittedResumeState = signature + if (resumeState?.runId) { + patchAtom(this.snapshotAtom, { runId: resumeState.runId }) + } else if (!this.isLoading) { + patchAtom(this.snapshotAtom, { runId: null }) + } + if (controller && !this.ownsRun(controller)) return this.callbacksRef.onResumeStateChange?.(resumeState) } @@ -869,7 +985,10 @@ export class GenerationClient< * stale `error` from a previous run is intentionally dropped — this run * succeeded. */ - private completePlainFetcherResumeSnapshot(rawResult: unknown): void { + private completePlainFetcherResumeSnapshot( + rawResult: unknown, + controller: AbortController, + ): void { const previous = this.resumeSnapshot const result = createGenerationResultSnapshot(rawResult) this.resumeSnapshot = { @@ -886,7 +1005,7 @@ export class GenerationClient< ? { result: { ...previous.result } } : {}), } - this.notifyResumeSnapshotChanged() + this.notifyResumeSnapshotChanged(controller) } /** @@ -895,15 +1014,20 @@ export class GenerationClient< * mark the snapshot `error`, leaving a persisted record that claims the * run is still in flight. */ - private recordResumeSnapshotError(error: Error): void { + private recordResumeSnapshotError( + error: Error, + controller?: AbortController, + ): void { // Surface the failure on the OBSERVABLE fields FIRST: a rejoin (or live // stream) that emits RUN_ERROR has already flipped the snapshot to `error` // via `observeResumeSnapshot`, so the early-return below would otherwise // skip this and leave `status` stuck on `generating` — the run would look // like it is still going forever. The guard avoids a duplicate `error` // emission on the live `generate()` path, which sets the status itself. - if (this.status !== 'error') this.setStatus('error') - this.setError(error) + if (this.status !== 'error') this.setStatus('error', controller) + if (controller && !this.ownsRun(controller)) return + this.setError(error, controller) + if (controller && !this.ownsRun(controller)) return if (this.resumeSnapshot?.status === 'error') return if (!this.resumeSnapshot && !this.serverDriven) return const previous = this.resumeSnapshot @@ -918,7 +1042,7 @@ export class GenerationClient< ...(previous?.result ? { result: { ...previous.result } } : {}), error: { message: error.message }, } - this.notifyResumeSnapshotChanged() + this.notifyResumeSnapshotChanged(controller) } /** @@ -1042,23 +1166,26 @@ export class GenerationClient< this.rejoinedRunId = runId const controller = new AbortController() this.abortController = controller - this.setIsLoading(true) - this.setStatus('generating') + this.setIsLoading(true, controller) + if (!this.ownsRun(controller)) return + this.setStatus('generating', controller) + if (!this.ownsRun(controller)) return void (async () => { try { await this.processStream( joinRun(runId, controller.signal), runId, - controller.signal, + controller, ) } catch (error) { - if (!controller.signal.aborted) { + if (this.ownsRun(controller)) { const failure = error instanceof Error ? error : new Error(String(error)) // Settles `status`/`error` AND rewrites the snapshot to a terminal // `error` with a null `resumeState`, so the next mount does not // rejoin this run again. - this.recordResumeSnapshotError(failure) + this.recordResumeSnapshotError(failure, controller) + if (!this.ownsRun(controller)) return this.callbacksRef.onError?.(failure) } } finally { @@ -1066,8 +1193,8 @@ export class GenerationClient< // fresh `generate()` may have replaced the controller while the tail // was settling, and that live run owns `isLoading` now. if (this.abortController === controller) { - this.abortController = null - this.setIsLoading(false) + this.setIsLoading(false, controller) + if (this.ownsRun(controller)) this.abortController = null } } })() diff --git a/packages/ai-client/src/generation-types.ts b/packages/ai-client/src/generation-types.ts index bee7670bb0..32036e5269 100644 --- a/packages/ai-client/src/generation-types.ts +++ b/packages/ai-client/src/generation-types.ts @@ -64,6 +64,29 @@ export type InferGenerationOutput = TFn extends ( */ export type GenerationClientState = 'idle' | 'generating' | 'success' | 'error' +/** + * UI snapshot published by {@link GenerationClient.subscribe} / + * {@link GenerationClient.getSnapshot}. + */ +export interface GenerationClientSnapshot { + readonly result: TOutput | null + readonly isLoading: boolean + readonly error: Error | undefined + readonly status: GenerationClientState + readonly runId: string | null +} + +/** + * UI snapshot published by {@link VideoGenerationClient.subscribe} / + * {@link VideoGenerationClient.getSnapshot}. + */ +export interface VideoGenerationClientSnapshot< + TOutput = unknown, +> extends GenerationClientSnapshot { + readonly jobId: string | null + readonly videoStatus: VideoStatusInfo | null +} + /** * Status of a persisted/restored generation run. * diff --git a/packages/ai-client/src/index.ts b/packages/ai-client/src/index.ts index a53e8bc742..b9e60ba966 100644 --- a/packages/ai-client/src/index.ts +++ b/packages/ai-client/src/index.ts @@ -57,6 +57,7 @@ export type { ChatRequestBody, InferChatMessages, InferredClientContext, + ChatClientSnapshot, ChatClientState, ConnectionStatus, ChatFetcher, @@ -77,7 +78,9 @@ export type { export type { InferGenerationOutput, InferGenerationOutputFromReturn, + GenerationClientSnapshot, GenerationClientState, + VideoGenerationClientSnapshot, GenerationPersistenceOptions, GenerationClientOptions, GenerationFetcher, diff --git a/packages/ai-client/src/realtime-client.ts b/packages/ai-client/src/realtime-client.ts index 7808818745..a97f420d99 100644 --- a/packages/ai-client/src/realtime-client.ts +++ b/packages/ai-client/src/realtime-client.ts @@ -1,4 +1,6 @@ import { convertSchemaToJsonSchema } from '@tanstack/ai/client' +import { createAtom, subscribeAtom } from './snapshot-atom' +import type { Atom } from './snapshot-atom' import type { AnyClientTool, AudioVisualization, @@ -57,6 +59,9 @@ export class RealtimeClient { pendingAssistantTranscript: null, error: null, } + private readonly snapshotAtom: Atom = createAtom( + this.buildSnapshot(), + ) constructor(options: RealtimeClientOptions) { this.options = { @@ -88,7 +93,13 @@ export class RealtimeClient { return } - this.updateState({ status: 'connecting', error: null }) + this.updateState({ + status: 'connecting', + error: null, + messages: [], + pendingUserTranscript: null, + pendingAssistantTranscript: null, + }) try { // Fetch token from server @@ -195,6 +206,7 @@ export class RealtimeClient { return } this.connection.interrupt() + this.updateState({ pendingAssistantTranscript: null }) } // ============================================================================ @@ -331,6 +343,16 @@ export class RealtimeClient { } } + /** + * Subscribe to UI snapshot changes. Does not fire with the current value; + * read {@link getSnapshot} first. + */ + subscribe = (listener: () => void): (() => void) => + subscribeAtom(this.snapshotAtom, listener) + + /** Current UI snapshot. */ + getSnapshot = (): RealtimeClientState => this.snapshotAtom.get() + // ============================================================================ // Cleanup // ============================================================================ @@ -349,11 +371,18 @@ export class RealtimeClient { // ============================================================================ private updateState(updates: Partial): void { + const changed = Object.keys(updates).some((key) => { + const stateKey = key as keyof RealtimeClientState + return !Object.is(this.state[stateKey], updates[stateKey]) + }) + if (!changed) return this.state = { ...this.state, ...updates } + this.snapshotAtom.set(this.buildSnapshot()) + const snapshot = this.snapshotAtom.get() // Notify callbacks for (const callback of this.stateChangeCallbacks) { - callback(this.state) + callback(snapshot) } // Notify specific callbacks @@ -365,6 +394,31 @@ export class RealtimeClient { } } + private buildSnapshot(): RealtimeClientState { + return { + ...this.state, + messages: Object.freeze( + this.state.messages.map((message) => + Object.freeze({ + ...message, + parts: Object.freeze( + message.parts.map((part) => + Object.freeze({ + ...part, + ...('source' in part && + typeof part.source === 'object' && + part.source !== null + ? { source: Object.freeze({ ...part.source }) } + : {}), + }), + ), + ), + }), + ), + ) as Array, + } + } + private addMessage(message: RealtimeMessage): void { this.updateState({ messages: [...this.state.messages, message], diff --git a/packages/ai-client/src/realtime-types.ts b/packages/ai-client/src/realtime-types.ts index c6f2470a97..1b4c205c88 100644 --- a/packages/ai-client/src/realtime-types.ts +++ b/packages/ai-client/src/realtime-types.ts @@ -108,12 +108,12 @@ export interface RealtimeClientOptions { * Internal state of the RealtimeClient */ export interface RealtimeClientState { - status: RealtimeStatus - mode: RealtimeMode - messages: Array - pendingUserTranscript: string | null - pendingAssistantTranscript: string | null - error: Error | null + readonly status: RealtimeStatus + readonly mode: RealtimeMode + readonly messages: Array + readonly pendingUserTranscript: string | null + readonly pendingAssistantTranscript: string | null + readonly error: Error | null } /** diff --git a/packages/ai-client/src/snapshot-atom.ts b/packages/ai-client/src/snapshot-atom.ts new file mode 100644 index 0000000000..ad409f79e7 --- /dev/null +++ b/packages/ai-client/src/snapshot-atom.ts @@ -0,0 +1,66 @@ +import { createAtom as createStoreAtom } from '@tanstack/store' +import type { Atom } from '@tanstack/store' + +export type { Atom } + +function freezeSnapshot(value: T): T { + return typeof value === 'object' && value !== null + ? Object.freeze(value) + : value +} + +/** Shallow copy, then freeze. Use for published snapshot fields that must not alias client internals. */ +export function cloneSnapshotValue(value: T): T { + if (value === null || typeof value !== 'object') return value + return Object.freeze(Array.isArray(value) ? [...value] : { ...value }) as T +} + +export function createAtom(initialValue: T): Atom { + const atom = createStoreAtom(freezeSnapshot(initialValue)) + const set = atom.set.bind(atom) + atom.set = (updater) => + set((previous) => + freezeSnapshot( + typeof updater === 'function' + ? (updater as (value: T) => T)(previous) + : updater, + ), + ) + return atom +} + +/** + * Merge a partial update into an object atom. Skips notify when every + * provided field is `Object.is`-equal to the current value. + */ +export function patchAtom( + atom: Atom, + patch: Partial, +): void { + atom.set((prev) => { + let changed = false + const next = { ...prev } + for (const key of Object.keys(patch) as Array) { + if (Object.is(prev[key], patch[key])) continue + next[key] = patch[key] as T[keyof T] + changed = true + } + return changed ? next : prev + }) +} + +/** + * Adapt a Store atom's `{ unsubscribe }` subscription to the + * `() => void` unsubscribe that `useSyncExternalStore` expects. + */ +export function subscribeAtom( + atom: { + subscribe: (listener: (value: T) => void) => { unsubscribe: () => void } + }, + listener: () => void, +): () => void { + const { unsubscribe } = atom.subscribe(() => { + listener() + }) + return unsubscribe +} diff --git a/packages/ai-client/src/types.ts b/packages/ai-client/src/types.ts index 01373cef02..1631e03df2 100644 --- a/packages/ai-client/src/types.ts +++ b/packages/ai-client/src/types.ts @@ -364,6 +364,27 @@ export type ToolResultState = */ export type ChatClientState = 'ready' | 'submitted' | 'streaming' | 'error' +/** + * UI snapshot published by {@link ChatClient.subscribeSnapshot} / + * {@link ChatClient.getSnapshot}. + */ +export interface ChatClientSnapshot< + TTools extends ReadonlyArray = any, + TInterrupts extends ReadonlyArray> = + readonly [], +> { + readonly messages: ReadonlyArray> + readonly status: ChatClientState + readonly isLoading: boolean + readonly error: Error | undefined + readonly isSubscribed: boolean + readonly connectionStatus: ConnectionStatus + readonly sessionGenerating: boolean + readonly queue: ReadonlyArray + readonly runId: string | null + readonly interruptState: ChatInterruptState +} + /** * Connection lifecycle state for the subscription loop. */ diff --git a/packages/ai-client/src/video-generation-client.ts b/packages/ai-client/src/video-generation-client.ts index 7ae11c2fc1..cf4675bc49 100644 --- a/packages/ai-client/src/video-generation-client.ts +++ b/packages/ai-client/src/video-generation-client.ts @@ -29,6 +29,13 @@ import type { VideoDevtoolsBridge, VideoDevtoolsBridgeOptions, } from './devtools' +import { + cloneSnapshotValue, + createAtom, + patchAtom, + subscribeAtom, +} from './snapshot-atom' +import type { Atom } from './snapshot-atom' import type { GenerationClientState, GenerationFetcher, @@ -39,6 +46,7 @@ import type { VideoGenerateInput, VideoGenerateResult, VideoGenerationClientOptions, + VideoGenerationClientSnapshot, VideoStatusInfo, } from './generation-types' @@ -139,6 +147,16 @@ export class VideoGenerationClient { private isLoading = false private error: Error | undefined = undefined private status: GenerationClientState = 'idle' + private readonly snapshotAtom: Atom> = + createAtom>({ + result: null, + isLoading: false, + error: undefined, + status: 'idle', + runId: null, + jobId: null, + videoStatus: null, + }) private resumeSnapshot: GenerationResumeSnapshot | undefined private abortController: AbortController | null = null private rejoinedRunId: string | undefined @@ -256,16 +274,23 @@ export class VideoGenerationClient { this.input = input this.progress = null - const runId = this.devtoolsBridge.beginRun(input) - this.setIsLoading(true) - this.setStatus('generating') - this.setError(undefined) - this.setJobId(null) - this.setVideoStatus(null) - const abortController = new AbortController() this.abortController = abortController const { signal } = abortController + const runId = this.devtoolsBridge.beginRun(input) + patchAtom(this.snapshotAtom, { runId }) + if (!this.ownsRun(abortController)) return + + this.setIsLoading(true, abortController) + if (!this.ownsRun(abortController)) return + this.setStatus('generating', abortController) + if (!this.ownsRun(abortController)) return + this.setError(undefined, abortController) + if (!this.ownsRun(abortController)) return + this.setJobId(null, abortController) + if (!this.ownsRun(abortController)) return + this.setVideoStatus(null, abortController) + if (!this.ownsRun(abortController)) return try { let headers: Record | undefined @@ -275,10 +300,11 @@ export class VideoGenerationClient { this.body.provider, ) headers = await prepareResolvedByokHeaders(this.byok, provider) + if (!this.ownsRun(abortController)) return } if (this.fetcher) { - await this.generateWithFetcher(input, signal, runId, headers) + await this.generateWithFetcher(input, abortController, runId, headers) } else if (this.connection) { const mergedData = { ...this.body, ...input } const stream = this.connection.connect( @@ -287,13 +313,13 @@ export class VideoGenerationClient { signal, this.createRunContext(runId, headers), ) - await this.processStream(stream, runId, signal) + await this.processStream(stream, runId, abortController) } else { throw new Error( 'VideoGenerationClient requires either a connection or fetcher option', ) } - if (!signal.aborted && this.status === 'success') { + if (this.ownsRun(abortController) && this.status === 'success') { this.devtoolsBridge.finishRun( this.devtoolsBridge.getActiveRunId() ?? runId, 'run:completed', @@ -301,17 +327,22 @@ export class VideoGenerationClient { ) } } catch (err: unknown) { - if (signal.aborted) return + if (!this.ownsRun(abortController)) return const error = err instanceof Error ? err : new Error(String(err)) if (error instanceof ByokMissingError) { this.byok?.request(error.provider, 'missing') + if (!this.ownsRun(abortController)) return } if (error instanceof ByokBlockedError && error.reason === 'locked') { this.byok?.request(error.provider, 'locked') + if (!this.ownsRun(abortController)) return } - this.setError(error) - this.setStatus('error') - this.recordResumeSnapshotError(error) + this.setError(error, abortController) + if (!this.ownsRun(abortController)) return + this.setStatus('error', abortController) + if (!this.ownsRun(abortController)) return + this.recordResumeSnapshotError(error, abortController) + if (!this.ownsRun(abortController)) return this.devtoolsBridge.finishRun( this.devtoolsBridge.getActiveRunId() ?? runId, 'run:errored', @@ -321,8 +352,11 @@ export class VideoGenerationClient { this.callbacksRef.onError?.(error) } finally { if (this.abortController === abortController) { - this.abortController = null - this.setIsLoading(false) + patchAtom(this.snapshotAtom, { runId: null }) + if (this.ownsRun(abortController)) { + this.setIsLoading(false, abortController) + if (this.ownsRun(abortController)) this.abortController = null + } } } } @@ -332,27 +366,34 @@ export class VideoGenerationClient { */ private async generateWithFetcher( input: VideoGenerateInput, - signal: AbortSignal, + abortController: AbortController, runId: string, headers?: Record, ): Promise { if (!this.fetcher) return + const { signal } = abortController // Fetcher returns a completed result directly, or a Response with SSE body const result = await this.fetcher( input, headers === undefined ? { signal } : { signal, headers }, ) - if (signal.aborted) return + if (!this.ownsRun(abortController)) return if (result instanceof Response) { // Server function returned SSE Response — parse stream - await this.processStream(parseSSEResponse(result, signal), runId, signal) + await this.processStream( + parseSSEResponse(result, signal), + runId, + abortController, + ) } else { this.devtoolsBridge.ensureRunStarted(runId) - this.setResult(result) - this.setStatus('success') - this.completePlainFetcherResumeSnapshot(result) + this.setResult(result, abortController) + if (!this.ownsRun(abortController)) return + this.setStatus('success', abortController) + if (!this.ownsRun(abortController)) return + this.completePlainFetcherResumeSnapshot(result, abortController) } } @@ -368,8 +409,9 @@ export class VideoGenerationClient { private async processStream( source: AsyncIterable, fallbackRunId: string, - signal: AbortSignal, + abortController: AbortController, ): Promise { + const { signal } = abortController let streamRunId: string | undefined let sawTerminalChunk = false @@ -378,7 +420,9 @@ export class VideoGenerationClient { const chunk = restoreInboundChunk(raw) this.callbacksRef.onChunk?.(chunk) - this.observeResumeSnapshot(chunk) + if (!this.ownsRun(abortController)) return + this.observeResumeSnapshot(chunk, abortController) + if (!this.ownsRun(abortController)) return const chunkRunId = 'runId' in chunk && typeof chunk.runId === 'string' ? chunk.runId @@ -388,6 +432,8 @@ export class VideoGenerationClient { switch (chunk.type) { case 'RUN_STARTED': { streamRunId = chunk.runId + patchAtom(this.snapshotAtom, { runId: chunk.runId }) + if (!this.ownsRun(abortController)) return this.devtoolsBridge.ensureRunStarted(chunk.runId) break } @@ -395,23 +441,30 @@ export class VideoGenerationClient { this.devtoolsBridge.ensureRunStarted(streamRunId ?? fallbackRunId) if (chunk.name === GENERATION_EVENTS.VIDEO_JOB_CREATED) { const { jobId } = chunk.value as { jobId: string } - this.setJobId(jobId) + this.setJobId(jobId, abortController) + if (!this.ownsRun(abortController)) return this.callbacksRef.onJobCreated?.(jobId) + if (!this.ownsRun(abortController)) return } else if (chunk.name === GENERATION_EVENTS.VIDEO_STATUS) { const statusInfo = chunk.value as VideoStatusInfo - this.setVideoStatus(statusInfo) + this.setVideoStatus(statusInfo, abortController) + if (!this.ownsRun(abortController)) return this.callbacksRef.onStatusUpdate?.(statusInfo) + if (!this.ownsRun(abortController)) return if (statusInfo.progress !== undefined) { - this.setProgress(statusInfo.progress) + this.setProgress(statusInfo.progress, undefined, abortController) + if (!this.ownsRun(abortController)) return } } else if (chunk.name === GENERATION_EVENTS.RESULT) { - this.setResult(chunk.value as VideoGenerateResult) + this.setResult(chunk.value as VideoGenerateResult, abortController) + if (!this.ownsRun(abortController)) return } else if (chunk.name === GENERATION_EVENTS.PROGRESS) { const { progress, message } = chunk.value as { progress: number message?: string } - this.setProgress(progress, message) + this.setProgress(progress, message, abortController) + if (!this.ownsRun(abortController)) return } break } @@ -419,7 +472,8 @@ export class VideoGenerationClient { streamRunId = chunk.runId sawTerminalChunk = true this.devtoolsBridge.ensureRunStarted(chunk.runId) - this.setStatus('success') + this.setStatus('success', abortController) + if (!this.ownsRun(abortController)) return break } case 'RUN_ERROR': { @@ -447,17 +501,29 @@ export class VideoGenerationClient { */ stop(): void { const runId = this.devtoolsBridge.getActiveRunId() + const stoppedStatus = this.status if (this.abortController) { this.abortController.abort() this.abortController = null } - this.setIsLoading(false) - if (this.status === 'generating') { - this.setStatus('idle') - if (runId) { + if (runId) { + if (stoppedStatus === 'success') { + this.devtoolsBridge.finishRun(runId, 'run:completed', 'completed') + } else if (stoppedStatus === 'error') { + this.devtoolsBridge.finishRun( + runId, + 'run:errored', + 'errored', + this.error?.message, + ) + } else { this.devtoolsBridge.finishRun(runId, 'run:cancelled', 'cancelled') } } + patchAtom(this.snapshotAtom, { runId: null }) + if (stoppedStatus === 'generating') { + this.setStatus('idle') + } // A stopped run is no longer resumable. Without this the in-memory // snapshot stays `running`, and a remount's `maybeResumeInFlight` would // rejoin a run the user just cancelled. @@ -469,6 +535,7 @@ export class VideoGenerationClient { } this.notifyResumeSnapshotChanged() } + this.setIsLoading(false) } /** @@ -479,6 +546,7 @@ export class VideoGenerationClient { */ reset(): void { this.stop() + if (this.abortController || this.isLoading) return this.setResult(null) this.input = null this.progress = null @@ -547,17 +615,31 @@ export class VideoGenerationClient { this.abortController.abort() this.abortController = null } - this.setIsLoading(false) + if (this.resumeSnapshot?.status !== 'running') { + patchAtom(this.snapshotAtom, { runId: null }) + } this.devtoolsBridge.dispose() this.devtoolsMounted = false this.serverHydrationStarted = false this.rejoinedRunId = undefined + this.setIsLoading(false) } // =========================== // Getters // =========================== + /** + * Subscribe to UI snapshot changes. Does not fire with the current value; + * read {@link getSnapshot} first. + */ + subscribe = (listener: () => void): (() => void) => + subscribeAtom(this.snapshotAtom, listener) + + /** Current UI snapshot. Frozen envelope. `result` is a shallow copy. */ + getSnapshot = (): VideoGenerationClientSnapshot => + this.snapshotAtom.get() + getResult(): TOutput | null { return this.result } @@ -613,23 +695,33 @@ export class VideoGenerationClient { // Private state setters // =========================== - private setResult(rawResult: VideoGenerateResult | null): void { + private setResult( + rawResult: VideoGenerateResult | null, + controller?: AbortController, + ): void { if (rawResult === null) { this.result = null + patchAtom(this.snapshotAtom, { result: null }) + if (controller && !this.ownsRun(controller)) return this.callbacksRef.onResultChange?.(null) + if (controller && !this.ownsRun(controller)) return this.devtoolsBridge.recordResultChange() return } const completedStatus = this.createCompletedVideoStatus(rawResult) if (this.progress?.value !== 100) { - this.setProgress(100, this.progress?.message) + this.setProgress(100, this.progress?.message, controller) + if (controller && !this.ownsRun(controller)) return } - this.setJobId(rawResult.jobId) - this.setVideoStatus(completedStatus) + this.setJobId(rawResult.jobId, controller) + if (controller && !this.ownsRun(controller)) return + this.setVideoStatus(completedStatus, controller) + if (controller && !this.ownsRun(controller)) return if (this.callbacksRef.onResult) { const transformed = this.callbacksRef.onResult(rawResult) + if (controller && !this.ownsRun(controller)) return if (transformed === null) { // null return → keep previous result unchanged, just re-emit this.devtoolsBridge.emitState() @@ -638,7 +730,12 @@ export class VideoGenerationClient { if (transformed !== undefined) { // Non-null, non-undefined → use transformed value this.result = transformed + patchAtom(this.snapshotAtom, { + result: cloneSnapshotValue(this.result), + }) + if (controller && !this.ownsRun(controller)) return this.callbacksRef.onResultChange?.(this.result) + if (controller && !this.ownsRun(controller)) return this.devtoolsBridge.recordResultChange() return } @@ -650,41 +747,76 @@ export class VideoGenerationClient { // sound. // oxlint-disable-next-line eslint-js/no-restricted-syntax -- TOutput defaults to VideoGenerateResult when no onResult transform is supplied this.result = rawResult as unknown as TOutput + patchAtom(this.snapshotAtom, { result: cloneSnapshotValue(this.result) }) + if (controller && !this.ownsRun(controller)) return this.callbacksRef.onResultChange?.(this.result) + if (controller && !this.ownsRun(controller)) return this.devtoolsBridge.recordResultChange() } - private setJobId(jobId: string | null): void { + private setJobId(jobId: string | null, controller?: AbortController): void { this.jobId = jobId + patchAtom(this.snapshotAtom, { jobId }) + if (controller && !this.ownsRun(controller)) return this.callbacksRef.onJobIdChange?.(jobId) + if (controller && !this.ownsRun(controller)) return this.devtoolsBridge.recordJobIdChange() } - private setVideoStatus(status: VideoStatusInfo | null): void { - this.videoStatus = status - this.callbacksRef.onVideoStatusChange?.(status) + private setVideoStatus( + status: VideoStatusInfo | null, + controller?: AbortController, + ): void { + this.videoStatus = status ? { ...status } : null + patchAtom(this.snapshotAtom, { + videoStatus: this.videoStatus + ? Object.freeze({ ...this.videoStatus }) + : null, + }) + if (controller && !this.ownsRun(controller)) return + this.callbacksRef.onVideoStatusChange?.(this.videoStatus) + if (controller && !this.ownsRun(controller)) return this.devtoolsBridge.recordVideoStatusChange() } - private setIsLoading(isLoading: boolean): void { + private setIsLoading(isLoading: boolean, controller?: AbortController): void { this.isLoading = isLoading + patchAtom(this.snapshotAtom, { isLoading }) + if (controller && !this.ownsRun(controller)) return this.callbacksRef.onLoadingChange?.(isLoading) + if (controller && !this.ownsRun(controller)) return this.devtoolsBridge.recordLoadingChange() } - private setError(error: Error | undefined): void { + private setError( + error: Error | undefined, + controller?: AbortController, + ): void { this.error = error + patchAtom(this.snapshotAtom, { error }) + if (controller && !this.ownsRun(controller)) return this.callbacksRef.onErrorChange?.(error) + if (controller && !this.ownsRun(controller)) return this.devtoolsBridge.recordErrorChange(error) } - private setStatus(status: GenerationClientState): void { + private setStatus( + status: GenerationClientState, + controller?: AbortController, + ): void { this.status = status + patchAtom(this.snapshotAtom, { status }) + if (controller && !this.ownsRun(controller)) return this.callbacksRef.onStatusChange?.(status) + if (controller && !this.ownsRun(controller)) return this.devtoolsBridge.recordStatusChange(status) } - private setProgress(value: number, message?: string): void { + private setProgress( + value: number, + message?: string, + controller?: AbortController, + ): void { this.progress = { value, ...(message ? { message } : {}), @@ -694,6 +826,7 @@ export class VideoGenerationClient { } else { this.callbacksRef.onProgress?.(value, message) } + if (controller && !this.ownsRun(controller)) return this.devtoolsBridge.recordProgressChange() } @@ -731,6 +864,10 @@ export class VideoGenerationClient { return `${prefix}-${Date.now()}-${Math.random().toString(36).substring(7)}` } + private ownsRun(controller: AbortController): boolean { + return !controller.signal.aborted && this.abortController === controller + } + private createRunContext( runId: string, headers?: Record, @@ -742,22 +879,26 @@ export class VideoGenerationClient { } } - private observeResumeSnapshot(chunk: StreamChunk): void { + private observeResumeSnapshot( + chunk: StreamChunk, + controller: AbortController, + ): void { this.resumeSnapshot = updateGenerationResumeSnapshot( this.resumeSnapshot, chunk, ) - this.notifyResumeSnapshotChanged() + this.notifyResumeSnapshotChanged(controller) } /** Notify the internal snapshot listener AND emit the public resume state. */ - private notifyResumeSnapshotChanged(): void { + private notifyResumeSnapshotChanged(controller?: AbortController): void { this.callbacksRef.onResumeSnapshotChange?.(this.resumeSnapshot) - this.emitResumeState() + if (controller && !this.ownsRun(controller)) return + this.emitResumeState(controller) } /** Derive the public `resumeState` from the internal snapshot. */ - private emitResumeState(): void { + private emitResumeState(controller?: AbortController): void { const snapshot = this.resumeSnapshot const state = snapshot?.resumeState const resumeState: GenerationResumeState | null = state @@ -768,6 +909,12 @@ export class VideoGenerationClient { : {}), } : null + if (resumeState?.runId) { + patchAtom(this.snapshotAtom, { runId: resumeState.runId }) + } else if (!this.isLoading) { + patchAtom(this.snapshotAtom, { runId: null }) + } + if (controller && !this.ownsRun(controller)) return this.callbacksRef.onResumeStateChange?.(resumeState) } @@ -881,7 +1028,10 @@ export class VideoGenerationClient { * stale `error` from a previous run is intentionally dropped — this run * succeeded. */ - private completePlainFetcherResumeSnapshot(rawResult: unknown): void { + private completePlainFetcherResumeSnapshot( + rawResult: unknown, + controller: AbortController, + ): void { const previous = this.resumeSnapshot const result = createGenerationResultSnapshot(rawResult) this.resumeSnapshot = { @@ -898,7 +1048,7 @@ export class VideoGenerationClient { ? { result: { ...previous.result } } : {}), } - this.notifyResumeSnapshotChanged() + this.notifyResumeSnapshotChanged(controller) } /** @@ -907,14 +1057,19 @@ export class VideoGenerationClient { * mark the snapshot `error`, leaving a persisted record that claims the * run is still in flight. */ - private recordResumeSnapshotError(error: Error): void { + private recordResumeSnapshotError( + error: Error, + controller?: AbortController, + ): void { // Surface the failure on the observable fields FIRST, unconditionally (see // the note in GenerationClient.recordResumeSnapshotError): a RUN_ERROR // already flipped the snapshot to `error`, so the early-return would else // skip this and leave `status` stuck on `generating`. The guard avoids a // duplicate `error` emission on the live `generate()` path. - if (this.status !== 'error') this.setStatus('error') - this.setError(error) + if (this.status !== 'error') this.setStatus('error', controller) + if (controller && !this.ownsRun(controller)) return + this.setError(error, controller) + if (controller && !this.ownsRun(controller)) return if (this.resumeSnapshot?.status === 'error') return if (!this.resumeSnapshot && !this.serverDriven) return const previous = this.resumeSnapshot @@ -929,7 +1084,7 @@ export class VideoGenerationClient { ...(previous?.result ? { result: { ...previous.result } } : {}), error: { message: error.message }, } - this.notifyResumeSnapshotChanged() + this.notifyResumeSnapshotChanged(controller) } /** @@ -1046,23 +1201,26 @@ export class VideoGenerationClient { this.rejoinedRunId = runId const controller = new AbortController() this.abortController = controller - this.setIsLoading(true) - this.setStatus('generating') + this.setIsLoading(true, controller) + if (!this.ownsRun(controller)) return + this.setStatus('generating', controller) + if (!this.ownsRun(controller)) return void (async () => { try { await this.processStream( joinRun(runId, controller.signal), runId, - controller.signal, + controller, ) } catch (error) { - if (!controller.signal.aborted) { + if (this.ownsRun(controller)) { const failure = error instanceof Error ? error : new Error(String(error)) // Settles `status`/`error` AND rewrites the snapshot to a terminal // `error` with a null `resumeState`, so the next mount does not // rejoin this run again. - this.recordResumeSnapshotError(failure) + this.recordResumeSnapshotError(failure, controller) + if (!this.ownsRun(controller)) return this.callbacksRef.onError?.(failure) } } finally { @@ -1070,8 +1228,8 @@ export class VideoGenerationClient { // fresh `generate()` may have replaced the controller while the tail // was settling, and that live run owns `isLoading` now. if (this.abortController === controller) { - this.abortController = null - this.setIsLoading(false) + this.setIsLoading(false, controller) + if (this.ownsRun(controller)) this.abortController = null } } })() diff --git a/packages/ai-client/tests/audio-recorder.test.ts b/packages/ai-client/tests/audio-recorder.test.ts index 49c773b0cd..387308fb8b 100644 --- a/packages/ai-client/tests/audio-recorder.test.ts +++ b/packages/ai-client/tests/audio-recorder.test.ts @@ -75,6 +75,21 @@ afterEach(() => { }) describe('AudioRecorder', () => { + it('uses Set subscription behavior and reports repeated states', async () => { + const recorder = new AudioRecorder() + const listener = vi.fn() + const first = recorder.subscribe(listener) + recorder.subscribe(listener) + + getUserMedia.mockRejectedValueOnce(new Error('denied')) + await expect(recorder.start()).rejects.toThrow('denied') + expect(listener).toHaveBeenCalledTimes(1) + first() + getUserMedia.mockRejectedValueOnce(new Error('denied')) + await expect(recorder.start()).rejects.toThrow('denied') + expect(listener).toHaveBeenCalledTimes(1) + }) + it('isSupported() is true when media APIs exist', () => { expect(AudioRecorder.isSupported()).toBe(true) }) diff --git a/packages/ai-client/tests/byok-client.test.ts b/packages/ai-client/tests/byok-client.test.ts index 6754240720..8832f82151 100644 --- a/packages/ai-client/tests/byok-client.test.ts +++ b/packages/ai-client/tests/byok-client.test.ts @@ -8,6 +8,34 @@ describe('defineByok memory', () => { vi.unstubAllGlobals() }) + it('isolates and freezes nested snapshot state', async () => { + const byok = defineByok() + await byok.update('openai', 'sk-abcdefghij') + const snapshot = byok.getSnapshot() + + expect(Object.isFrozen(snapshot)).toBe(true) + expect(Object.isFrozen(snapshot.status)).toBe(true) + expect(Object.isFrozen(snapshot.status.openai)).toBe(true) + Reflect.set(snapshot.status.openai!, 'masked', 'leak') + expect(byok.getSnapshot().status.openai).toEqual({ + state: 'set', + masked: 'ghij', + }) + }) + + it('uses Set subscription behavior', () => { + const byok = defineByok() + const listener = vi.fn() + const first = byok.subscribe(listener) + byok.subscribe(listener) + + byok.request('openai', 'missing') + expect(listener).toHaveBeenCalledTimes(1) + first() + byok.request('openai', 'missing') + expect(listener).toHaveBeenCalledTimes(1) + }) + it('updates and snapshots without exposing the raw key', async () => { const byok = defineByok({ storage: memoryStorage() }) await byok.update('openai', 'sk-abcdefghij') diff --git a/packages/ai-client/tests/byok-generation-client.test.ts b/packages/ai-client/tests/byok-generation-client.test.ts index 948635c5b2..eeceb405e9 100644 --- a/packages/ai-client/tests/byok-generation-client.test.ts +++ b/packages/ai-client/tests/byok-generation-client.test.ts @@ -87,6 +87,64 @@ describe('GenerationClient byok', () => { expect(client.getError()).toBeInstanceOf(ByokMissingError) }) + it('stops the failed run when the BYOK request subscriber stops', async () => { + const byok = defineByok({ storage: memoryStorage() }) + await byok.update('elevenlabs', ELEVENLABS_KEY) + const onErrorChange = vi.fn() + let client!: GenerationClient<{ prompt: string }, { id: string }> + client = new GenerationClient({ + fetcher: async (): Promise<{ id: string }> => { + throw new ByokMissingError('elevenlabs') + }, + byok, + byokProvider: () => 'elevenlabs', + onErrorChange, + }) + byok.subscribe(() => { + if (byok.getSnapshot().prompt) client.stop() + }) + + await client.generate({ prompt: 'first' }) + + expect(client.getStatus()).toBe('idle') + expect(client.getError()).toBeUndefined() + expect(onErrorChange).not.toHaveBeenCalledWith(expect.any(Error)) + }) + + it('does not let a failed BYOK run overwrite a replacement', async () => { + const byok = defineByok({ storage: memoryStorage() }) + await byok.update('elevenlabs', ELEVENLABS_KEY) + let releaseSecond!: (value: { id: string }) => void + const secondResult = new Promise<{ id: string }>((resolve) => { + releaseSecond = resolve + }) + let secondGenerate: Promise | undefined + let replaced = false + const client = new GenerationClient({ + fetcher: async (input) => { + if (input.prompt === 'first') { + throw new ByokMissingError('elevenlabs') + } + return secondResult + }, + byok, + byokProvider: () => 'elevenlabs', + }) + byok.subscribe(() => { + if (!byok.getSnapshot().prompt || replaced) return + replaced = true + client.stop() + secondGenerate = client.generate({ prompt: 'second' }) + }) + + await client.generate({ prompt: 'first' }) + + expect(client.getStatus()).toBe('generating') + expect(client.getError()).toBeUndefined() + releaseSecond({ id: 'second' }) + await secondGenerate + }) + it('requests a missing key when the fetcher returns a byokMissing Response', async () => { const byok = defineByok({ storage: memoryStorage() }) await byok.update('elevenlabs', ELEVENLABS_KEY) diff --git a/packages/ai-client/tests/byok-video-generation-client.test.ts b/packages/ai-client/tests/byok-video-generation-client.test.ts index 166b3eb4d4..2b7633c385 100644 --- a/packages/ai-client/tests/byok-video-generation-client.test.ts +++ b/packages/ai-client/tests/byok-video-generation-client.test.ts @@ -92,6 +92,64 @@ describe('VideoGenerationClient byok', () => { expect(client.getError()).toBeInstanceOf(ByokMissingError) }) + it('stops the failed run when the BYOK request subscriber stops', async () => { + const byok = defineByok({ storage: memoryStorage() }) + await byok.update('elevenlabs', ELEVENLABS_KEY) + const onErrorChange = vi.fn() + let client!: VideoGenerationClient + client = new VideoGenerationClient({ + fetcher: async () => { + throw new ByokMissingError('elevenlabs') + }, + byok, + byokProvider: () => 'elevenlabs', + onErrorChange, + }) + byok.subscribe(() => { + if (byok.getSnapshot().prompt) client.stop() + }) + + await client.generate({ prompt: 'first' }) + + expect(client.getStatus()).toBe('idle') + expect(client.getError()).toBeUndefined() + expect(onErrorChange).not.toHaveBeenCalledWith(expect.any(Error)) + }) + + it('does not let a failed BYOK run overwrite a replacement', async () => { + const byok = defineByok({ storage: memoryStorage() }) + await byok.update('elevenlabs', ELEVENLABS_KEY) + let releaseSecond!: (value: typeof VIDEO_RESULT) => void + const secondResult = new Promise((resolve) => { + releaseSecond = resolve + }) + let secondGenerate: Promise | undefined + let replaced = false + const client = new VideoGenerationClient({ + fetcher: async (input) => { + if (input.prompt === 'first') { + throw new ByokMissingError('elevenlabs') + } + return secondResult + }, + byok, + byokProvider: () => 'elevenlabs', + }) + byok.subscribe(() => { + if (!byok.getSnapshot().prompt || replaced) return + replaced = true + client.stop() + secondGenerate = client.generate({ prompt: 'second' }) + }) + + await client.generate({ prompt: 'first' }) + + expect(client.getStatus()).toBe('generating') + expect(client.getError()).toBeUndefined() + releaseSecond(VIDEO_RESULT) + await secondGenerate + }) + it('requests a missing key when the fetcher returns a byokMissing Response', async () => { const byok = defineByok({ storage: memoryStorage() }) await byok.update('elevenlabs', ELEVENLABS_KEY) diff --git a/packages/ai-client/tests/chat-client.test.ts b/packages/ai-client/tests/chat-client.test.ts index 44a8ce4ce0..dc01508989 100644 --- a/packages/ai-client/tests/chat-client.test.ts +++ b/packages/ai-client/tests/chat-client.test.ts @@ -68,6 +68,105 @@ describe('ChatClient', () => { expect(client.getError()).toBeUndefined() }) + it('exposes a UI snapshot that subscribeSnapshot notifies', () => { + const adapter = createMockConnectionAdapter() + const initialMessages: Array = [ + { + id: 'msg-1', + role: 'user', + parts: [{ type: 'text', content: 'Hello' }], + createdAt: new Date(), + }, + ] + const client = new ChatClient({ + connection: adapter, + initialMessages, + }) + + const snapshot = client.getSnapshot() + expect(snapshot.messages).toEqual(initialMessages) + expect(snapshot.status).toBe('ready') + expect(snapshot.isLoading).toBe(false) + expect(client.getSnapshot()).toBe(snapshot) + + const listener = vi.fn() + const stop = client.subscribeSnapshot(listener) + client.setMessagesManually([]) + expect(listener).toHaveBeenCalledTimes(1) + expect(client.getSnapshot().messages).toEqual([]) + expect(client.getSnapshot()).not.toBe(snapshot) + stop() + }) + + it('isolates nested message parts from callers and old snapshots', () => { + const adapter = createMockConnectionAdapter() + const source = { + type: 'url' as const, + value: 'https://example.com/a.png', + } + const client = new ChatClient({ + connection: adapter, + initialMessages: [ + { + id: 'msg-1', + role: 'user', + parts: [{ type: 'image', source }], + createdAt: new Date(), + }, + ], + }) + const oldSnapshot = client.getSnapshot() + + source.value = 'https://example.com/changed.png' + expect(oldSnapshot.messages[0]?.parts[0]).toMatchObject({ + source: { value: 'https://example.com/a.png' }, + }) + const publishedPart = oldSnapshot.messages[0]?.parts[0] + expect(Object.isFrozen(publishedPart)).toBe(true) + if (!publishedPart || !('source' in publishedPart)) { + throw new Error('Expected a message part with a source') + } + expect(Object.isFrozen(publishedPart.source)).toBe(true) + }) + + it('isolates messages published by normal updates', () => { + const client = new ChatClient({ + connection: createMockConnectionAdapter(), + }) + const oldSnapshot = client.getSnapshot() + const source = { + type: 'url' as const, + value: 'https://example.com/updated.png', + } + + client.setMessagesManually([ + { + id: 'updated-1', + role: 'user', + parts: [{ type: 'image', source }], + }, + ]) + + const snapshot = client.getSnapshot() + const part = snapshot.messages[0]?.parts[0] + expect(snapshot).not.toBe(oldSnapshot) + expect(oldSnapshot.messages).toEqual([]) + expect(Object.isFrozen(snapshot.messages)).toBe(true) + expect(Object.isFrozen(snapshot.messages[0])).toBe(true) + expect(Object.isFrozen(snapshot.messages[0]?.parts)).toBe(true) + expect(Object.isFrozen(part)).toBe(true) + expect(part).toMatchObject({ source }) + + if (!part || !('source' in part)) { + throw new Error('Expected a message part with a source') + } + const publishedSource = part.source + expect(Object.isFrozen(publishedSource)).toBe(true) + expect(Reflect.set(publishedSource, 'value', 'changed')).toBe(false) + expect(client.getMessages()[0]?.parts[0]).toMatchObject({ source }) + expect(client.getSnapshot()).toBe(snapshot) + }) + it('should initialize with provided messages', () => { const adapter = createMockConnectionAdapter() const initialMessages: Array = [ @@ -2248,6 +2347,61 @@ describe('ChatClient', () => { { type: 'text', content: 'Second' }, ]) }) + + it('isolates queue entries published by normal updates', async () => { + const client = new ChatClient({ + connection: createMockConnectionAdapter({ + chunks: createTextChunks('Response'), + chunkDelay: 100, + }), + }) + const firstSend = client.sendMessage('First') + const oldSnapshot = client.getSnapshot() + + await client.sendMessage({ + content: [ + { + type: 'image', + source: { + type: 'url', + value: 'https://example.com/queued.png', + }, + }, + ], + }) + + const snapshot = client.getSnapshot() + const queuedContent = snapshot.queue[0]?.content + expect(snapshot).not.toBe(oldSnapshot) + expect(oldSnapshot.queue).toEqual([]) + expect(Object.isFrozen(snapshot.queue)).toBe(true) + expect(Object.isFrozen(snapshot.queue[0])).toBe(true) + expect(typeof queuedContent).toBe('object') + if (typeof queuedContent === 'object') { + expect(Object.isFrozen(queuedContent)).toBe(true) + expect(Object.isFrozen(queuedContent.content)).toBe(true) + const queuedPart = Array.isArray(queuedContent.content) + ? queuedContent.content[0] + : undefined + expect(Object.isFrozen(queuedPart)).toBe(true) + if (!queuedPart || !('source' in queuedPart)) { + throw new Error('Expected a queued content part with a source') + } + const publishedSource = queuedPart.source + expect(Object.isFrozen(publishedSource)).toBe(true) + expect(Reflect.set(publishedSource, 'value', 'changed')).toBe(false) + } + expect(client.getQueue()[0]?.content).toMatchObject({ + content: [ + { + source: { value: 'https://example.com/queued.png' }, + }, + ], + }) + expect(client.getSnapshot()).toBe(snapshot) + + await firstSend + }) }) describe('append', () => { diff --git a/packages/ai-client/tests/client-snapshots.test.ts b/packages/ai-client/tests/client-snapshots.test.ts new file mode 100644 index 0000000000..835885e495 --- /dev/null +++ b/packages/ai-client/tests/client-snapshots.test.ts @@ -0,0 +1,161 @@ +import { describe, expect, it, vi } from 'vitest' +import { + AudioRecorder, + GenerationClient, + RealtimeClient, + VideoGenerationClient, +} from '../src' +import type { RealtimeAdapter } from '../src' + +describe('client UI snapshots', () => { + it('publishes and clears the active generation run id', async () => { + let resolve!: (value: { id: string }) => void + const pending = new Promise<{ id: string }>((done) => { + resolve = done + }) + const client = new GenerationClient({ fetcher: async () => pending }) + + const generating = client.generate({ prompt: 'test' }) + expect(client.getSnapshot().runId).toEqual(expect.any(String)) + expect(client.getSnapshot().runId).not.toBe('') + + resolve({ id: '1' }) + await generating + expect(client.getSnapshot().runId).toBeNull() + }) + + it('GenerationClient notifies subscribe and keeps getSnapshot identity until a change', async () => { + const client = new GenerationClient({ + fetcher: async () => ({ id: '1' }), + }) + const snapshot = client.getSnapshot() + expect(snapshot).toEqual({ + result: null, + isLoading: false, + error: undefined, + status: 'idle', + runId: null, + }) + expect(client.getSnapshot()).toBe(snapshot) + + const listener = vi.fn() + const stop = client.subscribe(listener) + await client.generate({ prompt: 'test' }) + + expect(listener).toHaveBeenCalled() + expect(client.getSnapshot().status).toBe('success') + expect(client.getSnapshot().result).toEqual({ id: '1' }) + expect(client.getSnapshot()).not.toBe(snapshot) + const published = client.getSnapshot().result + expect(published).toEqual({ id: '1' }) + if (published) { + Reflect.set(published, 'id', 'mutated') + } + expect(client.getResult()).toEqual({ id: '1' }) + stop() + }) + + it('VideoGenerationClient snapshot includes job fields', async () => { + const result = { + jobId: 'job-1', + status: 'completed' as const, + url: 'https://example.com/video.mp4', + } + const client = new VideoGenerationClient({ + fetcher: async () => result, + }) + const snapshot = client.getSnapshot() + expect(snapshot.jobId).toBeNull() + expect(snapshot.videoStatus).toBeNull() + expect(client.getSnapshot()).toBe(snapshot) + + const listener = vi.fn() + const stop = client.subscribe(listener) + await client.generate({ prompt: 'test video' }) + + expect(listener).toHaveBeenCalled() + expect(client.getSnapshot().status).toBe('success') + expect(client.getSnapshot().result).toEqual(result) + expect(client.getSnapshot().jobId).toBe('job-1') + expect(client.getSnapshot().videoStatus?.status).toBe('completed') + expect(client.getSnapshot()).not.toBe(snapshot) + const completed = client.getSnapshot() + expect(Object.isFrozen(completed.videoStatus)).toBe(true) + Reflect.set(completed.videoStatus!, 'status', 'failed') + expect(client.getVideoStatus()?.status).toBe('completed') + stop() + }) + + it('AudioRecorder getSnapshot matches state', () => { + const recorder = new AudioRecorder() + expect(recorder.getSnapshot()).toBe('idle') + expect(recorder.getSnapshot()).toBe(recorder.state) + }) + + it('RealtimeClient subscribe fires when connect fails', async () => { + const adapter: RealtimeAdapter = { + provider: 'test', + connect: async () => { + throw new Error('unused') + }, + } + const client = new RealtimeClient({ + getToken: async () => { + throw new Error('no token') + }, + adapter, + }) + const snapshot = client.getSnapshot() + expect(snapshot.status).toBe('idle') + expect(client.getSnapshot()).toBe(snapshot) + + const listener = vi.fn() + const stop = client.subscribe(listener) + await expect(client.connect()).rejects.toThrow('no token') + + expect(listener).toHaveBeenCalled() + expect(client.getSnapshot().status).toBe('error') + expect(client.getSnapshot()).not.toBe(snapshot) + stop() + client.destroy() + }) + + it('RealtimeClient does not read connection control from its public snapshot', async () => { + const getToken = vi.fn(async () => { + throw new Error('token attempted') + }) + const client = new RealtimeClient({ + getToken, + adapter: { provider: 'test', connect: vi.fn() }, + }) + + Reflect.set(client.getSnapshot(), 'status', 'connected') + await expect(client.connect()).rejects.toThrow('token attempted') + expect(getToken).toHaveBeenCalledOnce() + }) + + it('RealtimeClient isolates connection control from state change callbacks', async () => { + const getToken = vi.fn(async () => ({ + provider: 'test', + token: 'token', + expiresAt: Date.now() + 120_000, + config: {}, + })) + const connect = vi.fn(async () => { + throw new Error('connect attempted') + }) + const client = new RealtimeClient({ + getToken, + adapter: { provider: 'test', connect }, + }) + client.onStateChange((state) => { + Reflect.set(state, 'status', 'connected') + }) + + await expect(client.connect()).rejects.toThrow('connect attempted') + await expect(client.connect()).rejects.toThrow('connect attempted') + + expect(getToken).toHaveBeenCalledTimes(2) + expect(connect).toHaveBeenCalledTimes(2) + }) +}) diff --git a/packages/ai-client/tests/generation-client.test.ts b/packages/ai-client/tests/generation-client.test.ts index 8b941689dd..66ddeed6c4 100644 --- a/packages/ai-client/tests/generation-client.test.ts +++ b/packages/ai-client/tests/generation-client.test.ts @@ -115,6 +115,7 @@ describe('GenerationClient', () => { expect(onResultChange).toHaveBeenCalledWith(mockResult) expect(client.getResult()).toEqual(mockResult) expect(client.getStatus()).toBe('success') + expect(client.getSnapshot().runId).toBeNull() expect(client.getIsLoading()).toBe(false) }) @@ -245,6 +246,57 @@ describe('GenerationClient', () => { expect(client.getStatus()).toBe('success') }) + it('keeps the local run id when a stream chunk has no resume pointer', async () => { + const gate = createDeferred() + const chunks: Array = [ + { + type: EventType.CUSTOM, + name: 'generation:progress', + value: { percent: 1 }, + timestamp: Date.now(), + }, + { + type: EventType.RUN_STARTED, + runId: 'run-1', + threadId: 'thread-1', + timestamp: Date.now(), + }, + { + type: EventType.CUSTOM, + name: 'generation:result', + value: { id: '1' }, + timestamp: Date.now(), + }, + { + type: EventType.RUN_FINISHED, + runId: 'run-1', + threadId: 'thread-1', + metadata: { tanstack: { finishReason: 'stop' as const } }, + timestamp: Date.now(), + }, + ] + const connection = { + async *connect() { + yield chunks[0]! + await gate.promise + for (const chunk of chunks.slice(1)) { + yield chunk + } + }, + } + + const client = new GenerationClient({ connection }) + const pending = client.generate({ prompt: 'test' }) + await waitForCondition(() => { + expect(client.getSnapshot().isLoading).toBe(true) + }) + expect(client.getSnapshot().runId).toEqual(expect.any(String)) + expect(client.getSnapshot().runId).not.toBe('') + gate.resolve() + await pending + expect(client.getSnapshot().runId).toBeNull() + }) + it('should handle RUN_ERROR from stream', async () => { const onError = vi.fn() @@ -274,6 +326,7 @@ describe('GenerationClient', () => { expect(onError).toHaveBeenCalledWith(expect.any(Error)) expect(client.getStatus()).toBe('error') expect(client.getError()?.message).toBe('Generation failed') + expect(client.getSnapshot().runId).toBeNull() }) it('should report progress from CUSTOM progress events', async () => { @@ -408,14 +461,366 @@ describe('GenerationClient', () => { const generatePromise = client.generate({ prompt: 'test' }) expect(client.getIsLoading()).toBe(true) + expect(client.getSnapshot().runId).toEqual(expect.any(String)) client.stop() expect(client.getIsLoading()).toBe(false) expect(client.getStatus()).toBe('idle') + expect(client.getSnapshot().runId).toBeNull() resolvePromise!({ id: '1' }) await generatePromise }) + + it('should not overwrite a run started by the stopped loading callback', async () => { + let releaseFirst!: (value: { id: string }) => void + let releaseSecond!: (value: { id: string }) => void + const firstResult = new Promise<{ id: string }>((resolve) => { + releaseFirst = resolve + }) + const secondResult = new Promise<{ id: string }>((resolve) => { + releaseSecond = resolve + }) + let secondGenerate: Promise | undefined + let secondRunId: string | null = null + let replaced = false + let client!: GenerationClient<{ prompt: string }, { id: string }> + client = new GenerationClient({ + fetcher: async (input) => + input.prompt === 'first' ? firstResult : secondResult, + onLoadingChange: (isLoading) => { + if (isLoading || replaced) return + replaced = true + secondGenerate = client.generate({ prompt: 'second' }) + secondRunId = client.getSnapshot().runId + }, + }) + + const firstGenerate = client.generate({ prompt: 'first' }) + client.stop() + + expect(secondRunId).not.toBeNull() + expect(client.getSnapshot().runId).toBe(secondRunId) + expect(client.getIsLoading()).toBe(true) + expect(client.getStatus()).toBe('generating') + releaseFirst({ id: 'first' }) + releaseSecond({ id: 'second' }) + await Promise.all([firstGenerate, secondGenerate]) + }) + + it('should stop before transport work when loading callback stops', async () => { + const fetcher = vi.fn(async () => ({ id: 'unexpected' })) + let client!: GenerationClient<{ prompt: string }, { id: string }> + client = new GenerationClient({ + fetcher, + onLoadingChange: (isLoading) => { + if (isLoading) client.stop() + }, + }) + + await client.generate({ prompt: 'test' }) + + expect(fetcher).not.toHaveBeenCalled() + expect(client.getStatus()).toBe('idle') + expect(client.getSnapshot().runId).toBeNull() + }) + + it('should not complete resume state after success callback stops', async () => { + let client!: GenerationClient<{ prompt: string }, { id: string }> + client = new GenerationClient({ + fetcher: async () => ({ id: 'result' }), + onStatusChange: (status) => { + if (status === 'success') client.stop() + }, + }) + + await client.generate({ prompt: 'test' }) + + expect(client.getStatus()).toBe('success') + expect(client.getSnapshot().runId).toBeNull() + expect(client.getResumeSnapshot()).toBeUndefined() + }) + + it('should not let initialization continue after loading callback starts a replacement', async () => { + let releaseSecond!: (value: { id: string }) => void + const secondResult = new Promise<{ id: string }>((resolve) => { + releaseSecond = resolve + }) + const prompts: Array = [] + let client!: GenerationClient<{ prompt: string }, { id: string }> + let replaced = false + let secondGenerate: Promise | undefined + client = new GenerationClient({ + fetcher: async (input) => { + prompts.push(input.prompt) + return input.prompt === 'second' ? secondResult : { id: 'first' } + }, + onLoadingChange: (isLoading) => { + if (!isLoading || replaced) return + replaced = true + client.stop() + secondGenerate = client.generate({ prompt: 'second' }) + }, + }) + + await client.generate({ prompt: 'first' }) + + expect(prompts).toEqual(['second']) + expect(client.getStatus()).toBe('generating') + releaseSecond({ id: 'second' }) + await secondGenerate + }) + + it('should not let an old result callback complete a replacement run', async () => { + let releaseSecond!: (value: { id: string }) => void + const secondResult = new Promise<{ id: string }>((resolve) => { + releaseSecond = resolve + }) + let client!: GenerationClient<{ prompt: string }, { id: string }> + let secondGenerate: Promise | undefined + let secondRunId: string | null = null + client = new GenerationClient({ + fetcher: async (input) => + input.prompt === 'first' ? { id: 'first' } : secondResult, + onResultChange: (result) => { + if (result?.id !== 'first') return + client.stop() + secondGenerate = client.generate({ prompt: 'second' }) + secondRunId = client.getSnapshot().runId + }, + }) + + await client.generate({ prompt: 'first' }) + + expect(secondRunId).not.toBeNull() + expect(client.getSnapshot().runId).toBe(secondRunId) + expect(client.getStatus()).toBe('generating') + expect(client.getResumeSnapshot()).toBeUndefined() + releaseSecond({ id: 'second' }) + await secondGenerate + }) + + it('should not let an old error callback overwrite a replacement run', async () => { + let releaseSecond!: (value: { id: string }) => void + const secondResult = new Promise<{ id: string }>((resolve) => { + releaseSecond = resolve + }) + let client!: GenerationClient<{ prompt: string }, { id: string }> + let secondGenerate: Promise | undefined + let secondRunId: string | null = null + client = new GenerationClient({ + fetcher: async (input) => { + if (input.prompt === 'first') throw new Error('first failed') + return secondResult + }, + onErrorChange: (error) => { + if (error?.message !== 'first failed') return + client.stop() + secondGenerate = client.generate({ prompt: 'second' }) + secondRunId = client.getSnapshot().runId + }, + }) + + await client.generate({ prompt: 'first' }) + + expect(secondRunId).not.toBeNull() + expect(client.getSnapshot().runId).toBe(secondRunId) + expect(client.getStatus()).toBe('generating') + releaseSecond({ id: 'second' }) + await secondGenerate + }) + + it('should remain stopped when the error callback stops', async () => { + let client!: GenerationClient<{ prompt: string }, { id: string }> + client = new GenerationClient({ + fetcher: async (): Promise<{ id: string }> => { + throw new Error('failed') + }, + onErrorChange: (error) => { + if (error) client.stop() + }, + }) + + await client.generate({ prompt: 'test' }) + + expect(client.getStatus()).toBe('idle') + expect(client.getSnapshot().runId).toBeNull() + }) + + it('should not let an old error status callback overwrite a replacement run', async () => { + let releaseSecond!: (value: { id: string }) => void + const secondResult = new Promise<{ id: string }>((resolve) => { + releaseSecond = resolve + }) + let client!: GenerationClient<{ prompt: string }, { id: string }> + let secondGenerate: Promise | undefined + let replaced = false + client = new GenerationClient({ + fetcher: async (input) => { + if (input.prompt === 'first') throw new Error('first failed') + return secondResult + }, + onStatusChange: (status) => { + if (status !== 'error' || replaced) return + replaced = true + client.stop() + secondGenerate = client.generate({ prompt: 'second' }) + }, + }) + + await client.generate({ prompt: 'first' }) + + expect(client.getStatus()).toBe('generating') + expect(client.getError()).toBeUndefined() + releaseSecond({ id: 'second' }) + await secondGenerate + }) + + it('should stop before transport when the run id subscriber stops', async () => { + const fetcher = vi.fn(async () => ({ id: 'unexpected' })) + const onLoadingChange = vi.fn() + const client = new GenerationClient({ fetcher, onLoadingChange }) + client.subscribe(() => { + if (client.getSnapshot().runId) client.stop() + }) + + await client.generate({ prompt: 'test' }) + + expect(fetcher).not.toHaveBeenCalled() + expect(onLoadingChange).not.toHaveBeenCalledWith(true) + expect(client.getSnapshot().runId).toBeNull() + }) + + it('should not continue an old run after the run id subscriber starts a replacement', async () => { + let releaseSecond!: (value: { id: string }) => void + const secondResult = new Promise<{ id: string }>((resolve) => { + releaseSecond = resolve + }) + const prompts: Array = [] + let secondGenerate: Promise | undefined + let replaced = false + const client = new GenerationClient({ + fetcher: async (input) => { + prompts.push(input.prompt) + return input.prompt === 'second' ? secondResult : { id: 'first' } + }, + }) + client.subscribe(() => { + if (!client.getSnapshot().runId || replaced) return + replaced = true + client.stop() + secondGenerate = client.generate({ prompt: 'second' }) + }) + + await client.generate({ prompt: 'first' }) + + expect(prompts).toEqual(['second']) + expect(client.getStatus()).toBe('generating') + releaseSecond({ id: 'second' }) + await secondGenerate + }) + + it('should not call the loading callback after its snapshot subscriber stops', async () => { + const fetcher = vi.fn(async () => ({ id: 'unexpected' })) + const onLoadingChange = vi.fn() + const client = new GenerationClient({ fetcher, onLoadingChange }) + client.subscribe(() => { + if (client.getSnapshot().isLoading) client.stop() + }) + + await client.generate({ prompt: 'test' }) + + expect(fetcher).not.toHaveBeenCalled() + expect(onLoadingChange).not.toHaveBeenCalledWith(true) + }) + + it('should not call the old status callback after its subscriber starts a replacement', async () => { + let releaseSecond!: (value: { id: string }) => void + const secondResult = new Promise<{ id: string }>((resolve) => { + releaseSecond = resolve + }) + const generatingStatuses: Array = [] + let secondGenerate: Promise | undefined + let replaced = false + const client = new GenerationClient({ + fetcher: async (input) => + input.prompt === 'second' ? secondResult : { id: 'first' }, + onStatusChange: (status) => { + if (status === 'generating') generatingStatuses.push(status) + }, + }) + client.subscribe(() => { + if (client.getSnapshot().status !== 'generating' || replaced) return + replaced = true + client.stop() + secondGenerate = client.generate({ prompt: 'second' }) + }) + + await client.generate({ prompt: 'first' }) + + expect(generatingStatuses).toEqual(['generating']) + expect(client.getStatus()).toBe('generating') + releaseSecond({ id: 'second' }) + await secondGenerate + }) + + it('should not call the old result callback after its subscriber starts a replacement', async () => { + let releaseSecond!: (value: { id: string }) => void + const secondResult = new Promise<{ id: string }>((resolve) => { + releaseSecond = resolve + }) + const onResultChange = vi.fn() + let secondGenerate: Promise | undefined + let replaced = false + const client = new GenerationClient({ + fetcher: async (input) => + input.prompt === 'second' ? secondResult : { id: 'first' }, + onResultChange, + }) + client.subscribe(() => { + if (client.getResult()?.id !== 'first' || replaced) return + replaced = true + client.stop() + secondGenerate = client.generate({ prompt: 'second' }) + }) + + await client.generate({ prompt: 'first' }) + + expect(onResultChange).not.toHaveBeenCalledWith({ id: 'first' }) + expect(client.getStatus()).toBe('generating') + releaseSecond({ id: 'second' }) + await secondGenerate + }) + + it('should not call the old error callback after its subscriber starts a replacement', async () => { + let releaseSecond!: (value: { id: string }) => void + const secondResult = new Promise<{ id: string }>((resolve) => { + releaseSecond = resolve + }) + const onErrorChange = vi.fn() + let secondGenerate: Promise | undefined + let replaced = false + const client = new GenerationClient({ + fetcher: async (input) => { + if (input.prompt === 'first') throw new Error('first failed') + return secondResult + }, + onErrorChange, + }) + client.subscribe(() => { + if (client.getError()?.message !== 'first failed' || replaced) return + replaced = true + client.stop() + secondGenerate = client.generate({ prompt: 'second' }) + }) + + await client.generate({ prompt: 'first' }) + + expect(onErrorChange).not.toHaveBeenCalledWith(expect.any(Error)) + expect(client.getStatus()).toBe('generating') + releaseSecond({ id: 'second' }) + await secondGenerate + }) }) describe('reset()', () => { @@ -432,6 +837,82 @@ describe('GenerationClient', () => { expect(client.getResult()).toBeNull() expect(client.getError()).toBeUndefined() expect(client.getStatus()).toBe('idle') + expect(client.getSnapshot().runId).toBeNull() + }) + + it('should not overwrite a run started by the reset loading callback', async () => { + let releaseFirst!: (value: { id: string }) => void + let releaseSecond!: (value: { id: string }) => void + const firstResult = new Promise<{ id: string }>((resolve) => { + releaseFirst = resolve + }) + const secondResult = new Promise<{ id: string }>((resolve) => { + releaseSecond = resolve + }) + let replaceOnStop = false + let secondGenerate: Promise | undefined + let secondRunId: string | null = null + let client!: GenerationClient<{ prompt: string }, { id: string }> + client = new GenerationClient({ + fetcher: async (input) => { + if (input.prompt === 'seed') return { id: 'seed' } + return input.prompt === 'first' ? firstResult : secondResult + }, + onLoadingChange: (isLoading) => { + if (isLoading || !replaceOnStop) return + replaceOnStop = false + secondGenerate = client.generate({ prompt: 'second' }) + secondRunId = client.getSnapshot().runId + }, + }) + await client.generate({ prompt: 'seed' }) + const firstGenerate = client.generate({ prompt: 'first' }) + replaceOnStop = true + + client.reset() + + expect(secondRunId).not.toBeNull() + expect(client.getSnapshot().runId).toBe(secondRunId) + expect(client.getStatus()).toBe('generating') + expect(client.getIsLoading()).toBe(true) + expect(client.getResult()).toEqual({ id: 'seed' }) + releaseFirst({ id: 'first' }) + releaseSecond({ id: 'second' }) + await Promise.all([firstGenerate, secondGenerate]) + }) + }) + + describe('dispose()', () => { + it('should not overwrite a run started by the dispose loading callback', async () => { + const first = createDeferred<{ id: string }>() + const second = createDeferred<{ id: string }>() + let replaceOnDispose = false + let secondGenerate: Promise | undefined + let secondRunId: string | null = null + let client!: GenerationClient<{ prompt: string }, { id: string }> + client = new GenerationClient({ + fetcher: (input) => + input.prompt === 'first' ? first.promise : second.promise, + onLoadingChange: (isLoading) => { + if (isLoading || !replaceOnDispose) return + replaceOnDispose = false + client.mountDevtools() + secondGenerate = client.generate({ prompt: 'second' }) + secondRunId = client.getSnapshot().runId + }, + }) + const firstGenerate = client.generate({ prompt: 'first' }) + replaceOnDispose = true + + client.dispose() + + expect(secondRunId).not.toBeNull() + expect(client.getSnapshot().runId).toBe(secondRunId) + expect(client.getStatus()).toBe('generating') + expect(client.getIsLoading()).toBe(true) + first.resolve({ id: 'first' }) + second.resolve({ id: 'second' }) + await Promise.all([firstGenerate, secondGenerate]) }) }) @@ -560,6 +1041,75 @@ describe('GenerationClient', () => { expect(client.getStatus()).toBe('idle') }) + it('should not publish a run id after onChunk stops the run', async () => { + const connection: ConnectConnectionAdapter = { + async *connect() { + yield { + type: EventType.RUN_STARTED as const, + runId: 'run-1', + threadId: 'thread-1', + timestamp: Date.now(), + } + }, + } + let client!: GenerationClient<{ prompt: string }, { id: string }> + client = new GenerationClient({ + connection, + onChunk: (chunk) => { + if (chunk.type === EventType.RUN_STARTED) client.stop() + }, + }) + + await client.generate({ prompt: 'test' }) + + expect(client.getSnapshot().runId).toBeNull() + expect(client.getStatus()).toBe('idle') + }) + + it('should not let an old onChunk continuation overwrite a newer run', async () => { + let connectionCount = 0 + let releaseSecond!: () => void + const secondBlocked = new Promise((resolve) => { + releaseSecond = resolve + }) + const connection: ConnectConnectionAdapter = { + async *connect() { + connectionCount++ + if (connectionCount === 1) { + yield { + type: EventType.RUN_STARTED as const, + runId: 'run-1', + threadId: 'thread-1', + timestamp: Date.now(), + } + return + } + await secondBlocked + }, + } + let client!: GenerationClient<{ prompt: string }, { id: string }> + let secondGenerate: Promise | undefined + let secondLocalRunId: string | null = null + client = new GenerationClient({ + connection, + onChunk: (chunk) => { + if (chunk.type !== EventType.RUN_STARTED) return + client.stop() + secondGenerate = client.generate({ prompt: 'second' }) + secondLocalRunId = client.getSnapshot().runId + }, + }) + + await client.generate({ prompt: 'first' }) + + expect(secondLocalRunId).not.toBeNull() + expect(client.getSnapshot().runId).toBe(secondLocalRunId) + expect(client.getIsLoading()).toBe(true) + client.stop() + releaseSecond() + await secondGenerate + }) + it('should not let a stopped run clear the controller for a newer generation', async () => { const firstAborted = createDeferred() const firstCanFinish = createDeferred() @@ -621,12 +1171,14 @@ describe('GenerationClient', () => { await waitForCondition(() => { expect(signals).toHaveLength(2) expect(client.getIsLoading()).toBe(true) + expect(client.getSnapshot().runId).toBe('run-2') }) firstCanFinish.resolve(undefined) await firstGenerate expect(client.getIsLoading()).toBe(true) + expect(client.getSnapshot().runId).toBe('run-2') client.stop() expect(signals[1]?.aborted).toBe(true) @@ -758,12 +1310,14 @@ describe('GenerationClient', () => { await waitForCondition(() => { expect(signals).toHaveLength(2) expect(client.getIsLoading()).toBe(true) + expect(client.getSnapshot().runId).toBe('run-2') }) firstCanFinish.resolve(undefined) await firstGenerate expect(client.getIsLoading()).toBe(true) + expect(client.getSnapshot().runId).toBe('run-2') client.stop() expect(signals[1]?.aborted).toBe(true) diff --git a/packages/ai-client/tests/generation-devtools.test.ts b/packages/ai-client/tests/generation-devtools.test.ts index 133519a42d..ce967ca388 100644 --- a/packages/ai-client/tests/generation-devtools.test.ts +++ b/packages/ai-client/tests/generation-devtools.test.ts @@ -289,6 +289,184 @@ describe('generation client devtools bridge', () => { client.dispose() }) + it('finishes a generation devtools run when a runId subscriber stops it', async () => { + const fetcher = vi.fn(async () => ({ text: 'done' })) + const client = new GenerationClient({ + threadId: 'generation-run-id-stop', + fetcher, + devtools: { hookName: 'useGenerationRunIdStop' }, + }) + const unsubscribe = client.subscribe(() => { + if (client.getSnapshot().runId) client.stop() + }) + + await client.generate({ prompt: 'stop early' }) + + expect(fetcher).not.toHaveBeenCalled() + expect(latestSnapshotState().activeRunId).toBeNull() + unsubscribe() + client.dispose() + }) + + it('finishes a generation devtools run when loading callback stops it', async () => { + const fetcher = vi.fn(async () => ({ text: 'done' })) + let client: GenerationClient<{ prompt: string }, { text: string }> + client = new GenerationClient({ + threadId: 'generation-loading-stop', + fetcher, + devtools: { hookName: 'useGenerationLoadingStop' }, + onLoadingChange: (isLoading) => { + if (isLoading) client.stop() + }, + }) + + await client.generate({ prompt: 'stop while loading' }) + + expect(fetcher).not.toHaveBeenCalled() + expect(latestSnapshotState().activeRunId).toBeNull() + client.dispose() + }) + + it('finishes a completed generation devtools run when success callback stops it', async () => { + let client: GenerationClient<{ prompt: string }, { text: string }> + client = new GenerationClient({ + threadId: 'generation-success-stop', + fetcher: async () => ({ text: 'done' }), + devtools: { hookName: 'useGenerationSuccessStop' }, + onStatusChange: (status) => { + if (status === 'success') client.stop() + }, + }) + + await client.generate({ prompt: 'finish then stop' }) + + expect(latestSnapshotState().activeRunId).toBeNull() + expect(latestGenerationRuns().at(-1)).toEqual( + expect.objectContaining({ status: 'success', result: { text: 'done' } }), + ) + client.dispose() + }) + + it('finishes an errored generation devtools run when error callback stops it', async () => { + let client: GenerationClient<{ prompt: string }, { text: string }> + client = new GenerationClient<{ prompt: string }, { text: string }>({ + threadId: 'generation-error-stop', + devtools: { hookName: 'useGenerationErrorStop' }, + fetcher: async (): Promise<{ text: string }> => { + throw new Error('generation failed') + }, + onStatusChange: (status) => { + if (status === 'error') client.stop() + }, + }) + + await client.generate({ prompt: 'fail then stop' }) + + expect(latestSnapshotState().activeRunId).toBeNull() + expect(latestGenerationRuns().at(-1)).toEqual( + expect.objectContaining({ + status: 'error', + error: 'generation failed', + }), + ) + client.dispose() + }) + + it('finishes a video devtools run when a runId subscriber stops it', async () => { + const fetcher = vi.fn(async () => ({ + jobId: 'video-1', + status: 'completed' as const, + url: 'https://example.com/video-1.mp4', + })) + const client = new VideoGenerationClient({ + threadId: 'video-run-id-stop', + fetcher, + devtools: { hookName: 'useVideoRunIdStop' }, + }) + const unsubscribe = client.subscribe(() => { + if (client.getSnapshot().runId) client.stop() + }) + + await client.generate({ prompt: 'stop early' }) + + expect(fetcher).not.toHaveBeenCalled() + expect(latestSnapshotState().activeRunId).toBeNull() + unsubscribe() + client.dispose() + }) + + it('finishes a video devtools run when loading callback stops it', async () => { + const fetcher = vi.fn(async () => ({ + jobId: 'video-1', + status: 'completed' as const, + url: 'https://example.com/video-1.mp4', + })) + let client: VideoGenerationClient + client = new VideoGenerationClient({ + threadId: 'video-loading-stop', + fetcher, + devtools: { hookName: 'useVideoLoadingStop' }, + onLoadingChange: (isLoading) => { + if (isLoading) client.stop() + }, + }) + + await client.generate({ prompt: 'stop while loading' }) + + expect(fetcher).not.toHaveBeenCalled() + expect(latestSnapshotState().activeRunId).toBeNull() + client.dispose() + }) + + it('finishes a completed video devtools run when success callback stops it', async () => { + let client: VideoGenerationClient + client = new VideoGenerationClient({ + threadId: 'video-success-stop', + fetcher: async () => ({ + jobId: 'video-1', + status: 'completed' as const, + url: 'https://example.com/video-1.mp4', + }), + devtools: { hookName: 'useVideoSuccessStop' }, + onStatusChange: (status) => { + if (status === 'success') client.stop() + }, + }) + + await client.generate({ prompt: 'finish then stop' }) + + expect(latestSnapshotState().activeRunId).toBeNull() + expect(latestGenerationRuns().at(-1)).toEqual( + expect.objectContaining({ + status: 'success', + result: expect.objectContaining({ jobId: 'video-1' }), + }), + ) + client.dispose() + }) + + it('finishes an errored video devtools run when error callback stops it', async () => { + let client: VideoGenerationClient + client = new VideoGenerationClient({ + threadId: 'video-error-stop', + devtools: { hookName: 'useVideoErrorStop' }, + fetcher: async () => { + throw new Error('video failed') + }, + onStatusChange: (status) => { + if (status === 'error') client.stop() + }, + }) + + await client.generate({ prompt: 'fail then stop' }) + + expect(latestSnapshotState().activeRunId).toBeNull() + expect(latestGenerationRuns().at(-1)).toEqual( + expect.objectContaining({ status: 'error', error: 'video failed' }), + ) + client.dispose() + }) + it('includes input, progress, and renderable previews in generation snapshots', async () => { const client = new GenerationClient({ threadId: 'image-hook', diff --git a/packages/ai-client/tests/snapshot-atom.test.ts b/packages/ai-client/tests/snapshot-atom.test.ts new file mode 100644 index 0000000000..dbd0742190 --- /dev/null +++ b/packages/ai-client/tests/snapshot-atom.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it, vi } from 'vitest' +import { createAtom, patchAtom, subscribeAtom } from '../src/snapshot-atom' + +describe('snapshot-atom', () => { + it('freezes object snapshots and keeps identity without a change', () => { + const atom = createAtom({ count: 1 }) + const snapshot = atom.get() + + expect(Object.isFrozen(snapshot)).toBe(true) + expect(atom.get()).toBe(snapshot) + }) + + it('patchAtom skips notify when every field is unchanged', () => { + const atom = createAtom({ count: 1, label: 'a' }) + const listener = vi.fn() + const stop = subscribeAtom(atom, listener) + + patchAtom(atom, { count: 1, label: 'a' }) + expect(listener).not.toHaveBeenCalled() + expect(atom.get()).toEqual({ count: 1, label: 'a' }) + + patchAtom(atom, { count: 2 }) + expect(listener).toHaveBeenCalledTimes(1) + expect(atom.get()).toEqual({ count: 2, label: 'a' }) + stop() + }) + + it('subscribeAtom adapts { unsubscribe } to a plain cleanup', () => { + const atom = createAtom(0) + const listener = vi.fn() + const stop = subscribeAtom(atom, listener) + + atom.set(1) + expect(listener).toHaveBeenCalledTimes(1) + stop() + atom.set(2) + expect(listener).toHaveBeenCalledTimes(1) + }) +}) diff --git a/packages/ai-client/tests/video-generation-client.test.ts b/packages/ai-client/tests/video-generation-client.test.ts index be788e4d57..7e8ad13be0 100644 --- a/packages/ai-client/tests/video-generation-client.test.ts +++ b/packages/ai-client/tests/video-generation-client.test.ts @@ -224,6 +224,7 @@ describe('VideoGenerationClient', () => { ) expect(client.getStatus()).toBe('success') expect(client.getJobId()).toBe('job-123') + expect(client.getSnapshot().runId).toBeNull() }) it('should track video status updates', async () => { @@ -314,6 +315,7 @@ describe('VideoGenerationClient', () => { expect(onError).toHaveBeenCalledWith(expect.any(Error)) expect(client.getStatus()).toBe('error') expect(client.getError()?.message).toBe('Video generation failed') + expect(client.getSnapshot().runId).toBeNull() }) it('should report progress from video:status events', async () => { @@ -483,10 +485,12 @@ describe('VideoGenerationClient', () => { const generatePromise = client.generate({ prompt: 'test' }) expect(client.getIsLoading()).toBe(true) + expect(client.getSnapshot().runId).toEqual(expect.any(String)) client.stop() expect(client.getIsLoading()).toBe(false) expect(client.getStatus()).toBe('idle') + expect(client.getSnapshot().runId).toBeNull() resolvePromise!({ jobId: 'job-1', @@ -495,6 +499,454 @@ describe('VideoGenerationClient', () => { }) await generatePromise }) + + it('should not overwrite a run started by the stopped loading callback', async () => { + const firstVideo = { + jobId: 'job-1', + status: 'completed' as const, + url: 'https://example.com/first.mp4', + } + const secondVideo = { + jobId: 'job-2', + status: 'completed' as const, + url: 'https://example.com/second.mp4', + } + let releaseFirst!: (value: typeof firstVideo) => void + let releaseSecond!: (value: typeof secondVideo) => void + const firstResult = new Promise((resolve) => { + releaseFirst = resolve + }) + const secondResult = new Promise((resolve) => { + releaseSecond = resolve + }) + let secondGenerate: Promise | undefined + let secondRunId: string | null = null + let replaced = false + let client!: VideoGenerationClient + client = new VideoGenerationClient({ + fetcher: async (input) => + input.prompt === 'first' ? firstResult : secondResult, + onLoadingChange: (isLoading) => { + if (isLoading || replaced) return + replaced = true + secondGenerate = client.generate({ prompt: 'second' }) + secondRunId = client.getSnapshot().runId + }, + }) + + const firstGenerate = client.generate({ prompt: 'first' }) + client.stop() + + expect(secondRunId).not.toBeNull() + expect(client.getSnapshot().runId).toBe(secondRunId) + expect(client.getIsLoading()).toBe(true) + expect(client.getStatus()).toBe('generating') + releaseFirst(firstVideo) + releaseSecond(secondVideo) + await Promise.all([firstGenerate, secondGenerate]) + }) + + it('should clear the run id when the success status callback stops', async () => { + let client!: VideoGenerationClient + client = new VideoGenerationClient({ + fetcher: async () => ({ + jobId: 'job-1', + status: 'completed', + url: 'https://example.com/video.mp4', + }), + onStatusChange: (status) => { + if (status === 'success') client.stop() + }, + }) + + await client.generate({ prompt: 'test' }) + + expect(client.getStatus()).toBe('success') + expect(client.getSnapshot().runId).toBeNull() + expect(client.getResumeSnapshot()).toBeUndefined() + }) + + it('should remain stopped when the error callback stops', async () => { + let client!: VideoGenerationClient + client = new VideoGenerationClient({ + fetcher: async () => { + throw new Error('failed') + }, + onErrorChange: (error) => { + if (error) client.stop() + }, + }) + + await client.generate({ prompt: 'test' }) + + expect(client.getStatus()).toBe('idle') + expect(client.getSnapshot().runId).toBeNull() + }) + + it('should stop before transport work when loading callback stops', async () => { + const fetcher = vi.fn(async () => ({ + jobId: 'unexpected', + status: 'completed' as const, + url: 'https://example.com/unexpected.mp4', + })) + let client!: VideoGenerationClient + client = new VideoGenerationClient({ + fetcher, + onLoadingChange: (isLoading) => { + if (isLoading) client.stop() + }, + }) + + await client.generate({ prompt: 'test' }) + + expect(fetcher).not.toHaveBeenCalled() + expect(client.getStatus()).toBe('idle') + expect(client.getSnapshot().runId).toBeNull() + }) + + it('should not let initialization continue after loading callback starts a replacement', async () => { + const secondVideo = { + jobId: 'job-2', + status: 'completed' as const, + url: 'https://example.com/second.mp4', + } + let releaseSecond!: (value: typeof secondVideo) => void + const secondResult = new Promise((resolve) => { + releaseSecond = resolve + }) + const prompts: Array = [] + let client!: VideoGenerationClient + let replaced = false + let secondGenerate: Promise | undefined + client = new VideoGenerationClient({ + fetcher: async (input) => { + if (typeof input.prompt === 'string') prompts.push(input.prompt) + return input.prompt === 'second' + ? secondResult + : { + jobId: 'job-1', + status: 'completed', + url: 'https://example.com/first.mp4', + } + }, + onLoadingChange: (isLoading) => { + if (!isLoading || replaced) return + replaced = true + client.stop() + secondGenerate = client.generate({ prompt: 'second' }) + }, + }) + + await client.generate({ prompt: 'first' }) + + expect(prompts).toEqual(['second']) + expect(client.getStatus()).toBe('generating') + releaseSecond(secondVideo) + await secondGenerate + }) + + it('should not let an old result callback complete a replacement run', async () => { + const secondVideo = { + jobId: 'job-2', + status: 'completed' as const, + url: 'https://example.com/second.mp4', + } + let releaseSecond!: (value: typeof secondVideo) => void + const secondResult = new Promise((resolve) => { + releaseSecond = resolve + }) + let client!: VideoGenerationClient + let secondGenerate: Promise | undefined + let secondRunId: string | null = null + client = new VideoGenerationClient({ + fetcher: async (input) => + input.prompt === 'first' + ? { + jobId: 'job-1', + status: 'completed', + url: 'https://example.com/first.mp4', + } + : secondResult, + onResultChange: (result) => { + if (result?.jobId !== 'job-1') return + client.stop() + secondGenerate = client.generate({ prompt: 'second' }) + secondRunId = client.getSnapshot().runId + }, + }) + + await client.generate({ prompt: 'first' }) + + expect(secondRunId).not.toBeNull() + expect(client.getSnapshot().runId).toBe(secondRunId) + expect(client.getStatus()).toBe('generating') + expect(client.getResumeSnapshot()).toBeUndefined() + releaseSecond(secondVideo) + await secondGenerate + }) + + it('should not let an old error callback overwrite a replacement run', async () => { + const secondVideo = { + jobId: 'job-2', + status: 'completed' as const, + url: 'https://example.com/second.mp4', + } + let releaseSecond!: (value: typeof secondVideo) => void + const secondResult = new Promise((resolve) => { + releaseSecond = resolve + }) + let client!: VideoGenerationClient + let secondGenerate: Promise | undefined + let secondRunId: string | null = null + client = new VideoGenerationClient({ + fetcher: async (input) => { + if (input.prompt === 'first') throw new Error('first failed') + return secondResult + }, + onErrorChange: (error) => { + if (error?.message !== 'first failed') return + client.stop() + secondGenerate = client.generate({ prompt: 'second' }) + secondRunId = client.getSnapshot().runId + }, + }) + + await client.generate({ prompt: 'first' }) + + expect(secondRunId).not.toBeNull() + expect(client.getSnapshot().runId).toBe(secondRunId) + expect(client.getStatus()).toBe('generating') + releaseSecond(secondVideo) + await secondGenerate + }) + + it('should not let an old error status callback overwrite a replacement run', async () => { + const secondVideo = { + jobId: 'job-2', + status: 'completed' as const, + url: 'https://example.com/second.mp4', + } + let releaseSecond!: (value: typeof secondVideo) => void + const secondResult = new Promise((resolve) => { + releaseSecond = resolve + }) + let client!: VideoGenerationClient + let secondGenerate: Promise | undefined + let replaced = false + client = new VideoGenerationClient({ + fetcher: async (input) => { + if (input.prompt === 'first') throw new Error('first failed') + return secondResult + }, + onStatusChange: (status) => { + if (status !== 'error' || replaced) return + replaced = true + client.stop() + secondGenerate = client.generate({ prompt: 'second' }) + }, + }) + + await client.generate({ prompt: 'first' }) + + expect(client.getStatus()).toBe('generating') + expect(client.getError()).toBeUndefined() + releaseSecond(secondVideo) + await secondGenerate + }) + + it('should stop before transport when the run id subscriber stops', async () => { + const fetcher = vi.fn(async () => ({ + jobId: 'unexpected', + status: 'completed' as const, + url: 'https://example.com/unexpected.mp4', + })) + const onLoadingChange = vi.fn() + const client = new VideoGenerationClient({ fetcher, onLoadingChange }) + client.subscribe(() => { + if (client.getSnapshot().runId) client.stop() + }) + + await client.generate({ prompt: 'test' }) + + expect(fetcher).not.toHaveBeenCalled() + expect(onLoadingChange).not.toHaveBeenCalledWith(true) + expect(client.getSnapshot().runId).toBeNull() + }) + + it('should not continue an old run after the run id subscriber starts a replacement', async () => { + const secondVideo = { + jobId: 'job-2', + status: 'completed' as const, + url: 'https://example.com/second.mp4', + } + let releaseSecond!: (value: typeof secondVideo) => void + const secondResult = new Promise((resolve) => { + releaseSecond = resolve + }) + const prompts: Array = [] + let secondGenerate: Promise | undefined + let replaced = false + const client = new VideoGenerationClient({ + fetcher: async (input) => { + if (typeof input.prompt === 'string') prompts.push(input.prompt) + return input.prompt === 'second' + ? secondResult + : { + jobId: 'job-1', + status: 'completed', + url: 'https://example.com/first.mp4', + } + }, + }) + client.subscribe(() => { + if (!client.getSnapshot().runId || replaced) return + replaced = true + client.stop() + secondGenerate = client.generate({ prompt: 'second' }) + }) + + await client.generate({ prompt: 'first' }) + + expect(prompts).toEqual(['second']) + expect(client.getStatus()).toBe('generating') + releaseSecond(secondVideo) + await secondGenerate + }) + + it('should not call the loading callback after its snapshot subscriber stops', async () => { + const fetcher = vi.fn(async () => ({ + jobId: 'unexpected', + status: 'completed' as const, + url: 'https://example.com/unexpected.mp4', + })) + const onLoadingChange = vi.fn() + const client = new VideoGenerationClient({ fetcher, onLoadingChange }) + client.subscribe(() => { + if (client.getSnapshot().isLoading) client.stop() + }) + + await client.generate({ prompt: 'test' }) + + expect(fetcher).not.toHaveBeenCalled() + expect(onLoadingChange).not.toHaveBeenCalledWith(true) + }) + + it('should not call the old status callback after its subscriber starts a replacement', async () => { + const secondVideo = { + jobId: 'job-2', + status: 'completed' as const, + url: 'https://example.com/second.mp4', + } + let releaseSecond!: (value: typeof secondVideo) => void + const secondResult = new Promise((resolve) => { + releaseSecond = resolve + }) + const generatingStatuses: Array = [] + let secondGenerate: Promise | undefined + let replaced = false + const client = new VideoGenerationClient({ + fetcher: async (input) => + input.prompt === 'second' + ? secondResult + : { + jobId: 'job-1', + status: 'completed', + url: 'https://example.com/first.mp4', + }, + onStatusChange: (status) => { + if (status === 'generating') generatingStatuses.push(status) + }, + }) + client.subscribe(() => { + if (client.getSnapshot().status !== 'generating' || replaced) return + replaced = true + client.stop() + secondGenerate = client.generate({ prompt: 'second' }) + }) + + await client.generate({ prompt: 'first' }) + + expect(generatingStatuses).toEqual(['generating']) + expect(client.getStatus()).toBe('generating') + releaseSecond(secondVideo) + await secondGenerate + }) + + it('should not call the old result callback after its subscriber starts a replacement', async () => { + const secondVideo = { + jobId: 'job-2', + status: 'completed' as const, + url: 'https://example.com/second.mp4', + } + let releaseSecond!: (value: typeof secondVideo) => void + const secondResult = new Promise((resolve) => { + releaseSecond = resolve + }) + const onResultChange = vi.fn() + let secondGenerate: Promise | undefined + let replaced = false + const client = new VideoGenerationClient({ + fetcher: async (input) => + input.prompt === 'second' + ? secondResult + : { + jobId: 'job-1', + status: 'completed', + url: 'https://example.com/first.mp4', + }, + onResultChange, + }) + client.subscribe(() => { + if (client.getResult()?.jobId !== 'job-1' || replaced) return + replaced = true + client.stop() + secondGenerate = client.generate({ prompt: 'second' }) + }) + + await client.generate({ prompt: 'first' }) + + expect(onResultChange).not.toHaveBeenCalledWith( + expect.objectContaining({ jobId: 'job-1' }), + ) + expect(client.getStatus()).toBe('generating') + releaseSecond(secondVideo) + await secondGenerate + }) + + it('should not call the old error callback after its subscriber starts a replacement', async () => { + const secondVideo = { + jobId: 'job-2', + status: 'completed' as const, + url: 'https://example.com/second.mp4', + } + let releaseSecond!: (value: typeof secondVideo) => void + const secondResult = new Promise((resolve) => { + releaseSecond = resolve + }) + const onErrorChange = vi.fn() + let secondGenerate: Promise | undefined + let replaced = false + const client = new VideoGenerationClient({ + fetcher: async (input) => { + if (input.prompt === 'first') throw new Error('first failed') + return secondResult + }, + onErrorChange, + }) + client.subscribe(() => { + if (client.getError()?.message !== 'first failed' || replaced) return + replaced = true + client.stop() + secondGenerate = client.generate({ prompt: 'second' }) + }) + + await client.generate({ prompt: 'first' }) + + expect(onErrorChange).not.toHaveBeenCalledWith(expect.any(Error)) + expect(client.getStatus()).toBe('generating') + releaseSecond(secondVideo) + await secondGenerate + }) }) describe('reset()', () => { @@ -561,6 +1013,112 @@ describe('VideoGenerationClient', () => { expect(client.getVideoStatus()).toBeNull() expect(client.getError()).toBeUndefined() expect(client.getStatus()).toBe('idle') + expect(client.getSnapshot().runId).toBeNull() + }) + + it('should not overwrite a run started by the reset loading callback', async () => { + const seedVideo = { + jobId: 'seed', + status: 'completed' as const, + url: 'https://example.com/seed.mp4', + } + const firstVideo = { + jobId: 'job-1', + status: 'completed' as const, + url: 'https://example.com/first.mp4', + } + const secondVideo = { + jobId: 'job-2', + status: 'completed' as const, + url: 'https://example.com/second.mp4', + } + let releaseFirst!: (value: typeof firstVideo) => void + let releaseSecond!: (value: typeof secondVideo) => void + const firstResult = new Promise((resolve) => { + releaseFirst = resolve + }) + const secondResult = new Promise((resolve) => { + releaseSecond = resolve + }) + let replaceOnStop = false + let secondGenerate: Promise | undefined + let secondRunId: string | null = null + let client!: VideoGenerationClient + client = new VideoGenerationClient({ + fetcher: async (input) => { + if (input.prompt === 'seed') return seedVideo + return input.prompt === 'first' ? firstResult : secondResult + }, + onLoadingChange: (isLoading) => { + if (isLoading || !replaceOnStop) return + replaceOnStop = false + secondGenerate = client.generate({ prompt: 'second' }) + secondRunId = client.getSnapshot().runId + }, + }) + await client.generate({ prompt: 'seed' }) + const firstGenerate = client.generate({ prompt: 'first' }) + replaceOnStop = true + + client.reset() + + expect(secondRunId).not.toBeNull() + expect(client.getSnapshot().runId).toBe(secondRunId) + expect(client.getStatus()).toBe('generating') + expect(client.getIsLoading()).toBe(true) + expect(client.getResult()).toEqual(seedVideo) + releaseFirst(firstVideo) + releaseSecond(secondVideo) + await Promise.all([firstGenerate, secondGenerate]) + }) + }) + + describe('dispose()', () => { + it('should not overwrite a run started by the dispose loading callback', async () => { + const firstVideo = { + jobId: 'job-1', + status: 'completed' as const, + url: 'https://example.com/first.mp4', + } + const secondVideo = { + jobId: 'job-2', + status: 'completed' as const, + url: 'https://example.com/second.mp4', + } + let releaseFirst!: (value: typeof firstVideo) => void + let releaseSecond!: (value: typeof secondVideo) => void + const first = new Promise((resolve) => { + releaseFirst = resolve + }) + const second = new Promise((resolve) => { + releaseSecond = resolve + }) + let replaceOnDispose = false + let secondGenerate: Promise | undefined + let secondRunId: string | null = null + let client!: VideoGenerationClient + client = new VideoGenerationClient({ + fetcher: (input) => (input.prompt === 'first' ? first : second), + onLoadingChange: (isLoading) => { + if (isLoading || !replaceOnDispose) return + replaceOnDispose = false + client.mountDevtools() + secondGenerate = client.generate({ prompt: 'second' }) + secondRunId = client.getSnapshot().runId + }, + }) + const firstGenerate = client.generate({ prompt: 'first' }) + replaceOnDispose = true + + client.dispose() + + expect(secondRunId).not.toBeNull() + expect(client.getSnapshot().runId).toBe(secondRunId) + expect(client.getStatus()).toBe('generating') + expect(client.getIsLoading()).toBe(true) + releaseFirst(firstVideo) + releaseSecond(secondVideo) + await Promise.all([firstGenerate, secondGenerate]) }) }) @@ -652,6 +1210,75 @@ describe('VideoGenerationClient', () => { expect(client.getResult()).toBeNull() expect(client.getStatus()).toBe('idle') }) + + it('should not publish a run id after onChunk stops the run', async () => { + const connection: ConnectConnectionAdapter = { + async *connect() { + yield { + type: EventType.RUN_STARTED as const, + runId: 'run-1', + threadId: 'thread-1', + timestamp: Date.now(), + } + }, + } + let client!: VideoGenerationClient + client = new VideoGenerationClient({ + connection, + onChunk: (chunk) => { + if (chunk.type === EventType.RUN_STARTED) client.stop() + }, + }) + + await client.generate({ prompt: 'test' }) + + expect(client.getSnapshot().runId).toBeNull() + expect(client.getStatus()).toBe('idle') + }) + + it('should not let an old onChunk continuation overwrite a newer run', async () => { + let connectionCount = 0 + let releaseSecond!: () => void + const secondBlocked = new Promise((resolve) => { + releaseSecond = resolve + }) + const connection: ConnectConnectionAdapter = { + async *connect() { + connectionCount++ + if (connectionCount === 1) { + yield { + type: EventType.RUN_STARTED as const, + runId: 'run-1', + threadId: 'thread-1', + timestamp: Date.now(), + } + return + } + await secondBlocked + }, + } + let client!: VideoGenerationClient + let secondGenerate: Promise | undefined + let secondLocalRunId: string | null = null + client = new VideoGenerationClient({ + connection, + onChunk: (chunk) => { + if (chunk.type !== EventType.RUN_STARTED) return + client.stop() + secondGenerate = client.generate({ prompt: 'second' }) + secondLocalRunId = client.getSnapshot().runId + }, + }) + + await client.generate({ prompt: 'first' }) + + expect(secondLocalRunId).not.toBeNull() + expect(client.getSnapshot().runId).toBe(secondLocalRunId) + expect(client.getIsLoading()).toBe(true) + client.stop() + releaseSecond() + await secondGenerate + }) }) describe('error wrapping', () => { diff --git a/packages/ai-octane/src/use-chat.tsrx b/packages/ai-octane/src/use-chat.tsrx index f95f7f9fb8..3a11537649 100644 --- a/packages/ai-octane/src/use-chat.tsrx +++ b/packages/ai-octane/src/use-chat.tsrx @@ -11,11 +11,8 @@ import type { StreamChunk, } from '@tanstack/ai/client' import type { - ChatClientState, ResolvableChatInterrupt, - ChatInterruptState, ChatResumeState, - ConnectionStatus, InferredClientContext, QueuedMessage, SendMessageOptions, @@ -30,9 +27,6 @@ import type { UseChatReturn, } from './types' -const EMPTY_INTERRUPTS = Object.freeze([]) -const EMPTY_INTERRUPT_ERRORS = Object.freeze([]) - export function useChat< const TTools extends ReadonlyArray = any, TSchema extends SchemaInput | undefined = undefined, @@ -49,53 +43,13 @@ export function useChat< const hookId = useId() const clientId = options.threadId ?? hookId - const [messages, setMessages] = useState>>( - options.initialMessages || [], - ) - const [isLoading, setIsLoading] = useState(false) - const [error, setError] = useState(undefined) - const [status, setStatus] = useState('ready') - const [isSubscribed, setIsSubscribed] = useState(false) - const [connectionStatus, setConnectionStatus] = - useState('disconnected') - const [sessionGenerating, setSessionGenerating] = useState(false) - const [queue, setQueue] = useState>([]) - const [runId, setRunId] = useState(null) - const [interruptState, setInterruptState] = useState< - ChatInterruptState - >(() => ({ - interrupts: EMPTY_INTERRUPTS, - pendingInterrupts: EMPTY_INTERRUPTS, - interruptErrors: EMPTY_INTERRUPT_ERRORS, - resuming: false, - })) - - type Partial = DeepPartial>> - type Final = InferSchemaType> - - // Track the rendered snapshot so a replacement client only synchronizes - // state when its messages actually differ. - const messagesRef = useRef>>( - options.initialMessages || [], - ) const subscribedRef = useRef(false) const activeClientRef = useRef(null) const seenClientRef = useRef(null) - - // Update ref synchronously during render so it's always current when useMemo runs. - messagesRef.current = messages - - // Track current options in a ref to avoid recreating client when options change const optionsRef = useRef>(options) optionsRef.current = options - const syncResumeState = useCallback((target: ChatClient | null) => { - if (!target) return - setRunId(target.getCurrentRunId()) - setInterruptState(target.getInterruptState()) - }, []) - // Create ChatClient instance with callbacks to sync state const { client, initialization } = useMemo(() => { const messagesToUse = options.initialMessages || [] @@ -203,69 +157,11 @@ export function useChat< ...(options.streamProcessor !== undefined && { streamProcessor: options.streamProcessor, }), - onMessagesChange: (newMessages: Array>) => { - runOrQueueForActiveInstance(() => { - setMessages(newMessages) - }) - }, - onLoadingChange: (newIsLoading: boolean) => { - runOrQueueForActiveInstance(() => { - const currentInstance = getActiveInstance() - if (!currentInstance) return - setIsLoading(newIsLoading) - syncResumeState(currentInstance) - }) - }, - onErrorChange: (newError: Error | undefined) => { - runOrQueueForActiveInstance(() => { - setError(newError) - }) - }, - onStatusChange: (status: ChatClientState) => { - runOrQueueForActiveInstance(() => { - setStatus(status) - }) - }, - onSubscriptionChange: (nextIsSubscribed: boolean) => { - runOrQueueForActiveInstance(() => { - setIsSubscribed(nextIsSubscribed) - }) - }, - onConnectionStatusChange: (nextStatus: ConnectionStatus) => { - runOrQueueForActiveInstance(() => { - setConnectionStatus(nextStatus) - }) - }, - onSessionGeneratingChange: (isGenerating: boolean) => { - runOrQueueForActiveInstance(() => { - setSessionGenerating(isGenerating) - }) - }, ...(optionsRef.current.queue !== undefined && { queue: optionsRef.current.queue, }), - onQueueChange: (nextQueue: Array) => { - runOrQueueForActiveInstance(() => { - setQueue(nextQueue) - }) - }, - onRunIdChange: (nextRunId) => { - runOrQueueForActiveInstance(() => { - setRunId(nextRunId) - }) - }, - onResumeStateChange: (_nextResumeState, nextPendingInterrupts) => { - runOrQueueForActiveInstance(() => { - setInterruptState((current) => ({ - ...current, - interrupts: nextPendingInterrupts, - pendingInterrupts: nextPendingInterrupts, - })) - }) - }, onInterruptStateChange: (nextInterruptState, context) => { runOrQueueForActiveInstance(() => { - setInterruptState(nextInterruptState) optionsRef.current.onInterruptStateChange?.( nextInterruptState, context, @@ -275,7 +171,34 @@ export function useChat< }) instanceHolder.current = instance return { client: instance, initialization: initializationState } - }, [clientId, syncResumeState]) + }, [clientId]) + + type Partial = DeepPartial>> + type Final = InferSchemaType> + + // Constructor hydration (sync store / resume snapshot) must be on the first + // paint. `useEffect` runs after paint, so seed from the snapshot here. + const firstPaint = client.getSnapshot() + const [messages, setMessages] = useState>>( + firstPaint.messages as Array>, + ) + const [isLoading, setIsLoading] = useState(firstPaint.isLoading) + const [error, setError] = useState(firstPaint.error) + const [status, setStatus] = useState(firstPaint.status) + const [isSubscribed, setIsSubscribed] = useState(firstPaint.isSubscribed) + const [connectionStatus, setConnectionStatus] = useState( + firstPaint.connectionStatus, + ) + const [sessionGenerating, setSessionGenerating] = useState( + firstPaint.sessionGenerating, + ) + const [queue, setQueue] = useState>( + firstPaint.queue as Array, + ) + const [runId, setRunId] = useState(firstPaint.runId) + const [interruptState, setInterruptState] = useState( + firstPaint.interruptState, + ) useEffect(() => { activeClientRef.current = client @@ -296,10 +219,21 @@ export function useChat< }, [client, initialization]) useEffect(() => { - const clientMessages = client.getMessages() - if (clientMessages !== messagesRef.current) { - setMessages(clientMessages) + const applySnapshot = () => { + const next = client.getSnapshot() + setMessages(next.messages as Array>) + setIsLoading(next.isLoading) + setError(next.error) + setStatus(next.status) + setIsSubscribed(next.isSubscribed) + setConnectionStatus(next.connectionStatus) + setSessionGenerating(next.sessionGenerating) + setQueue(next.queue as Array) + setRunId(next.runId) + setInterruptState(next.interruptState) } + applySnapshot() + return client.subscribeSnapshot(applySnapshot) }, [client]) // OCTANE DIVERGENCE: upstream captures the initial transport. ChatClient @@ -369,7 +303,6 @@ export function useChat< useEffect(() => { client.attach() client.mountDevtools() - syncResumeState(client) return () => { client.detach() if (optionsRef.current.live) { @@ -382,20 +315,16 @@ export function useChat< activeClientRef.current = null } } - }, [client, syncResumeState]) + }, [client]) const sendMessage = useCallback( async ( content: string | MultimodalContent, sendOptions?: SendMessageOptions, ) => { - try { - await client.sendMessage(content, undefined, sendOptions) - } finally { - syncResumeState(client) - } + await client.sendMessage(content, undefined, sendOptions) }, - [client, syncResumeState], + [client], ) const cancelQueued = useCallback( @@ -407,22 +336,14 @@ export function useChat< const append = useCallback( async (message: ModelMessage | UIMessage) => { - try { - await client.append(message) - } finally { - syncResumeState(client) - } + await client.append(message) }, - [client, syncResumeState], + [client], ) const reload = useCallback(async () => { - try { - await client.reload() - } finally { - syncResumeState(client) - } - }, [client, syncResumeState]) + await client.reload() + }, [client]) const stop = useCallback(() => { client.stop() @@ -430,8 +351,7 @@ export function useChat< const clear = useCallback(() => { client.clear() - syncResumeState(client) - }, [client, syncResumeState]) + }, [client]) const setMessagesManually = useCallback( (newMessages: Array>) => { @@ -456,21 +376,15 @@ export function useChat< const addToolApprovalResponse = useCallback( async (response: { id: string; approved: boolean }) => { await client.addToolApprovalResponse(response) - syncResumeState(client) }, - [client, syncResumeState], + [client], ) const resumeInterrupts = useCallback( async (resumeItems: Array, state?: ChatResumeState) => { - const result = await client.resumeInterrupts( - resumeItems, - state ?? undefined, - ) - syncResumeState(client) - return result + return await client.resumeInterrupts(resumeItems, state ?? undefined) }, - [client, syncResumeState], + [client], ) const resolveInterrupts = useCallback( diff --git a/packages/ai-octane/src/use-generate-video.tsrx b/packages/ai-octane/src/use-generate-video.tsrx index 5772a97326..c18dddc7bf 100644 --- a/packages/ai-octane/src/use-generate-video.tsrx +++ b/packages/ai-octane/src/use-generate-video.tsrx @@ -172,12 +172,6 @@ export function useGenerateVideo( onStatusUpdate: (s: VideoStatusInfo) => { optionsRef.current.onStatusUpdate?.(s) }, - onResultChange: setResult, - onLoadingChange: setIsLoading, - onErrorChange: setError, - onStatusChange: setStatus, - onJobIdChange: setJobId, - onVideoStatusChange: setVideoStatus, } if (opts.connection) { @@ -199,6 +193,20 @@ export function useGenerateVideo( ) }, [clientId]) + useEffect(() => { + const applySnapshot = () => { + const next = client.getSnapshot() + setResult(next.result) + setIsLoading(next.isLoading) + setError(next.error) + setStatus(next.status) + setJobId(next.jobId) + setVideoStatus(next.videoStatus) + } + applySnapshot() + return client.subscribe(applySnapshot) + }, [client]) + // Sync body changes without recreating client useEffect(() => { // Conditional spread: target uses strict-optional `body?: T`. diff --git a/packages/ai-octane/src/use-generation.tsrx b/packages/ai-octane/src/use-generation.tsrx index e3ca19c084..23eb3b0489 100644 --- a/packages/ai-octane/src/use-generation.tsrx +++ b/packages/ai-octane/src/use-generation.tsrx @@ -160,10 +160,6 @@ export function useGeneration< onChunk: (c: StreamChunk) => { optionsRef.current.onChunk?.(c) }, - onResultChange: setResult, - onLoadingChange: setIsLoading, - onErrorChange: setError, - onStatusChange: setStatus, } if (opts.connection) { @@ -185,6 +181,18 @@ export function useGeneration< ) }, [clientId]) + useEffect(() => { + const applySnapshot = () => { + const next = client.getSnapshot() + setResult(next.result) + setIsLoading(next.isLoading) + setError(next.error) + setStatus(next.status) + } + applySnapshot() + return client.subscribe(applySnapshot) + }, [client]) + // Sync body changes without recreating client useEffect(() => { // Conditional spread: target uses strict-optional `body?: T`. diff --git a/packages/ai-octane/src/use-realtime-chat.tsrx b/packages/ai-octane/src/use-realtime-chat.tsrx index b486b8cc12..4ed30a20ee 100644 --- a/packages/ai-octane/src/use-realtime-chat.tsrx +++ b/packages/ai-octane/src/use-realtime-chat.tsrx @@ -124,17 +124,14 @@ export function useRealtimeChat( semanticEagerness: initial.semanticEagerness, }), onStatusChange: (newStatus) => { - setStatus(newStatus) // OCTANE DIVERGENCE: upstream declares this callback but does not // forward status changes beyond its own hook state. optionsRef.current.onStatusChange?.(newStatus) }, onModeChange: (newMode) => { - setMode(newMode) optionsRef.current.onModeChange?.(newMode) }, onMessage: (message) => { - setMessages((prev) => [...prev, message]) optionsRef.current.onMessage?.(message) }, onUsage: (usage) => { @@ -144,31 +141,36 @@ export function useRealtimeChat( optionsRef.current.onGoAway?.(timeLeft) }, onError: (err) => { - setError(err) optionsRef.current.onError?.(err) }, onConnect: () => { - setError(null) optionsRef.current.onConnect?.() }, onDisconnect: () => { optionsRef.current.onDisconnect?.() }, onInterrupted: () => { - setPendingAssistantTranscript(null) optionsRef.current.onInterrupted?.() }, }) - - // Subscribe to state changes for transcripts - clientRef.current.onStateChange((state) => { - setPendingUserTranscript(state.pendingUserTranscript) - setPendingAssistantTranscript(state.pendingAssistantTranscript) - }) } const client = clientRef.current + useEffect(() => { + const applySnapshot = () => { + const next = client.getSnapshot() + setStatus(next.status) + setMode(next.mode) + setMessages(next.messages) + setPendingUserTranscript(next.pendingUserTranscript) + setPendingAssistantTranscript(next.pendingAssistantTranscript) + setError(next.error) + } + applySnapshot() + return client.subscribe(applySnapshot) + }, [client]) + // Audio level animation loop useEffect(() => { function updateLevels() { @@ -200,10 +202,6 @@ export function useRealtimeChat( // Connection methods const connect = useCallback(async () => { - setError(null) - setMessages([]) - setPendingUserTranscript(null) - setPendingAssistantTranscript(null) await client.connect() }, [client]) diff --git a/packages/ai-octane/tests/conformance/use-chat.test.ts b/packages/ai-octane/tests/conformance/use-chat.test.ts index ccc5147141..b6898e10a4 100644 --- a/packages/ai-octane/tests/conformance/use-chat.test.ts +++ b/packages/ai-octane/tests/conformance/use-chat.test.ts @@ -215,15 +215,19 @@ describe('useChat', () => { removeItem: vi.fn(), } - const { result } = renderUseChat({ - connection: adapter, - threadId: 'persisted-chat', - persistence, + let firstMessages: ReadonlyArray | undefined + const { result } = renderHook(() => { + const chat = useChat({ + connection: adapter, + threadId: 'persisted-chat', + persistence, + }) + firstMessages ??= chat.messages + return chat }) - await waitFor(() => { - expect(result.current.messages).toEqual(persistedMessages) - }) + expect(firstMessages).toEqual(persistedMessages) + expect(result.current.messages).toEqual(persistedMessages) expect(persistence.getItem).toHaveBeenCalledWith('persisted-chat') }) diff --git a/packages/ai-preact/src/use-chat.ts b/packages/ai-preact/src/use-chat.ts index f8376da1ee..7d5492e343 100644 --- a/packages/ai-preact/src/use-chat.ts +++ b/packages/ai-preact/src/use-chat.ts @@ -1,19 +1,10 @@ import { ChatClient } from '@tanstack/ai-client' import { createChatDevtoolsBridge } from '@tanstack/ai-client/devtools' -import { - useCallback, - useEffect, - useId, - useMemo, - useRef, - useState, -} from 'preact/hooks' +import { useSyncExternalStore } from 'preact/compat' +import { useCallback, useEffect, useId, useMemo, useRef } from 'preact/hooks' import type { - ChatClientState, ResolvableChatInterrupt, - ChatInterruptState, ChatResumeState, - ConnectionStatus, InferredClientContext, QueuedMessage, SendMessageOptions, @@ -32,9 +23,6 @@ import type { UseChatReturn, } from './types' -const EMPTY_INTERRUPTS = Object.freeze([]) -const EMPTY_INTERRUPT_ERRORS = Object.freeze([]) - export function useChat< const TTools extends ReadonlyArray = any, TContext = InferredClientContext, @@ -50,32 +38,6 @@ export function useChat< const hookId = useId() const clientId = options.threadId ?? hookId - const [messages, setMessages] = useState>>( - options.initialMessages || [], - ) - const [isLoading, setIsLoading] = useState(false) - const [error, setError] = useState(undefined) - const [status, setStatus] = useState('ready') - const [isSubscribed, setIsSubscribed] = useState(false) - const [connectionStatus, setConnectionStatus] = - useState('disconnected') - const [sessionGenerating, setSessionGenerating] = useState(false) - const [queue, setQueue] = useState>([]) - const [runId, setRunId] = useState(null) - const [interruptState, setInterruptState] = useState< - ChatInterruptState - >(() => ({ - interrupts: EMPTY_INTERRUPTS, - pendingInterrupts: EMPTY_INTERRUPTS, - interruptErrors: EMPTY_INTERRUPT_ERRORS, - resuming: false, - })) - - // Track current messages in a ref to preserve them when client is recreated - const messagesRef = useRef>>( - options.initialMessages || [], - ) - const isFirstMountRef = useRef(true) const activeClientRef = useRef(null) const cleanupInvalidationRef = useRef | null>( null, @@ -89,19 +51,8 @@ export function useChat< optionsRef.current = options - const syncResumeState = useCallback((target: ChatClient | null) => { - if (!target) return - setRunId(target.getCurrentRunId()) - setInterruptState(target.getInterruptState()) - }, []) - - useEffect(() => { - messagesRef.current = messages - }, [messages]) - const { client, initialization } = useMemo(() => { const messagesToUse = options.initialMessages || [] - isFirstMountRef.current = false // Build options with conditional spreads for fields whose source // type is `T | undefined` but the ChatClient target uses a strict @@ -209,69 +160,11 @@ export function useChat< ...(options.streamProcessor !== undefined && { streamProcessor: options.streamProcessor, }), - onMessagesChange: (newMessages: Array>) => { - runOrQueueForActiveInstance(() => { - setMessages(newMessages) - }) - }, - onLoadingChange: (newIsLoading: boolean) => { - runOrQueueForActiveInstance(() => { - const currentInstance = getActiveInstance() - if (!currentInstance) return - setIsLoading(newIsLoading) - syncResumeState(currentInstance) - }) - }, - onStatusChange: (newStatus: ChatClientState) => { - runOrQueueForActiveInstance(() => { - setStatus(newStatus) - }) - }, - onErrorChange: (newError: Error | undefined) => { - runOrQueueForActiveInstance(() => { - setError(newError) - }) - }, - onSubscriptionChange: (nextIsSubscribed: boolean) => { - runOrQueueForActiveInstance(() => { - setIsSubscribed(nextIsSubscribed) - }) - }, - onConnectionStatusChange: (nextStatus: ConnectionStatus) => { - runOrQueueForActiveInstance(() => { - setConnectionStatus(nextStatus) - }) - }, - onSessionGeneratingChange: (isGenerating: boolean) => { - runOrQueueForActiveInstance(() => { - setSessionGenerating(isGenerating) - }) - }, ...(optionsRef.current.queue !== undefined && { queue: optionsRef.current.queue, }), - onQueueChange: (nextQueue: Array) => { - runOrQueueForActiveInstance(() => { - setQueue(nextQueue) - }) - }, - onRunIdChange: (nextRunId) => { - runOrQueueForActiveInstance(() => { - setRunId(nextRunId) - }) - }, - onResumeStateChange: (_nextResumeState, nextPendingInterrupts) => { - runOrQueueForActiveInstance(() => { - setInterruptState((current) => ({ - ...current, - interrupts: nextPendingInterrupts, - pendingInterrupts: nextPendingInterrupts, - })) - }) - }, onInterruptStateChange: (nextInterruptState, context) => { runOrQueueForActiveInstance(() => { - setInterruptState(nextInterruptState) optionsRef.current.onInterruptStateChange?.( nextInterruptState, context, @@ -281,7 +174,7 @@ export function useChat< }) instanceHolder.current = instance return { client: instance, initialization: initializationState } - }, [clientId, syncResumeState]) + }, [clientId]) useEffect(() => { activeClientRef.current = client @@ -301,12 +194,10 @@ export function useChat< } }, [client, initialization]) - useEffect(() => { - const clientMessages = client.getMessages() - if (clientMessages !== messagesRef.current) { - setMessages(clientMessages) - } - }, [client]) + const snapshot = useSyncExternalStore( + client.subscribeSnapshot, + client.getSnapshot, + ) // Sync body / forwardedProps changes to the client. // Both populate the same wire payload; `forwardedProps` is preferred @@ -368,10 +259,6 @@ export function useChat< cleanupInvalidationRef.current = null } client.mountDevtools() - // Delivery-durability resume is transparent: the resumable SSE connection - // adapter reattaches via the browser's native Last-Event-ID on reconnect. - // We only seed interrupt (state) resume from the client here. - syncResumeState(client) return () => { cleanupInvalidationRef.current = setTimeout(() => { @@ -400,7 +287,7 @@ export function useChat< } cleanupDisposalRef.current = disposal } - }, [client, syncResumeState]) + }, [client]) // All callback options are read through optionsRef at call time, so fresh // closures from each render are picked up without recreating the client. @@ -409,40 +296,28 @@ export function useChat< content: string | MultimodalContent, sendOptions?: SendMessageOptions, ) => { - try { - await client.sendMessage(content, undefined, sendOptions) - } finally { - syncResumeState(client) - } + await client.sendMessage(content, undefined, sendOptions) }, - [client, syncResumeState], + [client], ) const cancelQueued = useCallback( (id: string) => { client.cancelQueued(id) }, - [client, syncResumeState], + [client], ) const append = useCallback( async (message: ModelMessage | UIMessage) => { - try { - await client.append(message) - } finally { - syncResumeState(client) - } + await client.append(message) }, - [client, syncResumeState], + [client], ) const reload = useCallback(async () => { - try { - await client.reload() - } finally { - syncResumeState(client) - } - }, [client, syncResumeState]) + await client.reload() + }, [client]) const stop = useCallback(() => { client.stop() @@ -450,8 +325,7 @@ export function useChat< const clear = useCallback(() => { client.clear() - syncResumeState(client) - }, [client, syncResumeState]) + }, [client]) const setMessagesManually = useCallback( (newMessages: Array>) => { @@ -476,18 +350,15 @@ export function useChat< const addToolApprovalResponse = useCallback( async (response: { id: string; approved: boolean }) => { await client.addToolApprovalResponse(response) - syncResumeState(client) }, - [client, syncResumeState], + [client], ) const resumeInterrupts = useCallback( async (resumeItems: Array, state?: ChatResumeState) => { - const result = await client.resumeInterrupts(resumeItems, state) - syncResumeState(client) - return result + return await client.resumeInterrupts(resumeItems, state) }, - [client, syncResumeState], + [client], ) const resolveInterrupts = useCallback( @@ -521,31 +392,31 @@ export function useChat< [client], ) - const renderedMessages = client.getMessages() + const renderedMessages = snapshot.messages return { - messages: renderedMessages, + messages: renderedMessages as Array>, sendMessage, append, reload, stop, - isLoading, - error, - status, - isSubscribed, - connectionStatus, - sessionGenerating, + isLoading: snapshot.isLoading, + error: snapshot.error, + status: snapshot.status, + isSubscribed: snapshot.isSubscribed, + connectionStatus: snapshot.connectionStatus, + sessionGenerating: snapshot.sessionGenerating, setMessages: setMessagesManually, clear, addToolResult, addToolApprovalResponse, - queue, + queue: snapshot.queue as Array, cancelQueued, - runId, - interrupts: interruptState.interrupts, - pendingInterrupts: interruptState.pendingInterrupts, - interruptErrors: interruptState.interruptErrors, - resuming: interruptState.resuming, + runId: snapshot.runId, + interrupts: snapshot.interruptState.interrupts, + pendingInterrupts: snapshot.interruptState.pendingInterrupts, + interruptErrors: snapshot.interruptState.interruptErrors, + resuming: snapshot.interruptState.resuming, resolveInterrupts, cancelInterrupts, retryInterrupts, diff --git a/packages/ai-preact/tests/use-chat.test.ts b/packages/ai-preact/tests/use-chat.test.ts index 0d3ebea95d..949d5c665b 100644 --- a/packages/ai-preact/tests/use-chat.test.ts +++ b/packages/ai-preact/tests/use-chat.test.ts @@ -195,6 +195,43 @@ describe('useChat', () => { expect(result.current.messages).toEqual(initialMessages) }) + it('throws on in-place messages.push and accepts setMessages', () => { + const { result } = renderUseChat({ + connection: createMockConnectionAdapter(), + initialMessages: [ + { + id: 'msg-1', + role: 'user', + parts: [{ type: 'text', content: 'Hello' }], + createdAt: new Date(), + }, + ], + }) + + expect(Object.isFrozen(result.current.messages)).toBe(true) + expect(() => { + Array.prototype.push.call(result.current.messages, { + id: 'x', + role: 'user', + parts: [{ type: 'text', content: 'nope' }], + createdAt: new Date(), + }) + }).toThrow(TypeError) + + act(() => { + result.current.setMessages([ + ...result.current.messages, + { + id: 'ok', + role: 'user', + parts: [{ type: 'text', content: 'ok' }], + createdAt: new Date(), + }, + ]) + }) + expect(result.current.messages).toHaveLength(2) + }) + it('should initialize with persisted messages', async () => { const adapter = createMockConnectionAdapter() const persistedMessages: Array = [ diff --git a/packages/ai-react/src/use-chat.ts b/packages/ai-react/src/use-chat.ts index e5edb97d50..2fa6568bcc 100644 --- a/packages/ai-react/src/use-chat.ts +++ b/packages/ai-react/src/use-chat.ts @@ -1,6 +1,13 @@ import { ChatClient } from '@tanstack/ai-client' import { createChatDevtoolsBridge } from '@tanstack/ai-client/devtools' -import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react' +import { + useCallback, + useEffect, + useId, + useMemo, + useRef, + useSyncExternalStore, +} from 'react' import type { AnyClientTool, InferSchemaType, @@ -11,13 +18,10 @@ import type { StreamChunk, } from '@tanstack/ai/client' import type { - ChatClientState, + ChatClientPersistence, ResolvableChatInterrupt, - ChatInterruptState, ChatResumeState, - ConnectionStatus, InferredClientContext, - QueuedMessage, SendMessageOptions, StructuredOutputPart, } from '@tanstack/ai-client' @@ -30,9 +34,6 @@ import type { UseChatReturn, } from './types' -const EMPTY_INTERRUPTS = Object.freeze([]) -const EMPTY_INTERRUPT_ERRORS = Object.freeze([]) - export function useChat< const TTools extends ReadonlyArray = any, TSchema extends SchemaInput | undefined = undefined, @@ -49,35 +50,9 @@ export function useChat< const hookId = useId() const clientId = options.threadId ?? hookId - const [messages, setMessages] = useState>>( - options.initialMessages || [], - ) - const [isLoading, setIsLoading] = useState(false) - const [error, setError] = useState(undefined) - const [status, setStatus] = useState('ready') - const [isSubscribed, setIsSubscribed] = useState(false) - const [connectionStatus, setConnectionStatus] = - useState('disconnected') - const [sessionGenerating, setSessionGenerating] = useState(false) - const [queue, setQueue] = useState>([]) - const [runId, setRunId] = useState(null) - const [interruptState, setInterruptState] = useState< - ChatInterruptState - >(() => ({ - interrupts: EMPTY_INTERRUPTS, - pendingInterrupts: EMPTY_INTERRUPTS, - interruptErrors: EMPTY_INTERRUPT_ERRORS, - resuming: false, - })) - type Partial = DeepPartial>> type Final = InferSchemaType> - // Track current messages in a ref to preserve them when client is recreated - const messagesRef = useRef>>( - options.initialMessages || [], - ) - const isFirstMountRef = useRef(true) const subscribedRef = useRef(false) const activeClientRef = useRef(null) const cleanupInvalidationRef = useRef | null>( @@ -88,202 +63,186 @@ export function useChat< timeout: ReturnType } | null>(null) - // Update ref synchronously during render so it's always current when useMemo runs. - messagesRef.current = messages - // Track current options in a ref to avoid recreating client when options change const optionsRef = useRef>(options) optionsRef.current = options - const syncResumeState = useCallback((target: ChatClient | null) => { - if (!target) return - setRunId(target.getCurrentRunId()) - setInterruptState(target.getInterruptState()) - }, []) - - // Create ChatClient instance with callbacks to sync state - const { client, initialization } = useMemo(() => { - const messagesToUse = options.initialMessages || [] - isFirstMountRef.current = false - - // Build options with conditional spreads for fields whose source - // type is `T | undefined` but the ChatClient target uses a strict - // optional (`field?: T`) — `exactOptionalPropertyTypes` rejects - // assigning `undefined` to those, so we omit the key when absent. - const initialOptions = optionsRef.current - const transport = initialOptions.connection - ? { connection: initialOptions.connection } - : { fetcher: initialOptions.fetcher } - - const instanceHolder: { - current: ChatClient | undefined - } = { current: undefined } - const getActiveInstance = () => { - const currentInstance = instanceHolder.current - if (!currentInstance || activeClientRef.current !== currentInstance) { - return undefined + // Create ChatClient once per clientId. User callbacks read latest options. + const { client, initialization, serverSnapshot, startPersistenceHydration } = + useMemo(() => { + const messagesToUse = options.initialMessages || [] + + // Build options with conditional spreads for fields whose source + // type is `T | undefined` but the ChatClient target uses a strict + // optional (`field?: T`) — `exactOptionalPropertyTypes` rejects + // assigning `undefined` to those, so we omit the key when absent. + const initialOptions = optionsRef.current + let persistence = initialOptions.persistence + let releasePersistenceHydration = () => {} + if (persistence && typeof persistence === 'object') { + const source = persistence + let hydrationStarted = false + let releaseRead: (() => void) | undefined + let hydrationPromise: + | Promise< + Awaited['getItem']>> + > + | undefined + const gatedPersistence: ChatClientPersistence = { + getItem(id) { + if (!hydrationPromise) { + hydrationPromise = new Promise((resolve, reject) => { + let readStarted = false + releaseRead = () => { + if (readStarted) return + readStarted = true + try { + resolve(source.getItem.call(source, id)) + } catch (error) { + reject(error) + } + } + }) + } + if (hydrationStarted) releaseRead?.() + return hydrationPromise + }, + setItem(id, state) { + return source.setItem.call(source, id, state) + }, + removeItem(id) { + return source.removeItem.call(source, id) + }, + } + persistence = gatedPersistence + releasePersistenceHydration = () => { + if (hydrationStarted) return + hydrationStarted = true + releaseRead?.() + } } - return currentInstance - } - // ChatClient may publish while its constructor is running or while async - // persistence resolves before commit. Preserve those exact notifications - // until this render commits; invoking them here would run state setters and - // user callbacks for a client React may abandon. - const initializationState = { - ready: false, - callbacks: [] as Array<() => void>, - } - const runOrQueueForActiveInstance = (callback: () => void) => { - if (!initializationState.ready) { - initializationState.callbacks.push(callback) - return + const transport = initialOptions.connection + ? { connection: initialOptions.connection } + : { fetcher: initialOptions.fetcher } + + const instanceHolder: { + current: ChatClient | undefined + } = { current: undefined } + const getActiveInstance = () => { + const currentInstance = instanceHolder.current + if (!currentInstance || activeClientRef.current !== currentInstance) { + return undefined + } + return currentInstance } - const currentInstance = instanceHolder.current - if (!currentInstance || activeClientRef.current !== currentInstance) - return - callback() - } - const instance = new ChatClient({ - devtoolsBridgeFactory: createChatDevtoolsBridge, - ...transport, - initialMessages: messagesToUse, - ...(typeof initialOptions.threadId === 'string' && - initialOptions.persistence - ? { - persistence: initialOptions.persistence, - threadId: initialOptions.threadId, - } - : { - ...(initialOptions.threadId !== undefined && { + // Queue user callbacks until this instance is the committed client. + // Invoking them here would run onChunk/onFinish/onError for a client + // React may abandon. Browser persistence is gated until attach so the + // first paint stays on initialMessages / initialResumeSnapshot. + const initializationState = { + ready: false, + callbacks: [] as Array<() => void>, + } + const runOrQueueForActiveInstance = (callback: () => void) => { + if (!initializationState.ready) { + initializationState.callbacks.push(callback) + return + } + const currentInstance = instanceHolder.current + if (!currentInstance || activeClientRef.current !== currentInstance) + return + callback() + } + const instance = new ChatClient({ + devtoolsBridgeFactory: createChatDevtoolsBridge, + ...transport, + initialMessages: messagesToUse, + ...(typeof initialOptions.threadId === 'string' && persistence + ? { + persistence, threadId: initialOptions.threadId, + } + : { + ...(initialOptions.threadId !== undefined && { + threadId: initialOptions.threadId, + }), }), - }), - ...(initialOptions.body !== undefined && { body: initialOptions.body }), - ...(initialOptions.forwardedProps !== undefined && { - forwardedProps: initialOptions.forwardedProps, - }), - ...(initialOptions.byok !== undefined && { byok: initialOptions.byok }), - byokProvider: () => optionsRef.current.byokProvider?.(), - ...(initialOptions.initialResumeSnapshot !== undefined && { - initialResumeSnapshot: initialOptions.initialResumeSnapshot, - }), - ...(initialOptions.context !== undefined && { - context: initialOptions.context, - }), - devtools: { - ...initialOptions.devtools, - framework: 'react', - hookName: 'useChat', - outputKind: initialOptions.outputSchema ? 'structured' : 'chat', - }, - onResponse: (response) => { - // ChatClient awaits this return value. Queuing would drop the promise. - if (!getActiveInstance()) return - return optionsRef.current.onResponse?.(response) - }, - onChunk: (chunk: StreamChunk) => { - runOrQueueForActiveInstance(() => { - optionsRef.current.onChunk?.(chunk) - }) - }, - onFinish: (message: UIMessage) => { - runOrQueueForActiveInstance(() => { - optionsRef.current.onFinish?.(message) - }) - }, - onError: (error: Error) => { - runOrQueueForActiveInstance(() => { - optionsRef.current.onError?.(error) - }) - }, - ...(initialOptions.tools !== undefined && { - tools: initialOptions.tools, - }), - ...(initialOptions.interrupts !== undefined && { - interrupts: initialOptions.interrupts, - }), - onCustomEvent: (eventType, data, context) => { - runOrQueueForActiveInstance(() => { - optionsRef.current.onCustomEvent?.(eventType, data, context) - }) - }, - ...(options.streamProcessor !== undefined && { - streamProcessor: options.streamProcessor, - }), - onMessagesChange: (newMessages: Array>) => { - runOrQueueForActiveInstance(() => { - setMessages(newMessages) - }) - }, - onLoadingChange: (newIsLoading: boolean) => { - runOrQueueForActiveInstance(() => { - const currentInstance = getActiveInstance() - if (!currentInstance) return - setIsLoading(newIsLoading) - syncResumeState(currentInstance) - }) - }, - onErrorChange: (newError: Error | undefined) => { - runOrQueueForActiveInstance(() => { - setError(newError) - }) - }, - onStatusChange: (status: ChatClientState) => { - runOrQueueForActiveInstance(() => { - setStatus(status) - }) - }, - onSubscriptionChange: (nextIsSubscribed: boolean) => { - runOrQueueForActiveInstance(() => { - setIsSubscribed(nextIsSubscribed) - }) - }, - onConnectionStatusChange: (nextStatus: ConnectionStatus) => { - runOrQueueForActiveInstance(() => { - setConnectionStatus(nextStatus) - }) - }, - onSessionGeneratingChange: (isGenerating: boolean) => { - runOrQueueForActiveInstance(() => { - setSessionGenerating(isGenerating) - }) - }, - ...(optionsRef.current.queue !== undefined && { - queue: optionsRef.current.queue, - }), - onQueueChange: (nextQueue: Array) => { - runOrQueueForActiveInstance(() => { - setQueue(nextQueue) - }) - }, - onRunIdChange: (nextRunId) => { - runOrQueueForActiveInstance(() => { - setRunId(nextRunId) - }) - }, - onResumeStateChange: (_nextResumeState, nextPendingInterrupts) => { - runOrQueueForActiveInstance(() => { - setInterruptState((current) => ({ - ...current, - interrupts: nextPendingInterrupts, - pendingInterrupts: nextPendingInterrupts, - })) - }) - }, - onInterruptStateChange: (nextInterruptState, context) => { - runOrQueueForActiveInstance(() => { - setInterruptState(nextInterruptState) - optionsRef.current.onInterruptStateChange?.( - nextInterruptState, - context, - ) - }) - }, - }) - instanceHolder.current = instance - return { client: instance, initialization: initializationState } - }, [clientId, syncResumeState]) + ...(initialOptions.body !== undefined && { body: initialOptions.body }), + ...(initialOptions.forwardedProps !== undefined && { + forwardedProps: initialOptions.forwardedProps, + }), + ...(initialOptions.byok !== undefined && { byok: initialOptions.byok }), + byokProvider: () => optionsRef.current.byokProvider?.(), + ...(initialOptions.initialResumeSnapshot !== undefined && { + initialResumeSnapshot: initialOptions.initialResumeSnapshot, + }), + ...(initialOptions.context !== undefined && { + context: initialOptions.context, + }), + devtools: { + ...initialOptions.devtools, + framework: 'react', + hookName: 'useChat', + outputKind: initialOptions.outputSchema ? 'structured' : 'chat', + }, + onResponse: (response) => { + // ChatClient awaits this return value. Queuing would drop the promise. + if (!getActiveInstance()) return + return optionsRef.current.onResponse?.(response) + }, + onChunk: (chunk: StreamChunk) => { + runOrQueueForActiveInstance(() => { + optionsRef.current.onChunk?.(chunk) + }) + }, + onFinish: (message: UIMessage) => { + runOrQueueForActiveInstance(() => { + optionsRef.current.onFinish?.(message) + }) + }, + onError: (error: Error) => { + runOrQueueForActiveInstance(() => { + optionsRef.current.onError?.(error) + }) + }, + ...(initialOptions.tools !== undefined && { + tools: initialOptions.tools, + }), + ...(initialOptions.interrupts !== undefined && { + interrupts: initialOptions.interrupts, + }), + onCustomEvent: (eventType, data, context) => { + runOrQueueForActiveInstance(() => { + optionsRef.current.onCustomEvent?.(eventType, data, context) + }) + }, + ...(options.streamProcessor !== undefined && { + streamProcessor: options.streamProcessor, + }), + ...(optionsRef.current.queue !== undefined && { + queue: optionsRef.current.queue, + }), + onInterruptStateChange: (nextInterruptState, context) => { + runOrQueueForActiveInstance(() => { + optionsRef.current.onInterruptStateChange?.( + nextInterruptState, + context, + ) + }) + }, + }) + instanceHolder.current = instance + const capturedServerSnapshot = instance.getSnapshot() + return { + client: instance, + initialization: initializationState, + serverSnapshot: capturedServerSnapshot, + startPersistenceHydration: releasePersistenceHydration, + } + }, [clientId]) + + const getServerSnapshot = useCallback(() => serverSnapshot, [serverSnapshot]) useEffect(() => { activeClientRef.current = client @@ -303,12 +262,11 @@ export function useChat< } }, [client, initialization]) - useEffect(() => { - const clientMessages = client.getMessages() - if (clientMessages !== messagesRef.current) { - setMessages(clientMessages) - } - }, [client]) + const snapshot = useSyncExternalStore( + client.subscribeSnapshot, + client.getSnapshot, + getServerSnapshot, + ) // Sync each wire-payload slot in its own effect so an unrelated option // changing doesn't re-run the others. `updateOptions` declares strict-optional @@ -372,10 +330,11 @@ export function useChat< // run is picked straight back up from the durable log. useEffect(() => { client.attach() + startPersistenceHydration() return () => { client.detach() } - }, [client]) + }, [client, startPersistenceHydration]) useEffect(() => { if (cleanupDisposalRef.current?.client === client) { @@ -387,10 +346,6 @@ export function useChat< cleanupInvalidationRef.current = null } client.mountDevtools() - // Delivery-durability resume is transparent: the resumable SSE connection - // adapter re-attaches via the browser's native Last-Event-ID on reconnect. - // We only seed interrupt (state) resume from the client here. - syncResumeState(client) return () => { cleanupInvalidationRef.current = setTimeout(() => { @@ -423,47 +378,35 @@ export function useChat< } cleanupDisposalRef.current = disposal } - }, [client, syncResumeState]) + }, [client]) const sendMessage = useCallback( async ( content: string | MultimodalContent, sendOptions?: SendMessageOptions, ) => { - try { - await client.sendMessage(content, undefined, sendOptions) - } finally { - syncResumeState(client) - } + await client.sendMessage(content, undefined, sendOptions) }, - [client, syncResumeState], + [client], ) const cancelQueued = useCallback( (id: string) => { client.cancelQueued(id) }, - [client, syncResumeState], + [client], ) const append = useCallback( async (message: ModelMessage | UIMessage) => { - try { - await client.append(message) - } finally { - syncResumeState(client) - } + await client.append(message) }, - [client, syncResumeState], + [client], ) const reload = useCallback(async () => { - try { - await client.reload() - } finally { - syncResumeState(client) - } - }, [client, syncResumeState]) + await client.reload() + }, [client]) const stop = useCallback(() => { client.stop() @@ -471,8 +414,7 @@ export function useChat< const clear = useCallback(() => { client.clear() - syncResumeState(client) - }, [client, syncResumeState]) + }, [client]) const setMessagesManually = useCallback( (newMessages: Array>) => { @@ -497,21 +439,15 @@ export function useChat< const addToolApprovalResponse = useCallback( async (response: { id: string; approved: boolean }) => { await client.addToolApprovalResponse(response) - syncResumeState(client) }, - [client, syncResumeState], + [client], ) const resumeInterrupts = useCallback( async (resumeItems: Array, state?: ChatResumeState) => { - const result = await client.resumeInterrupts( - resumeItems, - state ?? undefined, - ) - syncResumeState(client) - return result + return await client.resumeInterrupts(resumeItems, state ?? undefined) }, - [client, syncResumeState], + [client], ) const resolveInterrupts = useCallback( @@ -555,7 +491,7 @@ export function useChat< // a stale assistant turn or a system prompt) we deliberately return null // rather than scanning historical assistants — otherwise a `final` from a // previous session would leak into the hook value on first render. - const renderedMessages = client.getMessages() + const renderedMessages = snapshot.messages const activeStructuredPart = useMemo(() => { let lastUserIndex = -1 @@ -600,23 +536,23 @@ export function useChat< append, reload, stop, - isLoading, - error, - status, - isSubscribed, - connectionStatus, - sessionGenerating, + isLoading: snapshot.isLoading, + error: snapshot.error, + status: snapshot.status, + isSubscribed: snapshot.isSubscribed, + connectionStatus: snapshot.connectionStatus, + sessionGenerating: snapshot.sessionGenerating, setMessages: setMessagesManually, clear, addToolResult, addToolApprovalResponse, - queue, + queue: snapshot.queue, cancelQueued, - runId, - interrupts: interruptState.interrupts, - pendingInterrupts: interruptState.pendingInterrupts, - interruptErrors: interruptState.interruptErrors, - resuming: interruptState.resuming, + runId: snapshot.runId, + interrupts: snapshot.interruptState.interrupts, + pendingInterrupts: snapshot.interruptState.pendingInterrupts, + interruptErrors: snapshot.interruptState.interruptErrors, + resuming: snapshot.interruptState.resuming, resolveInterrupts, cancelInterrupts, retryInterrupts, diff --git a/packages/ai-react/src/use-generate-video.ts b/packages/ai-react/src/use-generate-video.ts index 708281fee2..393c17f1ab 100644 --- a/packages/ai-react/src/use-generate-video.ts +++ b/packages/ai-react/src/use-generate-video.ts @@ -1,6 +1,13 @@ import { VideoGenerationClient } from '@tanstack/ai-client' import { createVideoDevtoolsBridge } from '@tanstack/ai-client/devtools' -import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react' +import { + useCallback, + useEffect, + useId, + useMemo, + useRef, + useSyncExternalStore, +} from 'react' import type { StreamChunk } from '@tanstack/ai' import type { AIDevtoolsDisplayOptions, @@ -175,14 +182,6 @@ export function useGenerateVideo( // The hook identity is `threadId`. `hookId` is only a React recreation key. const clientIdentity = options.threadId ?? hookId - const [result, setResult] = useState(null) - const [jobId, setJobId] = useState(null) - const [videoStatus, setVideoStatus] = useState(null) - const [isLoading, setIsLoading] = useState(false) - const [error, setError] = useState(undefined) - const [status, setStatus] = useState('idle') - const [runId, setRunId] = useState(null) - const optionsRef = useRef(options) optionsRef.current = options const disposedRef = useRef(false) @@ -235,27 +234,6 @@ export function useGenerateVideo( onStatusUpdate: (s: VideoStatusInfo) => { if (!disposedRef.current) optionsRef.current.onStatusUpdate?.(s) }, - onResultChange: (r: TOutput | null) => { - if (!disposedRef.current) setResult(r) - }, - onLoadingChange: (l: boolean) => { - if (!disposedRef.current) setIsLoading(l) - }, - onErrorChange: (e: Error | undefined) => { - if (!disposedRef.current) setError(e) - }, - onStatusChange: (s: GenerationClientState) => { - if (!disposedRef.current) setStatus(s) - }, - onJobIdChange: (id: string | null) => { - if (!disposedRef.current) setJobId(id) - }, - onVideoStatusChange: (s: VideoStatusInfo | null) => { - if (!disposedRef.current) setVideoStatus(s) - }, - onResumeStateChange: (rs: { runId: string } | null) => { - if (!disposedRef.current) setRunId(rs?.runId ?? null) - }, } const persistenceProps = @@ -289,6 +267,12 @@ export function useGenerateVideo( ) }, [clientIdentity, hookId]) + const snapshot = useSyncExternalStore( + client.subscribe, + client.getSnapshot, + client.getSnapshot, + ) + // Sync body changes without recreating client useEffect(() => { // Conditional spread: target uses strict-optional `body?: T`. @@ -327,14 +311,14 @@ export function useGenerateVideo( return { generate, - result, - jobId, - videoStatus, - isLoading, - error, - status, + result: snapshot.result, + jobId: snapshot.jobId, + videoStatus: snapshot.videoStatus, + isLoading: snapshot.isLoading, + error: snapshot.error, + status: snapshot.status, stop, reset, - runId, + runId: snapshot.runId, } } diff --git a/packages/ai-react/src/use-generation.ts b/packages/ai-react/src/use-generation.ts index 8106f4cf90..6ebe77c942 100644 --- a/packages/ai-react/src/use-generation.ts +++ b/packages/ai-react/src/use-generation.ts @@ -1,6 +1,13 @@ import { GenerationClient } from '@tanstack/ai-client' import { createGenerationDevtoolsBridge } from '@tanstack/ai-client/devtools' -import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react' +import { + useCallback, + useEffect, + useId, + useMemo, + useRef, + useSyncExternalStore, +} from 'react' import type { StreamChunk } from '@tanstack/ai' import type { AIDevtoolsDisplayOptions, @@ -174,12 +181,6 @@ export function useGeneration< // The hook identity is `threadId`. `hookId` is only a React recreation key. const clientIdentity = options.threadId ?? hookId - const [result, setResult] = useState(null) - const [isLoading, setIsLoading] = useState(false) - const [error, setError] = useState(undefined) - const [status, setStatus] = useState('idle') - const [runId, setRunId] = useState(null) - const optionsRef = useRef(options) optionsRef.current = options const disposedRef = useRef(false) @@ -226,21 +227,6 @@ export function useGeneration< onChunk: (c: StreamChunk) => { if (!disposedRef.current) optionsRef.current.onChunk?.(c) }, - onResultChange: (r) => { - if (!disposedRef.current) setResult(r) - }, - onLoadingChange: (l) => { - if (!disposedRef.current) setIsLoading(l) - }, - onErrorChange: (e) => { - if (!disposedRef.current) setError(e) - }, - onStatusChange: (s) => { - if (!disposedRef.current) setStatus(s) - }, - onResumeStateChange: (rs) => { - if (!disposedRef.current) setRunId(rs?.runId ?? null) - }, } const persistenceProps = @@ -274,6 +260,12 @@ export function useGeneration< ) }, [clientIdentity, hookId]) + const snapshot = useSyncExternalStore( + client.subscribe, + client.getSnapshot, + client.getSnapshot, + ) + // Sync body changes without recreating client useEffect(() => { // Conditional spread: target uses strict-optional `body?: T`. @@ -312,12 +304,12 @@ export function useGeneration< return { generate, - result, - isLoading, - error, - status, + result: snapshot.result, + isLoading: snapshot.isLoading, + error: snapshot.error, + status: snapshot.status, stop, reset, - runId, + runId: snapshot.runId, } } diff --git a/packages/ai-react/src/use-realtime-chat.ts b/packages/ai-react/src/use-realtime-chat.ts index bddef502c6..9646765285 100644 --- a/packages/ai-react/src/use-realtime-chat.ts +++ b/packages/ai-react/src/use-realtime-chat.ts @@ -1,11 +1,12 @@ -import { useCallback, useEffect, useRef, useState } from 'react' +import { + useCallback, + useEffect, + useRef, + useState, + useSyncExternalStore, +} from 'react' import { RealtimeClient } from '@tanstack/ai-client' -import type { - RealtimeMessage, - RealtimeMode, - RealtimeSessionConfig, - RealtimeStatus, -} from '@tanstack/ai' +import type { RealtimeSessionConfig } from '@tanstack/ai' import type { UseRealtimeChatOptions, UseRealtimeChatReturn, @@ -58,17 +59,6 @@ const emptyTimeDomainData = new Uint8Array(128).fill(128) export function useRealtimeChat( options: UseRealtimeChatOptions, ): UseRealtimeChatReturn { - // State - const [status, setStatus] = useState('idle') - const [mode, setMode] = useState('idle') - const [messages, setMessages] = useState>([]) - const [pendingUserTranscript, setPendingUserTranscript] = useState< - string | null - >(null) - const [pendingAssistantTranscript, setPendingAssistantTranscript] = useState< - string | null - >(null) - const [error, setError] = useState(null) const [inputLevel, setInputLevel] = useState(0) const [outputLevel, setOutputLevel] = useState(0) @@ -113,15 +103,10 @@ export function useRealtimeChat( ...(initial.semanticEagerness !== undefined && { semanticEagerness: initial.semanticEagerness, }), - onStatusChange: (newStatus) => { - setStatus(newStatus) - }, onModeChange: (newMode) => { - setMode(newMode) optionsRef.current.onModeChange?.(newMode) }, onMessage: (message) => { - setMessages((prev) => [...prev, message]) optionsRef.current.onMessage?.(message) }, onUsage(usage) { @@ -131,30 +116,26 @@ export function useRealtimeChat( optionsRef.current.onGoAway?.(timeLeft) }, onError: (err) => { - setError(err) optionsRef.current.onError?.(err) }, onConnect: () => { - setError(null) optionsRef.current.onConnect?.() }, onDisconnect: () => { optionsRef.current.onDisconnect?.() }, onInterrupted: () => { - setPendingAssistantTranscript(null) optionsRef.current.onInterrupted?.() }, }) - - // Subscribe to state changes for transcripts - clientRef.current.onStateChange((state) => { - setPendingUserTranscript(state.pendingUserTranscript) - setPendingAssistantTranscript(state.pendingAssistantTranscript) - }) } const client = clientRef.current + const snapshot = useSyncExternalStore( + client.subscribe, + client.getSnapshot, + client.getSnapshot, + ) // Audio level animation loop useEffect(() => { @@ -166,7 +147,7 @@ export function useRealtimeChat( animationFrameRef.current = requestAnimationFrame(updateLevels) } - if (status === 'connected') { + if (snapshot.status === 'connected') { updateLevels() } @@ -176,7 +157,7 @@ export function useRealtimeChat( animationFrameRef.current = null } } - }, [status]) + }, [snapshot.status]) // Cleanup on unmount useEffect(() => { @@ -187,10 +168,6 @@ export function useRealtimeChat( // Connection methods const connect = useCallback(async () => { - setError(null) - setMessages([]) - setPendingUserTranscript(null) - setPendingAssistantTranscript(null) await client.connect() }, [client]) @@ -258,16 +235,16 @@ export function useRealtimeChat( return { // Connection state - status, - error, + status: snapshot.status, + error: snapshot.error, connect, disconnect, // Conversation state - mode, - messages, - pendingUserTranscript, - pendingAssistantTranscript, + mode: snapshot.mode, + messages: snapshot.messages, + pendingUserTranscript: snapshot.pendingUserTranscript, + pendingAssistantTranscript: snapshot.pendingAssistantTranscript, // Voice control startListening, diff --git a/packages/ai-react/tests/use-chat.test.ts b/packages/ai-react/tests/use-chat.test.ts index 3f6d6593f8..c43acf3f3d 100644 --- a/packages/ai-react/tests/use-chat.test.ts +++ b/packages/ai-react/tests/use-chat.test.ts @@ -1,7 +1,13 @@ import { EventType } from '@tanstack/ai' import { ChatClient } from '@tanstack/ai-client' import { act, render, renderHook, waitFor } from '@testing-library/react' -import { StrictMode, Suspense, createElement, useState } from 'react' +import { + StrictMode, + Suspense, + createElement, + useLayoutEffect, + useState, +} from 'react' import { afterEach, describe, expect, it, vi } from 'vitest' import { useChat } from '../src/use-chat' import { @@ -204,6 +210,43 @@ describe('useChat', () => { expect(result.current.messages).toEqual(initialMessages) }) + it('throws on in-place messages.push and accepts setMessages', () => { + const { result } = renderUseChat({ + connection: createMockConnectionAdapter(), + initialMessages: [ + { + id: 'msg-1', + role: 'user', + parts: [{ type: 'text', content: 'Hello' }], + createdAt: new Date(), + }, + ], + }) + + expect(Object.isFrozen(result.current.messages)).toBe(true) + expect(() => { + Array.prototype.push.call(result.current.messages, { + id: 'x', + role: 'user', + parts: [{ type: 'text', content: 'nope' }], + createdAt: new Date(), + }) + }).toThrow(TypeError) + + act(() => { + result.current.setMessages([ + ...result.current.messages, + { + id: 'ok', + role: 'user', + parts: [{ type: 'text', content: 'ok' }], + createdAt: new Date(), + }, + ]) + }) + expect(result.current.messages).toHaveLength(2) + }) + it('should initialize with persisted messages', async () => { const adapter = createMockConnectionAdapter() const persistedMessages: Array = [ @@ -232,7 +275,7 @@ describe('useChat', () => { expect(persistence.getItem).toHaveBeenCalledWith('persisted-chat') }) - it('should forward synchronous persisted interrupt hydration', () => { + it('forwards persisted interrupt hydration from a synchronous store', async () => { const onInterruptStateChange = vi.fn() const persistence = { getItem: vi.fn(() => ({ @@ -254,7 +297,9 @@ describe('useChat', () => { { wrapper: StrictMode }, ) - expect(result.current.interrupts).toHaveLength(2) + await waitFor(() => { + expect(result.current.interrupts).toHaveLength(2) + }) expect(onInterruptStateChange).toHaveBeenCalledOnce() expect(onInterruptStateChange).toHaveBeenCalledWith( expect.objectContaining({ interrupts: result.current.interrupts }), @@ -262,6 +307,360 @@ describe('useChat', () => { ) }) + it('keeps the server message count for the first hydration render', async () => { + const initialMessages: Array = [ + { + id: 'server-message', + role: 'user', + parts: [{ type: 'text', content: 'Server message' }], + createdAt: new Date(), + }, + ] + const persistedMessages: Array = [ + ...initialMessages, + { + id: 'persisted-message', + role: 'assistant', + parts: [{ type: 'text', content: 'Persisted message' }], + createdAt: new Date(), + }, + ] + const persistence = { + getItem: vi.fn(() => persistedMessages), + setItem: vi.fn(), + removeItem: vi.fn(), + } + const renderCounts: Array = [] + const onRecoverableError = vi.fn() + const container = document.createElement('div') + container.innerHTML = '
1
' + document.body.append(container) + + function HydratedChat() { + const chat = useChat({ + connection: createMockConnectionAdapter(), + threadId: 'hydrated-message-chat', + initialMessages, + persistence, + }) + renderCounts.push(chat.messages.length) + return createElement( + 'div', + { 'data-testid': 'message-count' }, + chat.messages.length, + ) + } + + const view = render( + createElement(StrictMode, null, createElement(HydratedChat)), + { container, hydrate: true, onRecoverableError }, + ) + + expect(renderCounts[0]).toBe(1) + await waitFor(() => { + expect(view.getByTestId('message-count').textContent).toBe('2') + }) + expect(persistence.getItem).toHaveBeenCalledOnce() + expect(onRecoverableError).not.toHaveBeenCalled() + }) + + it('keeps server interrupts empty until persistence hydration starts after commit', async () => { + const persistence = { + getItem: vi.fn(() => ({ + messages: [], + resume: createInterruptResumeSnapshot(), + })), + setItem: vi.fn(), + removeItem: vi.fn(), + } + const renderCounts: Array = [] + const hydrationCommitStates: Array = [] + const onRecoverableError = vi.fn() + const container = document.createElement('div') + container.innerHTML = '
0
' + document.body.append(container) + + function HydratedInterrupts() { + let committed = false + useLayoutEffect(() => { + committed = true + }, []) + const chat = useChat({ + connection: createMockConnectionAdapter(), + threadId: 'hydrated-interrupt-chat', + persistence, + onInterruptStateChange: (_state, context) => { + if (context.source === 'hydrate') { + hydrationCommitStates.push(committed) + } + }, + }) + renderCounts.push(chat.interrupts.length) + return createElement( + 'div', + { 'data-testid': 'interrupt-count' }, + chat.interrupts.length, + ) + } + + const view = render( + createElement(StrictMode, null, createElement(HydratedInterrupts)), + { container, hydrate: true, onRecoverableError }, + ) + + expect(renderCounts[0]).toBe(0) + await waitFor(() => { + expect(view.getByTestId('interrupt-count').textContent).toBe('2') + }) + expect(hydrationCommitStates).toEqual([true]) + expect(persistence.getItem).toHaveBeenCalledOnce() + expect(onRecoverableError).not.toHaveBeenCalled() + }) + + it('keeps the initial resume snapshot through hydration before rejoining the stored run', async () => { + const initialResumeSnapshot = createInterruptResumeSnapshot() + const persistence = { + getItem: vi.fn(() => ({ + messages: [], + resume: { + schemaVersion: 2, + resumeState: { + threadId: 'hydrated-resume-chat', + runId: 'stored-run', + }, + }, + })), + setItem: vi.fn(), + removeItem: vi.fn(), + } + const joinRun = vi.fn(async function* (_runId: string) {}) + const connection: ResumableConnectConnectionAdapter = { + connect: async function* () {}, + joinRun, + } + const renderCounts: Array = [] + const onRecoverableError = vi.fn() + const container = document.createElement('div') + container.innerHTML = '
2
' + document.body.append(container) + + function HydratedResume() { + const chat = useChat({ + connection, + threadId: 'hydrated-resume-chat', + initialResumeSnapshot, + persistence, + }) + renderCounts.push(chat.interrupts.length) + return createElement( + 'div', + { 'data-testid': 'interrupt-count' }, + chat.interrupts.length, + ) + } + + const view = render(createElement(HydratedResume), { + container, + hydrate: true, + onRecoverableError, + }) + + expect(renderCounts[0]).toBe(2) + expect(joinRun).not.toHaveBeenCalled() + await waitFor(() => { + expect(view.getByTestId('interrupt-count').textContent).toBe('0') + expect(joinRun).toHaveBeenCalledOnce() + }) + expect(joinRun).toHaveBeenCalledWith('stored-run', expect.anything()) + expect(persistence.getItem).toHaveBeenCalledOnce() + expect(onRecoverableError).not.toHaveBeenCalled() + }) + + it('preserves the persistence adapter receiver for reads, writes, and removal', async () => { + class ReceiverSensitivePersistence implements ChatClientPersistence { + calls: Array = [] + + getItem(id: string) { + this.calls.push(`get:${id}`) + return [] + } + + setItem(id: string) { + this.calls.push(`set:${id}`) + } + + removeItem(id: string) { + this.calls.push(`remove:${id}`) + } + } + + const persistence = new ReceiverSensitivePersistence() + const { result } = renderUseChat({ + connection: createMockConnectionAdapter({ + chunks: createTextChunks('Persisted reply'), + }), + threadId: 'receiver-chat', + persistence, + }) + + await waitFor(() => { + expect(persistence.calls).toContain('get:receiver-chat') + }) + await act(async () => { + await result.current.sendMessage('Persist me') + }) + await waitFor(() => { + expect(persistence.calls).toContain('set:receiver-chat') + }) + const removalsBeforeClear = persistence.calls.filter( + (call) => call === 'remove:receiver-chat', + ).length + act(() => result.current.clear()) + await waitFor(() => { + const removalsAfterClear = persistence.calls.filter( + (call) => call === 'remove:receiver-chat', + ).length + expect(removalsAfterClear).toBeGreaterThan(removalsBeforeClear) + }) + }) + + it('keeps initial state when a synchronous persistence read throws during hydration', async () => { + const initialMessages: Array = [ + { + id: 'initial-message', + role: 'user', + parts: [{ type: 'text', content: 'Initial' }], + createdAt: new Date(), + }, + ] + const persistence = { + getItem: vi.fn(() => { + throw new Error('storage unavailable') + }), + setItem: vi.fn(), + removeItem: vi.fn(), + } + const onInterruptStateChange = vi.fn() + const onRecoverableError = vi.fn() + const container = document.createElement('div') + container.innerHTML = '
1
' + document.body.append(container) + + function HydratedChat() { + const chat = useChat({ + connection: createMockConnectionAdapter(), + threadId: 'throwing-sync-chat', + initialMessages, + persistence, + onInterruptStateChange, + }) + return createElement( + 'div', + { 'data-testid': 'message-count' }, + chat.messages.length, + ) + } + + const view = render(createElement(HydratedChat), { + container, + hydrate: true, + onRecoverableError, + }) + + await waitFor(() => { + expect(persistence.getItem).toHaveBeenCalledOnce() + }) + expect(view.getByTestId('message-count').textContent).toBe('1') + expect(onInterruptStateChange).not.toHaveBeenCalled() + expect(onRecoverableError).not.toHaveBeenCalled() + }) + + it('keeps initial state when an asynchronous persistence read rejects', async () => { + const initialMessages: Array = [ + { + id: 'initial-message', + role: 'user', + parts: [{ type: 'text', content: 'Initial' }], + createdAt: new Date(), + }, + ] + const persistence = { + getItem: vi.fn(async () => { + throw new Error('storage unavailable') + }), + setItem: vi.fn(), + removeItem: vi.fn(), + } + const onInterruptStateChange = vi.fn() + const { result } = renderUseChat({ + connection: createMockConnectionAdapter(), + threadId: 'throwing-async-chat', + initialMessages, + persistence, + onInterruptStateChange, + }) + + await waitFor(() => { + expect(persistence.getItem).toHaveBeenCalledOnce() + }) + expect(result.current.messages).toEqual(initialMessages) + expect(onInterruptStateChange).not.toHaveBeenCalled() + }) + + it('applies asynchronous persistence only after attach and commit', async () => { + const hydration = createDeferred<{ + messages: Array + resume: ReturnType + }>() + let committed = false + const readCommitStates: Array = [] + const hydrationCommitStates: Array = [] + const persistence = { + getItem: vi.fn(() => { + readCommitStates.push(committed) + return hydration.promise + }), + setItem: vi.fn(), + removeItem: vi.fn(), + } + + function useHydratedChat() { + useLayoutEffect(() => { + committed = true + }, []) + const chat = useChat({ + connection: createMockConnectionAdapter(), + threadId: 'async-hydration-chat', + persistence, + onInterruptStateChange: (_state, context) => { + if (context.source === 'hydrate') { + hydrationCommitStates.push(committed) + } + }, + }) + return chat.interrupts.length + } + + const { result } = renderHook(() => useHydratedChat()) + + expect(result.current).toBe(0) + await waitFor(() => { + expect(persistence.getItem).toHaveBeenCalledOnce() + }) + expect(readCommitStates).toEqual([true]) + await act(async () => { + hydration.resolve({ + messages: [], + resume: createInterruptResumeSnapshot(), + }) + await hydration.promise + }) + await waitFor(() => { + expect(result.current).toBe(2) + }) + expect(hydrationCommitStates).toEqual([true]) + }) + it('does not publish async hydration from an abandoned render', async () => { const hydration = createDeferred<{ messages: Array @@ -302,6 +701,7 @@ describe('useChat', () => { await hydration.promise }) + expect(persistence.getItem).not.toHaveBeenCalled() expect(onInterruptStateChange).not.toHaveBeenCalled() }) diff --git a/packages/ai-solid/src/use-chat.ts b/packages/ai-solid/src/use-chat.ts index 6d5737b6b4..59e38c2541 100644 --- a/packages/ai-solid/src/use-chat.ts +++ b/packages/ai-solid/src/use-chat.ts @@ -82,11 +82,6 @@ export function useChat< resuming: false, }) - const syncResumeState = () => { - setRunId(client().getCurrentRunId()) - setInterruptState(client().getInterruptState()) - } - // Structured-output `partial` / `final` are derived from `messages` — // specifically from the structured-output part on the latest assistant // message (the one after the most recent user message). Per-turn parts @@ -94,7 +89,7 @@ export function useChat< type Partial = DeepPartial>> type Final = InferSchemaType> - // Create ChatClient instance with callbacks to sync state. + // Build the client once per clientId. User callbacks read latest options. // Every user-provided callback is wrapped so the LATEST `options.xxx` value // is read at call time. Direct assignment would freeze the callback to the // reference we saw at creation; the wrapper lets reactive `options` or @@ -159,44 +154,8 @@ export function useChat< ...(options.streamProcessor !== undefined && { streamProcessor: options.streamProcessor, }), - onMessagesChange: (newMessages: Array>) => { - setMessages(newMessages) - }, - onLoadingChange: (newIsLoading: boolean) => { - setIsLoading(newIsLoading) - syncResumeState() - }, - onStatusChange: (newStatus: ChatClientState) => { - setStatus(newStatus) - }, - onErrorChange: (newError: Error | undefined) => { - setError(newError) - }, - onSubscriptionChange: (nextIsSubscribed: boolean) => { - setIsSubscribed(nextIsSubscribed) - }, - onConnectionStatusChange: (nextStatus: ConnectionStatus) => { - setConnectionStatus(nextStatus) - }, - onSessionGeneratingChange: (isGenerating: boolean) => { - setSessionGenerating(isGenerating) - }, ...(options.queue !== undefined && { queue: options.queue }), - onQueueChange: (nextQueue: Array) => { - setQueue(nextQueue) - }, - onRunIdChange: (nextRunId) => { - setRunId(nextRunId) - }, - onResumeStateChange: (_nextResumeState, nextPendingInterrupts) => { - setInterruptState((current) => ({ - ...current, - interrupts: nextPendingInterrupts, - pendingInterrupts: nextPendingInterrupts, - })) - }, onInterruptStateChange: (nextInterruptState, context) => { - setInterruptState(nextInterruptState) options.onInterruptStateChange?.(nextInterruptState, context) }, }) @@ -204,8 +163,26 @@ export function useChat< // Connection and other options are captured at creation time }, [clientId]) - setMessages(client().getMessages()) - syncResumeState() + const applySnapshot = (target: ChatClient) => { + const next = target.getSnapshot() + setMessages(next.messages as Array>) + setIsLoading(next.isLoading) + setError(next.error) + setStatus(next.status) + setIsSubscribed(next.isSubscribed) + setConnectionStatus(next.connectionStatus) + setSessionGenerating(next.sessionGenerating) + setQueue(next.queue as Array) + setRunId(next.runId) + setInterruptState(next.interruptState) + } + + createEffect(() => { + const target = client() + applySnapshot(target) + const unsubscribe = target.subscribeSnapshot(() => applySnapshot(target)) + onCleanup(unsubscribe) + }) // Sync body / forwardedProps changes to the client. // Both populate the same wire payload; `forwardedProps` is preferred @@ -230,6 +207,11 @@ export function useChat< client().unsubscribe() } + // First paint must see constructor hydration (sync store / resume snapshot) + // and the live subscribe above. `createEffect` runs after paint, so the + // subscribe effect cannot be the first apply. + applySnapshot(client()) + createEffect(() => { if (options.live) { client().subscribe() @@ -245,10 +227,6 @@ export function useChat< // connections per origin until the page reloaded. client().attach() client().mountDevtools() - // Delivery-durability resume is transparent: the resumable SSE connection - // adapter reattaches via the browser's native Last-Event-ID on reconnect. - // We only seed interrupt (state) resume from the client here. - syncResumeState() }) // Cleanup on unmount: stop any in-flight requests. @@ -272,27 +250,15 @@ export function useChat< content: string | MultimodalContent, sendOptions?: SendMessageOptions, ) => { - try { - await client().sendMessage(content, undefined, sendOptions) - } finally { - syncResumeState() - } + await client().sendMessage(content, undefined, sendOptions) } const append = async (message: ModelMessage | UIMessage) => { - try { - await client().append(message) - } finally { - syncResumeState() - } + await client().append(message) } const reload = async () => { - try { - await client().reload() - } finally { - syncResumeState() - } + await client().reload() } const stop = () => { @@ -301,7 +267,6 @@ export function useChat< const clear = () => { client().clear() - syncResumeState() } const setMessagesManually = (newMessages: Array>) => { @@ -323,7 +288,6 @@ export function useChat< approved: boolean }) => { await client().addToolApprovalResponse(response) - syncResumeState() } const cancelQueued = (id: string) => client().cancelQueued(id) @@ -332,9 +296,7 @@ export function useChat< resumeItems: Array, state?: ChatResumeState, ) => { - const result = await client().resumeInterrupts(resumeItems, state) - syncResumeState() - return result + return await client().resumeInterrupts(resumeItems, state) } const resolveInterrupts = ( diff --git a/packages/ai-solid/src/use-generate-video.ts b/packages/ai-solid/src/use-generate-video.ts index 42acd27e53..ceba999c4f 100644 --- a/packages/ai-solid/src/use-generate-video.ts +++ b/packages/ai-solid/src/use-generate-video.ts @@ -244,27 +244,6 @@ export function useGenerateVideo( onStatusUpdate: (s: VideoStatusInfo) => { if (!disposed) options.onStatusUpdate?.(s) }, - onResultChange: (r: TOutput | null) => { - if (!disposed) setResult(() => r) - }, - onLoadingChange: (l: boolean) => { - if (!disposed) setIsLoading(l) - }, - onErrorChange: (e: Error | undefined) => { - if (!disposed) setError(e) - }, - onStatusChange: (s: GenerationClientState) => { - if (!disposed) setStatus(s) - }, - onJobIdChange: (id: string | null) => { - if (!disposed) setJobId(id) - }, - onVideoStatusChange: (s: VideoStatusInfo | null) => { - if (!disposed) setVideoStatus(s) - }, - onResumeStateChange: (rs: { runId: string } | null) => { - if (!disposed) setRunId(rs?.runId ?? null) - }, } if (options.connection) { @@ -286,6 +265,19 @@ export function useGenerateVideo( ) }) + const applySnapshot = () => { + const next = client.getSnapshot() + setResult(() => next.result) + setIsLoading(next.isLoading) + setError(next.error) + setStatus(next.status) + setRunId(next.runId) + setJobId(next.jobId) + setVideoStatus(next.videoStatus) + } + applySnapshot() + onCleanup(client.subscribe(applySnapshot)) + // Sync body changes without recreating client createEffect(() => { const currentBody = options.body diff --git a/packages/ai-solid/src/use-generation.ts b/packages/ai-solid/src/use-generation.ts index eedc8eb5c2..ee614f1d8c 100644 --- a/packages/ai-solid/src/use-generation.ts +++ b/packages/ai-solid/src/use-generation.ts @@ -230,21 +230,6 @@ export function useGeneration< onChunk: (c: StreamChunk) => { if (!disposed) options.onChunk?.(c) }, - onResultChange: (r) => { - if (!disposed) setResult(() => r) - }, - onLoadingChange: (l) => { - if (!disposed) setIsLoading(l) - }, - onErrorChange: (e) => { - if (!disposed) setError(e) - }, - onStatusChange: (s) => { - if (!disposed) setStatus(s) - }, - onResumeStateChange: (rs) => { - if (!disposed) setRunId(rs?.runId ?? null) - }, } const persistenceProps = @@ -280,6 +265,17 @@ export function useGeneration< ) }) + const applySnapshot = () => { + const next = client.getSnapshot() + setResult(() => next.result) + setIsLoading(next.isLoading) + setError(next.error) + setStatus(next.status) + setRunId(next.runId) + } + applySnapshot() + onCleanup(client.subscribe(applySnapshot)) + // Sync body changes without recreating client createEffect(() => { const currentBody = options.body diff --git a/packages/ai-solid/tests/use-chat.test.ts b/packages/ai-solid/tests/use-chat.test.ts index 050654c896..52d182bac7 100644 --- a/packages/ai-solid/tests/use-chat.test.ts +++ b/packages/ai-solid/tests/use-chat.test.ts @@ -153,14 +153,20 @@ describe('useChat', () => { removeItem: vi.fn(), } - const { result } = renderUseChat({ - connection: adapter, - threadId: 'persisted-chat', - persistence: persistence, + let firstMessages: Array | undefined + const rendered = renderHook(() => { + const chat = useChat({ + connection: adapter, + threadId: 'persisted-chat', + persistence, + }) + firstMessages ??= chat.messages() + return chat }) + expect(firstMessages).toEqual(persistedMessages) await waitFor(() => { - expect(result.current.messages).toEqual(persistedMessages) + expect(rendered.result.messages()).toEqual(persistedMessages) }) expect(persistence.getItem).toHaveBeenCalledWith('persisted-chat') }) diff --git a/packages/ai-svelte/src/create-chat.svelte.ts b/packages/ai-svelte/src/create-chat.svelte.ts index 9e72e21aeb..8d89f6ab4b 100644 --- a/packages/ai-svelte/src/create-chat.svelte.ts +++ b/packages/ai-svelte/src/create-chat.svelte.ts @@ -163,48 +163,28 @@ export function createChat< ...(options.streamProcessor !== undefined && { streamProcessor: options.streamProcessor, }), - onMessagesChange: (newMessages: Array>) => { - messages = newMessages - }, - onLoadingChange: (newIsLoading: boolean) => { - isLoading = newIsLoading - syncResumeState() - }, - onStatusChange: (newStatus: ChatClientState) => { - status = newStatus - }, - onErrorChange: (newError: Error | undefined) => { - error = newError - }, - onSubscriptionChange: (nextIsSubscribed: boolean) => { - isSubscribed = nextIsSubscribed - }, - onConnectionStatusChange: (nextStatus: ConnectionStatus) => { - connectionStatus = nextStatus - }, - onSessionGeneratingChange: (isGenerating: boolean) => { - sessionGenerating = isGenerating - }, ...(options.queue !== undefined && { queue: options.queue }), - onQueueChange: (nextQueue: Array) => { - queue = nextQueue - }, - onRunIdChange: (nextRunId) => { - runId = nextRunId - }, onInterruptStateChange: (nextInterruptState, context) => { - interruptState = nextInterruptState options.onInterruptStateChange?.(nextInterruptState, context) }, }) - function syncResumeState() { - runId = client.getCurrentRunId() - interruptState = client.getInterruptState() + function applySnapshot() { + const next = client.getSnapshot() + messages = next.messages as Array> + isLoading = next.isLoading + error = next.error + status = next.status + isSubscribed = next.isSubscribed + connectionStatus = next.connectionStatus + sessionGenerating = next.sessionGenerating + queue = next.queue as Array + runId = next.runId + interruptState = next.interruptState } - messages = client.getMessages() - interruptState = client.getInterruptState() + applySnapshot() + const unsubscribeSnapshot = client.subscribeSnapshot(applySnapshot) if (options.live) { client.subscribe() @@ -218,7 +198,6 @@ export function createChat< // Delivery-durability resume is transparent: the resumable SSE // connection adapter reattaches via the browser's native // Last-Event-ID on reconnect. We only seed interrupt (state) resume. - syncResumeState() client.attach() // ONLY THE VIEW ON SCREEN HOLDS A STREAM. `onMount`'s returned function // runs when the component is destroyed, which is the one automatic @@ -230,6 +209,7 @@ export function createChat< // `detach` keeps the transcript and the resume pointer, so re-entering // the view picks the run back up from the durable log. return () => { + unsubscribeSnapshot() client.detach() } }) @@ -247,29 +227,17 @@ export function createChat< content: string | MultimodalContent, sendOptions?: SendMessageOptions, ) => { - try { - await client.sendMessage(content, undefined, sendOptions) - } finally { - syncResumeState() - } + await client.sendMessage(content, undefined, sendOptions) } const cancelQueued = (id: string) => client.cancelQueued(id) const append = async (message: ModelMessage | UIMessage) => { - try { - await client.append(message) - } finally { - syncResumeState() - } + await client.append(message) } const reload = async () => { - try { - await client.reload() - } finally { - syncResumeState() - } + await client.reload() } const stop = () => { @@ -277,12 +245,12 @@ export function createChat< } const dispose = () => { + unsubscribeSnapshot() client.dispose() } const clear = () => { client.clear() - syncResumeState() } const setMessages = (newMessages: Array>) => { @@ -304,16 +272,13 @@ export function createChat< approved: boolean }) => { await client.addToolApprovalResponse(response) - syncResumeState() } const resumeInterrupts = async ( resumeItems: Array, state?: ChatResumeState, ) => { - const result = await client.resumeInterrupts(resumeItems, state) - syncResumeState() - return result + return await client.resumeInterrupts(resumeItems, state) } const resolveInterrupts = ( diff --git a/packages/ai-svelte/src/create-generate-video.svelte.ts b/packages/ai-svelte/src/create-generate-video.svelte.ts index d8b8615bcf..4448245f8e 100644 --- a/packages/ai-svelte/src/create-generate-video.svelte.ts +++ b/packages/ai-svelte/src/create-generate-video.svelte.ts @@ -235,34 +235,6 @@ export function createGenerateVideo( onStatusUpdate: (s: VideoStatusInfo) => { if (!disposed) options.onStatusUpdate?.(s) }, - onResultChange: (r: TOutput | null) => { - if (disposed) return - result = r - }, - onLoadingChange: (l: boolean) => { - if (disposed) return - isLoading = l - }, - onErrorChange: (e: Error | undefined) => { - if (disposed) return - error = e - }, - onStatusChange: (s: GenerationClientState) => { - if (disposed) return - status = s - }, - onJobIdChange: (id: string | null) => { - if (disposed) return - jobId = id - }, - onVideoStatusChange: (s: VideoStatusInfo | null) => { - if (disposed) return - videoStatus = s - }, - onResumeStateChange: (rs: { runId: string } | null) => { - if (disposed) return - runId = rs?.runId ?? null - }, } let client: VideoGenerationClient @@ -283,6 +255,19 @@ export function createGenerateVideo( ) } + const applySnapshot = () => { + const next = client.getSnapshot() + result = next.result + isLoading = next.isLoading + error = next.error + status = next.status + runId = next.runId + jobId = next.jobId + videoStatus = next.videoStatus + } + applySnapshot() + let unsubscribeSnapshot = client.subscribe(applySnapshot) + // Mount devtools only. Generation runs are never auto-started on setup — // persisted state is read-only for display. client.mountDevtools() @@ -297,7 +282,11 @@ export function createGenerateVideo( // frameworks revive via mountDevtools() in their mount effects), so an // explicit generate() after dispose() is the Svelte revive path: bring // the client and the reactive bindings back together. - disposed = false + if (disposed) { + disposed = false + applySnapshot() + unsubscribeSnapshot = client.subscribe(applySnapshot) + } client.mountDevtools() await client.generate(input) } @@ -312,6 +301,8 @@ export function createGenerateVideo( const dispose = () => { disposed = true + unsubscribeSnapshot() + unsubscribeSnapshot = () => {} client.dispose() } diff --git a/packages/ai-svelte/src/create-generation.svelte.ts b/packages/ai-svelte/src/create-generation.svelte.ts index bf99c9dd3b..1c659dd2f8 100644 --- a/packages/ai-svelte/src/create-generation.svelte.ts +++ b/packages/ai-svelte/src/create-generation.svelte.ts @@ -233,26 +233,6 @@ export function createGeneration< onChunk: (c: StreamChunk) => { if (!disposed) options.onChunk?.(c) }, - onResultChange: (r: TOutput | null) => { - if (disposed) return - result = r - }, - onLoadingChange: (l: boolean) => { - if (disposed) return - isLoading = l - }, - onErrorChange: (e: Error | undefined) => { - if (disposed) return - error = e - }, - onStatusChange: (s: GenerationClientState) => { - if (disposed) return - status = s - }, - onResumeStateChange: (rs) => { - if (disposed) return - runId = rs?.runId ?? null - }, } const persistenceProps = @@ -287,6 +267,17 @@ export function createGeneration< ) } + const applySnapshot = () => { + const next = client.getSnapshot() + result = next.result + isLoading = next.isLoading + error = next.error + status = next.status + runId = next.runId + } + applySnapshot() + let unsubscribeSnapshot = client.subscribe(applySnapshot) + // Mount devtools only. Generation runs are never auto-started on setup — // persisted state is read-only for display. client.mountDevtools() @@ -301,7 +292,11 @@ export function createGeneration< // frameworks revive via mountDevtools() in their mount effects), so an // explicit generate() after dispose() is the Svelte revive path: bring // the client and the reactive bindings back together. - disposed = false + if (disposed) { + disposed = false + applySnapshot() + unsubscribeSnapshot = client.subscribe(applySnapshot) + } client.mountDevtools() await client.generate(input) } @@ -316,6 +311,8 @@ export function createGeneration< const dispose = () => { disposed = true + unsubscribeSnapshot() + unsubscribeSnapshot = () => {} client.dispose() } diff --git a/packages/ai-svelte/tests/create-generation-dispose.test.ts b/packages/ai-svelte/tests/create-generation-dispose.test.ts new file mode 100644 index 0000000000..31920ea009 --- /dev/null +++ b/packages/ai-svelte/tests/create-generation-dispose.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest' +import { createGeneration } from '../src/create-generation.svelte' +import { createGenerateVideo } from '../src/create-generate-video.svelte' + +describe('Svelte generate() after dispose() rebinds the snapshot', () => { + it('createGeneration updates result after dispose then generate', async () => { + const gen = createGeneration<{ prompt: string }, { id: string }>({ + fetcher: async (input) => ({ id: input.prompt }), + }) + + await gen.generate({ prompt: 'a' }) + expect(gen.result).toEqual({ id: 'a' }) + + gen.dispose() + await gen.generate({ prompt: 'b' }) + expect(gen.result).toEqual({ id: 'b' }) + }) + + it('createGenerateVideo updates result after dispose then generate', async () => { + const video = createGenerateVideo({ + fetcher: async (input) => { + if (typeof input.prompt !== 'string') { + throw new Error('Expected a text prompt') + } + + return { + jobId: input.prompt, + status: 'completed' as const, + url: `https://example.com/${input.prompt}.mp4`, + } + }, + }) + + await video.generate({ prompt: 'one' }) + expect(video.result?.jobId).toBe('one') + + video.dispose() + await video.generate({ prompt: 'two' }) + expect(video.result?.jobId).toBe('two') + }) +}) diff --git a/packages/ai-vertex/package.json b/packages/ai-vertex/package.json index 0fead1cade..f5d635271c 100644 --- a/packages/ai-vertex/package.json +++ b/packages/ai-vertex/package.json @@ -58,7 +58,6 @@ "@tanstack/ai": "workspace:^" }, "devDependencies": { - "@google/genai": "^2.10.0", "@tanstack/ai": "workspace:*", "@vitest/coverage-v8": "4.1.10", "vite": "^8.2.1" diff --git a/packages/ai-vertex/tests/factories.test.ts b/packages/ai-vertex/tests/factories.test.ts index 54c9c86b84..c21f6c0d5e 100644 --- a/packages/ai-vertex/tests/factories.test.ts +++ b/packages/ai-vertex/tests/factories.test.ts @@ -11,22 +11,35 @@ import { const mocks = vi.hoisted(() => { return { - constructorSpy: vi.fn<(options: Record) => void>(), + constructorSpy: + vi.fn< + (kind: string, options: Record, model: string) => void + >(), } }) -vi.mock('@google/genai', async (importOriginal) => { - const actual = await importOriginal() +vi.mock('@tanstack/ai-gemini', async (importOriginal) => { + const actual = await importOriginal() + const adapterClass = (kind: string) => { + return class { + readonly name = 'gemini' + readonly model: string - class MockGoogleGenAI { - constructor(options: Record) { - mocks.constructorSpy(options) + constructor(options: Record, model: string) { + this.model = model + mocks.constructorSpy(kind, options, model) + } } } return { ...actual, - GoogleGenAI: MockGoogleGenAI, + GeminiTextAdapter: adapterClass('text'), + GeminiImageAdapter: adapterClass('image'), + GeminiEmbeddingAdapter: adapterClass('embedding'), + GeminiTTSAdapter: adapterClass('speech'), + GeminiAudioAdapter: adapterClass('audio'), + GeminiVideoAdapter: adapterClass('video'), } }) @@ -49,11 +62,15 @@ describe('vertex factories', () => { expect(adapter.name).toBe('gemini') expect(adapter.model).toBe('gemini-3.7-flash') - expect(mocks.constructorSpy).toHaveBeenCalledExactlyOnceWith({ - project: 'my-project', - location: 'europe-west1', - vertexai: true, - }) + expect(mocks.constructorSpy).toHaveBeenCalledExactlyOnceWith( + 'text', + { + project: 'my-project', + location: 'europe-west1', + vertexai: true, + }, + 'gemini-3.7-flash', + ) }) it('builds summarize, image, embedding, speech, audio, and video adapters', () => { @@ -66,13 +83,42 @@ describe('vertex factories', () => { expect(vertexAudio('lyria-3-pro-preview', auth).name).toBe('gemini') expect(vertexVideo('veo-3.1-generate-preview', auth).name).toBe('gemini') - expect(mocks.constructorSpy).toHaveBeenCalledTimes(6) - for (const call of mocks.constructorSpy.mock.calls) { - expect(call[0]).toMatchObject({ - project: 'my-project', - location: 'europe-west1', - vertexai: true, - }) - } + expect(mocks.constructorSpy.mock.calls).toEqual([ + [ + 'text', + { project: 'my-project', location: 'europe-west1', vertexai: true }, + 'gemini-3.7-flash', + ], + [ + 'image', + { project: 'my-project', location: 'europe-west1', vertexai: true }, + 'gemini-3.1-flash-image', + ], + [ + 'embedding', + { project: 'my-project', location: 'europe-west1', vertexai: true }, + 'gemini-embedding-001', + ], + [ + 'speech', + { project: 'my-project', location: 'europe-west1', vertexai: true }, + 'gemini-3.1-flash-tts-preview', + ], + [ + 'audio', + { project: 'my-project', location: 'europe-west1', vertexai: true }, + 'lyria-3-pro-preview', + ], + [ + 'video', + { + project: 'my-project', + location: 'europe-west1', + vertexai: true, + allowUrlFetch: undefined, + }, + 'veo-3.1-generate-preview', + ], + ]) }) }) diff --git a/packages/ai-vue/src/use-chat.ts b/packages/ai-vue/src/use-chat.ts index 8e748f1a75..e1a4cdf1f5 100644 --- a/packages/ai-vue/src/use-chat.ts +++ b/packages/ai-vue/src/use-chat.ts @@ -149,48 +149,28 @@ export function useChat< ...(options.streamProcessor !== undefined && { streamProcessor: options.streamProcessor, }), - onMessagesChange: (newMessages: Array>) => { - messages.value = newMessages - }, - onLoadingChange: (newIsLoading: boolean) => { - isLoading.value = newIsLoading - syncResumeState() - }, - onStatusChange: (newStatus: ChatClientState) => { - status.value = newStatus - }, - onErrorChange: (newError: Error | undefined) => { - error.value = newError - }, - onSubscriptionChange: (nextIsSubscribed: boolean) => { - isSubscribed.value = nextIsSubscribed - }, - onConnectionStatusChange: (nextStatus: ConnectionStatus) => { - connectionStatus.value = nextStatus - }, - onSessionGeneratingChange: (isGenerating: boolean) => { - sessionGenerating.value = isGenerating - }, ...(options.queue !== undefined && { queue: options.queue }), - onQueueChange: (nextQueue: Array) => { - queue.value = nextQueue - }, - onRunIdChange: (nextRunId) => { - runId.value = nextRunId - }, onInterruptStateChange: (nextInterruptState, context) => { - interruptState.value = nextInterruptState options.onInterruptStateChange?.(nextInterruptState, context) }, }) - function syncResumeState() { - runId.value = client.getCurrentRunId() - interruptState.value = client.getInterruptState() + function applySnapshot() { + const next = client.getSnapshot() + messages.value = next.messages as Array> + isLoading.value = next.isLoading + error.value = next.error + status.value = next.status + isSubscribed.value = next.isSubscribed + connectionStatus.value = next.connectionStatus + sessionGenerating.value = next.sessionGenerating + queue.value = next.queue as Array + runId.value = next.runId + interruptState.value = next.interruptState } - messages.value = client.getMessages() - interruptState.value = client.getInterruptState() + applySnapshot() + onScopeDispose(client.subscribeSnapshot(applySnapshot)) // Sync body / forwardedProps changes to the client. // Both populate the same wire payload; `forwardedProps` is preferred @@ -236,10 +216,6 @@ export function useChat< // connections per origin until the page reloaded. client.attach() client.mountDevtools() - // Delivery-durability resume is transparent: the resumable SSE connection - // adapter reattaches via the browser's native Last-Event-ID on reconnect. - // We only seed interrupt (state) resume from the client here. - syncResumeState() }) // Cleanup on unmount: stop any in-flight requests @@ -263,29 +239,17 @@ export function useChat< content: string | MultimodalContent, sendOptions?: SendMessageOptions, ) => { - try { - await client.sendMessage(content, undefined, sendOptions) - } finally { - syncResumeState() - } + await client.sendMessage(content, undefined, sendOptions) } const cancelQueued = (id: string) => client.cancelQueued(id) const append = async (message: ModelMessage | UIMessage) => { - try { - await client.append(message) - } finally { - syncResumeState() - } + await client.append(message) } const reload = async () => { - try { - await client.reload() - } finally { - syncResumeState() - } + await client.reload() } const stop = () => { @@ -294,7 +258,6 @@ export function useChat< const clear = () => { client.clear() - syncResumeState() } const setMessagesManually = (newMessages: Array>) => { @@ -316,16 +279,13 @@ export function useChat< approved: boolean }) => { await client.addToolApprovalResponse(response) - syncResumeState() } const resumeInterrupts = async ( resumeItems: Array, state?: ChatResumeState, ) => { - const result = await client.resumeInterrupts(resumeItems, state) - syncResumeState() - return result + return await client.resumeInterrupts(resumeItems, state) } const interrupts = computed(() => interruptState.value.interrupts) diff --git a/packages/ai-vue/src/use-generate-video.ts b/packages/ai-vue/src/use-generate-video.ts index fb2ac7a2a7..66ae22cd0a 100644 --- a/packages/ai-vue/src/use-generate-video.ts +++ b/packages/ai-vue/src/use-generate-video.ts @@ -231,34 +231,6 @@ export function useGenerateVideo( onStatusUpdate: (s: VideoStatusInfo) => { if (!disposed) options.onStatusUpdate?.(s) }, - onResultChange: (r: TOutput | null) => { - if (disposed) return - result.value = r - }, - onLoadingChange: (l: boolean) => { - if (disposed) return - isLoading.value = l - }, - onErrorChange: (e: Error | undefined) => { - if (disposed) return - error.value = e - }, - onStatusChange: (s: GenerationClientState) => { - if (disposed) return - status.value = s - }, - onJobIdChange: (id: string | null) => { - if (disposed) return - jobId.value = id - }, - onVideoStatusChange: (s: VideoStatusInfo | null) => { - if (disposed) return - videoStatus.value = s - }, - onResumeStateChange: (rs: { runId: string } | null) => { - if (disposed) return - runId.value = rs?.runId ?? null - }, } let client: VideoGenerationClient @@ -279,6 +251,19 @@ export function useGenerateVideo( ) } + const applySnapshot = () => { + const next = client.getSnapshot() + result.value = next.result + isLoading.value = next.isLoading + error.value = next.error + status.value = next.status + runId.value = next.runId + jobId.value = next.jobId + videoStatus.value = next.videoStatus + } + applySnapshot() + onScopeDispose(client.subscribe(applySnapshot)) + // Sync body changes to the client. // Conditional spread: `updateOptions` declares `body?: Record` // (strict optional) and rejects explicit `undefined` under EOPT. diff --git a/packages/ai-vue/src/use-generation.ts b/packages/ai-vue/src/use-generation.ts index cbec8f435f..2095da2d28 100644 --- a/packages/ai-vue/src/use-generation.ts +++ b/packages/ai-vue/src/use-generation.ts @@ -220,26 +220,6 @@ export function useGeneration< onChunk: (c: StreamChunk) => { if (!disposed) options.onChunk?.(c) }, - onResultChange: (r: TOutput | null) => { - if (disposed) return - result.value = r - }, - onLoadingChange: (l: boolean) => { - if (disposed) return - isLoading.value = l - }, - onErrorChange: (e: Error | undefined) => { - if (disposed) return - error.value = e - }, - onStatusChange: (s: GenerationClientState) => { - if (disposed) return - status.value = s - }, - onResumeStateChange: (rs) => { - if (disposed) return - runId.value = rs?.runId ?? null - }, } const persistenceProps = @@ -274,6 +254,17 @@ export function useGeneration< ) } + const applySnapshot = () => { + const next = client.getSnapshot() + result.value = next.result + isLoading.value = next.isLoading + error.value = next.error + status.value = next.status + runId.value = next.runId + } + applySnapshot() + onScopeDispose(client.subscribe(applySnapshot)) + // Sync body changes to the client. // Conditional spread: `updateOptions` declares `body?: Record` // (strict optional) and rejects explicit `undefined` under EOPT. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index da0e7c0f37..f056ed5d3b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -204,11 +204,11 @@ importers: specifier: workspace:* version: link:../../packages/ai-sandbox-local-process '@tanstack/react-router': - specifier: ^1.158.4 - version: 1.159.5(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + specifier: ^1.170.32 + version: 1.170.32(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@tanstack/react-start': - specifier: ^1.159.0 - version: 1.159.5(crossws@0.4.6(srvx@0.11.17))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite-plugin-solid@2.11.14(solid-js@1.9.10)(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) + specifier: ^1.168.49 + version: 1.168.49(crossws@0.4.6(srvx@0.11.17))(esbuild@0.28.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(rolldown@1.2.4)(rollup@4.60.1)(vite-plugin-solid@2.11.14(solid-js@1.9.10)(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) lucide-react: specifier: ^0.561.0 version: 0.561.0(react@19.2.3) @@ -244,8 +244,8 @@ importers: specifier: ^4.1.18 version: 4.1.18(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) '@tanstack/router-plugin': - specifier: ^1.158.4 - version: 1.159.5(@tanstack/react-router@1.159.5(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(supports-color@7.2.0)(vite-plugin-solid@2.11.14(solid-js@1.9.10)(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) + specifier: ^1.168.35 + version: 1.168.35(@tanstack/react-router@1.170.32(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(esbuild@0.28.1)(rolldown@1.2.4)(rollup@4.60.1)(vite-plugin-solid@2.11.14(solid-js@1.9.10)(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) '@types/node': specifier: ^24.10.1 version: 24.10.3 @@ -292,11 +292,11 @@ importers: specifier: workspace:* version: link:../../packages/ai-sandbox-docker '@tanstack/react-router': - specifier: ^1.158.4 - version: 1.159.5(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + specifier: ^1.170.32 + version: 1.170.32(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@tanstack/react-start': - specifier: ^1.159.0 - version: 1.159.5(crossws@0.4.6(srvx@0.11.17))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite-plugin-solid@2.11.14(solid-js@1.9.10)(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) + specifier: ^1.168.49 + version: 1.168.49(crossws@0.4.6(srvx@0.11.17))(esbuild@0.28.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(rolldown@1.2.4)(rollup@4.60.1)(vite-plugin-solid@2.11.14(solid-js@1.9.10)(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) lucide-react: specifier: ^0.561.0 version: 0.561.0(react@19.2.3) @@ -329,8 +329,8 @@ importers: specifier: ^4.1.18 version: 4.1.18(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) '@tanstack/router-plugin': - specifier: ^1.158.4 - version: 1.159.5(@tanstack/react-router@1.159.5(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(supports-color@7.2.0)(vite-plugin-solid@2.11.14(solid-js@1.9.10)(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) + specifier: ^1.168.35 + version: 1.168.35(@tanstack/react-router@1.170.32(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(esbuild@0.28.1)(rolldown@1.2.4)(rollup@4.60.1)(vite-plugin-solid@2.11.14(solid-js@1.9.10)(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) '@types/node': specifier: ^24.10.1 version: 24.10.3 @@ -480,17 +480,17 @@ importers: specifier: workspace:* version: link:../../packages/ai-react '@tanstack/react-router': - specifier: ^1.158.4 - version: 1.159.5(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + specifier: ^1.170.32 + version: 1.170.32(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@tanstack/react-start': - specifier: ^1.159.0 - version: 1.159.5(crossws@0.4.6(srvx@0.11.17))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite-plugin-solid@2.11.14(solid-js@1.9.10)(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) + specifier: ^1.168.49 + version: 1.168.49(crossws@0.4.6(srvx@0.11.17))(esbuild@0.28.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(rolldown@1.2.4)(rollup@4.60.1)(vite-plugin-solid@2.11.14(solid-js@1.9.10)(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) '@tanstack/router-plugin': - specifier: ^1.158.4 - version: 1.159.5(@tanstack/react-router@1.159.5(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(supports-color@7.2.0)(vite-plugin-solid@2.11.14(solid-js@1.9.10)(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) + specifier: ^1.168.35 + version: 1.168.35(@tanstack/react-router@1.170.32(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(esbuild@0.28.1)(rolldown@1.2.4)(rollup@4.60.1)(vite-plugin-solid@2.11.14(solid-js@1.9.10)(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) '@tanstack/store': - specifier: ^0.8.0 - version: 0.8.0 + specifier: ^0.11.1 + version: 0.11.1 framer-motion: specifier: ^11.15.0 version: 11.18.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -589,17 +589,17 @@ importers: specifier: ^0.9.10 version: 0.9.10(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(csstype@3.2.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(solid-js@1.9.10) '@tanstack/react-router': - specifier: ^1.158.4 - version: 1.159.5(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + specifier: ^1.170.32 + version: 1.170.32(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@tanstack/react-router-devtools': - specifier: ^1.158.4 - version: 1.159.5(@tanstack/react-router@1.159.5(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@tanstack/router-core@1.159.4)(csstype@3.2.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + specifier: ^1.167.1 + version: 1.167.1(@tanstack/react-router@1.170.32(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@tanstack/router-core@1.171.27)(csstype@3.2.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@tanstack/react-start': - specifier: ^1.159.0 - version: 1.159.5(crossws@0.4.6(srvx@0.11.17))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite-plugin-solid@2.11.14(solid-js@1.9.10)(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) + specifier: ^1.168.49 + version: 1.168.49(crossws@0.4.6(srvx@0.11.17))(esbuild@0.28.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(rolldown@1.2.4)(rollup@4.60.1)(vite-plugin-solid@2.11.14(solid-js@1.9.10)(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) '@tanstack/router-plugin': - specifier: ^1.158.4 - version: 1.159.5(@tanstack/react-router@1.159.5(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(supports-color@7.2.0)(vite-plugin-solid@2.11.14(solid-js@1.9.10)(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) + specifier: ^1.168.35 + version: 1.168.35(@tanstack/react-router@1.170.32(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(esbuild@0.28.1)(rolldown@1.2.4)(rollup@4.60.1)(vite-plugin-solid@2.11.14(solid-js@1.9.10)(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) capnweb: specifier: ^0.10.0 version: 0.10.0 @@ -773,26 +773,26 @@ importers: specifier: ^0.9.10 version: 0.9.10(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(csstype@3.2.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(solid-js@1.9.10) '@tanstack/react-router': - specifier: ^1.158.4 - version: 1.159.5(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + specifier: ^1.170.32 + version: 1.170.32(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@tanstack/react-router-devtools': - specifier: ^1.158.4 - version: 1.159.5(@tanstack/react-router@1.159.5(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@tanstack/router-core@1.159.4)(csstype@3.2.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + specifier: ^1.167.1 + version: 1.167.1(@tanstack/react-router@1.170.32(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@tanstack/router-core@1.171.27)(csstype@3.2.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@tanstack/react-router-ssr-query': - specifier: ^1.158.4 - version: 1.159.5(@tanstack/query-core@5.90.12)(@tanstack/react-query@5.90.12(react@19.2.3))(@tanstack/react-router@1.159.5(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@tanstack/router-core@1.159.4)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + specifier: ^1.167.2 + version: 1.167.2(@tanstack/query-core@5.90.12)(@tanstack/react-query@5.90.12(react@19.2.3))(@tanstack/react-router@1.170.32(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@tanstack/router-core@1.171.27)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@tanstack/react-start': - specifier: ^1.159.0 - version: 1.159.5(crossws@0.4.6(srvx@0.11.17))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite-plugin-solid@2.11.14(solid-js@1.9.10)(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) + specifier: ^1.168.49 + version: 1.168.49(crossws@0.4.6(srvx@0.11.17))(esbuild@0.28.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(rolldown@1.2.4)(rollup@4.60.1)(vite-plugin-solid@2.11.14(solid-js@1.9.10)(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) '@tanstack/react-store': - specifier: ^0.8.0 - version: 0.8.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + specifier: ^0.11.1 + version: 0.11.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@tanstack/router-plugin': - specifier: ^1.158.4 - version: 1.159.5(@tanstack/react-router@1.159.5(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(supports-color@7.2.0)(vite-plugin-solid@2.11.14(solid-js@1.9.10)(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) + specifier: ^1.168.35 + version: 1.168.35(@tanstack/react-router@1.170.32(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(esbuild@0.28.1)(rolldown@1.2.4)(rollup@4.60.1)(vite-plugin-solid@2.11.14(solid-js@1.9.10)(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) '@tanstack/store': - specifier: ^0.8.0 - version: 0.8.0 + specifier: ^0.11.1 + version: 0.11.1 dockerode: specifier: ^4.0.2 version: 4.0.12(supports-color@7.2.0) @@ -909,14 +909,14 @@ importers: specifier: workspace:* version: link:../../packages/ai-react '@tanstack/react-router': - specifier: ^1.158.4 - version: 1.159.5(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + specifier: ^1.170.32 + version: 1.170.32(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@tanstack/react-start': - specifier: ^1.159.0 - version: 1.159.5(crossws@0.4.6(srvx@0.11.17))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite-plugin-solid@2.11.14(solid-js@1.9.10)(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) + specifier: ^1.168.49 + version: 1.168.49(crossws@0.4.6(srvx@0.11.17))(esbuild@0.28.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(rolldown@1.2.4)(rollup@4.60.1)(vite-plugin-solid@2.11.14(solid-js@1.9.10)(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) '@tanstack/router-plugin': - specifier: ^1.158.4 - version: 1.159.5(@tanstack/react-router@1.159.5(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(supports-color@7.2.0)(vite-plugin-solid@2.11.14(solid-js@1.9.10)(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) + specifier: ^1.168.35 + version: 1.168.35(@tanstack/react-router@1.170.32(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(esbuild@0.28.1)(rolldown@1.2.4)(rollup@4.60.1)(vite-plugin-solid@2.11.14(solid-js@1.9.10)(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) lucide-react: specifier: ^0.561.0 version: 0.561.0(react@19.2.3) @@ -1013,14 +1013,14 @@ importers: specifier: workspace:* version: link:../../packages/ai-openrouter '@tanstack/react-router': - specifier: ^1.158.4 - version: 1.159.5(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + specifier: ^1.170.32 + version: 1.170.32(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@tanstack/react-start': - specifier: ^1.159.0 - version: 1.159.5(crossws@0.4.6(srvx@0.11.17))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite-plugin-solid@2.11.14(solid-js@1.9.10)(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) + specifier: ^1.168.49 + version: 1.168.49(crossws@0.4.6(srvx@0.11.17))(esbuild@0.28.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(rolldown@1.2.4)(rollup@4.60.1)(vite-plugin-solid@2.11.14(solid-js@1.9.10)(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) '@tanstack/router-plugin': - specifier: ^1.158.4 - version: 1.159.5(@tanstack/react-router@1.159.5(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(supports-color@7.2.0)(vite-plugin-solid@2.11.14(solid-js@1.9.10)(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) + specifier: ^1.168.35 + version: 1.168.35(@tanstack/react-router@1.170.32(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(esbuild@0.28.1)(rolldown@1.2.4)(rollup@4.60.1)(vite-plugin-solid@2.11.14(solid-js@1.9.10)(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) lucide-react: specifier: ^0.561.0 version: 0.561.0(react@19.2.3) @@ -1086,23 +1086,23 @@ importers: specifier: ^5.90.12 version: 5.90.12(react@19.2.3) '@tanstack/react-router': - specifier: ^1.158.4 - version: 1.159.5(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + specifier: ^1.170.32 + version: 1.170.32(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@tanstack/react-router-devtools': - specifier: ^1.158.4 - version: 1.159.5(@tanstack/react-router@1.159.5(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@tanstack/router-core@1.159.4)(csstype@3.2.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + specifier: ^1.167.1 + version: 1.167.1(@tanstack/react-router@1.170.32(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@tanstack/router-core@1.171.27)(csstype@3.2.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@tanstack/react-router-ssr-query': - specifier: ^1.158.4 - version: 1.159.5(@tanstack/query-core@5.90.12)(@tanstack/react-query@5.90.12(react@19.2.3))(@tanstack/react-router@1.159.5(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@tanstack/router-core@1.159.4)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + specifier: ^1.167.2 + version: 1.167.2(@tanstack/query-core@5.90.12)(@tanstack/react-query@5.90.12(react@19.2.3))(@tanstack/react-router@1.170.32(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@tanstack/router-core@1.171.27)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@tanstack/react-start': - specifier: ^1.159.0 - version: 1.159.5(crossws@0.4.6(srvx@0.11.17))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite-plugin-solid@2.11.14(solid-js@1.9.10)(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) + specifier: ^1.168.49 + version: 1.168.49(crossws@0.4.6(srvx@0.11.17))(esbuild@0.28.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(rolldown@1.2.4)(rollup@4.60.1)(vite-plugin-solid@2.11.14(solid-js@1.9.10)(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) '@tanstack/router-plugin': - specifier: ^1.158.4 - version: 1.159.5(@tanstack/react-router@1.159.5(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(supports-color@7.2.0)(vite-plugin-solid@2.11.14(solid-js@1.9.10)(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) + specifier: ^1.168.35 + version: 1.168.35(@tanstack/react-router@1.170.32(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(esbuild@0.28.1)(rolldown@1.2.4)(rollup@4.60.1)(vite-plugin-solid@2.11.14(solid-js@1.9.10)(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) '@tanstack/zod-adapter': specifier: ^1.140.1 - version: 1.166.9(@tanstack/react-router@1.159.5(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(zod@4.3.6) + version: 1.166.9(@tanstack/react-router@1.170.32(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(zod@4.3.6) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -1201,17 +1201,17 @@ importers: specifier: ^0.9.10 version: 0.9.10(@types/react-dom@19.2.3(@types/react@19.2.18))(@types/react@19.2.18)(csstype@3.2.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(solid-js@1.9.10) '@tanstack/react-router': - specifier: ^1.158.4 - version: 1.159.5(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + specifier: ^1.170.32 + version: 1.170.32(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@tanstack/react-router-devtools': - specifier: ^1.158.4 - version: 1.159.5(@tanstack/react-router@1.159.5(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@tanstack/router-core@1.159.4)(csstype@3.2.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + specifier: ^1.167.1 + version: 1.167.1(@tanstack/react-router@1.170.32(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@tanstack/router-core@1.171.27)(csstype@3.2.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@tanstack/react-start': - specifier: ^1.159.0 - version: 1.159.5(crossws@0.4.6(srvx@0.11.17))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite-plugin-solid@2.11.14(solid-js@1.9.10)(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) + specifier: ^1.168.49 + version: 1.168.49(crossws@0.4.6(srvx@0.11.17))(esbuild@0.28.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(rolldown@1.2.4)(rollup@4.60.1)(vite-plugin-solid@2.11.14(solid-js@1.9.10)(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) '@tanstack/router-plugin': - specifier: ^1.158.4 - version: 1.159.5(@tanstack/react-router@1.159.5(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(supports-color@7.2.0)(vite-plugin-solid@2.11.14(solid-js@1.9.10)(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) + specifier: ^1.168.35 + version: 1.168.35(@tanstack/react-router@1.170.32(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(esbuild@0.28.1)(rolldown@1.2.4)(rollup@4.60.1)(vite-plugin-solid@2.11.14(solid-js@1.9.10)(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -1338,8 +1338,8 @@ importers: specifier: ^1.155.0 version: 1.155.0(@electric-sql/pglite@0.4.3)(aws4fetch@1.0.20)(better-sqlite3@12.11.1)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260317.1)(@electric-sql/pglite@0.4.3)(@opentelemetry/api@1.9.1)(@prisma/client@7.9.0(prisma@7.9.0(@types/react@19.2.18)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@12.11.1)(bun-types@1.3.14)(mysql2@3.15.3)(postgres@3.4.7)(prisma@7.9.0(@types/react@19.2.18)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)))(mysql2@3.15.3)(rolldown@1.1.5)(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) '@tanstack/router-plugin': - specifier: ^1.158.4 - version: 1.159.5(@tanstack/react-router@1.159.5(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(supports-color@7.2.0)(vite-plugin-solid@2.11.14(solid-js@1.9.10)(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) + specifier: ^1.168.35 + version: 1.168.35(@tanstack/react-router@1.170.32(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(esbuild@0.28.1)(rolldown@1.2.4)(rollup@4.60.1)(vite-plugin-solid@2.11.14(solid-js@1.9.10)(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) '@tanstack/solid-ai-devtools': specifier: workspace:* version: link:../../packages/solid-ai-devtools @@ -1347,23 +1347,23 @@ importers: specifier: ^0.7.15 version: 0.7.17(csstype@3.2.3)(solid-js@1.9.10) '@tanstack/solid-router': - specifier: ^1.139.10 - version: 1.141.1(solid-js@1.9.10) + specifier: ^1.170.30 + version: 1.170.30(solid-js@1.9.10) '@tanstack/solid-router-devtools': - specifier: ^1.139.10 - version: 1.141.1(@tanstack/router-core@1.159.4)(@tanstack/solid-router@1.141.1(solid-js@1.9.10))(csstype@3.2.3)(solid-js@1.9.10) + specifier: ^1.167.1 + version: 1.167.1(@tanstack/router-core@1.171.27)(@tanstack/solid-router@1.170.30(solid-js@1.9.10))(csstype@3.2.3)(solid-js@1.9.10) '@tanstack/solid-router-ssr-query': - specifier: ^1.139.10 - version: 1.141.1(@tanstack/query-core@5.90.12)(@tanstack/router-core@1.159.4)(@tanstack/solid-query@5.90.15(solid-js@1.9.10))(@tanstack/solid-router@1.141.1(solid-js@1.9.10))(eslint@9.39.4(jiti@2.7.0))(solid-js@1.9.10)(supports-color@7.2.0)(typescript@5.9.3) + specifier: ^1.167.2 + version: 1.167.2(@tanstack/query-core@5.90.12)(@tanstack/router-core@1.171.27)(@tanstack/solid-query@5.90.15(solid-js@1.9.10))(@tanstack/solid-router@1.170.30(solid-js@1.9.10))(solid-js@1.9.10) '@tanstack/solid-start': - specifier: ^1.139.10 - version: 1.141.1(@tanstack/react-router@1.159.5(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(crossws@0.4.6(srvx@0.11.17))(solid-js@1.9.10)(vite-plugin-solid@2.11.14(solid-js@1.9.10)(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) + specifier: ^1.168.47 + version: 1.168.47(@tanstack/react-router@1.170.32(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(crossws@0.4.6(srvx@0.11.17))(esbuild@0.28.1)(rolldown@1.2.4)(rollup@4.60.1)(solid-js@1.9.10)(vite-plugin-solid@2.11.14(solid-js@1.9.10)(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) '@tanstack/solid-store': - specifier: ^0.8.0 - version: 0.8.0(solid-js@1.9.10) + specifier: ^0.11.1 + version: 0.11.1(solid-js@1.9.10) '@tanstack/store': - specifier: ^0.8.0 - version: 0.8.0 + specifier: ^0.11.1 + version: 0.11.1 highlight.js: specifier: ^11.11.1 version: 11.11.1 @@ -1785,6 +1785,9 @@ importers: '@tanstack/ai-utils': specifier: workspace:^ version: link:../ai-utils + '@tanstack/store': + specifier: ^0.11.1 + version: 0.11.1 devDependencies: '@standard-schema/spec': specifier: ^1.1.0 @@ -2910,9 +2913,6 @@ importers: specifier: workspace:^ version: link:../ai-gemini devDependencies: - '@google/genai': - specifier: ^2.10.0 - version: 2.10.0(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6)) '@tanstack/ai': specifier: workspace:* version: link:../ai @@ -3203,11 +3203,11 @@ importers: specifier: workspace:* version: link:../../packages/react-ai-devtools '@tanstack/react-router': - specifier: ^1.158.4 - version: 1.159.5(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + specifier: ^1.170.32 + version: 1.170.32(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@tanstack/react-start': - specifier: ^1.159.0 - version: 1.159.5(crossws@0.4.6(srvx@0.11.17))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite-plugin-solid@2.11.14(solid-js@1.9.10)(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) + specifier: ^1.168.49 + version: 1.168.49(crossws@0.4.6(srvx@0.11.17))(esbuild@0.28.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(rolldown@1.2.4)(rollup@4.60.1)(vite-plugin-solid@2.11.14(solid-js@1.9.10)(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) arktype: specifier: ^2.1.28 version: 2.2.0 @@ -3327,14 +3327,14 @@ importers: specifier: ^0.9.10 version: 0.9.10(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(csstype@3.2.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(solid-js@1.9.10) '@tanstack/react-router': - specifier: ^1.158.4 - version: 1.159.5(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + specifier: ^1.170.32 + version: 1.170.32(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@tanstack/react-start': - specifier: ^1.159.0 - version: 1.159.5(crossws@0.4.6(srvx@0.11.17))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite-plugin-solid@2.11.14(solid-js@1.9.10)(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) + specifier: ^1.168.49 + version: 1.168.49(crossws@0.4.6(srvx@0.11.17))(esbuild@0.28.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(rolldown@1.2.4)(rollup@4.60.1)(vite-plugin-solid@2.11.14(solid-js@1.9.10)(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) '@tanstack/start': specifier: ^1.120.20 - version: 1.120.20(340eefabdbae5b0c948fd6f8943277d1) + version: 1.120.20(c688c64811f9265f6bbbab232e625bf6) highlight.js: specifier: ^11.11.1 version: 11.11.1 @@ -4261,10 +4261,6 @@ packages: resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==} engines: {node: '>=6.9.0'} - '@babel/template@7.27.2': - resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==} - engines: {node: '>=6.9.0'} - '@babel/template@7.29.7': resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} engines: {node: '>=6.9.0'} @@ -4281,10 +4277,6 @@ packages: resolution: {integrity: sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==} engines: {node: '>=6.9.0'} - '@babel/types@7.28.5': - resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==} - engines: {node: '>=6.9.0'} - '@babel/types@7.29.0': resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} @@ -6111,9 +6103,6 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} - '@nothing-but/utils@0.17.0': - resolution: {integrity: sha512-TuCHcHLOqDL0SnaAxACfuRHBNRgNJcNn9X0GiH5H3YSDBVquCr3qEIG3FOQAuMyZCbu9w8nk2CHhOsn7IvhIwQ==} - '@nx/nx-darwin-arm64@23.1.1': resolution: {integrity: sha512-Rq/RXLX5uIvJQfb6kuUgEirquT5ARaAgQyNaMZVnOPAL5wuxaDvQag8WX/WUCIgbUKZuGkclJwM7Vlnvtn3bdg==} cpu: [arm64] @@ -8836,9 +8825,6 @@ packages: '@rolldown/pluginutils@1.0.0-beta.27': resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} - '@rolldown/pluginutils@1.0.0-beta.40': - resolution: {integrity: sha512-s3GeJKSQOwBlzdUrj4ISjJj5SfSh+aqn0wjOar4Bx95iV1ETI7F6S/5hLcfAxZ9kXDcyrAkxPlqmd1ZITttf+w==} - '@rolldown/pluginutils@1.0.0-beta.53': resolution: {integrity: sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ==} @@ -9288,26 +9274,6 @@ packages: resolution: {integrity: sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==} engines: {node: '>=14.0.0'} - '@solid-devtools/debugger@0.28.1': - resolution: {integrity: sha512-6qIUI6VYkXoRnL8oF5bvh2KgH71qlJ18hNw/mwSyY6v48eb80ZR48/5PDXufUa3q+MBSuYa1uqTMwLewpay9eg==} - peerDependencies: - solid-js: ^1.9.0 - - '@solid-devtools/logger@0.9.11': - resolution: {integrity: sha512-THbiY1iQlieL6vdgJc4FIsLe7V8a57hod/Thm8zdKrTkWL88UPZjkBBfM+mVNGusd4OCnAN20tIFBhNnuT1Dew==} - peerDependencies: - solid-js: ^1.9.0 - - '@solid-devtools/shared@0.20.0': - resolution: {integrity: sha512-o5TACmUOQsxpzpOKCjbQqGk8wL8PMi+frXG9WNu4Lh3PQVUB6hs95Kl/S8xc++zwcMguUKZJn8h5URUiMOca6Q==} - peerDependencies: - solid-js: ^1.9.0 - - '@solid-primitives/bounds@0.1.3': - resolution: {integrity: sha512-UbiyKMdSPmtijcEDnYLQL3zzaejpwWDAJJ4Gt5P0hgVs6A72piov0GyNw7V2SroH7NZFwxlYS22YmOr8A5xc1Q==} - peerDependencies: - solid-js: ^1.6.12 - '@solid-primitives/event-listener@2.4.3': resolution: {integrity: sha512-h4VqkYFv6Gf+L7SQj+Y6puigL/5DIi7x5q07VZET7AWcS+9/G3WfIE9WheniHWJs51OEkRB43w6lDys5YeFceg==} peerDependencies: @@ -9318,11 +9284,6 @@ packages: peerDependencies: solid-js: ^1.6.12 - '@solid-primitives/media@2.3.3': - resolution: {integrity: sha512-hQ4hLOGvfbugQi5Eu1BFWAIJGIAzztq9x0h02xgBGl2l0Jaa3h7tg6bz5tV1NSuNYVGio4rPoa7zVQQLkkx9dA==} - peerDependencies: - solid-js: ^1.6.12 - '@solid-primitives/refs@1.1.2': resolution: {integrity: sha512-K7tf2thy7L+YJjdqXspXOg5xvNEOH8tgEWsp0+1mQk3obHBRD6hEjYZk7p7FlJphSZImS35je3UfmWuD7MhDfg==} peerDependencies: @@ -9338,21 +9299,11 @@ packages: peerDependencies: solid-js: ^1.6.12 - '@solid-primitives/scheduled@1.5.2': - resolution: {integrity: sha512-/j2igE0xyNaHhj6kMfcUQn5rAVSTLbAX+CDEBm25hSNBmNiHLu2lM7Usj2kJJ5j36D67bE8wR1hBNA8hjtvsQA==} - peerDependencies: - solid-js: ^1.6.12 - '@solid-primitives/static-store@0.1.2': resolution: {integrity: sha512-ReK+5O38lJ7fT+L6mUFvUr6igFwHBESZF+2Ug842s7fvlVeBdIVEdTCErygff6w7uR6+jrr7J8jQo+cYrEq4Iw==} peerDependencies: solid-js: ^1.6.12 - '@solid-primitives/styles@0.1.2': - resolution: {integrity: sha512-7iX5K+J5b1PRrbgw3Ki92uvU2LgQ0Kd/QMsrAZxDg5dpUBwMyTijZkA3bbs1ikZsT1oQhS41bTyKbjrXeU0Awg==} - peerDependencies: - solid-js: ^1.6.12 - '@solid-primitives/utils@6.3.2': resolution: {integrity: sha512-hZ/M/qr25QOCcwDPOHtGjxTD8w2mNyVAYvcfgwzBHq2RwNqHNdDNsMZYap20+ruRwW4A3Cdkczyoz0TSxLCAPQ==} peerDependencies: @@ -9644,6 +9595,10 @@ packages: resolution: {integrity: sha512-xyIfof8eHBuub1CkBnbKNKQXeRZC4dClhmzePHVOEel4G7lk/dW+TQ16da7CFdeNLv6u6Owf5VoBQxoo6DFTSA==} engines: {node: '>=12'} + '@tanstack/history@1.162.1': + resolution: {integrity: sha512-DR9t6lfLVdrjgCwpglrR9DR7Ok8/HlXjcOE+goWXF3zyuLUO/ug7vMbSFxTqrQTtbRghJfyhmIZ0S6LhPIy44w==} + engines: {node: '>=20.19'} + '@tanstack/nitro-v2-vite-plugin@1.155.0': resolution: {integrity: sha512-iW9S2c4QxlT+WgKVT+Mw8hzvLqyVSg4UefzX74LX5dfUxbGDahxZItp+07JSD5Ok8ZdTlUHzwLnIsncXh/MNAQ==} engines: {node: '>=22.12'} @@ -9682,24 +9637,24 @@ packages: peerDependencies: react: ^18 || ^19 - '@tanstack/react-router-devtools@1.159.5': - resolution: {integrity: sha512-IIyomu+ypWTxyoYT32mxamVmdTs7ZCGcTbdj7HVvtD3xp1lvo/bwRXj9oERENmb+OAPOaWF2doRYC/pmKjK5vg==} - engines: {node: '>=12'} + '@tanstack/react-router-devtools@1.167.1': + resolution: {integrity: sha512-pjfGrmjj4d7naEPM7oshqFfwBoxDPNo/UxltlHH5ePbHsJ+plBhd+JaAewm1ueYOjZ0js9hckjWWDYXpCrSfKw==} + engines: {node: '>=20.19'} peerDependencies: - '@tanstack/react-router': ^1.159.5 - '@tanstack/router-core': ^1.159.4 + '@tanstack/react-router': ^1.170.19 + '@tanstack/router-core': ^1.171.16 react: '>=18.0.0 || >=19.0.0' react-dom: '>=18.0.0 || >=19.0.0' peerDependenciesMeta: '@tanstack/router-core': optional: true - '@tanstack/react-router-ssr-query@1.159.5': - resolution: {integrity: sha512-r/Um9DASjmaTdiyXA3cpooGLrSD1BlcXUNsRz4zAlvD38m0gIFvaykOM2Naztqm9H+2CJkocRZp2/kR69Xcmyg==} - engines: {node: '>=12'} + '@tanstack/react-router-ssr-query@1.167.2': + resolution: {integrity: sha512-yRvy0VJ00R8huPuquk+qyYQGMpYRc/G33mc6/yZ4f7LaBZz9h/VbACjmLzhm/+6u5WSmUt6ouJJZnI1/5Npfag==} + engines: {node: '>=20.19'} peerDependencies: - '@tanstack/query-core': '>=5.90.0' - '@tanstack/react-query': '>=5.90.0' + '@tanstack/query-core': '>=5.102.0' + '@tanstack/react-query': '>=5.102.0' '@tanstack/react-router': '>=1.127.0' react: '>=18.0.0 || >=19.0.0' react-dom: '>=18.0.0 || >=19.0.0' @@ -9711,9 +9666,9 @@ packages: react: '>=18.0.0 || >=19.0.0' react-dom: '>=18.0.0 || >=19.0.0' - '@tanstack/react-router@1.159.5': - resolution: {integrity: sha512-rVb0MtKzP5c0BkWIoFgWBiRAJHYSU3bhsEHbT0cRdRLmlJiw21Awb6VEjgYq3hJiEhowcKKm6J8AdRD/8oZ5dQ==} - engines: {node: '>=12'} + '@tanstack/react-router@1.170.32': + resolution: {integrity: sha512-SIpxvaTKco100a5ZR3ePmArbhtm3XOx+w1dpGYY9gxHDta4iXSKDdQuhLonwJbIMkVJsU1rwXf0UDHMrF/1snw==} + engines: {node: '>=20.19'} peerDependencies: react: '>=18.0.0 || >=19.0.0' react-dom: '>=18.0.0 || >=19.0.0' @@ -9725,8 +9680,8 @@ packages: react: '>=18.0.0 || >=19.0.0' react-dom: '>=18.0.0 || >=19.0.0' - '@tanstack/react-start-client@1.159.5': - resolution: {integrity: sha512-Qynx7XWHI1rhVpSUx6P40zazcQVJRhl2fnAcSH0I6vaAxSuZm8lvI37YacXPRu8flvI/ZGlF095arxiyps+A0w==} + '@tanstack/react-start-client@1.168.30': + resolution: {integrity: sha512-qsZuykUl1EF0/rc1bin1RtjFzz07YMOTBzhOstSDzbOVm/WKf1QKFTN+qAZi74xlbXSWNuT2MiFRyg4RKwj8iw==} engines: {node: '>=22.12.0'} peerDependencies: react: '>=18.0.0 || >=19.0.0' @@ -9743,6 +9698,23 @@ packages: resolution: {integrity: sha512-z+4YL6shTtsHjk32yaIemQwgkx6FcqwPBYfeNt7Co2eOpWrvsoo/Fe9869/oIY2sPyhiWDs1rDb3e0qnAy8Cag==} engines: {node: '>=12'} + '@tanstack/react-start-rsc@0.1.48': + resolution: {integrity: sha512-UglRdTMuF3c4dvzL/gh4dMVbMWHsPy8ZgTQdT2qpTlyt1b/3m+R40tBFdsfTgw76VTXivW+qV/ih0PN9000XTw==} + engines: {node: '>=22.12.0'} + peerDependencies: + '@rspack/core': '>=2.0.0-0' + '@vitejs/plugin-rsc': '>=0.5.30' + react: '>=18.0.0 || >=19.0.0' + react-dom: '>=18.0.0 || >=19.0.0' + react-server-dom-rspack: '>=0.0.2' + peerDependenciesMeta: + '@rspack/core': + optional: true + '@vitejs/plugin-rsc': + optional: true + react-server-dom-rspack: + optional: true + '@tanstack/react-start-server@1.141.1': resolution: {integrity: sha512-GsZ7De5CCDkKcrimvwJiYJHMFGw7LQRu8IuLvqyzYgQvJPcbkfY5UIQd2IZfgRNFegzb2aYzkAXwv5lxfdu1VQ==} engines: {node: '>=22.12.0'} @@ -9750,20 +9722,35 @@ packages: react: '>=18.0.0 || >=19.0.0' react-dom: '>=18.0.0 || >=19.0.0' - '@tanstack/react-start-server@1.159.5': - resolution: {integrity: sha512-X4/SunwDTEbGkYTfM0gR+79amfk6phAM+2+NlC2s7TX2cD51xE8bUz2a//RxfOh9xg8f0f2CRIO34xTEDHGTfQ==} + '@tanstack/react-start-server@1.167.37': + resolution: {integrity: sha512-cODHpFU8vIm7AdHii9W3NEuwyruNmT5wLDZjRsJLT9Jp+7FACnfJrRbvxnp1ldSv/9mxcHKi/OgwbdHQeMZcAQ==} engines: {node: '>=22.12.0'} peerDependencies: react: '>=18.0.0 || >=19.0.0' react-dom: '>=18.0.0 || >=19.0.0' - '@tanstack/react-start@1.159.5': - resolution: {integrity: sha512-vfnF7eYswAK54ru6Ay08nb0TXVzTBaVRsbbRW7hx2M0chgwtSx+YScYzoixqkccRARQBN8a/CeVq7vNFW8525w==} + '@tanstack/react-start@1.168.49': + resolution: {integrity: sha512-iQb1ZoEHqvZMGLR4G7v3tTtgL06yv/bwxvGP3waVHxVn7bRpyopM44YbOluaGkcJzc13ZTvdLKWYNAN3bxMX/Q==} engines: {node: '>=22.12.0'} peerDependencies: + '@rsbuild/core': ^2.0.0 + '@vitejs/plugin-rsc': '*' react: '>=18.0.0 || >=19.0.0' react-dom: '>=18.0.0 || >=19.0.0' vite: '>=7.0.0' + peerDependenciesMeta: + '@rsbuild/core': + optional: true + '@vitejs/plugin-rsc': + optional: true + vite: + optional: true + + '@tanstack/react-store@0.11.1': + resolution: {integrity: sha512-HaIGKI3YLmjBYIvy5DFDY23oNaYZIsTZfngey07Uh5iLVJgM3bIGCnZeOFOqzjFld9JHWcaHJnasD/bKoGKwJQ==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 '@tanstack/react-store@0.8.0': resolution: {integrity: sha512-1vG9beLIuB7q69skxK9r5xiLN3ztzIPfSQSs0GfeqWGO2tGIyInZx0x1COhpx97RKaONSoAb8C3dxacWksm1ow==} @@ -9771,6 +9758,12 @@ packages: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + '@tanstack/react-store@0.9.3': + resolution: {integrity: sha512-y2iHd/N9OkoQbFJLUX1T9vbc2O9tjH0pQRgTcx1/Nz4IlwLvkgpuglXUx+mXt0g5ZDFrEeDnONPqkbfxXJKwRg==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + '@tanstack/router-core@1.131.50': resolution: {integrity: sha512-eojd4JZ5ziUhGEmXZ4CaVX5mQdiTMiz56Sp8ZQ6r7deb55Q+5G4JQDkeuXpI7HMAvzr+4qlsFeLaDRXXjXyOqQ==} engines: {node: '>=12'} @@ -9783,22 +9776,15 @@ packages: resolution: {integrity: sha512-MFzPH39ijNO83qJN3pe7x4iAlhZyqgao3sJIzv3SJ4Pnk12xMnzuDzIAQT/1WV6JolPQEcw0Wr4L5agF8yxoeg==} engines: {node: '>=12'} - '@tanstack/router-devtools-core@1.141.1': - resolution: {integrity: sha512-wD9yRvOk6FI+thiNBplhkGutPIPBlXvWu9ttU/obdFY5oXQj9WYgNS+IO9BEe8Pz5rNEu8zE/oLn4RUGIVdtnw==} - engines: {node: '>=12'} - peerDependencies: - '@tanstack/router-core': ^1.141.1 - csstype: ^3.0.10 - solid-js: '>=1.9.5' - peerDependenciesMeta: - csstype: - optional: true + '@tanstack/router-core@1.171.27': + resolution: {integrity: sha512-wDwSLvoLwIaNcnx9UNcN9Mb7Y8QwCYq1U1RQZwyN186gnkIoIYI2SOxy8VqH1vFigbkHkk4FmwMAQlghPgDK2g==} + engines: {node: '>=20.19'} - '@tanstack/router-devtools-core@1.159.4': - resolution: {integrity: sha512-qMUeIv+6n1mZOcO2raCIbdOeDeMpJEmgm6oMs/nWEG61lYrzJYaCcpBTviAX0nRhSiQSUCX9cHiosUEA0e2HAw==} - engines: {node: '>=12'} + '@tanstack/router-devtools-core@1.168.1': + resolution: {integrity: sha512-qr4voa4cpSMwQvS3867xkU3AB3MtJbTuovKIy+btjJ/Faju6er9w0nDylmD+005Mk/3YKw9/iueZJl2JAB7JOA==} + engines: {node: '>=20.19'} peerDependencies: - '@tanstack/router-core': ^1.159.4 + '@tanstack/router-core': ^1.171.16 csstype: ^3.0.10 peerDependenciesMeta: csstype: @@ -9808,14 +9794,14 @@ packages: resolution: {integrity: sha512-zlMBw5l88GIg3v+378JsfDYq3ejEaJmD3P1R+m0yEPxh0N//Id1FjKNSS7yJbejlK2WGVm9DUG46iBdTDMQM+Q==} engines: {node: '>=12'} - '@tanstack/router-generator@1.141.1': - resolution: {integrity: sha512-21RbVAoIDn7s/n/PKMN6U60d5hCeVADrBH/uN6B/poMT4MVYtJXqISVzkc2RAboVRw6eRdYFeF+YlwA3nF6y3Q==} - engines: {node: '>=12'} - '@tanstack/router-generator@1.159.4': resolution: {integrity: sha512-O8tICQoSuvK6vs3mvBdI3zVLFmYfj/AYDCX0a5msSADP/2S0GsgDDTB5ah731TqYCtjeNriaWz9iqst38cjF/Q==} engines: {node: '>=12'} + '@tanstack/router-generator@1.167.33': + resolution: {integrity: sha512-Z3lCWIPuRUMPmuI8Mm48x/s49TxmHOaFVZ52j1W1QKYrsFHyT6U/h9bqfHJDxfQ8kz7y9q+W1YPKZ15Ee7yuCA==} + engines: {node: '>=20.19'} + '@tanstack/router-plugin@1.131.50': resolution: {integrity: sha512-gdEBPGzx7llQNRnaqfPJ1iaPS3oqB8SlvKRG5l7Fxp4q4yINgkeowFYSKEhPOc9bjoNhGrIHOlvPTPXEzAQXzQ==} engines: {node: '>=12'} @@ -9837,35 +9823,14 @@ packages: webpack: optional: true - '@tanstack/router-plugin@1.141.1': - resolution: {integrity: sha512-SoNXm8QK8cqX1Q4y1AfLU0tZLOt4mB4wkdpBI/Mi3ZEezF8tIrmaenyJ3987cjT9jedTy3VDBab1wN0g8MoOXg==} - engines: {node: '>=12'} - peerDependencies: - '@rsbuild/core': '>=1.0.2' - '@tanstack/react-router': ^1.141.1 - vite: '>=5.0.0 || >=6.0.0 || >=7.0.0' - vite-plugin-solid: ^2.11.10 - webpack: '>=5.92.0' - peerDependenciesMeta: - '@rsbuild/core': - optional: true - '@tanstack/react-router': - optional: true - vite: - optional: true - vite-plugin-solid: - optional: true - webpack: - optional: true - - '@tanstack/router-plugin@1.159.5': - resolution: {integrity: sha512-i2LR3WRaBOAZ1Uab5QBG9UxZIRJ3V56JVu890NysbuX15rgzRiL5yLAbfenOHdhaHy2+4joX35VICAHuVWy7Og==} - engines: {node: '>=12'} + '@tanstack/router-plugin@1.168.35': + resolution: {integrity: sha512-foDAZKFqHXae+oFbIgcsSvy2QCVRn7XdS3nhwcRvD+ed6JrKPUP/1lQMsZLJqWycgR1vkZF7gs955KGa0NZQ0w==} + engines: {node: '>=20.19'} peerDependencies: - '@rsbuild/core': '>=1.0.2' - '@tanstack/react-router': ^1.159.5 - vite: '>=5.0.0 || >=6.0.0 || >=7.0.0' - vite-plugin-solid: ^2.11.10 + '@rsbuild/core': '>=1.0.2 || ^2.0.0' + '@tanstack/react-router': ^1.170.32 + vite: '>=5.0.0 || >=6.0.0 || >=7.0.0 || >=8.0.0' + vite-plugin-solid: ^2.11.10 || ^3.0.0-0 webpack: '>=5.92.0' peerDependenciesMeta: '@rsbuild/core': @@ -9879,18 +9844,11 @@ packages: webpack: optional: true - '@tanstack/router-ssr-query-core@1.141.1': - resolution: {integrity: sha512-bkRXUhktifxBewnnphH59E0sGcsUI1NmNqxzCAmXIb93xYgafhjUGGYwfK6FqFBOmCB5isr32exGO3+UMHJr/A==} - engines: {node: '>=12'} - peerDependencies: - '@tanstack/query-core': '>=5.90.0' - '@tanstack/router-core': '>=1.127.0' - - '@tanstack/router-ssr-query-core@1.159.4': - resolution: {integrity: sha512-zqgJaf8iTk2ugrJqvU+b8NnFf3MKcnFaurtlp5tP38lNHQGW4En9UaOBOou/6zYZMlKQBcvqxSGJiwaLIyTK2g==} - engines: {node: '>=12'} + '@tanstack/router-ssr-query-core@1.169.2': + resolution: {integrity: sha512-7pO65Aiq/1+aS3Mb6vSGtIjzQ/YGv9JTfBbn2EYlHcZnJ4s4ZGCeTPI847geB1RfJqVhfwxM8bZgGM51mYkolg==} + engines: {node: '>=20.19'} peerDependencies: - '@tanstack/query-core': '>=5.90.0' + '@tanstack/query-core': '>=5.102.0' '@tanstack/router-core': '>=1.127.0' '@tanstack/router-utils@1.131.2': @@ -9905,6 +9863,10 @@ packages: resolution: {integrity: sha512-qZ76eaLKU6Ae9iI/mc5zizBX149DXXZkBVVO3/QRIll79uKLJZHQlMKR++2ba7JsciBWz1pgpIBcCJPE9S0LVg==} engines: {node: '>=12'} + '@tanstack/router-utils@1.162.2': + resolution: {integrity: sha512-hTWqJtqIFFdvuCl8WXNyrodp2L9zo2G37xKRrcVmVRWpAB2h+U1LuRAfS4tsFTiWOIoE/B+WDVFB8JpoEdw6jQ==} + engines: {node: '>=20.19'} + '@tanstack/server-functions-plugin@1.131.2': resolution: {integrity: sha512-hWsaSgEZAVyzHg8+IcJWCEtfI9ZSlNELErfLiGHG9XCHEXMegFWsrESsKHlASzJqef9RsuOLDl+1IMPIskwdDw==} engines: {node: '>=12'} @@ -9924,53 +9886,59 @@ packages: peerDependencies: solid-js: ^1.6.0 - '@tanstack/solid-router-devtools@1.141.1': - resolution: {integrity: sha512-ezm8V1UR47kY/xquIobascexHkbWOcH1YXc8BRvYDKV33NEdeUhMVUqkZmwuojlRYXhF7LEfj2aSwZr0uBfH8A==} - engines: {node: '>=12'} + '@tanstack/solid-router-devtools@1.167.1': + resolution: {integrity: sha512-GZiRbHx7E1vbuv1zGjPl1EmCKbwKZKoCU0TjycBjU6SW0rMt/24SON0MbtMbPlLs3XE28jFi7C5odwE2hryh/Q==} + engines: {node: '>=20.19'} peerDependencies: - '@tanstack/router-core': ^1.141.1 - '@tanstack/solid-router': ^1.141.1 + '@tanstack/router-core': ^1.171.16 + '@tanstack/solid-router': ^1.170.19 solid-js: ^1.9.10 peerDependenciesMeta: '@tanstack/router-core': optional: true - '@tanstack/solid-router-ssr-query@1.141.1': - resolution: {integrity: sha512-jM2RKblxWbrMV2nmZ+L+Y2HvfBZ448tZHXYBP/NrQa2YAHVXCZfVE5ojjB5vyDZHc7ueFAF8+yIvMGnIQQE99g==} - engines: {node: '>=12'} + '@tanstack/solid-router-ssr-query@1.167.2': + resolution: {integrity: sha512-4epfTG9kLKnD/azHJAU5pKTLD258X93OgKXCF342Caiq2y3Y8QfdTPl11efTA+TAcZlj4CjYT2Y/7WvBR19FAA==} + engines: {node: '>=20.19'} peerDependencies: - '@tanstack/query-core': '>=5.90.0' - '@tanstack/solid-query': '>=5.90.0' + '@tanstack/query-core': '>=5.102.0' + '@tanstack/solid-query': '>=5.102.0' '@tanstack/solid-router': '>=1.127.0' solid-js: ^1.9.10 - '@tanstack/solid-router@1.141.1': - resolution: {integrity: sha512-xcWkYdwvTXzQwLd4gI0efq7dUzXlcTyent/UDrGf1K5dDBqlFSAj9c11vNwP0+YvCaPt4U1r8czyMD1Qr3aNGA==} - engines: {node: '>=12'} + '@tanstack/solid-router@1.170.30': + resolution: {integrity: sha512-FozWeln6hx6o2T45gFFdkGQJxvq/LN0EUlzx7+eENy9HJH/thEBSb89KTkQahULzs9bsMOufzeXJJVgB6H9/wA==} + engines: {node: '>=20.19'} peerDependencies: solid-js: ^1.9.10 - '@tanstack/solid-start-client@1.141.1': - resolution: {integrity: sha512-NGc70Jj9DoWFieFX9iQG70fmuTvX7nfTUG5vlboxBzvTuPrIdyeddrQVnBX/kXUdFzLGzez7QlxqyOrm3zouuQ==} + '@tanstack/solid-start-client@1.168.29': + resolution: {integrity: sha512-+F3Z/J1SVxPj2Hs53Wvos2DhIe7cGvlHpwZD9x0+aIWofIlkRSnrGm2wehWZzun1tzgZNnK2L2thkQlXVtuVaw==} engines: {node: '>=22.12.0'} peerDependencies: solid-js: '>=1.0.0' - '@tanstack/solid-start-server@1.141.1': - resolution: {integrity: sha512-vIuw74+6cgBmh1zAqc27dKHd5rU2K4V6nHt+u7vBKUUu/9QIF7PoQD0fj2dWnrjnz7EJKb1W7Gr9rAtOdglgGw==} + '@tanstack/solid-start-server@1.167.36': + resolution: {integrity: sha512-7WMqLxe1Yr+9Q+Ze2dsYrPnousiPmA5LndJtg1MnaIRNecL1NInd5jsiIwNB42qOWTwcGr8aD8LnHH4dcVh/wA==} engines: {node: '>=22.12.0'} peerDependencies: solid-js: ^1.0.0 - '@tanstack/solid-start@1.141.1': - resolution: {integrity: sha512-b7B7UUPn3GANvnvTusiryTr+CUEs+lMMFRRiDiBZXx+Yb1cqtp/UYilEDHdqnx2xUoAhR0FBd4kRcieCV69DVg==} + '@tanstack/solid-start@1.168.47': + resolution: {integrity: sha512-yqXrj5Q1wGh/9Gb/wGp117AavC48tFk29LP/2oGHBJKq4ZATYes8s3yPBRSWlSkqQG9N2yQzjmF+sutXZSZVzw==} engines: {node: '>=22.12.0'} peerDependencies: + '@rsbuild/core': ^2.0.0 solid-js: '>=1.0.0' vite: '>=7.0.0' + peerDependenciesMeta: + '@rsbuild/core': + optional: true + vite: + optional: true - '@tanstack/solid-store@0.8.0': - resolution: {integrity: sha512-JwqTedbxyOGw7mfmdGkB0RGgefRCw/tNauc8tlMcaS1mV5wTFT8c1KIB3LgttuHaanMJEBeqQJ7bc/R0WTP1fA==} + '@tanstack/solid-store@0.11.1': + resolution: {integrity: sha512-wzJsiqqLHyQacqbYPT8dKNfhR00NNu743vGeWz2lEpOyE/R+6AYIfFLhXsCIT/Kb+KKiT1KDkB2/35DhLEGx8g==} peerDependencies: solid-js: ^1.6.0 @@ -9990,6 +9958,10 @@ packages: resolution: {integrity: sha512-9j2i1PRTIGcYAD+509znve0ngK81ZUfbX4XCpoNFMaUUpRHoEPPK5I9+PzLFvL9sNOto67x+WULCUggzX+lEKQ==} engines: {node: '>=22.12.0'} + '@tanstack/start-client-core@1.170.27': + resolution: {integrity: sha512-Ro6ZSM0NgYKDMxM0e8qyU4mBfnld2Zb74BA/9f4i35C0Y3IAA8Zxs/DIPfOo9VRYWp5c5n5Q8dRBn2qn0vtPhQ==} + engines: {node: '>=22.12.0'} + '@tanstack/start-config@1.120.20': resolution: {integrity: sha512-oH/mfTSHV8Qbil74tWicPLW6+kKmT3esXCnDzvrkhi3+N8ZuVUDr01Qpil0Wxf9lLPfM5L6VX03nF4hSU8vljg==} engines: {node: '>=12'} @@ -10002,23 +9974,27 @@ packages: resolution: {integrity: sha512-D69B78L6pcFN5X5PHaydv7CScQcKLzJeEYqs7jpuyyqGQHSUIZUjS955j+Sir8cHhuDIovCe2LmsYHeZfWf3dQ==} engines: {node: '>=22.12.0'} + '@tanstack/start-fn-stubs@1.162.0': + resolution: {integrity: sha512-QWfUZ3Yo923tdQn38LyKMU8rcTw69zc+T4dAvgTWV4O56SqFRsGfS0lSWIMhJRwXIx/bvdi7nTUBDdZtTHtpTQ==} + engines: {node: '>=22.12.0'} + '@tanstack/start-plugin-core@1.131.50': resolution: {integrity: sha512-eFvMA0chqLtHbq+8ojp1fXN7AQjhmeoOpQaZaU1d51wb7ugetrn0k3OuHblxtE/O0L4HEC9s4X5zmFJt0vLh0w==} engines: {node: '>=12'} peerDependencies: vite: '>=6.0.0' - '@tanstack/start-plugin-core@1.141.1': - resolution: {integrity: sha512-jXfgKeM4XX7aoP7WF/cJZH52N0ewIYRdP028ItXaSwUMcDyBO+PPTNbSEqTu3zbzLsmTOOyuJEpIrVA0/lmbjA==} - engines: {node: '>=22.12.0'} - peerDependencies: - vite: '>=7.0.0' - - '@tanstack/start-plugin-core@1.159.5': - resolution: {integrity: sha512-QGiBw+L3qu2sUY0Tg9KovxjDSi5kevoANEcq9RLX7iIhLkTjrILN6hnAlXZUzqk5Egaf0aN2yWhwI4HWucMprw==} + '@tanstack/start-plugin-core@1.171.39': + resolution: {integrity: sha512-Zyj6G4MDFLXcHYhPavhewCBo8dsxi3qvPk31zl/QtTzYzOY9rJHDDBGarKsJq20ziChmkGn/sttYqb4vGCxJDA==} engines: {node: '>=22.12.0'} peerDependencies: + '@rsbuild/core': ^2.0.0 vite: '>=7.0.0' + peerDependenciesMeta: + '@rsbuild/core': + optional: true + vite: + optional: true '@tanstack/start-server-core@1.131.50': resolution: {integrity: sha512-3SWwwhW2GKMhPSaqWRal6Jj1Y9ObfdWEXKFQid1LBuk5xk/Es4bmW68o++MbVgs/GxUxyeZ3TRVqb0c7RG1sog==} @@ -10032,6 +10008,10 @@ packages: resolution: {integrity: sha512-sGpr+iil+pcY3Gglvbnxaj7fCEPTQJv4oF7YA24SVv8YvayLXtBXpF26miJLA+KR9P31dQdPYe1gTjv5zRyvHg==} engines: {node: '>=22.12.0'} + '@tanstack/start-server-core@1.169.31': + resolution: {integrity: sha512-56w8l+Fao01YCrmv0hzNxL3b3FRmqLRGdE11izZNQsW+1CcSYuehAM5khWKABuI1DI/UIBLGV7BwL4Dlg0eHCw==} + engines: {node: '>=22.12.0'} + '@tanstack/start-server-functions-client@1.131.50': resolution: {integrity: sha512-4aM17fFdVAFH6uLPswKJxzrhhIjcCwKqzfTcgY3OnhUKnaZBTQwJA+nUHQCI6IWvEvrcrNVtFTtv13TkDk3YMw==} engines: {node: '>=12'} @@ -10064,16 +10044,26 @@ packages: resolution: {integrity: sha512-iGkmuCIq3PLI4GKOGwgUNHQKZ13YV8LGq62o2hVnyXE64Jm2SP7c5z6D1ndydpk4JwdRzQKlcOFT/1agvS6Nsg==} engines: {node: '>=22.12.0'} + '@tanstack/start-storage-context@1.167.29': + resolution: {integrity: sha512-8qfprC5774XMRDQlMogkfiGpFLiBf0xDG4bMFUbfkSzpCAQwpLbgGY4Zwft22O9rKYq2vUXusKAdVjvJTUEquQ==} + engines: {node: '>=22.12.0'} + '@tanstack/start@1.120.20': resolution: {integrity: sha512-fQO+O/5xJpli5KlV6pwDz6DtpbqO/0atdVSyVnkemzk0Mej9azm4HXtw+cKkIPtsSplWs4B1EbMtgGMb9ADhSA==} engines: {node: '>=12'} + '@tanstack/store@0.11.1': + resolution: {integrity: sha512-mzTOBhypOuDJAy/D8n2MfUZ1HFkXnmSETviRyhqEC8LUE7/IZQExOTxMANj3KjTofYTkFNpBY67qaVrT41YccA==} + '@tanstack/store@0.7.7': resolution: {integrity: sha512-xa6pTan1bcaqYDS9BDpSiS63qa6EoDkPN9RsRaxHuDdVDNntzq3xNwR5YKTU/V3SkSyC9T4YVOPh2zRQN0nhIQ==} '@tanstack/store@0.8.0': resolution: {integrity: sha512-Om+BO0YfMZe//X2z0uLF2j+75nQga6TpTJgLJQBiq85aOyZNIhkCgleNcud2KQg4k4v9Y9l+Uhru3qWMPGTOzQ==} + '@tanstack/store@0.9.3': + resolution: {integrity: sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw==} + '@tanstack/typedoc-config@0.3.4': resolution: {integrity: sha512-WXJavpiNqumNwdBQTM3wBqbHaULgoZ3cIri7xJinKqX/mOGXgc6ZghfAardD2PydP8tOcPf+xhrd1Qk/am9DMg==} engines: {node: '>=18'} @@ -10082,14 +10072,14 @@ packages: resolution: {integrity: sha512-VEEOxc4mvyu67O+Bl0APtYjwcNRcL9it9B4HKbNgcBTIOEalhk+ufBl4kiqc8WP1sx1+NAaiS+3CcJBhrqaSRg==} engines: {node: '>=12'} - '@tanstack/virtual-file-routes@1.141.0': - resolution: {integrity: sha512-CJrWtr6L9TVzEImm9S7dQINx+xJcYP/aDkIi6gnaWtIgbZs1pnzsE0yJc2noqXZ+yAOqLx3TBGpBEs9tS0P9/A==} - engines: {node: '>=12'} - '@tanstack/virtual-file-routes@1.154.7': resolution: {integrity: sha512-cHHDnewHozgjpI+MIVp9tcib6lYEQK5MyUr0ChHpHFGBl8Xei55rohFK0I0ve/GKoHeioaK42Smd8OixPp6CTg==} engines: {node: '>=12'} + '@tanstack/virtual-file-routes@1.162.0': + resolution: {integrity: sha512-uhOeFyxLcU41HzvrxsGpiWdcMbScY1EDgbZ5K7DVRMYInbLYWAC0EA/kx9wXAoSM8q82bUG2hRl8+EAjE6XAbA==} + engines: {node: '>=20.19'} + '@tanstack/vite-config@0.6.0': resolution: {integrity: sha512-TtcetFc7aBm9BBclG3/8pM8ZlyzS8yNwi+eScN4gqsB9xnxyOPCbuvvkk//ybfQj8MVEZavQ03EOQxmOJdW8Rw==} engines: {node: '>=18'} @@ -10373,32 +10363,16 @@ packages: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/project-service@8.49.0': - resolution: {integrity: sha512-/wJN0/DKkmRUMXjZUXYZpD1NEQzQAAn9QWfGwo+Ai8gnzqH7tvqS7oNVdTjKqOcPyVIdZdyCMoqN66Ia789e7g==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/project-service@8.59.4': resolution: {integrity: sha512-Ly00Vu4oAacfDeHp2Zg85ioNG6l8HG+tN1D7J+xTHSxu9y0awYKJ2zH1rFBn8ZSfuGK+7FxK3Cgl3uAz0aZZLg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/scope-manager@8.49.0': - resolution: {integrity: sha512-npgS3zi+/30KSOkXNs0LQXtsg9ekZ8OISAOLGWA/ZOEn0ZH74Ginfl7foziV8DT+D98WfQ5Kopwqb/PZOaIJGg==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/scope-manager@8.59.4': resolution: {integrity: sha512-mUeR/3H1WrTAddJrwut8OoPjfauaztMQmRwV5fQTUyNVJCLiUXXe4lGEyYIL2oFDpP7UtgbGJXCt72wT0z2S3Q==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/tsconfig-utils@8.49.0': - resolution: {integrity: sha512-8prixNi1/6nawsRYxet4YOhnbW+W9FK/bQPxsGB1D3ZrDzbJ5FXw5XmzxZv82X3B+ZccuSxo/X8q9nQ+mFecWA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/tsconfig-utils@8.59.4': resolution: {integrity: sha512-DLCpnKgD4alVxTBSKulK+gU1KCqOgUXfDRDXh2mZgzokQKa/70ax93I2uVO3m/LLvIAtWZIFoiifudmIqAxpMA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -10412,33 +10386,16 @@ packages: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/types@8.49.0': - resolution: {integrity: sha512-e9k/fneezorUo6WShlQpMxXh8/8wfyc+biu6tnAqA81oWrEic0k21RHzP9uqqpyBBeBKu4T+Bsjy9/b8u7obXQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/types@8.59.4': resolution: {integrity: sha512-F1o7WJcCq+bc8dwcO/YsSEOudAH8RDtaOhM6wcAQhcUsFhnWQl81JKy48q1hoxAU0qrzM89+31GYh1515Zde3Q==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/typescript-estree@8.49.0': - resolution: {integrity: sha512-jrLdRuAbPfPIdYNppHJ/D0wN+wwNfJ32YTAm10eJVsFmrVpXQnDWBn8niCSMlWjvml8jsce5E/O+86IQtTbJWA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/typescript-estree@8.59.4': resolution: {integrity: sha512-F+RuOmcDXo4+TPdfd/TCLS3m2nw8gE9XXyZLrA3JBfaA5tz9TtdkyD3YJFmPxulyc2cKbEok/CvFE3MgSLWnag==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/utils@8.49.0': - resolution: {integrity: sha512-N3W7rJw7Rw+z1tRsHZbK395TWSYvufBXumYtEGzypgMUthlg0/hmCImeA8hgO2d2G4pd7ftpxxul2J8OdtdaFA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - eslint: ^8.57.0 || ^9.0.0 - typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/utils@8.59.4': resolution: {integrity: sha512-cYXeNAUsG4lJo5dbc1FcKm+JwIWrj1/UpTORsC6tGMjEZ81DYcvIr9/ueikhMa/Y/gDQYGp+YX9/xQrXje5BJw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -10446,10 +10403,6 @@ packages: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/visitor-keys@8.49.0': - resolution: {integrity: sha512-LlKaciDe3GmZFphXIc79THF/YYBugZ7FS1pO581E/edlVVNbZKDy93evqmrfQ9/Y4uN0vVhX4iuchq26mK/iiA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/visitor-keys@8.59.4': resolution: {integrity: sha512-U3gxVaDVnuZKhSspW/MzMxE1kq7zOdc072FcSNoqA1I9p8HyKbBFfEHoWckBAMgNMph4MamwS5iTVzFmrnt8TQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -11647,6 +11600,9 @@ packages: cookie-es@2.0.1: resolution: {integrity: sha512-aVf4A4hI2w70LnF7GG+7xDQUkliwiXWXFvTjkip4+b64ygDQ2sJPRSKFDHbxn8o0xu9QzPkMuuiWIXyFSE2slA==} + cookie-es@3.1.1: + resolution: {integrity: sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==} + cookie-signature@1.2.2: resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} engines: {node: '>=6.6.0'} @@ -12380,13 +12336,6 @@ packages: engines: {node: '>=6.0'} hasBin: true - eslint-plugin-solid@0.14.5: - resolution: {integrity: sha512-nfuYK09ah5aJG/oEN6P1qziy1zLgW4PDWe75VNPi4CEFYk1x2AEqwFeQfEPR7gNn0F2jOeqKhx2E+5oNCOBYWQ==} - engines: {node: '>=18.0.0'} - peerDependencies: - eslint: ^6.0.0 || ^7.0.0 || ^8.0.0 || ^9.0.0 - typescript: '>=4.8.4' - eslint-plugin-unused-imports@4.3.0: resolution: {integrity: sha512-ZFBmXMGBYfHttdRtOG9nFFpmUvMtbHSjsKrS20vdWdbfiVYsO3yA2SGYy9i9XmZJDfMGBflZGBCm70SEnFQtOA==} peerDependencies: @@ -13038,6 +12987,16 @@ packages: crossws: optional: true + h3@2.0.1-rc.20: + resolution: {integrity: sha512-28ljodXuUp0fZovdiSRq4G9OgrxCztrJe5VdYzXAB7ueRvI7pIUqLU14Xi3XqdYJ/khXjfpUOOD2EQa6CmBgsg==} + engines: {node: '>=20.11.1'} + hasBin: true + peerDependencies: + crossws: ^0.4.1 + peerDependenciesMeta: + crossws: + optional: true + h3@2.0.1-rc.22: resolution: {integrity: sha512-Esv0DMIuPkCTSWCA0vO73vcTqwzH1wjSrAO1TXNu/K3up1sZHa9EKMapbmxCDYBeymC3fVTk4qxp7ogQWQ+KgA==} engines: {node: '>=20.11.1'} @@ -13163,10 +13122,6 @@ packages: html-link-extractor@1.0.5: resolution: {integrity: sha512-ADd49pudM157uWHwHQPUSX4ssMsvR/yHIswOR5CUfBdK9g9ZYGMhVSE6KZVHJ6kCkR0gH4htsfzU6zECDNVwyw==} - html-tags@3.3.1: - resolution: {integrity: sha512-ztqyC3kLto0e9WbNp0aeP+M3kTt+nbaIveGmUxAtZa+8iFgKLUOD4YKM5j+f3QD89bra7UeumolZHKuOXnTmeQ==} - engines: {node: '>=8'} - html-url-attributes@3.0.1: resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==} @@ -13394,10 +13349,6 @@ packages: is-hexadecimal@2.0.1: resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==} - is-html@2.0.0: - resolution: {integrity: sha512-S+OpgB5i7wzIue/YSE5hg0e5ZYfG3hhpNh9KGl6ayJ38p7ED6wxQLd1TV91xHpcTvw90KMJ9EwN3F/iNflHBVg==} - engines: {node: '>=8'} - is-inside-container@1.0.0: resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==} engines: {node: '>=14.16'} @@ -13721,9 +13672,6 @@ packages: jws@4.0.1: resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} - kebab-case@1.0.2: - resolution: {integrity: sha512-7n6wXq4gNgBELfDCpzKc+mRrZFs7D+wgfF5WRFLNAr4DA/qtr9Js8uOAVAfHhuLMfAcQ0pRKqbpjx+TcJVdE1Q==} - keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -13758,9 +13706,6 @@ packages: knitwork@1.3.0: resolution: {integrity: sha512-4LqMNoONzR43B1W0ek0fhXMsDNW/zxa1NdFAVMY+k28pgZLovR4G3PB5MrpTxCy1QaZCqNoiaKPr5w5qZHfSNw==} - known-css-properties@0.30.0: - resolution: {integrity: sha512-VSWXYUnsPu9+WYKkfmJyLKtIvaRJi1kXUqVmBACORXZQxT5oZDsoZ2vQP+bQFDnWtpI/4eq3MLoRMjI2fnLzTQ==} - kolorist@1.8.0: resolution: {integrity: sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==} @@ -15960,6 +15905,12 @@ packages: peerDependencies: seroval: ^1.0 + seroval-plugins@1.6.4: + resolution: {integrity: sha512-R0f1U9hmn38+dFMz6b6ab8lwucmw4AtiY7St+JPWudy1dm+Bs3g884nyrsH9Cy6rKpZKLYayXuMda9GZ/fl8JQ==} + engines: {node: '>=10'} + peerDependencies: + seroval: ^1.0 + seroval@1.3.2: resolution: {integrity: sha512-RbcPH1n5cfwKrru7v7+zrZvjLurgHhGyso3HTyGtRivGWgYjbOmGuivCQaORNELjNONoK35nj28EoWul9sb1zQ==} engines: {node: '>=10'} @@ -15972,6 +15923,10 @@ packages: resolution: {integrity: sha512-OE4cvmJ1uSPrKorFIH9/w/Qwuvi/IMcGbv5RKgcJ/zjA/IohDLU6SVaxFN9FwajbP7nsX0dQqMDes1whk3y+yw==} engines: {node: '>=10'} + seroval@1.6.4: + resolution: {integrity: sha512-LErWMNS2RRFdu2RMA5u/PA59/IWs0XsikyEXGQ2/36iEWFrdG0ABmg17E17cikrv76891kOAMq3TkTFXpwAHXw==} + engines: {node: '>=10'} + serve-placeholder@2.0.2: resolution: {integrity: sha512-/TMG8SboeiQbZJWRlfTCqMs2DD3SZgWp0kDQePz9yUuCnDfDh/92gf7/PxGhzXTKBIPASIHxFcZndoNbp6QOLQ==} @@ -16554,12 +16509,6 @@ packages: ts-algebra@2.0.0: resolution: {integrity: sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==} - ts-api-utils@2.1.0: - resolution: {integrity: sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==} - engines: {node: '>=18.12'} - peerDependencies: - typescript: '>=4.8.4' - ts-api-utils@2.5.0: resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} engines: {node: '>=18.12'} @@ -16885,6 +16834,39 @@ packages: resolution: {integrity: sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==} engines: {node: '>=18.12.0'} + unplugin@3.3.0: + resolution: {integrity: sha512-qa66K+crbfyE6JK10GjvbJeRrOsuC/JpbnHctfyp/i4oBTxWOzJfRZyDiOk1PtErMFRu8JhsU/wPvOdBNWe5Rg==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + '@farmfe/core': '*' + '@rspack/core': '*' + bun-types-no-globals: '*' + esbuild: '*' + rolldown: '*' + rollup: '*' + unloader: '*' + vite: '*' + webpack: '*' + peerDependenciesMeta: + '@farmfe/core': + optional: true + '@rspack/core': + optional: true + bun-types-no-globals: + optional: true + esbuild: + optional: true + rolldown: + optional: true + rollup: + optional: true + unloader: + optional: true + vite: + optional: true + webpack: + optional: true + unrun@0.2.19: resolution: {integrity: sha512-DbwbJ9BvPEb3BeZnIpP9S5tGLO/JIgPQ3JrpMRFIfZMZfMG19f26OlLbC2ml8RRdrI2ZA7z2t+at5tsIHbh6Qw==} engines: {node: '>=20.19.0'} @@ -17294,14 +17276,6 @@ packages: yaml: optional: true - vitefu@1.1.1: - resolution: {integrity: sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ==} - peerDependencies: - vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0 - peerDependenciesMeta: - vite: - optional: true - vitefu@1.1.3: resolution: {integrity: sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==} peerDependencies: @@ -17709,6 +17683,9 @@ packages: zod@4.3.6: resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} + zod@4.5.4: + resolution: {integrity: sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA==} + zone.js@0.15.1: resolution: {integrity: sha512-XE96n56IQpJM7NAoXswY3XRLcWFW83xe0BiAOeMD7K5k5xecOeul3Qcpx6GqEeeHNkW5DWL5zOyTbEfB4eti8w==} @@ -17903,7 +17880,7 @@ snapshots: '@angular/compiler-cli@21.2.17(@angular/compiler@21.2.20)(typescript@5.9.3)': dependencies: '@angular/compiler': 21.2.20 - '@babel/core': 7.29.0(supports-color@7.2.0) + '@babel/core': 7.29.0 '@jridgewell/sourcemap-codec': 1.5.5 chokidar: 5.0.0 convert-source-map: 1.9.0 @@ -18390,9 +18367,9 @@ snapshots: '@babel/helper-module-transforms': 7.28.3(@babel/core@7.28.5) '@babel/helpers': 7.28.4 '@babel/parser': 7.28.5 - '@babel/template': 7.27.2 + '@babel/template': 7.29.7 '@babel/traverse': 7.28.5 - '@babel/types': 7.28.5 + '@babel/types': 7.29.7 '@jridgewell/remapping': 2.3.5 convert-source-map: 2.0.0 debug: 4.4.3(supports-color@7.2.0) @@ -18410,9 +18387,9 @@ snapshots: '@babel/helper-module-transforms': 7.28.3(@babel/core@7.28.5(supports-color@7.2.0)) '@babel/helpers': 7.28.4 '@babel/parser': 7.28.5 - '@babel/template': 7.27.2 + '@babel/template': 7.29.7 '@babel/traverse': 7.28.5 - '@babel/types': 7.28.5 + '@babel/types': 7.29.7 '@jridgewell/remapping': 2.3.5 convert-source-map: 2.0.0 debug: 4.4.3(supports-color@7.2.0) @@ -18422,12 +18399,12 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/core@7.29.0(supports-color@7.2.0)': + '@babel/core@7.29.0': dependencies: '@babel/code-frame': 7.29.7 '@babel/generator': 7.29.7 '@babel/helper-compilation-targets': 7.29.7 - '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.0(supports-color@7.2.0)) + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.0) '@babel/helpers': 7.29.7 '@babel/parser': 7.29.7 '@babel/template': 7.29.7 @@ -18465,7 +18442,7 @@ snapshots: '@babel/generator@7.28.5': dependencies: '@babel/parser': 7.29.0 - '@babel/types': 7.29.0 + '@babel/types': 7.29.7 '@jridgewell/gen-mapping': 0.3.13 '@jridgewell/trace-mapping': 0.3.31 jsesc: 3.1.0 @@ -18549,19 +18526,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/helper-create-class-features-plugin@7.29.7(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0(supports-color@7.2.0) - '@babel/helper-annotate-as-pure': 7.29.7 - '@babel/helper-member-expression-to-functions': 7.29.7 - '@babel/helper-optimise-call-expression': 7.29.7 - '@babel/helper-replace-supers': 7.29.7(@babel/core@7.29.0) - '@babel/helper-skip-transparent-expression-wrappers': 7.29.7 - '@babel/traverse': 7.29.7 - semver: 6.3.1 - transitivePeerDependencies: - - supports-color - '@babel/helper-create-class-features-plugin@7.29.7(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 @@ -18647,15 +18611,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/helper-module-transforms@7.28.3(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0(supports-color@7.2.0) - '@babel/helper-module-imports': 7.29.7 - '@babel/helper-validator-identifier': 7.29.7 - '@babel/traverse': 7.29.7 - transitivePeerDependencies: - - supports-color - '@babel/helper-module-transforms@7.28.3(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 @@ -18665,9 +18620,9 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.0(supports-color@7.2.0))': + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.29.0(supports-color@7.2.0) + '@babel/core': 7.29.0 '@babel/helper-module-imports': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 '@babel/traverse': 7.29.7 @@ -18733,15 +18688,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/helper-replace-supers@7.29.7(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0(supports-color@7.2.0) - '@babel/helper-member-expression-to-functions': 7.29.7 - '@babel/helper-optimise-call-expression': 7.29.7 - '@babel/traverse': 7.29.7 - transitivePeerDependencies: - - supports-color - '@babel/helper-replace-supers@7.29.7(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 @@ -18805,7 +18751,7 @@ snapshots: '@babel/parser@7.29.0': dependencies: - '@babel/types': 7.29.0 + '@babel/types': 7.29.7 '@babel/parser@7.29.7': dependencies: @@ -18855,21 +18801,11 @@ snapshots: '@babel/core': 7.28.5(supports-color@7.2.0) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-jsx@7.27.1(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0(supports-color@7.2.0) - '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-jsx@7.27.1(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0(supports-color@7.2.0) - '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 @@ -18890,11 +18826,6 @@ snapshots: '@babel/core': 7.28.5(supports-color@7.2.0) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-typescript@7.27.1(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0(supports-color@7.2.0) - '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-typescript@7.27.1(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 @@ -19005,14 +18936,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/plugin-transform-modules-commonjs@7.27.1(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0(supports-color@7.2.0) - '@babel/helper-module-transforms': 7.28.3(@babel/core@7.29.0) - '@babel/helper-plugin-utils': 7.27.1 - transitivePeerDependencies: - - supports-color - '@babel/plugin-transform-modules-commonjs@7.27.1(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 @@ -19161,17 +19084,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/plugin-transform-typescript@7.28.5(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0(supports-color@7.2.0) - '@babel/helper-annotate-as-pure': 7.29.7 - '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.0) - '@babel/helper-plugin-utils': 7.29.7 - '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 - '@babel/plugin-syntax-typescript': 7.27.1(@babel/core@7.29.0) - transitivePeerDependencies: - - supports-color - '@babel/plugin-transform-typescript@7.28.5(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 @@ -19207,17 +19119,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/preset-typescript@7.28.5(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0(supports-color@7.2.0) - '@babel/helper-plugin-utils': 7.27.1 - '@babel/helper-validator-option': 7.27.1 - '@babel/plugin-syntax-jsx': 7.27.1(@babel/core@7.29.0) - '@babel/plugin-transform-modules-commonjs': 7.27.1(@babel/core@7.29.0) - '@babel/plugin-transform-typescript': 7.28.5(@babel/core@7.29.0) - transitivePeerDependencies: - - supports-color - '@babel/preset-typescript@7.28.5(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 @@ -19240,12 +19141,6 @@ snapshots: '@babel/runtime@7.29.2': {} - '@babel/template@7.27.2': - dependencies: - '@babel/code-frame': 7.29.7 - '@babel/parser': 7.29.7 - '@babel/types': 7.29.7 - '@babel/template@7.29.7': dependencies: '@babel/code-frame': 7.29.7 @@ -19258,8 +19153,8 @@ snapshots: '@babel/generator': 7.28.5 '@babel/helper-globals': 7.28.0 '@babel/parser': 7.28.5 - '@babel/template': 7.27.2 - '@babel/types': 7.28.5 + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 debug: 4.4.3(supports-color@7.2.0) transitivePeerDependencies: - supports-color @@ -19288,11 +19183,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/types@7.28.5': - dependencies: - '@babel/helper-string-parser': 7.29.7 - '@babel/helper-validator-identifier': 7.29.7 - '@babel/types@7.29.0': dependencies: '@babel/helper-string-parser': 7.27.1 @@ -20361,7 +20251,7 @@ snapshots: '@expo/metro-config@56.0.13(expo@56.0.5)(typescript@5.9.3)': dependencies: '@babel/code-frame': 7.29.0 - '@babel/core': 7.29.0(supports-color@7.2.0) + '@babel/core': 7.29.7 '@babel/generator': 7.29.1 '@expo/config': 56.0.9(typescript@5.9.3) '@expo/env': 2.3.0 @@ -21196,8 +21086,6 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.19.1 - '@nothing-but/utils@0.17.0': {} - '@nx/nx-darwin-arm64@23.1.1': optional: true @@ -24342,8 +24230,6 @@ snapshots: '@rolldown/pluginutils@1.0.0-beta.27': {} - '@rolldown/pluginutils@1.0.0-beta.40': {} - '@rolldown/pluginutils@1.0.0-beta.53': {} '@rolldown/pluginutils@1.0.0-rc.3': {} @@ -24683,48 +24569,6 @@ snapshots: '@smithy/util-buffer-from': 2.2.0 tslib: 2.8.1 - '@solid-devtools/debugger@0.28.1(solid-js@1.9.10)': - dependencies: - '@nothing-but/utils': 0.17.0 - '@solid-devtools/shared': 0.20.0(solid-js@1.9.10) - '@solid-primitives/bounds': 0.1.3(solid-js@1.9.10) - '@solid-primitives/event-listener': 2.4.3(solid-js@1.9.10) - '@solid-primitives/keyboard': 1.3.3(solid-js@1.9.10) - '@solid-primitives/rootless': 1.5.2(solid-js@1.9.10) - '@solid-primitives/scheduled': 1.5.2(solid-js@1.9.10) - '@solid-primitives/static-store': 0.1.2(solid-js@1.9.10) - '@solid-primitives/utils': 6.3.2(solid-js@1.9.10) - solid-js: 1.9.10 - - '@solid-devtools/logger@0.9.11(solid-js@1.9.10)': - dependencies: - '@nothing-but/utils': 0.17.0 - '@solid-devtools/debugger': 0.28.1(solid-js@1.9.10) - '@solid-devtools/shared': 0.20.0(solid-js@1.9.10) - '@solid-primitives/utils': 6.3.2(solid-js@1.9.10) - solid-js: 1.9.10 - - '@solid-devtools/shared@0.20.0(solid-js@1.9.10)': - dependencies: - '@nothing-but/utils': 0.17.0 - '@solid-primitives/event-listener': 2.4.3(solid-js@1.9.10) - '@solid-primitives/media': 2.3.3(solid-js@1.9.10) - '@solid-primitives/refs': 1.1.2(solid-js@1.9.10) - '@solid-primitives/rootless': 1.5.2(solid-js@1.9.10) - '@solid-primitives/scheduled': 1.5.2(solid-js@1.9.10) - '@solid-primitives/static-store': 0.1.2(solid-js@1.9.10) - '@solid-primitives/styles': 0.1.2(solid-js@1.9.10) - '@solid-primitives/utils': 6.3.2(solid-js@1.9.10) - solid-js: 1.9.10 - - '@solid-primitives/bounds@0.1.3(solid-js@1.9.10)': - dependencies: - '@solid-primitives/event-listener': 2.4.3(solid-js@1.9.10) - '@solid-primitives/resize-observer': 2.1.3(solid-js@1.9.10) - '@solid-primitives/static-store': 0.1.2(solid-js@1.9.10) - '@solid-primitives/utils': 6.3.2(solid-js@1.9.10) - solid-js: 1.9.10 - '@solid-primitives/event-listener@2.4.3(solid-js@1.9.10)': dependencies: '@solid-primitives/utils': 6.3.2(solid-js@1.9.10) @@ -24737,14 +24581,6 @@ snapshots: '@solid-primitives/utils': 6.3.2(solid-js@1.9.10) solid-js: 1.9.10 - '@solid-primitives/media@2.3.3(solid-js@1.9.10)': - dependencies: - '@solid-primitives/event-listener': 2.4.3(solid-js@1.9.10) - '@solid-primitives/rootless': 1.5.2(solid-js@1.9.10) - '@solid-primitives/static-store': 0.1.2(solid-js@1.9.10) - '@solid-primitives/utils': 6.3.2(solid-js@1.9.10) - solid-js: 1.9.10 - '@solid-primitives/refs@1.1.2(solid-js@1.9.10)': dependencies: '@solid-primitives/utils': 6.3.2(solid-js@1.9.10) @@ -24763,21 +24599,11 @@ snapshots: '@solid-primitives/utils': 6.3.2(solid-js@1.9.10) solid-js: 1.9.10 - '@solid-primitives/scheduled@1.5.2(solid-js@1.9.10)': - dependencies: - solid-js: 1.9.10 - '@solid-primitives/static-store@0.1.2(solid-js@1.9.10)': dependencies: '@solid-primitives/utils': 6.3.2(solid-js@1.9.10) solid-js: 1.9.10 - '@solid-primitives/styles@0.1.2(solid-js@1.9.10)': - dependencies: - '@solid-primitives/rootless': 1.5.2(solid-js@1.9.10) - '@solid-primitives/utils': 6.3.2(solid-js@1.9.10) - solid-js: 1.9.10 - '@solid-primitives/utils@6.3.2(solid-js@1.9.10)': dependencies: solid-js: 1.9.10 @@ -25109,6 +24935,8 @@ snapshots: '@tanstack/history@1.154.14': {} + '@tanstack/history@1.162.1': {} + '@tanstack/nitro-v2-vite-plugin@1.155.0(@electric-sql/pglite@0.4.3)(aws4fetch@1.0.20)(better-sqlite3@12.11.1)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260317.1)(@electric-sql/pglite@0.4.3)(@opentelemetry/api@1.9.1)(@prisma/client@7.9.0(prisma@7.9.0(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@12.11.1)(bun-types@1.3.14)(mysql2@3.15.3)(postgres@3.4.7)(prisma@7.9.0(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)))(mysql2@3.15.3)(rolldown@1.2.4)(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0))': dependencies: nitropack: 2.13.1(@electric-sql/pglite@0.4.3)(aws4fetch@1.0.20)(better-sqlite3@12.11.1)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260317.1)(@electric-sql/pglite@0.4.3)(@opentelemetry/api@1.9.1)(@prisma/client@7.9.0(prisma@7.9.0(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@12.11.1)(bun-types@1.3.14)(mysql2@3.15.3)(postgres@3.4.7)(prisma@7.9.0(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(better-sqlite3@12.11.1)(magicast@0.5.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3)))(mysql2@3.15.3)(rolldown@1.2.4) @@ -25231,23 +25059,23 @@ snapshots: '@tanstack/query-core': 5.90.12 react: 19.2.3 - '@tanstack/react-router-devtools@1.159.5(@tanstack/react-router@1.159.5(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@tanstack/router-core@1.159.4)(csstype@3.2.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@tanstack/react-router-devtools@1.167.1(@tanstack/react-router@1.170.32(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@tanstack/router-core@1.171.27)(csstype@3.2.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: - '@tanstack/react-router': 1.159.5(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@tanstack/router-devtools-core': 1.159.4(@tanstack/router-core@1.159.4)(csstype@3.2.3) + '@tanstack/react-router': 1.170.32(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@tanstack/router-devtools-core': 1.168.1(@tanstack/router-core@1.171.27)(csstype@3.2.3) react: 19.2.3 react-dom: 19.2.3(react@19.2.3) optionalDependencies: - '@tanstack/router-core': 1.159.4 + '@tanstack/router-core': 1.171.27 transitivePeerDependencies: - csstype - '@tanstack/react-router-ssr-query@1.159.5(@tanstack/query-core@5.90.12)(@tanstack/react-query@5.90.12(react@19.2.3))(@tanstack/react-router@1.159.5(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@tanstack/router-core@1.159.4)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@tanstack/react-router-ssr-query@1.167.2(@tanstack/query-core@5.90.12)(@tanstack/react-query@5.90.12(react@19.2.3))(@tanstack/react-router@1.170.32(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@tanstack/router-core@1.171.27)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@tanstack/query-core': 5.90.12 '@tanstack/react-query': 5.90.12(react@19.2.3) - '@tanstack/react-router': 1.159.5(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@tanstack/router-ssr-query-core': 1.159.4(@tanstack/query-core@5.90.12)(@tanstack/router-core@1.159.4) + '@tanstack/react-router': 1.170.32(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@tanstack/router-ssr-query-core': 1.169.2(@tanstack/query-core@5.90.12)(@tanstack/router-core@1.171.27) react: 19.2.3 react-dom: 19.2.3(react@19.2.3) transitivePeerDependencies: @@ -25264,16 +25092,14 @@ snapshots: tiny-invariant: 1.3.3 tiny-warning: 1.0.3 - '@tanstack/react-router@1.159.5(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@tanstack/react-router@1.170.32(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: - '@tanstack/history': 1.154.14 - '@tanstack/react-store': 0.8.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@tanstack/router-core': 1.159.4 + '@tanstack/history': 1.162.1 + '@tanstack/react-store': 0.9.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@tanstack/router-core': 1.171.27 isbot: 5.1.32 react: 19.2.3 react-dom: 19.2.3(react@19.2.3) - tiny-invariant: 1.3.3 - tiny-warning: 1.0.3 '@tanstack/react-start-client@1.141.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: @@ -25285,19 +25111,17 @@ snapshots: tiny-invariant: 1.3.3 tiny-warning: 1.0.3 - '@tanstack/react-start-client@1.159.5(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@tanstack/react-start-client@1.168.30(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: - '@tanstack/react-router': 1.159.5(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@tanstack/router-core': 1.159.4 - '@tanstack/start-client-core': 1.159.4 + '@tanstack/react-router': 1.170.32(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@tanstack/router-core': 1.171.27 + '@tanstack/start-client-core': 1.170.27 react: 19.2.3 react-dom: 19.2.3(react@19.2.3) - tiny-invariant: 1.3.3 - tiny-warning: 1.0.3 - '@tanstack/react-start-plugin@1.131.50(e7a157c953e155a2765d901b34df38b3)': + '@tanstack/react-start-plugin@1.131.50(5e76e58f58fcdc553da46eb093ac388b)': dependencies: - '@tanstack/start-plugin-core': 1.131.50(1a1cdd8b9c2c533357f243540b1932ef) + '@tanstack/start-plugin-core': 1.131.50(b114ec7757aa79b8f316a176b0457a25) '@vitejs/plugin-react': 4.7.0(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) pathe: 2.0.3 vite: 8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0) @@ -25387,6 +25211,32 @@ snapshots: - xml2js - yaml + '@tanstack/react-start-rsc@0.1.48(crossws@0.4.6(srvx@0.11.17))(esbuild@0.28.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(rolldown@1.2.4)(rollup@4.60.1)(vite-plugin-solid@2.11.14(solid-js@1.9.10)(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0))': + dependencies: + '@tanstack/react-router': 1.170.32(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@tanstack/router-core': 1.171.27 + '@tanstack/router-utils': 1.162.2 + '@tanstack/start-client-core': 1.170.27 + '@tanstack/start-fn-stubs': 1.162.0 + '@tanstack/start-plugin-core': 1.171.39(@tanstack/react-router@1.170.32(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(crossws@0.4.6(srvx@0.11.17))(esbuild@0.28.1)(rolldown@1.2.4)(rollup@4.60.1)(vite-plugin-solid@2.11.14(solid-js@1.9.10)(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) + '@tanstack/start-storage-context': 1.167.29 + pathe: 2.0.3 + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + transitivePeerDependencies: + - '@farmfe/core' + - '@rsbuild/core' + - bun-types-no-globals + - crossws + - esbuild + - rolldown + - rollup + - supports-color + - unloader + - vite + - vite-plugin-solid + - webpack + '@tanstack/react-start-server@1.141.1(crossws@0.4.6(srvx@0.11.17))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@tanstack/history': 1.141.0 @@ -25399,38 +25249,52 @@ snapshots: transitivePeerDependencies: - crossws - '@tanstack/react-start-server@1.159.5(crossws@0.4.6(srvx@0.11.17))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@tanstack/react-start-server@1.167.37(crossws@0.4.6(srvx@0.11.17))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: - '@tanstack/history': 1.154.14 - '@tanstack/react-router': 1.159.5(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@tanstack/router-core': 1.159.4 - '@tanstack/start-client-core': 1.159.4 - '@tanstack/start-server-core': 1.159.4(crossws@0.4.6(srvx@0.11.17)) + '@tanstack/react-router': 1.170.32(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@tanstack/router-core': 1.171.27 + '@tanstack/start-server-core': 1.169.31(crossws@0.4.6(srvx@0.11.17)) react: 19.2.3 react-dom: 19.2.3(react@19.2.3) transitivePeerDependencies: - crossws - '@tanstack/react-start@1.159.5(crossws@0.4.6(srvx@0.11.17))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite-plugin-solid@2.11.14(solid-js@1.9.10)(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0))': + '@tanstack/react-start@1.168.49(crossws@0.4.6(srvx@0.11.17))(esbuild@0.28.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(rolldown@1.2.4)(rollup@4.60.1)(vite-plugin-solid@2.11.14(solid-js@1.9.10)(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0))': dependencies: - '@tanstack/react-router': 1.159.5(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@tanstack/react-start-client': 1.159.5(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@tanstack/react-start-server': 1.159.5(crossws@0.4.6(srvx@0.11.17))(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@tanstack/router-utils': 1.158.0 - '@tanstack/start-client-core': 1.159.4 - '@tanstack/start-plugin-core': 1.159.5(@tanstack/react-router@1.159.5(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(crossws@0.4.6(srvx@0.11.17))(vite-plugin-solid@2.11.14(solid-js@1.9.10)(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) - '@tanstack/start-server-core': 1.159.4(crossws@0.4.6(srvx@0.11.17)) + '@tanstack/react-router': 1.170.32(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@tanstack/react-start-client': 1.168.30(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@tanstack/react-start-rsc': 0.1.48(crossws@0.4.6(srvx@0.11.17))(esbuild@0.28.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(rolldown@1.2.4)(rollup@4.60.1)(vite-plugin-solid@2.11.14(solid-js@1.9.10)(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) + '@tanstack/react-start-server': 1.167.37(crossws@0.4.6(srvx@0.11.17))(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@tanstack/router-utils': 1.162.2 + '@tanstack/start-client-core': 1.170.27 + '@tanstack/start-plugin-core': 1.171.39(@tanstack/react-router@1.170.32(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(crossws@0.4.6(srvx@0.11.17))(esbuild@0.28.1)(rolldown@1.2.4)(rollup@4.60.1)(vite-plugin-solid@2.11.14(solid-js@1.9.10)(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) + '@tanstack/start-server-core': 1.169.31(crossws@0.4.6(srvx@0.11.17)) pathe: 2.0.3 react: 19.2.3 react-dom: 19.2.3(react@19.2.3) + optionalDependencies: vite: 8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0) transitivePeerDependencies: - - '@rsbuild/core' + - '@farmfe/core' + - '@rspack/core' + - bun-types-no-globals - crossws + - esbuild + - react-server-dom-rspack + - rolldown + - rollup - supports-color + - unloader - vite-plugin-solid - webpack + '@tanstack/react-store@0.11.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@tanstack/store': 0.11.1 + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + use-sync-external-store: 1.6.0(react@19.2.3) + '@tanstack/react-store@0.8.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@tanstack/store': 0.8.0 @@ -25438,6 +25302,13 @@ snapshots: react-dom: 19.2.3(react@19.2.3) use-sync-external-store: 1.6.0(react@19.2.3) + '@tanstack/react-store@0.9.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@tanstack/store': 0.9.3 + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + use-sync-external-store: 1.6.0(react@19.2.3) + '@tanstack/router-core@1.131.50': dependencies: '@tanstack/history': 1.131.2 @@ -25468,22 +25339,18 @@ snapshots: tiny-invariant: 1.3.3 tiny-warning: 1.0.3 - '@tanstack/router-devtools-core@1.141.1(@tanstack/router-core@1.159.4)(csstype@3.2.3)(solid-js@1.9.10)': + '@tanstack/router-core@1.171.27': dependencies: - '@tanstack/router-core': 1.159.4 - clsx: 2.1.1 - goober: 2.1.18(csstype@3.2.3) - solid-js: 1.9.10 - tiny-invariant: 1.3.3 - optionalDependencies: - csstype: 3.2.3 + '@tanstack/history': 1.162.1 + cookie-es: 3.1.1 + seroval: 1.6.4 + seroval-plugins: 1.6.4(seroval@1.6.4) - '@tanstack/router-devtools-core@1.159.4(@tanstack/router-core@1.159.4)(csstype@3.2.3)': + '@tanstack/router-devtools-core@1.168.1(@tanstack/router-core@1.171.27)(csstype@3.2.3)': dependencies: - '@tanstack/router-core': 1.159.4 + '@tanstack/router-core': 1.171.27 clsx: 2.1.1 goober: 2.1.18(csstype@3.2.3) - tiny-invariant: 1.3.3 optionalDependencies: csstype: 3.2.3 @@ -25500,11 +25367,11 @@ snapshots: transitivePeerDependencies: - supports-color - '@tanstack/router-generator@1.141.1': + '@tanstack/router-generator@1.159.4': dependencies: - '@tanstack/router-core': 1.141.1 - '@tanstack/router-utils': 1.141.0 - '@tanstack/virtual-file-routes': 1.141.0 + '@tanstack/router-core': 1.159.4 + '@tanstack/router-utils': 1.158.0 + '@tanstack/virtual-file-routes': 1.154.7 prettier: 3.7.4 recast: 0.23.11 source-map: 0.7.6 @@ -25513,20 +25380,20 @@ snapshots: transitivePeerDependencies: - supports-color - '@tanstack/router-generator@1.159.4': + '@tanstack/router-generator@1.167.33': dependencies: - '@tanstack/router-core': 1.159.4 - '@tanstack/router-utils': 1.158.0 - '@tanstack/virtual-file-routes': 1.154.7 + '@babel/types': 7.29.7 + '@tanstack/router-core': 1.171.27 + '@tanstack/router-utils': 1.162.2 + '@tanstack/virtual-file-routes': 1.162.0 + jiti: 2.7.0 + magic-string: 0.30.21 prettier: 3.7.4 - recast: 0.23.11 - source-map: 0.7.6 - tsx: 4.21.0 - zod: 3.25.76 + zod: 4.5.4 transitivePeerDependencies: - supports-color - '@tanstack/router-plugin@1.131.50(@tanstack/react-router@1.159.5(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite-plugin-solid@2.11.14(solid-js@1.9.10)(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0))': + '@tanstack/router-plugin@1.131.50(@tanstack/react-router@1.170.32(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite-plugin-solid@2.11.14(solid-js@1.9.10)(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0))': dependencies: '@babel/core': 7.29.7 '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7) @@ -25543,66 +25410,41 @@ snapshots: unplugin: 2.3.11 zod: 3.25.76 optionalDependencies: - '@tanstack/react-router': 1.159.5(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@tanstack/react-router': 1.170.32(react-dom@19.2.3(react@19.2.3))(react@19.2.3) vite: 8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0) vite-plugin-solid: 2.11.14(solid-js@1.9.10)(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) transitivePeerDependencies: - supports-color - '@tanstack/router-plugin@1.141.1(@tanstack/react-router@1.159.5(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite-plugin-solid@2.11.14(solid-js@1.9.10)(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0))': + '@tanstack/router-plugin@1.168.35(@tanstack/react-router@1.170.32(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(esbuild@0.28.1)(rolldown@1.2.4)(rollup@4.60.1)(vite-plugin-solid@2.11.14(solid-js@1.9.10)(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0))': dependencies: '@babel/core': 7.29.7 - '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-syntax-typescript': 7.27.1(@babel/core@7.29.7) '@babel/template': 7.29.7 - '@babel/traverse': 7.29.7 '@babel/types': 7.29.7 - '@tanstack/router-core': 1.141.1 - '@tanstack/router-generator': 1.141.1 - '@tanstack/router-utils': 1.141.0 - '@tanstack/virtual-file-routes': 1.141.0 - babel-dead-code-elimination: 1.0.10 - chokidar: 3.6.0 - unplugin: 2.3.11 - zod: 3.25.76 - optionalDependencies: - '@tanstack/react-router': 1.159.5(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - vite: 8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0) - vite-plugin-solid: 2.11.14(solid-js@1.9.10)(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) - transitivePeerDependencies: - - supports-color - - '@tanstack/router-plugin@1.159.5(@tanstack/react-router@1.159.5(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(supports-color@7.2.0)(vite-plugin-solid@2.11.14(solid-js@1.9.10)(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0))': - dependencies: - '@babel/core': 7.29.0(supports-color@7.2.0) - '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.0) - '@babel/plugin-syntax-typescript': 7.27.1(@babel/core@7.29.0) - '@babel/template': 7.29.7 - '@babel/traverse': 7.29.7 - '@babel/types': 7.29.7 - '@tanstack/router-core': 1.159.4 - '@tanstack/router-generator': 1.159.4 - '@tanstack/router-utils': 1.158.0 - '@tanstack/virtual-file-routes': 1.154.7 - chokidar: 3.6.0 - unplugin: 2.3.11 - zod: 3.25.76 + '@tanstack/router-core': 1.171.27 + '@tanstack/router-generator': 1.167.33 + '@tanstack/router-utils': 1.162.2 + chokidar: 5.0.0 + unplugin: 3.3.0(esbuild@0.28.1)(rolldown@1.2.4)(rollup@4.60.1)(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) + zod: 4.5.4 optionalDependencies: - '@tanstack/react-router': 1.159.5(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@tanstack/react-router': 1.170.32(react-dom@19.2.3(react@19.2.3))(react@19.2.3) vite: 8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0) vite-plugin-solid: 2.11.14(solid-js@1.9.10)(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) transitivePeerDependencies: + - '@farmfe/core' + - '@rspack/core' + - bun-types-no-globals + - esbuild + - rolldown + - rollup - supports-color + - unloader - '@tanstack/router-ssr-query-core@1.141.1(@tanstack/query-core@5.90.12)(@tanstack/router-core@1.159.4)': - dependencies: - '@tanstack/query-core': 5.90.12 - '@tanstack/router-core': 1.159.4 - - '@tanstack/router-ssr-query-core@1.159.4(@tanstack/query-core@5.90.12)(@tanstack/router-core@1.159.4)': + '@tanstack/router-ssr-query-core@1.169.2(@tanstack/query-core@5.90.12)(@tanstack/router-core@1.171.27)': dependencies: '@tanstack/query-core': 5.90.12 - '@tanstack/router-core': 1.159.4 + '@tanstack/router-core': 1.171.27 '@tanstack/router-utils@1.131.2': dependencies: @@ -25630,7 +25472,20 @@ snapshots: '@tanstack/router-utils@1.158.0': dependencies: - '@babel/core': 7.29.0(supports-color@7.2.0) + '@babel/core': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + ansis: 4.2.0 + babel-dead-code-elimination: 1.0.12 + diff: 8.0.2 + pathe: 2.0.3 + tinyglobby: 0.2.17 + transitivePeerDependencies: + - supports-color + + '@tanstack/router-utils@1.162.2': + dependencies: '@babel/generator': 7.29.7 '@babel/parser': 7.29.7 '@babel/types': 7.29.7 @@ -25688,86 +25543,80 @@ snapshots: '@tanstack/query-core': 5.90.12 solid-js: 1.9.10 - '@tanstack/solid-router-devtools@1.141.1(@tanstack/router-core@1.159.4)(@tanstack/solid-router@1.141.1(solid-js@1.9.10))(csstype@3.2.3)(solid-js@1.9.10)': + '@tanstack/solid-router-devtools@1.167.1(@tanstack/router-core@1.171.27)(@tanstack/solid-router@1.170.30(solid-js@1.9.10))(csstype@3.2.3)(solid-js@1.9.10)': dependencies: - '@tanstack/router-devtools-core': 1.141.1(@tanstack/router-core@1.159.4)(csstype@3.2.3)(solid-js@1.9.10) - '@tanstack/solid-router': 1.141.1(solid-js@1.9.10) + '@tanstack/router-devtools-core': 1.168.1(@tanstack/router-core@1.171.27)(csstype@3.2.3) + '@tanstack/solid-router': 1.170.30(solid-js@1.9.10) solid-js: 1.9.10 optionalDependencies: - '@tanstack/router-core': 1.159.4 + '@tanstack/router-core': 1.171.27 transitivePeerDependencies: - csstype - '@tanstack/solid-router-ssr-query@1.141.1(@tanstack/query-core@5.90.12)(@tanstack/router-core@1.159.4)(@tanstack/solid-query@5.90.15(solid-js@1.9.10))(@tanstack/solid-router@1.141.1(solid-js@1.9.10))(eslint@9.39.4(jiti@2.7.0))(solid-js@1.9.10)(supports-color@7.2.0)(typescript@5.9.3)': + '@tanstack/solid-router-ssr-query@1.167.2(@tanstack/query-core@5.90.12)(@tanstack/router-core@1.171.27)(@tanstack/solid-query@5.90.15(solid-js@1.9.10))(@tanstack/solid-router@1.170.30(solid-js@1.9.10))(solid-js@1.9.10)': dependencies: '@tanstack/query-core': 5.90.12 - '@tanstack/router-ssr-query-core': 1.141.1(@tanstack/query-core@5.90.12)(@tanstack/router-core@1.159.4) + '@tanstack/router-ssr-query-core': 1.169.2(@tanstack/query-core@5.90.12)(@tanstack/router-core@1.171.27) '@tanstack/solid-query': 5.90.15(solid-js@1.9.10) - '@tanstack/solid-router': 1.141.1(solid-js@1.9.10) - eslint-plugin-solid: 0.14.5(eslint@9.39.4(jiti@2.7.0))(supports-color@7.2.0)(typescript@5.9.3) + '@tanstack/solid-router': 1.170.30(solid-js@1.9.10) solid-js: 1.9.10 transitivePeerDependencies: - '@tanstack/router-core' - - eslint - - supports-color - - typescript - '@tanstack/solid-router@1.141.1(solid-js@1.9.10)': + '@tanstack/solid-router@1.170.30(solid-js@1.9.10)': dependencies: - '@solid-devtools/logger': 0.9.11(solid-js@1.9.10) '@solid-primitives/refs': 1.1.2(solid-js@1.9.10) '@solidjs/meta': 0.29.4(solid-js@1.9.10) - '@tanstack/history': 1.141.0 - '@tanstack/router-core': 1.141.1 - '@tanstack/solid-store': 0.8.0(solid-js@1.9.10) + '@tanstack/history': 1.162.1 + '@tanstack/router-core': 1.171.27 isbot: 5.1.32 solid-js: 1.9.10 - tiny-invariant: 1.3.3 - tiny-warning: 1.0.3 - '@tanstack/solid-start-client@1.141.1(solid-js@1.9.10)': + '@tanstack/solid-start-client@1.168.29(solid-js@1.9.10)': dependencies: - '@tanstack/router-core': 1.141.1 - '@tanstack/solid-router': 1.141.1(solid-js@1.9.10) - '@tanstack/start-client-core': 1.141.1 + '@tanstack/router-core': 1.171.27 + '@tanstack/solid-router': 1.170.30(solid-js@1.9.10) + '@tanstack/start-client-core': 1.170.27 solid-js: 1.9.10 - tiny-invariant: 1.3.3 - tiny-warning: 1.0.3 - '@tanstack/solid-start-server@1.141.1(crossws@0.4.6(srvx@0.11.17))(solid-js@1.9.10)': + '@tanstack/solid-start-server@1.167.36(crossws@0.4.6(srvx@0.11.17))(solid-js@1.9.10)': dependencies: - '@solidjs/meta': 0.29.4(solid-js@1.9.10) - '@tanstack/history': 1.141.0 - '@tanstack/router-core': 1.141.1 - '@tanstack/solid-router': 1.141.1(solid-js@1.9.10) - '@tanstack/start-client-core': 1.141.1 - '@tanstack/start-server-core': 1.141.1(crossws@0.4.6(srvx@0.11.17)) + '@tanstack/router-core': 1.171.27 + '@tanstack/solid-router': 1.170.30(solid-js@1.9.10) + '@tanstack/start-server-core': 1.169.31(crossws@0.4.6(srvx@0.11.17)) solid-js: 1.9.10 transitivePeerDependencies: - crossws - '@tanstack/solid-start@1.141.1(@tanstack/react-router@1.159.5(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(crossws@0.4.6(srvx@0.11.17))(solid-js@1.9.10)(vite-plugin-solid@2.11.14(solid-js@1.9.10)(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0))': + '@tanstack/solid-start@1.168.47(@tanstack/react-router@1.170.32(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(crossws@0.4.6(srvx@0.11.17))(esbuild@0.28.1)(rolldown@1.2.4)(rollup@4.60.1)(solid-js@1.9.10)(vite-plugin-solid@2.11.14(solid-js@1.9.10)(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0))': dependencies: - '@tanstack/solid-router': 1.141.1(solid-js@1.9.10) - '@tanstack/solid-start-client': 1.141.1(solid-js@1.9.10) - '@tanstack/solid-start-server': 1.141.1(crossws@0.4.6(srvx@0.11.17))(solid-js@1.9.10) - '@tanstack/start-client-core': 1.141.1 - '@tanstack/start-plugin-core': 1.141.1(@tanstack/react-router@1.159.5(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(crossws@0.4.6(srvx@0.11.17))(vite-plugin-solid@2.11.14(solid-js@1.9.10)(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) - '@tanstack/start-server-core': 1.141.1(crossws@0.4.6(srvx@0.11.17)) + '@tanstack/solid-router': 1.170.30(solid-js@1.9.10) + '@tanstack/solid-start-client': 1.168.29(solid-js@1.9.10) + '@tanstack/solid-start-server': 1.167.36(crossws@0.4.6(srvx@0.11.17))(solid-js@1.9.10) + '@tanstack/start-client-core': 1.170.27 + '@tanstack/start-plugin-core': 1.171.39(@tanstack/react-router@1.170.32(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(crossws@0.4.6(srvx@0.11.17))(esbuild@0.28.1)(rolldown@1.2.4)(rollup@4.60.1)(vite-plugin-solid@2.11.14(solid-js@1.9.10)(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) + '@tanstack/start-server-core': 1.169.31(crossws@0.4.6(srvx@0.11.17)) pathe: 2.0.3 solid-js: 1.9.10 + optionalDependencies: vite: 8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0) transitivePeerDependencies: - - '@rsbuild/core' + - '@farmfe/core' + - '@rspack/core' - '@tanstack/react-router' + - bun-types-no-globals - crossws + - esbuild + - rolldown + - rollup - supports-color + - unloader - vite-plugin-solid - webpack - '@tanstack/solid-store@0.8.0(solid-js@1.9.10)': + '@tanstack/solid-store@0.11.1(solid-js@1.9.10)': dependencies: - '@tanstack/store': 0.8.0 + '@tanstack/store': 0.11.1 solid-js: 1.9.10 '@tanstack/start-api-routes@1.120.19(91e012b4b7f2d471a11b1e1ea02d5103)': @@ -25846,12 +25695,19 @@ snapshots: tiny-invariant: 1.3.3 tiny-warning: 1.0.3 - '@tanstack/start-config@1.120.20(340eefabdbae5b0c948fd6f8943277d1)': + '@tanstack/start-client-core@1.170.27': + dependencies: + '@tanstack/router-core': 1.171.27 + '@tanstack/start-fn-stubs': 1.162.0 + '@tanstack/start-storage-context': 1.167.29 + seroval: 1.6.4 + + '@tanstack/start-config@1.120.20(c688c64811f9265f6bbbab232e625bf6)': dependencies: - '@tanstack/react-router': 1.159.5(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@tanstack/react-start-plugin': 1.131.50(e7a157c953e155a2765d901b34df38b3) + '@tanstack/react-router': 1.170.32(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@tanstack/react-start-plugin': 1.131.50(5e76e58f58fcdc553da46eb093ac388b) '@tanstack/router-generator': 1.159.4 - '@tanstack/router-plugin': 1.159.5(@tanstack/react-router@1.159.5(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(supports-color@7.2.0)(vite-plugin-solid@2.11.14(solid-js@1.9.10)(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) + '@tanstack/router-plugin': 1.168.35(@tanstack/react-router@1.170.32(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(esbuild@0.28.1)(rolldown@1.2.4)(rollup@4.60.1)(vite-plugin-solid@2.11.14(solid-js@1.9.10)(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) '@tanstack/server-functions-plugin': 1.141.0(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) '@tanstack/start-server-functions-handler': 1.120.19(crossws@0.4.6(srvx@0.11.17)) '@vitejs/plugin-react': 4.7.0(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) @@ -25873,10 +25729,12 @@ snapshots: - '@capacitor/preferences' - '@deno/kv' - '@electric-sql/pglite' + - '@farmfe/core' - '@libsql/client' - '@netlify/blobs' - '@planetscale/database' - '@rsbuild/core' + - '@rspack/core' - '@types/node' - '@upstash/redis' - '@vercel/blob' @@ -25885,11 +25743,13 @@ snapshots: - aws4fetch - bare-abort-controller - better-sqlite3 + - bun-types-no-globals - crossws - db0 - debug - drizzle-orm - encoding + - esbuild - idb-keyval - ioredis - jiti @@ -25898,6 +25758,7 @@ snapshots: - mysql2 - react-native-b4a - rolldown + - rollup - sass - sass-embedded - sqlite3 @@ -25906,6 +25767,7 @@ snapshots: - supports-color - terser - tsx + - unloader - uploadthing - vite-plugin-solid - webpack @@ -25914,14 +25776,16 @@ snapshots: '@tanstack/start-fn-stubs@1.154.7': {} - '@tanstack/start-plugin-core@1.131.50(1a1cdd8b9c2c533357f243540b1932ef)': + '@tanstack/start-fn-stubs@1.162.0': {} + + '@tanstack/start-plugin-core@1.131.50(b114ec7757aa79b8f316a176b0457a25)': dependencies: '@babel/code-frame': 7.26.2 '@babel/core': 7.29.7 '@babel/types': 7.29.7 '@tanstack/router-core': 1.131.50 '@tanstack/router-generator': 1.131.50 - '@tanstack/router-plugin': 1.131.50(@tanstack/react-router@1.159.5(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite-plugin-solid@2.11.14(solid-js@1.9.10)(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) + '@tanstack/router-plugin': 1.131.50(@tanstack/react-router@1.170.32(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite-plugin-solid@2.11.14(solid-js@1.9.10)(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) '@tanstack/router-utils': 1.131.2 '@tanstack/server-functions-plugin': 1.131.2(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) '@tanstack/start-server-core': 1.131.50 @@ -25934,7 +25798,7 @@ snapshots: pathe: 2.0.3 ufo: 1.6.3 vite: 8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0) - vitefu: 1.1.1(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) + vitefu: 1.1.3(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) xmlbuilder2: 3.1.1 zod: 3.25.76 transitivePeerDependencies: @@ -25972,65 +25836,41 @@ snapshots: - webpack - xml2js - '@tanstack/start-plugin-core@1.141.1(@tanstack/react-router@1.159.5(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(crossws@0.4.6(srvx@0.11.17))(vite-plugin-solid@2.11.14(solid-js@1.9.10)(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0))': - dependencies: - '@babel/code-frame': 7.26.2 - '@babel/core': 7.29.0(supports-color@7.2.0) - '@babel/types': 7.29.0 - '@rolldown/pluginutils': 1.0.0-beta.40 - '@tanstack/router-core': 1.141.1 - '@tanstack/router-generator': 1.141.1 - '@tanstack/router-plugin': 1.141.1(@tanstack/react-router@1.159.5(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite-plugin-solid@2.11.14(solid-js@1.9.10)(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) - '@tanstack/router-utils': 1.141.0 - '@tanstack/server-functions-plugin': 1.141.0(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) - '@tanstack/start-client-core': 1.141.1 - '@tanstack/start-server-core': 1.141.1(crossws@0.4.6(srvx@0.11.17)) - babel-dead-code-elimination: 1.0.10 - cheerio: 1.1.2 - exsolve: 1.0.8 - pathe: 2.0.3 - srvx: 0.8.16 - tinyglobby: 0.2.17 - ufo: 1.6.1 - vite: 8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0) - vitefu: 1.1.1(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) - xmlbuilder2: 4.0.3 - zod: 3.25.76 - transitivePeerDependencies: - - '@rsbuild/core' - - '@tanstack/react-router' - - crossws - - supports-color - - vite-plugin-solid - - webpack - - '@tanstack/start-plugin-core@1.159.5(@tanstack/react-router@1.159.5(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(crossws@0.4.6(srvx@0.11.17))(vite-plugin-solid@2.11.14(solid-js@1.9.10)(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0))': + '@tanstack/start-plugin-core@1.171.39(@tanstack/react-router@1.170.32(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(crossws@0.4.6(srvx@0.11.17))(esbuild@0.28.1)(rolldown@1.2.4)(rollup@4.60.1)(vite-plugin-solid@2.11.14(solid-js@1.9.10)(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0))': dependencies: '@babel/code-frame': 7.27.1 - '@babel/core': 7.29.0(supports-color@7.2.0) + '@babel/core': 7.29.7 '@babel/types': 7.29.7 - '@rolldown/pluginutils': 1.0.0-beta.40 - '@tanstack/router-core': 1.159.4 - '@tanstack/router-generator': 1.159.4 - '@tanstack/router-plugin': 1.159.5(@tanstack/react-router@1.159.5(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(supports-color@7.2.0)(vite-plugin-solid@2.11.14(solid-js@1.9.10)(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) - '@tanstack/router-utils': 1.158.0 - '@tanstack/start-client-core': 1.159.4 - '@tanstack/start-server-core': 1.159.4(crossws@0.4.6(srvx@0.11.17)) - cheerio: 1.1.2 + '@tanstack/router-core': 1.171.27 + '@tanstack/router-generator': 1.167.33 + '@tanstack/router-plugin': 1.168.35(@tanstack/react-router@1.170.32(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(esbuild@0.28.1)(rolldown@1.2.4)(rollup@4.60.1)(vite-plugin-solid@2.11.14(solid-js@1.9.10)(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)))(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) + '@tanstack/router-utils': 1.162.2 + '@tanstack/start-server-core': 1.169.31(crossws@0.4.6(srvx@0.11.17)) exsolve: 1.0.8 + lightningcss: 1.33.0 pathe: 2.0.3 + picomatch: 4.0.5 + seroval: 1.6.4 + source-map: 0.7.6 srvx: 0.11.17 tinyglobby: 0.2.17 ufo: 1.6.3 - vite: 8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0) - vitefu: 1.1.1(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) + vitefu: 1.1.3(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) xmlbuilder2: 4.0.3 - zod: 3.25.76 + zod: 4.5.4 + optionalDependencies: + vite: 8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0) transitivePeerDependencies: - - '@rsbuild/core' + - '@farmfe/core' + - '@rspack/core' - '@tanstack/react-router' + - bun-types-no-globals - crossws + - esbuild + - rolldown + - rollup - supports-color + - unloader - vite-plugin-solid - webpack @@ -26070,6 +25910,18 @@ snapshots: transitivePeerDependencies: - crossws + '@tanstack/start-server-core@1.169.31(crossws@0.4.6(srvx@0.11.17))': + dependencies: + '@tanstack/history': 1.162.1 + '@tanstack/router-core': 1.171.27 + '@tanstack/start-client-core': 1.170.27 + '@tanstack/start-storage-context': 1.167.29 + fetchdts: 0.1.7 + h3-v2: h3@2.0.1-rc.20(crossws@0.4.6(srvx@0.11.17)) + seroval: 1.6.4 + transitivePeerDependencies: + - crossws + '@tanstack/start-server-functions-client@1.131.50(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0))': dependencies: '@tanstack/server-functions-plugin': 1.131.2(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) @@ -26124,13 +25976,17 @@ snapshots: dependencies: '@tanstack/router-core': 1.159.4 - '@tanstack/start@1.120.20(340eefabdbae5b0c948fd6f8943277d1)': + '@tanstack/start-storage-context@1.167.29': + dependencies: + '@tanstack/router-core': 1.171.27 + + '@tanstack/start@1.120.20(c688c64811f9265f6bbbab232e625bf6)': dependencies: '@tanstack/react-start-client': 1.141.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@tanstack/react-start-router-manifest': 1.120.19(e2865e417d15aa2efdac89fcc0288f0b) '@tanstack/react-start-server': 1.141.1(crossws@0.4.6(srvx@0.11.17))(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@tanstack/start-api-routes': 1.120.19(91e012b4b7f2d471a11b1e1ea02d5103) - '@tanstack/start-config': 1.120.20(340eefabdbae5b0c948fd6f8943277d1) + '@tanstack/start-config': 1.120.20(c688c64811f9265f6bbbab232e625bf6) '@tanstack/start-server-functions-client': 1.131.50(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) '@tanstack/start-server-functions-handler': 1.120.19(crossws@0.4.6(srvx@0.11.17)) '@tanstack/start-server-functions-server': 1.131.2(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)) @@ -26145,10 +26001,12 @@ snapshots: - '@capacitor/preferences' - '@deno/kv' - '@electric-sql/pglite' + - '@farmfe/core' - '@libsql/client' - '@netlify/blobs' - '@planetscale/database' - '@rsbuild/core' + - '@rspack/core' - '@types/node' - '@upstash/redis' - '@vercel/blob' @@ -26157,11 +26015,13 @@ snapshots: - aws4fetch - bare-abort-controller - better-sqlite3 + - bun-types-no-globals - crossws - db0 - debug - drizzle-orm - encoding + - esbuild - idb-keyval - ioredis - jiti @@ -26172,6 +26032,7 @@ snapshots: - react-dom - react-native-b4a - rolldown + - rollup - sass - sass-embedded - sqlite3 @@ -26180,6 +26041,7 @@ snapshots: - supports-color - terser - tsx + - unloader - uploadthing - vite - vite-plugin-solid @@ -26187,10 +26049,14 @@ snapshots: - xml2js - yaml + '@tanstack/store@0.11.1': {} + '@tanstack/store@0.7.7': {} '@tanstack/store@0.8.0': {} + '@tanstack/store@0.9.3': {} + '@tanstack/typedoc-config@0.3.4': dependencies: typedoc: 0.28.20(@typescript/typescript6@6.0.2) @@ -26200,10 +26066,10 @@ snapshots: '@tanstack/virtual-file-routes@1.131.2': {} - '@tanstack/virtual-file-routes@1.141.0': {} - '@tanstack/virtual-file-routes@1.154.7': {} + '@tanstack/virtual-file-routes@1.162.0': {} + '@tanstack/vite-config@0.6.0(@microsoft/api-extractor@7.47.7(@types/node@24.10.3))(esbuild@0.28.1)(rolldown@1.1.5)(rollup@4.60.1)(supports-color@7.2.0)(typescript@7.0.2)(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0))': dependencies: vite: 8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0) @@ -26221,9 +26087,9 @@ snapshots: - typescript - webpack - '@tanstack/zod-adapter@1.166.9(@tanstack/react-router@1.159.5(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(zod@4.3.6)': + '@tanstack/zod-adapter@1.166.9(@tanstack/react-router@1.170.32(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(zod@4.3.6)': dependencies: - '@tanstack/react-router': 1.159.5(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@tanstack/react-router': 1.170.32(react-dom@19.2.3(react@19.2.3))(react@19.2.3) zod: 4.3.6 '@testing-library/dom@10.4.1': @@ -26558,15 +26424,6 @@ snapshots: - supports-color optional: true - '@typescript-eslint/project-service@8.49.0(supports-color@7.2.0)(typescript@5.9.3)': - dependencies: - '@typescript-eslint/tsconfig-utils': 8.49.0(typescript@5.9.3) - '@typescript-eslint/types': 8.59.4 - debug: 4.4.3(supports-color@7.2.0) - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - '@typescript-eslint/project-service@8.59.4(typescript@7.0.2)': dependencies: '@typescript-eslint/tsconfig-utils': 8.59.4(typescript@7.0.2) @@ -26577,21 +26434,12 @@ snapshots: - supports-color optional: true - '@typescript-eslint/scope-manager@8.49.0': - dependencies: - '@typescript-eslint/types': 8.49.0 - '@typescript-eslint/visitor-keys': 8.49.0 - '@typescript-eslint/scope-manager@8.59.4': dependencies: '@typescript-eslint/types': 8.59.4 '@typescript-eslint/visitor-keys': 8.59.4 optional: true - '@typescript-eslint/tsconfig-utils@8.49.0(typescript@5.9.3)': - dependencies: - typescript: 5.9.3 - '@typescript-eslint/tsconfig-utils@8.59.4(typescript@7.0.2)': dependencies: typescript: 7.0.2 @@ -26610,25 +26458,8 @@ snapshots: - supports-color optional: true - '@typescript-eslint/types@8.49.0': {} - '@typescript-eslint/types@8.59.4': {} - '@typescript-eslint/typescript-estree@8.49.0(supports-color@7.2.0)(typescript@5.9.3)': - dependencies: - '@typescript-eslint/project-service': 8.49.0(supports-color@7.2.0)(typescript@5.9.3) - '@typescript-eslint/tsconfig-utils': 8.49.0(typescript@5.9.3) - '@typescript-eslint/types': 8.49.0 - '@typescript-eslint/visitor-keys': 8.49.0 - debug: 4.4.3(supports-color@7.2.0) - minimatch: 9.0.5 - semver: 7.8.4 - tinyglobby: 0.2.17 - ts-api-utils: 2.1.0(typescript@5.9.3) - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - '@typescript-eslint/typescript-estree@8.59.4(typescript@7.0.2)': dependencies: '@typescript-eslint/project-service': 8.59.4(typescript@7.0.2) @@ -26645,17 +26476,6 @@ snapshots: - supports-color optional: true - '@typescript-eslint/utils@8.49.0(eslint@9.39.4(jiti@2.7.0))(supports-color@7.2.0)(typescript@5.9.3)': - dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.7.0)) - '@typescript-eslint/scope-manager': 8.49.0 - '@typescript-eslint/types': 8.49.0 - '@typescript-eslint/typescript-estree': 8.49.0(supports-color@7.2.0)(typescript@5.9.3) - eslint: 9.39.4(jiti@2.7.0) - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - '@typescript-eslint/utils@8.59.4(eslint@9.39.4(jiti@2.7.0))(typescript@7.0.2)': dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.7.0)) @@ -26668,11 +26488,6 @@ snapshots: - supports-color optional: true - '@typescript-eslint/visitor-keys@8.49.0': - dependencies: - '@typescript-eslint/types': 8.49.0 - eslint-visitor-keys: 4.2.1 - '@typescript-eslint/visitor-keys@8.59.4': dependencies: '@typescript-eslint/types': 8.59.4 @@ -27405,15 +27220,6 @@ snapshots: transitivePeerDependencies: - supports-color - babel-plugin-jsx-dom-expressions@0.40.3(@babel/core@7.29.0): - dependencies: - '@babel/core': 7.29.0(supports-color@7.2.0) - '@babel/helper-module-imports': 7.18.6 - '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.0) - '@babel/types': 7.29.7 - html-entities: 2.3.3 - parse5: 7.3.0 - babel-plugin-jsx-dom-expressions@0.40.3(@babel/core@7.29.7): dependencies: '@babel/core': 7.29.7 @@ -27567,13 +27373,6 @@ snapshots: - '@babel/core' - supports-color - babel-preset-solid@1.9.10(@babel/core@7.29.0)(solid-js@1.9.10): - dependencies: - '@babel/core': 7.29.0(supports-color@7.2.0) - babel-plugin-jsx-dom-expressions: 0.40.3(@babel/core@7.29.0) - optionalDependencies: - solid-js: 1.9.10 - babel-preset-solid@1.9.10(@babel/core@7.29.7)(solid-js@1.9.10): dependencies: '@babel/core': 7.29.7 @@ -28164,6 +27963,8 @@ snapshots: cookie-es@2.0.1: {} + cookie-es@3.1.1: {} + cookie-signature@1.2.2: {} cookie@0.6.0: {} @@ -28759,9 +28560,9 @@ snapshots: esbuild-plugin-solid@0.5.0(esbuild@0.28.1)(solid-js@1.9.10): dependencies: - '@babel/core': 7.29.0(supports-color@7.2.0) - '@babel/preset-typescript': 7.28.5(@babel/core@7.29.0) - babel-preset-solid: 1.9.10(@babel/core@7.29.0)(solid-js@1.9.10) + '@babel/core': 7.29.7 + '@babel/preset-typescript': 7.28.5(@babel/core@7.29.7) + babel-preset-solid: 1.9.10(@babel/core@7.29.7)(solid-js@1.9.10) esbuild: 0.28.1 solid-js: 1.9.10 transitivePeerDependencies: @@ -28898,19 +28699,6 @@ snapshots: optionalDependencies: source-map: 0.6.1 - eslint-plugin-solid@0.14.5(eslint@9.39.4(jiti@2.7.0))(supports-color@7.2.0)(typescript@5.9.3): - dependencies: - '@typescript-eslint/utils': 8.49.0(eslint@9.39.4(jiti@2.7.0))(supports-color@7.2.0)(typescript@5.9.3) - eslint: 9.39.4(jiti@2.7.0) - estraverse: 5.3.0 - is-html: 2.0.0 - kebab-case: 1.0.2 - known-css-properties: 0.30.0 - style-to-object: 1.0.14 - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - eslint-plugin-unused-imports@4.3.0(@typescript-eslint/eslint-plugin@8.59.4(@typescript-eslint/parser@8.59.4(eslint@9.39.4(jiti@2.7.0))(typescript@7.0.2))(eslint@9.39.4(jiti@2.7.0))(typescript@7.0.2))(eslint@9.39.4(jiti@2.7.0)): dependencies: eslint: 9.39.4(jiti@2.7.0) @@ -29754,6 +29542,13 @@ snapshots: optionalDependencies: crossws: 0.4.6(srvx@0.11.17) + h3@2.0.1-rc.20(crossws@0.4.6(srvx@0.11.17)): + dependencies: + rou3: 0.8.1 + srvx: 0.11.17 + optionalDependencies: + crossws: 0.4.6(srvx@0.11.17) + h3@2.0.1-rc.22(crossws@0.4.6(srvx@0.11.17)): dependencies: rou3: 0.8.1 @@ -29930,8 +29725,6 @@ snapshots: dependencies: cheerio: 1.1.2 - html-tags@3.3.1: {} - html-url-attributes@3.0.1: {} html-void-elements@3.0.0: {} @@ -30160,10 +29953,6 @@ snapshots: is-hexadecimal@2.0.1: {} - is-html@2.0.0: - dependencies: - html-tags: 3.3.1 - is-inside-container@1.0.0: dependencies: is-docker: 3.0.0 @@ -30528,8 +30317,6 @@ snapshots: jwa: 2.0.1 safe-buffer: 5.2.1 - kebab-case@1.0.2: {} - keyv@4.5.4: dependencies: json-buffer: 3.0.1 @@ -30571,8 +30358,6 @@ snapshots: knitwork@1.3.0: {} - known-css-properties@0.30.0: {} - kolorist@1.8.0: {} lan-network@0.2.1: {} @@ -31198,7 +30983,7 @@ snapshots: metro-source-map@0.84.4: dependencies: '@babel/traverse': 7.29.0 - '@babel/types': 7.29.0 + '@babel/types': 7.29.7 flow-enums-runtime: 0.0.6 invariant: 2.2.4 metro-symbolicate: 0.84.4 @@ -33875,7 +33660,7 @@ snapshots: dependencies: '@babel/generator': 7.28.5 '@babel/parser': 7.29.0 - '@babel/types': 7.29.0 + '@babel/types': 7.29.7 ast-kit: 2.2.0 birpc: 3.0.0 dts-resolver: 2.1.3(oxc-resolver@11.21.3) @@ -34212,12 +33997,18 @@ snapshots: dependencies: seroval: 1.5.0 + seroval-plugins@1.6.4(seroval@1.6.4): + dependencies: + seroval: 1.6.4 + seroval@1.3.2: {} seroval@1.4.0: {} seroval@1.5.0: {} + seroval@1.6.4: {} + serve-placeholder@2.0.2: dependencies: defu: 6.1.4 @@ -34874,10 +34665,6 @@ snapshots: ts-algebra@2.0.0: {} - ts-api-utils@2.1.0(typescript@5.9.3): - dependencies: - typescript: 5.9.3 - ts-api-utils@2.5.0(typescript@7.0.2): dependencies: typescript: 7.0.2 @@ -35270,6 +35057,17 @@ snapshots: picomatch: 4.0.5 webpack-virtual-modules: 0.6.2 + unplugin@3.3.0(esbuild@0.28.1)(rolldown@1.2.4)(rollup@4.60.1)(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)): + dependencies: + '@jridgewell/remapping': 2.3.5 + picomatch: 4.0.5 + webpack-virtual-modules: 0.6.2 + optionalDependencies: + esbuild: 0.28.1 + rolldown: 1.2.4 + rollup: 4.60.1 + vite: 8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0) + unrun@0.2.19(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1): dependencies: rolldown: 1.0.0-beta.53(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) @@ -35665,10 +35463,6 @@ snapshots: tsx: 4.23.12 yaml: 2.9.0 - vitefu@1.1.1(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)): - optionalDependencies: - vite: 8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0) - vitefu@1.1.3(vite@8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0)): optionalDependencies: vite: 8.2.1(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.0) @@ -36094,6 +35888,8 @@ snapshots: zod@4.3.6: {} + zod@4.5.4: {} + zone.js@0.15.1: {} zwitch@2.0.4: {} diff --git a/testing/e2e/package.json b/testing/e2e/package.json index a6d86fa5ec..cd6729fe1e 100644 --- a/testing/e2e/package.json +++ b/testing/e2e/package.json @@ -47,8 +47,8 @@ "@tanstack/devtools-event-bus": "^0.4.1", "@tanstack/nitro-v2-vite-plugin": "^1.155.0", "@tanstack/react-ai-devtools": "workspace:*", - "@tanstack/react-router": "^1.158.4", - "@tanstack/react-start": "^1.159.0", + "@tanstack/react-router": "^1.170.32", + "@tanstack/react-start": "^1.168.49", "arktype": "^2.1.28", "react": "^19.2.3", "react-dom": "^19.2.3", diff --git a/testing/e2e/src/components/ImageGenUI.tsx b/testing/e2e/src/components/ImageGenUI.tsx index 93cec8b7ce..bd59c7d159 100644 --- a/testing/e2e/src/components/ImageGenUI.tsx +++ b/testing/e2e/src/components/ImageGenUI.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react' +import { useRef, useState } from 'react' import { useGenerateImage, fetchServerSentEvents, @@ -67,8 +67,12 @@ export function ImageGenUI({ } } - const { generate, result, isLoading, error, status } = + const { generate, result, isLoading, error, status, runId } = useGenerateImage(connectionOptions()) + const activeTuple = useRef({ runId: '', status: 'idle', isLoading: false }) + if (runId && status === 'generating' && isLoading) { + activeTuple.current = { runId, status, isLoading } + } const handleGenerate = async () => { if (!imageFile) { @@ -89,6 +93,12 @@ export function ImageGenUI({ return (
+ {activeTuple.current.runId} + {activeTuple.current.status} + + {String(activeTuple.current.isLoading)} + + {runId ?? ''}
{ if (!imageFile) { @@ -92,6 +96,12 @@ export function VideoGenUI({ return (
+ {activeTuple.current.runId} + {activeTuple.current.status} + + {String(activeTuple.current.isLoading)} + + {runId ?? ''}
{ 'Fender Stratocaster', ) + const hydrationErrors: Array = [] + page.on('pageerror', (error) => { + if ( + /hydration failed|hydrated but|server rendered html/i.test( + error.message, + ) + ) { + hydrationErrors.push(error.message) + } + }) + page.on('console', (message) => { + const text = message.text() + if ( + message.type() === 'error' && + /hydration failed|hydrated but|server rendered html/i.test(text) + ) { + hydrationErrors.push(text) + } + }) + await page.reload() await expect(page.getByTestId('user-message')).toContainText( @@ -232,6 +252,7 @@ test.describe('openai chat persistence', () => { await expect(page.getByTestId('assistant-message')).toContainText( 'Fender Stratocaster', ) + expect(hydrationErrors).toEqual([]) }) test('clear() removes the persisted conversation so a reload starts empty', async ({ diff --git a/testing/e2e/tests/generic-middleware-interrupts.spec.ts b/testing/e2e/tests/generic-middleware-interrupts.spec.ts index 083e841fd3..d6b65b69bf 100644 --- a/testing/e2e/tests/generic-middleware-interrupts.spec.ts +++ b/testing/e2e/tests/generic-middleware-interrupts.spec.ts @@ -307,6 +307,23 @@ test('restores a pending generic interrupt after reload and resumes it', async ( aimockPort, }) => { await startScenario(page, testId, aimockPort, 'generic-before-model') + const hydrationErrors: Array = [] + page.on('pageerror', (error) => { + if ( + /hydration failed|hydrated but|server rendered html/i.test(error.message) + ) { + hydrationErrors.push(error.message) + } + }) + page.on('console', (message) => { + const text = message.text() + if ( + message.type() === 'error' && + /hydration failed|hydrated but|server rendered html/i.test(text) + ) { + hydrationErrors.push(text) + } + }) await page.reload() await expect(page.getByTestId('generic-review-plan')).toBeVisible() await resolveReview(page) @@ -318,4 +335,5 @@ test('restores a pending generic interrupt after reload and resumes it', async ( expect(capture.resolutions).toEqual([ expect.objectContaining({ status: 'resolved' }), ]) + expect(hydrationErrors).toEqual([]) }) diff --git a/testing/e2e/tests/image-gen.spec.ts b/testing/e2e/tests/image-gen.spec.ts index 7997996fa5..927b6d77a5 100644 --- a/testing/e2e/tests/image-gen.spec.ts +++ b/testing/e2e/tests/image-gen.spec.ts @@ -20,6 +20,10 @@ for (const provider of providersFor('image-gen')) { await fillPrompt(page, 'a guitar in a music store') await clickGenerate(page) await waitForGenerationComplete(page) + await expect(page.getByTestId('active-run-id')).not.toHaveText('') + await expect(page.getByTestId('active-status')).toHaveText('generating') + await expect(page.getByTestId('active-loading')).toHaveText('true') + await expect(page.getByTestId('final-run-id')).toHaveText('') const images = page.getByTestId('generated-image') await expect(images).toHaveCount(1) }) @@ -35,6 +39,10 @@ for (const provider of providersFor('image-gen')) { await fillPrompt(page, 'a guitar in a music store') await clickGenerate(page) await waitForGenerationComplete(page) + await expect(page.getByTestId('active-run-id')).not.toHaveText('') + await expect(page.getByTestId('active-status')).toHaveText('generating') + await expect(page.getByTestId('active-loading')).toHaveText('true') + await expect(page.getByTestId('final-run-id')).toHaveText('') const images = page.getByTestId('generated-image') await expect(images).toHaveCount(1) }) @@ -50,6 +58,10 @@ for (const provider of providersFor('image-gen')) { await fillPrompt(page, 'a guitar in a music store') await clickGenerate(page) await waitForGenerationComplete(page) + await expect(page.getByTestId('active-run-id')).not.toHaveText('') + await expect(page.getByTestId('active-status')).toHaveText('generating') + await expect(page.getByTestId('active-loading')).toHaveText('true') + await expect(page.getByTestId('final-run-id')).toHaveText('') const images = page.getByTestId('generated-image') await expect(images).toHaveCount(1) }) diff --git a/testing/e2e/tests/persistence-durability.spec.ts b/testing/e2e/tests/persistence-durability.spec.ts index 0c57dcd9ff..a6af750e7f 100644 --- a/testing/e2e/tests/persistence-durability.spec.ts +++ b/testing/e2e/tests/persistence-durability.spec.ts @@ -59,6 +59,26 @@ test.describe('persistence durability (browser refresh)', () => { expect(Array.isArray(record.messages)).toBe(true) expect(record.messages.length).toBeGreaterThanOrEqual(2) + const hydrationErrors: Array = [] + page.on('pageerror', (error) => { + if ( + /hydration failed|hydrated but|server rendered html/i.test( + error.message, + ) + ) { + hydrationErrors.push(error.message) + } + }) + page.on('console', (message) => { + const text = message.text() + if ( + message.type() === 'error' && + /hydration failed|hydrated but|server rendered html/i.test(text) + ) { + hydrationErrors.push(text) + } + }) + // Full reload: the conversation must come back from localStorage alone. await page.reload() @@ -71,6 +91,7 @@ test.describe('persistence durability (browser refresh)', () => { ) // No in-flight run to rejoin after a clean finish — the page settles idle. await expect(page.getByTestId('loading-indicator')).toHaveCount(0) + expect(hydrationErrors).toEqual([]) }) test('a pending interrupt survives a reload (rehydrated from the resume snapshot)', async ({ @@ -98,6 +119,26 @@ test.describe('persistence durability (browser refresh)', () => { const record = JSON.parse(stored!) as { resume?: unknown } expect(record.resume).toBeTruthy() + const hydrationErrors: Array = [] + page.on('pageerror', (error) => { + if ( + /hydration failed|hydrated but|server rendered html/i.test( + error.message, + ) + ) { + hydrationErrors.push(error.message) + } + }) + page.on('console', (message) => { + const text = message.text() + if ( + message.type() === 'error' && + /hydration failed|hydrated but|server rendered html/i.test(text) + ) { + hydrationErrors.push(text) + } + }) + // Full reload: the interrupt must rehydrate from localStorage, not a refetch. await page.reload() @@ -108,6 +149,7 @@ test.describe('persistence durability (browser refresh)', () => { await expect(page.getByTestId('interrupt-confirm-shipment')).toBeVisible() await expect(page.getByTestId('interrupt-kind')).toHaveText('generic') await expect(page.getByTestId('interrupt-source')).toHaveText('hydrate') + expect(hydrationErrors).toEqual([]) }) test('restores a pending interrupt from the SERVER on a fresh load (persistence: true)', async ({ diff --git a/testing/e2e/tests/video-gen.spec.ts b/testing/e2e/tests/video-gen.spec.ts index 841fbccb8e..d7f5da6a62 100644 --- a/testing/e2e/tests/video-gen.spec.ts +++ b/testing/e2e/tests/video-gen.spec.ts @@ -20,6 +20,10 @@ for (const provider of providersFor('video-gen')) { await fillPrompt(page, 'a guitar being played in a store') await clickGenerate(page) await waitForGenerationComplete(page, 60_000) + await expect(page.getByTestId('active-run-id')).not.toHaveText('') + await expect(page.getByTestId('active-status')).toHaveText('generating') + await expect(page.getByTestId('active-loading')).toHaveText('true') + await expect(page.getByTestId('final-run-id')).toHaveText('') const video = page.getByTestId('generated-video') await expect(video).toBeVisible() }) @@ -35,6 +39,10 @@ for (const provider of providersFor('video-gen')) { await fillPrompt(page, 'a guitar being played in a store') await clickGenerate(page) await waitForGenerationComplete(page, 60_000) + await expect(page.getByTestId('active-run-id')).not.toHaveText('') + await expect(page.getByTestId('active-status')).toHaveText('generating') + await expect(page.getByTestId('active-loading')).toHaveText('true') + await expect(page.getByTestId('final-run-id')).toHaveText('') const video = page.getByTestId('generated-video') await expect(video).toBeVisible() }) @@ -50,6 +58,10 @@ for (const provider of providersFor('video-gen')) { await fillPrompt(page, 'a guitar being played in a store') await clickGenerate(page) await waitForGenerationComplete(page, 60_000) + await expect(page.getByTestId('active-run-id')).not.toHaveText('') + await expect(page.getByTestId('active-status')).toHaveText('generating') + await expect(page.getByTestId('active-loading')).toHaveText('true') + await expect(page.getByTestId('final-run-id')).toHaveText('') const video = page.getByTestId('generated-video') await expect(video).toBeVisible() }) diff --git a/testing/panel/package.json b/testing/panel/package.json index f51fa0b379..320270b00b 100644 --- a/testing/panel/package.json +++ b/testing/panel/package.json @@ -28,8 +28,8 @@ "@tanstack/nitro-v2-vite-plugin": "^1.155.0", "@tanstack/react-ai-devtools": "workspace:*", "@tanstack/react-devtools": "^0.9.10", - "@tanstack/react-router": "^1.158.4", - "@tanstack/react-start": "^1.159.0", + "@tanstack/react-router": "^1.170.32", + "@tanstack/react-start": "^1.168.49", "@tanstack/start": "^1.120.20", "highlight.js": "^11.11.1", "lucide-react": "^0.561.0",