Skip to content

fix(deserialize)!: share resources instead of copying them per path - #220

Merged
tvdeyen merged 1 commit into
mainfrom
fix/deserialize-shared-references
Oct 8, 2026
Merged

tvdeyen merged 1 commit into
mainfrom
fix/deserialize-shared-references

Conversation

@mamhoff

@mamhoff mamhoff commented Oct 8, 2026

Copy link
Copy Markdown
Contributor

Summary

deserialize now materializes every included resource once and hands out
the 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) Map index (3.x was quadratic on large
documents).

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 included array (included.forEach(...)), so it was
O(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 the
cyclic graph. It did so by:

  1. dropping the cache, so resolveResource builds a new object every time
    a resource is referenced, and
  2. passing down a path set of ancestors, and stubbing ({ id }) only a
    reference that points back at one of them.

The path guard does stop infinite recursion, but it only rules out
ancestors. 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.product back-references,
taxon children/ancestors, menu node.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

const resolveResource = (index, cache, resource) => {
  const key = keyOf(resource)
  const cached = cache.get(key)
  if (cached) return cached

  const result = { ...resource.attributes, id: resource.id }
  cache.set(key, result) // register before descending: cycles resolve to `result`

  for (const [name, rel] of Object.entries(resource.relationships ?? {})) {
    result[name] = resolveRelationship(index, cache, rel?.data ?? null)
  }
  return result
}
  • One object per type:id, so the output is linear in the document size.
  • The Map index from 4.0.0 is kept, so lookups are O(1).
  • Recursion terminates on any cycle, because a resource being resolved is
    already in the cache.
  • The rest of the contract is unchanged:
    • single vs. array data;
    • attributes flattened with id;
    • absent targets become { id } stubs;
    • null linkage becomes null;
    • the input is cloned and never aliased;
    • references to the primary data (which is not in included) 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)

N K 3.0.0 time 3.0.0 objects 4.0.0 time 4.0.0 objects this PR time this PR objects
10 1 0.2 ms 62 1.0 ms 912 0.2 ms 62
10 2 0.2 ms 62 6.4 ms 17,612 0.2 ms 62
10 3 0.2 ms 62 32.8 ms 116,062 0.2 ms 62
12 4 0.3 ms 74 1,102 ms 3,825,518 0.3 ms 74
20 2 0.6 ms 122 1,514 ms 4,378,022 0.4 ms 122
20 3 0.7 ms 122 out of memory – 0.5 ms 122
40 2 1.6 ms 242 out of memory – 1.0 ms 242
60 3 2.6 ms 362 out of memory – 2.3 ms 362
500 0 143 ms 3,002 17.0 ms 4,502 16.7 ms 3,002
2,000 0 2,196 ms 12,002 42.0 ms 18,002 37.5 ms 12,002
5,000 0 7,359 ms 30,002 95.3 ms 45,002 86.2 ms 30,002
  • 4.0.0 grows exponentially with cross-links: 20 products with 3 related
    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.
  • 3.0.0 always builds the minimal graph, but it is quadratic in the size of
    included.
  • This PR builds the same minimal graph as 3.0.0 at 4.0.0's lookup speed.

Real-world responses

Document 3.0.0 4.0.0 this PR
CMS page with 9 product sliders (1.66 MB) deserialize 44 ms 350 ms 16 ms
objects 9,963 900,849 9,963
heap retained 2.9 MB 65.8 MB 2.7 MB
devalue output 1.18 MB 69.07 MB 1.18 MB
devalue time 35 ms 2,314 ms 44 ms
Main navigation menu (node.parent cycles) objects 860 67,054 860
devalue output 0.06 MB 2.75 MB 0.06 MB
Product thumbnail list objects 913 1,351 913

Specs

javascript/src/__tests__/deserialize.spec.ts. The 4 specs marked † fail
against 4.0.0; the linear-size one takes about 6 s to fail there.

  • cycles and shared references
    • † resolves back-references to the same shared object: in a taxonomy
      where root lists child and itself in children, root.children[0] === child
      and root.children[1] === root.
    • † materializes a resource referenced from many places only once: two
      products sharing a fragrance get the identical object.
    • terminates on cyclic documents and yields a graph structuredClone can
      copy
      (replaces "produces an acyclic graph that JSON.stringify can
      serialize").
    • stubs references back to the primary resource, which is not in included
      (A → B → A with A as data; still JSON-serializable).
    • The breadcrumb ancestor names/urlPaths spec is unchanged.
  • output size
    • † is linear in the number of resources for densely cross-linked
      documents
      : 9 products, each related to all others, each with a variant
      pointing back. Expects exactly 2 + 9 × 4 objects (4.0.0 builds ~10⁵).
    • † shares related products and variant back-references.
  • All other specs pass unchanged, including the end-to-end product 2333 graph
    (its only back-edge targets data, so it remains acyclic and
    JSON.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.

  • Unaffected: structuredClone, devalue (Nuxt payloads), Vue/React
    reactive state, and any walker that tracks visited objects.
  • Affected: JSON.stringify(result) and naive recursive walkers.
    Serialize a projection of the fields you need, or use a cycle-aware
    serializer.
  • Shared objects: mutating a resource is visible everywhere it is
    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

  • Keep the acyclic guarantee, but share non-ancestor references. The
    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.
  • Revert to 3.x. It is quadratic in included (7.4 s at 5,000 products).
    This PR keeps the Map.

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
mamhoff force-pushed the fix/deserialize-shared-references branch from c9129bb to 8e2d073 Compare October 8, 2026 07:13
@tvdeyen tvdeyen added the breaking-change Changes that need a major release label Oct 8, 2026
@tvdeyen
tvdeyen merged commit 0af5e01 into main Oct 8, 2026
12 checks passed
@tvdeyen
tvdeyen deleted the fix/deserialize-shared-references branch October 8, 2026 09:57
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).
@alchemycms-bot alchemycms-bot Bot mentioned this pull request Oct 27, 2026
@tvdeyen tvdeyen added the javascript Pull requests that update Javascript code label Oct 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

breaking-change Changes that need a major release javascript Pull requests that update Javascript code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants