Repository: taiga-family/taiga-ui (2025-07-31T16:04:38Z)

Issue:
🐞 - `Combobox` call stringify after update
### Reproduction url

https://stackblitz.com/edit/select-async-items-with-stringify-nkzamhwp?file=src%2Fapp%2Fapp.component.ts


app.html

```
<tui-textfield tuiChevron [stringify]="stringify()">
  <input tuiComboBox [formControl]="control" [matcher]="matcher()" />

  <tui-data-list *tuiTextfieldDropdown>
    <button
      *ngFor="let item of items()"
      new
      tuiOption
      type="button"
      [value]="item.id"
    >
      {{ item.name }}
    </button>
  </tui-data-list>
</tui-textfield>

<p>
  <strong>Form control:</strong>
  <code>{{ control.value | json }}</code>
</p>
```

app.ts

```ts
import { JsonPipe, NgForOf } from '@angular/common';
import {
  ChangeDetectionStrategy,
  Component,
  computed,
  Signal,
} from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
import type { TuiStringHandler, TuiStringMatcher } from '@taiga-ui/cdk';
import { TuiDataList, TuiTextfield } from '@taiga-ui/core';
import { TuiChevron, TuiSelect, TuiComboBox } from '@taiga-ui/kit';
import { delay, of } from 'rxjs';

interface Python {
  readonly id: number;
  readonly name: string;
}

@Component({
  selector: 'app',
  standalone: true,
  exportAs: 'Example5',
  imports: [
    JsonPipe,
    NgForOf,
    ReactiveFormsModule,
    TuiChevron,
    TuiDataList,
    TuiComboBox,
    TuiSelect,
    TuiTextfield,
  ],
  templateUrl: './app.template.html',
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class App {
  private api$ = of<readonly Python[]>([
    { id: 42, name: 'John Cleese' },
    { id: 237, name: 'Eric Idle' },
    { id: 666, name: 'Michael Palin' },
    { id: 123, name: 'Terry Gilliam' },
    { id: 777, name: 'Terry Jones' },
    { id: 999, name: 'Graham Chapman' },
  ]).pipe(delay(3_000));

  protected readonly control = new FormControl<number | null>(777);

  protected items = toSignal(this.api$, {
    initialValue: [] as readonly Python[],
  });

  protected stringify: Signal<TuiStringHandler<number>> = computed(
    (items = this.items()) =>
      (id) =>
        items.find((item) => item.id === id)?.name ?? ''
  );

  protected readonly matcher: Signal<TuiStringMatcher<number>> = computed(
    (faculties = this.items()) => {
      console.log('matcher');
      return (id, query) => {
        console.log('matcher2');
        const faculty = faculties.find((item: any) => item.id === id);

        if (!faculty) {
          return false;
        }

        const name = faculty.name;

        return (
          String(id) === query || name.toLowerCase() === query.toLowerCase()
        );
      };
    }
  );
}
```

### Description

When the input props stringify is updated,  should be called again with the current value of the control

### Taiga UI version

4.46.0

### Browser Used

Chrome

### OS Used

Windows


Discussion:

