Skip to content

Commit d6f6938

Browse files
fix(dialog): Open from a trigger destroyed by the same click (#5344)
## Description * A `lu-dropdown-action` opening a dialog no longer throws `NG0911`. * The dialog closes again on backdrop click and Escape. ----- * `provideLuDialog()` on `luDialogOpen` makes the service injector the trigger's node injector. * `lu-dropdown-action` disposes the panel on its own `(click)` before `luDialogOpen` runs. * `afterNextRender` then registers on the destroyed view and throws. * The throw skips the backdrop and Escape close subscription. * The new *Dropdown / Angular / Dialog* story covers the case. Fixes #5342. ----- ### Contribution - [x] Root cause of the bug is identified and understood - [x] Bug is no longer reproducible - [x] E2E test or UI diff is added if required - [x] `npm run build` is OK - [x] `npm run lint` is OK ### Functional review - [x] UI diff is OK - [x] All related impacted functionalities are manually tested and show no regression ----- --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7443ba7 commit d6f6938

2 files changed

Lines changed: 61 additions & 2 deletions

File tree

packages/ng/dialog/dialog.service.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { Dialog, DialogRef } from '@angular/cdk/dialog';
22
import { Overlay } from '@angular/cdk/overlay';
3-
import { afterNextRender, inject, Injectable, Injector, Renderer2 } from '@angular/core';
3+
import { afterNextRender, EnvironmentInjector, inject, Injectable, Injector, Renderer2 } from '@angular/core';
44
import { isObservable, merge, of, take } from 'rxjs';
55
import { filter, switchMap, takeUntil } from 'rxjs/operators';
66
import { LuDialogConfig, LuDialogData, LuDialogRef, LuDialogResult } from './model';
@@ -14,6 +14,8 @@ export class LuDialogService {
1414

1515
#injector = inject(Injector);
1616

17+
#environmentInjector = inject(EnvironmentInjector);
18+
1719
open<C, TData = LuDialogData<C>>(config: LuDialogConfig<C, NoInfer<TData>>): LuDialogRef<C, TData> {
1820
// Assigned synchronously inside the `providers` callback below, which CDK calls during `Dialog.open()`.
1921
let luDialogRef!: LuDialogRef<C, TData>;
@@ -69,7 +71,9 @@ export class LuDialogService {
6971

7072
// Re-check once the view is stable: block strategy only locks scroll if the page already
7173
// overflows at attach time, which can miss for dialogs opened asynchronously (e.g. via a route).
72-
afterNextRender(() => scrollStrategy.enable(), { injector: this.#injector });
74+
// With `provideLuDialog()` on a directive, `#injector` is the trigger's node injector.
75+
// A `lu-dropdown-action` trigger is already destroyed by this click: `afterNextRender` on it would throw `NG0911`.
76+
afterNextRender(() => scrollStrategy.enable(), { injector: this.#environmentInjector });
7377

7478
if (cdkRef.componentRef) {
7579
const renderer = cdkRef.componentRef.injector.get(Renderer2);
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { ButtonComponent } from '@lucca-front/ng/button';
2+
import { configureLuDialog, DialogComponent, DialogContentComponent, DialogOpenDirective } from '@lucca-front/ng/dialog';
3+
import { DropdownActionComponent, DropdownItemComponent, DropdownMenuComponent, LuDropdownTriggerDirective } from '@lucca-front/ng/dropdown';
4+
import { applicationConfig, Meta, moduleMetadata, StoryObj } from '@storybook/angular-vite';
5+
import { createTestStory } from '@/helpers/stories';
6+
import { waitForAngular } from '@/helpers/test';
7+
import { expect, screen, userEvent, within } from 'storybook/test';
8+
9+
export default {
10+
title: 'Documentation/Overlays/Dropdown/Angular/Dialog',
11+
decorators: [
12+
applicationConfig({ providers: [configureLuDialog()] }),
13+
moduleMetadata({
14+
imports: [ButtonComponent, LuDropdownTriggerDirective, DropdownMenuComponent, DropdownItemComponent, DropdownActionComponent, DialogOpenDirective, DialogComponent, DialogContentComponent],
15+
}),
16+
],
17+
} as Meta;
18+
19+
export const Dialog: StoryObj = {
20+
render: () => ({
21+
template: `<button type="button" luButton disclosure [luDropdown]="dropdownSample">Dropdown</button>
22+
<ng-template #dropdownSample>
23+
<lu-dropdown-menu>
24+
<lu-dropdown-item>
25+
<button lu-dropdown-action type="button" [luDialogOpen]="dialogTpl">Open dialog</button>
26+
</lu-dropdown-item>
27+
</lu-dropdown-menu>
28+
</ng-template>
29+
<ng-template #dialogTpl>
30+
<lu-dialog>
31+
<lu-dialog-content>Backdrop click and Escape close this dialog.</lu-dialog-content>
32+
</lu-dialog>
33+
</ng-template>`,
34+
}),
35+
};
36+
37+
export const DialogTEST = createTestStory(Dialog, async ({ canvasElement, step }) => {
38+
await waitForAngular();
39+
const trigger = within(canvasElement).getByRole('button', { name: /dropdown/i });
40+
41+
await step('Opens the dialog from the dropdown action', async () => {
42+
await userEvent.click(trigger);
43+
await waitForAngular();
44+
await userEvent.click(screen.getByRole('button', { name: /open dialog/i }));
45+
await waitForAngular();
46+
await expect(screen.getByRole('dialog')).toBeVisible();
47+
await expect(trigger).toHaveAttribute('aria-expanded', 'false');
48+
});
49+
50+
await step('Closes the dialog with Escape', async () => {
51+
await userEvent.keyboard('{Escape}');
52+
await waitForAngular();
53+
await expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
54+
});
55+
});

0 commit comments

Comments
 (0)