MultiSelectComboBox throws "selectedItems is undefined" error in AutoCrud Form

I’m encountering an error with the MultiSelectComboBox component when used within an AutoCrud form. The error occurs specifically when selecting a row in the grid to edit a user. Interestingly, the same MultiSelectComboBox implementation works perfectly in a standalone AutoForm component.

The error message I am getting is:

Uncaught TypeError: this.selectedItems is undefined
    __updateChips vaadin-multi-select-combo-box-mixin.js:822
    _disabledChanged vaadin-multi-select-combo-box-mixin.js:484

I have tried with version 24.6.4 as well as 24.7.0.alpha6, same issue.

Here is the working implementation in my profile page with just an AutoForm:

import { ViewConfig } from '@vaadin/hilla-file-router/types.js';
import { RoleService, UserService } from "Frontend/generated/endpoints";
import UserModel from "Frontend/generated/ai/goacquire/shared/entity/UserModel";
import { AutoForm } from "@vaadin/hilla-react-crud";
import { useAuth } from "Frontend/auth";
import { useEffect, useState } from 'react';
import { useSignal } from "@vaadin/hilla-react-signals";
import User from 'Frontend/generated/ai/goacquire/shared/entity/User';
import Role from 'Frontend/generated/ai/goacquire/shared/entity/Role';
import { MultiSelectComboBox } from "@vaadin/react-components";

export const config: ViewConfig = {
    menu: { exclude: true },
    title: 'Profile',
    loginRequired: true
};

export default function ProfileView() {
    const { state } = useAuth();
    const [user, setUser] = useState<User | null>(null);
    // Add roles signal for the MultiSelectComboBox
    const roles = useSignal<Role[]>([]);

    useEffect(() => {
        // Fetch roles when component mounts
        RoleService.getRoles().then((value) => {
            if (value) {
                roles.value = value.filter((role): role is Role => role !== undefined);
            }
        });

        // Fetch user data
        if (state.user?.id) {
            UserService.get(state.user.id)
                .then((fetchedUser) => {
                    if (fetchedUser) {
                        setUser(fetchedUser);
                    }
                });
        }
    }, [state.user?.id]);

    return (
        <AutoForm
            service={UserService}
            model={UserModel}
            item={user}
            visibleFields={['email', 'firstName', 'lastName', 'roles']}
            fieldOptions={{
                firstName: {
                    readonly: true
                },
                lastName: {
                    readonly: true
                },
                roles: {
                    renderer: ({ field }) => (
                        <MultiSelectComboBox
                            {...field}
                            items={roles.value}
                            itemIdPath="id"
                            itemValuePath="name"
                            itemLabelPath="name"
                            readonly={false}
                        />
                    ),
                },
            }}
        />
    );
}

And here is the problematic version in my user admin page:

import { ViewConfig } from '@vaadin/hilla-file-router/types.js';
import { RoleService, UserService } from "Frontend/generated/endpoints";
import UserModel from "Frontend/generated/ai/goacquire/shared/entity/UserModel";
import { useEffect } from "react";
import { useSignal } from "@vaadin/hilla-react-signals";
import Role from "Frontend/generated/ai/goacquire/shared/entity/Role";
import User from "Frontend/generated/ai/goacquire/shared/entity/User";
import { AutoCrud } from "@vaadin/hilla-react-crud";
import { GridColumn, MultiSelectComboBox, TextField } from "@vaadin/react-components";
import Matcher from "Frontend/generated/com/vaadin/hilla/crud/filter/PropertyStringFilter/Matcher";

export const config: ViewConfig = {
    menu: { order: 2, icon: 'vaadin:user' },
    title: 'Users',
    loginRequired: true,
};

export default function UsersView() {
    // Initialize with an empty array and explicitly type it
    const roles = useSignal<Role[]>([]);

    useEffect(() => {
        // Handle the Promise properly and ensure type safety
        RoleService.getRoles().then((value) => {
            // Ensure we only assign non-null values
            if (value) {
                // Filter out any undefined roles and assign
                roles.value = value.filter((role): role is Role => role !== undefined);
            }
        });
    }, []);

    return (
        <AutoCrud
            service={UserService}
            model={UserModel}
            style={{ height: '100%' }}
            gridProps={{
                visibleColumns: ['email', 'firstName', 'lastName', 'roles', 'provider', 'active'],
                customColumns: [
                    <GridColumn
                        key="roles"
                        header="Roles"
                        autoWidth
                        renderer={({ item }: { item: User }) => {
                            const { roles } = item;
                            return <span>{roles?.map((r) => r?.name).filter(Boolean).join(", ")}</span>;
                        }}
                    />,
                ],
                columnOptions: {
                    roles: {
                        headerFilterRenderer: ({ setFilter }) => (
                            <TextField
                                theme='small'
                                placeholder='Filter roles...'
                                onValueChanged={({ detail: { value } }) =>
                                    setFilter({
                                        propertyId: "roles.name",
                                        filterValue: value,
                                        matcher: Matcher.CONTAINS,
                                        "@type": "propertyString",
                                    })
                                }
                            />
                        ),
                    }
                }
            }}
            formProps={{
                visibleFields: ['email', 'firstName', 'lastName', 'roles', 'active'],
                fieldOptions: {
                    firstName: {
                        readonly: true
                    },
                    lastName: {
                        readonly: true
                    },
                    roles: {
                        renderer: ({ field }) => (
                            <MultiSelectComboBox
                                {...field}
                                items={roles.value}
                                itemIdPath="id"
                                itemValuePath="name"
                                itemLabelPath="name"                                
                            />
                        ),
                    },
                },
            }}
        />
    );
}