Patch:
diff --git a/projects/demo-cypress/src/support/commands/index.ts b/projects/demo-cypress/src/support/commands/index.ts
new file mode 100644
index 000000000000..3ad1f7f81348
--- /dev/null
+++ b/projects/demo-cypress/src/support/commands/index.ts
@@ -0,0 +1,21 @@
+/// <reference types="cypress" />
+import {smartTick} from './smart-tick';
+
+declare global {
+    namespace Cypress {
+        interface Chainable<Subject> {
+            smartTick(
+                durationMs: number,
+                options?: Parameters<typeof smartTick>[2],
+            ): Chainable<Subject>;
+
+            paste(value: string): Chainable<Subject>;
+        }
+    }
+}
+
+Cypress.Commands.add(
+    'smartTick',
+    {prevSubject: ['optional', 'element', 'window', 'document']},
+    smartTick,
+);
diff --git a/projects/demo-cypress/src/support/commands/smart-tick.ts b/projects/demo-cypress/src/support/commands/smart-tick.ts
new file mode 100644
index 000000000000..93b066de437e
--- /dev/null
+++ b/projects/demo-cypress/src/support/commands/smart-tick.ts
@@ -0,0 +1,36 @@
+import type {ComponentFixture} from '@angular/core/testing';
+
+export function smartTick<T extends Cypress.PrevSubjectMap<void>[Cypress.PrevSubject]>(
+    $subject: T,
+    durationMs: number,
+    {
+        frequencyMs = 100,
+        fixture,
+    }: {
+        fixture?: ComponentFixture<unknown>;
+        frequencyMs?: number; // ms
+    } = {},
+): Cypress.Chainable<T> {
+    const iterations = Math.ceil(durationMs / frequencyMs);
+    const lastIterationMs = durationMs % frequencyMs || frequencyMs;
+
+    for (let i = 1; i <= iterations; i++) {
+        cy.tick(i === iterations ? lastIterationMs : frequencyMs, {log: false}).then(
+            () => fixture?.detectChanges(), // ensure @Input()-properties are changed
+        );
+        cy.wait(0, {log: false});
+    }
+
+    Cypress.log({
+        displayName: 'smartTick',
+        message: `${durationMs}ms`,
+        consoleProps() {
+            return {
+                durationMs,
+                frequencyMs,
+            };
+        },
+    });
+
+    return cy.wrap($subject, {log: false});
+}
diff --git a/projects/demo-cypress/src/support/component.ts b/projects/demo-cypress/src/support/component.ts
index 8bc7f3553abe..10a671b63b58 100644
--- a/projects/demo-cypress/src/support/component.ts
+++ b/projects/demo-cypress/src/support/component.ts
@@ -1,4 +1,5 @@
 import 'cypress-plugin-tab';
+import './commands';
 
 import {provideNoopAnimations} from '@angular/platform-browser/animations';
 import {NG_EVENT_PLUGINS} from '@taiga-ui/event-plugins';
diff --git a/projects/demo/src/modules/components/combo-box/index.html b/projects/demo/src/modules/components/combo-box/index.html
index fcb5bc7091fd..c933cd554bc0 100644
--- a/projects/demo/src/modules/components/combo-box/index.html
+++ b/projects/demo/src/modules/components/combo-box/index.html
@@ -402,6 +402,18 @@
             >
                 Value must only be an item of suggestions&nbsp;
                 <code>&lt;T&gt;</code>
+
+                <p>
+                    When
+                    <code>false</code>
+                    , make sure your handlers, such as
+                    <code>stringify</code>
+                    can handle
+                    <code>string</code>
+                    value, as well as
+                    <code>&lt;T&gt;</code>
+                    !
+                </p>
             </tr>
 
             <tr
diff --git a/projects/kit/components/combo-box/combo-box.directive.ts b/projects/kit/components/combo-box/combo-box.directive.ts
index bc037ac8b523..6e319221a90e 100644
--- a/projects/kit/components/combo-box/combo-box.directive.ts
+++ b/projects/kit/components/combo-box/combo-box.directive.ts
@@ -55,8 +55,7 @@ export class TuiComboBox<T>
     private readonly open = tuiDropdownOpen();
     private readonly dropdownEnabled = tuiDropdownEnabled(this.interactive);
     private readonly dropdown = inject(TuiDropdownDirective);
-    private readonly itemsHandlers: TuiItemsHandlers<T | string> =
-        inject(TUI_ITEMS_HANDLERS);
+    private readonly handlers: TuiItemsHandlers<T | string> = inject(TUI_ITEMS_HANDLERS);
 
     private readonly matcher = signal<TuiStringMatcher<T> | null>(TUI_STRICT_MATCHER);
     private readonly strict = signal(true);
@@ -68,10 +67,10 @@ export class TuiComboBox<T>
         () =>
             this.datalist()
                 ?.options?.() // TODO(v5): remove optional call `?.()`
-                .filter((x) => !this.itemsHandlers.disabledItemHandler()(x)) ?? [],
+                .filter((x) => !this.handlers.disabledItemHandler()(x)) ?? [],
     );
 
-    protected readonly nonStrictValueEffect = effect(() => {
+    protected readonly nonStrictControlEffect = effect(() => {
         if (
             !this.options().length &&
             !this.strict() &&
@@ -91,11 +90,10 @@ export class TuiComboBox<T>
 
         const textfieldValue = this.textfield.value();
         const selectedOption = options.find((x) =>
-            matcher(x, textfieldValue, this.itemsHandlers.stringify()),
+            matcher(x, textfieldValue, this.handlers.stringify()),
         );
         const value = untracked(() => this.value());
         const unchanged = this.stringify(value) === textfieldValue;
-        const stringified = this.stringify(selectedOption);
         const fallback = this.strict() || !textfieldValue ? null : textfieldValue;
 
         this.onChange(
@@ -108,10 +106,12 @@ export class TuiComboBox<T>
                  */
                 (unchanged ? value : fallback),
         );
+    }, TUI_ALLOW_SIGNAL_WRITES);
 
-        if (stringified && stringified !== textfieldValue) {
-            this.textfield.value.set(stringified);
-        }
+    protected readonly newValueEffect = effect(() => {
+        const stringified = this.stringify(this.value());
+
+        this.textfield.value.update((x) => stringified || x);
     }, TUI_ALLOW_SIGNAL_WRITES);
 
     protected readonly blurEffect = effect(() => {
@@ -170,6 +170,6 @@ export class TuiComboBox<T>
     }
 
     private stringify(value?: T | string | null): string {
-        return value != null ? this.itemsHandlers.stringify()(value) : '';
+        return value != null ? this.handlers.stringify()(value) : '';
     }
 }


Test patch:
diff --git a/projects/demo-cypress/src/tests/combo-box/combo-box-form-control-as-id.cy.ts b/projects/demo-cypress/src/tests/combo-box/combo-box-form-control-as-id.cy.ts
new file mode 100644
index 000000000000..71e62549bdb8
--- /dev/null
+++ b/projects/demo-cypress/src/tests/combo-box/combo-box-form-control-as-id.cy.ts
@@ -0,0 +1,181 @@
+import {NgForOf} from '@angular/common';
+import type {OnInit} from '@angular/core';
+import {
+    ChangeDetectionStrategy,
+    Component,
+    computed,
+    EventEmitter,
+    Input,
+    Output,
+    signal,
+} from '@angular/core';
+import type {ComponentFixture} from '@angular/core/testing';
+import {FormControl, ReactiveFormsModule} from '@angular/forms';
+import type {TuiStringHandler, TuiStringMatcher} from '@taiga-ui/cdk';
+import {TUI_ANIMATIONS_SPEED, TuiDataList, TuiRoot, TuiTextfield} from '@taiga-ui/core';
+import {TuiChevron, TuiComboBox} from '@taiga-ui/kit';
+import {createOutputSpy} from 'cypress/angular';
+
+interface Item {
+    readonly id: number;
+    readonly name: string;
+}
+
+@Component({
+    standalone: true,
+    imports: [
+        NgForOf,
+        ReactiveFormsModule,
+        TuiChevron,
+        TuiComboBox,
+        TuiDataList,
+        TuiRoot,
+        TuiTextfield,
+    ],
+    template: `
+        <tui-root>
+            <tui-textfield
+                tuiChevron
+                [stringify]="stringify()"
+            >
+                <input
+                    tuiComboBox
+                    [formControl]="control"
+                    [matcher]="matcher()"
+                    (input)="inputEvent.emit($any($event.target).value)"
+                />
+
+                <tui-data-list *tuiTextfieldDropdown>
+                    <button
+                        *ngFor="let item of items()"
+                        new
+                        tuiOption
+                        type="button"
+                        [value]="item.id"
+                    >
+                        {{ item.name }}
+                    </button>
+                </tui-data-list>
+            </tui-textfield>
+        </tui-root>
+    `,
+    changeDetection: ChangeDetectionStrategy.OnPush,
+    providers: [{provide: TUI_ANIMATIONS_SPEED, useValue: 0}],
+})
+export class Sandbox implements OnInit {
+    protected readonly items = signal<readonly Item[]>([]);
+
+    protected readonly stringify = computed<TuiStringHandler<number>>(
+        (items = this.items()) =>
+            (id) =>
+                items.find((item) => item.id === id)?.name ?? '',
+    );
+
+    protected readonly matcher = computed<TuiStringMatcher<number>>(
+        (items = this.items()) =>
+            (id, query) => {
+                const name = items.find((item) => item.id === id)?.name ?? '';
+
+                return query === String(id) || query.toLowerCase() === name.toLowerCase();
+            },
+    );
+
+    @Input()
+    public control = new FormControl<number | null>(null);
+
+    @Output()
+    public readonly valueChanges = new EventEmitter();
+
+    @Output()
+    public readonly inputEvent = new EventEmitter<string>();
+
+    public ngOnInit(): void {
+        this.control.valueChanges.subscribe((x) => this.valueChanges.emit(x));
+
+        // Simulate API request. Don't use rxjs to avoid problems with cy.clock()!
+        setTimeout(() => {
+            this.items.set([
+                {id: 42, name: 'John Cleese'},
+                {id: 0, name: 'Eric Idle'},
+                {id: 666, name: 'Michael Palin'},
+                {id: 123, name: 'Terry Gilliam'},
+                {id: 777, name: 'Terry Jones'},
+                {id: 999, name: 'Graham Chapman'},
+            ]);
+        }, 3_000);
+    }
+}
+
+describe('ComboBox + form control contains IDs of items from datalist', () => {
+    describe('items will be loaded later from API', () => {
+        let fixture!: ComponentFixture<unknown>;
+
+        describe('but form control already contains initial value', () => {
+            beforeEach(() => {
+                cy.clock();
+
+                cy.mount(Sandbox, {
+                    componentProperties: {
+                        control: new FormControl(0),
+                    },
+                }).then((x) => {
+                    fixture = x.fixture;
+                });
+            });
+
+            it('textfield initially empty during items loading', () => {
+                cy.smartTick(100, {fixture});
+                cy.get('[tuiComboBox]').should('have.value', '');
+                cy.smartTick(900, {fixture});
+                cy.get('[tuiComboBox]').should('have.value', '');
+                cy.smartTick(1_000, {fixture});
+                cy.get('[tuiComboBox]').should('have.value', '');
+            });
+
+            it('textfield are filled with value when items are loaded', () => {
+                cy.smartTick(1_000, {fixture});
+                cy.get('[tuiComboBox]').should('have.value', '');
+                cy.smartTick(2_000, {fixture});
+                cy.get('[tuiComboBox]').should('have.value', 'Eric Idle');
+            });
+        });
+
+        describe('but user already enters something', () => {
+            beforeEach(() => {
+                cy.clock();
+
+                cy.mount(Sandbox, {
+                    componentProperties: {
+                        valueChanges: createOutputSpy('valueChanges'),
+                    },
+                }).then((x) => {
+                    fixture = x.fixture;
+                });
+            });
+
+            it('user enters item ID', () => {
+                cy.get('[tuiComboBox]').type('42');
+
+                cy.smartTick(1_000, {fixture});
+                cy.get('[tuiComboBox]').should('have.value', '42');
+                cy.get('@valueChanges').should('not.have.been.called');
+
+                cy.smartTick(2_000, {fixture});
+                cy.get('[tuiComboBox]').should('have.value', 'John Cleese');
+                cy.get('@valueChanges').should('have.been.calledOnceWith', 42);
+            });
+
+            it('user enters item name', () => {
+                cy.get('[tuiComboBox]').type('tErRy JoNeS');
+
+                cy.smartTick(1_000, {fixture});
+                cy.get('[tuiComboBox]').should('have.value', 'tErRy JoNeS');
+                cy.get('@valueChanges').should('not.have.been.called');
+
+                cy.smartTick(2_000, {fixture});
+                cy.get('[tuiComboBox]').should('have.value', 'Terry Jones');
+                cy.get('@valueChanges').should('have.been.calledOnceWith', 777);
+            });
+        });
+    });
+});
diff --git a/projects/demo-cypress/src/tests/combo-box/combo-box-non-strict.cy.ts b/projects/demo-cypress/src/tests/combo-box/combo-box-non-strict.cy.ts
index 22ad45dda13c..a96d1e6597ed 100644
--- a/projects/demo-cypress/src/tests/combo-box/combo-box-non-strict.cy.ts
+++ b/projects/demo-cypress/src/tests/combo-box/combo-box-non-strict.cy.ts
@@ -71,7 +71,7 @@ export class TestComboBox {
         this.control.valueChanges.subscribe((x) => this.valueChanges.emit(x));
     }
 
-    protected readonly stringify: TuiStringHandler<Country> = (x) => x.name;
+    protected readonly stringify: TuiStringHandler<Country> = (x) => x.name ?? x;
 }
 
 describe('ComboBox[strict=false]', () => {
@@ -122,6 +122,15 @@ describe('ComboBox[strict=false]', () => {
                 name: 'Andorra',
             });
         });
+
+        it('formats textfield value on matching', () => {
+            cy.get('[tuiComboBox]').type('aNdOrRa').should('have.value', 'Andorra');
+
+            cy.get('@valueChanges').should('have.been.calledWith', {
+                id: 'AD',
+                name: 'Andorra',
+            });
+        });
     });
 
     describe('on cleaner click', () => {