Skip to content

Commit

Permalink
feat: add opt-out for automatic re-rendering
Browse files Browse the repository at this point in the history
Signed-off-by: Lukas Reining <lukas.reining@codecentric.de>
  • Loading branch information
lukas-reining committed Sep 12, 2024
1 parent b866d3e commit 8e523a1
Show file tree
Hide file tree
Showing 3 changed files with 196 additions and 17 deletions.
36 changes: 28 additions & 8 deletions packages/angular/projects/angular-sdk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,6 @@ If `initializing` and `reconciling` are not given, the feature flag value that i
determine what will be rendered.

```html

<div *booleanFeatureFlag="'isFeatureEnabled'; default: true">
This is shown when the feature flag is enabled.
</div>
Expand All @@ -152,6 +151,9 @@ This parameter is optional, if omitted, the `thenTemplate` will always be render

The `domain` parameter is _optional_ and will be used as domain when getting the OpenFeature provider.

The `updateOnConfigurationChanged` and `updateOnContextChanged` parameter are _optional_ and used to disable the
automatic re-rendering on flag value or context change. They are set to `true` by default.

The template referenced in `else` will be rendered if the evaluated feature flag is `false` for the `booleanFeatureFlag`
directive and if the `value` does not match evaluated flag value for all other directives.
This parameter is _optional_.
Expand All @@ -163,7 +165,8 @@ This parameter is _optional_, if omitted, the `then` and `else` templates will b
##### Boolean Feature Flag

```html
<div *booleanFeatureFlag="'isFeatureEnabled'; default: true; domain: 'userDomain'; else: booleanFeatureElse; initializing: booleanFeatureInitializing; reconciling: booleanFeatureReconciling">
<div
*booleanFeatureFlag="'isFeatureEnabled'; default: true; domain: 'userDomain'; else: booleanFeatureElse; initializing: booleanFeatureInitializing; reconciling: booleanFeatureReconciling">
This is shown when the feature flag is enabled.
</div>
<ng-template #booleanFeatureElse>
Expand All @@ -180,7 +183,8 @@ This parameter is _optional_, if omitted, the `then` and `else` templates will b
##### Number Feature Flag

```html
<div *numberFeatureFlag="'discountRate'; value: 10; default: 5; domain: 'userDomain'; else: numberFeatureElse; initializing: numberFeatureInitializing; reconciling: numberFeatureReconciling">
<div
*numberFeatureFlag="'discountRate'; value: 10; default: 5; domain: 'userDomain'; else: numberFeatureElse; initializing: numberFeatureInitializing; reconciling: numberFeatureReconciling">
This is shown when the feature flag matches the specified discount rate.
</div>
<ng-template #numberFeatureElse>
Expand All @@ -197,7 +201,8 @@ This parameter is _optional_, if omitted, the `then` and `else` templates will b
##### String Feature Flag

```html
<div *stringFeatureFlag="'themeColor'; value: 'dark'; default: 'light'; domain: 'userDomain'; else: stringFeatureElse; initializing: stringFeatureInitializing; reconciling: stringFeatureReconciling">
<div
*stringFeatureFlag="'themeColor'; value: 'dark'; default: 'light'; domain: 'userDomain'; else: stringFeatureElse; initializing: stringFeatureInitializing; reconciling: stringFeatureReconciling">
This is shown when the feature flag matches the specified theme color.
</div>
<ng-template #stringFeatureElse>
Expand All @@ -214,7 +219,8 @@ This parameter is _optional_, if omitted, the `then` and `else` templates will b
##### Object Feature Flag

```html
<div *objectFeatureFlag="'userConfig'; value: { theme: 'dark' }; default: { theme: 'light' }; domain: 'userDomain'; else: objectFeatureElse; initializing: objectFeatureInitializing; reconciling: objectFeatureReconciling">
<div
*objectFeatureFlag="'userConfig'; value: { theme: 'dark' }; default: { theme: 'light' }; domain: 'userDomain'; else: objectFeatureElse; initializing: objectFeatureInitializing; reconciling: objectFeatureReconciling">
This is shown when the feature flag matches the specified user configuration.
</div>
<ng-template #objectFeatureElse>
Expand All @@ -228,21 +234,35 @@ This parameter is _optional_, if omitted, the `then` and `else` templates will b
</ng-template>
```

##### Opting-out of automatic re-rendering

By default, the directive re-renders when the flag value changes or the context changes.

In cases, this is not desired, re-rendering can be disabled for both events:

```html
<div *booleanFeatureFlag="'isFeatureEnabled'; default: true; updateOnContextChanged: false; updateOnConfigurationChanged: false;">
This is shown when the feature flag is enabled.
</div>
```

##### Consuming the evaluation details

The `evaluation details` can be used when rendering the templates.
The directives [`$implicit`](https://angular.dev/guide/directives/structural-directives#structural-directive-shorthand) value will be bound to the flag value and additionally the value `evaluationDetails` will be
The directives [`$implicit`](https://angular.dev/guide/directives/structural-directives#structural-directive-shorthand)
value will be bound to the flag value and additionally the value `evaluationDetails` will be
bound to the whole evaluation details.
They can be referenced in all templates.

The following example shows `value` being implicitly bound and `details` being bound to the evaluation details.

```html
<div *stringFeatureFlag="'themeColor'; value: 'dark'; default: 'light'; else: stringFeatureElse; let value; let details = evaluationDetails">
<div
*stringFeatureFlag="'themeColor'; value: 'dark'; default: 'light'; else: stringFeatureElse; let value; let details = evaluationDetails">
It was a match!
The theme color is {{ value }} because of {{ details.reason }}
</div>
<ng-template #stringFeatureElse let-value let-details="evaluationDetails">
<ng-template #stringFeatureElse let-value let-details='evaluationDetails'>
It was no match!
The theme color is {{ value }} because of {{ details.reason }}
</ng-template>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,23 @@ import {
{{ value }}
</div>
</div>
<div class="case-12">
<div
*booleanFeatureFlag="
'test-flag';
default: true;
else: elseTemplate;
domain: domain;
updateOnConfigurationChanged: false
"
class="flag-status"
>
Flag On
</div>
<ng-template #elseTemplate>
<div class="flag-status">Flag Off</div>
</ng-template>
</div>
</ng-container>
`,
})
Expand Down Expand Up @@ -380,6 +397,28 @@ describe('FeatureFlagDirective', () => {
await expectRenderedText(fixture, 'case-6', 'Flag Off');
});

it('should opt-out of re-rendering when flag value changes', async () => {
const { fixture, client, provider } = await createTestingModule({
flagConfiguration: {
'test-flag': {
variants: { default: true },
defaultVariant: 'default',
disabled: false,
},
'new-test-flag': {
variants: { default: false },
defaultVariant: 'default',
disabled: false,
},
},
});
await waitForClientReady(client);
await expectRenderedText(fixture, 'case-12', 'Flag On');

await updateFlagValue(provider, false);
await expectRenderedText(fixture, 'case-12', 'Flag On');
});

