Skip to content

Commit d918c9f

Browse files
authored
fix(spec): keep lazySchema proxies identity-compatible with z.toJSONSchema (objectui#2561) (#3021)
1 parent 663e7d6 commit d918c9f

5 files changed

Lines changed: 162 additions & 0 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
fix(spec): keep `lazySchema` proxies identity-compatible with `z.toJSONSchema` (objectui#2561)
6+
7+
zod's `toJSONSchema` keys its `seen` map on the node object it traverses — the `lazySchema` Proxy wherever a schema is referenced lazily (`z.lazy(() => X)` recursion getters, direct conversion roots) — while its wrapper-type processors (pipe/lazy/optional/default/…) look themselves up via the REAL instance captured at construction (`inst._zod.processJSONSchema = (ctx, …) => pipeProcessor(inst, …)`). The identity mismatch crashed conversion with `Cannot set properties of undefined (setting 'ref')`.
8+
9+
This stayed latent while lazy-referenced schemas were plain objects (the object processor never looks itself up); ADR-0089 D3a turned `PageComponentSchema` / `FormFieldSchema` into `.strict().transform(…)` **pipes**, which broke ObjectUI Studio's spec-derived Page/View inspector JSONSchema derivation under spec 15.
10+
11+
Fix: the proxy now serves a memoised `_zod` facade that prototype-delegates to the real internals and wraps only `processJSONSchema` to alias the proxy's `seen` entry onto the real instance before delegating. Parse behavior is unchanged; `OS_EAGER_SCHEMAS=1` remains the bypass. Regression tests cover the D3a pipe shape, recursion through `z.lazy(() => proxy)`, mixed proxy+real traversal, and the full `PageSchema` / `ViewSchema` Studio derivation paths.

packages/spec/src/shared/lazy-schema.test.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,3 +56,64 @@ describe('lazySchema', () => {
5656
}
5757
});
5858
});
59+
60+
/**
61+
* zod's `toJSONSchema` keys its `seen` map on the traversed node (the Proxy),
62+
* while its wrapper-type processors (pipe/lazy/optional/…) look themselves up
63+
* via the construction-time REAL instance. Without the `_zod` facade aliasing
64+
* the two identities, any lazySchema wrapping a non-object root — e.g. the
65+
* ADR-0089 D3a `.strict().transform(…)` pipes — crashed with
66+
* `Cannot set properties of undefined (setting 'ref')` (objectui#2561).
67+
*/
68+
describe('lazySchema × z.toJSONSchema identity', () => {
69+
const TO_JSON = { io: 'input', unrepresentable: 'any' } as const;
70+
71+
it('converts a lazy `.strict().transform(…)` pipe (ADR-0089 D3a shape)', () => {
72+
const schema = lazySchema(() =>
73+
z.object({ name: z.string() }).strict().transform((v) => v),
74+
);
75+
const json = z.toJSONSchema(schema, TO_JSON) as Record<string, any>;
76+
expect(json.properties?.name).toBeDefined();
77+
});
78+
79+
it('converts recursion reaching the pipe through `z.lazy(() => proxy)` (FormFieldSchema shape)', () => {
80+
const NodeSchema: z.ZodType<any> = lazySchema(() =>
81+
z
82+
.object({
83+
field: z.string(),
84+
fields: z.array(z.lazy(() => NodeSchema)).optional(),
85+
})
86+
.strict()
87+
.transform((v) => v),
88+
);
89+
const json = z.toJSONSchema(NodeSchema, TO_JSON);
90+
expect(JSON.stringify(json)).toContain('field');
91+
});
92+
93+
it('does not crash when one conversion sees both the proxy and the real instance', () => {
94+
const Leaf: z.ZodType<any> = lazySchema(() =>
95+
z.object({ id: z.string() }).strict().transform((v) => v),
96+
);
97+
// `.optional()` resolves through the proxy and captures the REAL pipe as
98+
// its innerType; the `z.lazy` getter hands zod the PROXY — one traversal
99+
// meets both identities in either order.
100+
const DocA = z.object({
101+
a: (Leaf as any).optional(),
102+
b: z.lazy(() => Leaf),
103+
});
104+
const DocB = z.object({
105+
b: z.lazy(() => Leaf),
106+
a: (Leaf as any).optional(),
107+
});
108+
for (const Doc of [DocA, DocB]) {
109+
const json = z.toJSONSchema(Doc, TO_JSON) as Record<string, any>;
110+
expect(json.properties?.a).toBeDefined();
111+
expect(json.properties?.b).toBeDefined();
112+
}
113+
});
114+
115+
it('memoises the `_zod` facade (identity-stable across accesses)', () => {
116+
const schema = lazySchema(() => z.object({ x: z.number() }).strict().transform((v) => v));
117+
expect((schema as any)._zod).toBe((schema as any)._zod);
118+
});
119+
});

packages/spec/src/shared/lazy-schema.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,57 @@ export function lazySchema<T extends z.ZodTypeAny>(factory: () => T): T {
3131

3232
const target = function lazyZod() {} as unknown as T;
3333

34+
/**
35+
* Memoised `_zod` facade (one per proxy — `_zod.run` is the parse hot path).
36+
*
37+
* Why it exists: zod's `toJSONSchema` traversal keys its `seen` map on the
38+
* node object it was handed — this Proxy, wherever the schema is referenced
39+
* lazily (a `z.lazy(() => X)` recursion getter, or a direct conversion
40+
* root). But zod's per-type JSON-Schema hooks close over the REAL instance
41+
* at construction time (`inst._zod.processJSONSchema = (ctx, …) =>
42+
* pipeProcessor(inst, ctx, …)`), and the wrapper-type processors
43+
* (pipe/lazy/optional/default/…) then resolve `ctx.seen.get(inst)` — which
44+
* misses when the entry was keyed on the Proxy, crashing with
45+
* `Cannot set properties of undefined (setting 'ref')`. Plain-object
46+
* schemas never look themselves up, which kept this latent until ADR-0089
47+
* D3a turned FormFieldSchema / PageComponentSchema into
48+
* `.strict().transform(…)` pipes.
49+
*
50+
* The facade prototype-delegates every `_zod` read to the real internals,
51+
* wrapping only `processJSONSchema` to alias the Proxy's `seen` entry onto
52+
* the real instance before delegating, so both identities resolve to the
53+
* same entry. If the real instance was already traversed under its own
54+
* identity it keeps its entry (alias, never clobber).
55+
*/
56+
let zodFacade: object | undefined;
57+
const makeZodFacade = (real: T): object | undefined => {
58+
const realZod = (real as unknown as { _zod?: Record<string, unknown> })._zod;
59+
if (!realZod || typeof realZod.processJSONSchema !== 'function') {
60+
return realZod;
61+
}
62+
const delegate = realZod.processJSONSchema as (...a: unknown[]) => unknown;
63+
return Object.create(realZod as object, {
64+
processJSONSchema: {
65+
enumerable: true,
66+
value: (ctx: { seen?: Map<unknown, unknown> }, json: unknown, params: unknown) => {
67+
const seen = ctx?.seen;
68+
if (seen && typeof seen.get === 'function') {
69+
const entry = seen.get(proxy);
70+
if (entry !== undefined && !seen.has(real)) seen.set(real, entry);
71+
}
72+
return delegate(ctx, json, params);
73+
},
74+
},
75+
}) as object;
76+
};
77+
3478
const proxy = new Proxy(target as object, {
3579
get(_t, prop) {
3680
const real = resolve() as unknown as Record<PropertyKey, unknown>;
81+
if (prop === '_zod') {
82+
zodFacade ??= makeZodFacade(real as unknown as T);
83+
return zodFacade;
84+
}
3785
const value = real[prop];
3886
if (typeof value === 'function') {
3987
return (value as (...a: unknown[]) => unknown).bind(real);

packages/spec/src/ui/page.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { describe, it, expect } from 'vitest';
2+
import { z } from 'zod';
23
import {
34
PageSchema,
45
PAGE_TYPE_ROADMAP,
@@ -1043,3 +1044,25 @@ describe('ADR-0089 D3a — strict page component schema (loud mis-layered keys)'
10431044
expect(res.success).toBe(false);
10441045
});
10451046
});
1047+
1048+
/**
1049+
* ObjectUI Studio derives the Page inspector's authoring JSONSchema straight
1050+
* from these zod schemas via `z.toJSONSchema` (objectui#2561). The lazySchema
1051+
* proxy must stay identity-compatible with zod's converter — the D3a
1052+
* `.strict().transform(…)` pipe on PageComponentSchema is exactly the shape
1053+
* that crashed it before the `_zod` facade fix.
1054+
*/
1055+
describe('PageSchema → JSON Schema derivation (Studio inspector path)', () => {
1056+
const TO_JSON = { io: 'input', unrepresentable: 'any' } as const;
1057+
1058+
it('derives the input-io JSONSchema for the whole Page document', () => {
1059+
const json = z.toJSONSchema(PageSchema, TO_JSON);
1060+
const text = JSON.stringify(json);
1061+
expect(text).toContain('interfaceConfig');
1062+
expect(text).toContain('regions');
1063+
});
1064+
1065+
it('derives the input-io JSONSchema for a bare PageComponent', () => {
1066+
expect(() => z.toJSONSchema(PageComponentSchema, TO_JSON)).not.toThrow();
1067+
});
1068+
});

packages/spec/src/ui/view.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2641,3 +2641,22 @@ describe('ADR-0089 D3a — strict view form schemas (loud mis-layered keys)', ()
26412641
expect(() => FormSectionSchema.parse({ label: 'S', visibleOn: 'record.a == 1', fields: [] })).not.toThrow();
26422642
});
26432643
});
2644+
2645+
/**
2646+
* ObjectUI Studio derives the View inspector's authoring JSONSchema from
2647+
* ViewSchema via `z.toJSONSchema` (objectui#2561). FormFieldSchema is a
2648+
* self-recursive `.strict().transform(…)` pipe reached through
2649+
* `z.lazy(() => FormFieldSchema)` — the exact lazySchema-proxy identity shape
2650+
* that crashed zod's converter before the `_zod` facade fix.
2651+
*/
2652+
describe('ViewSchema → JSON Schema derivation (Studio inspector path)', () => {
2653+
const TO_JSON = { io: 'input', unrepresentable: 'any' } as const;
2654+
2655+
it('derives the input-io JSONSchema for the whole View document', () => {
2656+
expect(() => z.toJSONSchema(ViewSchema, TO_JSON)).not.toThrow();
2657+
});
2658+
2659+
it('derives the input-io JSONSchema for the recursive FormFieldSchema', () => {
2660+
expect(() => z.toJSONSchema(FormFieldSchema, TO_JSON)).not.toThrow();
2661+
});
2662+
});

0 commit comments

Comments
 (0)