Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions api-goldens/element-ng/chat-messages/index.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<string>;
focus(): void;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,8 @@
*/
import { isPlatformBrowser } from '@angular/common';
import {
AfterContentInit,
afterNextRender,
Component,
effect,
ElementRef,
input,
OnDestroy,
Expand Down Expand Up @@ -44,7 +43,7 @@ import {
'[class]': 'colorVariant()'
}
})
export class SiChatContainerComponent implements AfterContentInit, OnDestroy {
export class SiChatContainerComponent implements OnDestroy {
private readonly messagesContainer = viewChild<ElementRef<HTMLDivElement>>('messagesContainer');
private readonly platformId = inject(PLATFORM_ID);

Expand All @@ -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.
Expand All @@ -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);
Expand Down Expand Up @@ -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;

Expand Down
Loading