Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/custom-element-properties.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'@tanstack/redact': patch
---

Assign custom element props as properties, the way React 19 does

A prop on a tag whose name contains a dash is now set as a property when the
element declares one, and only falls back to an attribute otherwise. Object and
function values reach web components intact instead of being stringified into
`[object Object]`, so a component that reads its data from a property (for
example `<number-flow-react data={…}>`) updates on every render rather than only
on mount.
10 changes: 10 additions & 0 deletions packages/redact/src/dom/dom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,16 @@ export function setProp(
return
}

// React assigns a prop on a dashed tag as a property when the element
// declares one — the only way an object value reaches a web component. Its
// other dashed exclusions are SVG, already ruled out by `isSvg`.
if (!isSvg && name in el && el.localName.indexOf('-') !== -1 && el.localName !== 'annotation-xml') {
try {
;(el as any)[name] = next
return
} catch {}
}

if (name === 'className' || name === 'class' || name === 'htmlFor' || STRING_BOOLEAN_ATTRS.has(attr)) {
if (next == null) el.removeAttribute(attr)
else el.setAttribute(attr, '' + next)
Expand Down
22 changes: 11 additions & 11 deletions scripts/size-check.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,22 +8,22 @@ const BUDGETS = {
'redact': 2793,
'redact/jsx-runtime': 217,
'redact/dom': 25246,
'redact/dom-client': 25006,
'redact/dom-client': 25030,
'redact/server': 8989,
'client total': 28183,
'client total (default Vite)': 23335,
'redact/dom-client (activity=stub)': 24438,
'redact/dom-client (fragmentRefs=stub)': 22814,
'redact/dom-client (viewTransitions=stub)': 20159,
'redact/dom-client (portal=stub)': 24972,
'redact/dom-client (context=stub)': 24719,
'client total (default Vite)': 23377,
'redact/dom-client (activity=stub)': 24461,
'redact/dom-client (fragmentRefs=stub)': 22842,
'redact/dom-client (viewTransitions=stub)': 20219,
'redact/dom-client (portal=stub)': 24998,
'redact/dom-client (context=stub)': 24741,
'redact/dom-client (suspense=stub)': 23479,
'redact/dom-client (memo=stub)': 24912,
'redact/dom-client (memo=stub)': 24944,
'redact/dom-client (forwardRef=stub)': 24973,
'redact/dom-client (lazy=stub)': 24983,
'redact/dom-client (lazy=stub)': 25011,
'redact/dom-client (classComponents=stub)': 24512,
'redact/dom-client (hydration=stub)': 22400,
'redact/dom-client (nano)': 12491,
'redact/dom-client (hydration=stub)': 22425,
'redact/dom-client (nano)': 12524,
}

let failed = false
Expand Down
64 changes: 64 additions & 0 deletions tests/custom-element-props.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { describe, expect, it } from 'vitest'
import * as React from 'react'
import { createRoot } from 'react-dom/client'
import { flushSync } from 'react-dom'
import { setProp } from '../packages/redact/src/dom/dom'

class PropElement extends HTMLElement {
received: unknown[] = []
#payload: unknown

get payload(): unknown {
return this.#payload
}

set payload(value: unknown) {
this.#payload = value
this.received.push(value)
}
}

customElements.define('prop-element', PropElement)

function setup() {
const container = document.createElement('div')
document.body.appendChild(container)
return container
}

describe('custom element props', () => {
it('assigns declared properties instead of stringifying them', () => {
const el = document.createElement('prop-element') as PropElement
const payload = { value: 1 }
setProp(el, 'payload', payload, undefined, false)
expect(el.payload).toBe(payload)
expect(el.hasAttribute('payload')).toBe(false)
})

it('leaves undeclared names, aria and data props as attributes', () => {
const el = document.createElement('prop-element')
setProp(el, 'undeclared', 'text', undefined, false)
setProp(el, 'data-mask', 'on', undefined, false)
setProp(el, 'aria-label', 'label', undefined, false)
expect(el.getAttribute('undeclared')).toBe('text')
expect(el.getAttribute('data-mask')).toBe('on')
expect(el.getAttribute('aria-label')).toBe('label')
})

it('treats dashed SVG and MathML tags as ordinary elements', () => {
const el = document.createElementNS('http://www.w3.org/1998/Math/MathML', 'annotation-xml')
setProp(el, 'id', 'x', undefined, false)
expect(el.getAttribute('id')).toBe('x')
})

it('pushes every update through, not just the first', () => {
const container = setup()
const root = createRoot(container)
const first = { count: 1 }
const second = { count: 2 }
flushSync(() => root.render(<prop-element payload={first} />))
const el = container.firstElementChild as PropElement
flushSync(() => root.render(<prop-element payload={second} />))
expect(el.received).toEqual([first, second])
})
})