Skip to content

[9.4] fix(zod): adopt upstream lazy-bind memory optimization (colinhacks/zod#5897) (#266343)#266411

Closed
kibanamachine wants to merge 1 commit intoelastic:9.4from
kibanamachine:backport/9.4/pr-266343
Closed

[9.4] fix(zod): adopt upstream lazy-bind memory optimization (colinhacks/zod#5897) (#266343)#266411
kibanamachine wants to merge 1 commit intoelastic:9.4from
kibanamachine:backport/9.4/pr-266343

Conversation

@kibanamachine
Copy link
Copy Markdown
Contributor

Backport

This will backport the following commits from main to 9.4:

Questions ?

Please refer to the Backport tool documentation

@gsoldevila gsoldevila disabled auto-merge April 29, 2026 14:24
…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)
@kibanamachine kibanamachine force-pushed the backport/9.4/pr-266343 branch from 9cb8bba to 667cd64 Compare April 29, 2026 14:33
@kibanamachine kibanamachine enabled auto-merge (squash) April 29, 2026 14:33
@gsoldevila gsoldevila disabled auto-merge April 29, 2026 15:50
@gsoldevila gsoldevila marked this pull request as draft April 29, 2026 15:51
@elasticmachine
Copy link
Copy Markdown
Contributor

🤖 Jobs for this PR can be triggered through checkboxes. 🚧

ℹ️ To trigger the CI, please tick the checkbox below 👇

  • Click to trigger kibana-pull-request for this PR!
  • Click to trigger kibana-deploy-project-from-pr for this PR!
  • Click to trigger kibana-deploy-cloud-from-pr for this PR!
  • Click to trigger kibana-entity-store-performance-from-pr for this PR!
  • Click to trigger kibana-storybooks-from-pr for this PR!

@gsoldevila
Copy link
Copy Markdown
Member

backported manually through #266401

@gsoldevila gsoldevila closed this Apr 29, 2026
@kibanamachine
Copy link
Copy Markdown
Contributor Author

💛 Build succeeded, but was flaky

Failed CI Steps

Test Failures

  • [job] [logs] affected Scout: [ security / entity_store ] plugin / local-stateful-classic - Entity Store CCS logs extraction (test against local instance) - Should run CCS extraction for generic and write to updates then latest index

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 -2.5KB

cc @gsoldevila

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

Labels

backport This PR is a backport of another PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants