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
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))
6 changes: 5 additions & 1 deletion 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 } 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 @@ -145,7 +146,10 @@ 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;
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
71 changes: 62 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,63 @@ 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;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
// 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.
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;
}
}
};
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'],
},
}
20 changes: 19 additions & 1 deletion scripts/size-check.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -63,11 +63,29 @@ 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],
}

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 + (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
85 changes: 85 additions & 0 deletions tests/issue115-controlled-restore.browser.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { 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'

// Real typing runs microtasks between listeners, which dispatchEvent never does.
it('lets every listener on a controlled input read the typed edit', async () => {
const container = document.createElement('div')
document.body.appendChild(container)
const root = createRoot(container)
const seen: string[] = []
flushSync(() => root.render(h('input', {
value: '',
onChange: (event: any) => seen.push('change ' + event.target.value),
onInput: (event: any) => seen.push('input ' + event.target.value),
})))
const field = container.querySelector('input')!
try {
await userEvent.type(field, 'a')
expect(seen.sort()).toEqual(['change a', 'input a'])
expect(field.value).toBe('')
} finally {
flushSync(() => root.unmount())
container.remove()
}
})

it('lets onChange on a checkbox with onInput read the click', async () => {
const container = document.createElement('div')
document.body.appendChild(container)
const root = createRoot(container)
const seen: boolean[] = []
flushSync(() => root.render(h('input', {
type: 'checkbox',
checked: false,
onInput: () => {},
onChange: (event: any) => seen.push(event.target.checked),
})))
const box = container.querySelector('input')!
try {
await userEvent.click(box)
expect(seen).toEqual([true])
expect(box.checked).toBe(false)
} finally {
flushSync(() => root.unmount())
container.remove()
}
})

function render(element: any): [HTMLElement, () => void] {
const container = document.createElement('div')
document.body.appendChild(container)
const root = createRoot(container)
flushSync(() => root.render(element))
return [container, () => { flushSync(() => root.unmount()); container.remove() }]
}

// React restores after its root listener, which runs after the control's own.
it('lets a native listener added after Redact\'s read the typed edit', async () => {
const seen: string[] = []
const [container, cleanup] = render(h('input', { value: '', onChange: () => {} }))
const field = container.querySelector('input')!
field.addEventListener('input', () => seen.push(field.value))
try {
await userEvent.type(field, 'a')
expect(seen).toEqual(['a'])
expect(field.value).toBe('')
} finally { cleanup() }
})

it('keeps an edit that an ancestor\'s onInput accepts', async () => {
function Form() {
const [value, setValue] = useState('')
return h('div', { onInput: (event: any) => setValue(event.target.value) },
h('input', { value, onChange: () => {} }))
}
const [container, cleanup] = render(h(Form))
const field = container.querySelector('input')!
try {
await userEvent.type(field, 'ab')
expect(field.value).toBe('ab')
} finally { cleanup() }
})

Loading