Skip to content

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

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

perf(shared/codegen-core): few small performance improvements#3917
SukkaW wants to merge 13 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

🦋 Changeset detected

Latest commit: ec2bdba

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 4 packages
Name Type
@hey-api/codegen-core Patch
@hey-api/shared Patch
@hey-api/openapi-python Patch
@hey-api/openapi-ts Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another 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.76%. Comparing base (2702ce2) to head (ec2bdba).
⚠️ Report is 1 commits behind head on main.

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.75%   37.76%   +0.01%     
==========================================
  Files         582      582              
  Lines       20817    20853      +36     
  Branches     6064     6076      +12     
==========================================
+ Hits         7859     7875      +16     
- Misses      10543    10564      +21     
+ Partials     2415     2414       -1     
Flag Coverage Δ
unittests 37.76% <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𝕏

Comment thread packages/shared/src/openApi/3.1.x/parser/schema.ts
Comment thread packages/shared/src/openApi/3.0.x/parser/schema.ts
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𝕏

Comment thread packages/shared/src/openApi/3.1.x/parser/schema.ts
Comment thread packages/shared/src/openApi/3.0.x/parser/schema.ts
@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: ec2bdba

@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𝕏

@dosubot dosubot Bot added the lgtm This PR has been approved by a maintainer label May 23, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

internal ⚙️ Internal development work. lgtm This PR has been approved by a maintainer 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