Skip to content

Bulk partner invite#3462

Merged
steven-tey merged 8 commits intomainfrom
bulk-partner-invite
Feb 13, 2026
Merged

Bulk partner invite#3462
steven-tey merged 8 commits intomainfrom
bulk-partner-invite

Conversation

@marcusljf
Copy link
Collaborator

@marcusljf marcusljf commented Feb 12, 2026

Allows the workspace to invite one to many partners (max 50) with the same application instance. The user separates each email with a comma to create the email chip.

Additional features:

  • Delete to highlight the previous email chip. Press key again to delete
  • User can use the left and right arrow keys to select other chips to delete
  • When tabbing out of the input, the email chip is created or validated to correct
  • Separate emails are sent to each partner to not share PIP
  • User can click on the X on each email chip to delete from the list as well.
CleanShot.2026-02-12.at.10.42.07.mp4

Summary by CodeRabbit

  • New Features

    • Multi-email recipient input with chips, CSV paste, keyboard navigation and a 50-recipient limit
    • Bulk partner invites with aggregated success/skip feedback and batched personalized emails
  • Improvements

    • Show name/short-link fields only for single recipients
    • Better helper text, per-recipient validation, combined Send/Cancel loading and disabled states
    • Email preview preserved and integrated with multi-recipient flow

Allows the workspace to invite one to many partners with the same application instance. The user separates each email with a comma to create the email chip.

Additional features:
- Backspace/del to highlight the previous email chip. Press key again to delete
- User can use the left and right arrow keys to select other chips to delete
- When tabbing out of the input, the email chip is created or validated to correct
- Separate emails are sent to each partner so not share PIP
@vercel
Copy link
Contributor

vercel bot commented Feb 12, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
dub Ready Ready Preview Feb 13, 2026 1:21pm

Request Review

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 12, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds multi-recipient partner invite support: UI accepts multiple emails, introduces MultiValueInput and bulk invite schema/action, routes submit to single or bulk invite flows, creates enrollments, sends invite emails in background, and returns aggregated invite/skip counts.

Changes

