diff --git a/examples/chat/angular/src/app/shell/demo-shell.component.spec.ts b/examples/chat/angular/src/app/shell/demo-shell.component.spec.ts index 86b84efc5..1032edc66 100644 --- a/examples/chat/angular/src/app/shell/demo-shell.component.spec.ts +++ b/examples/chat/angular/src/app/shell/demo-shell.component.spec.ts @@ -1,7 +1,7 @@ import { signal } from '@angular/core'; import { describe, it, expect, beforeEach } from 'vitest'; import { TestBed } from '@angular/core/testing'; -import { provideRouter, Router } from '@angular/router'; +import { provideRouter, Router, NavigationEnd } from '@angular/router'; import { LangGraphThreadsAdapter } from '@ngaf/langgraph'; import { DemoShell } from './demo-shell.component'; @@ -215,6 +215,38 @@ describe('DemoShell — URL thread sync', () => { }; expect(cmp.threadIdSignal()).toBe('url-thread'); }); + + it('does not re-navigate when hydrating from URL (no nav-loop)', async () => { + // Regression guard for the URL↔signal sync invariant that every PR + // in the routing chain (#500/#504/#514/#518/#527) was dancing + // around: when the URL→signal effect hydrates `threadIdSignal` + // from `/embed/`, the subsequent signal→URL effect must see + // signal === urlId and short-circuit (compare-and-set guard). + // Without that guard we'd loop: URL → signal → router.navigate → + // URL again, observable as extra NavigationEnd events. + const router = TestBed.inject(Router); + await router.navigateByUrl('/embed/no-loop-thread'); + + // Subscribe BEFORE createComponent so we capture any NavigationEnd + // events the component's effects might emit. The initial nav above + // already fired before we subscribed, so it doesn't count. + const navEnds: string[] = []; + const sub = router.events.subscribe((e) => { + if (e instanceof NavigationEnd) navEnds.push(e.urlAfterRedirects); + }); + + const fx = TestBed.createComponent(DemoShell); + fx.detectChanges(); + sub.unsubscribe(); + + const cmp = fx.componentInstance as unknown as { + threadIdSignal: { (): string | null }; + }; + expect(cmp.threadIdSignal()).toBe('no-loop-thread'); + // Zero NavigationEnd events — the signal→URL effect short-circuited + // because signal already matched urlState (compare-and-set guard). + expect(navEnds).toEqual([]); + }); }); describe('DemoShell — URL knob hydration', () => {