fix(sanity-bridge): avoid combinatorial blow-up converting mutually-embedding types
While upgrading our studio from v4 to v5 we hit a complete freeze: opening any document with a portable text field would pin the main thread until the tab crashed. No errors, nothing in the console. We eventually traced it to sanitySchemaToPortableTextSchema.
The walk added in #2630 tracks ancestors per branch, so it only stops when a type repeats within its own chain. Our schema has ~30 block object types, and about a dozen of them embed the shared blockContent array again (accordions, quotes, cards and so on). With that shape the walk ends up visiting every possible path through the type graph, which grows factorially with the number of block objects. For us that was ~2.1s per conversion — and since PortableTextInput converts on render, the studio just locked up. A slightly more connected schema never finishes at all.
The fix relies on the fact that Schema.compile produces one canonical instance per named type [...] I added a small memo (scoped to one conversion call) keyed by the compiled type instance [...] which makes the walk linear in the size of the compiled schema.
A few things I was careful about:
- The memo key is the instance, not the name. Same-named inline declarations with different fields compile to distinct instances [...] the cases in
same-name-objects.test.ts are unaffected.
- Cycle detection via ancestor names is unchanged, and only completed expansions are memoized [...]
- All 15 existing tests pass without modification. [...] output is byte-identical.
Numbers: the new regression test (12 block objects sharing a named array type) runs in ~5ms with the fix. Without it, vitest times out [...] Against our production schema the conversion goes from 2,122ms to 1ms with identical output.
One semantic note worth flagging: a memoized expansion is computed under the ancestor chain of wherever it was first reached. [...] No existing test pins a case where the difference is observable.
The merged PR body ends with a generated-by footer; that's the one part not
to copy — the anti-patterns below ban it.
Why it's canonical: opens with the lived symptom, diagnoses with the actual
growth mechanism, states the fix as an insight about the platform
(Schema.compile canonical instances), the careful-abouts each pair a
decision with the test guarding it, numbers have before/after + methodology,
and it volunteers a semantic delta nobody would have caught in review.