Skip to content

Perf/large spec rendering - #2834

Closed
mtoutcalt wants to merge 3 commits into
Redocly:mainfrom
mtoutcalt:perf/large-spec-rendering
Closed

Perf/large spec rendering#2834
mtoutcalt wants to merge 3 commits into
Redocly:mainfrom
mtoutcalt:perf/large-spec-rendering

Conversation

@mtoutcalt

Copy link
Copy Markdown

What/Why/How?

Reference

Tests

Screenshots (optional)

Check yourself

  • Code is linted
  • Tested
  • All new/updated code is covered with tests

mtoutcalt and others added 3 commits August 28, 2026 20:33
The lint script single-quoted its glob. npm run executes scripts through
cmd.exe on Windows, which does not treat single quotes as quoting, so eslint
received the literal string 'src/**/*.{js,ts,tsx}' -- quote characters
included -- matched no files and exited non-zero.

That made the husky pre-commit hook fail, blocking every commit made from a
Windows checkout. Double quotes are stripped by cmd.exe and by POSIX shells
alike, so this is a no-op for existing Unix contributors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Redoc's cost was proportional not to spec size but to the number of distinct
paths through the reference graph, which grows multiplicatively with reference
depth. A 70 KB generated spec allocated 1.2 GB; a 0.29 MB one exhausted a 3 GB
heap outright with "Ineffective mark-compacts near heap limit".

Three independent causes, all measured:

1. Schema expansion was eager. SchemaModel's constructor called buildFields,
   which built a FieldModel per property, which built another SchemaModel,
   terminating only on the refsStack cycle check. Since refsStack tracks the
   current path, a schema reachable by N paths was materialised N times, each
   fully expanded. Nothing needed that work up front: the renderer shows one
   level at a time and only expands a row on demand. `fields` is now a lazy
   getter. 199,132 -> 766 models on the tiny preset.

2. Payload samples were eager. Every media type of every operation generated a
   sample at construction, walking to generatedSamplesMaxDepth (10) and
   retaining the result. hasSample made it worse by answering a yes/no question
   through `.examples`, forcing generation for the whole document. Generation is
   now deferred and hasSample consults a cheap predicate.

3. Ref stacks compounded. mergeAllOf stamps x-refsStack onto every merged
   property and deref concatenates that stored stack onto the current path, so
   length grew multiplicatively rather than with depth -- observed max depth
   10,546 against a MAX_DEREF_DEPTH guard of 999, and 147M array entries
   allocated. refsStack is only consulted via includes() and a length check, so
   duplicates carry no information; deduping is semantics-preserving and takes
   it to 4.7M entries, max depth 12.

Adds a stress-spec generator and a measurement CLI. Use the CLI rather than the
jest suite when chasing a regression: jest and jsdom add roughly 10x overhead,
enough to hide which layer is actually slow.

Still outstanding: mergeAllOf is eager and re-walks the graph (871,763 calls for
3,836 models). Memoising does not help -- only 7.5% of calls repeat a key. The
medium preset remains blocked on it, marked with a skipped test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Redoc mounts every operation into the DOM on load, so opening a large spec
builds and retains models and payload samples for the entire API before the
reader has looked at any of it. focusMode makes the sidebar selection drive a
single rendered section, so cost tracks what is on screen.

Selecting an operation renders that operation. Selecting a tag renders its
description plus links to its operations rather than expanding them -- a tag
with 200 operations would otherwise reintroduce the problem. Rendering those
links is free: it reads sidebarLabel and id, never the schemas.

Scroll spy is disabled in this mode; only one section is mounted, so there is
nothing to track and leaving it running would fight the menu by reactivating
whatever is on screen. scrollToActive goes to the top of the content pane
instead of to an element, since activation is what causes the section to render
and there is no target at the time the scroll is requested. Deep links, search
and history are unchanged -- they all route through MenuStore.activate.

On the small preset with samples on (what a browser actually does):

    render everything    3836 models   12.5 s   1142 MB
    focus mode             54 models   0.08 s      9 MB

Focus mode does not rescue the deepest specs. On the large preset, building a
single operation still exhausts a 4 GB heap, because the outstanding mergeAllOf
cost is per schema and driven by depth, not by how many operations render.
Bounding what renders is necessary but not sufficient. Documented in
docs/large-spec-performance.md.

Verified with jsdom render tests, not in a real browser.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@mtoutcalt
mtoutcalt requested review from a team as code owners August 29, 2026 00:39
@mtoutcalt mtoutcalt closed this Aug 29, 2026
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.

1 participant