Skip to content

Commit a0a2767

Browse files
authored
fix(): custom component rendering (#145)
1 parent 9ab1cfc commit a0a2767

2 files changed

Lines changed: 86 additions & 3 deletions

File tree

projects/schedule-x/angular/src/lib/calendar.component.spec.ts

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,19 @@
1-
import { ComponentFixture, TestBed } from '@angular/core/testing';
1+
import { Component } from '@angular/core';
2+
import {
3+
ComponentFixture,
4+
fakeAsync,
5+
flushMicrotasks,
6+
TestBed,
7+
} from '@angular/core/testing';
8+
import type { CalendarApp } from '@schedule-x/calendar';
29

310
import { CalendarComponent } from './calendar.component';
411

12+
type CustomComponentFn = (
13+
wrapperElement: HTMLElement,
14+
props: Record<string, unknown>,
15+
) => void;
16+
517
describe('AngularComponent', () => {
618
let component: CalendarComponent;
719
let fixture: ComponentFixture<CalendarComponent>;
@@ -21,3 +33,49 @@ describe('AngularComponent', () => {
2133
expect(component).toBeTruthy();
2234
});
2335
});
36+
37+
@Component({
38+
imports: [CalendarComponent],
39+
template: `
40+
<sx-calendar [calendarApp]="calendarApp">
41+
<ng-template #headerContent let-arg>
42+
<span>{{ arg.$app.label }}</span>
43+
</ng-template>
44+
</sx-calendar>
45+
`,
46+
})
47+
class HostComponent {
48+
private customComponentFns: Record<string, CustomComponentFn> = {};
49+
50+
calendarApp = {
51+
_setCustomComponentFn: (fnId: string, fn: CustomComponentFn) => {
52+
this.customComponentFns[fnId] = fn;
53+
},
54+
render: (calendarElement: HTMLElement) => {
55+
const wrapperElement = document.createElement('div');
56+
wrapperElement.dataset['ccid'] = 'header-content';
57+
calendarElement.appendChild(wrapperElement);
58+
59+
this.customComponentFns['headerContent'](wrapperElement, {
60+
$app: { label: 'Custom header' },
61+
});
62+
},
63+
} as unknown as CalendarApp;
64+
}
65+
66+
describe('AngularComponent with custom components', () => {
67+
beforeEach(async () => {
68+
await TestBed.configureTestingModule({
69+
imports: [HostComponent],
70+
}).compileComponents();
71+
});
72+
73+
it('renders custom components without expression changed errors', fakeAsync(() => {
74+
const fixture = TestBed.createComponent(HostComponent);
75+
76+
expect(() => fixture.detectChanges()).not.toThrow();
77+
expect(() => flushMicrotasks()).not.toThrow();
78+
79+
expect(fixture.nativeElement.textContent).toContain('Custom header');
80+
}));
81+
});

projects/schedule-x/angular/src/lib/calendar.component.ts

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import {
22
AfterViewInit,
3+
ChangeDetectorRef,
34
Component,
45
ContentChild,
56
Input,
7+
OnDestroy,
68
TemplateRef,
79
} from '@angular/core';
810
import { CalendarApp } from '@schedule-x/calendar';
@@ -34,7 +36,7 @@ export const randomStringId = () =>
3436
`,
3537
styles: ``,
3638
})
37-
export class CalendarComponent implements AfterViewInit {
39+
export class CalendarComponent implements AfterViewInit, OnDestroy {
3840
@Input() calendarApp: CalendarApp;
3941

4042
@ContentChild('timeGridEvent') timeGridEvent: TemplateRef<any>;
@@ -55,6 +57,9 @@ export class CalendarComponent implements AfterViewInit {
5557
customComponentsMeta: CustomComponentsMeta = [];
5658

5759
public calendarElementId = randomStringId();
60+
private isDestroyed = false;
61+
62+
constructor(private changeDetectorRef: ChangeDetectorRef) {}
5863

5964
getTemplate(componentName: string): TemplateRef<any> {
6065
if (componentName === 'timeGridEvent') return this.timeGridEvent;
@@ -84,7 +89,7 @@ export class CalendarComponent implements AfterViewInit {
8489
throw new Error(`No template found for component name: ${componentName}`);
8590
}
8691

87-
ngAfterViewInit() {
92+
async ngAfterViewInit() {
8893
if (typeof window !== 'object') return;
8994

9095
const calendarElement = document?.getElementById(this.calendarElementId);
@@ -96,10 +101,23 @@ export class CalendarComponent implements AfterViewInit {
96101
return;
97102
}
98103

104+
// Schedule-X renders synchronously and can request custom component portals
105+
// during that render. If this happens inside Angular's current
106+
// AfterViewInit check, dev mode sees customComponentsMeta change from
107+
// [] to a wrapper element and throws ExpressionChangedAfterItHasBeenCheckedError.
108+
// Deferring one microtask starts the external render after this check settles.
109+
await Promise.resolve();
110+
111+
if (this.isDestroyed) return;
112+
99113
this.setCustomComponentFns();
100114
this.calendarApp?.render(calendarElement);
101115
}
102116

117+
ngOnDestroy() {
118+
this.isDestroyed = true;
119+
}
120+
103121
private setCustomComponentFns() {
104122
if (this.timeGridEvent) {
105123
this.calendarApp._setCustomComponentFn(
@@ -213,6 +231,8 @@ export class CalendarComponent implements AfterViewInit {
213231
}
214232

215233
setCustomComponentMeta = (component: CustomComponentMeta) => {
234+
if (this.isDestroyed) return;
235+
216236
const wrapperWasDetached = !(
217237
component.wrapperElement instanceof HTMLElement
218238
);
@@ -243,5 +263,10 @@ export class CalendarComponent implements AfterViewInit {
243263
}
244264

245265
this.customComponentsMeta = [...newCustomComponents, component];
266+
267+
// Custom component callbacks originate from Schedule-X/Preact, outside of
268+
// Angular's normal template event flow. Run a local detection pass so the
269+
// portal created above is reconciled immediately.
270+
this.changeDetectorRef.detectChanges();
246271
};
247272
}

0 commit comments

Comments
 (0)