From eb401e81efb8c1972bdc0d8e4642f8e569b877bb Mon Sep 17 00:00:00 2001 From: Brian Love Date: Wed, 20 May 2026 21:50:56 -0700 Subject: [PATCH] fix(examples-chat): collapse mode routes via UrlMatcher to preserve component instance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #500 added a route entry per (mode, hasThreadId) — six entries total, two per mode. Navigating from `/embed` to `/embed/` was a route CHANGE (different entry), which tore down EmbedMode and remounted it, killing the active stream when the agent auto-created a thread mid-send. Symptom: `examples/chat — e2e` test "failed stream surfaces an alert and the next send recovers" + "core controls expose expected accessible names" both fail because the assistant message never renders (stream died at remount time). Vercel deploy gates on e2e, so prod has been stuck on the pre-#500 bundle. Fix: collapse the per-mode pair into a single route entry via UrlMatcher. Both `/embed` and `/embed/` now resolve to the same route, so the component instance survives the navigation and the stream keeps flowing. Co-Authored-By: Claude Opus 4.7 (1M context) --- examples/chat/angular/src/app/app.routes.ts | 57 ++++++++++++--------- 1 file changed, 32 insertions(+), 25 deletions(-) diff --git a/examples/chat/angular/src/app/app.routes.ts b/examples/chat/angular/src/app/app.routes.ts index 799e5dd1..40026813 100644 --- a/examples/chat/angular/src/app/app.routes.ts +++ b/examples/chat/angular/src/app/app.routes.ts @@ -1,12 +1,34 @@ // SPDX-License-Identifier: MIT -import { Routes } from '@angular/router'; +import { Routes, UrlMatcher, UrlSegment } from '@angular/router'; + +/** Matcher factory: collapses `` and `/` into a + * single route entry. Two separate route entries (`embed` + `embed/:threadId`) + * cause Angular to tear down + remount the mode component when navigating + * from one to the other — which, post-PR-#500, was killing the in-flight + * stream when the agent auto-created a thread mid-send and our + * signal→URL effect navigated `/embed` → `/embed/`. + * + * This matcher recognises both URL shapes as the same route, so the + * component instance survives the navigation. + * + * Exported `posParams.threadId` is consumable via ActivatedRoute / + * router.firstChild.paramMap if a consumer ever needs it; DemoShell + * itself reads from `router.url` via `parseUrl()` and doesn't depend + * on the param being plumbed through ActivatedRoute. */ +function modeMatcher(modeName: string): UrlMatcher { + return (segments: UrlSegment[]) => { + if (segments.length === 0) return null; + if (segments[0].path !== modeName) return null; + if (segments.length === 1) { + return { consumed: segments, posParams: {} }; + } + if (segments.length === 2) { + return { consumed: segments, posParams: { threadId: segments[1] } }; + } + return null; + }; +} -// Each mode gets two route entries: a stateless `` and a -// thread-scoped `/:threadId`. Angular Router doesn't support -// `?`-style optional params, hence the duplication. DemoShell's -// URL ↔ signal sync (see spec 2026-05-20-url-thread-routing-design.md) -// reads `route.firstChild.paramMap.threadId` so both shapes feed the -// same handler. export const routes: Routes = [ { path: '', pathMatch: 'full', redirectTo: 'embed' }, { @@ -15,32 +37,17 @@ export const routes: Routes = [ import('./shell/demo-shell.component').then((m) => m.DemoShell), children: [ { - path: 'embed', + matcher: modeMatcher('embed'), loadComponent: () => import('./modes/embed-mode.component').then((m) => m.EmbedMode), }, { - path: 'embed/:threadId', - loadComponent: () => - import('./modes/embed-mode.component').then((m) => m.EmbedMode), - }, - { - path: 'popup', + matcher: modeMatcher('popup'), loadComponent: () => import('./modes/popup-mode.component').then((m) => m.PopupMode), }, { - path: 'popup/:threadId', - loadComponent: () => - import('./modes/popup-mode.component').then((m) => m.PopupMode), - }, - { - path: 'sidebar', - loadComponent: () => - import('./modes/sidebar-mode.component').then((m) => m.SidebarMode), - }, - { - path: 'sidebar/:threadId', + matcher: modeMatcher('sidebar'), loadComponent: () => import('./modes/sidebar-mode.component').then((m) => m.SidebarMode), },