Skip to content

[plugin-ecommerce] Variant options selector and duplicate-variant validation silently stop at the first 10 join results #18498

Open

Description

@klaatu-code

Describe the Bug

plugin-ecommerce reads two join fields without passing a limit, so both fall back to the database adapter's join default (field.defaultLimit ?? 10, see @payloadcms/drizzle → find/traverseFields.js). Nothing in the plugin accounts for that cap, so once a store grows past 10 items in either join, the plugin quietly works with an incomplete list.

1. The variant options selector only offers the first 10 options of a variant type

VariantOptionsSelector loads each variant type with:

await req.payload.findByID({
  collection: variantTypesSlug,
  id: variantTypeID,
  depth: 1,
  joins: {
    options: {
      sort: 'value', // no limit -> 10
    },
  },
})

Only the first 10 options, sorted by value, are ever rendered in the dropdown. Options 11+ exist in the database and are visible in the Variant Options collection, but an editor cannot attach them to a variant. There is no "load more", no warning and no error. The option simply isn't there.

Because the sort is a plain string sort on value, which options disappear isn't obvious to an editor either. A "Color" type with 12 options:

black, blue, brown, gray, green, navy, orange, pink, purple, red, white, yellow

renders the first ten and drops white and yellow. A "Storage" type with 16gb … 2tb loses whichever values happen to sort last. From the editor's point of view, "some of my options are broken", and adding a new option can push a previously selectable one out of the list.

2. Duplicate-variant validation only compares against the first 10 sibling variants

validateOptions loads the product's existing variants with:

joins: {
  variants: {
    where: {
      deletedAt: { exists: false },
      ...(data.id && { id: { not_equals: data.id } }),
    },
    // no limit -> 10
  },
},

and checks the incoming option combination against product.variants.docs. Once a product has more than 10 variants, a combination that already exists on any variant outside the 10 returned docs passes validation, so the same option combination can be saved twice. The selector's client-side duplicate warning (ErrorBox, fed by product.variants?.docs from a findByID that also passes no join limit) has the same blind spot.

This second one is a data-integrity issue rather than a UI one: it lets duplicate variants into the database, which then surface in the storefront and in carts/orders.

Why this matters

Ten is a very low ceiling for variants, and it isn't specific to one industry. Apparel and fashion (sizes, colors, fits), electronics (storage, memory, colors), electrical and hardware supplies (amperage, voltage, diameter, length), cosmetics (shades), furniture (finishes, dimensions), and really any catalog with a long list of values for a single attribute or a few attributes combined will go past 10 options per type, or 10 variants per product, very quickly. In all of these cases the variants feature is effectively capped at 10 without anyone being told.

In practice editors work around it by creating a second variant type with the same values ("Color", "Color 2", …), which fragments the catalog and makes the data much harder to clean up later.

Suggested fix

Pass an explicit limit: 0 (unbounded, as already supported by the adapter) in all three places:

 // ui/VariantOptionsSelector/index.tsx (variant types)
 joins: {
   options: {
+    limit: 0,
     sort: 'value',
   },
 },

 // ui/VariantOptionsSelector/index.tsx (product read for existing combinations)
+joins: {
+  variants: { limit: 0 },
+},

 // collections/variants/createVariantsCollection/hooks/validateOptions.ts
 joins: {
   variants: {
+    limit: 0,
     where: { ... },
   },
 },

Alternatively, defaultLimit: 0 on the plugin's variantTypes.options and products.variants join fields covers all three call sites at once, at the cost of also changing the admin join table's page size.

Workaround for anyone hitting this today

Set defaultLimit on those two join fields through the plugin's collection overrides (variantTypesCollectionOverride and productsCollectionOverride). Since none of the plugin's queries pass a limit, the field-level default is what applies.

A personal note

I want to say clearly that this report comes from a very happy user. Moving a B2B store to Payload has felt like finally stepping out of years of being held hostage by WooCommerce. Payload has been a real balm for that wound, and I'm finally building the things I always wanted to build. That's exactly why I wanted to flag something that can quietly hurt anyone selling apparel or electronics.

I know Payload v4 is on the way, and I wasn't sure whether a v3 bug filed here would carry over into v4 development or whether it belongs somewhere else. The template says v3 is in maintenance mode and still accepts bug fixes, so this looked like the right channel. Happy to move it if not.

I've already patched this locally in my own project, so it isn't blocking me. It still felt serious enough to report so others don't run into it silently.

Link to the code that reproduces this issue

https://github.com/klaatu-code/payload-ecommerce-variant-limit-repro

Reproduction Steps

The repo is a blank create-payload-app template (SQLite) plus @payloadcms/plugin-ecommerce and one script, src/repro.ts.

pnpm install
cp .env.example .env
pnpm repro              # reproduces both bugs
pnpm repro:workaround   # same script with defaultLimit: 0 on the two join fields -> both gone

What the script does:

  1. Creates a color variant type with 12 options (black … yellow) and runs the exact findByID query VariantOptionsSelector runs. Only 10 options come back; white and yellow are missing.
  2. Creates a product with a size variant type and 11 variants, one per size, then runs the join query validateOptions uses. It returns the 10 newest variants; the oldest one is outside the result.
  3. Control: creating a variant that duplicates the newest variant's option is rejected, as expected.
  4. Bug: creating a variant that duplicates the oldest variant's option is saved.

Output on 3.90.2:

[Bug 1] options in DB for "color": 12
[Bug 1] options offered by VariantOptionsSelector: 10 [ 'black', 'blue', 'brown', 'gray', 'green', 'navy', 'orange', 'pink', 'purple', 'red' ]
[Bug 1] options the editor can never select: [ 'white', 'yellow' ]

[Bug 2] variants on product: 11 | variants validateOptions compares against: 10
[Bug 2] variants outside the join result: [ 1 ]
[Bug 2] control: duplicate of a visible sibling rejected: The following field is invalid: Variant options
[Bug 2] DUPLICATE SAVED: option 13 already used by variant 1

Result: bug1=REPRODUCED bug2=REPRODUCED

With pnpm repro:workaround the selector query returns all 12 options, the validator sees all 11 siblings, and the duplicate is rejected.

Minor, related: when the duplicate is rejected, the server logs key not found: ecommerce:variantOptionsAlreadyExists. validateOptions looks up translation keys under ecommerce:, while the rest of the plugin uses plugin-ecommerce:, so the specific "already exists" message is never resolved. Through the Local API only the generic "The following field is invalid: Variant options" comes back.

Which area(s) are affected?

plugin: ecommerce, db: postgres, db: sqlite (adapter-agnostic: both drizzle adapters default joins to field.defaultLimit ?? 10)

Environment Info

payload: 3.90.2
@payloadcms/plugin-ecommerce: 3.90.2
@payloadcms/db-postgres: 3.90.2 (production project)
@payloadcms/db-sqlite: 3.90.2 (reproduction repo)
@payloadcms/ui: 3.90.2
next: 16.3.3
react: 19.2.4
node: 24.19.0
pnpm: 11.18.0

The relevant code is unchanged on main at the time of writing.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions