Skip to content

perf(shared/codegen-core): few small performance improvements#3917

Open
SukkaW wants to merge 11 commits into
hey-api:mainfrom
SukkaW:more-performance-improvement
Open

perf(shared/codegen-core): few small performance improvements#3917
SukkaW wants to merge 11 commits into
hey-api:mainfrom
SukkaW:more-performance-improvement

Conversation

@SukkaW
Copy link
Copy Markdown
Contributor

@SukkaW SukkaW commented May 19, 2026

With the changes, the Stripe OpenAPI 3.1 spec generation time is reduced from 12 ~ 13s down to 10 ~ 11s, about 9% (more or less, more less than more more though) faster for Stripe spec.


Here are the optimizations:

  • Avoid using Object.keys(obj).length to detect if an object is empty or not, especially when we have already iterated the object with a for loop, we already know if the object is empty or not.
  • A few regexps are inlined inside the function, thus those regexp objects get re-created every time the function is invoked. This is now hoisted to the module level.
  • parseEnums currently uses for...of.. over an array's iterator protocol; however, the iterator protocol introduces a lot of overhead while parseEnums is on the hot path. Use a normal for loop on the array instead.
  • Currently, the isTopLevelComponent check will normalize the json pointer, then simply discard the parsed result, wasting result array object allocation. This is now avoided with string comparison (indexOf)
  • Avoid spread to reduce object allocation via copy:
    • Prefer in-place mutate when merging: obj = { ...obj, new } -> Object.assign(obj, new).
    • buildIndexKeySpace now prefers recursive over top-level merging
  • Fast path for object schema w/o properties: avoid unused object allocations
  • For JSON pointer path w/o ~, skip iterating and mapping
  • Avoid delete when possible: delete can easily cause v8 to deopt an object. Use = undefined when the type is allowed.
  • getSchemaType and getSchemaTypes no longer accept nested objects. It was never extended for the last 2 years. By passing schema directly, we also avoid one object allocation per invocation.

@bolt-new-by-stackblitz
Copy link
Copy Markdown

Review PR in StackBlitz Codeflow Run & review this pull request in StackBlitz Codeflow.

@vercel
Copy link
Copy Markdown

vercel Bot commented May 19, 2026

@SukkaW is attempting to deploy a commit to the Hey API Team on Vercel.

A member of the Team first needs to authorize it.

@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented May 19, 2026

⚠️ No Changeset found

Latest commit: 1dc3fa3

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@dosubot dosubot Bot added size:L This PR changes 100-499 lines, ignoring generated files. internal ⚙️ Internal development work. labels May 19, 2026
@SukkaW SukkaW changed the title perf(shared): multiple performance improvements perf(shared/codegen-core): few small performance improvements May 19, 2026
@codecov
Copy link
Copy Markdown

codecov Bot commented May 19, 2026

Codecov Report

❌ Patch coverage is 42.42424% with 95 lines in your changes missing coverage. Please review.
✅ Project coverage is 37.72%. Comparing base (0c865e1) to head (1dc3fa3).

Files with missing lines Patch % Lines
packages/shared/src/openApi/3.1.x/parser/schema.ts 0.00% 36 Missing and 7 partials ⚠️
packages/shared/src/openApi/3.0.x/parser/schema.ts 0.00% 25 Missing and 4 partials ⚠️
packages/shared/src/openApi/2.0.x/parser/schema.ts 56.00% 10 Missing and 1 partial ⚠️
packages/shared/src/utils/ref.ts 64.70% 4 Missing and 2 partials ⚠️
...ages/shared/src/openApi/3.1.x/parser/pagination.ts 0.00% 2 Missing ⚠️
packages/shared/src/ir/utils.ts 0.00% 1 Missing ⚠️
...ages/shared/src/openApi/2.0.x/parser/pagination.ts 0.00% 1 Missing ⚠️
...ages/shared/src/openApi/3.0.x/parser/pagination.ts 0.00% 1 Missing ⚠️
packages/shared/src/openApi/shared/utils/graph.ts 96.55% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #3917      +/-   ##
==========================================
+ Coverage   37.70%   37.72%   +0.01%     
==========================================
  Files         582      582              
  Lines       20844    20880      +36     
  Branches     6063     6075      +12     
==========================================
+ Hits         7860     7876      +16     
- Misses      10570    10591      +21     
+ Partials     2414     2413       -1     
Flag Coverage Δ
unittests 37.72% <42.42%> (+0.01%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Copy link
Copy Markdown
Contributor

@pullfrog pullfrog Bot left a comment

Choose a reason for hiding this comment

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

Important

Most of the optimizations look clean and the swap from spread-copy to Object.assign / in-place mutation is safe where the target is freshly constructed. However, replacing required = [...required, ...schema.required] with required.push(...schema.required) is not equivalent in this code path: irCompositionSchema.required (and inlineSchema.required in the discriminator branch) may already alias schema.required from the source spec via parseType's irSchema.required = schema.required, so the push mutates the user's input OpenAPI document. The previous spread allocated a fresh array and avoided this. Five inline comments below covering all three parser versions.

TL;DR — A batch of micro-optimizations across the shared parser (hoisted regexps, in-place mutation, fast paths, iterator-free loops, delete= undefined) reportedly shaves ~9% off Stripe spec generation. The wins look solid, but a few of the spread-to-push rewrites cross from "harmless in-place mutation of fresh IR objects" into "mutation of arrays aliased from the input spec".

Key changes

  • Hoist top-level IR pattern regexps — move the Record<IrTopLevelKind, RegExp> table out of matchIrPointerToGroup so the regex literals aren't re-created on each call.
  • Switch object-emptiness checks to a running flagparseObject now tracks isSchemaPropertiesEmpty / isPatternPropertiesEmpty during iteration instead of calling Object.keys(...).length afterward, and skips the inner allocation when schema.properties / schema.patternProperties are absent.
  • getSchemaType / getSchemaTypes accept the schema directly — drop the unused destructuring wrapper at every call site across 2.0.x / 3.0.x / 3.1.x parsers.
  • parseEnum uses an index loop and hoists x-enum* lookups — avoids the iterator-protocol overhead of entries() and the per-iteration property reads.
  • In-place merges over spreadObject.assign(target, source) replaces target = {...target, ...source} in deduplicateSchema, addItemsToSchema, and the parseArray composition-lift branches.
  • delete= undefined — for state.inAllOf reset and the result.items / result.logicalOperator cleanup in deduplicateSchema, to keep V8 hidden-class shapes stable.
  • buildIndexKeySpace accumulates into a shared entries array — recursion passes the array down instead of spreading sub-results back up.
  • Fast path in jsonPointerToPath — skip the .map(replaceAll) decode pass entirely when the pointer contains no ~.
  • Fast path in isTopLevelComponent for string input — segment-count via indexOf instead of parsing the pointer into an array and discarding it.
  • Lazy Set allocation in collectPointerDependencies — switch subtreeDependencies / transitiveDependencies to Set<string> | null, allocate only when there's something to add, and use Map.has instead of Map.get truthy-checks so null results cache correctly.
  • Move patternProperties parsing before additionalProperties branch — lets the isPatternPropertiesEmpty flag participate in the isEmptyObjectInAllOf check.

Summary | 12 files | 11 commits | base: mainmore-performance-improvement


In-place push onto aliased required arrays

Before: spread allocated a fresh array, leaving the input spec untouched.
After: required.push(...) mutates whatever array irCompositionSchema.required (or inlineSchema.required) currently points at — which may be the source spec's array.

In parseType, irSchema.required = schema.required aliases the source schema's required array directly into the IR (no copy). When a $ref'd composition schema is later processed in parseAllOf and the parent schema.required is non-empty, the new irCompositionSchema.required.push(...schema.required) writes into the referenced spec node's required array. The same hazard applies to the discriminator inlineSchema.required.push(discriminator.propertyName) rewrites. The original spread ([...a, ...b]) avoided this by always producing a fresh array; the safe fix here is the same — either keep the spread on this hot path, or guard with a "clone if aliased" check.

packages/shared/src/openApi/3.1.x/parser/schema.ts · packages/shared/src/openApi/3.0.x/parser/schema.ts · packages/shared/src/openApi/2.0.x/parser/schema.ts


isTopLevelComponent string fast path and pointer encoding

Before: decoded the pointer through jsonPointerToPath and checked segment count.
After: counts / characters directly in the raw string.

The fast path treats encoded slashes (~1) inside a component name as real segment separators, so e.g. a hypothetical #/components/schemas/foo~1bar (name containing a /) now returns false where the array path returned true. The OpenAPI spec restricts component-key names to ^[a-zA-Z0-9.\-_]+$, so this is unreachable for conformant specs — flagging only because the previous behavior was documented as "decode then count" and this is a quiet semantic narrowing. Not worth fixing unless there's an appetite for an explicit guard.

packages/shared/src/utils/ref.ts

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using Claude Opus𝕏

if (schema.required) {
if (irCompositionSchema.required) {
irCompositionSchema.required = [...irCompositionSchema.required, ...schema.required];
irCompositionSchema.required.push(...schema.required);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

irCompositionSchema.required may alias the source spec's array — parseType does irSchema.required = schema.required (no copy) at line 476, so for a $ref'd compositionSchema the IR's required points into the input document. The old [...irCompositionSchema.required, ...schema.required] returned a fresh array; this push mutates the source spec's required array, which can corrupt the input on re-parse or affect other references to the same component. Either keep the spread, or clone before pushing: irCompositionSchema.required = irCompositionSchema.required.concat(schema.required).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Technically, this is OK, we don't use original required anywhere else but to turn them into irSchema.required. Also, the unit test says passed, so I expected this to be fine.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Fair point on tests passing. The residual concern is the multi-reference case: resolveRef returns the live spec node, so when one #/components/schemas/Foo is $ref'd from multiple parent allOfs, the first parent's push(...schema.required) writes into Foo.required in the spec, and the second parent then reads (and pushes onto) a polluted array. If your benchmark spec doesn't hit that shape it won't show up in tests. Happy to defer — flagging only so you know what to look for if you see surprising IR shapes later.

Pullfrog  | View workflow run | via Pullfrog | Using Claude Opus𝕏

if (schema.required) {
if (irCompositionSchema.required) {
irCompositionSchema.required = [...irCompositionSchema.required, ...schema.required];
irCompositionSchema.required.push(...schema.required);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Same aliasing concern as the 3.1.x version: irCompositionSchema.required can be the same array reference as the underlying spec's schema.required (assigned without copy in parseType). The previous spread allocated fresh; this push mutates the input spec. Suggest irCompositionSchema.required = irCompositionSchema.required.concat(schema.required) instead.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Technically, this is OK, we don't use original required anywhere else but to turn them into irSchema.required. Also, the unit test says passed, so I expected this to be fine.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Same context as the 3.1.x thread — the residual concern is a single $ref'd component used from multiple allOf parents, where the first push pollutes the spec node read by the second. Not blocking; deferring to you since tests pass.

Pullfrog  | View workflow run | via Pullfrog | Using Claude Opus𝕏

if (schema.required) {
if (irCompositionSchema.required) {
irCompositionSchema.required = [...irCompositionSchema.required, ...schema.required];
irCompositionSchema.required.push(...schema.required);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Same aliasing concern: 2.0.x's parseType also does irSchema.required = schema.required without copying (see line 251 of this file), so irCompositionSchema.required can alias the source spec. This push will mutate the input document. .concat(...) would preserve the perf intent while keeping the array fresh.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Technically, this is OK, we don't use original required anywhere else but to turn them into irSchema.required. Also, the unit test says passed, so I expected this to be fine.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Same context as the 3.1.x thread — the residual concern is a single $ref'd component used from multiple allOf parents, where the first push pollutes the spec node read by the second. Not blocking; deferring to you since tests pass.

Pullfrog  | View workflow run | via Pullfrog | Using Claude Opus𝕏

}
if (!inlineSchema.required.includes(discriminator.propertyName)) {
inlineSchema.required = [...inlineSchema.required, discriminator.propertyName];
inlineSchema.required.push(discriminator.propertyName);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

inlineSchema is the last object/properties-bearing entry in schemaItems, which can carry a required aliased to the source spec via parseType. The previous inlineSchema.required = [...inlineSchema.required, discriminator.propertyName] created a fresh array; this push mutates whatever inlineSchema.required currently points at, including potentially the input document. Use .concat([discriminator.propertyName]) if you want to keep allocation low without aliasing.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The same, we don't use the original input document anywhere else but to turn it into inlineSchema including inlineSchema.required. Also, the unit test says passed, so I expected this to be fine.

Also concat copies array so can't skip the allocation here.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

You're right that concat allocates — my suggestion didn't save anything over the original spread. Withdrawing the concat fix. The residual concern (inline schema's required may alias the spec and get polluted when the same composition is referenced again) stands, but it's narrow enough that deferring to your judgement here makes sense.

Pullfrog  | View workflow run | via Pullfrog | Using Claude Opus𝕏

}
if (!inlineSchema.required.includes(discriminator.propertyName)) {
inlineSchema.required = [...inlineSchema.required, discriminator.propertyName];
inlineSchema.required.push(discriminator.propertyName);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Same aliasing concern as the 3.1.x discriminator branch — inlineSchema.required may reference the source spec's array. Suggest .concat([discriminator.propertyName]) to keep the perf gain without risking mutation of the input document.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The same, we don't use the original input document anywhere else but to turn it into inlineSchema including inlineSchema.required. Also, the unit test says passed, so I expected this to be fine.

Also concat copies array so can't skip the allocation here.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

You're right that concat allocates — that suggestion didn't actually save anything over the original spread. Withdrawing it. Same residual aliasing concern as the 3.1.x discriminator thread, but narrow enough to defer.

Pullfrog  | View workflow run | via Pullfrog | Using Claude Opus𝕏

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented May 19, 2026

Open in StackBlitz

@hey-api/codegen-core

npm i https://pkg.pr.new/@hey-api/codegen-core@3917

@hey-api/json-schema-ref-parser

npm i https://pkg.pr.new/@hey-api/json-schema-ref-parser@3917

@hey-api/nuxt

npm i https://pkg.pr.new/@hey-api/nuxt@3917

@hey-api/openapi-ts

npm i https://pkg.pr.new/@hey-api/openapi-ts@3917

@hey-api/shared

npm i https://pkg.pr.new/@hey-api/shared@3917

@hey-api/spec-types

npm i https://pkg.pr.new/@hey-api/spec-types@3917

@hey-api/types

npm i https://pkg.pr.new/@hey-api/types@3917

@hey-api/vite-plugin

npm i https://pkg.pr.new/@hey-api/vite-plugin@3917

commit: 1dc3fa3

@mrlubos
Copy link
Copy Markdown
Member

mrlubos commented May 19, 2026

@pullfrog respond to the comments..

@pullfrog
Copy link
Copy Markdown
Contributor

pullfrog Bot commented May 19, 2026

Replied to all 5 review threads. Conceded concat doesn't save allocation over the original spread (withdrawing that suggestion), and narrowed the residual concern to the concrete multi-reference scenario where a single $ref'd component is composed into multiple parent allOfs — the first parent's push pollutes the spec node read by the next. Flagged as a watch-out rather than blocking; deferring to author since tests pass. Threads left unresolved for SukkaW to close.

Pullfrog  | View workflow run | via Pullfrog | Using Claude Opus𝕏

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

Labels

internal ⚙️ Internal development work. size:L This PR changes 100-499 lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants