fix(v4): preserve default English locale across tree-shaken bundles#5959
fix(v4): preserve default English locale across tree-shaken bundles#5959colinhacks wants to merge 1 commit into
Conversation
The top-level `config(en())` in `classic/external.ts` was being tree-shaken out by bundlers honoring `sideEffects: false` on the stub package.json files (added in #5689), so error messages bundled with esbuild or Rollup fell through to the hardcoded "Invalid input" fallback instead of the descriptive English text. Move the registration into a `_ensureDefaultLocale()` helper called from inside the `ZodType` `$constructor` body. The call site is runtime-reachable from any user `z.string()` etc., so no tree-shaker can drop it. Gated on `!core.globalConfig.localeError` so an explicit `z.config(z.locales.xx())` always wins regardless of call order. Closes #5953, #5725.
|
TL;DR — Moves the default English locale registration off a top-level Key changes
Summary | 3 files | 1 commit | base: Locale registration survives tree-shaking
The
Regression coverage for the lazy init path
|
There was a problem hiding this comment.
Important
The fix correctly addresses the tree-shaking regression described in #5953 / #5725 (confirmed: sideEffects: false on the stub package.json files lets esbuild and Rollup/Vite drop top-level module-init calls, and there's no portable inline annotation that forces retention — moving the call onto a reachable runtime path is the standard workaround). The /*@__PURE__*/ on core.$constructor("ZodType", …) does not interfere, since ZodType is always consumed by concrete schema constructors so the return value is never unused.
One behavior change worth confirming is intentional: the previous top-level config(en()) ran exactly once at module load, so z.config({ localeError: undefined }) (or any direct core.globalConfig.localeError = undefined) would persist. With the helper hooked into $constructor, the next schema instantiated silently re-registers English. If the intent is to match the pre-4.4.1 "English once on load, then user owns it" contract, a module-scoped sentinel would be closer:
let _defaultLocaleInstalled = false;
function _ensureDefaultLocale(): void {
if (_defaultLocaleInstalled) return;
_defaultLocaleInstalled = true;
if (!core.globalConfig.localeError) core.config(en());
}That also drops the per-construction property read on the hot path. If instead the intent is the current "English always wins unless another locale is set," the existing shape is fine and this is just a doc nit.
Claude Opus | 𝕏
| function _ensureDefaultLocale(): void { | ||
| if (!core.globalConfig.localeError) core.config(en()); | ||
| } |
There was a problem hiding this comment.
This guard runs on every ZodType construction, so an explicit z.config({ localeError: undefined }) is reverted on the next schema instantiated. The old top-level config(en()) ran once at module load and left that clear state alone. Consider a module-level installed sentinel so the registration fires at most once per process (matches old semantics and removes the per-construction property read).
| function _ensureDefaultLocale(): void { | |
| if (!core.globalConfig.localeError) core.config(en()); | |
| } | |
| let _defaultLocaleInstalled = false; | |
| function _ensureDefaultLocale(): void { | |
| if (_defaultLocaleInstalled) return; | |
| _defaultLocaleInstalled = true; | |
| if (!core.globalConfig.localeError) core.config(en()); | |
| } |
| beforeEach(() => { | ||
| z.config({ localeError: undefined }); | ||
| }); |
There was a problem hiding this comment.
z.config({ localeError: undefined }) goes through Object.assign, which writes localeError as an own property rather than deleting it. It happens to work here because the production guard is a truthiness check, but jitless-allows-eval.test.ts uses delete core.globalConfig.jitless for the analogous reset — worth matching for consistency.
| beforeEach(() => { | |
| z.config({ localeError: undefined }); | |
| }); | |
| beforeEach(() => { | |
| delete core.globalConfig.localeError; | |
| }); |

The top-level
config(en())inclassic/external.tswas being tree-shaken out by bundlers honoringsideEffects: falseon the stubpackage.jsonfiles added in #5689 — esbuild and Rollup (Vite prod) drop it as an unused side-effectful statement, so issue messages fall through to the hardcoded"Invalid input"fallback incore/util.js. Reported in #5953 (esbuild) and #5725 (Vite).sideEffectsarrays on the nested stubs don't reliably override esbuild's traversal, and there's no inline annotation that forces a bundler to keep a top-level call. The fix moves the registration into a_ensureDefaultLocale()helper called from inside theZodType$constructorbody — a runtime-reachable code path no tree-shaker can drop. Gated on!core.globalConfig.localeErrorso an explicitz.config(z.locales.xx())always wins regardless of call order.Closes #5953, #5725.