Cohort / File(s) Summary
Invitation UI
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/invite-partner-sheet.tsx
Replaces single email input with MultiValueInput (emails: string[]), commits pending input on submit, conditionally shows single-recipient fields, enforces per-request max, and splits submit logic to single vs bulk invite actions with per-flow loading/toast behavior.
Bulk Invite Action
apps/web/lib/actions/partners/bulk-invite-partners.ts
New bulkInvitePartnersAction: validates input, normalizes/dedupes emails, creates Partner and ProgramEnrollment records for non-enrolled emails, queues background tasks to build links, fetch rewards, send ProgramInvite emails, and record audit logs; returns invited/skipped counts.
Single Invite Action
apps/web/lib/actions/partners/invite-partner.ts
Refactors program lookup to use program.partners for status detection, adjusts input destructuring and groupId enforcement, and preserves enrollment/email flow with updated status messaging.
Validation Schemas
apps/web/lib/zod/schemas/partners.ts
invitePartnerSchema updated (groupId nullish), new bulkInvitePartnersSchema added (workspaceId, groupId?, emails: string[] validated as emails, capped by MAX_PARTNERS_INVITES_PER_REQUEST).
Constants
apps/web/lib/constants/program.ts
Adds MAX_PARTNERS_INVITES_PER_REQUEST = 50.
UI package export
packages/ui/src/index.tsx
Re-exports new multi-value-input component from UI public surface.
MultiValueInput component
packages/ui/src/multi-value-input.tsx
New client component with CSV-like paste parsing, add/remove chips, keyboard navigation, wrapping layout, maxValues, normalize, and imperative ref API commitPendingInput(): string[].
Email template typing
packages/email/src/templates/program-invite.tsx
ProgramInvite prop name made optional (`name?: string

Sequence Diagram(s)

sequenceDiagram
    participant User as User/Sheet
    participant Schema as Zod Schema
    participant Action as Invite Action
    participant DB as Database
    participant Email as Email Service
    participant Audit as Audit Logger

    User->>Schema: Submit form (email or emails)
    Schema->>Schema: Validate fields & max 50 recipients
    Schema-->>User: Validation result

    alt Validation passes
        User->>Action: Invoke single or bulk invite action
        Action->>Action: Deduplicate & normalize recipients
        Action->>DB: Fetch program (groups, partners, domains)
        loop per recipient
            Action->>DB: Check/create Partner & ProgramEnrollment
            alt Already enrolled
                Action-->>Action: Mark skipped
            else
                Action->>Audit: Queue audit entry
            end
        end
        par Background tasks
            Action->>Email: Send invitation emails (ProgramInvite)
            Action->>Audit: Persist audit logs
        and
            Email-->>Action: Return send results
            Audit-->>Action: Return log results
        end
        Action-->>User: Return invitedCount, skippedCount, details
    else Validation fails
        Schema-->>User: Return error
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Suggested reviewers

  • devkiran
  • TWilson023

Poem

🐇 I hopped through chips and commas bright,
I gathered emails in a single flight,
Fifty hops I counted, neat and small,
I nudged each invite — some skipped, some all,
A rabbit's cheer for invites big and small!

🚥 Pre-merge checks | ✅ 2 | ❌ 2
❌ Failed checks (2 warnings)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 33.33% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Merge Conflict Detection ⚠️ Warning ❌ Merge conflicts detected (13 files):

⚔️ apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/invite-partner-sheet.tsx (content)
⚔️ apps/web/lib/actions/partners/create-manual-commission.ts (content)
⚔️ apps/web/lib/actions/partners/invite-partner.ts (content)
⚔️ apps/web/lib/api/conversions/track-sale.ts (content)
⚔️ apps/web/lib/constants/program.ts (content)
⚔️ apps/web/lib/partners/create-partner-commission.ts (content)
⚔️ apps/web/lib/zod/schemas/partners.ts (content)
⚔️ apps/web/lib/zod/schemas/rewards.ts (content)
⚔️ apps/web/tests/rewards/reward-conditions.test.ts (content)
⚔️ apps/web/ui/partners/program-reward-modifiers-tooltip.tsx (content)
⚔️ apps/web/ui/partners/rewards/rewards-logic.tsx (content)
⚔️ packages/email/src/templates/program-invite.tsx (content)
⚔️ packages/ui/src/index.tsx (content)

These conflicts must be resolved before merging into main.
Resolve conflicts locally and push changes to this branch.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title "Bulk partner invite" directly and concisely summarizes the main feature: enabling bulk invitation of multiple partners. It accurately reflects the core change across all modified files.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch bulk-partner-invite
⚔️ Resolve merge conflicts (beta)
  • Auto-commit resolved conflicts to branch bulk-partner-invite
  • Create stacked PR with resolved conflicts
  • Post resolved changes as copyable diffs in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🤖 Fix all issues with AI agents
In
`@apps/web/app/app.dub.co/`(dashboard)/[slug]/(ee)/program/partners/invite-partner-sheet.tsx:
- Around line 462-481: The remove-button's onClick handler inside the Button
(for the X icon) should stop event propagation so the parent span's onClick
doesn't re-select the removed email; update the Button's onClick to accept the
event parameter (e) and call e.stopPropagation() before calling
setSelectedRecipientEmail and setRecipientEmails for the recipientEmail; this
change should be applied where the Button, recipientEmail,
selectedRecipientEmail, setSelectedRecipientEmail, and setRecipientEmails are
referenced.

In `@apps/web/lib/actions/partners/invite-partner.ts`:
- Around line 183-206: Partial failures during the loop currently re-throw after
sending post-invite emails, hiding how many partners were successfully enrolled;
modify the loop using uniqueRecipientEmails and createAndEnrollPartner to catch
per-recipient errors (push failures into an errors array with recipientEmail and
error), continue processing remaining recipients, ensure
createPostInvitePromises (used by waitUntil) is still invoked for any
enrolledPartners, and finally return a result object like { invitedCount:
enrolledPartners.length, invited: enrolledPartners, errors } instead of throwing
so the caller/UI can show accurate success/failure counts; update callers to
expect this summary return shape.
- Around line 83-101: The validation misses ProgramEnrollmentStatus values
"banned", "archived", and "deactivated", so statusMessages lookup can be
undefined and allow duplicate enrollments; update the statusMessages map used in
the invite loop (variable statusMessages) to include entries for "banned",
"archived", and "deactivated" with appropriate messages, ensure the for-loop
that checks existingProgramEnrollment (using existingEnrollmentByEmail and
uniqueRecipientEmails) still throws an Error when any of those statuses are
present, and keep createAndEnrollPartner calls from being executed with
skipEnrollmentCheck: true for those emails.
🧹 Nitpick comments (4)
apps/web/lib/zod/schemas/partners.ts (1)

731-753: Schema looks solid overall; one nit on the dedup + count check.

The superRefine deduplicates before counting, but the .max(50) on the emails array (line 736) applies before the superRefine runs — so if someone sends 50 emails in the array plus a different email, the array check passes but the combined unique set is 51, and the superRefine correctly catches it. This is fine and consistent.

However, note that neither email nor emails is required at the schema level — both are optional. The server action guards against an empty set at runtime (line 34-36 of invite-partner.ts), but adding a schema-level refinement for "at least one recipient" would give earlier, more descriptive Zod errors. Not blocking since the action handles it.

apps/web/lib/actions/partners/invite-partner.ts (1)

184-196: Sequential enrollment — consider batching for large invite lists.

Partners are enrolled one-at-a-time in a for loop. For up to 50 recipients, this means 50 serial DB round-trips. While acceptable for small batches, this could result in noticeable latency at higher counts. No immediate fix needed, but worth noting for future optimization (e.g., parallel batches of 5-10).

apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/invite-partner-sheet.tsx (2)

323-361: emailInput is processed twice — once by commitEmailInput and again inline. Works due to React batching but is fragile.

commitEmailInput() at line 328 adds parsed emails to recipientEmails state and clears emailInput — but since React batches state updates, neither change is reflected synchronously. Lines 334-342 then re-parse emailInput to build finalRecipientEmails. This works today because state is stale within the same handler, but it's subtle and would break if the function were made async before line 334, or if commitEmailInput were refactored.

Consider having commitEmailInput return the committed emails directly so onSubmit can use them without re-parsing.

Sketch
- const commitEmailInput = () => {
+ const commitEmailInput = (): string[] | false => {
     const emailCandidates = emailInput
       .split(",")
       .map((value) => value.trim())
       .filter(Boolean);

     if (emailCandidates.length === 0) {
       setEmailInput("");
-      return true;
+      return [];
     }

+    const committed: string[] = [];
     for (const candidate of emailCandidates) {
       const wasAdded = addRecipientEmail(candidate);
       if (!wasAdded) {
         return false;
       }
+      committed.push(candidate.trim().toLowerCase());
     }

     setEmailInput("");
-    return true;
+    return committed;
   };

Then in onSubmit:

-    const didCommitPendingInput = commitEmailInput();
-    if (!didCommitPendingInput) {
+    const committed = commitEmailInput();
+    if (committed === false) {
       return;
     }
-    const finalRecipientEmails = Array.from(
-      new Set([
-        ...recipientEmails,
-        ...emailInput
-          .split(",")
-          .map((value) => value.trim().toLowerCase())
-          .filter(Boolean),
-      ]),
-    );
+    const finalRecipientEmails = Array.from(
+      new Set([...recipientEmails, ...committed]),
+    );

217-218: Prefer ?? 1 over || 1 for invitedCount fallback.

|| 1 would coerce a legitimate invitedCount of 0 to 1. While 0 shouldn't occur given server validation, ?? is more precise and conveys the intent of "default only if nullish."

Proposed fix
-      const invitedCount = data?.invitedCount || 1;
+      const invitedCount = data?.invitedCount ?? 1;

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/invite-partner-sheet.tsx (1)

109-135: ⚠️ Potential issue | 🟡 Minor

Partial-success recovery path is a dead end.

When some invites succeed and others fail (lines 123-128), the toast shows only counts. The successfully-invited emails remain in the input, and retrying will fail because the backend's pre-check (throw new Error("Partner X has already been invited…")) aborts the entire request on the first already-enrolled email.

Consider either:

  1. Removing successfully-invited emails from the input and surfacing per-recipient errors so the user can fix and retry only the failures, or
  2. Closing the sheet on partial success and showing a detailed toast/notification with the failures list.
🤖 Fix all issues with AI agents
In `@packages/ui/src/multi-value-input.tsx`:
- Around line 228-232: handleBlur is causing a duplicate onChange because
commitPendingInput() already calls onChange(next) when it parses new values;
remove the redundant call in handleBlur so it only clears selection and commits
input. Specifically, in the handleBlur function (which calls
setSelectedValue(null) and const next = commitPendingInput()), delete the
subsequent if (next !== values) onChange(next) check so commitPendingInput is
solely responsible for emitting updates.
🧹 Nitpick comments (3)
packages/ui/src/index.tsx (1)

26-27: Nit: export ordering — multi-value-input should come after menu-item.

The surrounding exports are alphabetically sorted. Since "menu-item" < "multi-value-input" lexicographically, these two lines should be swapped to maintain consistency.

Proposed fix
-export * from "./multi-value-input";
 export * from "./menu-item";
+export * from "./multi-value-input";
packages/ui/src/multi-value-input.tsx (2)

137-157: Consider separating the ResizeObserver setup from the manual checkWrapped call.

The [values, inputValue] dependencies cause the observer to be torn down and recreated on every keystroke and chip change. The observer only needs to be created once; the manual checkWrapped() call is what needs to re-run when values change.

Sketch
+ // Manual re-check when content changes
+ useEffect(() => {
+   const container = containerRef.current;
+   if (!container) return;
+   const children = Array.from(container.children) as HTMLElement[];
+   if (children.length <= 1) { setIsWrapped(false); return; }
+   const tops = children.map((el) => el.offsetTop);
+   const firstRowTop = Math.min(...tops);
+   setIsWrapped(tops.some((top) => top - firstRowTop > 2));
+ }, [values, inputValue]);
+
+ // ResizeObserver — mount once
  useEffect(() => {
    const container = containerRef.current;
    if (!container) return;
-
    const checkWrapped = () => {
      const children = Array.from(container.children) as HTMLElement[];
-     if (children.length <= 1) {
-       setIsWrapped(false);
-       return;
-     }
+     if (children.length <= 1) { setIsWrapped(false); return; }
      const tops = children.map((el) => el.offsetTop);
      const firstRowTop = Math.min(...tops);
      setIsWrapped(tops.some((top) => top - firstRowTop > 2));
    };
-
-   checkWrapped();
    const observer = new ResizeObserver(checkWrapped);
    observer.observe(container);
    return () => observer.disconnect();
- }, [values, inputValue]);
+ }, []);

67-84: Optional: With React 19, forwardRef is no longer needed.

Since this project uses React 19.1.3, ref can be received as a regular prop. The forwardRef wrapper still works but is the legacy pattern.

Sketch (React 19 style)
-const MultiValueInput = React.forwardRef<
-  MultiValueInputRef,
-  MultiValueInputProps
->(function MultiValueInput(
-  { values, onChange, placeholder, id, className, inputClassName, disabled, normalize = (v) => v.trim(), maxValues, autoFocus },
-  ref,
-) {
+function MultiValueInput({
+  values, onChange, placeholder, id, className, inputClassName, disabled,
+  normalize = (v) => v.trim(), maxValues, autoFocus, ref,
+}: MultiValueInputProps & { ref?: React.Ref<MultiValueInputRef> }) {
   // ... component body unchanged
-});
-MultiValueInput.displayName = "MultiValueInput";
+}

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🤖 Fix all issues with AI agents
In `@apps/web/lib/actions/partners/bulk-invite-partners.ts`:
- Around line 125-153: The code sends invites to uniqueRecipientEmails
regardless of whether programEnrollment.createMany actually enrolled them
(skipDuplicates can skip some); fix by deriving the recipient list from the
actual successful enrollments returned by programEnrollment.createMany (or by
querying the created enrollments) instead of using uniqueRecipientEmails, then
pass that filtered list to sendBatchEmail; update references to
programEnrollment.createMany, partners, uniqueRecipientEmails and sendBatchEmail
so only emails for successfully created enrollment records are included.
- Around line 115-176: The async IIFE passed to waitUntil lacks error handling
so failures from sendBatchEmail or recordAuditLog are swallowed; wrap the entire
IIFE body in a try/catch, catch any thrown error, and handle it the same way as
the single-invite flow: log the error (use your logger e.g., processLogger.error
or console.error) with contextual info (programId, workspace.id,
uniqueRecipientEmails), record a failing audit entry or call the existing
error-reporting path (e.g., Sentry.captureException) so the failure is visible,
and ensure the catch does not silently swallow the error without recording or
logging it (keep references to waitUntil, sendBatchEmail, recordAuditLog, and
ProgramInvite to locate changes).
- Around line 63-65: The current all-or-nothing check using program.partners
should be changed to handle partial successes: update the partners query to
explicitly include the nested partner relation so you can read partner.email,
then build a set of alreadyEnrolledEmails from program.partners.map(p =>
p.partner.email); filter the incoming invite list to exclude those emails (or
collect them as skippedEmails), proceed to create invites/enrollments for the
remaining emails, and return/throw a response that includes the skippedEmails so
the caller knows which addresses were ignored; refer to program.partners and the
partners query and make the filtering logic inside the bulk-invite-partners
handler (e.g., bulkInvitePartners) to implement this flow.
🧹 Nitpick comments (4)
packages/ui/src/multi-value-input.tsx (2)

91-106: addValues calls onChange with the original reference when all candidates are duplicates.

When all normalized candidates already exist in values, addValues returns the original values reference (line 97). Callers like handlePaste (line 223-224) then call onChange(next) with this same reference, triggering an unnecessary state update. Not a bug (React/react-hook-form handles same-reference sets gracefully), but worth noting.


138-157: ResizeObserver is recreated on every values/inputValue change.

The checkWrapped callback reads from the DOM, so the observer itself doesn't need to be recreated — only the immediate checkWrapped() call on line 153 needs to re-run. Consider splitting the observer setup (no deps) from the immediate check to avoid unnecessary observer churn.

apps/web/lib/zod/schemas/partners.ts (1)

739-743: Consider adding .min(1) to the emails array.

The schema currently allows an empty emails array. While the UI guards against this, the server action would silently proceed with zero work if called directly with emails: []. Other bulk schemas in this file (e.g., bulkApprovePartnersSchema at line 756) enforce .min(1).

Proposed fix
 export const bulkInvitePartnersSchema = z.object({
   workspaceId: z.string(),
   groupId: z.string().nullish(),
-  emails: z.array(z.email().trim().min(1).max(100)).max(50),
+  emails: z.array(z.email().trim().min(1).max(100)).min(1).max(50),
 });
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/invite-partner-sheet.tsx (1)

46-52: email field in InvitePartnerFormData is unused.

The email field is set via setValue in the onChange handler (line 300-303) but is never read in the onSubmit handler — finalEmails[0] is used instead. Consider removing it to reduce confusion.

@steven-tey
Copy link
Collaborator

@coderabbitai full review, any breaking changes?

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 13, 2026

@steven-tey I'll perform a full review and analyze breaking changes.

Breaking Changes Analysis

⚠️ Potential Breaking Change

invitePartnerSchema in apps/web/lib/zod/schemas/partners.ts:

  • Before: groupId: z.string().nullish().default(null)
  • After: groupId: z.string().nullish() (removed .default(null))

Impact: Any code that relied on the automatic null default for groupId when parsing with this schema will now receive undefined instead. Callers must now explicitly provide null or handle undefined.

Mitigation needed: Verify all call sites of invitePartnerSchema.parse() to ensure they handle the absence of a default value.

✅ Backward Compatible Changes

ProgramInvite email template:

  • Changed name: string | nullname?: string | null
  • Not breaking: Making a prop optional is backward compatible. Existing callers passing name will continue to work.

New Exports (Additive only):

  • MultiValueInput component
  • bulkInvitePartnersAction server action
  • bulkInvitePartnersSchema validation schema
  • MAX_PARTNERS_INVITES_PER_REQUEST constant

These are all new additions and don't break existing APIs.

Key Areas for Full Review

  1. Security & Authorization - Bulk operations with multiple recipients
  2. Validation Logic - Email deduplication, max limits enforcement
  3. Error Handling - Partial failures in bulk operations
  4. Database Transactions - Creating multiple partners/enrollments atomically
  5. Email Delivery - Individual PIP (Partner Invite Page) per partner
  6. UI/UX - MultiValueInput keyboard navigation and accessibility
  7. Performance - Handling 50 invites efficiently

I'll now trigger a comprehensive review of all changes.

✅ Actions performed

Full review triggered.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Fix all issues with AI agents
In `@apps/web/lib/zod/schemas/partners.ts`:
- Around line 740-746: The bulkInvitePartnersSchema currently allows an empty
emails array; update the schema for bulkInvitePartnersSchema to require at least
one email by adding .min(1) to the emails array chain (keeping existing
z.email().trim().min(1).max(100) and .max(MAX_PARTNERS_INVITES_PER_REQUEST)
intact) so server-side validation matches other bulk schemas like
bulkApprovePartnersSchema.

In `@packages/ui/src/multi-value-input.tsx`:
- Around line 91-106: addValues currently only filters candidates against the
existing values array, so duplicate entries within the same candidates batch
(after normalize) can be added and create duplicate chips; update addValues (the
function using normalize, values, maxValues and producing next) to deduplicate
the normalized candidates themselves before pushing into next (e.g., track a
local Set of already-seen items or check next.includes(v) before push) and still
respect maxValues while building next so duplicates from the batch are skipped
and no duplicate keys are produced.
🧹 Nitpick comments (2)
packages/ui/src/multi-value-input.tsx (2)

108-118: onChange is called unconditionally even when no new values are added.

When all parsed values are already present, addValues returns the original values reference. Line 115 still calls onChange(next), triggering a no-op state update in the parent. Consider guarding with a reference check.

Proposed fix
   const commitPendingInput = useCallback((): string[] => {
     const parsed = parseCsvLikeValues(inputValue);
     if (parsed.length === 0) {
       setInputValue("");
       return values;
     }
     const next = addValues(parsed);
-    onChange(next);
+    if (next !== values) onChange(next);
     setInputValue("");
     return next;
   }, [inputValue, values, addValues, onChange]);

248-277: Chip elements are not keyboard-accessible on their own.

The <span> chips use onClick for selection but have no role, tabIndex, or keyboard event handlers. Users relying on assistive technology cannot interact with individual chips via click or focus. The keyboard navigation via the input element partially mitigates this, but the chips themselves are invisible to screen readers as interactive elements.

@steven-tey steven-tey merged commit 714a8bf into main Feb 13, 2026
10 checks passed
@steven-tey steven-tey deleted the bulk-partner-invite branch February 13, 2026 18:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants