Skip to content

fix(v4): preserve default English locale across tree-shaken bundles#5959

Open
colinhacks wants to merge 1 commit into
mainfrom
fix-locale-tree-shake
Open

fix(v4): preserve default English locale across tree-shaken bundles#5959
colinhacks wants to merge 1 commit into
mainfrom
fix-locale-tree-shake

Conversation

@colinhacks
Copy link
Copy Markdown
Owner

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 — esbuild and Rollup (Vite prod) drop it as an unused side-effectful statement, so issue messages fall through to the hardcoded "Invalid input" fallback in core/util.js. Reported in #5953 (esbuild) and #5725 (Vite).

sideEffects arrays 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 the ZodType $constructor body — a runtime-reachable code path no tree-shaker can drop. Gated on !core.globalConfig.localeError so an explicit z.config(z.locales.xx()) always wins regardless of call order.

Closes #5953, #5725.

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.
@pullfrog
Copy link
Copy Markdown
Contributor

pullfrog Bot commented May 5, 2026

TL;DR — Moves the default English locale registration off a top-level config(en()) side effect in external.ts and into a lazy helper invoked from the ZodType $constructor, so bundlers honoring sideEffects: false can't drop it and error messages stop falling back to "Invalid input". Closes #5953, #5725.

Key changes

  • Drop the tree-shakeable top-level config(en()) — remove the side-effectful import and call from classic/external.ts so bundlers no longer have an unused top-level expression to eliminate.
  • Register the default locale lazily from ZodType construction — add _ensureDefaultLocale() in classic/schemas.ts, called from the ZodType $constructor; gated on !core.globalConfig.localeError so explicit z.config(...) wins.
  • Add regression tests for default and explicit locale behavior — new classic/tests/default-locale.test.ts covers the lazy-init path plus explicit locale set before and after schema construction.

Summary | 3 files | 1 commit | base: mainfix-locale-tree-shake


Locale registration survives tree-shaking

Before: config(en()) ran at module top-level in external.ts; bundlers like esbuild and Rollup (Vite prod) treated it as an unused side effect under sideEffects: false and dropped it, leaving localeError unset and issue messages falling through to the hardcoded "Invalid input" in core/util.js.
After: Registration moves into _ensureDefaultLocale(), called from inside the ZodType $constructor body — a runtime-reachable path no tree-shaker can eliminate. The check !core.globalConfig.localeError means an explicit z.config(z.locales.xx()) still wins regardless of call order.

The sideEffects arrays on the nested package stubs introduced in #5689 didn't reliably override esbuild's traversal, and there's no inline annotation that forces a bundler to keep a top-level call. Anchoring registration to a code path users actually execute sidesteps the problem entirely.

Why gate on `!core.globalConfig.localeError`? The helper runs on every `ZodType` instantiation but only installs the English default when no locale has been configured yet. An explicit `z.config(z.locales.fr())` before the first schema sets `localeError`, so the gate is already false when `_ensureDefaultLocale()` runs and the explicit choice is preserved. An explicit call after the first schema overwrites the English default as usual, since `config()` always replaces `localeError`.

packages/zod/src/v4/classic/external.ts · packages/zod/src/v4/classic/schemas.ts


Regression coverage for the lazy init path

Before: No tests asserted that the default English locale was actually registered through normal usage; the bundler regression slipped past CI.
After: default-locale.test.ts resets localeError between cases and verifies three scenarios: first ZodType construction installs the default, an explicit locale set beforehand is preserved, and an explicit locale set afterward overrides the default.

packages/zod/src/v4/classic/tests/default-locale.test.ts

Pullfrog  | View workflow run | via Pullfrog | Using Claude Opus𝕏

Copy link
Copy Markdown
Contributor

@pullfrog pullfrog Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using Claude Opus𝕏

Comment on lines +16 to +18
function _ensureDefaultLocale(): void {
if (!core.globalConfig.localeError) core.config(en());
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
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());
}

Comment on lines +7 to +9
beforeEach(() => {
z.config({ localeError: undefined });
});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
beforeEach(() => {
z.config({ localeError: undefined });
});
beforeEach(() => {
delete core.globalConfig.localeError;
});

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

"sideEffects": false in zod/v4/package.json causes esbuild to tree-shake locale initialization since version 4.4.1

1 participant