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(