I’ve attached a short video showing the behavior.

Any help would be greatly appreciated.

Is User.roles defined as nullable? Try adding @NonNull to the bean property and see if that helps.

Here is my User entity:

package ai.goacquire.shared.entity;

import jakarta.persistence.*;
import jakarta.validation.constraints.NotNull;
import lombok.Getter;
import lombok.Setter;
import lombok.NoArgsConstructor;

import java.util.List;

@Entity
@Table(name = "users")
@Getter
@Setter
@NoArgsConstructor
public class User {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, unique = true)
    @NotNull
    private String email;

    private String firstName;
    private String lastName;

    @ManyToMany(fetch = FetchType.EAGER)
    @JoinTable(
            name = "user_roles",
            joinColumns = @JoinColumn(name = "user_id"),
            inverseJoinColumns = @JoinColumn(name = "role_id")
    )
    private List<Role> roles;

    private String provider; // "google", "microsoft", or "local"
    private String providerId; // ID from the provider
    @Column(nullable = false)
    private boolean active = false;
}

Here are my UserService:

package ai.goacquire.frontend.service;

import ai.goacquire.shared.entity.User;
import ai.goacquire.shared.repository.UserRepository;
import com.vaadin.flow.server.auth.AnonymousAllowed;
import com.vaadin.hilla.BrowserCallable;
import com.vaadin.hilla.crud.CrudRepositoryService;

@BrowserCallable
@AnonymousAllowed
public class UserService extends CrudRepositoryService<User, Long, UserRepository> {

}

and RoleService:

package ai.goacquire.frontend.service;

import ai.goacquire.shared.entity.Role;
import ai.goacquire.shared.repository.RoleRepository;
import com.vaadin.flow.server.auth.AnonymousAllowed;
import com.vaadin.hilla.BrowserCallable;
import com.vaadin.hilla.crud.CrudRepositoryService;

import java.util.List;

@BrowserCallable
@AnonymousAllowed
public class RoleService extends CrudRepositoryService<Role, Long, RoleRepository> {

    public List<Role> getRoles() {
        return super.getRepository().findAll();
    }
}

I just tried adding @NonNull as well as @NotNull, neither changed the behavior.

I gave it a try, it works fine when making the roles property non-nullable and making sure that the property actually has an empty list when editing something. Created an example project here: GitHub - sissbruecker/hilla-autocrud-multi-select-combobox-example: Small example how to use a MultiSelectComboBox in the Hilla AutoCrud component

Hmm, I tried assigning an empty ArrayList to my roles property like you did. It’s still broken with just that change.
I noticed you used @Nonnull (com.vaadin.hilla.Nonnull), which is apparently different than @NonNull (lombok.NonNull) and @NotNull (jakarta.validation.constraints.NotNull).

Well, that sucks. All my entities are in a non-hilla module so I can reuse them for my client facing API, so I can’t modify the entity with a Hilla annotation because that module doesn’t have Hilla as a dependency.

Guess i’ll need to complicate things and use a DTO pattern or something.

Why would this work as-is with the AutoForm though, and break with AutoCRUD? That seems like a bug IMO.

EDIT: I read the javadocs for com.vaadin.hilla.Nonnull and noticed it said it was deprecated for removal, and to use org.jspecify.annotations.NonNull instead.
I added the jspecify dependency and the annotation to my shared code module, and everything seems to be working fine now.

package ai.goacquire.shared.entity;

import jakarta.persistence.*;
import jakarta.validation.constraints.NotNull;
import lombok.Getter;
import lombok.Setter;
import lombok.NoArgsConstructor;
import org.jspecify.annotations.NonNull;

import java.util.ArrayList;
import java.util.List;

@Entity
@Table(name = "users")
@Getter
@Setter
@NoArgsConstructor
public class User {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, unique = true)
    @NotNull
    private String email;

    private String firstName;
    private String lastName;

    @ManyToMany(fetch = FetchType.EAGER)
    @JoinTable(
            name = "user_roles",
            joinColumns = @JoinColumn(name = "user_id"),
            inverseJoinColumns = @JoinColumn(name = "role_id")
    )
    @NonNull
    private List<Role> roles = new ArrayList<>();

    private String provider; // "google", "microsoft", or "local"
    private String providerId; // ID from the provider
    @Column(nullable = false)
    private boolean active = false;
}

Really appreciate you pointing me in the right direction.