diff --git a/.changeset/custom-element-properties.md b/.changeset/custom-element-properties.md new file mode 100644 index 0000000..0f964a0 --- /dev/null +++ b/.changeset/custom-element-properties.md @@ -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 ``) updates on every render rather than only +on mount. diff --git a/packages/redact/src/dom/dom.ts b/packages/redact/src/dom/dom.ts index f4737b3..f3a2292 100644 --- a/packages/redact/src/dom/dom.ts +++ b/packages/redact/src/dom/dom.ts @@ -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) diff --git a/scripts/size-check.mjs b/scripts/size-check.mjs index 187bd47..ba621c7 100644 --- a/scripts/size-check.mjs +++ b/scripts/size-check.mjs @@ -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 diff --git a/tests/custom-element-props.test.tsx b/tests/custom-element-props.test.tsx new file mode 100644 index 0000000..2b48888 --- /dev/null +++ b/tests/custom-element-props.test.tsx @@ -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()) + const el = container.firstElementChild as PropElement + flushSync(() => root.render()) + expect(el.received).toEqual([first, second]) + }) +})