diff --git a/api-goldens/element-ng/chat-messages/index.api.md b/api-goldens/element-ng/chat-messages/index.api.md index eb8b04b3f5..fc1b8ff08d 100644 --- a/api-goldens/element-ng/chat-messages/index.api.md +++ b/api-goldens/element-ng/chat-messages/index.api.md @@ -4,7 +4,6 @@ ```ts -import { AfterContentInit } from '@angular/core'; import { AfterViewInit } from '@angular/core'; import * as _angular_core from '@angular/core'; import { ElementRef } from '@angular/core'; @@ -82,7 +81,7 @@ export class SiAttachmentListComponent { } // @public -export class SiChatContainerComponent implements AfterContentInit, OnDestroy { +export class SiChatContainerComponent implements OnDestroy { constructor(); readonly colorVariant: _angular_core.InputSignal; focus(): void; diff --git a/playwright/e2e/element-examples/si-filtered-search.spec.ts b/playwright/e2e/element-examples/si-filtered-search.spec.ts index 9f304cbb9f..e000d46cec 100644 --- a/playwright/e2e/element-examples/si-filtered-search.spec.ts +++ b/playwright/e2e/element-examples/si-filtered-search.spec.ts @@ -40,7 +40,11 @@ test.describe('filtered search', () => { await page.keyboard.press('Enter'); await page.keyboard.press('Escape'); await expect(page.getByText('Event', { exact: true })).not.toBeAttached(); - // delete location criterion + // delete location criterion. Ensure focus is on the free-text search before + // pressing Backspace, otherwise the keypress may be routed to whichever + // element currently has focus after the Event pill was removed. + await freeTextSearch.focus(); + await expect(freeTextSearch).toBeFocused(); await page.keyboard.press('Backspace'); await expect( page.locator('.pill-group', { hasText: 'Location' }).getByRole('combobox') diff --git a/playwright/snapshots/si-page-header.spec.ts-snapshots/si-page-header--si-page-header--small-element-examples-chromium-dark-linux.png b/playwright/snapshots/si-page-header.spec.ts-snapshots/si-page-header--si-page-header--small-element-examples-chromium-dark-linux.png index 57925f7cdb..5a3c58853c 100644 --- a/playwright/snapshots/si-page-header.spec.ts-snapshots/si-page-header--si-page-header--small-element-examples-chromium-dark-linux.png +++ b/playwright/snapshots/si-page-header.spec.ts-snapshots/si-page-header--si-page-header--small-element-examples-chromium-dark-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:cbd6fd7835659f9916190be44218dbd342d5807e45786b4fb2ac4bd3f53595c2 -size 18865 +oid sha256:c053033b1c9fee81849de49ed1f966a84325dfa789f5f4ece3391938bf9479f8 +size 18833 diff --git a/playwright/snapshots/static.spec.ts-snapshots/si-markdown-renderer--si-markdown-renderer-element-examples-chromium-dark-linux.png b/playwright/snapshots/static.spec.ts-snapshots/si-markdown-renderer--si-markdown-renderer-element-examples-chromium-dark-linux.png index 5ce87f7b9d..585dcf14c9 100644 --- a/playwright/snapshots/static.spec.ts-snapshots/si-markdown-renderer--si-markdown-renderer-element-examples-chromium-dark-linux.png +++ b/playwright/snapshots/static.spec.ts-snapshots/si-markdown-renderer--si-markdown-renderer-element-examples-chromium-dark-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9099595741c83cc68b109c3e10c580a16baa955edada83be763a4e0463c4b562 -size 142664 +oid sha256:d1a06070911e18b191b0b085dbb7608dc361d0fa1f6f482e877bc9fd705af1b0 +size 142650 diff --git a/playwright/snapshots/static.spec.ts-snapshots/typography--type-styles-element-examples-chromium-light-linux.png b/playwright/snapshots/static.spec.ts-snapshots/typography--type-styles-element-examples-chromium-light-linux.png index b16e204a35..cdc9803408 100644 --- a/playwright/snapshots/static.spec.ts-snapshots/typography--type-styles-element-examples-chromium-light-linux.png +++ b/playwright/snapshots/static.spec.ts-snapshots/typography--type-styles-element-examples-chromium-light-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c3a0bb9ae124cb2d943a470ab96f349363c6af171c632862b3f867019e8f5b1b -size 42836 +oid sha256:1ea6ffa74d5025d78bfd2dfdde0493ae2486753e95eeeeba94b77428b169a3c8 +size 42846 diff --git a/projects/element-ng/chat-messages/si-chat-container.component.spec.ts b/projects/element-ng/chat-messages/si-chat-container.component.spec.ts index e1b30798f2..22be1ae0a4 100644 --- a/projects/element-ng/chat-messages/si-chat-container.component.spec.ts +++ b/projects/element-ng/chat-messages/si-chat-container.component.spec.ts @@ -163,12 +163,4 @@ describe('SiChatContainerComponent', () => { fixture.componentInstance.ngOnDestroy(); expect(ngOnDestroySpy).toHaveBeenCalled(); }); - - it('should call ngAfterContentInit', () => { - const newFixture = TestBed.createComponent(SiChatContainerComponent); - const newComponent = newFixture.componentInstance; - const ngAfterContentInitSpy = vi.spyOn(newComponent, 'ngAfterContentInit'); - newComponent.ngAfterContentInit(); - expect(ngAfterContentInitSpy).toHaveBeenCalled(); - }); }); diff --git a/projects/element-ng/chat-messages/si-chat-container.component.ts b/projects/element-ng/chat-messages/si-chat-container.component.ts index b131246e76..1dc84615ce 100644 --- a/projects/element-ng/chat-messages/si-chat-container.component.ts +++ b/projects/element-ng/chat-messages/si-chat-container.component.ts @@ -4,9 +4,8 @@ */ import { isPlatformBrowser } from '@angular/common'; import { - AfterContentInit, + afterNextRender, Component, - effect, ElementRef, input, OnDestroy, @@ -44,7 +43,7 @@ import { '[class]': 'colorVariant()' } }) -export class SiChatContainerComponent implements AfterContentInit, OnDestroy { +export class SiChatContainerComponent implements OnDestroy { private readonly messagesContainer = viewChild>('messagesContainer'); private readonly platformId = inject(PLATFORM_ID); @@ -55,6 +54,7 @@ export class SiChatContainerComponent implements AfterContentInit, OnDestroy { private scrollDebounceMs = 7; // ~144fps private resizeObserver: ResizeObserver | undefined; private contentObserver: MutationObserver | undefined; + private initialScrollDone = false; /** * The color variant to apply to the container. @@ -71,18 +71,37 @@ export class SiChatContainerComponent implements AfterContentInit, OnDestroy { }); constructor() { - effect(() => { - if (this.messagesContainer()) { + // Use the phased afterNextRender API: the `read` phase forces a layout read of + // scrollHeight against fully committed DOM, the `write` phase performs the + // scroll mutation, and the observers are wired up only after that initial + // settled scroll. This guarantees a single deterministic scrollTop write per + // mount before the live-preview-done class becomes visible, eliminating the + // race that produced flaky VRT snapshots. + afterNextRender({ + earlyRead: () => this.messagesContainer()?.nativeElement.scrollHeight, + write: scrollHeight => { + const el = this.messagesContainer()?.nativeElement; + if (el && scrollHeight != null && !this.noAutoScroll()) { + el.scrollTop = scrollHeight; + } + this.initialScrollDone = true; this.setupResizeObserver(); this.setupContentObserver(); + + // Web fonts may settle after the initial layout commit and shift the + // content height. Re-pin to the bottom once fonts are ready so VRT + // screenshots and real users both see the latest message. + if (isPlatformBrowser(this.platformId)) { + document.fonts.ready.then(() => { + if (this.isUserAtBottom) { + this.scrollToBottomDuringStreaming(); + } + }); + } } }); } - ngAfterContentInit(): void { - this.scrollToBottomDuringStreaming(); - } - ngOnDestroy(): void { if (this.scrollTimeout) { clearTimeout(this.scrollTimeout); @@ -110,6 +129,12 @@ export class SiChatContainerComponent implements AfterContentInit, OnDestroy { } private debouncedScrollToBottom(): void { + // Skip observer-driven scrolls until the explicit initial scroll has completed. + // This prevents racing with the afterNextRender settled scrollTop assignment. + if (!this.initialScrollDone) { + return; + } + const now = Date.now(); const timeSinceLastScroll = now - this.lastScrollTime;