From ad8377709a11b3eb816584d8f3d29fd7acab7e15 Mon Sep 17 00:00:00 2001 From: Petr Glaser Date: Fri, 30 Oct 2026 02:06:41 +0100 Subject: [PATCH] fix: copy non-enumerable Event fields into synthetic events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit createSyntheticEvent copied native fields with for…in, which skips non-enumerable properties. happy-dom defines type, target, currentTarget, bubbles and composedPath as class accessors, so under happy-dom every handler saw them as undefined and a controlled input could not read e.target.value. Walk the prototype chain with Object.getOwnPropertyNames instead. Names from Object.prototype are already present on the synthetic event, so they are skipped. The hydration=stub client gains 29/32 B gzip (source/built) and gets that measured allowance; every other entry stays within its existing budget. Co-Authored-By: Claude Opus 5.5 --- .../synthetic-event-inherited-fields.md | 5 ++ packages/redact/src/dom/synthetic-event.ts | 13 +++- pnpm-lock.yaml | 61 ++++++++++++++- scripts/size-check.mjs | 8 +- tests/package.json | 1 + tests/synthetic-event-happy-dom.test.tsx | 74 +++++++++++++++++++ 6 files changed, 153 insertions(+), 9 deletions(-) create mode 100644 .changeset/synthetic-event-inherited-fields.md create mode 100644 tests/synthetic-event-happy-dom.test.tsx diff --git a/.changeset/synthetic-event-inherited-fields.md b/.changeset/synthetic-event-inherited-fields.md new file mode 100644 index 0000000..2fd10ae --- /dev/null +++ b/.changeset/synthetic-event-inherited-fields.md @@ -0,0 +1,5 @@ +--- +'@tanstack/redact': patch +--- + +Copy non-enumerable Event fields into synthetic events. DOM implementations such as happy-dom define `type`, `target`, `currentTarget` and `composedPath` as non-enumerable accessors, so handlers saw them as `undefined` and controlled inputs never read `e.target.value` in happy-dom test suites. diff --git a/packages/redact/src/dom/synthetic-event.ts b/packages/redact/src/dom/synthetic-event.ts index 121704b..2ba7a65 100644 --- a/packages/redact/src/dom/synthetic-event.ts +++ b/packages/redact/src/dom/synthetic-event.ts @@ -12,10 +12,15 @@ export function createSyntheticEvent(native: Event) { stopImmediatePropagation() { stopped = true; native.stopImmediatePropagation() }, persist() {}, } - for (const key in native) { - if (!(key in event)) { - const value = (native as any)[key] - event[key] = isFunction(value) ? value.bind(native) : value + // Walk the prototype chain instead of `for…in`: DOM implementations such as + // happy-dom define `type`, `target` and other Event fields as non-enumerable + // accessors. Object.prototype names are already `in event`, so they are skipped. + for (let o: object | null = native; o; o = Object.getPrototypeOf(o)) { + for (const key of Object.getOwnPropertyNames(o)) { + if (!(key in event)) { + const value = (native as any)[key] + event[key] = isFunction(value) ? value.bind(native) : value + } } } return event diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 59bc291..f8b8d55 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -50,7 +50,7 @@ importers: version: 5.4.21(@types/node@26.6.5)(lightningcss@1.33.0)(terser@5.51.2) vitest: specifier: ^2.1.8 - version: 2.1.9(@types/node@26.6.5)(@vitest/browser@2.1.9)(jsdom@25.0.1)(lightningcss@1.33.0)(msw@2.13.4(@types/node@26.6.5)(typescript@5.9.3))(terser@5.51.2) + version: 2.1.9(@types/node@26.6.5)(@vitest/browser@2.1.9)(happy-dom@21.0.4)(jsdom@25.0.1)(lightningcss@1.33.0)(msw@2.13.4(@types/node@26.6.5)(typescript@5.9.3))(terser@5.51.2) examples/dom-jsx-playground: {} @@ -192,6 +192,9 @@ importers: '@vitest/browser': specifier: ^2.1.8 version: 2.1.9(@types/node@26.6.5)(playwright@1.64.1)(typescript@5.9.3)(vite@5.4.21(@types/node@26.6.5)(lightningcss@1.33.0)(terser@5.51.2))(vitest@2.1.9) + happy-dom: + specifier: ^21.0.4 + version: 21.0.4 jsdom: specifier: ^25.0.1 version: 25.0.1 @@ -209,7 +212,7 @@ importers: version: 0.19.0 vitest: specifier: ^2.1.8 - version: 2.1.9(@types/node@26.6.5)(@vitest/browser@2.1.9)(jsdom@25.0.1)(lightningcss@1.33.0)(msw@2.13.4(@types/node@26.6.5)(typescript@5.9.3))(terser@5.51.2) + version: 2.1.9(@types/node@26.6.5)(@vitest/browser@2.1.9)(happy-dom@21.0.4)(jsdom@25.0.1)(lightningcss@1.33.0)(msw@2.13.4(@types/node@26.6.5)(typescript@5.9.3))(terser@5.51.2) packages: @@ -1575,6 +1578,12 @@ packages: '@types/statuses@2.0.6': resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==} + '@types/whatwg-mimetype@3.0.2': + resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==} + + '@types/ws@8.18.2': + resolution: {integrity: sha512-67MQl+fpWKVTT1NYdnmo3U4sc/xPo/zQBncVnI74qmQa0z/b+1g6iYqNmGCPbxO+zz2aklb08a0oHfegiVd0/w==} + '@unpic/core@1.0.3': resolution: {integrity: sha512-aum9YNVUGso7MjGLD0Rp/08kywCGLqZ03/q6VQBFFakDBOXWEc8D4kPGcZ8v5wEnGRex3lE+++bOuucBp3KJ/w==} @@ -1811,6 +1820,10 @@ packages: buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + buffer-image-size@0.6.4: + resolution: {integrity: sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==} + engines: {node: '>=4.0'} + cac@6.7.14: resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} engines: {node: '>=8'} @@ -1970,6 +1983,10 @@ packages: resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} engines: {node: '>=0.12'} + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + error-stack-parser-es@1.0.5: resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==} @@ -2145,6 +2162,10 @@ packages: resolution: {integrity: sha512-5bJ+nf/UCpAjHM8i06fl7eLyVC9iuNAjm9qzkiu2ZGhM0VscSvS6WDPfAwkdkBuoXGM9FJSbKl6wylMwP9Ktig==} engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0} + happy-dom@21.0.4: + resolution: {integrity: sha512-tFm+GaLBgo6o0awHnvHaNoraVo27qJ+2tZEtZ+cIa08hXbeitrxAJajLI05hsUy/2/YTKcUjQYzHe5mCcraLBw==} + engines: {node: '>=20.0.0'} + harfbuzzjs@0.10.0: resolution: {integrity: sha512-SN8LVwCOzvTq3OPNd0+EAghgXugItz56wst567D1vs7LnnKGWprhi3EG58aVOBwhN8HEbQxL4I9NDWkcXUutRw==} @@ -3231,6 +3252,10 @@ packages: engines: {node: '>=18'} deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation + whatwg-mimetype@3.0.0: + resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==} + engines: {node: '>=12'} + whatwg-mimetype@4.0.0: resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} engines: {node: '>=18'} @@ -4336,6 +4361,12 @@ snapshots: '@types/statuses@2.0.6': {} + '@types/whatwg-mimetype@3.0.2': {} + + '@types/ws@8.18.2': + dependencies: + '@types/node': 22.19.17 + '@unpic/core@1.0.3': dependencies: unpic: 4.2.2 @@ -4414,7 +4445,7 @@ snapshots: msw: 2.13.4(@types/node@26.6.5)(typescript@5.9.3) sirv: 3.0.2 tinyrainbow: 1.2.0 - vitest: 2.1.9(@types/node@26.6.5)(@vitest/browser@2.1.9)(jsdom@25.0.1)(lightningcss@1.33.0)(msw@2.13.4(@types/node@26.6.5)(typescript@5.9.3))(terser@5.51.2) + vitest: 2.1.9(@types/node@26.6.5)(@vitest/browser@2.1.9)(happy-dom@21.0.4)(jsdom@25.0.1)(lightningcss@1.33.0)(msw@2.13.4(@types/node@26.6.5)(typescript@5.9.3))(terser@5.51.2) ws: 8.20.0 optionalDependencies: playwright: 1.64.1 @@ -4620,6 +4651,10 @@ snapshots: buffer-from@1.1.2: {} + buffer-image-size@0.6.4: + dependencies: + '@types/node': 22.19.17 + cac@6.7.14: {} call-bind-apply-helpers@1.0.2: @@ -4749,6 +4784,8 @@ snapshots: entities@6.0.1: {} + entities@7.0.1: {} + error-stack-parser-es@1.0.5: {} es-define-property@1.0.1: {} @@ -5013,6 +5050,19 @@ snapshots: graphql@16.13.2: {} + happy-dom@21.0.4: + dependencies: + '@types/node': 22.19.17 + '@types/whatwg-mimetype': 3.0.2 + '@types/ws': 8.18.2 + buffer-image-size: 0.6.4 + entities: 7.0.1 + whatwg-mimetype: 3.0.0 + ws: 8.22.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + harfbuzzjs@0.10.0: {} has-flag@4.0.0: {} @@ -6007,7 +6057,7 @@ snapshots: optionalDependencies: vite: 8.3.5(@types/node@26.6.5)(esbuild@0.28.2)(terser@5.51.2)(tsx@4.21.0) - vitest@2.1.9(@types/node@26.6.5)(@vitest/browser@2.1.9)(jsdom@25.0.1)(lightningcss@1.33.0)(msw@2.13.4(@types/node@26.6.5)(typescript@5.9.3))(terser@5.51.2): + vitest@2.1.9(@types/node@26.6.5)(@vitest/browser@2.1.9)(happy-dom@21.0.4)(jsdom@25.0.1)(lightningcss@1.33.0)(msw@2.13.4(@types/node@26.6.5)(typescript@5.9.3))(terser@5.51.2): dependencies: '@vitest/expect': 2.1.9 '@vitest/mocker': 2.1.9(msw@2.13.4(@types/node@26.6.5)(typescript@5.9.3))(vite@5.4.21(@types/node@26.6.5)(lightningcss@1.33.0)(terser@5.51.2)) @@ -6032,6 +6082,7 @@ snapshots: optionalDependencies: '@types/node': 26.6.5 '@vitest/browser': 2.1.9(@types/node@26.6.5)(playwright@1.64.1)(typescript@5.9.3)(vite@5.4.21(@types/node@26.6.5)(lightningcss@1.33.0)(terser@5.51.2))(vitest@2.1.9) + happy-dom: 21.0.4 jsdom: 25.0.1 transitivePeerDependencies: - less @@ -6139,6 +6190,8 @@ snapshots: dependencies: iconv-lite: 0.6.3 + whatwg-mimetype@3.0.0: {} + whatwg-mimetype@4.0.0: {} whatwg-url@14.2.0: diff --git a/scripts/size-check.mjs b/scripts/size-check.mjs index fc69f74..a79371f 100644 --- a/scripts/size-check.mjs +++ b/scripts/size-check.mjs @@ -63,11 +63,17 @@ const STORE_HYDRATION_SIZE_ALLOWANCE = { 'redact/dom-client (hydration=stub)': [3, 0], } +// Synthetic events read non-enumerable Event fields (happy-dom) on main 37c051c. +// Only the entry exceeding its limit receives the measured increment. +const EVENT_FIELDS_SIZE_ALLOWANCE = { + 'redact/dom-client (hydration=stub)': [29, 32], +} + let failed = false for (const tree of ['src', 'dist']) { for (const row of await measureSizes(tree)) { const baseBudget = BUDGETS[row.name] - const budget = baseBudget === undefined ? undefined : baseBudget + (STORE_HYDRATION_SIZE_ALLOWANCE[row.name]?.[tree === 'src' ? 0 : 1] ?? 0) + (ROOT_TRANSITION_SIZE_ALLOWANCE[row.name]?.[tree === 'src' ? 0 : 1] ?? 0) + (QUEUE_SIZE_ALLOWANCE[row.name]?.[tree === 'src' ? 0 : 1] ?? 0) + const budget = baseBudget === undefined ? undefined : baseBudget + (STORE_HYDRATION_SIZE_ALLOWANCE[row.name]?.[tree === 'src' ? 0 : 1] ?? 0) + (ROOT_TRANSITION_SIZE_ALLOWANCE[row.name]?.[tree === 'src' ? 0 : 1] ?? 0) + (QUEUE_SIZE_ALLOWANCE[row.name]?.[tree === 'src' ? 0 : 1] ?? 0) + (EVENT_FIELDS_SIZE_ALLOWANCE[row.name]?.[tree === 'src' ? 0 : 1] ?? 0) const ok = budget !== undefined && row.gz <= budget failed ||= !ok console.log(`${ok ? 'PASS' : 'FAIL'} ${tree} ${row.name}: ${row.gz} B gzip, budget ${budget ?? 'missing'}`) diff --git a/tests/package.json b/tests/package.json index 9fb5d55..643ab21 100644 --- a/tests/package.json +++ b/tests/package.json @@ -12,6 +12,7 @@ }, "devDependencies": { "@vitest/browser": "^2.1.8", + "happy-dom": "^21.0.4", "jsdom": "^25.0.1", "preact": "10.29.1", "preact-render-to-string": "^6.5.0", diff --git a/tests/synthetic-event-happy-dom.test.tsx b/tests/synthetic-event-happy-dom.test.tsx new file mode 100644 index 0000000..e535383 --- /dev/null +++ b/tests/synthetic-event-happy-dom.test.tsx @@ -0,0 +1,74 @@ +// @vitest-environment happy-dom +/** + * happy-dom defines Event fields such as `type`, `target` and `currentTarget` + * as non-enumerable class accessors. Synthetic events must still expose them, + * or controlled inputs never see `e.target.value` in happy-dom test suites. + */ +import { describe, it, expect } from 'vitest' +import * as React from 'react' +import { createRoot } from 'react-dom/client' +import { flushSync } from 'react-dom' + +function setup() { + const c = document.createElement('div') + document.body.appendChild(c) + return c +} + +describe('synthetic events in happy-dom', () => { + it('keeps the base Event fields happy-dom does not enumerate', () => { + expect(Object.keys(Event.prototype)).not.toContain('target') + const container = setup() + let captured: any = null + function App() { + const [v, setV] = React.useState('') + return ( + { + captured = e + setV(e.target.value) + }} + /> + ) + } + flushSync(() => createRoot(container).render()) + const input = container.querySelector('input')! + flushSync(() => { + input.value = 'a' + input.dispatchEvent(new Event('input', { bubbles: true })) + }) + expect(captured.type).toBe('input') + expect(captured.target).toBe(input) + expect(captured.bubbles).toBe(true) + expect(captured.composedPath()[0]).toBe(input) + expect(input.value).toBe('a') + }) + + it('reads subclass fields and currentTarget on a bubbled click', () => { + const container = setup() + let captured: any = null + let currentTarget: unknown = null + function App() { + return ( +
{ + captured = e + currentTarget = e.currentTarget + }} + > + +
+ ) + } + flushSync(() => createRoot(container).render()) + const button = container.querySelector('button')! + flushSync(() => { + button.dispatchEvent(new MouseEvent('click', { bubbles: true, shiftKey: true })) + }) + expect(captured.type).toBe('click') + expect(captured.target).toBe(button) + expect(currentTarget).toBe(container.firstChild) + expect(captured.shiftKey).toBe(true) + }) +})