Repository navigation
fix(deserialize)!: share resources instead of copying them per path - #220
Merged
Merged
Conversation
4.0.0 broke reference cycles by re-resolving a resource at every place it
was referenced and stubbing only references to its own ancestors. That
keeps the result acyclic, but makes its size proportional to the number
of paths through the relationship graph rather than the number of
resources. Once included resources reference each other (menu nodes and
their parent/children, pages and their ancestors, elements linking the
same pages) the output grows exponentially: a menu of 781 nodes, 4 levels
deep, deserializes to 1.8 million objects.
Materialize each `type:id` once instead, cache it before resolving its
relationships, and hand out the same object for every reference. The
`Map` index from 4.0.0 is kept, so lookups stay O(1) (3.x scanned
`included` linearly per reference, which is quadratic on large
documents).
BREAKING CHANGE: back-references between included resources are now the
same object rather than `{ id }` stubs, so the result may contain cycles
and is no longer guaranteed to be `JSON.stringify`-safe. structuredClone,
devalue and Vue/React state handle cycles. References to the primary
`data` resource are still `{ id }` stubs, as before.
mamhoff
force-pushed
the
fix/deserialize-shared-references
branch
from
October 8, 2026 07:13
c9129bb to
8e2d073
Compare
tvdeyen
approved these changes
Oct 8, 2026
tvdeyen
added a commit
that referenced
this pull request
Oct 16, 2026
🤖 I have created a release *beep* *boop* --- ## [5.0.0](package-v4.0.0...package-v5.0.0) (2026-10-08) ### ⚠ BREAKING CHANGES * **deserialize:** share resources instead of copying them per path ([#220](#220)) * **deserialize:** back-references between included resources are now the same object rather than `{ id }` stubs, so the result may contain cycles and is no longer guaranteed to be `JSON.stringify`-safe. structuredClone, devalue and Vue/React state handle cycles. References to the primary `data` resource are still `{ id }` stubs, as before. ### Bug Fixes * **deserialize:** share resources instead of copying them per path ([8e2d073](8e2d073)) * **deserialize:** share resources instead of copying them per path ([#220](#220)) ([0af5e01](0af5e01)) --- This PR was generated with [Release Please](https://github.com/googleapis/release-please). See [documentation](https://github.com/googleapis/release-please#release-please).
Closed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
deserializenow materializes every included resource once and hands outthe same object wherever it is referenced. This fixes exponential output growth
introduced in 4.0.0 by #200 ("break reference cycles to prevent stack
overflow"), while keeping 4.0.0's O(1)
Mapindex (3.x was quadratic on largedocuments).
This is a breaking change: back-references between included resources are
real references again, so the result may contain cycles and is no longer
guaranteed to be
JSON.stringify-safe.The problem
3.x: shared objects, but quadratic lookups
3.x cached each resource (
included.cached[type][id]) and returned the cached,in-progress object on any later reference. That gave a minimal graph (one
object per resource), with real cycles. But it resolved every relationship by
scanning the whole
includedarray (included.forEach(...)), so it wasO(references × included). On a 2,000-product document with no
cross-references at all it takes 2.2 s, and 7.4 s at 5,000.
4.0.0: O(1) lookups, but exponential output
#200 fixed a stack overflow when consumers walked or
JSON.stringify'd thecyclic graph. It did so by:
resolveResourcebuilds a new object every timea resource is referenced, and
pathset of ancestors, and stubbing ({ id }) only areference that points back at one of them.
The
pathguard does stop infinite recursion, but it only rules outancestors. Any resource reachable along two different paths is built twice,
and its whole subtree with it. The output therefore grows with the number of
simple paths through the relationship graph, not the number of resources.
That is harmless for trees, but exponential as soon as included resources
reference each other: related products,
variant.productback-references,taxon
children/ancestors, menunode.parent.A real CMS page with 9 product sliders (63 products that list each other as
related products, 205 variants pointing back at their product) is a 1.66 MB
response. 4.0.0 turns it into 900,849 objects. Through Nuxt's devalue that
became a 69 MB
_payload.json.The fix
type:id, so the output is linear in the document size.Mapindex from 4.0.0 is kept, so lookups are O(1).already in the cache.
data;id;{ id }stubs;nulllinkage becomesnull;data(which is not inincluded) remain{ id }stubs, as in both 3.x and 4.0.0.Performance
All numbers: Node 24, Linux x64, median of 5 runs after a warm-up, 4 GB heap.
"Objects" is the number of distinct objects reachable from the result.
Synthetic: N products × K related products × 3 variants (
variant.product→ product)products each exhaust a 4 GB heap. Even with no product cross-links (K = 0)
it builds 50% more objects than necessary, because each variant gets its own
copy of its product.
included.Real-world responses
node.parentcycles)Specs
javascript/src/__tests__/deserialize.spec.ts. The 4 specs marked † failagainst 4.0.0; the linear-size one takes about 6 s to fail there.
cycles and shared referenceswhere root lists child and itself in
children,root.children[0] === childand
root.children[1] === root.products sharing a fragrance get the identical object.
structuredClonecancopy (replaces "produces an acyclic graph that JSON.stringify can
serialize").
(A → B → A with A as
data; still JSON-serializable).output sizedocuments: 9 products, each related to all others, each with a variant
pointing back. Expects exactly
2 + 9 × 4objects (4.0.0 builds ~10⁵).(its only back-edge targets
data, so it remains acyclic andJSON.stringify-able).pnpm test -- --run: 20/20 ·pnpm typecheck: clean ·pnpm lint: clean ·pnpm build:dist/regenerated and committed.Breaking change and migration
The result may contain cycles when included resources reference each other.
structuredClone, devalue (Nuxt payloads), Vue/Reactreactive state, and any walker that tracks visited objects.
JSON.stringify(result)and naive recursive walkers.Serialize a projection of the fields you need, or use a cycle-aware
serializer.
referenced. Copy before mutating.
The stack overflow from #200 came from consumers walking the cyclic graph
without tracking visited objects. That is now documented in the README and the
module doc, rather than avoided by duplicating resources.
Alternatives considered
result is still acyclic and linear, but whether a given reference is a stub
or a full object then depends on traversal order:
a.relatedProducts[0]may be the full product B, or a stub, depending on which slider was visited
first. That is a subtle, data-dependent contract; rejected.
included(7.4 s at 5,000 products).This PR keeps the
Map.