bug(material/dialog): exit animation "jumps" (shrinks, then snaps back) when close() is called inside a requestAnimationFrame callback — common in zoneless apps
Maintainer thường phản hồi trong vòng 1 ngày
Chưa có ai nhận issue này.
Đánh giá
- Độ khó
- 3/5
- Thời gian dự kiến
- 1-2 ngày
- Mức phù hợp với người mới
- 67/100
- Loại issue
- Lỗi
- Độ rõ ràng
- Đặc tả rõ ràng
- Mức độ hoạt động
- Sôi nổi
- Công nghệ
- angular, typescript
- Lĩnh vực
- frontend
Hướng nghiên cứu
Start with MatDialogContainer._startExitAnimation() in the dialog container source and trace how it changes the open and closing classes. Reproduce the issue with the linked StackBlitz by closing from a requestAnimationFrame callback, then check the dialog animation tests for existing exit behavior. Done means the exit transition moves directly from open to closing without a frame in the class-less state.
Do mô hình lập chỉ mục viết ra từ nội dung của issue.
Mô tả
Is this a regression?
- Yes, this behavior used to work in the previous version
The previous version in which this bug was not present was
No response
Description
MatDialogContainer._startExitAnimation() removes mdc-dialog--open synchronously, but adds mdc-dialog--closing in a requestAnimationFrame callback:
this._hostElement.classList.remove(OPEN_CLASS);
if (this._animationsEnabled) {
this._hostElement.style.setProperty(TRANSITION_DURATION_PROPERTY, `${this._exitAnimationDuration}ms`);
this._requestAnimationFrame(() => this._hostElement.classList.add(CLOSING_CLASS));
...
}
If close() is called from a normal task (click handler, setTimeout, XHR callback), the rAF runs before the next paint, so the dialog goes straight from --open to --closing.
If close() is called from inside a rAF callback, the nested rAF is deferred to the next frame. One frame is painted with neither class. In that state the base styles apply: .mat-mdc-dialog-surface { transform: scale(0.8) } and .mat-mdc-dialog-inner-container { opacity: 0 }, both with a transition. The surface starts shrinking, then --closing arrives (transform: none) and it snaps back to full size before fading out.
In a zoneless app this happens without any explicit rAF in user code. The zoneless scheduler runs change detection via scheduleCallbackWithRafRace (setTimeout vs rAF, whichever fires first). Effects run during that tick (runEffect → runEffectsInView → ApplicationRef.tick → ChangeDetectionSchedulerImpl.tick), so a dialog that closes itself from an effect() is closed inside a rAF whenever the rAF wins the race. This is a common pattern when the close is driven by state, e.g. "close when the save request succeeded".
Because it depends on the race, it is intermittent. Measured on the reproduction below, by hooking classList.add/remove and recording the rAF frame timestamp of each change:
How close() is called |
Class-less frame painted | Result |
|---|---|---|
Inside a requestAnimationFrame callback |
10 / 10 | ❌ always jumps |
From an effect() (zoneless, real mouse clicks) |
2 / 4 | ⚠️ intermittent |
| Directly in the click handler | 0 / 10 | ✅ clean fade-out |
Example log for the rAF case: remove(mdc-dialog--open) happens in frame 92399.7, and add(mdc-dialog--closing) happens in frame 92424.8. In a real app, closing from an effect() after an HTTP response showed the glitch in 2 out of 6 attempts.
Not a regression as far as we can tell. The nested requestAnimationFrame was added in #26971 (v16), but it only becomes visible when close() runs inside a rAF callback, which zoneless change detection makes common.
Reproduction
StackBlitz link: https://stackblitz.com/edit/mat-dialog-close-in-raf?file=src%2Fmain.ts
The dialog component from the StackBlitz (the app is bootstrapped with provideZonelessChangeDetection()):
@Component({
imports: [MatButton, MatDialogActions, MatDialogContent, MatDialogTitle],
template: `
<h2 mat-dialog-title>Example dialog</h2>
<mat-dialog-content>Pick a way to close this dialog.</mat-dialog-content>
<mat-dialog-actions>
<button mat-button (click)="closeInRaf()">Close inside rAF</button>
<button mat-button (click)="closeRequested.set(true)">Close via effect</button>
<button mat-button (click)="dialogRef.close()">Close directly</button>
</mat-dialog-actions>
`,
})
export class ExampleDialog {
protected dialogRef = inject(MatDialogRef<ExampleDialog>);
protected closeRequested = signal(false);
constructor() {
effect(() => {
if (this.closeRequested()) {
this.dialogRef.close();
}
});
}
closeInRaf(): void {
requestAnimationFrame(() => this.dialogRef.close());
}
}
Steps to reproduce:
- Click "Open dialog", then "Close inside rAF". The surface visibly shrinks, jumps back to full size, then fades out. Every time.
- Click "Open dialog", then "Close via effect". Repeat several times. Some closes show the same jump.
- Click "Open dialog", then "Close directly". The dialog always fades out cleanly.
Slowing animations down in the DevTools Animations panel makes the jump easy to see.
Expected Behavior
The dialog transitions directly from the open state to the closing state, no matter which task or callback close() is called from.
Actual Behavior
When close() runs inside a rAF callback, one frame is rendered without mdc-dialog--open or mdc-dialog--closing. The surface starts animating toward scale(0.8) and then snaps back to transform: none when --closing is applied.
Related issues
- angular/angular#57528 (canonical: consequences of macrotask scheduling). This is a related consequence of zoneless scheduling, but the intermediate state here is created by
MatDialogContaineritself:--openis removed in one frame and--closingis added in the next. Angular's scheduler only determines whereclose()runs. The fix can stay local to the dialog container and needs no change to the scheduler. - Same class of zoneless timing bugs in other components: #29174 (virtual scroll flicker, fixed in #31316 by keeping the DOM write and the render in the same tick), #33837 (drag-drop blink), #33851 (select exit fade).
Possible fix
Either of these removes the class-less frame:
- Add
CLOSING_CLASSsynchronously in_startExitAnimation(). The rAF from #26971 is needed for the opening animation, because a freshly attached element has no previous computed style to transition from. On close, the element has already been rendered in the open state, so a synchronous class change starts the transition correctly. The duration custom property set in the same task is picked up as well, because transitions use the after-change style. - Keep the rAF, but move the
OPEN_CLASSremoval into the same callback:this._requestAnimationFrame(() => { this._hostElement.classList.remove(OPEN_CLASS); this._hostElement.classList.add(CLOSING_CLASS); });
Environment
- Angular: 22.2.4 (zoneless)
- CDK/Material: 22.2.6. The same code is present on main.
- Browser(s): Chromium-based
- Operating System: macOS
- Ngôn ngữ chính
- TypeScript
- Star
- 25k
- Fork
- 6.9k
- Merge trung bình
- 2 ngày 7 giờ
- Pull request đã merge (30 ngày)
- 83
Chuẩn bị môi trường
- Không có Dockerfile hay tệp Docker Compose
- Không có mẫu pull request
- Đọc hướng dẫn đóng góp
Bắt đầu từ đâu
- Đọc hết issue, rồi đọc hướng dẫn đóng góp của dự án.
- Bình luận trên issue rằng bạn sẽ nhận — tránh hai người làm cùng một việc.
- Fork repository và làm thay đổi trên một nhánh.
- Mở pull request có tham chiếu số hiệu của issue.
Issue khác của angular/components
-
bug(mat-slider): on Firefox, slider thumb is not aligned with cursorCó thể đã có người làm @Devyalamaddi đã nhận 3 ngày trước. Đang mởarea: material/slider P4
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 62/100
angular/components#34094 · 1 bình luận ·
Maintainer thường phản hồi trong vòng 1 ngày
-
bug(material/sort): hover hint arrow on an unsorted header shows the active sort direction, not the resulting direction after click.Có thể đã có người làm @abdul-karim-mia đã nhận 2 ngày trước. Đang mởarea: material/sort P3
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 70/100
angular/components#33933 ·
Maintainer thường phản hồi trong vòng 1 ngày
-
bug(mat-date-picker): month shown twice in date pickerCó thể đã có người làm @PreethiPantangi đã nhận 54 ngày trước. Đang mởarea: material/datepicker P4
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 72/100
angular/components#33763 · 2 bình luận · 3 reaction ·
Maintainer thường phản hồi trong vòng 1 ngày
-
Bug in table.scss footer cell height: unset instead of footer row height unsetCó thể đã có người làm @minwookshin đã nhận 41 ngày trước. Đang mởarea: material/table P4
Độ khó 1/5 Dưới một giờ Mức phù hợp với người mới 88/100
angular/components#33709 · 1 bình luận ·
Maintainer thường phản hồi trong vòng 1 ngày
-
bug(date-adapter): Misleading JSDoc for createDate returns typeCó thể đã có người làm @BHUVANSH855 đã nhận 171 ngày trước. Đang mởarea: material/core P3
Độ khó 1/5 Dưới một giờ Mức phù hợp với người mới 78/100
angular/components#33059 · 2 bình luận ·
Maintainer thường phản hồi trong vòng 1 ngày
Tất cả issue của angular/components
Issue tương tự
-
normalizePropName strips `#` characters from Figma property names instead of only the `#id` suffixĐang mở
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 72/100
figma/code-connect#419 ·
-
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 78/100
-
bug
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 70/100
jeanibarz/knowledge-base-mcp-server#1268 ·
Maintainer thường phản hồi trong vòng 1 ngày
-
Độ khó 1/5 Dưới một giờ Mức phù hợp với người mới 78/100
Koenkk/zigbee-herdsman-converters#13820 ·
Maintainer thường phản hồi trong vòng 2 ngày
-
A live recovery copy treats a large whole number and text with the same digits as the same valueĐang mởbug sharing & sync
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 64/100
Maintainer thường phản hồi trong vòng 1 ngày