it('should evaluate on flag domain change', async () => {
const { fixture, client } = await createTestingModule({
flagConfiguration: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
ClientProviderEvents,
ClientProviderStatus,
EvaluationDetails,
EventDetails,

Check failure on line 16 in packages/angular/projects/angular-sdk/src/lib/feature-flag.directive.ts

View workflow job for this annotation

GitHub Actions / build-test-lint (18.x)

'EventDetails' is defined but never used

Check failure on line 16 in packages/angular/projects/angular-sdk/src/lib/feature-flag.directive.ts

View workflow job for this annotation

GitHub Actions / build-test-lint (20.x)

'EventDetails' is defined but never used
EventHandler,
FlagValue,
JsonValue,
Expand Down Expand Up @@ -42,7 +43,14 @@ export abstract class FeatureFlagDirective<T extends FlagValue> implements OnDes

protected _client: Client;
protected _lastEvaluationResult: EvaluationDetails<T>;
protected _flagChangeHandler: EventHandler<ClientProviderEvents> | null = null;

protected _readyHandler: EventHandler<ClientProviderEvents.Ready> | null = null;
protected _flagChangeHandler: EventHandler<ClientProviderEvents.ConfigurationChanged> | null = null;
protected _contextChangeHandler: EventHandler<ClientProviderEvents.Error> | null = null;
protected _reconcilingHandler: EventHandler<ClientProviderEvents.Reconciling> | null = null;

protected _updateOnContextChanged: boolean = true;
protected _updateOnConfigurationChanged: boolean = true;

protected _thenTemplateRef: TemplateRef<FeatureFlagDirectiveContext<T>> | null;
protected _thenViewRef: EmbeddedViewRef<unknown> | null;
Expand Down Expand Up @@ -85,25 +93,49 @@ export abstract class FeatureFlagDirective<T extends FlagValue> implements OnDes
if (this._client) {
this.disposeClient(this._client);
}

this._client = OpenFeature.getClient(this._featureFlagDomain);
this._flagChangeHandler = () => {

const baseHandler = () => {
const result = this.getFlagDetails(this._featureFlagKey, this._featureFlagDefault);
this.onFlagValue(result, this._client.providerStatus);
};

this._client.addHandler(ClientProviderEvents.ContextChanged, this._flagChangeHandler);
this._flagChangeHandler = () => {
if (this._updateOnConfigurationChanged) {
baseHandler();
}
};

this._contextChangeHandler = () => {
if (this._updateOnContextChanged) {
baseHandler();
}
};

this._readyHandler = () => baseHandler();
this._reconcilingHandler = () => baseHandler();

this._client.addHandler(ClientProviderEvents.ConfigurationChanged, this._flagChangeHandler);
this._client.addHandler(ClientProviderEvents.Ready, this._flagChangeHandler);
this._client.addHandler(ClientProviderEvents.Reconciling, this._flagChangeHandler);
this._client.addHandler(ClientProviderEvents.ContextChanged, this._contextChangeHandler);
this._client.addHandler(ClientProviderEvents.Ready, this._readyHandler);
this._client.addHandler(ClientProviderEvents.Reconciling, this._reconcilingHandler);
}

private disposeClient(client: Client) {
if (this._contextChangeHandler()) {
client.removeHandler(ClientProviderEvents.ContextChanged, this._contextChangeHandler);
}

if (this._flagChangeHandler) {
client.removeHandler(ClientProviderEvents.ContextChanged, this._flagChangeHandler);
client.removeHandler(ClientProviderEvents.ConfigurationChanged, this._flagChangeHandler);
client.removeHandler(ClientProviderEvents.Ready, this._flagChangeHandler);
client.removeHandler(ClientProviderEvents.Reconciling, this._flagChangeHandler);
}

if (this._readyHandler) {
client.removeHandler(ClientProviderEvents.Ready, this._readyHandler);
}

if (this._reconcilingHandler) {
client.removeHandler(ClientProviderEvents.Reconciling, this._reconcilingHandler);
}
}

Expand Down Expand Up @@ -226,6 +258,28 @@ export class BooleanFeatureFlagDirective extends FeatureFlagDirective<boolean> i
super.featureFlagDomain = domain;
}

/**
* Update the component if the provider emits a ConfigurationChanged event.
* Set to false to prevent components from re-rendering when flag value changes
* are received by the associated provider.
* Defaults to true.
*/
@Input({ required: false })
set booleanFeatureFlagUpdateOnConfigurationChanged(enabled: boolean | undefined) {
this._updateOnConfigurationChanged = enabled ?? true;
}

/**
* Update the component when the OpenFeature context changes.
* Set to false to prevent components from re-rendering when attributes which
* may be factors in flag evaluation change.
* Defaults to true.
*/
@Input({ required: false })
set booleanFeatureFlagUpdateOnContextChanged(enabled: boolean | undefined) {
this._updateOnContextChanged = enabled ?? true;
}

/**
* Template to be displayed when the feature flag is false.
*/
Expand Down Expand Up @@ -324,6 +378,28 @@ export class NumberFeatureFlagDirective extends FeatureFlagDirective<number> imp
super.featureFlagDomain = domain;
}

/**
* Update the component if the provider emits a ConfigurationChanged event.
* Set to false to prevent components from re-rendering when flag value changes
* are received by the associated provider.
* Defaults to true.
*/
@Input({ required: false })
set numberFeatureFlagUpdateOnConfigurationChanged(enabled: boolean | undefined) {
this._updateOnConfigurationChanged = enabled ?? true;
}

/**
* Update the component when the OpenFeature context changes.
* Set to false to prevent components from re-rendering when attributes which
* may be factors in flag evaluation change.
* Defaults to true.
*/
@Input({ required: false })
set numberFeatureFlagUpdateOnContextChanged(enabled: boolean | undefined) {
this._updateOnContextChanged = enabled ?? true;
}

/**
* Template to be displayed when the feature flag does not match value.
*/
Expand Down Expand Up @@ -422,6 +498,28 @@ export class StringFeatureFlagDirective extends FeatureFlagDirective<string> imp
super.featureFlagDomain = domain;
}

/**
* Update the component if the provider emits a ConfigurationChanged event.
* Set to false to prevent components from re-rendering when flag value changes
* are received by the associated provider.
* Defaults to true.
*/
@Input({ required: false })
set stringFeatureFlagUpdateOnConfigurationChanged(enabled: boolean | undefined) {
this._updateOnConfigurationChanged = enabled ?? true;
}

/**
* Update the component when the OpenFeature context changes.
* Set to false to prevent components from re-rendering when attributes which
* may be factors in flag evaluation change.
* Defaults to true.
*/
@Input({ required: false })
set stringFeatureFlagUpdateOnContextChanged(enabled: boolean | undefined) {
this._updateOnContextChanged = enabled ?? true;
}

/**
* Template to be displayed when the feature flag does not match value.
*/
Expand Down Expand Up @@ -520,6 +618,28 @@ export class ObjectFeatureFlagDirective<T extends JsonValue> extends FeatureFlag
super.featureFlagDomain = domain;
}

/**
* Update the component if the provider emits a ConfigurationChanged event.
* Set to false to prevent components from re-rendering when flag value changes
* are received by the associated provider.
* Defaults to true.
*/
@Input({ required: false })
set objectFeatureFlagUpdateOnConfigurationChanged(enabled: boolean | undefined) {
this._updateOnConfigurationChanged = enabled ?? true;
}

/**
* Update the component when the OpenFeature context changes.
* Set to false to prevent components from re-rendering when attributes which
* may be factors in flag evaluation change.
* Defaults to true.
*/
@Input({ required: false })
set objectFeatureFlagUpdateOnContextChanged(enabled: boolean | undefined) {
this._updateOnContextChanged = enabled ?? true;
}

/**
* Template to be displayed when the feature flag does not match value.
*/
Expand Down

0 comments on commit 8e523a1

Please sign in to comment.