Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 1 addition & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ A modern collection of utilities for [Angular](https://angular.dev) – signals,
- **Internationalization**: Utilities for i18n and formatting.
- **SVG & UI**: SVG sprite helpers, repeat pipe, trackBy helpers, and more.

> **See the [full documentation](https://ngxtension.netlify.app/) for a complete list and usage examples.**
> **See the [full documentation](https://ngxtension.dev) for a complete list and usage examples.**

---

Expand Down Expand Up @@ -182,5 +182,3 @@ This project follows the [all-contributors](https://github.com/all-contributors/
## License

MIT


Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
import { CommonModule } from '@angular/common';
import { Component, Provider, effect, inject } from '@angular/core';
import { TestBed, fakeAsync, flush } from '@angular/core/testing';
import { Component, effect, inject, Provider } from '@angular/core';
import { fakeAsync, flush, TestBed } from '@angular/core/testing';
import {
FormControl,
FormsModule,
NgControl,
ReactiveFormsModule,
} from '@angular/forms';
import { By } from '@angular/platform-browser';
import { NgxControlValueAccessor } from './control-value-accessor';
import {
NgxControlValueAccessor,
provideCvaDefaultValue,
} from './control-value-accessor';

describe('NgxControlValueAccessor', () => {
@Component({
Expand Down Expand Up @@ -478,4 +481,57 @@ describe('NgxControlValueAccessor', () => {
});
});
});

/**
* Regression tests for the NG0203 injection-context bug.
*
* The etalytics fork changed `value$` to a `linkedSignal` whose source
* function originally called `injectCvaDefaultValue()` directly.
* `linkedSignal` source functions re-run reactively outside any injection
* context, causing `inject()` to throw NG0203 on every `detectChanges()`.
*
* The fix captures the default value as a private field initializer
* (inside the injection context) and references that field in the source
* function instead.
*/
describe('NG0203 regression — inject() must not be called outside injection context', () => {
it('should not throw NG0203 on first detectChanges() without NgControl', () => {
expect(() => render(`<custom-input />`)).not.toThrow();
});

it('should not throw NG0203 on repeated detectChanges() without NgControl', () => {
const { fixture } = render(`<custom-input />`);
expect(() => {
fixture.detectChanges();
fixture.detectChanges();
fixture.detectChanges();
}).not.toThrow();
});

it('should not throw NG0203 when value changes trigger linkedSignal re-evaluation', () => {
const params = { value: 'initial' };
const { fixture, cva } = render(
`<custom-input [value]="value" />`,
params,
);

expect(() => {
// Trigger multiple change-detection cycles to exercise the linkedSignal source
(params as { value: string }).value = 'updated';
fixture.detectChanges();
(params as { value: string }).value = 'updated-again';
fixture.detectChanges();
}).not.toThrow();

expect(cva.value).toEqual('updated-again');
});

it('should not throw NG0203 when used with a custom default value provider', () => {
expect(() =>
render(`<custom-input />`, undefined, [
provideCvaDefaultValue(() => 'custom-default', true),
]),
).not.toThrow();
});
});
});
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
import {
Directive,
Input,
Output,
booleanAttribute,
Directive,
effect,
inject,
Input,
linkedSignal,
Output,
signal,
untracked,
} from '@angular/core';
import { toObservable } from '@angular/core/rxjs-interop';
import { NgControl, NgModel, type ControlValueAccessor } from '@angular/forms';
import { type ControlValueAccessor, NgControl, NgModel } from '@angular/forms';
import { createInjectionToken } from 'ngxtension/create-injection-token';
import { skip } from 'rxjs';

Expand Down Expand Up @@ -210,22 +211,51 @@ export class NgxControlValueAccessor<T = any> implements ControlValueAccessor {
if (this.ngControl != null) this.ngControl.valueAccessor = this;
}

/** @ignore */
private initialValue = (): T => {
if (this.ngControl != null) return this.ngControl.value;
return injectCvaDefaultValue();
};
/**
* Captured at construction time (inside the injection context) so that
* `initialValue` — which runs as a `linkedSignal` source function outside
* the injection context — can read the default value without calling
* `inject()` again (which would throw NG0203).
* @ignore
*/
private readonly _cvaDefaultValue = injectCvaDefaultValue();

/** The value of this. If a control is present, it reflects it's value. */
public readonly value$ = signal(this.initialValue(), {
/**
* We need to use untracked here to avoid that the linkedSignal
* is initialized again.
* @ignore
*/
Comment on lines +223 to +227

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The comment here is misleading. untracked does not prevent linkedSignal from being re-initialized; it prevents dependency tracking on signals read within its scope. This clarification is important for future maintainability. Let's update the comment to accurately reflect what untracked does in this context.

	/**
	 * We use `untracked` to prevent `linkedSignal` from creating a dependency
	 * on any signals that might be read here. This ensures the source function
	 * is for initialization only and doesn't re-run unexpectedly.
	 * @ignore
	 */

private readonly initialValue = (): T =>
untracked(() =>
this.ngControl ? this.ngControl.value : this._cvaDefaultValue,
);

/**
* The value of this. If a control is present, it reflects it's value.
* @remarks Internally, this uses a `linkedSignal` to delay the initialization until
* the host component's inputs are set to avoid runtime exceptions.
*/
public readonly value$ = linkedSignal(this.initialValue, {
equal: (a, b) => this.compareTo(a, b),
});

/** Whether this is disabled. If a control is present, it reflects it's disabled state. */
public readonly disabled$ = signal(this.ngControl?.disabled ?? false);
/**
* We need to use untracked here to avoid that the linkedSignal
* is initialized again.
* @ignore
*/
Comment on lines +242 to +246

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Similar to a previous comment, this explanation for using untracked is misleading. It's important for code clarity and maintainability that comments accurately describe the behavior. Let's update this one as well to correctly state that untracked is used to prevent dependency tracking.

	/**
	 * We use `untracked` to prevent `linkedSignal` from creating a dependency
	 * on any signals that might be read here. This ensures the source function
	 * is for initialization only and doesn't re-run unexpectedly.
	 * @ignore
	 */

private readonly initialDisabled = (): boolean =>
untracked(() => this.ngControl?.disabled ?? false);

/**
* A comparator, which determines value changes. Should return true, if two values are considered semanticly equal.
* Whether this is disabled. If a control is present, it reflects it's disabled state.
*
* @remarks Internally, this uses a `linkedSignal` to delay the initialization until
* the host component's inputs are set to avoid runtime exceptions. */

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

There appears to be a trailing whitespace character (a tab) at the end of this line. It should be removed to maintain code cleanliness.

Suggested change
* the host component's inputs are set to avoid runtime exceptions. */
* the host component's inputs are set to avoid runtime exceptions. */

public readonly disabled$ = linkedSignal(this.initialDisabled);

/**
* A comparator, which determines value changes. Should return true, if two values are considered semantically equal.
*
* Defaults to {@link Object.is} in order to align with change detection behavior for inputs.
*/
Expand Down