diff --git a/packages/compiler/src/backend/llvm/expr-stream-bridges.ts b/packages/compiler/src/backend/llvm/expr-stream-bridges.ts index 1acc75cec..d6d5a5b7c 100644 --- a/packages/compiler/src/backend/llvm/expr-stream-bridges.ts +++ b/packages/compiler/src/backend/llvm/expr-stream-bridges.ts @@ -993,6 +993,25 @@ export function streamTypedRefMaterializeAdapter( : `call void @scr_error_dyn_place_name(ptr ${out}, ptr ${host.cstr(nameAfter)}, ${host.sizeType} ${Buffer.byteLength(nameAfter, "utf8")})`, ); } + // A JavaScript Error subclass's property bag records, once a dynamic + // write has committed the view, where `name` and `message` were created + // (zod's error factory assigns `name` before it defines `message`); the + // slots hold their values. + if ( + meta.def.errorBagOrder && + meta.root.def.name === "%Error" && + meta.def.fields.some((field) => field.name === DYN_CLASS_PROPERTIES) + ) { + host.declare(`declare void @scr_error_dyn_follow_bag(ptr, ptr, ptr)`); + const { index } = classFieldIndex(meta, DYN_CLASS_PROPERTIES); + const slot = B.tmp(), + bag = B.tmp(); + B.line( + `${slot} = getelementptr inbounds %${classStructSym(t.className)}, ptr %p, i64 0, i32 ${index}`, + ); + B.line(`${bag} = load ptr, ptr ${slot}`); + B.line(`call void @scr_error_dyn_follow_bag(ptr ${out}, ptr ${bag}, ptr %p)`); + } // A frozen, sealed or non-extensible instance's view says so. if (withAttributes) { host.declare(`declare void @scr_dyn_class_view_attributes(ptr, double)`); diff --git a/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts b/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts index abf03811e..471632408 100644 --- a/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts +++ b/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts @@ -1634,6 +1634,15 @@ export class ClassDynamicDispatch { if (this.deletedNames === undefined) this.deletedNames = deletedPropertyNames(lowerer.moduleOrder); if (!root.base?.def.runtime && !fixedFieldFamily(root, this.deletedNames)) track(root); + // An Error family's bag records where `name` and `message` were + // created, which the error slots do not. + if (root.base?.def.name === "%Error") { + const order = (current: ClassInfo): void => { + current.def.errorBagOrder = true; + current.subclasses.forEach(order); + }; + order(info); + } } const loc = this.propertyBag.loc; const { receiver, condition } = this.propertyReceiver(plan, loc); diff --git a/packages/compiler/src/ir/ir.ts b/packages/compiler/src/ir/ir.ts index 550333edd..534911042 100644 --- a/packages/compiler/src/ir/ir.ts +++ b/packages/compiler/src/ir/ir.ts @@ -1499,6 +1499,10 @@ export interface IrClassDef { * class of the chain sets `name` during construction, and then an own * `name` follows every other own property. */ errorNameAfter?: string | null; + /** JavaScript Error subclasses with a property bag: a view committed by a + * dynamic write keeps `name` and `message` where the bag recorded them + * (scr_error_dyn_follow_bag). */ + errorBagOrder?: boolean; /** Stable module symbol identities for public instance layout fields. */ symbolFields?: { field: string; globalId: string }[]; /** RUNTIME-PROVIDED class (the builtin Error hierarchy): the struct, RC diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8112-error-factory-own-key-order.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8112-error-factory-own-key-order.js.json new file mode 100644 index 000000000..4b64d1d75 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8112-error-factory-own-key-order.js.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8112-error-factory-own-key-order.js" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/npm/cases/hono-zod-default-hook/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/npm/cases/hono-zod-default-hook/main.ts.json new file mode 100644 index 000000000..2310d6e0f --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/npm/cases/hono-zod-default-hook/main.ts.json @@ -0,0 +1,61 @@ +{ + "order": [ + "/tests/fixtures/npm/cases/hono-zod-default-hook/main.ts" + ], + "diags": [ + { + "code": "SC0001", + "message": "Cannot find name 'WebSocket'.", + "loc": { + "file": "/tests/fixtures/npm/node_modules/hono/dist/types/client/types.d.ts", + "start": 1398, + "end": 1407 + } + }, + { + "code": "SC0001", + "message": "Cannot find name 'WebSocket'.", + "loc": { + "file": "/tests/fixtures/npm/node_modules/hono/dist/types/client/types.d.ts", + "start": 1413, + "end": 1422 + } + }, + { + "code": "SC0001", + "message": "Cannot find name 'WebSocket'.", + "loc": { + "file": "/tests/fixtures/npm/node_modules/hono/dist/types/client/types.d.ts", + "start": 3716, + "end": 3725 + } + }, + { + "code": "SC0001", + "message": "Cannot find name 'FormData'.", + "loc": { + "file": "/tests/fixtures/npm/node_modules/hono/dist/types/client/types.d.ts", + "start": 4798, + "end": 4806 + } + }, + { + "code": "SC0001", + "message": "Cannot find name 'FormData'.", + "loc": { + "file": "/tests/fixtures/npm/node_modules/hono/dist/types/request.d.ts", + "start": 549, + "end": 557 + } + }, + { + "code": "SC0001", + "message": "Cannot find name 'FormData'.", + "loc": { + "file": "/tests/fixtures/npm/node_modules/hono/dist/types/request.d.ts", + "start": 5572, + "end": 5580 + } + } + ] +} diff --git a/packages/runtime/src/scr_dyn.h b/packages/runtime/src/scr_dyn.h index b1d052356..b743aaf0f 100644 --- a/packages/runtime/src/scr_dyn.h +++ b/packages/runtime/src/scr_dyn.h @@ -1330,6 +1330,10 @@ ScrDyn *scr_dyn_from_error(const ScrError *e); void scr_error_register_dyn(const ScrVt *vt, ScrDyn *(*box)(void *)); ScrDyn *scr_error_dyn_fields(const ScrError *e); void scr_error_dyn_place_name(ScrDyn *view, const char *after, size_t after_len); +/* An Error subclass view of an instance whose class keeps a property bag: + * `name` and `message` take their committed positions, and data values + * from the slots (scr_json.c). */ +void scr_error_dyn_follow_bag(ScrDyn *view, const ScrDyn *bag, const ScrError *e); /* An accessor property of a fresh object: enumerable and configurable, * with the given getter and setter (either may be NULL). Moves both. */ void scr_dyn_obj_set_accessor(ScrDyn *obj, const char *key, size_t key_len, ScrDyn *getter, ScrDyn *setter); diff --git a/packages/runtime/src/scr_json.c b/packages/runtime/src/scr_json.c index 35e1d7674..2086ab5ee 100644 --- a/packages/runtime/src/scr_json.c +++ b/packages/runtime/src/scr_json.c @@ -8310,6 +8310,63 @@ void scr_error_dyn_place_name(ScrDyn *view, const char *after, size_t after_len) view->v.obj.index = NULL; } +static size_t scr_dyn_entry_index(const ScrDyn *object, const char *key, size_t key_len) { + for (size_t i = 0; i < object->v.obj.len; i++) { + const ScrDynEntry *entry = &object->v.obj.entries[i]; + if (entry->key_len == key_len && memcmp(entry->key, key, key_len) == 0) return i; + } + return SIZE_MAX; +} + +/* The bag holds the view as it was last committed, in the order its + * properties were created: zod's error factory assigns `name` and then + * defines `message`, and Node lists them in that order. The slots rebuild + * both at fixed positions (scr_error_dyn_fields, scr_error_dyn_place_name), + * so each one present in the bag moves to follow the property it follows + * there. A typed write to `name` or `message` reaches only the slot, so a + * data property takes the slot's value. */ +void scr_error_dyn_follow_bag(ScrDyn *view, const ScrDyn *bag, const ScrError *e) { + if (!bag || bag->kind != SCR_DYN_OBJ || view->kind != SCR_DYN_OBJ) return; + static const struct { const char *key; size_t len; } slots[] = {{"name", 4}, {"message", 7}}; + for (size_t s = 0; s < 2; s++) { + bool name = s == 0; + if (!(name ? e->name_present : e->message_present)) continue; + size_t at = scr_dyn_entry_index(view, slots[s].key, slots[s].len); + if (at == SIZE_MAX || view->v.obj.entries[at].accessor) continue; + ScrDyn *value = scr_dyn_new_str(name ? e->name : e->message); + scr_dyn_region_store(view, value); + ScrDyn *old = view->v.obj.entries[at].value; + view->v.obj.entries[at].value = value; + scr_dyn_release(old); + } + bool moved = false; + for (size_t b = 0; b < bag->v.obj.len; b++) { + const ScrDynEntry *bag_entry = &bag->v.obj.entries[b]; + bool slot = (bag_entry->key_len == 4 && memcmp(bag_entry->key, "name", 4) == 0) || + (bag_entry->key_len == 7 && memcmp(bag_entry->key, "message", 7) == 0); + if (!slot) continue; + size_t from = scr_dyn_entry_index(view, bag_entry->key, bag_entry->key_len); + if (from == SIZE_MAX) continue; + /* The nearest earlier bag property the view has; none puts it first. */ + size_t after = SIZE_MAX; + for (size_t p = b; p-- > 0 && after == SIZE_MAX;) + after = scr_dyn_entry_index(view, bag->v.obj.entries[p].key, bag->v.obj.entries[p].key_len); + size_t to = after == SIZE_MAX ? 0 : after < from ? after + 1 : after; + if (to == from) continue; + ScrDynEntry *entries = view->v.obj.entries; + ScrDynEntry entry = entries[from]; + if (to < from) memmove(&entries[to + 1], &entries[to], (from - to) * sizeof *entries); + else memmove(&entries[from], &entries[from + 1], (to - from) * sizeof *entries); + entries[to] = entry; + moved = true; + } + if (moved) { + SCR_JS_SHAPE_RESET(view); + free(view->v.obj.index); + view->v.obj.index = NULL; + } +} + ScrDyn *scr_dyn_from_error(const ScrError *e) { size_t hit = scr_errdyn_find(e, true); if (hit != SIZE_MAX) return scr_dyn_retain(scr_errdyn_cache[hit].dyn); diff --git a/tests/corpus/8112-error-factory-own-key-order.js b/tests/corpus/8112-error-factory-own-key-order.js new file mode 100644 index 000000000..3e7cf277a --- /dev/null +++ b/tests/corpus/8112-error-factory-own-key-order.js @@ -0,0 +1,73 @@ +"use strict"; +// Errors built by a constructor factory, the way zod 4 builds ZodError: a +// class extending the parent passed in, then an initializer that assigns +// `name`, defines non-enumerable fields and an enumerable `message` +// accessor, and a second initializer that reassigns `name`. JSON and +// Object.keys list `name` before `message`, as they were created, and +// `name` holds the last assignment. + +const messageDescriptor = { + get() { + return this.state.message ?? JSON.stringify(this.state.issues); + }, + set(value) { + this.state.message = value; + }, + enumerable: true, + configurable: true, +}; + +function factory(name, initializer, parent) { + const Parent = parent ?? Object; + class Definition extends Parent {} + Object.defineProperty(Definition, "name", { value: name }); + function construct(def) { + const instance = parent ? new Definition() : this; + Object.defineProperty(instance, "state", { value: { issues: def }, enumerable: false }); + initializer(instance, def); + return instance; + } + Object.defineProperty(construct, "init", { value: initializer }); + Object.defineProperty(construct, "name", { value: name }); + return construct; +} + +const baseInit = (instance, def) => { + instance.name = "$BaseError"; + Object.defineProperty(instance, "issues", { value: def, enumerable: false }); + Object.defineProperty(instance, "message", messageDescriptor); +}; +const BaseError = factory("$BaseError", baseInit, Error); +const ReportError = factory( + "ReportError", + (instance, def) => { + BaseError.init(instance, def); + instance.name = "ReportError"; + }, + Error, +); + +const report = new ReportError([{ code: "too_small", path: ["title"] }]); +console.log(report.name, report instanceof Error, Object.keys(report).join(",")); +console.log(JSON.stringify(report)); +console.log(JSON.stringify({ success: false, error: report })); +report.message = "replaced"; +console.log(JSON.stringify(report), report.message); +report.name = "Renamed"; +console.log(JSON.stringify(report), report.name); + +const base = new BaseError([1, 2]); +console.log(base.name, Object.keys(base).join(","), JSON.stringify(base)); + +// The same steps on a class extending Error directly. +function build(flag) { + class Direct extends Error {} + const instance = flag ? new Direct() : {}; + instance.name = "First"; + Object.defineProperty(instance, "message", messageDescriptor); + Object.defineProperty(instance, "state", { value: { issues: ["i"] }, enumerable: false }); + instance.name = "Second"; + return instance; +} +const direct = build(true); +console.log(Object.keys(direct).join(","), JSON.stringify(direct), direct.name); diff --git a/tests/fixtures/npm/cases/hono-zod-default-hook/main.ts b/tests/fixtures/npm/cases/hono-zod-default-hook/main.ts new file mode 100644 index 000000000..9981e7154 --- /dev/null +++ b/tests/fixtures/npm/cases/hono-zod-default-hook/main.ts @@ -0,0 +1,38 @@ +// @hono/zod-validator without a hook: a failed check answers 400 with the +// whole safeParse result serialized, so the ZodError's own enumerable +// properties (name, then message) and their order reach the client. +import { zValidator } from "@hono/zod-validator"; +import { Hono } from "hono"; +import { z } from "zod"; + +const Item = z.object({ title: z.string().min(2), qty: z.number().int().positive() }); + +const app = new Hono(); +app.post("/items", zValidator("json", Item), (c) => { + const item = c.req.valid("json"); + return c.json({ created: item.title, qty: item.qty }, 201); +}); +app.get("/items", zValidator("query", z.object({ limit: z.coerce.number().int().max(5) })), (c) => { + return c.json({ limit: c.req.valid("query").limit }); +}); + +const post = (body: unknown) => + app.request("/items", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) }); + +const show = async (label: string, res: Response | Promise) => { + const r = await res; + console.log(label, r.status, await r.text()); +}; + +await show("ok", post({ title: "pen", qty: 2 })); +await show("bad-title", post({ title: "p", qty: 2 })); +await show("bad-both", post({ title: 3, qty: -1 })); +await show("query-ok", app.request("/items?limit=4")); +await show("query-bad", app.request("/items?limit=9")); + +const result = Item.safeParse({ title: "x", qty: 0 }); +if (!result.success) { + const e = result.error; + console.log(e.name, e instanceof Error, Object.keys(e).join(",")); + console.log(JSON.stringify(e)); +} diff --git a/tests/fixtures/npm/cases/hono-zod-default-hook/package.json b/tests/fixtures/npm/cases/hono-zod-default-hook/package.json new file mode 100644 index 000000000..a734aaddb --- /dev/null +++ b/tests/fixtures/npm/cases/hono-zod-default-hook/package.json @@ -0,0 +1 @@ +{ "name": "hono-zod-default-hook-case", "private": true, "type": "module" } diff --git a/tests/harness/corpus-costs/8112-error-factory-own-key-order.js.json b/tests/harness/corpus-costs/8112-error-factory-own-key-order.js.json new file mode 100644 index 000000000..9013dc548 --- /dev/null +++ b/tests/harness/corpus-costs/8112-error-factory-own-key-order.js.json @@ -0,0 +1 @@ +{ "plain": 2200, "san": 10300 }