Skip to content

fix(zod): reduce per-schema heap cost via shared method references#263121

Merged
gsoldevila merged 10 commits intoelastic:mainfrom
gsoldevila:zod-memory-optimization
Apr 22, 2026
Merged

fix(zod): reduce per-schema heap cost via shared method references#263121
gsoldevila merged 10 commits intoelastic:mainfrom
gsoldevila:zod-memory-optimization

Conversation

@gsoldevila
Copy link
Copy Markdown
Member

@gsoldevila gsoldevila commented Apr 14, 2026

Part of #264170

Summary

Applies a memory optimization patch to Zod v4 to address significant heap cost regression introduced with the upgrade to Zod 4.x (see upstream issue #5760).

Problem

Zod v4 assigns every method (parse, check, optional, email, etc.) as an own-property arrow function on each schema instance. Because each arrow function closes over inst, each instance allocates ~91 unique function objects. V8 switches from fast-property mode to slow "dictionary mode" when an object accumulates more than ~27 own properties, causing an extra heap tax on top of the closure overhead. In practice this costs ~12.8 KB of heap per schema (vs ~1.5 KB with Zod 3).

Fix — Shadow-Proto Architecture

Introduces a hidden intermediate prototype layer (internalProto) between _.prototype (the user-visible class prototype) and the parent. Library methods are placed on internalProto via _initProto (once per concrete type, lazily), so they become inherited rather than own properties.

instance → _.prototype (user space, empty by default)
         → internalProto (library space: 69 shared methods)
         → Parent

Key properties:

  • Own-property count: ~91 → ~22 (well below V8's dictionary-mode threshold of ~27)
  • Shared methods: all 69 builder/parse methods are prototype-inherited; instances share the same function objects (s1.optional === s2.optional)
  • Backward-compatible: user-added prototype extensions on _.prototype still shadow internalProto transparently
  • Covers both variants: classic and mini Zod APIs (6 files patched)

Six Zod files are patched (both CJS and ESM variants):

  • zod/v4/classic/schemas.cjs / schemas.js_initProto helper + moves all builder methods to internalProto
  • zod/v4/mini/schemas.cjs / schemas.js — same for the mini API surface
  • zod/v4/core/core.cjs / core.js — wires _.prototype → internalProto, exposes $internalProto symbol; copy-loop left as no-op for library methods

The patch is managed by patch-package and lives in patches/zod+4.3.6.patch. An upstream PR is being prepared: colinhacks/zod#5870.

Result

Metric Before (Zod v4, unpatched) After (shadow-proto patch)
Own properties per instance ~91 ~22
V8 property storage mode Dictionary (slow) Fast
Heap cost per z.string() ~12.8 KB ~2.5 KB
Shared method references ✗ (per-instance closures) (prototype-inherited)
Memory reduction ~80%
z.iso.datetime().optional()
Prototype augmentation
All parse/validate/chain APIs

Validated by full Kibana heap snapshot comparison: ~113 MB reduction at startup.

Patch persistence

The patch is applied automatically during yarn kbn bootstrap (after yarn install) via patch-package --error-on-fail. The patches/ directory is committed and version-controlled.

Removal criteria: remove patches/zod+4.3.6.patch (and the patch-package devDependency) once the upstream Zod issue is resolved and Kibana upgrades to the patched version.

New dependency: patch-package

Purpose Applies and maintains the shadow-proto patch to node_modules/zod across yarn install runs. Invoked as patch-package --error-on-fail in the bootstrap step.
Justification The optimization requires modifying Zod's compiled JavaScript internals ($constructor wiring, _initProto helper, builder method placement). These changes cannot be applied at the TypeScript/import level, so a post-install patch is the correct mechanism. patch-package is the industry-standard tool for this pattern, with a simple invocation model and deterministic patch application.
Alternatives explored (1) Custom patching script — Kibana used a bespoke src/dev/node_modules_patches/ mechanism in an earlier iteration of this PR; patch-package is strictly simpler and more maintainable. (2) @kbn/zod wrapper — the wrapper re-exports zod/v4 but cannot intercept the compiled $constructor and prototype wiring needed for this optimization. (3) Private Elastic fork/registry — viable but significantly heavier: requires maintaining a fork, publishing to a registry, and updating consumers; disproportionate effort for a temporary vendor patch.
Existing dependencies Kibana has no existing dependency providing patch-package-equivalent functionality. yarn patch (a built-in Yarn Berry feature) is not available since Kibana uses Yarn Classic.

Test plan

  • npx patch-package --error-on-fail applies cleanly from scratch
  • z.iso.datetime().optional() works
  • z.string().email().optional(), obj.pick(), enum.extract() all work
  • z.httpUrl() correctly rejects ftp://, file:/// URLs (only http/https accepted)
  • Shared references confirmed: z.string().optional === z.string().optionaltrue
  • Own-property count confirmed: Object.getOwnPropertyNames(z.string()).length22
  • Memory benchmark: ~2.5 KB per z.string() (down from ~12.8 KB)
  • node scripts/check_changes.ts passes
  • Heap snapshot comparison: ~113 MB reduction at Kibana startup

@gsoldevila gsoldevila added Team:Core Platform Core services: plugins, logging, config, saved objects, http, ES client, i18n, etc t// release_note:skip Skip the PR/issue when compiling release notes backport:skip This PR does not require backporting labels Apr 14, 2026
@gsoldevila gsoldevila force-pushed the zod-memory-optimization branch from 5826139 to b7e99a8 Compare April 14, 2026 15:36
@gsoldevila
Copy link
Copy Markdown
Member Author

/ci

@gsoldevila gsoldevila changed the title fix(zod): memory optimization - move methods to prototype chain fix(zod): reduce per-schema heap cost via shared method references Apr 14, 2026
gsoldevila added a commit to gsoldevila/zod that referenced this pull request Apr 14, 2026
## Problem

Zod v4 assigns every schema method (`parse`, `check`, `optional`, `email`,
etc.) as an own-property arrow function on each schema instance. Because arrow
functions close over `inst`, each instance gets ~50 unique function objects.
For applications that define many schemas this causes significant heap pressure.

Rough measurements (Node.js, `--expose-gc`):

| Schema | Before | After |
|--------|--------|-------|
| `z.string()` | ~12.8 KB | ~8.3 KB |
| (relative) | baseline | **–35%** |

## Fix

Builder-style methods (`check`, `optional`, `nullable`, `clone`, `describe`,
string/number/date validators, object methods, etc.) are now defined once as
named `function` declarations at module scope and use `this` for context.
All instances share the same ~50 function objects for these methods.

**Not changed:** parse/safeParse/parseAsync and encode/decode variants are
kept as per-instance closures because they are commonly called in a detached
manner (e.g. `arr.map(schema.parse)`, `const p = schema.parse; p(data)`).

The "support prototype modifications" loop in `$constructor` (core.ts) is
preserved. It remains useful for runtime prototype extensions and is now only
activated for genuinely new keys (not for the shared methods, since `k in inst`
will be true for all shared own properties).

## Notes

- The `as any` casts on shared function assignments are implementation-only;
  the declared interface types remain authoritative for TypeScript consumers.
- All 3575 existing tests pass unchanged, including prototype extension tests
  and the "this binding" test.
- This was validated by applying the compiled-output equivalent as a
  `patch-package` patch in the Kibana monorepo (elastic/kibana#263121).

Made-with: Cursor
@gsoldevila gsoldevila force-pushed the zod-memory-optimization branch from 9e43965 to 9da0bd2 Compare April 21, 2026 10:09
@gsoldevila gsoldevila added backport:version Backport to applied version labels v9.4.0 and removed backport:skip This PR does not require backporting labels Apr 21, 2026
@gsoldevila gsoldevila marked this pull request as ready for review April 21, 2026 10:11
@gsoldevila gsoldevila requested review from a team as code owners April 21, 2026 10:11
@elasticmachine
Copy link
Copy Markdown
Contributor

Pinging @elastic/kibana-core (Team:Core)

@kibanamachine
Copy link
Copy Markdown
Contributor

Dependency Review Bot Analysis 🔍

Found 1 new third-party dependencies:

Package Version Vulnerabilities Health Score
patch-package 8.0.1 🔴 C: 0, 🟠 H: 0, 🟡 M: 0, 🟢 L: 0 patch-package

Self Checklist

To help with the review, please update the PR description to address the following points for each new third-party dependency listed above:

  • Purpose: What is this dependency used for? Briefly explain its role in your changes.
  • Justification: Why is adding this dependency the best approach?
  • Alternatives explored: Were other options considered (e.g., using existing internal libraries/utilities, implementing the functionality directly)? If so, why was this dependency chosen over them?
  • Existing dependencies: Does Kibana have a dependency providing similar functionality? If so, why is the new one preferred?

Thank you for providing this information!

Comment thread patches/zod+4.3.6.patch Outdated
Comment thread src/core/packages/root/server-internal/src/memory_profile.ts Outdated
@gsoldevila gsoldevila force-pushed the zod-memory-optimization branch 2 times, most recently from 73a6bcf to 660d174 Compare April 21, 2026 12:43
Comment thread renovate.json Outdated
Comment thread scripts/apply_node_modules_patches.js Outdated
* This is also called automatically during `yarn kbn bootstrap` (after yarn install).
*/

import('../src/dev/node_modules_patches/zod_prototype_memory_optimization.mjs')
Copy link
Copy Markdown
Contributor

@TinaHeiligers TinaHeiligers Apr 21, 2026

Choose a reason for hiding this comment

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

../src/dev/node_modules_patches/zod_prototype_memory_optimization.mjs was removed when the approach switched to patch-package. The file is dead and will throw if anyone tries to run it manually.
It's also causing the lint error that's blocking CI 😉

Copy link
Copy Markdown
Contributor

@TinaHeiligers TinaHeiligers left a comment

Choose a reason for hiding this comment

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

The shadow-proto architecture is clean. It correctly preserves user prototype augmentation while sharing library methods, and keeping parse/safeParse as per-instance closures for detached-call semantics (arr.map(schema.parse)) is the right call.

gsoldevila and others added 2 commits April 22, 2026 11:21
Zod v4 assigns every method (parse, check, optional, email, etc.) as own
properties on each schema instance, resulting in ~91 own properties per
instance. V8 switches objects to slow "dictionary mode" when they exceed
~27 own properties, increasing heap usage to ~13KB per schema instance.

This patch:
- Moves all shared methods from instance own properties to the prototype
  chain via a _setupZodPrototypes() call added to zod's schemas.cjs
- Removes the $constructor "support prototype modifications" loop in
  core.cjs that was re-copying prototype methods back to instances
- Establishes a proper inheritance chain: ZodString -> _ZodString ->
  ZodType, each level adding type-specific methods

Result: own property count drops from ~91 to ~5-8, keeping instances
in V8's fast-property mode. Heap cost drops from ~13KB to ~2KB per
schema instance (~85% reduction).

The patch is applied automatically during `yarn kbn bootstrap` (after
yarn install) via src/dev/node_modules_patches/. The patch is idempotent
and version-gated: it will warn and skip if the Zod version changes.

Upstream issue: colinhacks/zod#5760

Made-with: Cursor
gsoldevila and others added 6 commits April 22, 2026 11:21
…el savings)

Replaces the shared-method-references patch with the more complete
shadow-proto strategy:

- Introduces a hidden `internalProto` layer between `_.prototype` (user
  space) and the parent prototype. All library methods are placed on
  `internalProto` via `_initProto`, making them prototype-inherited rather
  than own properties.
- Own-property count per schema instance drops from ~91 → ~22, keeping
  all instances firmly in V8's fast-property mode (threshold: ~27).
- Methods are shared across instances (prototype-inherited), eliminating
  the ~50-closure per-instance allocation from the original design.
- Backward-compatible: user-added prototype extensions continue to work
  because `_.prototype` (user space) shadows `internalProto`.
- Covers classic and mini Zod API (6 files: schemas.cjs/js + core.cjs/js
  + mini/schemas.cjs/js).

Upstream PR: colinhacks/zod#5870

Made-with: Cursor
Co-authored-by: macroscopeapp[bot] <170038800+macroscopeapp[bot]@users.noreply.github.com>
The script imported zod_prototype_memory_optimization.mjs which was
removed when the patch mechanism switched to patch-package. The script
is now dead code: patch-package is invoked automatically during
`yarn kbn bootstrap` and covers all patching needs.

Fixes lint failure blocking CI.

Made-with: Cursor
@gsoldevila gsoldevila force-pushed the zod-memory-optimization branch from db30da7 to 7f3d62e Compare April 22, 2026 09:22
@gsoldevila gsoldevila requested a review from maximpn April 22, 2026 09:41
@elasticmachine
Copy link
Copy Markdown
Contributor

elasticmachine commented Apr 22, 2026

💛 Build succeeded, but was flaky

Failed CI Steps

Test Failures

  • [job] [logs] Scout: [ platform / alerting ] plugin / local-serverless-observability_complete - Alerting Rule - The rule runs and event log shows success
  • [job] [logs] Scout: [ platform / alerting ] plugin / local-serverless-observability_complete - API key invalidation on rule operations - update_api_key rotates both apiKey and uiamApiKey
  • [job] [logs] FTR Configs #50 / task_manager migrations 8.5.0 migrates active tasks to set enabled to true

Metrics [docs]

Async chunks

Total size of all lazy-loaded chunks that will be downloaded as the user navigates the app

id before after diff
stackConnectors 1.7MB 1.7MB +12.0B
workflowsManagement 2.3MB 2.3MB +12.0B
total +24.0B

Page load bundle

Size of the bundles that are downloaded on every page load. Target size is below 100kb

id before after diff
kbnUiSharedDeps-npmDll 7.3MB 7.3MB +3.6KB

History

Copy link
Copy Markdown
Contributor

@kc13greiner kc13greiner left a comment

Choose a reason for hiding this comment

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

++ LGTM! thanks for filling out the dep justification!

Copy link
Copy Markdown
Contributor

@maximpn maximpn left a comment

Choose a reason for hiding this comment

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

Thanks for making this improvement @gsoldevila 🙏

@gsoldevila gsoldevila merged commit ae70b77 into elastic:main Apr 22, 2026
22 checks passed
@kibanamachine
Copy link
Copy Markdown
Contributor

Starting backport for target branches: 9.4

https://github.com/elastic/kibana/actions/runs/24780551958

@kibanamachine
Copy link
Copy Markdown
Contributor

💚 All backports created successfully

Status Branch Result
9.4

Note: Successful backport PRs will be merged automatically after passing CI.

Questions ?

Please refer to the Backport tool documentation

kibanamachine added a commit that referenced this pull request Apr 22, 2026
…ces (#263121) (#265044)

# Backport

This will backport the following commits from `main` to `9.4`:
- [fix(zod): reduce per-schema heap cost via shared method references
(#263121)](#263121)

<!--- Backport version: 9.6.6 -->

### Questions ?
Please refer to the [Backport tool
documentation](https://github.com/sorenlouv/backport)

<!--BACKPORT [{"author":{"name":"Gerard
Soldevila","email":"gerard.soldevila@elastic.co"},"sourceCommit":{"committedDate":"2026-04-22T13:19:07Z","message":"fix(zod):
reduce per-schema heap cost via shared method references (#263121)\n\n##
Summary\n\nApplies a memory optimization patch to Zod v4 to address
significant\nheap cost regression introduced with the upgrade to Zod 4.x
(see\nupstream issue
[#5760](https://github.com/colinhacks/zod/issues/5760)).\n\n###
Problem\n\nZod v4 assigns every method (`parse`, `check`, `optional`,
`email`,\netc.) as an **own-property arrow function** on each schema
instance.\nBecause each arrow function closes over `inst`, each instance
allocates\n~91 unique function objects. V8 switches from fast-property
mode to slow\n\"dictionary mode\" when an object accumulates more than
~27 own\nproperties, causing an extra heap tax on top of the closure
overhead. In\npractice this costs **~12.8 KB of heap per schema** (vs
~1.5 KB with Zod\n3).\n\n### Fix — Shadow-Proto
Architecture\n\nIntroduces a hidden intermediate prototype layer
(`internalProto`)\nbetween `_.prototype` (the user-visible class
prototype) and the parent.\nLibrary methods are placed on
`internalProto` via `_initProto` (once per\nconcrete type, lazily), so
they become **inherited** rather than own\nproperties.\n\n```\ninstance
→ _.prototype (user space, empty by default)\n → internalProto (library
space: 69 shared methods)\n → Parent\n```\n\nKey properties:\n-
**Own-property count**: ~91 → **~22** (well below V8's
dictionary-mode\nthreshold of ~27)\n- **Shared methods**: all 69
builder/parse methods are\nprototype-inherited; instances share the same
function objects\n(`s1.optional === s2.optional`)\n-
**Backward-compatible**: user-added prototype extensions
on\n`_.prototype` still shadow `internalProto` transparently\n- **Covers
both variants**: classic and mini Zod APIs (6 files patched)\n\nSix Zod
files are patched (both CJS and ESM variants):\n\n-
`zod/v4/classic/schemas.cjs` / `schemas.js` — `_initProto` helper
+\nmoves all builder methods to `internalProto`\n-
`zod/v4/mini/schemas.cjs` / `schemas.js` — same for the mini
API\nsurface\n- `zod/v4/core/core.cjs` / `core.js` — wires `_.prototype
→\ninternalProto`, exposes `$internalProto` symbol; copy-loop left as
no-op\nfor library methods\n\nThe patch is managed by `patch-package`
and lives in\n`patches/zod+4.3.6.patch`. An upstream PR is being
prepared:\n[colinhacks/zod#5870](https://github.com/colinhacks/zod/pull/5870).\n\n###
Result\n\n| Metric | Before (Zod v4, unpatched) | After (shadow-proto
patch) |\n| ------------------------------- | --------------------------
|\n-------------------------- |\n| Own properties per instance | ~91 |
**~22** |\n| V8 property storage mode | Dictionary (slow) | **Fast**
|\n| Heap cost per `z.string()` | ~12.8 KB | **~2.5 KB** |\n| Shared
method references | ✗ (per-instance closures) |
**✓**\n(prototype-inherited)|\n| **Memory reduction** | — | **~80%**
|\n| `z.iso.datetime().optional()` | ✅ | ✅ |\n| Prototype augmentation |
✅ | ✅ |\n| All parse/validate/chain APIs | ✅ | ✅ |\n\n> Validated by
full Kibana heap snapshot comparison: ~113 MB reduction\nat
startup.\n\n### Patch persistence\n\nThe patch is applied automatically
during `yarn kbn bootstrap` (after\n`yarn install`) via `patch-package
--error-on-fail`. The `patches/`\ndirectory is committed and
version-controlled.\n\n**Removal criteria**: remove
`patches/zod+4.3.6.patch` (and the\n`patch-package` devDependency) once
the upstream Zod issue is resolved\nand Kibana upgrades to the patched
version.\n\n### New dependency: `patch-package`\n\n| | |\n|---|---|\n|
**Purpose** | Applies and maintains the shadow-proto patch
to\n`node_modules/zod` across `yarn install` runs. Invoked as
`patch-package\n--error-on-fail` in the bootstrap step. |\n|
**Justification** | The optimization requires modifying Zod's
compiled\nJavaScript internals (`$constructor` wiring, `_initProto`
helper,\nbuilder method placement). These changes cannot be applied at
the\nTypeScript/import level, so a post-install patch is the
correct\nmechanism. `patch-package` is the industry-standard tool for
this\npattern, with a simple invocation model and deterministic
patch\napplication. |\n| **Alternatives explored** | (1) **Custom
patching script** — Kibana\nused a bespoke
`src/dev/node_modules_patches/` mechanism in an earlier\niteration of
this PR; `patch-package` is strictly simpler and more\nmaintainable. (2)
**`@kbn/zod` wrapper** — the wrapper re-exports\n`zod/v4` but cannot
intercept the compiled `$constructor` and prototype\nwiring needed for
this optimization. (3) **Private Elastic\nfork/registry** — viable but
significantly heavier: requires maintaining\na fork, publishing to a
registry, and updating consumers;\ndisproportionate effort for a
temporary vendor patch. |\n| **Existing dependencies** | Kibana has no
existing dependency\nproviding `patch-package`-equivalent functionality.
`yarn patch` (a\nbuilt-in Yarn Berry feature) is not available since
Kibana uses Yarn\nClassic. |\n\n### Test plan\n\n- `npx patch-package
--error-on-fail` applies cleanly from scratch\n-
`z.iso.datetime().optional()` works\n- `z.string().email().optional()`,
`obj.pick()`, `enum.extract()` all\nwork\n- `z.httpUrl()` correctly
rejects `ftp://`, `file:///` URLs (only\n`http`/`https` accepted)\n-
Shared references confirmed: `z.string().optional
===\nz.string().optional` → `true`\n- Own-property count
confirmed:\n`Object.getOwnPropertyNames(z.string()).length` → `22`\n-
Memory benchmark: ~2.5 KB per `z.string()` (down from ~12.8 KB)\n- `node
scripts/check_changes.ts` passes\n- Heap snapshot comparison: ~113 MB
reduction at Kibana startup\n\n---------\n\nCo-authored-by:
kibanamachine
<42973632+kibanamachine@users.noreply.github.com>\nCo-authored-by:
macroscopeapp[bot]
<170038800+macroscopeapp[bot]@users.noreply.github.com>","sha":"ae70b77d9551d54563537c088b92f643f43e96fa","branchLabelMapping":{"^v9.5.0$":"main","^v(\\d+).(\\d+).\\d+$":"$1.$2"}},"sourcePullRequest":{"labels":["Team:Core","release_note:skip","ci:build-cloud-image","backport:version","v9.4.0","v9.5.0"],"title":"fix(zod):
reduce per-schema heap cost via shared method
references","number":263121,"url":"https://github.com/elastic/kibana/pull/263121","mergeCommit":{"message":"fix(zod):
reduce per-schema heap cost via shared method references (#263121)\n\n##
Summary\n\nApplies a memory optimization patch to Zod v4 to address
significant\nheap cost regression introduced with the upgrade to Zod 4.x
(see\nupstream issue
[#5760](https://github.com/colinhacks/zod/issues/5760)).\n\n###
Problem\n\nZod v4 assigns every method (`parse`, `check`, `optional`,
`email`,\netc.) as an **own-property arrow function** on each schema
instance.\nBecause each arrow function closes over `inst`, each instance
allocates\n~91 unique function objects. V8 switches from fast-property
mode to slow\n\"dictionary mode\" when an object accumulates more than
~27 own\nproperties, causing an extra heap tax on top of the closure
overhead. In\npractice this costs **~12.8 KB of heap per schema** (vs
~1.5 KB with Zod\n3).\n\n### Fix — Shadow-Proto
Architecture\n\nIntroduces a hidden intermediate prototype layer
(`internalProto`)\nbetween `_.prototype` (the user-visible class
prototype) and the parent.\nLibrary methods are placed on
`internalProto` via `_initProto` (once per\nconcrete type, lazily), so
they become **inherited** rather than own\nproperties.\n\n```\ninstance
→ _.prototype (user space, empty by default)\n → internalProto (library
space: 69 shared methods)\n → Parent\n```\n\nKey properties:\n-
**Own-property count**: ~91 → **~22** (well below V8's
dictionary-mode\nthreshold of ~27)\n- **Shared methods**: all 69
builder/parse methods are\nprototype-inherited; instances share the same
function objects\n(`s1.optional === s2.optional`)\n-
**Backward-compatible**: user-added prototype extensions
on\n`_.prototype` still shadow `internalProto` transparently\n- **Covers
both variants**: classic and mini Zod APIs (6 files patched)\n\nSix Zod
files are patched (both CJS and ESM variants):\n\n-
`zod/v4/classic/schemas.cjs` / `schemas.js` — `_initProto` helper
+\nmoves all builder methods to `internalProto`\n-
`zod/v4/mini/schemas.cjs` / `schemas.js` — same for the mini
API\nsurface\n- `zod/v4/core/core.cjs` / `core.js` — wires `_.prototype
→\ninternalProto`, exposes `$internalProto` symbol; copy-loop left as
no-op\nfor library methods\n\nThe patch is managed by `patch-package`
and lives in\n`patches/zod+4.3.6.patch`. An upstream PR is being
prepared:\n[colinhacks/zod#5870](https://github.com/colinhacks/zod/pull/5870).\n\n###
Result\n\n| Metric | Before (Zod v4, unpatched) | After (shadow-proto
patch) |\n| ------------------------------- | --------------------------
|\n-------------------------- |\n| Own properties per instance | ~91 |
**~22** |\n| V8 property storage mode | Dictionary (slow) | **Fast**
|\n| Heap cost per `z.string()` | ~12.8 KB | **~2.5 KB** |\n| Shared
method references | ✗ (per-instance closures) |
**✓**\n(prototype-inherited)|\n| **Memory reduction** | — | **~80%**
|\n| `z.iso.datetime().optional()` | ✅ | ✅ |\n| Prototype augmentation |
✅ | ✅ |\n| All parse/validate/chain APIs | ✅ | ✅ |\n\n> Validated by
full Kibana heap snapshot comparison: ~113 MB reduction\nat
startup.\n\n### Patch persistence\n\nThe patch is applied automatically
during `yarn kbn bootstrap` (after\n`yarn install`) via `patch-package
--error-on-fail`. The `patches/`\ndirectory is committed and
version-controlled.\n\n**Removal criteria**: remove
`patches/zod+4.3.6.patch` (and the\n`patch-package` devDependency) once
the upstream Zod issue is resolved\nand Kibana upgrades to the patched
version.\n\n### New dependency: `patch-package`\n\n| | |\n|---|---|\n|
**Purpose** | Applies and maintains the shadow-proto patch
to\n`node_modules/zod` across `yarn install` runs. Invoked as
`patch-package\n--error-on-fail` in the bootstrap step. |\n|
**Justification** | The optimization requires modifying Zod's
compiled\nJavaScript internals (`$constructor` wiring, `_initProto`
helper,\nbuilder method placement). These changes cannot be applied at
the\nTypeScript/import level, so a post-install patch is the
correct\nmechanism. `patch-package` is the industry-standard tool for
this\npattern, with a simple invocation model and deterministic
patch\napplication. |\n| **Alternatives explored** | (1) **Custom
patching script** — Kibana\nused a bespoke
`src/dev/node_modules_patches/` mechanism in an earlier\niteration of
this PR; `patch-package` is strictly simpler and more\nmaintainable. (2)
**`@kbn/zod` wrapper** — the wrapper re-exports\n`zod/v4` but cannot
intercept the compiled `$constructor` and prototype\nwiring needed for
this optimization. (3) **Private Elastic\nfork/registry** — viable but
significantly heavier: requires maintaining\na fork, publishing to a
registry, and updating consumers;\ndisproportionate effort for a
temporary vendor patch. |\n| **Existing dependencies** | Kibana has no
existing dependency\nproviding `patch-package`-equivalent functionality.
`yarn patch` (a\nbuilt-in Yarn Berry feature) is not available since
Kibana uses Yarn\nClassic. |\n\n### Test plan\n\n- `npx patch-package
--error-on-fail` applies cleanly from scratch\n-
`z.iso.datetime().optional()` works\n- `z.string().email().optional()`,
`obj.pick()`, `enum.extract()` all\nwork\n- `z.httpUrl()` correctly
rejects `ftp://`, `file:///` URLs (only\n`http`/`https` accepted)\n-
Shared references confirmed: `z.string().optional
===\nz.string().optional` → `true`\n- Own-property count
confirmed:\n`Object.getOwnPropertyNames(z.string()).length` → `22`\n-
Memory benchmark: ~2.5 KB per `z.string()` (down from ~12.8 KB)\n- `node
scripts/check_changes.ts` passes\n- Heap snapshot comparison: ~113 MB
reduction at Kibana startup\n\n---------\n\nCo-authored-by:
kibanamachine
<42973632+kibanamachine@users.noreply.github.com>\nCo-authored-by:
macroscopeapp[bot]
<170038800+macroscopeapp[bot]@users.noreply.github.com>","sha":"ae70b77d9551d54563537c088b92f643f43e96fa"}},"sourceBranch":"main","suggestedTargetBranches":["9.4"],"targetPullRequestStates":[{"branch":"9.4","label":"v9.4.0","branchLabelMappingKey":"^v(\\d+).(\\d+).\\d+$","isSourceBranch":false,"state":"NOT_CREATED"},{"branch":"main","label":"v9.5.0","branchLabelMappingKey":"^v9.5.0$","isSourceBranch":true,"state":"MERGED","url":"https://github.com/elastic/kibana/pull/263121","number":263121,"mergeCommit":{"message":"fix(zod):
reduce per-schema heap cost via shared method references (#263121)\n\n##
Summary\n\nApplies a memory optimization patch to Zod v4 to address
significant\nheap cost regression introduced with the upgrade to Zod 4.x
(see\nupstream issue
[#5760](https://github.com/colinhacks/zod/issues/5760)).\n\n###
Problem\n\nZod v4 assigns every method (`parse`, `check`, `optional`,
`email`,\netc.) as an **own-property arrow function** on each schema
instance.\nBecause each arrow function closes over `inst`, each instance
allocates\n~91 unique function objects. V8 switches from fast-property
mode to slow\n\"dictionary mode\" when an object accumulates more than
~27 own\nproperties, causing an extra heap tax on top of the closure
overhead. In\npractice this costs **~12.8 KB of heap per schema** (vs
~1.5 KB with Zod\n3).\n\n### Fix — Shadow-Proto
Architecture\n\nIntroduces a hidden intermediate prototype layer
(`internalProto`)\nbetween `_.prototype` (the user-visible class
prototype) and the parent.\nLibrary methods are placed on
`internalProto` via `_initProto` (once per\nconcrete type, lazily), so
they become **inherited** rather than own\nproperties.\n\n```\ninstance
→ _.prototype (user space, empty by default)\n → internalProto (library
space: 69 shared methods)\n → Parent\n```\n\nKey properties:\n-
**Own-property count**: ~91 → **~22** (well below V8's
dictionary-mode\nthreshold of ~27)\n- **Shared methods**: all 69
builder/parse methods are\nprototype-inherited; instances share the same
function objects\n(`s1.optional === s2.optional`)\n-
**Backward-compatible**: user-added prototype extensions
on\n`_.prototype` still shadow `internalProto` transparently\n- **Covers
both variants**: classic and mini Zod APIs (6 files patched)\n\nSix Zod
files are patched (both CJS and ESM variants):\n\n-
`zod/v4/classic/schemas.cjs` / `schemas.js` — `_initProto` helper
+\nmoves all builder methods to `internalProto`\n-
`zod/v4/mini/schemas.cjs` / `schemas.js` — same for the mini
API\nsurface\n- `zod/v4/core/core.cjs` / `core.js` — wires `_.prototype
→\ninternalProto`, exposes `$internalProto` symbol; copy-loop left as
no-op\nfor library methods\n\nThe patch is managed by `patch-package`
and lives in\n`patches/zod+4.3.6.patch`. An upstream PR is being
prepared:\n[colinhacks/zod#5870](https://github.com/colinhacks/zod/pull/5870).\n\n###
Result\n\n| Metric | Before (Zod v4, unpatched) | After (shadow-proto
patch) |\n| ------------------------------- | --------------------------
|\n-------------------------- |\n| Own properties per instance | ~91 |
**~22** |\n| V8 property storage mode | Dictionary (slow) | **Fast**
|\n| Heap cost per `z.string()` | ~12.8 KB | **~2.5 KB** |\n| Shared
method references | ✗ (per-instance closures) |
**✓**\n(prototype-inherited)|\n| **Memory reduction** | — | **~80%**
|\n| `z.iso.datetime().optional()` | ✅ | ✅ |\n| Prototype augmentation |
✅ | ✅ |\n| All parse/validate/chain APIs | ✅ | ✅ |\n\n> Validated by
full Kibana heap snapshot comparison: ~113 MB reduction\nat
startup.\n\n### Patch persistence\n\nThe patch is applied automatically
during `yarn kbn bootstrap` (after\n`yarn install`) via `patch-package
--error-on-fail`. The `patches/`\ndirectory is committed and
version-controlled.\n\n**Removal criteria**: remove
`patches/zod+4.3.6.patch` (and the\n`patch-package` devDependency) once
the upstream Zod issue is resolved\nand Kibana upgrades to the patched
version.\n\n### New dependency: `patch-package`\n\n| | |\n|---|---|\n|
**Purpose** | Applies and maintains the shadow-proto patch
to\n`node_modules/zod` across `yarn install` runs. Invoked as
`patch-package\n--error-on-fail` in the bootstrap step. |\n|
**Justification** | The optimization requires modifying Zod's
compiled\nJavaScript internals (`$constructor` wiring, `_initProto`
helper,\nbuilder method placement). These changes cannot be applied at
the\nTypeScript/import level, so a post-install patch is the
correct\nmechanism. `patch-package` is the industry-standard tool for
this\npattern, with a simple invocation model and deterministic
patch\napplication. |\n| **Alternatives explored** | (1) **Custom
patching script** — Kibana\nused a bespoke
`src/dev/node_modules_patches/` mechanism in an earlier\niteration of
this PR; `patch-package` is strictly simpler and more\nmaintainable. (2)
**`@kbn/zod` wrapper** — the wrapper re-exports\n`zod/v4` but cannot
intercept the compiled `$constructor` and prototype\nwiring needed for
this optimization. (3) **Private Elastic\nfork/registry** — viable but
significantly heavier: requires maintaining\na fork, publishing to a
registry, and updating consumers;\ndisproportionate effort for a
temporary vendor patch. |\n| **Existing dependencies** | Kibana has no
existing dependency\nproviding `patch-package`-equivalent functionality.
`yarn patch` (a\nbuilt-in Yarn Berry feature) is not available since
Kibana uses Yarn\nClassic. |\n\n### Test plan\n\n- `npx patch-package
--error-on-fail` applies cleanly from scratch\n-
`z.iso.datetime().optional()` works\n- `z.string().email().optional()`,
`obj.pick()`, `enum.extract()` all\nwork\n- `z.httpUrl()` correctly
rejects `ftp://`, `file:///` URLs (only\n`http`/`https` accepted)\n-
Shared references confirmed: `z.string().optional
===\nz.string().optional` → `true`\n- Own-property count
confirmed:\n`Object.getOwnPropertyNames(z.string()).length` → `22`\n-
Memory benchmark: ~2.5 KB per `z.string()` (down from ~12.8 KB)\n- `node
scripts/check_changes.ts` passes\n- Heap snapshot comparison: ~113 MB
reduction at Kibana startup\n\n---------\n\nCo-authored-by:
kibanamachine
<42973632+kibanamachine@users.noreply.github.com>\nCo-authored-by:
macroscopeapp[bot]
<170038800+macroscopeapp[bot]@users.noreply.github.com>","sha":"ae70b77d9551d54563537c088b92f643f43e96fa"}}]}]
BACKPORT-->

Co-authored-by: Gerard Soldevila <gerard.soldevila@elastic.co>
Co-authored-by: macroscopeapp[bot] <170038800+macroscopeapp[bot]@users.noreply.github.com>
@jbudz jbudz mentioned this pull request Apr 22, 2026
jbudz added a commit that referenced this pull request Apr 23, 2026
Follow up to #263121, which also
needs to run on the build.

https://buildkite.com/elastic/kibana-artifacts-snapshot/builds/8279

```
jon@mbp % tar xzf kibana-9.4.0-SNAPSHOT-linux-x86_64.tar.gz --to-stdout kibana-9.4.0-SNAPSHOT/node_modules/zod/v4/classic/schemas.cjs | head -160 | tail -35
const checks = __importStar(require("./checks.cjs"));
const iso = __importStar(require("./iso.cjs"));
const parse = __importStar(require("./parse.cjs"));
// Maps (internalProto, key) pairs already initialized — avoids repeated prototype setup
const _protoInitMap = new WeakMap();
/**
 * Sets shared methods on the *internal* prototype layer of inst's concrete constructor
 * (once per concrete type per key). The internal prototype sits one level below the
 * user-visible `_.prototype`, keeping `Object.keys(_.prototype)` empty by default
 * and preventing V8 dictionary-mode degradation.
 *
 * Falls back to `Object.getPrototypeOf(inst)` for constructors not created with
 * `$constructor` (e.g., in tests or third-party code).
 */
function _initProto(inst, key, methods, defineProps) {
    const proto = inst._zod?.constr?.[core.$internalProto] ?? Object.getPrototypeOf(inst);
```
tiansivive pushed a commit to tiansivive/kibana that referenced this pull request Apr 23, 2026
…lastic#263121)

## Summary

Applies a memory optimization patch to Zod v4 to address significant
heap cost regression introduced with the upgrade to Zod 4.x (see
upstream issue [elastic#5760](colinhacks/zod#5760)).

### Problem

Zod v4 assigns every method (`parse`, `check`, `optional`, `email`,
etc.) as an **own-property arrow function** on each schema instance.
Because each arrow function closes over `inst`, each instance allocates
~91 unique function objects. V8 switches from fast-property mode to slow
"dictionary mode" when an object accumulates more than ~27 own
properties, causing an extra heap tax on top of the closure overhead. In
practice this costs **~12.8 KB of heap per schema** (vs ~1.5 KB with Zod
3).

### Fix — Shadow-Proto Architecture

Introduces a hidden intermediate prototype layer (`internalProto`)
between `_.prototype` (the user-visible class prototype) and the parent.
Library methods are placed on `internalProto` via `_initProto` (once per
concrete type, lazily), so they become **inherited** rather than own
properties.

```
instance → _.prototype (user space, empty by default)
         → internalProto (library space: 69 shared methods)
         → Parent
```

Key properties:
- **Own-property count**: ~91 → **~22** (well below V8's dictionary-mode
threshold of ~27)
- **Shared methods**: all 69 builder/parse methods are
prototype-inherited; instances share the same function objects
(`s1.optional === s2.optional`)
- **Backward-compatible**: user-added prototype extensions on
`_.prototype` still shadow `internalProto` transparently
- **Covers both variants**: classic and mini Zod APIs (6 files patched)

Six Zod files are patched (both CJS and ESM variants):

- `zod/v4/classic/schemas.cjs` / `schemas.js` — `_initProto` helper +
moves all builder methods to `internalProto`
- `zod/v4/mini/schemas.cjs` / `schemas.js` — same for the mini API
surface
- `zod/v4/core/core.cjs` / `core.js` — wires `_.prototype →
internalProto`, exposes `$internalProto` symbol; copy-loop left as no-op
for library methods

The patch is managed by `patch-package` and lives in
`patches/zod+4.3.6.patch`. An upstream PR is being prepared:
[colinhacks/zod#5870](colinhacks/zod#5870).

### Result

| Metric | Before (Zod v4, unpatched) | After (shadow-proto patch) |
| ------------------------------- | -------------------------- |
-------------------------- |
| Own properties per instance | ~91 | **~22** |
| V8 property storage mode | Dictionary (slow) | **Fast** |
| Heap cost per `z.string()` | ~12.8 KB | **~2.5 KB** |
| Shared method references | ✗ (per-instance closures) | **✓**
(prototype-inherited)|
| **Memory reduction** | — | **~80%** |
| `z.iso.datetime().optional()` | ✅ | ✅ |
| Prototype augmentation | ✅ | ✅ |
| All parse/validate/chain APIs | ✅ | ✅ |

> Validated by full Kibana heap snapshot comparison: ~113 MB reduction
at startup.

### Patch persistence

The patch is applied automatically during `yarn kbn bootstrap` (after
`yarn install`) via `patch-package --error-on-fail`. The `patches/`
directory is committed and version-controlled.

**Removal criteria**: remove `patches/zod+4.3.6.patch` (and the
`patch-package` devDependency) once the upstream Zod issue is resolved
and Kibana upgrades to the patched version.

### New dependency: `patch-package`

| | |
|---|---|
| **Purpose** | Applies and maintains the shadow-proto patch to
`node_modules/zod` across `yarn install` runs. Invoked as `patch-package
--error-on-fail` in the bootstrap step. |
| **Justification** | The optimization requires modifying Zod's compiled
JavaScript internals (`$constructor` wiring, `_initProto` helper,
builder method placement). These changes cannot be applied at the
TypeScript/import level, so a post-install patch is the correct
mechanism. `patch-package` is the industry-standard tool for this
pattern, with a simple invocation model and deterministic patch
application. |
| **Alternatives explored** | (1) **Custom patching script** — Kibana
used a bespoke `src/dev/node_modules_patches/` mechanism in an earlier
iteration of this PR; `patch-package` is strictly simpler and more
maintainable. (2) **`@kbn/zod` wrapper** — the wrapper re-exports
`zod/v4` but cannot intercept the compiled `$constructor` and prototype
wiring needed for this optimization. (3) **Private Elastic
fork/registry** — viable but significantly heavier: requires maintaining
a fork, publishing to a registry, and updating consumers;
disproportionate effort for a temporary vendor patch. |
| **Existing dependencies** | Kibana has no existing dependency
providing `patch-package`-equivalent functionality. `yarn patch` (a
built-in Yarn Berry feature) is not available since Kibana uses Yarn
Classic. |

### Test plan

- `npx patch-package --error-on-fail` applies cleanly from scratch
- `z.iso.datetime().optional()` works
- `z.string().email().optional()`, `obj.pick()`, `enum.extract()` all
work
- `z.httpUrl()` correctly rejects `ftp://`, `file:///` URLs (only
`http`/`https` accepted)
- Shared references confirmed: `z.string().optional ===
z.string().optional` → `true`
- Own-property count confirmed:
`Object.getOwnPropertyNames(z.string()).length` → `22`
- Memory benchmark: ~2.5 KB per `z.string()` (down from ~12.8 KB)
- `node scripts/check_changes.ts` passes
- Heap snapshot comparison: ~113 MB reduction at Kibana startup

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: macroscopeapp[bot] <170038800+macroscopeapp[bot]@users.noreply.github.com>
smith pushed a commit to smith/kibana that referenced this pull request Apr 23, 2026
Follow up to elastic#263121, which also
needs to run on the build.

https://buildkite.com/elastic/kibana-artifacts-snapshot/builds/8279

```
jon@mbp % tar xzf kibana-9.4.0-SNAPSHOT-linux-x86_64.tar.gz --to-stdout kibana-9.4.0-SNAPSHOT/node_modules/zod/v4/classic/schemas.cjs | head -160 | tail -35
const checks = __importStar(require("./checks.cjs"));
const iso = __importStar(require("./iso.cjs"));
const parse = __importStar(require("./parse.cjs"));
// Maps (internalProto, key) pairs already initialized — avoids repeated prototype setup
const _protoInitMap = new WeakMap();
/**
 * Sets shared methods on the *internal* prototype layer of inst's concrete constructor
 * (once per concrete type per key). The internal prototype sits one level below the
 * user-visible `_.prototype`, keeping `Object.keys(_.prototype)` empty by default
 * and preventing V8 dictionary-mode degradation.
 *
 * Falls back to `Object.getPrototypeOf(inst)` for constructors not created with
 * `$constructor` (e.g., in tests or third-party code).
 */
function _initProto(inst, key, methods, defineProps) {
    const proto = inst._zod?.constr?.[core.$internalProto] ?? Object.getPrototypeOf(inst);
```
rbrtj pushed a commit to walterra/kibana that referenced this pull request Apr 27, 2026
Follow up to elastic#263121, which also
needs to run on the build.

https://buildkite.com/elastic/kibana-artifacts-snapshot/builds/8279

```
jon@mbp % tar xzf kibana-9.4.0-SNAPSHOT-linux-x86_64.tar.gz --to-stdout kibana-9.4.0-SNAPSHOT/node_modules/zod/v4/classic/schemas.cjs | head -160 | tail -35
const checks = __importStar(require("./checks.cjs"));
const iso = __importStar(require("./iso.cjs"));
const parse = __importStar(require("./parse.cjs"));
// Maps (internalProto, key) pairs already initialized — avoids repeated prototype setup
const _protoInitMap = new WeakMap();
/**
 * Sets shared methods on the *internal* prototype layer of inst's concrete constructor
 * (once per concrete type per key). The internal prototype sits one level below the
 * user-visible `_.prototype`, keeping `Object.keys(_.prototype)` empty by default
 * and preventing V8 dictionary-mode degradation.
 *
 * Falls back to `Object.getPrototypeOf(inst)` for constructors not created with
 * `$constructor` (e.g., in tests or third-party code).
 */
function _initProto(inst, key, methods, defineProps) {
    const proto = inst._zod?.constr?.[core.$internalProto] ?? Object.getPrototypeOf(inst);
```
SoniaSanzV pushed a commit to SoniaSanzV/kibana that referenced this pull request Apr 27, 2026
…lastic#263121)

## Summary

Applies a memory optimization patch to Zod v4 to address significant
heap cost regression introduced with the upgrade to Zod 4.x (see
upstream issue [elastic#5760](colinhacks/zod#5760)).

### Problem

Zod v4 assigns every method (`parse`, `check`, `optional`, `email`,
etc.) as an **own-property arrow function** on each schema instance.
Because each arrow function closes over `inst`, each instance allocates
~91 unique function objects. V8 switches from fast-property mode to slow
"dictionary mode" when an object accumulates more than ~27 own
properties, causing an extra heap tax on top of the closure overhead. In
practice this costs **~12.8 KB of heap per schema** (vs ~1.5 KB with Zod
3).

### Fix — Shadow-Proto Architecture

Introduces a hidden intermediate prototype layer (`internalProto`)
between `_.prototype` (the user-visible class prototype) and the parent.
Library methods are placed on `internalProto` via `_initProto` (once per
concrete type, lazily), so they become **inherited** rather than own
properties.

```
instance → _.prototype (user space, empty by default)
         → internalProto (library space: 69 shared methods)
         → Parent
```

Key properties:
- **Own-property count**: ~91 → **~22** (well below V8's dictionary-mode
threshold of ~27)
- **Shared methods**: all 69 builder/parse methods are
prototype-inherited; instances share the same function objects
(`s1.optional === s2.optional`)
- **Backward-compatible**: user-added prototype extensions on
`_.prototype` still shadow `internalProto` transparently
- **Covers both variants**: classic and mini Zod APIs (6 files patched)

Six Zod files are patched (both CJS and ESM variants):

- `zod/v4/classic/schemas.cjs` / `schemas.js` — `_initProto` helper +
moves all builder methods to `internalProto`
- `zod/v4/mini/schemas.cjs` / `schemas.js` — same for the mini API
surface
- `zod/v4/core/core.cjs` / `core.js` — wires `_.prototype →
internalProto`, exposes `$internalProto` symbol; copy-loop left as no-op
for library methods

The patch is managed by `patch-package` and lives in
`patches/zod+4.3.6.patch`. An upstream PR is being prepared:
[colinhacks/zod#5870](colinhacks/zod#5870).

### Result

| Metric | Before (Zod v4, unpatched) | After (shadow-proto patch) |
| ------------------------------- | -------------------------- |
-------------------------- |
| Own properties per instance | ~91 | **~22** |
| V8 property storage mode | Dictionary (slow) | **Fast** |
| Heap cost per `z.string()` | ~12.8 KB | **~2.5 KB** |
| Shared method references | ✗ (per-instance closures) | **✓**
(prototype-inherited)|
| **Memory reduction** | — | **~80%** |
| `z.iso.datetime().optional()` | ✅ | ✅ |
| Prototype augmentation | ✅ | ✅ |
| All parse/validate/chain APIs | ✅ | ✅ |

> Validated by full Kibana heap snapshot comparison: ~113 MB reduction
at startup.

### Patch persistence

The patch is applied automatically during `yarn kbn bootstrap` (after
`yarn install`) via `patch-package --error-on-fail`. The `patches/`
directory is committed and version-controlled.

**Removal criteria**: remove `patches/zod+4.3.6.patch` (and the
`patch-package` devDependency) once the upstream Zod issue is resolved
and Kibana upgrades to the patched version.

### New dependency: `patch-package`

| | |
|---|---|
| **Purpose** | Applies and maintains the shadow-proto patch to
`node_modules/zod` across `yarn install` runs. Invoked as `patch-package
--error-on-fail` in the bootstrap step. |
| **Justification** | The optimization requires modifying Zod's compiled
JavaScript internals (`$constructor` wiring, `_initProto` helper,
builder method placement). These changes cannot be applied at the
TypeScript/import level, so a post-install patch is the correct
mechanism. `patch-package` is the industry-standard tool for this
pattern, with a simple invocation model and deterministic patch
application. |
| **Alternatives explored** | (1) **Custom patching script** — Kibana
used a bespoke `src/dev/node_modules_patches/` mechanism in an earlier
iteration of this PR; `patch-package` is strictly simpler and more
maintainable. (2) **`@kbn/zod` wrapper** — the wrapper re-exports
`zod/v4` but cannot intercept the compiled `$constructor` and prototype
wiring needed for this optimization. (3) **Private Elastic
fork/registry** — viable but significantly heavier: requires maintaining
a fork, publishing to a registry, and updating consumers;
disproportionate effort for a temporary vendor patch. |
| **Existing dependencies** | Kibana has no existing dependency
providing `patch-package`-equivalent functionality. `yarn patch` (a
built-in Yarn Berry feature) is not available since Kibana uses Yarn
Classic. |

### Test plan

- `npx patch-package --error-on-fail` applies cleanly from scratch
- `z.iso.datetime().optional()` works
- `z.string().email().optional()`, `obj.pick()`, `enum.extract()` all
work
- `z.httpUrl()` correctly rejects `ftp://`, `file:///` URLs (only
`http`/`https` accepted)
- Shared references confirmed: `z.string().optional ===
z.string().optional` → `true`
- Own-property count confirmed:
`Object.getOwnPropertyNames(z.string()).length` → `22`
- Memory benchmark: ~2.5 KB per `z.string()` (down from ~12.8 KB)
- `node scripts/check_changes.ts` passes
- Heap snapshot comparison: ~113 MB reduction at Kibana startup

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: macroscopeapp[bot] <170038800+macroscopeapp[bot]@users.noreply.github.com>
SoniaSanzV pushed a commit to SoniaSanzV/kibana that referenced this pull request Apr 27, 2026
Follow up to elastic#263121, which also
needs to run on the build.

https://buildkite.com/elastic/kibana-artifacts-snapshot/builds/8279

```
jon@mbp % tar xzf kibana-9.4.0-SNAPSHOT-linux-x86_64.tar.gz --to-stdout kibana-9.4.0-SNAPSHOT/node_modules/zod/v4/classic/schemas.cjs | head -160 | tail -35
const checks = __importStar(require("./checks.cjs"));
const iso = __importStar(require("./iso.cjs"));
const parse = __importStar(require("./parse.cjs"));
// Maps (internalProto, key) pairs already initialized — avoids repeated prototype setup
const _protoInitMap = new WeakMap();
/**
 * Sets shared methods on the *internal* prototype layer of inst's concrete constructor
 * (once per concrete type per key). The internal prototype sits one level below the
 * user-visible `_.prototype`, keeping `Object.keys(_.prototype)` empty by default
 * and preventing V8 dictionary-mode degradation.
 *
 * Falls back to `Object.getPrototypeOf(inst)` for constructors not created with
 * `$constructor` (e.g., in tests or third-party code).
 */
function _initProto(inst, key, methods, defineProps) {
    const proto = inst._zod?.constr?.[core.$internalProto] ?? Object.getPrototypeOf(inst);
```
gsoldevila added a commit that referenced this pull request Apr 29, 2026
…d#5897) (#266343)

## Summary

Adopts the memory optimization implemented by Colin McDonnell in
[colinhacks/zod#5897](colinhacks/zod#5897) as a
`patch-package` patch, ahead of the next official zod release. Also
fixes
[colinhacks/zod#5760](colinhacks/zod#5760).

### Background

The previous patch (#263121) introduced a **shadow-proto** architecture
that shared builder methods on a hidden prototype layer. While it
reduced per-schema heap cost by ~80%, it introduced a breaking change:
**detached method calls** stopped working.

```ts
const opt = schema.optional;  // extracts method reference
opt();                         // ❌ previously threw — `this` was undefined
```

The root cause was that `_initProto` used `Object.assign` to copy
methods as plain value properties onto the prototype. Detaching such a
property loses the `this` context.

### This PR

Replaces `_initProto` with Colin's `_installLazyMethods` approach from
[colinhacks/zod#5897](colinhacks/zod#5897):

```js
function _installLazyMethods(inst, group, methods) {
  // ... one-time setup per (proto, group) ...
  for (const key in methods) {
    const fn = methods[key];
    Object.defineProperty(proto, key, {
      configurable: true,
      enumerable: false,
      get() {
        const bound = fn.bind(this);
        // Cache on the instance — subsequent accesses skip the getter
        Object.defineProperty(this, key, {
          configurable: true, writable: true, enumerable: true,
          value: bound,
        });
        return bound;
      },
      set(v) {
        Object.defineProperty(this, key, {
          configurable: true, writable: true, enumerable: true,
          value: v,
        });
      },
    });
  }
}
```

**How it works:**
- Builder methods live as non-enumerable getters on each concrete
schema's prototype
- On first access per instance, the getter allocates `fn.bind(this)` and
caches it as an own property — subsequent accesses skip the getter
entirely
- Detached calls (`const m = schema.optional; m()`) work because `m` is
a bound function with `this` already resolved
- Parse-family methods (`parse`, `safeParse`, etc.) stay as eager
per-instance closures — they're the hot path and most-detached methods

**Memory characteristics are unchanged:** builder methods are not own
properties until first accessed, so V8 keeps schema instances in
fast-property mode. Per Colin's numbers: -40% heap per instance, -25%
construction time vs Zod 4.3.6 baseline.

## Why `patch-package`

Upstream zod CI has been broken for months and there is no scheduled
release. `patch-package` allows us to ship the fix now. Once a new zod
version including
[colinhacks/zod#5897](colinhacks/zod#5897) is
released, this patch can be dropped.

## Changes

- `patches/zod+4.3.6.patch` — replaces the previous shadow-proto patch
with Colin's `_installLazyMethods` approach (only touches
`v4/classic/schemas.cjs` and `v4/classic/schemas.js`)
- `src/platform/packages/shared/kbn-zod/v4/detached_methods.test.ts` —
behavioural unit tests proving detached calls work and that the
lazy-cache correctly returns the same bound function on repeated access

## Test plan

- [x] `node scripts/jest
src/platform/packages/shared/kbn-zod/v4/detached_methods.test.ts` — 5/5
pass
- [ ] Full CI green

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
kibanamachine added a commit to kibanamachine/kibana that referenced this pull request Apr 29, 2026
…d#5897) (elastic#266343)

## Summary

Adopts the memory optimization implemented by Colin McDonnell in
[colinhacks/zod#5897](colinhacks/zod#5897) as a
`patch-package` patch, ahead of the next official zod release. Also
fixes
[colinhacks/zod#5760](colinhacks/zod#5760).

### Background

The previous patch (elastic#263121) introduced a **shadow-proto** architecture
that shared builder methods on a hidden prototype layer. While it
reduced per-schema heap cost by ~80%, it introduced a breaking change:
**detached method calls** stopped working.

```ts
const opt = schema.optional;  // extracts method reference
opt();                         // ❌ previously threw — `this` was undefined
```

The root cause was that `_initProto` used `Object.assign` to copy
methods as plain value properties onto the prototype. Detaching such a
property loses the `this` context.

### This PR

Replaces `_initProto` with Colin's `_installLazyMethods` approach from
[colinhacks/zod#5897](colinhacks/zod#5897):

```js
function _installLazyMethods(inst, group, methods) {
  // ... one-time setup per (proto, group) ...
  for (const key in methods) {
    const fn = methods[key];
    Object.defineProperty(proto, key, {
      configurable: true,
      enumerable: false,
      get() {
        const bound = fn.bind(this);
        // Cache on the instance — subsequent accesses skip the getter
        Object.defineProperty(this, key, {
          configurable: true, writable: true, enumerable: true,
          value: bound,
        });
        return bound;
      },
      set(v) {
        Object.defineProperty(this, key, {
          configurable: true, writable: true, enumerable: true,
          value: v,
        });
      },
    });
  }
}
```

**How it works:**
- Builder methods live as non-enumerable getters on each concrete
schema's prototype
- On first access per instance, the getter allocates `fn.bind(this)` and
caches it as an own property — subsequent accesses skip the getter
entirely
- Detached calls (`const m = schema.optional; m()`) work because `m` is
a bound function with `this` already resolved
- Parse-family methods (`parse`, `safeParse`, etc.) stay as eager
per-instance closures — they're the hot path and most-detached methods

**Memory characteristics are unchanged:** builder methods are not own
properties until first accessed, so V8 keeps schema instances in
fast-property mode. Per Colin's numbers: -40% heap per instance, -25%
construction time vs Zod 4.3.6 baseline.

## Why `patch-package`

Upstream zod CI has been broken for months and there is no scheduled
release. `patch-package` allows us to ship the fix now. Once a new zod
version including
[colinhacks/zod#5897](colinhacks/zod#5897) is
released, this patch can be dropped.

## Changes

- `patches/zod+4.3.6.patch` — replaces the previous shadow-proto patch
with Colin's `_installLazyMethods` approach (only touches
`v4/classic/schemas.cjs` and `v4/classic/schemas.js`)
- `src/platform/packages/shared/kbn-zod/v4/detached_methods.test.ts` —
behavioural unit tests proving detached calls work and that the
lazy-cache correctly returns the same bound function on repeated access

## Test plan

- [x] `node scripts/jest
src/platform/packages/shared/kbn-zod/v4/detached_methods.test.ts` — 5/5
pass
- [ ] Full CI green

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
(cherry picked from commit a9ecabf)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

backport:version Backport to applied version labels ci:build-cloud-image release_note:skip Skip the PR/issue when compiling release notes Team:Core Platform Core services: plugins, logging, config, saved objects, http, ES client, i18n, etc t// v9.4.0 v9.5.0

Projects

None yet

Development

Successfully merging this pull request may close these issues.

8 participants