mirror of
https://github.com/colinhacks/zod.git
synced 2026-10-28 08:35:41 +08:00
Preserve callsites in parse stack traces (#5910)
* fix(v4): preserve callsites in parse stack traces * fix(v4): await the async parse wrappers so the callee stays on the chain `Error.captureStackTrace(e, callee)` is skip-until-seen: V8 discards frames until it meets `callee`, and if it never meets it, it discards them all. An async function that returns its inner promise without awaiting has already left the async chain by the time the rejection is built, so its frame is not there to be found and `err.stack` comes back as the message line alone. That made all four async codec paths return an empty stack where main returned a usable one, which is the opposite of this branch's purpose. Instance `.parseAsync()` had the same defect on main already; it is fixed here too, since it is the same one-word change and the same invariant. The existing coverage could not catch this. An async function body runs synchronously up to its first `await`, so `z.string()` never suspends and its callee is always still on the chain. The new test uses an async refinement and an async codec, and fails without this fix. * test(treeshake): re-measure the ceilings against the current base The three raises were measured against a base five commits stale. Merging current main puts `zod-mini-object` at exactly its ceiling and the other two at 18 bytes, back in the 16-21 band this file's own comment records as having broken within a day. Re-measured on the merged tree: 2808 / 3128 / 4301, so the ceilings move to 28 bytes of headroom each, which is what the comment claims. * test(treeshake): attribute the ceiling raise to the right cause The comment booked the whole 2813 / 3130 / 4288 raise to the callee threading. Measured against the merge base, this branch costs +13 / +17 / +16; the remainder is drift on main since those numbers were last set, and main alone had already reached 4285 against its own 4288. A file whose job is recording what caused each raise should say which part was which.
This commit is contained in:
1 parent
2a41709765
commit
f101d8cada
6 files changed
+195
-52
No files matched your search
@@ -0,0 +1,8 @@
|
||||
import { build } from "esbuild";
|
||||
import { gzipSync } from "node:zlib";
|
||||
import path from "node:path";
|
||||
for (const f of ["zod-mini-boolean", "zod-mini-string", "zod-mini-object"]) {
|
||||
const r = await build({ entryPoints: [path.join("packages/treeshake", `${f}.ts`)], bundle: true, minify: true, format: "esm", write: false, logLevel: "silent" });
|
||||
const n = gzipSync(Buffer.from(r.outputFiles[0].contents), { level: 9 }).length;
|
||||
console.log(`${f.padEnd(20)} measured=${n} ceiling(+28)=${n + 28}`);
|
||||
}
|
||||
@@ -38,11 +38,12 @@ const BUILT_ENTRY = path.join(__dirname, "node_modules", "zod", "index.js");
|
||||
* The failure message prints the measured size, so updating is mechanical.
|
||||
*/
|
||||
const CEILINGS: Record<string, number> = {
|
||||
"zod-mini-boolean": 2813,
|
||||
// Raised from 3130 in the commit that caused it: string length checks now measure code points, so any bundle using `.min`/`.max`/`.length` on a string carries the surrogate scan. Measured 3257; 28 bytes of headroom to match the others. `zod-mini-object` does not move — a bundle with no length check still carries none of it.
|
||||
"zod-mini-string": 3285,
|
||||
// Raised from 4257 in the commit that caused it: the construction-time discriminator check writes a WeakMap entry from `$ZodObject`, so every bundle containing `z.object` carries it. Measured 4260; 28 bytes of headroom to match the other two.
|
||||
"zod-mini-object": 4288,
|
||||
// Raised from 2813 / 3285 / 4288 by the commit that caused it: threading a `callee` to `Error.captureStackTrace` from every throwing parse entry point costs +13 / +17 / +16, since a bundle that parses at all carries it. Measured 2808 / 3271 / 4301 here; 28 bytes of headroom each. The per-fixture notes below record what each ceiling already carried before this.
|
||||
"zod-mini-boolean": 2836,
|
||||
// Also carries the code-point string length scan: `.min`/`.max`/`.length` on a string pulls in the surrogate walk.
|
||||
"zod-mini-string": 3299,
|
||||
// Also carries the construction-time discriminator check, which writes a WeakMap entry from `$ZodObject`, so every bundle containing `z.object` pays for it whether or not it builds a discriminated union.
|
||||
"zod-mini-object": 4329,
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -36,25 +36,29 @@ export const safeParseAsync: <T extends core.$ZodType>(
|
||||
export const encode: <T extends core.$ZodType>(
|
||||
schema: T,
|
||||
value: core.output<T>,
|
||||
_ctx?: core.ParseContext<core.$ZodIssue>
|
||||
_ctx?: core.ParseContext<core.$ZodIssue>,
|
||||
_params?: { callee?: core.util.AnyFunc; Err?: core.$ZodErrorClass }
|
||||
) => core.input<T> = /* @__PURE__ */ core._encode(ZodRealError);
|
||||
|
||||
export const decode: <T extends core.$ZodType>(
|
||||
schema: T,
|
||||
value: core.input<T>,
|
||||
_ctx?: core.ParseContext<core.$ZodIssue>
|
||||
_ctx?: core.ParseContext<core.$ZodIssue>,
|
||||
_params?: { callee?: core.util.AnyFunc; Err?: core.$ZodErrorClass }
|
||||
) => core.output<T> = /* @__PURE__ */ core._decode(ZodRealError);
|
||||
|
||||
export const encodeAsync: <T extends core.$ZodType>(
|
||||
schema: T,
|
||||
value: core.output<T>,
|
||||
_ctx?: core.ParseContext<core.$ZodIssue>
|
||||
_ctx?: core.ParseContext<core.$ZodIssue>,
|
||||
_params?: { callee?: core.util.AnyFunc; Err?: core.$ZodErrorClass }
|
||||
) => Promise<core.input<T>> = /* @__PURE__ */ core._encodeAsync(ZodRealError);
|
||||
|
||||
export const decodeAsync: <T extends core.$ZodType>(
|
||||
schema: T,
|
||||
value: core.input<T>,
|
||||
_ctx?: core.ParseContext<core.$ZodIssue>
|
||||
_ctx?: core.ParseContext<core.$ZodIssue>,
|
||||
_params?: { callee?: core.util.AnyFunc; Err?: core.$ZodErrorClass }
|
||||
) => Promise<core.output<T>> = /* @__PURE__ */ core._decodeAsync(ZodRealError);
|
||||
|
||||
export const safeEncode: <T extends core.$ZodType>(
|
||||
|
||||
@@ -314,17 +314,32 @@ function _zodTypeParseProps(): _LazyPropsOf<ZodType> {
|
||||
return fn;
|
||||
},
|
||||
parseAsync: (self) => {
|
||||
const fn: ZodType["parseAsync"] = async (data, params) => parse.parseAsync(self, data, params, { callee: fn });
|
||||
const fn: ZodType["parseAsync"] = async (data, params) =>
|
||||
await parse.parseAsync(self, data, params, { callee: fn });
|
||||
return fn;
|
||||
},
|
||||
safeParse: (self) => (data, params) => parse.safeParse(self, data, params),
|
||||
safeParseAsync: (self) => async (data, params) => parse.safeParseAsync(self, data, params),
|
||||
// `spa` is an alias: same function object as `safeParseAsync`, as before.
|
||||
spa: (self) => self.safeParseAsync,
|
||||
encode: (self) => (data, params) => parse.encode(self, data, params),
|
||||
decode: (self) => (data, params) => parse.decode(self, data, params),
|
||||
encodeAsync: (self) => async (data, params) => parse.encodeAsync(self, data, params),
|
||||
decodeAsync: (self) => async (data, params) => parse.decodeAsync(self, data, params),
|
||||
encode: (self) => {
|
||||
const fn: ZodType["encode"] = (data, params) => parse.encode(self, data, params, { callee: fn });
|
||||
return fn;
|
||||
},
|
||||
decode: (self) => {
|
||||
const fn: ZodType["decode"] = (data, params) => parse.decode(self, data, params, { callee: fn });
|
||||
return fn;
|
||||
},
|
||||
encodeAsync: (self) => {
|
||||
const fn: ZodType["encodeAsync"] = async (data, params) =>
|
||||
await parse.encodeAsync(self, data, params, { callee: fn });
|
||||
return fn;
|
||||
},
|
||||
decodeAsync: (self) => {
|
||||
const fn: ZodType["decodeAsync"] = async (data, params) =>
|
||||
await parse.decodeAsync(self, data, params, { callee: fn });
|
||||
return fn;
|
||||
},
|
||||
safeEncode: (self) => (data, params) => parse.safeEncode(self, data, params),
|
||||
safeDecode: (self) => (data, params) => parse.safeDecode(self, data, params),
|
||||
safeEncodeAsync: (self) => async (data, params) => parse.safeEncodeAsync(self, data, params),
|
||||
|
||||
@@ -7,6 +7,36 @@ afterEach(() => {
|
||||
z.config({ customError: undefined });
|
||||
});
|
||||
|
||||
function getThrownError(fn: () => unknown): unknown {
|
||||
try {
|
||||
fn();
|
||||
} catch (error) {
|
||||
return error;
|
||||
}
|
||||
throw new Error("Expected function to throw");
|
||||
}
|
||||
|
||||
async function getRejectedError(fn: () => Promise<unknown>): Promise<unknown> {
|
||||
try {
|
||||
await fn();
|
||||
} catch (error) {
|
||||
return error;
|
||||
}
|
||||
throw new Error("Expected function to reject");
|
||||
}
|
||||
|
||||
function expectFirstStackFrameAtCallsite(error: unknown): void {
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
const stack = (error as Error).stack;
|
||||
expect(stack).toEqual(expect.any(String));
|
||||
|
||||
const firstFrame = stack!.split("\n").find((line) => line.trim().startsWith("at "));
|
||||
expect(firstFrame).toBeDefined();
|
||||
expect(firstFrame).toContain("error.test.ts");
|
||||
expect(firstFrame).not.toContain("core/parse.ts");
|
||||
expect(firstFrame).not.toContain("classic/schemas.ts");
|
||||
}
|
||||
|
||||
test("error creation", () => {
|
||||
const err1 = new z.ZodError([]);
|
||||
|
||||
@@ -721,6 +751,59 @@ test("error inheritance", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("parse errors capture the caller stack frame", async () => {
|
||||
const schema = z.string();
|
||||
const parse = schema.parse;
|
||||
const parseAsync = schema.parseAsync;
|
||||
|
||||
expectFirstStackFrameAtCallsite(getThrownError(() => schema.parse(123)));
|
||||
expectFirstStackFrameAtCallsite(getThrownError(() => parse(123)));
|
||||
expectFirstStackFrameAtCallsite(getThrownError(() => z.parse(schema, 123)));
|
||||
|
||||
expectFirstStackFrameAtCallsite(await getRejectedError(() => schema.parseAsync(123)));
|
||||
expectFirstStackFrameAtCallsite(await getRejectedError(() => parseAsync(123)));
|
||||
expectFirstStackFrameAtCallsite(await getRejectedError(() => z.parseAsync(schema, 123)));
|
||||
});
|
||||
|
||||
test("codec errors capture the caller stack frame", async () => {
|
||||
const schema = z.string();
|
||||
const encode = schema.encode;
|
||||
const decode = schema.decode;
|
||||
const encodeAsync = schema.encodeAsync;
|
||||
const decodeAsync = schema.decodeAsync;
|
||||
|
||||
expectFirstStackFrameAtCallsite(getThrownError(() => schema.encode(123 as any)));
|
||||
expectFirstStackFrameAtCallsite(getThrownError(() => encode(123 as any)));
|
||||
expectFirstStackFrameAtCallsite(getThrownError(() => z.encode(schema, 123 as any)));
|
||||
|
||||
expectFirstStackFrameAtCallsite(getThrownError(() => schema.decode(123 as any)));
|
||||
expectFirstStackFrameAtCallsite(getThrownError(() => decode(123 as any)));
|
||||
expectFirstStackFrameAtCallsite(getThrownError(() => z.decode(schema, 123 as any)));
|
||||
|
||||
expectFirstStackFrameAtCallsite(await getRejectedError(() => schema.encodeAsync(123 as any)));
|
||||
expectFirstStackFrameAtCallsite(await getRejectedError(() => encodeAsync(123 as any)));
|
||||
expectFirstStackFrameAtCallsite(await getRejectedError(() => z.encodeAsync(schema, 123 as any)));
|
||||
|
||||
expectFirstStackFrameAtCallsite(await getRejectedError(() => schema.decodeAsync(123 as any)));
|
||||
expectFirstStackFrameAtCallsite(await getRejectedError(() => decodeAsync(123 as any)));
|
||||
expectFirstStackFrameAtCallsite(await getRejectedError(() => z.decodeAsync(schema, 123 as any)));
|
||||
});
|
||||
|
||||
test("async errors that fail after suspending capture the caller stack frame", async () => {
|
||||
// `Error.captureStackTrace` is skip-until-seen: when it cannot find its callee it discards every frame, not just the ones above. An async wrapper that returns its inner promise without awaiting has already left the async chain by the time the rejection is built, so its callee is unfindable and the stack comes back empty. Only a schema that actually suspends reaches that path — the sync cases above run straight through to their first await and pass either way.
|
||||
const refined = z.string().refine(async () => false, "nope");
|
||||
const codec = z.codec(z.string(), z.number(), {
|
||||
decode: async (value) => value as never,
|
||||
encode: async (value) => value as never,
|
||||
});
|
||||
|
||||
expectFirstStackFrameAtCallsite(await getRejectedError(() => refined.parseAsync("x")));
|
||||
expectFirstStackFrameAtCallsite(await getRejectedError(() => codec.decodeAsync("x")));
|
||||
expectFirstStackFrameAtCallsite(await getRejectedError(() => z.decodeAsync(codec, "x")));
|
||||
expectFirstStackFrameAtCallsite(await getRejectedError(() => codec.encodeAsync(5)));
|
||||
expectFirstStackFrameAtCallsite(await getRejectedError(() => z.encodeAsync(codec, 5)));
|
||||
});
|
||||
|
||||
test("error serialization", () => {
|
||||
try {
|
||||
z.string().parse(123);
|
||||
|
||||
@@ -4,27 +4,36 @@ import type * as schemas from "./schemas.js";
|
||||
import * as util from "./util.js";
|
||||
|
||||
export type $ZodErrorClass = { new (issues: errors.$ZodIssue[]): errors.$ZodError };
|
||||
type $ParseParams = { callee?: util.AnyFunc; Err?: $ZodErrorClass | undefined };
|
||||
|
||||
// Always both keys, so the `_params` read site in `_parse` sees one object shape rather than two.
|
||||
function finalizeParams(callee: util.AnyFunc, params: $ParseParams | undefined): $ParseParams {
|
||||
return { callee: params?.callee ?? callee, Err: params?.Err };
|
||||
}
|
||||
|
||||
/////////// METHODS ///////////
|
||||
export type $Parse = <T extends schemas.$ZodType>(
|
||||
schema: T,
|
||||
value: unknown,
|
||||
_ctx?: schemas.ParseContext<errors.$ZodIssue>,
|
||||
_params?: { callee?: util.AnyFunc; Err?: $ZodErrorClass }
|
||||
_params?: $ParseParams
|
||||
) => core.output<T>;
|
||||
|
||||
export const _parse: (_Err: $ZodErrorClass) => $Parse = (_Err) => (schema, value, _ctx, _params) => {
|
||||
const ctx: schemas.ParseContextInternal = _ctx ? { ..._ctx, async: false } : { async: false };
|
||||
const result = schema._zod.run({ value, issues: [] }, ctx);
|
||||
if (result instanceof Promise) {
|
||||
throw new core.$ZodAsyncError();
|
||||
}
|
||||
if (result.issues.length) {
|
||||
const e = new (_params?.Err ?? _Err)(result.issues.map((iss) => util.finalizeIssue(iss, ctx, core.config())));
|
||||
util.captureStackTrace(e, _params?.callee);
|
||||
throw e;
|
||||
}
|
||||
return result.value as core.output<typeof schema>;
|
||||
export const _parse: (_Err: $ZodErrorClass) => $Parse = (_Err) => {
|
||||
const fn: $Parse = (schema, value, _ctx, _params) => {
|
||||
const ctx: schemas.ParseContextInternal = _ctx ? { ..._ctx, async: false } : { async: false };
|
||||
const result = schema._zod.run({ value, issues: [] }, ctx);
|
||||
if (result instanceof Promise) {
|
||||
throw new core.$ZodAsyncError();
|
||||
}
|
||||
if (result.issues.length) {
|
||||
const e = new (_params?.Err ?? _Err)(result.issues.map((iss) => util.finalizeIssue(iss, ctx, core.config())));
|
||||
util.captureStackTrace(e, _params?.callee ?? fn);
|
||||
throw e;
|
||||
}
|
||||
return result.value as core.output<typeof schema>;
|
||||
};
|
||||
return fn;
|
||||
};
|
||||
|
||||
export const parse: $Parse = /* @__PURE__*/ _parse(errors.$ZodRealError);
|
||||
@@ -33,19 +42,22 @@ export type $ParseAsync = <T extends schemas.$ZodType>(
|
||||
schema: T,
|
||||
value: unknown,
|
||||
_ctx?: schemas.ParseContext<errors.$ZodIssue>,
|
||||
_params?: { callee?: util.AnyFunc; Err?: $ZodErrorClass }
|
||||
_params?: $ParseParams
|
||||
) => Promise<core.output<T>>;
|
||||
|
||||
export const _parseAsync: (_Err: $ZodErrorClass) => $ParseAsync = (_Err) => async (schema, value, _ctx, params) => {
|
||||
const ctx: schemas.ParseContextInternal = _ctx ? { ..._ctx, async: true } : { async: true };
|
||||
let result = schema._zod.run({ value, issues: [] }, ctx);
|
||||
if (result instanceof Promise) result = await result;
|
||||
if (result.issues.length) {
|
||||
const e = new (params?.Err ?? _Err)(result.issues.map((iss) => util.finalizeIssue(iss, ctx, core.config())));
|
||||
util.captureStackTrace(e, params?.callee);
|
||||
throw e;
|
||||
}
|
||||
return result.value as core.output<typeof schema>;
|
||||
export const _parseAsync: (_Err: $ZodErrorClass) => $ParseAsync = (_Err) => {
|
||||
const fn: $ParseAsync = async (schema, value, _ctx, params) => {
|
||||
const ctx: schemas.ParseContextInternal = _ctx ? { ..._ctx, async: true } : { async: true };
|
||||
let result = schema._zod.run({ value, issues: [] }, ctx);
|
||||
if (result instanceof Promise) result = await result;
|
||||
if (result.issues.length) {
|
||||
const e = new (params?.Err ?? _Err)(result.issues.map((iss) => util.finalizeIssue(iss, ctx, core.config())));
|
||||
util.captureStackTrace(e, params?.callee ?? fn);
|
||||
throw e;
|
||||
}
|
||||
return result.value as core.output<typeof schema>;
|
||||
};
|
||||
return fn;
|
||||
};
|
||||
|
||||
export const parseAsync: $ParseAsync = /* @__PURE__*/ _parseAsync(errors.$ZodRealError);
|
||||
@@ -97,12 +109,17 @@ export const safeParseAsync: $SafeParseAsync = /* @__PURE__*/ _safeParseAsync(er
|
||||
export type $Encode = <T extends schemas.$ZodType>(
|
||||
schema: T,
|
||||
value: core.output<T>,
|
||||
_ctx?: schemas.ParseContext<errors.$ZodIssue>
|
||||
_ctx?: schemas.ParseContext<errors.$ZodIssue>,
|
||||
_params?: $ParseParams
|
||||
) => core.input<T>;
|
||||
|
||||
export const _encode: (_Err: $ZodErrorClass) => $Encode = (_Err) => (schema, value, _ctx) => {
|
||||
const ctx = _ctx ? { ..._ctx, direction: "backward" as const } : { direction: "backward" as const };
|
||||
return _parse(_Err)(schema, value, ctx as any) as any;
|
||||
export const _encode: (_Err: $ZodErrorClass) => $Encode = (_Err) => {
|
||||
const parse = _parse(_Err);
|
||||
const fn: $Encode = (schema, value, _ctx, _params) => {
|
||||
const ctx = _ctx ? { ..._ctx, direction: "backward" as const } : { direction: "backward" as const };
|
||||
return parse(schema, value, ctx as any, finalizeParams(fn, _params)) as any;
|
||||
};
|
||||
return fn;
|
||||
};
|
||||
|
||||
export const encode: $Encode = /* @__PURE__*/ _encode(errors.$ZodRealError);
|
||||
@@ -110,11 +127,16 @@ export const encode: $Encode = /* @__PURE__*/ _encode(errors.$ZodRealError);
|
||||
export type $Decode = <T extends schemas.$ZodType>(
|
||||
schema: T,
|
||||
value: core.input<T>,
|
||||
_ctx?: schemas.ParseContext<errors.$ZodIssue>
|
||||
_ctx?: schemas.ParseContext<errors.$ZodIssue>,
|
||||
_params?: $ParseParams
|
||||
) => core.output<T>;
|
||||
|
||||
export const _decode: (_Err: $ZodErrorClass) => $Decode = (_Err) => (schema, value, _ctx) => {
|
||||
return _parse(_Err)(schema, value, _ctx);
|
||||
export const _decode: (_Err: $ZodErrorClass) => $Decode = (_Err) => {
|
||||
const parse = _parse(_Err);
|
||||
const fn: $Decode = (schema, value, _ctx, _params) => {
|
||||
return parse(schema, value, _ctx, finalizeParams(fn, _params));
|
||||
};
|
||||
return fn;
|
||||
};
|
||||
|
||||
export const decode: $Decode = /* @__PURE__*/ _decode(errors.$ZodRealError);
|
||||
@@ -122,12 +144,17 @@ export const decode: $Decode = /* @__PURE__*/ _decode(errors.$ZodRealError);
|
||||
export type $EncodeAsync = <T extends schemas.$ZodType>(
|
||||
schema: T,
|
||||
value: core.output<T>,
|
||||
_ctx?: schemas.ParseContext<errors.$ZodIssue>
|
||||
_ctx?: schemas.ParseContext<errors.$ZodIssue>,
|
||||
_params?: $ParseParams
|
||||
) => Promise<core.input<T>>;
|
||||
|
||||
export const _encodeAsync: (_Err: $ZodErrorClass) => $EncodeAsync = (_Err) => async (schema, value, _ctx) => {
|
||||
const ctx = _ctx ? { ..._ctx, direction: "backward" as const } : { direction: "backward" as const };
|
||||
return _parseAsync(_Err)(schema, value, ctx as any) as any;
|
||||
export const _encodeAsync: (_Err: $ZodErrorClass) => $EncodeAsync = (_Err) => {
|
||||
const parseAsync = _parseAsync(_Err);
|
||||
const fn: $EncodeAsync = async (schema, value, _ctx, _params) => {
|
||||
const ctx = _ctx ? { ..._ctx, direction: "backward" as const } : { direction: "backward" as const };
|
||||
return (await parseAsync(schema, value, ctx as any, finalizeParams(fn, _params))) as any;
|
||||
};
|
||||
return fn;
|
||||
};
|
||||
|
||||
export const encodeAsync: $EncodeAsync = /* @__PURE__*/ _encodeAsync(errors.$ZodRealError);
|
||||
@@ -135,11 +162,16 @@ export const encodeAsync: $EncodeAsync = /* @__PURE__*/ _encodeAsync(errors.$Zod
|
||||
export type $DecodeAsync = <T extends schemas.$ZodType>(
|
||||
schema: T,
|
||||
value: core.input<T>,
|
||||
_ctx?: schemas.ParseContext<errors.$ZodIssue>
|
||||
_ctx?: schemas.ParseContext<errors.$ZodIssue>,
|
||||
_params?: $ParseParams
|
||||
) => Promise<core.output<T>>;
|
||||
|
||||
export const _decodeAsync: (_Err: $ZodErrorClass) => $DecodeAsync = (_Err) => async (schema, value, _ctx) => {
|
||||
return _parseAsync(_Err)(schema, value, _ctx);
|
||||
export const _decodeAsync: (_Err: $ZodErrorClass) => $DecodeAsync = (_Err) => {
|
||||
const parseAsync = _parseAsync(_Err);
|
||||
const fn: $DecodeAsync = async (schema, value, _ctx, _params) => {
|
||||
return await parseAsync(schema, value, _ctx, finalizeParams(fn, _params));
|
||||
};
|
||||
return fn;
|
||||
};
|
||||
|
||||
export const decodeAsync: $DecodeAsync = /* @__PURE__*/ _decodeAsync(errors.$ZodRealError);
|
||||
|
||||
Reference in new issue
Block a user