Skip to content

fix(core): Price every ProductVariant in relation arrays, avoid spread RangeError - #5058

Open
ryandiginomad wants to merge 4 commits into
vendurehq:masterfrom
ryandiginomad:fix/entity-hydrator-spread-and-price-sampling
Open

fix(core): Price every ProductVariant in relation arrays, avoid spread RangeError#5058
ryandiginomad wants to merge 4 commits into
vendurehq:masterfrom
ryandiginomad:fix/entity-hydrator-spread-and-price-sampling

Conversation

@ryandiginomad

@ryandiginomad ryandiginomad commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Description

This is the first half of the follow-up agreed in #4986. @michaelbromley wrote there:

Separate follow-up (not this PR): isTranslatable() and the price applicator still sample element [0], so e.g. a null variants[0].featuredAsset suppresses translation of the newly-hydrated variants[1].featuredAsset.

and @biggamesmallworld added a note on reachability:

The undefined-hole change in this PR widens who can hit it. If element [0] of a relation array is a hole or null, getRelationEntityAtPath() pushes it into the result, and the entity[0] instanceof ProductVariant sample then skips pricing (and isTranslatable() skips translation) for the entire array, not just that element. Same root cause, more reachable now.

This PR covers the price-applicator half, plus a sibling spread overflow in the same class. The isTranslatable() half needs a change in translate-entity.ts to avoid regressing null relations into undefined, so it goes in companion PR #5059 to keep each review focused.

Fix 1: [0] sampling in the price application (second commit)

Whether an array-valued relation received prices was decided by entity[0] instanceof ProductVariant, but entity.map(...) then called applyChannelPriceAndTax() on every element — and applyChannelPriceAndTax() dereferences its argument on its first statement (variant.productVariantPrices). Since getRelationEntityAtPath() deliberately pushes null/undefined elements into its result, the [0] sample was wrong in both directions:

relation array master this PR
[null, variant] silently prices nothing prices variant
[variant, null] TypeError out of hydrate() prices variant
[variant, variant] prices both prices both (unchanged)

So [0] was not acting as a guard — it was a coin flip on where the hole sits. The variants to price are now selected with a per-element type-guard filter (new getProductVariantsToPrice() helper), which fixes both directions at once. Note that widening the gate alone (e.g. entity.some(e => e instanceof ProductVariant)) would have made things worse: it converts both silent-skip rows above into crashes because the body still maps over every element. I verified that variant against the new tests and it fails both of them.

On reachability, to be precise: every core @ManyToOne into ProductVariant is non-nullable, so pure core code mostly cannot produce the mixed array; the live path is the undefined hole on entities held in plugin/event-handler code (the #4955 lineage), which #4986's hole-preservation made more visible, as noted in the quote above.

Fix 2: spread RangeError in getRelationEntityAtPath() (first commit)

getRelationEntityAtPath() collects a terminal relation array with result.push(...target) — the exact defect you had me fix in getMissingRelations() in #4986 (spreading a very large array into call arguments exceeds V8's stack budget and throws RangeError: Maximum call stack size exceeded). I missed this sibling occurrence in the same class when writing that fix; found it while scoping this follow-up. Replaced with the same for...of loop merged in #4986. forEach would also avoid the RangeError but silently skips holes (measured: 999,999 elements collected instead of 1,000,000), which matters precisely because downstream code now handles those holes per element.

The two fixes are in one PR because they are causally linked: fix 2 un-blocks arrays above ~110k elements so they now reach the price application instead of aborting hydrate() — landing them together means the path fix 2 opens is already safe on arrival.

Breaking changes

None. Arrays whose element [0] was a hole now get their real ProductVariants priced (the documented intent of applyProductVariantPrices), and very large terminal relation arrays no longer throw. A variant that master's [0] sample skipped and this PR now prices takes exactly the exposure master already takes today on every array whose [0] is a variant — getRequiredProductVariantRelations() appends .taxCategory/.productVariantPrices for those paths under the same option flag. Cost-wise, the filter adds one instanceof check per element, only under applyProductVariantPrices: true, on an array the code already walks to price.

Testing

Unit (entity-hydrator.service.spec.ts, 7 new cases, no DB):

  • 2 call-site tests drive the real hydrate() (stubbed query builder, real ProductPriceApplicator with stubbed strategies) and are the regression guards for fix 1 — one per failure direction. RED on master: [null, variant] leaves listPrice unset; [variant, null] rejects with TypeError: Cannot read properties of null (reading 'productVariantPrices').
  • 1 pins the fully-populated [variant, variant] case; passes before and after.
  • 3 pin the semantic edges of the new helper ([], [null, undefined], non-ProductVariant entities). These are spec-locks for the helper, not regression proofs — stated here so the coverage isn't overread.
  • 1 guards fix 2: a 1M-element terminal relation array with a deliberate hole. RED on master with RangeError at the push(...target) line; the hole assertion is what rules out a forEach-based fix. Costs ~35 ms / ~58 MB transient on an M-series Mac (measured), cheaper than the neighbouring 200k-distinct-objects fixture.

Robustness checks on the tests themselves: I mutation-tested the suite — reverting each fix individually, substituting the .some() gate for fix 1 and forEach for fix 2 — and confirmed every mutant turns at least one of the new tests red (after asserting the mutant actually applied).

What I ran:

Result
entity-hydrator.service.spec.ts before the fixes 6 failed / 16 passed
entity-hydrator.service.spec.ts after 22 passed
Full core unit suite 75 files, 1144 passed
entity-hydrator.e2e-spec.ts (rebuilt dist) 25 passed

No new e2e: no core relation can produce the mixed array (non-nullable FKs, see reachability note), so an e2e would need a permanent nullable-FK fixture change purely to demonstrate a shape core cannot produce, and would only exercise one failure direction per run depending on row order. The DB-free call-site tests cover both directions deterministically. The pre-existing type error in order.service.ts:1460 that stops npm run build locally is unrelated (file untouched, reproduces on clean master).

🤖 AI assistance

I used Claude Code while working on this. I have reviewed every line of the change myself, and the testing table above reflects test runs I actually performed and observed — including verifying that each new regression test fails without its fix and passes with it.

Checklist

📌 Always:

  • I have set a clear title
  • My PR is small and contains a single feature
  • I have checked my own PR

👍 Most of the time:

  • I have added or updated test cases
  • I have updated the README if needed

View with [code]smith Autofix with [code]smith Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

getRelationEntityAtPath() collected a terminal relation array with
result.push(...target), which expands the array into call arguments
and exceeds V8's stack budget on very large arrays (e.g.
collection.productVariants on a big catalog), throwing RangeError:
Maximum call stack size exceeded.

This is the same defect that vendurehq#4986 fixed in getMissingRelations() in
this class; the sibling occurrence was missed there. Replaced with a
for...of loop, which is overflow-safe and, unlike forEach, preserves
undefined holes in the collected result.
When hydrate() is called with applyProductVariantPrices, whether an
array-valued relation received prices was decided by sampling element
[0] (entity[0] instanceof ProductVariant). A relation array can contain
null/undefined elements - getRelationEntityAtPath() pushes them
deliberately - so the sample was wrong in both directions: a hole at
[0] silently skipped pricing for every real ProductVariant in the
array, while a ProductVariant at [0] passed the holes behind it into
applyChannelPriceAndTax(), which dereferences its argument and throws
a TypeError out of hydrate().

The ProductVariants to price are now selected per element with a
type-guard filter, which fixes both directions at once: every real
ProductVariant is priced, and holes never reach the applicator.
@vercel

vercel Bot commented Jul 30, 2026

Copy link
Copy Markdown

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

Project Deployment Actions Updated (UTC)
vendure-storybook Ready Ready Preview Aug 5, 2026 11:26pm

Request Review

@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

EntityHydrator now accumulates relation arrays without spread-based argument limits. Product variant pricing filters hydrated values to actual ProductVariant instances and applies prices sequentially. Tests cover sparse arrays, undefined relations, mixed inputs, and pricing with null relation elements.

Possibly related PRs

  • vendurehq/vendure#4672: Extends related EntityHydrator.getRelationEntityAtPath() handling for null, undefined, and sparse relation elements.
  • vendurehq/vendure#4961: Addresses sparse and undefined relation arrays in a different EntityHydrator workflow.

Suggested reviewers: michaelbromley

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly summarizes both primary fixes: pricing every ProductVariant in relation arrays and preventing spread-related RangeError failures.
Description check ✅ Passed The description explains the changes, related issues, breaking-change status, tests, and checklist items; the optional screenshots section is not required.
✨ Finishing Touches 💡 1
🛠️ Fix failing CI checks 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

@HouseinIsProgramming HouseinIsProgramming left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Solid follow-up to #4986. One ask, one suggestion.

1. Missing tests. getProductVariantsToPrice() is only tested with arrays. The non-array branch, which handles pricing a single variant (e.g. hydrate(ctx, orderLine, { relations: ['productVariant'], applyProductVariantPrices: true })), has no coverage: it could regress and no test would fail. Please add two cases: bare ProductVariant returns [variant], undefined returns [].

2. There is a problem that your fix might expose, would be nice if we guard against it.
The pricing step now runs applyChannelPriceAndTax() for all variants concurrently via Promise.all, without any limit.
Before your fix, it was being stopped by the RangeError crash which capped it. Now that the limit is gone, we would need to bound the Promise.all somehow.
I see no upside to the concurrency here, correct me if I am wrong.
So consider adding a limit or sequentialising this. ProductVariantService.assignProductVariantsToChannel has a similar pattern you can follow.

Will approve once both points above are addressed, thanks for the thorough PR.

Relation arrays are unbounded in size since the RangeError fix, so the
pre-existing unlimited Promise.all could fan out arbitrarily many
concurrent applyChannelPriceAndTax() calls. Applied per variant in
sequence instead, following ProductVariantService.assignProductVariantsToChannel().
@ryandiginomad

ryandiginomad commented Aug 5, 2026

Copy link
Copy Markdown
Contributor Author

@HouseinIsProgramming Both points are in.

  1. Tests: af19e29 adds the two non-array cases (bare ProductVariant wraps to [variant], undefined returns []). I also checked they bite: turning the non-array branch into return [] fails the new bare-variant case.

  2. Sequential pricing: b56c030 replaces the Promise.all with a per-variant await loop, same shape as assignProductVariantsToChannel(). Agreed there is no upside to the concurrency. The Promise.all itself predates this PR, but the RangeError fix is what lets very large arrays reach it now, so bounding it belongs here.

What I ran after the changes: hydrator spec 24/24, full core unit suite 1146, entity-hydrator.e2e-spec.ts 25/25 against a rebuilt dist.

Thanks for the quick pickup and for pointing at the existing pattern to follow.

@HouseinIsProgramming HouseinIsProgramming left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Good fix. The tests fail for the right reasons in both directions, and the newly-priced variants already had their price relations fetched, so there is no new exposure. The isTranslatable half is covered in #5059.

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

Labels

T2: Elevated risk Touches critical paths or has wide blast radius. Needs careful, owned work.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants