Skip to content

Commit

Permalink
feat: run core outside of Angular zone (#188)
Browse files Browse the repository at this point in the history
* feat: run core outside of Angular zone

* fixup: add a test with both signal change and event
  • Loading branch information
divdavem authored Oct 24, 2023
1 parent 07d66a9 commit 24d49aa
Show file tree
Hide file tree
Showing 17 changed files with 419 additions and 45 deletions.
9 changes: 4 additions & 5 deletions angular/demo/src/app/utils.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
import type {WidgetsConfig} from '@agnos-ui/angular';
import {provideWidgetsConfig, toAngularSignal} from '@agnos-ui/angular';
import {getPropValues} from '@agnos-ui/common/propsValues';
import type {ReadableSignal} from '@amadeus-it-group/tansu';
import {computed, get} from '@amadeus-it-group/tansu';
import type {WidgetsConfig} from '@agnos-ui/angular';
import {provideWidgetsConfig} from '@agnos-ui/angular';
import type {Provider} from '@angular/core';
import {InjectionToken, effect, inject} from '@angular/core';
import {toSignal} from '@angular/core/rxjs-interop';
import {ActivatedRoute} from '@angular/router';
import {getPropValues} from '@agnos-ui/common/propsValues';

function getJsonHash(json: string) {
const {config = {}, props = {}} = JSON.parse(json ?? '{}');
Expand Down Expand Up @@ -35,7 +34,7 @@ export function provideHashConfig(widgetName: keyof WidgetsConfig): Provider[] {
}

export function hashChangeHook(propsCallback: (props: any) => void) {
const hashConfig$ = toSignal(inject(hashConfigToken), {requireSync: true});
const hashConfig$ = toAngularSignal(inject(hashConfigToken));
let lastProps;

async function callPropsCallback(props: any) {
Expand Down
3 changes: 1 addition & 2 deletions angular/demo/tsconfig.spec.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,7 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"outDir": "../out-tsc/spec",
"types": ["jasmine"]
"outDir": "../out-tsc/spec"
},
"files": ["src/test.ts", "src/polyfills.ts"],
"include": ["src/**/*.spec.ts", "src/**/*.d.ts"]
Expand Down
3 changes: 2 additions & 1 deletion angular/headless/.eslintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@
"style": "camelCase"
}
],
"@angular-eslint/no-input-rename": "off"
"@angular-eslint/no-input-rename": "off",
"@angular-eslint/no-output-rename": "off"
}
},
{
Expand Down
4 changes: 2 additions & 2 deletions angular/headless/src/lib/slot.directive.spec.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import {writable} from '@amadeus-it-group/tansu';
import type {TemplateRef} from '@angular/core';
import {ChangeDetectionStrategy, Component, Injectable, Input, ViewChild, inject} from '@angular/core';
import {toSignal} from '@angular/core/rxjs-interop';
import {TestBed} from '@angular/core/testing';
import {describe, expect, it} from 'vitest';
import {injectWidgetsConfig, provideWidgetsConfig} from './config';
import {SlotDirective} from './slot.directive';
import type {SlotContent} from './slotTypes';
import {ComponentTemplate} from './slotTypes';
import {toAngularSignal} from './utils';

describe('slot directive', () => {
@Component({
Expand Down Expand Up @@ -192,7 +192,7 @@ describe('widgets config', () => {
],
})
class MyTestComponent {
myConfig = toSignal(injectWidgetsConfig(), {requireSync: true});
myConfig = toAngularSignal(injectWidgetsConfig());
}

const component = TestBed.createComponent(MyTestComponent);
Expand Down
292 changes: 292 additions & 0 deletions angular/headless/src/lib/utils.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,292 @@
import type {Directive, Widget, WidgetFactory} from '@agnos-ui/core';
import {stateStores, typeFunction, typeString, writablesForProps} from '@agnos-ui/core';
import {computed, readable, writable} from '@amadeus-it-group/tansu';
import type {OnChanges, SimpleChanges} from '@angular/core';
import {ChangeDetectionStrategy, Component, EventEmitter, Input, NgZone, Output, effect} from '@angular/core';
import {TestBed} from '@angular/core/testing';
import {beforeEach, describe, expect, it} from 'vitest';
import {UseDirective} from './use.directive';
import {callWidgetFactoryWithConfig, patchSimpleChanges, toAngularSignal} from './utils';

describe('utils', () => {
let log: string[] = [];
beforeEach(() => {
log = [];
});

const createZoneCheckFn =
<T extends any[], R>(name: string, fn: (...args: T) => R) =>
(...args: T): R => {
log.push(`begin ${name}, ngZone = ${NgZone.isInAngularZone()}`);
try {
return fn(...args);
} finally {
log.push(`end ${name}, ngZone = ${NgZone.isInAngularZone()}`);
}
};

describe('toAngularSignal', () => {
it('works synchronously', () => {
const tansuStore = writable(1);
const signal = TestBed.runInInjectionContext(() => toAngularSignal(tansuStore));
expect(signal()).toBe(1);
tansuStore.set(2);
expect(signal()).toBe(2);
TestBed.resetTestEnvironment(); // this ends the subscription
tansuStore.set(3);
expect(signal()).toBe(2); // no change as the subscription was ended
});

it('subscribes and unsubscribes outside Angular zone', async () => {
const ngZone = TestBed.inject(NgZone);
const tansuStore = readable(0 as number, {
onUse: createZoneCheckFn('onUse', (set) => {
set(1);
return createZoneCheckFn('destroy', () => {});
}),
});
ngZone.run(
createZoneCheckFn('ngZone.run', () => {
const signal = TestBed.runInInjectionContext(() => toAngularSignal(tansuStore));
expect(signal()).toBe(1);
TestBed.resetTestingModule();
})
);
expect(log).toStrictEqual([
'begin ngZone.run, ngZone = true',
'begin onUse, ngZone = false',
'end onUse, ngZone = false',
'begin destroy, ngZone = false',
'end destroy, ngZone = false',
'end ngZone.run, ngZone = true',
]);
});

@Component({
standalone: true,
template: `{{ mySignal() }}`,
})
class MyTestComponent {
myStore = writable(1);
mySignal = toAngularSignal(this.myStore);
changes = (() => {
const res: number[] = [];
effect(() => {
res.push(this.mySignal());
});
return res;
})();
}

it('works in a template (inside Angular zone)', () => {
const fixture = TestBed.createComponent(MyTestComponent);
fixture.autoDetectChanges();
expect(fixture.nativeElement.textContent).toBe('1');
expect(fixture.componentInstance.changes).toStrictEqual([1]);
const zone = TestBed.inject(NgZone);
zone.run(() => {
expect(NgZone.isInAngularZone()).toBeTruthy();
fixture.componentInstance.myStore.set(2);
fixture.componentInstance.myStore.set(3);
});
expect(fixture.nativeElement.textContent).toBe('3');
expect(fixture.componentInstance.changes).toStrictEqual([1, 3]);
fixture.destroy();
});

it('works in a template (outside Angular zone)', async () => {
const fixture = TestBed.createComponent(MyTestComponent);
fixture.autoDetectChanges();
expect(fixture.nativeElement.textContent).toBe('1');
expect(fixture.componentInstance.changes).toStrictEqual([1]);
expect(NgZone.isInAngularZone()).toBeFalsy();
fixture.componentInstance.myStore.set(2);
fixture.componentInstance.myStore.set(3);
await 0;
expect(fixture.nativeElement.textContent).toBe('3');
expect(fixture.componentInstance.changes).toStrictEqual([1, 3]);
fixture.destroy();
});
});

describe('callWidgetFactoryWithConfig', () => {
it('calls the core outside angular zone and events in angular zone', async () => {
const noop = () => {};
type MyWidget = Widget<
{onMyAction: () => void; onCounterChange: (value: number) => void; myValue: string},
{derivedValue: string; counter: number},
{myApiFn: () => void; incrementCounter: () => void},
{myAction: () => void},
{myDirective: Directive}
>;

const factory: WidgetFactory<MyWidget> = createZoneCheckFn('factory', (propsConfig) => {
const [{onMyAction$, onCounterChange$, myValue$}, patch] = writablesForProps(
{
onMyAction: noop,
onCounterChange: noop,
myValue: 'defValue',
},
propsConfig,
{
onMyAction: typeFunction,
myValue: typeString,
}
);
const derivedValue$ = computed(createZoneCheckFn('computeDerivedValue', () => `derived from ${myValue$()}`));
const counter$ = writable(0);
return {
...stateStores({
derivedValue$,
counter$,
}),
api: {
myApiFn: createZoneCheckFn('myApiFn', () => {}),
incrementCounter: createZoneCheckFn('incrementCounter', () => {
const value = counter$() + 1;
counter$.set(value);
onCounterChange$()(value);
}),
},
actions: {
myAction: createZoneCheckFn('myAction', () => {
onMyAction$()();
}),
},
directives: {
myDirective: createZoneCheckFn('myDirective', (arg) => ({
update: createZoneCheckFn('myDirectiveUpdate', noop),
destroy: createZoneCheckFn('myDirectiveDestroy', noop),
})),
},
patch: createZoneCheckFn('patch', patch),
};
});

@Component({
standalone: true,
imports: [UseDirective],
template: `<button type="button" [auUse]="_widget.directives.myDirective" (click)="onClick()">
{{ state$().derivedValue }} {{ state$().counter }}
</button>`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
class MyWidgetComponent implements OnChanges {
@Output('auMyAction') myAction = new EventEmitter<void>();
@Output('auCounterChange') counterChange = new EventEmitter<number>();
@Input('auMyValue') myValue: string | undefined;

_widget = createZoneCheckFn(
'callWidgetFactoryWithConfig',
callWidgetFactoryWithConfig
)({
factory,
events: {
onCounterChange: (event) => this.counterChange.emit(event),
onMyAction: () => this.myAction.emit(),
},
});
api = this._widget.api;
state$ = toAngularSignal(this._widget.state$);

ngOnChanges(changes: SimpleChanges): void {
patchSimpleChanges(this._widget.patch, changes);
}

onClick = createZoneCheckFn('onClick', this._widget.actions.myAction);
}

const ngZone = TestBed.inject(NgZone);
ngZone.onUnstable.subscribe(() => {
log.push('enter ngZone');
});
ngZone.onStable.subscribe(() => {
log.push('leave ngZone');
});
const fixture = TestBed.createComponent(MyWidgetComponent);
log.push('before autoDetectChanges');
fixture.componentInstance.myAction.subscribe(createZoneCheckFn('myActionListener', noop));
fixture.componentInstance.counterChange.subscribe(createZoneCheckFn('counterChangeListener', noop));
fixture.autoDetectChanges(true);
log.push('after autoDetectChanges');
expect(fixture.nativeElement.innerText.trim()).toBe('derived from defValue 0');
log.push('before first await 0');
await 0;
log.push('after first await 0');
ngZone.run(
createZoneCheckFn('ngZone.run', () => {
fixture.componentRef.setInput('auMyValue', 'newValue');
fixture.componentInstance.api.myApiFn();
})
);
log.push('after ngZone.run');
expect(fixture.nativeElement.innerText.trim()).toBe('derived from newValue 0');
log.push('before click');
fixture.nativeElement.querySelector('button').click();
log.push('after click');
log.push('before incrementCounter');
fixture.componentInstance.api.incrementCounter();
log.push('after incrementCounter');
expect(fixture.nativeElement.innerText.trim()).toBe('derived from newValue 1');
log.push('before destroy');
fixture.destroy();
log.push('after destroy');
log.push('before last await 0');
await 0;
log.push('after last await 0');
expect(log).toStrictEqual([
'enter ngZone',
'begin callWidgetFactoryWithConfig, ngZone = true',
'begin factory, ngZone = false',
'end factory, ngZone = false',
'end callWidgetFactoryWithConfig, ngZone = true',
'begin computeDerivedValue, ngZone = false',
'end computeDerivedValue, ngZone = false',
'leave ngZone',
'before autoDetectChanges',
'enter ngZone',
'leave ngZone',
'after autoDetectChanges',
'before first await 0',
'begin myDirective, ngZone = false',
'end myDirective, ngZone = false',
'after first await 0',
'enter ngZone',
'begin ngZone.run, ngZone = true',
'begin myApiFn, ngZone = false',
'end myApiFn, ngZone = false',
'end ngZone.run, ngZone = true',
'begin patch, ngZone = false',
'begin computeDerivedValue, ngZone = false',
'end computeDerivedValue, ngZone = false',
'end patch, ngZone = false',
'leave ngZone',
'after ngZone.run',
'before click',
'enter ngZone',
'begin onClick, ngZone = true',
'begin myAction, ngZone = false',
'begin myActionListener, ngZone = true',
'end myActionListener, ngZone = true',
'end myAction, ngZone = false',
'end onClick, ngZone = true',
'leave ngZone',
'after click',
'before incrementCounter',
'begin incrementCounter, ngZone = false',
'enter ngZone',
'begin counterChangeListener, ngZone = true',
'end counterChangeListener, ngZone = true',
'leave ngZone',
'end incrementCounter, ngZone = false',
'after incrementCounter',
'before destroy',
'after destroy',
'before last await 0',
'begin myDirectiveDestroy, ngZone = false',
'end myDirectiveDestroy, ngZone = false',
'after last await 0',
]);
});
});
});
Loading

0 comments on commit 24d49aa

Please sign in to comment.