Skip to content
Draft
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
5 changes: 5 additions & 0 deletions .changeset/detached-checkbox-change.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/redact': patch
---

Call `onChange` for a checkbox or radio clicked in a container that is not in the document, and restore it when controlled, as React does. Browsers fire no `change` event there.
5 changes: 5 additions & 0 deletions .changeset/restore-controlled-inputs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/redact': patch
---

Restore a controlled input's, textarea's or select's rendered value, and a checkbox's or radio group's checked state, after its change handlers run, including after hydration. An edit, click or choice the app does not accept no longer stays in the DOM. ([#115](https://github.com/TanStack/redact/issues/115))
31 changes: 25 additions & 6 deletions packages/redact/src/dom/dom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { createSyntheticEvent } from './synthetic-event';
import { UNITLESS_STYLE } from '../core/unitless-style';
import { devToolsMetadata } from '../core/devtools';
import { formActions } from './form-actions';
import { queueRestore, scheduleControlledRestore } from './form-controls';
import { attributeName, attributeValue } from '../core/attributes';
import { isString } from "../core/functions";
const SVG_NS = 'http://www.w3.org/2000/svg';
Expand Down Expand Up @@ -112,10 +113,13 @@ function setEventHandler(el: Element, name: string, next: any): void {
const enabled = isFunction(next);
const capture = name.endsWith('Capture') && !name.endsWith('PointerCapture');
const lower = name.slice(2, capture ? -7 : undefined).toLowerCase();
const events = lower === 'change' && (el.tagName === 'TEXTAREA' ||
(el.tagName === 'INPUT' && !/^(checkbox|radio|file)$/.test((el as HTMLInputElement).type)))
? ['input', 'change']
: [lower === 'focus' ? 'focusin' : lower === 'blur' ? 'focusout' : lower === 'doubleclick' ? 'dblclick' : lower];
const type = (el as HTMLInputElement).type;
// Browsers fire no `change` for a checkbox or radio clicked outside a
// document; React listens for `click` there, so onChange still runs.
const events = lower !== 'change' ? [lower === 'focus' ? 'focusin' : lower === 'blur' ? 'focusout' : lower === 'doubleclick' ? 'dblclick' : lower]
: /^(checkbox|radio)$/.test(type) ? ['click', 'change']
: el.tagName === 'TEXTAREA' || el.tagName === 'INPUT' && type !== 'file' ? ['input', 'change']
: ['change'];
// Key by the React prop name (not DOM event name) so `onChange` and
// `onInput` on the same text input — which both dispatch from the native
// `input` event — can coexist without clobbering each other's handlers.
Expand All @@ -132,7 +136,15 @@ function setEventHandler(el: Element, name: string, next: any): void {
return;
if (!existing) {
const entry = ((e: Event) => {
if (events.length > 1) {
const click = e.type === 'click' && events[1];
if (click) {
// Only a click that checked a detached radio or toggled a
// detached checkbox; attached ones get their `change` event.
const props = (el as any).__p || {};
if (el.isConnected || (el as HTMLInputElement).type === 'radio' && (props.checked ?? (el as any).__k ?? props.defaultChecked))
return;
}
else if (events[0] === 'input') {
const value = (el as HTMLInputElement).value;
if (e.type === 'change' && (el as any).__v === value)
return;
Expand All @@ -145,7 +157,14 @@ function setEventHandler(el: Element, name: string, next: any): void {
devToolsMetadata.current = devToolsMetadata.hosts.get(el) || null;
try { entry.c(event); } finally { devToolsMetadata.current = previous; }
} else entry.c(event);
} finally { event.currentTarget = null; }
} finally {
event.currentTarget = null;
// A detached click has no container to reach, so it restores here.
if (click)
scheduleControlledRestore(el as HTMLInputElement);
else
queueRestore(e, el);
}
}) as ((e: Event) => void) & {
c: Function;
e: string[];
Expand Down
3 changes: 3 additions & 0 deletions packages/redact/src/dom/features/hydration/full.ts
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,9 @@ export function adoptHostDom(fiber: Fiber, parent: Fiber): boolean {
// Form defaults precede event registration so input type owns event mapping.
if (tag === 'input' || tag === 'textarea')
queueHydrationFormState(candidate as Element, props, tag);
// Hydration skips the form syncs, which record props for controlled restores.
if (tag === 'input' || tag === 'textarea' || tag === 'select')
(candidate as any).__p = props;
hydrateProps(fiber, candidate as Element, props, tag, isSvg);
// React SSR adds transport fields for progressively enhanced Server Actions.
// They have no corresponding client fibers and must not consume child slots.
Expand Down
4 changes: 3 additions & 1 deletion packages/redact/src/dom/features/portal/full.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { FiberTag, type Fiber, type ReactNode } from '../../../core';
import { REACT_PORTAL_TYPE } from '../../../react';
import { listenForRestores } from '../../form-controls';
import { installRenderer, registerTypeMatcher, registerElementMarker, reconcileRaw, childrenToArray, } from '../../reconcile';
function renderPortal(fiber: Fiber): void {
const { children } = fiber.v as {
const { children, container } = fiber.v as {
children: ReactNode;
container: Element;
};
listenForRestores(container);
reconcileRaw(fiber, children);
fiber.m = fiber.v;
}
Expand Down
73 changes: 64 additions & 9 deletions packages/redact/src/dom/form-controls.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { trackInputValue } from './dom';
import { ReactSharedInternals } from '../react';
export function syncTextareaValue(el: HTMLTextAreaElement, props: any, mounting?: boolean): void {
(el as any).__p = props;
if (mounting) {
const children = props.children;
const initial = props.value ?? props.defaultValue ?? (Array.isArray(children) ? children[0] : children) ?? '';
Expand All @@ -12,15 +14,14 @@ export function syncTextareaValue(el: HTMLTextAreaElement, props: any, mounting?
return;
}
const value = props.value;
if (value != null && el.value !== '' + value) {
el.value = '' + value;
trackInputValue(el);
}
if (value != null)
writeInputValue(el, value);
const fallback = props.defaultValue ?? value ?? '';
if (el.defaultValue !== '' + fallback)
el.defaultValue = '' + fallback;
}
export function syncInputValue(el: HTMLInputElement, props: any, prev?: any): void {
(el as any).__p = props;
if (!prev) {
el.checked = el.defaultChecked = !!(props.checked ?? props.defaultChecked);
}
Expand All @@ -46,11 +47,7 @@ export function syncInputValue(el: HTMLInputElement, props: any, prev?: any): vo
return;
}
if (value != null) {
// Number inputs keep equivalent user spelling, including trailing zeroes.
if (el.type === 'number' ? (value === 0 && el.value === '') || el.value != value : el.value !== '' + value) {
el.value = '' + value;
trackInputValue(el);
}
writeInputValue(el, value);
const nextDefault = el.type === 'number' && el.value == value ? el.value : '' + value;
if (el.defaultValue !== nextDefault)
el.defaultValue = nextDefault;
Expand All @@ -63,7 +60,65 @@ export function syncInputValue(el: HTMLInputElement, props: any, prev?: any): vo
el.removeAttribute('value');
}
}
function writeInputValue(el: HTMLInputElement | HTMLTextAreaElement, value: any): void {
// Number inputs keep equivalent user spelling, including trailing zeroes.
if (el.type === 'number' ? (value === 0 && el.value === '') || el.value != value : el.value !== '' + value) {
el.value = '' + value;
trackInputValue(el);
}
}
// Like React, restore from the root and portal containers, after every listener
// on the control and its ancestors has read the edit. A checkbox, radio or
// select fires input before change, so with a change listener it restores
// after change.
export function queueRestore(e: any, el: any): void {
if (el.__p && (e.type === 'change' || e.type === 'input' && !(/^(checkbox|radio|select|file)/.test(el.type) && Object.values(el.__h).some((other: any) => other?.e.includes('change')))))
e.__r = el;
// A stopped or non-bubbling event never reaches the container.
if (e.cancelBubble || !e.bubbles)
restorePending(e);
}
export function listenForRestores(container: Node): void {
container.addEventListener('input', restorePending);
container.addEventListener('change', restorePending);
}
function restorePending(e: any): void {
if (e.__r)
scheduleControlledRestore(e.__r);
e.__r = null;
}
// Like React, write the rendered value and checked state back after onChange,
// so an edit or click the app did not accept never stays in the DOM. Updates
// from the handler flush in a microtask (or act's queue) queued before this
// one, so the restore reads the props they render.
export function scheduleControlledRestore(el: HTMLInputElement): void {
if (!(el as any).__p)
return;
const restore = () => {
// Clicking a radio unchecks the rest of its group.
for (const input of el.type === 'radio' && el.name ? (el.getRootNode() as ParentNode).querySelectorAll<HTMLInputElement>('input[type=radio]') : [el]) {
const props = (input as any).__p;
if (!props || input.name !== el.name)
continue;
if (input.tagName === 'SELECT')
syncSelectValue(input as any, props, props);
else {
if (props.value != null)
writeInputValue(input, props.value);
if (props.checked != null)
input.checked = !!props.checked;
// Detached radios compare a click against this.
(input as any).__k = input.checked;
}
}
};
if (process.env.NODE_ENV !== 'production' && ReactSharedInternals.A)
ReactSharedInternals.A.push(restore);
else
queueMicrotask(restore);
}
export function syncSelectValue(select: HTMLSelectElement, props: any, prev?: any): void {
(select as any).__p = props;
const multiple = select.multiple;
const value = props.value ?? ((!prev || !!prev.multiple !== multiple) ? props.defaultValue ?? (prev && []) : null);
if (value == null)
Expand Down
2 changes: 2 additions & 0 deletions packages/redact/src/dom/root-internal.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { listenForRestores } from './form-controls';
import { FiberTag, createFiber, type Fiber, type FiberRoot, type RecoverableErrorHandler } from '../core';
export interface RootOptionsInternal {
identifierStrategy?: 'react';
Expand All @@ -19,6 +20,7 @@ export function createFiberRoot(container: Element | Document | DocumentFragment
if (options.identifierStrategy === 'react')
root.ri = true;
attachRootFiber(root, container);
listenForRestores(container);
return root;
}
export function attachRootFiber(root: FiberRoot, container: Element | Document | DocumentFragment): void {
Expand Down
2 changes: 1 addition & 1 deletion scripts/actions-browser.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,6 @@ export default {
cacheDir: resolve('node_modules/.vite/actions-browser'),
test: {
...config.test,
include: ['actions.test.tsx', 'transition-retention.test.tsx', 'activity-transition.test.tsx', 'select-defaults.test.tsx', 'event-parity.test.tsx', 'lazy-wrapped-components.test.tsx', 'thenable-children.test.tsx', 'root-suspension.test.tsx', 'find-dom-node.test.tsx', 'document-shell.test.tsx'],
include: ['actions.test.tsx', 'transition-retention.test.tsx', 'activity-transition.test.tsx', 'select-defaults.test.tsx', 'event-parity.test.tsx', 'lazy-wrapped-components.test.tsx', 'thenable-children.test.tsx', 'root-suspension.test.tsx', 'find-dom-node.test.tsx', 'document-shell.test.tsx', 'issue115-controlled-restore.browser.tsx', 'detached-checkbox-change.browser.tsx'],
},
}
29 changes: 28 additions & 1 deletion scripts/size-check.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -63,11 +63,38 @@ const STORE_HYDRATION_SIZE_ALLOWANCE = {
'redact/dom-client (hydration=stub)': [3, 0],
}

// Controlled input restore cost (#115) on main 37c051c, [source, built].
// Pending maintainer approval. Only entries exceeding existing limits receive
// their measured increment.
const CONTROLLED_RESTORE_SIZE_ALLOWANCE = {
'redact/dom-client': [253, 278],
'client total': [264, 259],
'client total (default Vite)': [286, 309],
'redact/dom-client (activity=stub)': [270, 267],
'redact/dom-client (fragmentRefs=stub)': [310, 323],
'redact/dom-client (viewTransitions=stub)': [330, 334],
'redact/dom-client (portal=stub)': [240, 261],
'redact/dom-client (context=stub)': [252, 280],
'redact/dom-client (memo=stub)': [277, 286],
'redact/dom-client (forwardRef=stub)': [261, 268],
'redact/dom-client (hydration=stub)': [296, 286],
'redact/dom-client (nano)': [261, 262],
}

// Detached checkbox and radio onChange cost on #116 (9fa1855), [source, built].
// Pending maintainer approval. Only entries exceeding existing limits receive
// their measured increment.
const DETACHED_CHANGE_SIZE_ALLOWANCE = {
'redact/dom-client (lazy=stub)': [84, 77],
'redact/dom-client (classComponents=stub)': [73, 79],
'redact/dom-client (hydration=stub)': [86, 94],
}

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 + (DETACHED_CHANGE_SIZE_ALLOWANCE[row.name]?.[tree === 'src' ? 0 : 1] ?? 0) + (CONTROLLED_RESTORE_SIZE_ALLOWANCE[row.name]?.[tree === 'src' ? 0 : 1] ?? 0) + (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 ok = budget !== undefined && row.gz <= budget
failed ||= !ok
console.log(`${ok ? 'PASS' : 'FAIL'} ${tree} ${row.name}: ${row.gz} B gzip, budget ${budget ?? 'missing'}`)
Expand Down
86 changes: 86 additions & 0 deletions tests/detached-checkbox-change.browser.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { afterEach, expect, it } from 'vitest'
import { userEvent } from '@vitest/browser/context'
import { createElement as h, useState } from 'react'
import { createRoot } from 'react-dom/client'
import { flushSync } from 'react-dom'

const cleanups: Array<() => void> = []
afterEach(() => { for (const cleanup of cleanups.splice(0)) cleanup() })

function mount(element: any, attached: boolean): HTMLElement {
const container = document.createElement('div')
if (attached) document.body.appendChild(container)
const root = createRoot(container)
flushSync(() => root.render(element))
cleanups.push(() => { flushSync(() => root.unmount()); container.remove() })
return container
}

const settle = () => new Promise(resolve => setTimeout(resolve))

function Form({ seen, accept }: { seen: string[], accept: boolean }) {
const [checked, setChecked] = useState(false)
const [value, setValue] = useState('a')
return h('div', null,
h('input', {
type: 'checkbox',
checked,
onChange: (event: any) => {
seen.push('checkbox ' + event.target.checked)
if (accept) setChecked(event.target.checked)
},
}),
['a', 'b'].map(key => h('input', {
key,
type: 'radio',
name: 'browser-' + accept,
checked: value === key,
onChange: () => {
seen.push('radio ' + key)
if (accept) setValue(key)
},
})),
h('input', {
type: 'checkbox',
onChange: () => seen.push('prevented'),
onClick: (event: any) => event.preventDefault(),
}),
)
}

for (const accept of [true, false]) {
it(`handles real clicks on attached controls (accept: ${accept})`, async () => {
const seen: string[] = []
const [checkbox, a, b, prevented] = [...mount(h(Form, { seen, accept }), true).querySelectorAll('input')] as [HTMLInputElement, HTMLInputElement, HTMLInputElement, HTMLInputElement]
for (const input of [checkbox, a, b, b, prevented]) {
await userEvent.click(input)
await settle()
}
expect(seen).toEqual(accept
? ['checkbox true', 'radio b']
: ['checkbox true', 'radio b', 'radio b'])
expect([checkbox.checked, a.checked, b.checked, prevented.checked]).toEqual(accept
? [true, false, true, false]
: [false, true, false, false])
})

// Browsers fire no change event here, so onChange comes from the click.
it(`handles clicks on detached controls (accept: ${accept})`, async () => {
const seen: string[] = []
const container = mount(h(Form, { seen, accept }), false)
const native: string[] = []
container.addEventListener('change', () => native.push('change'))
const [checkbox, a, b] = [...container.querySelectorAll('input')] as [HTMLInputElement, HTMLInputElement, HTMLInputElement]
for (const input of [checkbox, a, b, b]) {
input.click()
await settle()
}
expect(native).toEqual([])
expect(seen).toEqual(accept
? ['checkbox true', 'radio b']
: ['checkbox true', 'radio b', 'radio b'])
expect([checkbox.checked, a.checked, b.checked]).toEqual(accept
? [true, false, true]
: [false, true, false])
})
}
Loading