feat: persist AI analysis with Vercel Workflow DevKit#24
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
- Extract analysis logic into durable workflow (workflows/analysis.ts) using DurableAgent from @workflow/ai with prepareStep + stopWhen - Rewrite /api/chat to use workflow start(), return x-workflow-run-id header - Add /api/chat/[id]/stream reconnection endpoint using getRun() - Replace DefaultChatTransport with WorkflowChatTransport in HeroSection for automatic stream reconnection via localStorage run ID persistence - Convert <a> to <Link> in CoreWebVitalsSection, VitalsReport, DemoHeader for client-side navigation that preserves analysis state - Fix handleReset in ChatInterface and FollowUpSuggestions to clear workflow run ID and use router.push instead of window.location.reload - Wrap next.config with withWorkflow() for directive compilation Co-Authored-By: Claude <noreply@anthropic.com>
GHSA-vw5p-8cq8-m7mv and GHSA-g2pg-6438-jwpf — memory/CPU exhaustion in devalue.parse, patched in 5.6.2. Co-Authored-By: Claude <noreply@anthropic.com>
…way-and-back Move localStorage run ID cleanup from onFinish (which fires even after unmount via zombie fetch) to a mounted useEffect that only triggers when status transitions to "ready". Abort client-side stream on unmount so no background callbacks mutate stale state. Co-Authored-By: Claude <noreply@anthropic.com>
stop() returns a promise that rejects with AbortError when killing the fetch — need .catch() not try/catch in a sync cleanup. Co-Authored-By: Claude <noreply@anthropic.com>
The stop() call throws AbortError through SDK internals that can't be cleanly caught. It's also unnecessary: the only reason to abort on unmount was to prevent onFinish from clearing localStorage, which we already moved to a mounted useEffect. A zombie stream completing in the background now only triggers Sentry logging — harmless. Co-Authored-By: Claude <noreply@anthropic.com>
- Share web-vitals subscriptions via Zustand store (useVitalsStore) so LiveWebVitals and VitalsReport read from a single source - Restore document.visibilityState after triggerVisibilityChange so the web-vitals library doesn't think the page is backgrounded on subsequent soft navigations - Use try/catch for defineProperty to survive HMR reloads - Force hard navigation for FCP and TTFB demos (same as TTFB was already doing) since these metrics require a fresh page load Co-Authored-By: Claude <noreply@anthropic.com>
The per-component isInitialMount ref didn't survive React Strict Mode (double-fired effects consumed the ref, then second fire wiped metrics). Soft nav between pages also left stale values in the shared Zustand store. Move reset logic to a single VitalsNavigationReset component in root layout that compares pathname via useRef — Strict Mode safe because both fires see the same pathname and skip. On actual soft nav, pathname differs → reset fires once. Co-Authored-By: Claude <noreply@anthropic.com>
Reset useLoadState to loading:true alongside vitals store reset so demo pages (FCP 2s delay, CLS block additions, etc.) start from a clean state after soft navigation instead of rendering instantly. Co-Authored-By: Claude <noreply@anthropic.com>
The FCP demo's client-side setTimeout returning null didn't actually affect the browser's FCP metric — the server-rendered shell painted immediately. The TTFB demo defined a delay constant but never used it. Move the 2s delay to the server components so HTML delivery is genuinely delayed, which is what both metrics actually measure. Co-Authored-By: Claude <noreply@anthropic.com>
Server component `await` doesn't delay TTFB (Next.js streams layout immediately) and executes twice per request (~4s instead of ~2s). Move the 2s delay to proxy.ts so the entire HTTP response is delayed, correctly affecting both TTFB and FCP. Homepage FCP/TTFB cards now use `<a>` instead of `<Link>` so navigation creates real timing entries for metrics to register. Co-Authored-By: Claude <noreply@anthropic.com>
5a02d68 to
50ecdb3
Compare
Named `proxy` export works locally but Vercel's build adapter may not pick it up reliably (vercel/next.js#85711). Default export is the canonical pattern in Vercel's routing middleware docs. Co-Authored-By: Claude <noreply@anthropic.com>
Default export breaks edge function deployment. The proxy.ts convention requires a named `proxy` export. Co-Authored-By: Claude <noreply@anthropic.com>
proxy.ts delay works locally but Vercel's build pipeline has bugs with the new convention (vercel/next.js#85711, #86122). middleware.ts produces the identical edge function bundle and is battle-tested on Vercel. Co-Authored-By: Claude <noreply@anthropic.com>
Adds X-Demo-Delay response header to verify whether the proxy function actually executes on Vercel preview deployments. Co-Authored-By: Claude <noreply@anthropic.com>
Soft-nav TTFB/FCP values were overwriting the real hard-navigation values in the Zustand store, causing the demo to show ~13ms instead of the actual ~2000ms delay. Co-Authored-By: Claude <noreply@anthropic.com>
| try { localStorage.removeItem("webvitals-run-id"); } catch {} | ||
| window.location.href = "/"; |
There was a problem hiding this comment.
Bug: In Safari private mode, localStorage.removeItem() fails silently. The subsequent hard navigation with window.location.href causes the old run ID to persist, resuming the previous workflow.
Severity: HIGH
Suggested Fix
Replace the hard navigation window.location.href = "/" with a soft navigation, for example using Next.js's router.push("/"). This would allow the in-memory state to be reset correctly without relying on localStorage clearing, which is unreliable in private browsing modes. Alternatively, implement a more robust reset mechanism that doesn't depend on localStorage.removeItem() succeeding before navigation.
Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent.
Verify if this is a real issue. If it is, propose a fix; if not, explain why it's not
valid.
Location: components/ChatInterface.tsx#L47-L48
Potential issue: When a user in Safari's private browsing mode clicks "Analyze another",
the `handleReset` function attempts to clear the session by calling
`localStorage.removeItem("webvitals-run-id")`. This operation fails silently in Safari's
private mode. The code then performs a hard page navigation via `window.location.href =
"/"`. Upon reload, the application reads the stale `activeRunId` from `localStorage`,
which was never removed, and incorrectly calls `resumeStream()` to continue the previous
analysis workflow instead of initiating a new one. This prevents affected users from
starting a new analysis.
There was a problem hiding this comment.
Not a real issue. Safari's private browsing mode has supported localStorage since Safari 11 (2017). The old behavior where localStorage.setItem() threw in private mode was removed years ago. In modern Safari private browsing, localStorage
works normally — it's just scoped to the session and cleared when the window closes.
The try/catch is already defensive enough for any truly exotic edge case. The removeItem call won't fail silently — it'll work fine or get caught.
Summary
workflows/analysis.ts) usingDurableAgentfrom@workflow/ai— survives navigation, hard refreshes, and server restartsDefaultChatTransportwithWorkflowChatTransportfor automatic stream reconnection via localStorage run ID persistence<a>tags to<Link>for client-side navigation (CoreWebVitalsSection, VitalsReport, DemoHeader)router.pushinstead ofwindow.location.reload()Regression fixes
web-vitalsto5.1.0-soft-navs-2withreportSoftNavs: trueso metrics re-fire on soft navigationsonChatEndcallback that cleared the run ID — workflow now persists across soft navigation round-trips"submitted"status so loading facts appear immediately when starting a scanTest plan
npx workflow web— inspect workflow runs in the WDK dashboard🤖 Generated with Claude Code