Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Template of an OnPush component is no longer updated after a signal change in some cases #50320

Closed
divdavem opened this issue May 16, 2023 · 11 comments
Assignees
Labels
area: core Issues related to the framework runtime bug core: reactivity Work related to fine-grained reactivity in the core framework cross-cutting: signals P3 An issue that is relevant to core functions, but does not impede progress. Important, but not urgent
Milestone

Comments

@divdavem
Copy link
Contributor

Which @angular/* package(s) are the source of the bug?

core

Is this a regression?

No

Description

Go to this stackblitz
Click on the Toggle state button.
item is visible appears
Click again on the Toggle state button.
item is visible disappears
Click again on the Toggle state button.
Nothing happens! I would expect the item is visible element to appear again.

Here is the code of the stackblitz:

import 'zone.js/dist/zone';
import {
  ChangeDetectionStrategy,
  Component,
  Directive,
  effect,
  signal,
  Input,
  OnChanges,
  OnDestroy,
  SimpleChanges,
  computed,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { bootstrapApplication } from '@angular/platform-browser';

interface State {
  isInDom: boolean;
  shouldBeInDom: boolean;
}

const createModel = () => {
  const state = signal<State>({
    isInDom: false,
    shouldBeInDom: false,
  });
  const updateState = (changes: Partial<State>) =>
    state.update((value) => ({
      ...value,
      ...changes,
    }));
  const toggle = () => updateState({ shouldBeInDom: !state().shouldBeInDom });
  const setDomState = (isInDom: boolean) => updateState({ isInDom });
  return { state, toggle, setDomState };
};

@Directive({
  standalone: true,
  selector: '[myDirective]',
})
export class MyDirective implements OnChanges, OnDestroy {
  @Input({ required: true })
  myDirective!: ReturnType<typeof createModel>;

  async ngOnChanges(changes: SimpleChanges) {
    // Comment the following line to have the issue on display:
    await 0;
    this.myDirective.setDomState(true);
  }

  async ngOnDestroy() {
    // Uncomment the following line as a work-around to fix the issue:
    // await 0;
    this.myDirective.setDomState(false);
  }
}

@Component({
  selector: 'my-component',
  standalone: true,
  // Comment the following line as a work-around to fix the issue:
  changeDetection: ChangeDetectionStrategy.OnPush,
  imports: [CommonModule, MyDirective],
  template: `
    <div *ngIf="state().shouldBeInDom" [myDirective]="model">item is visible</div>
  `,
})
export class MyComponent {
  model = createModel();
  // Uncomment the following line as a work-around to fix the issue:
  // state = this.model.state;
  state = computed(() => this.model.state());

  constructor() {
    // Uncomment the following line as a work-around to fix the issue:
    // effect(() => console.log(this.state()));
  }
}

@Component({
  selector: 'my-app',
  standalone: true,
  imports: [CommonModule, MyComponent],
  template: `
    <button (click)="c.model.toggle()">Toggle state</button>
    <my-component #c></my-component>
  `,
})
export class App {}

bootstrapApplication(App);

Please provide a link to a minimal reproduction of the bug

https://stackblitz.com/edit/angular-nltu6v?file=src/main.ts

Please provide the exception or error you saw

There is no exception, no explanation.

Please provide the environment you discovered this bug in (run ng version)

Angular CLI: 16.0.1
Node: 18.16.0
Package Manager: npm 9.5.1
OS: linux x64

Angular: 16.0.1
... animations, cli, common, compiler, compiler-cli, core, forms
... platform-browser, platform-browser-dynamic, router

Package                         Version
---------------------------------------------------------
@angular-devkit/architect       0.1600.1
@angular-devkit/build-angular   16.0.1
@angular-devkit/core            16.0.1
@angular-devkit/schematics      16.0.1
@schematics/angular             16.0.1
ng-packagr                      16.0.1
rxjs                            7.8.1
typescript                      5.0.4

Anything else?

What triggers the bug seems to be the fact that the signal is synchronously changed either from ngOnChanges or from ngOnDestroy in the directive. If this is done asynchronously, the problem does not happen.

This makes me think about the famous ExpressionChangedAfterItHasBeenCheckedError issue (even if there is no exception displayed in this case), the idea is the same: there is an issue when updating data at some specific times.

As mentioned in the comments of the code, if any of the following modifications is done (alone), the bug does not happen:

  • if state is a direct reference to this.model.state in MyComponent instead of being a computed
  • if an effect that reads this.state() is added in the constructor of MyComponent
  • if MyComponent is not OnPush
@pkozlowski-opensource
Copy link
Member

pkozlowski-opensource commented May 16, 2023

This is an interesting case, thnx for sharing!

What is (roughly, working theory) going on here is that a computed property is marked as "stale" (by the update in ngOnChanges) after the reactive context of the OnPush component accessed this value (and marked it as "clean"). This means that subsequent signal change notification do not reach the computed.

Ultimately the update in ngOnChanges is the "culprit" here but we do need better guidance / guide-rails here.

Trying to narrow down the reproduction to a simpler case.

@pkozlowski-opensource pkozlowski-opensource added area: core Issues related to the framework runtime P3 An issue that is relevant to core functions, but does not impede progress. Important, but not urgent bug core: reactivity Work related to fine-grained reactivity in the core framework labels May 16, 2023
@ngbot ngbot bot modified the milestone: Backlog May 16, 2023
@pkozlowski-opensource
Copy link
Member

Here is a simpler repro: https://stackblitz.com/edit/angular-hu21mk?file=src%2Fmain.ts

This is ExpressionChangedAfterItHasBeenCheckedError (as seen by commenting out OnPush that "masks" this error).

@zip-fa
Copy link

zip-fa commented May 16, 2023

I have the same bug here with ExpressionChanged error when changing signal value in ngOnDestroy. Will give stackblitz soon, maybe it will help

@divdavem
Copy link
Contributor Author

@pkozlowski-opensource Thank you for your answers. I don't see ExpressionChangedAfterItHasBeenCheckedError in your stackblitz.

I find updating signals in ngOnChanges or ngOnDestroy quite useful. I am a bit afraid when you say:

Ultimately the update in ngOnChanges is the "culprit" here but we do need better guidance / guide-rails here.

Do you mean that you are planning to only add a clear error and better documentation to "fix" this bug?

Ideally, it should be possible to update signals at any time (without having to add an asynchronous step), with no error, with the new values correctly taken into account inside templates, and without any resurgence of the old ExpressionChangedAfterItHasBeenCheckedError error (or new variants of it).

@swami-sanapathi
Copy link
Contributor

Yes, I encountered a similar issue and I described it in an RFC as well.

#49684 (comment)

@divdavem
Copy link
Contributor Author

Do you have any news about this bug?
Note that, as shown this stackblitz, since version 16.2.4 of Angular, adding an effect that reads this.state() in the constructor of MyComponent is no longer a workaround for this issue.

@pkozlowski-opensource
Copy link
Member

I'm still trying to wrap my head about the details of what is going on here and my working theory is the same as previously: modifying signals (during CD) that is also marking a view as dirty puts in trouble. Essentially we are marking a signals as dirty and it is trying to mark a view as dirty, but the very same view is being change detected.

Here is a simplified reproduce scenario, still working on making it smaller: https://stackblitz.com/edit/stackblitz-starters-t5x8cv?file=src%2Fmain.ts

@devversion
Copy link
Member

Here is a more up to date reproduction using Angular v17 next and some ReactiveNode debugging starting points: https://stackblitz.com/edit/vdjh5r?file=package.json,angular.json,src%2Findex.html,src%2Fmain.ts

@pkozlowski-opensource
Copy link
Member

Another simplification of the reproduction scenario, this time with the latest Angular version (17.x next) https://stackblitz.com/edit/vdjh5r-pe334b?file=package.json,angular.json,src%2Findex.html,src%2Fmain.ts

@pkozlowski-opensource
Copy link
Member

@divdavem please have a look at the latest, trimmed down reproduction (which I think corresponds to the initial use-case).

What we need to observe there is that the situation involves writing to a signal that is read by a template being change detected. Currently the Angular doesn't have any mechanism for "going back" to the already change detected views and it is logically equivalent to the ExpressionChangedAfterItHasBeenCheckedError.

We are going to make this situation an explicit error in v17. Having said this we more than understand that is is hard for a developer to know what is going on with the change detection - it requires deep awareness of the fwk internals and this is this not great. This is why we are working on the updated change detection algorithm that will handle the situation described here without any errors and proper UI refresh.

alxhub added a commit to alxhub/angular that referenced this issue Oct 16, 2023
Issue angular#50320 shows that in some cases, updating a signal that's a dependency
of a template during change detection of that template can have several
adverse effects. This can happen, for example, if the signal is set during
the lifecycle hook of a directive within the same template that reads the
signal.

This can cause a few things to happen:

* Straightforwardly, it can cause `ExpressionChanged` errors.
* Surprisingly, it can cause an assertion within the `ReactiveLViewConsumer`
  to fail.
* Very surprisingly, it can cause change detection for an `OnPush` component
  to stop working.

The root cause of these later behaviors is subtle, and is ultimately a
desync between the reactive graph and the view tree's notion of "dirty" for
a given view. This will be fixed with further work planned for change
detection to handle such updates directly. Until then, this commit improves
the DX through two changes:

1. The mechanism of "committing" `ReactiveLViewConsumer`s to a view is
   changed to use the `consumerOnSignalRead` hook from the reactive graph.
   This prevents the situation which required the assertion in the first
   place.

2. A `console.warn` warning is added when a view is marked dirty via a
   signal while it's still executing.

The warning informs users that they're pushing data against the direction of
change detection, risking `ExpressionChanged` or other issues. It's a
warning and not an error because the check is overly broad and captures
situations where the application would not actually break as a result, such
as if a `computed` marked the template dirty but still returned the same
value.
alxhub added a commit to alxhub/angular that referenced this issue Oct 26, 2023
Issue angular#50320 shows that in some cases, updating a signal that's a dependency
of a template during change detection of that template can have several
adverse effects. This can happen, for example, if the signal is set during
the lifecycle hook of a directive within the same template that reads the
signal.

This can cause a few things to happen:

* Straightforwardly, it can cause `ExpressionChanged` errors.
* Surprisingly, it can cause an assertion within the `ReactiveLViewConsumer`
  to fail.
* Very surprisingly, it can cause change detection for an `OnPush` component
  to stop working.

The root cause of these later behaviors is subtle, and is ultimately a
desync between the reactive graph and the view tree's notion of "dirty" for
a given view. This will be fixed with further work planned for change
detection to handle such updates directly. Until then, this commit improves
the DX through two changes:

1. The mechanism of "committing" `ReactiveLViewConsumer`s to a view is
   changed to use the `consumerOnSignalRead` hook from the reactive graph.
   This prevents the situation which required the assertion in the first
   place.

2. A `console.warn` warning is added when a view is marked dirty via a
   signal while it's still executing.

The warning informs users that they're pushing data against the direction of
change detection, risking `ExpressionChanged` or other issues. It's a
warning and not an error because the check is overly broad and captures
situations where the application would not actually break as a result, such
as if a `computed` marked the template dirty but still returned the same
value.
atscott pushed a commit to atscott/angular that referenced this issue Oct 26, 2023
Issue angular#50320 shows that in some cases, updating a signal that's a dependency
of a template during change detection of that template can have several
adverse effects. This can happen, for example, if the signal is set during
the lifecycle hook of a directive within the same template that reads the
signal.

This can cause a few things to happen:

* Straightforwardly, it can cause `ExpressionChanged` errors.
* Surprisingly, it can cause an assertion within the `ReactiveLViewConsumer`
  to fail.
* Very surprisingly, it can cause change detection for an `OnPush` component
  to stop working.

The root cause of these later behaviors is subtle, and is ultimately a
desync between the reactive graph and the view tree's notion of "dirty" for
a given view. This will be fixed with further work planned for change
detection to handle such updates directly. Until then, this commit improves
the DX through two changes:

1. The mechanism of "committing" `ReactiveLViewConsumer`s to a view is
   changed to use the `consumerOnSignalRead` hook from the reactive graph.
   This prevents the situation which required the assertion in the first
   place.

2. A `console.warn` warning is added when a view is marked dirty via a
   signal while it's still executing.

The warning informs users that they're pushing data against the direction of
change detection, risking `ExpressionChanged` or other issues. It's a
warning and not an error because the check is overly broad and captures
situations where the application would not actually break as a result, such
as if a `computed` marked the template dirty but still returned the same
value.
alxhub added a commit to alxhub/angular that referenced this issue Oct 27, 2023
Issue angular#50320 shows that in some cases, updating a signal that's a dependency
of a template during change detection of that template can have several
adverse effects. This can happen, for example, if the signal is set during
the lifecycle hook of a directive within the same template that reads the
signal.

This can cause a few things to happen:

* Straightforwardly, it can cause `ExpressionChanged` errors.
* Surprisingly, it can cause an assertion within the `ReactiveLViewConsumer`
  to fail.
* Very surprisingly, it can cause change detection for an `OnPush` component
  to stop working.

The root cause of these later behaviors is subtle, and is ultimately a
desync between the reactive graph and the view tree's notion of "dirty" for
a given view. This will be fixed with further work planned for change
detection to handle such updates directly. Until then, this commit improves
the DX through two changes:

1. The mechanism of "committing" `ReactiveLViewConsumer`s to a view is
   changed to use the `consumerOnSignalRead` hook from the reactive graph.
   This prevents the situation which required the assertion in the first
   place.

2. A `console.warn` warning is added when a view is marked dirty via a
   signal while it's still executing.

The warning informs users that they're pushing data against the direction of
change detection, risking `ExpressionChanged` or other issues. It's a
warning and not an error because the check is overly broad and captures
situations where the application would not actually break as a result, such
as if a `computed` marked the template dirty but still returned the same
value.
alxhub added a commit to alxhub/angular that referenced this issue Oct 27, 2023
Issue angular#50320 shows that in some cases, updating a signal that's a dependency
of a template during change detection of that template can have several
adverse effects. This can happen, for example, if the signal is set during
the lifecycle hook of a directive within the same template that reads the
signal.

This can cause a few things to happen:

* Straightforwardly, it can cause `ExpressionChanged` errors.
* Surprisingly, it can cause an assertion within the `ReactiveLViewConsumer`
  to fail.
* Very surprisingly, it can cause change detection for an `OnPush` component
  to stop working.

The root cause of these later behaviors is subtle, and is ultimately a
desync between the reactive graph and the view tree's notion of "dirty" for
a given view. This will be fixed with further work planned for change
detection to handle such updates directly. Until then, this commit improves
the DX through two changes:

1. The mechanism of "committing" `ReactiveLViewConsumer`s to a view is
   changed to use the `consumerOnSignalRead` hook from the reactive graph.
   This prevents the situation which required the assertion in the first
   place.

2. A `console.warn` warning is added when a view is marked dirty via a
   signal while it's still executing.

The warning informs users that they're pushing data against the direction of
change detection, risking `ExpressionChanged` or other issues. It's a
warning and not an error because the check is overly broad and captures
situations where the application would not actually break as a result, such
as if a `computed` marked the template dirty but still returned the same
value.
alxhub added a commit that referenced this issue Oct 27, 2023
Issue #50320 shows that in some cases, updating a signal that's a dependency
of a template during change detection of that template can have several
adverse effects. This can happen, for example, if the signal is set during
the lifecycle hook of a directive within the same template that reads the
signal.

This can cause a few things to happen:

* Straightforwardly, it can cause `ExpressionChanged` errors.
* Surprisingly, it can cause an assertion within the `ReactiveLViewConsumer`
  to fail.
* Very surprisingly, it can cause change detection for an `OnPush` component
  to stop working.

The root cause of these later behaviors is subtle, and is ultimately a
desync between the reactive graph and the view tree's notion of "dirty" for
a given view. This will be fixed with further work planned for change
detection to handle such updates directly. Until then, this commit improves
the DX through two changes:

1. The mechanism of "committing" `ReactiveLViewConsumer`s to a view is
   changed to use the `consumerOnSignalRead` hook from the reactive graph.
   This prevents the situation which required the assertion in the first
   place.

2. A `console.warn` warning is added when a view is marked dirty via a
   signal while it's still executing.

The warning informs users that they're pushing data against the direction of
change detection, risking `ExpressionChanged` or other issues. It's a
warning and not an error because the check is overly broad and captures
situations where the application would not actually break as a result, such
as if a `computed` marked the template dirty but still returned the same
value.

PR Close #52234
alxhub added a commit to alxhub/angular that referenced this issue Oct 27, 2023
Issue angular#50320 shows that in some cases, updating a signal that's a dependency
of a template during change detection of that template can have several
adverse effects. This can happen, for example, if the signal is set during
the lifecycle hook of a directive within the same template that reads the
signal.

This can cause a few things to happen:

* Straightforwardly, it can cause `ExpressionChanged` errors.
* Surprisingly, it can cause an assertion within the `ReactiveLViewConsumer`
  to fail.
* Very surprisingly, it can cause change detection for an `OnPush` component
  to stop working.

The root cause of these later behaviors is subtle, and is ultimately a
desync between the reactive graph and the view tree's notion of "dirty" for
a given view. This will be fixed with further work planned for change
detection to handle such updates directly. Until then, this commit improves
the DX through two changes:

1. The mechanism of "committing" `ReactiveLViewConsumer`s to a view is
   changed to use the `consumerOnSignalRead` hook from the reactive graph.
   This prevents the situation which required the assertion in the first
   place.

2. A `console.warn` warning is added when a view is marked dirty via a
   signal while it's still executing.

The warning informs users that they're pushing data against the direction of
change detection, risking `ExpressionChanged` or other issues. It's a
warning and not an error because the check is overly broad and captures
situations where the application would not actually break as a result, such
as if a `computed` marked the template dirty but still returned the same
value.
alxhub added a commit that referenced this issue Oct 27, 2023
Issue #50320 shows that in some cases, updating a signal that's a dependency
of a template during change detection of that template can have several
adverse effects. This can happen, for example, if the signal is set during
the lifecycle hook of a directive within the same template that reads the
signal.

This can cause a few things to happen:

* Straightforwardly, it can cause `ExpressionChanged` errors.
* Surprisingly, it can cause an assertion within the `ReactiveLViewConsumer`
  to fail.
* Very surprisingly, it can cause change detection for an `OnPush` component
  to stop working.

The root cause of these later behaviors is subtle, and is ultimately a
desync between the reactive graph and the view tree's notion of "dirty" for
a given view. This will be fixed with further work planned for change
detection to handle such updates directly. Until then, this commit improves
the DX through two changes:

1. The mechanism of "committing" `ReactiveLViewConsumer`s to a view is
   changed to use the `consumerOnSignalRead` hook from the reactive graph.
   This prevents the situation which required the assertion in the first
   place.

2. A `console.warn` warning is added when a view is marked dirty via a
   signal while it's still executing.

The warning informs users that they're pushing data against the direction of
change detection, risking `ExpressionChanged` or other issues. It's a
warning and not an error because the check is overly broad and captures
situations where the application would not actually break as a result, such
as if a `computed` marked the template dirty but still returned the same
value.

PR Close #52427
atscott added a commit to atscott/angular that referenced this issue Oct 31, 2023
…tion

The significance of the combination of angular#51854 and angular#52302 went mostly
unnoticed. The first removed a unidirectional data flow constraint for
transplanted views and the second updated the signal implementation to
share transplanted view logic. The result is that we automatically get behavior
that (mostly) removes `ExpressionChangedAfterItWasCheckedError` when signals are
used to drive application state to DOM synchronization.

fixes angular#50320
atscott added a commit to atscott/angular that referenced this issue Oct 31, 2023
…tion

The significance of the combination of angular#51854 and angular#52302 went mostly
unnoticed. The first removed a unidirectional data flow constraint for
transplanted views and the second updated the signal implementation to
share transplanted view logic. The result is that we automatically get behavior
that (mostly) removes `ExpressionChangedAfterItWasCheckedError` when signals are
used to drive application state to DOM synchronization.

fixes angular#50320
atscott added a commit to atscott/angular that referenced this issue Oct 31, 2023
…tion

The significance of the combination of angular#51854 and angular#52302 went mostly
unnoticed. The first removed a unidirectional data flow constraint for
transplanted views and the second updated the signal implementation to
share transplanted view logic. The result is that we automatically get behavior
that (mostly) removes `ExpressionChangedAfterItWasCheckedError` when signals are
used to drive application state to DOM synchronization.

fixes angular#50320
atscott added a commit to atscott/angular that referenced this issue Oct 31, 2023
…tion

The significance of the combination of angular#51854 and angular#52302 went mostly
unnoticed. The first removed a unidirectional data flow constraint for
transplanted views and the second updated the signal implementation to
share transplanted view logic. The result is that we automatically get behavior
that (mostly) removes `ExpressionChangedAfterItWasCheckedError` when signals are
used to drive application state to DOM synchronization.

fixes angular#50320
atscott added a commit to atscott/angular that referenced this issue Nov 2, 2023
…tion

The significance of the combination of angular#51854 and angular#52302 went mostly
unnoticed. The first removed a unidirectional data flow constraint for
transplanted views and the second updated the signal implementation to
share transplanted view logic. The result is that we automatically get behavior
that (mostly) removes `ExpressionChangedAfterItWasCheckedError` when signals are
used to drive application state to DOM synchronization.

fixes angular#50320
@alxhub alxhub closed this as completed in 58d74a2 Nov 2, 2023
alxhub pushed a commit that referenced this issue Nov 2, 2023
…tion (#52476)

The significance of the combination of #51854 and #52302 went mostly
unnoticed. The first removed a unidirectional data flow constraint for
transplanted views and the second updated the signal implementation to
share transplanted view logic. The result is that we automatically get behavior
that (mostly) removes `ExpressionChangedAfterItWasCheckedError` when signals are
used to drive application state to DOM synchronization.

fixes #50320

PR Close #52476
Abseil-byte pushed a commit to Abseil-byte/angular that referenced this issue Nov 5, 2023
…tion (angular#52476)

The significance of the combination of angular#51854 and angular#52302 went mostly
unnoticed. The first removed a unidirectional data flow constraint for
transplanted views and the second updated the signal implementation to
share transplanted view logic. The result is that we automatically get behavior
that (mostly) removes `ExpressionChangedAfterItWasCheckedError` when signals are
used to drive application state to DOM synchronization.

fixes angular#50320

PR Close angular#52476
divdavem added a commit to divdavem/AgnosUI that referenced this issue Nov 24, 2023
Now that angular/angular#50320 is fixed,
it should be possible to change any angular signal at any time.
divdavem added a commit to divdavem/AgnosUI that referenced this issue Nov 24, 2023
Now that angular/angular#50320 is fixed,
it should be possible to change any angular signal at any time,
and there be no need anymore to delay the call of directives.
divdavem added a commit to divdavem/AgnosUI that referenced this issue Nov 24, 2023
Now that angular/angular#50320 is fixed,
it should be possible to change any angular signal at any time,
and there should be no need anymore to delay the call of directives.
@angular-automatic-lock-bot
Copy link

This issue has been automatically locked due to inactivity.
Please file a new issue if you are encountering a similar or related problem.

Read more about our automatic conversation locking policy.

This action has been performed automatically by a bot.

@angular-automatic-lock-bot angular-automatic-lock-bot bot locked and limited conversation to collaborators Dec 3, 2023
tbondwilkinson pushed a commit to tbondwilkinson/angular that referenced this issue Dec 6, 2023
…tion (angular#52476)

The significance of the combination of angular#51854 and angular#52302 went mostly
unnoticed. The first removed a unidirectional data flow constraint for
transplanted views and the second updated the signal implementation to
share transplanted view logic. The result is that we automatically get behavior
that (mostly) removes `ExpressionChangedAfterItWasCheckedError` when signals are
used to drive application state to DOM synchronization.

fixes angular#50320

PR Close angular#52476
ChellappanRajan pushed a commit to ChellappanRajan/angular that referenced this issue Jan 23, 2024
…tion (angular#52476)

The significance of the combination of angular#51854 and angular#52302 went mostly
unnoticed. The first removed a unidirectional data flow constraint for
transplanted views and the second updated the signal implementation to
share transplanted view logic. The result is that we automatically get behavior
that (mostly) removes `ExpressionChangedAfterItWasCheckedError` when signals are
used to drive application state to DOM synchronization.

fixes angular#50320

PR Close angular#52476
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
area: core Issues related to the framework runtime bug core: reactivity Work related to fine-grained reactivity in the core framework cross-cutting: signals P3 An issue that is relevant to core functions, but does not impede progress. Important, but not urgent
Projects
Status: Done
Development

No branches or pull requests

6 participants