diff --git a/.changeset/restore-controlled-inputs.md b/.changeset/restore-controlled-inputs.md new file mode 100644 index 0000000..0cd25dc --- /dev/null +++ b/.changeset/restore-controlled-inputs.md @@ -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)) diff --git a/.changeset/restore-unhandled-controlled-inputs.md b/.changeset/restore-unhandled-controlled-inputs.md new file mode 100644 index 0000000..f827edc --- /dev/null +++ b/.changeset/restore-unhandled-controlled-inputs.md @@ -0,0 +1,5 @@ +--- +'@tanstack/redact': patch +--- + +Restore a controlled input, textarea, select, checkbox or radio that has no `onChange` handler, so it stays read-only as in React. Clicking a controlled radio without `onChange` also restores the rest of its group. ([#115](https://github.com/TanStack/redact/issues/115)) diff --git a/packages/redact/src/dom/dom.ts b/packages/redact/src/dom/dom.ts index c282da0..821303c 100644 --- a/packages/redact/src/dom/dom.ts +++ b/packages/redact/src/dom/dom.ts @@ -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'; @@ -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[]; diff --git a/packages/redact/src/dom/features/hydration/full.ts b/packages/redact/src/dom/features/hydration/full.ts index e768033..ec81144 100644 --- a/packages/redact/src/dom/features/hydration/full.ts +++ b/packages/redact/src/dom/features/hydration/full.ts @@ -11,7 +11,7 @@ export { reactId, forkId, assignReactIds } from '../../react-ids'; import { FiberTag, type Fiber, type FiberRoot, type ReactNode, } from '../../../core'; import { hydratedFormStates } from '../../../react/form-state'; import { queueBeforeMutation, queueMutation, queueProp, queueText, onCommitRollback } from '../../commit'; -import { syncTextareaValue } from '../../form-controls'; +import { recordProps, syncTextareaValue } from '../../form-controls'; // Re-export from event-replay so all hydration concerns live behind one // feature boundary — the plugin's stub swap strips drainReplayQueue too. export { drainReplayQueue }; @@ -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') + recordProps(candidate, 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. diff --git a/packages/redact/src/dom/features/portal/full.ts b/packages/redact/src/dom/features/portal/full.ts index 0ec1b6d..a203561 100644 --- a/packages/redact/src/dom/features/portal/full.ts +++ b/packages/redact/src/dom/features/portal/full.ts @@ -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; } diff --git a/packages/redact/src/dom/form-controls.ts b/packages/redact/src/dom/form-controls.ts index d40d876..89a2c2e 100644 --- a/packages/redact/src/dom/form-controls.ts +++ b/packages/redact/src/dom/form-controls.ts @@ -1,5 +1,17 @@ import { trackInputValue } from './dom'; +import { ReactSharedInternals } from '../react'; +// Record the latest props for controlled restores. Like React, a controlled +// control restores even without an input or change handler, so it listens +// like a handler once it is controlled. +export function recordProps(el: any, props: any): void { + el.__p = props; + if ((/^(checkbox|radio)$/.test(el.type) ? props.checked : props.value) != null) { + el.addEventListener('input', queueRestore); + el.addEventListener('change', queueRestore); + } +} export function syncTextareaValue(el: HTMLTextAreaElement, props: any, mounting?: boolean): void { + recordProps(el, props); if (mounting) { const children = props.children; const initial = props.value ?? props.defaultValue ?? (Array.isArray(children) ? children[0] : children) ?? ''; @@ -12,15 +24,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 { + recordProps(el, props); if (!prev) { el.checked = el.defaultChecked = !!(props.checked ?? props.defaultChecked); } @@ -46,11 +57,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; @@ -63,7 +70,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(this: any, e: any, el: any = this): 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. +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('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 { + recordProps(select, props); const multiple = select.multiple; const value = props.value ?? ((!prev || !!prev.multiple !== multiple) ? props.defaultValue ?? (prev && []) : null); if (value == null) diff --git a/packages/redact/src/dom/root-internal.ts b/packages/redact/src/dom/root-internal.ts index 59a2a3f..d01699d 100644 --- a/packages/redact/src/dom/root-internal.ts +++ b/packages/redact/src/dom/root-internal.ts @@ -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'; @@ -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 { diff --git a/scripts/actions-browser.config.mjs b/scripts/actions-browser.config.mjs index 1bc6933..2f21d79 100644 --- a/scripts/actions-browser.config.mjs +++ b/scripts/actions-browser.config.mjs @@ -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'], }, } diff --git a/scripts/size-check.mjs b/scripts/size-check.mjs index fc69f74..e6c68c9 100644 --- a/scripts/size-check.mjs +++ b/scripts/size-check.mjs @@ -63,11 +63,37 @@ 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], +} + +// Handler-less controlled restore cost (#115) on #116 9fa1855, [source, built]. +// Pending maintainer approval. Only entries exceeding existing limits receive +// their measured increment. +const UNHANDLED_RESTORE_SIZE_ALLOWANCE = { + 'redact/dom-client (lazy=stub)': [42, 38], + 'redact/dom-client (hydration=stub)': [34, 39], +} + 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 + (UNHANDLED_RESTORE_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'}`) diff --git a/tests/issue115-controlled-restore.browser.tsx b/tests/issue115-controlled-restore.browser.tsx new file mode 100644 index 0000000..cc4dda1 --- /dev/null +++ b/tests/issue115-controlled-restore.browser.tsx @@ -0,0 +1,122 @@ +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() } +}) + +it('keeps a controlled input without a change handler read-only', async () => { + const [container, cleanup] = render(h('input', { value: 'x' })) + const field = container.querySelector('input')! + try { + await userEvent.type(field, 'y') + expect(field.value).toBe('x') + } finally { cleanup() } +}) + +it('restores the group when a radio without onChange is clicked', async () => { + const [container, cleanup] = render(h('div', null, + h('input', { type: 'radio', name: 'size', value: 's', checked: true, onChange: () => {} }), + h('input', { type: 'radio', name: 'size', value: 'm', checked: false }), + )) + const [small, medium] = [...container.querySelectorAll('input')] + try { + await userEvent.click(medium!) + expect(small!.checked).toBe(true) + expect(medium!.checked).toBe(false) + } finally { cleanup() } +}) + +// A checkbox fires input before change. +it('lets onChange on a controlled checkbox read the click', async () => { + const seen: boolean[] = [] + const [container, cleanup] = render(h('input', { + type: 'checkbox', + checked: false, + 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 { cleanup() } +}) diff --git a/tests/issue115-controlled-restore.test.tsx b/tests/issue115-controlled-restore.test.tsx new file mode 100644 index 0000000..32dd5fa --- /dev/null +++ b/tests/issue115-controlled-restore.test.tsx @@ -0,0 +1,337 @@ +import { afterEach, expect, it } from 'vitest' +import { act, createElement as h, useState } from 'react' +import { createRoot, hydrateRoot } from 'react-dom/client' +import { createPortal, flushSync } from 'react-dom' +import { renderToString } from 'react-dom/server' + +const cleanups: Array<() => void> = [] +afterEach(() => { for (const cleanup of cleanups.splice(0)) cleanup() }) + +function mount(element: any): HTMLElement { + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + flushSync(() => root.render(element)) + cleanups.push(() => { flushSync(() => root.unmount()); container.remove() }) + return container +} + +async function hydrate(element: any): Promise { + const container = document.createElement('div') + container.innerHTML = renderToString(element) + document.body.appendChild(container) + const root = hydrateRoot(container, element) + cleanups.push(() => { flushSync(() => root.unmount()); container.remove() }) + await settle() + return container +} + +// Updates from an event handler flush in a microtask, as do restores. +const settle = () => new Promise(resolve => setTimeout(resolve)) + +const type = async (field: HTMLInputElement | HTMLTextAreaElement, value: string) => { + field.value = value + field.dispatchEvent(new Event('input', { bubbles: true })) + await settle() +} + +const click = async (input: HTMLInputElement) => { + input.click() + await settle() +} + +function Digits({ tag = 'input', seen = [] }: { tag?: string, seen?: string[] }) { + const [value, setValue] = useState('') + return h(tag, { + value, + onChange: (event: any) => { + seen.push(event.target.value) + if (/^\d*$/.test(event.target.value)) setValue(event.target.value) + }, + }) +} + +it.each(['input', 'textarea'])('restores a controlled %s value that onChange rejected', async tag => { + const seen: string[] = [] + const field = mount(h(Digits, { tag, seen })).firstChild as HTMLInputElement + await type(field, '1') + await type(field, '1a') + expect(field.value).toBe('1') + await type(field, 'abc') + expect(field.value).toBe('1') + expect(seen).toEqual(['1', '1a', 'abc']) + // The restored value is the renderer's, so blur reports no change. + field.dispatchEvent(new Event('change', { bubbles: true })) + await settle() + expect(seen).toEqual(['1', '1a', 'abc']) +}) + +it('restores after updates that flushSync renders', async () => { + const field = mount(h(Digits)).firstChild as HTMLInputElement + for (const value of ['1', '1a']) { + field.value = value + flushSync(() => field.dispatchEvent(new Event('input', { bubbles: true }))) + } + await settle() + expect(field.value).toBe('1') +}) + +function Text() { + const [value, setValue] = useState('ac') + return h('input', { value, onChange: (event: any) => setValue(event.target.value) }) +} + +it('keeps the caret of an accepted edit', async () => { + const field = mount(h(Text)).firstChild as HTMLInputElement + field.value = 'abc' + field.setSelectionRange(2, 2) + field.dispatchEvent(new Event('input', { bubbles: true })) + await settle() + expect(field.value).toBe('abc') + expect(field.selectionStart).toBe(2) +}) + +it('lets onChangeCapture and onChange both read the typed value', async () => { + const seen: string[] = [] + const field = mount(h('input', { + value: '', + onChangeCapture: (event: any) => seen.push('capture ' + event.target.value), + onChange: (event: any) => seen.push('bubble ' + event.target.value), + })).firstChild as HTMLInputElement + await type(field, 'a') + expect(seen).toEqual(['capture a', 'bubble a']) + expect(field.value).toBe('') +}) + +it('restores a value rejected by onChangeCapture alone', async () => { + const field = mount(h('input', { value: 'x', onChangeCapture: () => {} })).firstChild as HTMLInputElement + await type(field, 'xy') + expect(field.value).toBe('x') +}) + +it('keeps an equivalent spelling of a controlled number', async () => { + const field = mount(h('input', { type: 'number', value: 1, onChange: () => {} })).firstChild as HTMLInputElement + await type(field, '1.0') + expect(field.value).toBe('1.0') + await type(field, '2') + expect(field.value).toBe('1') +}) + +it('leaves an uncontrolled input alone', async () => { + const field = mount(h('input', { defaultValue: '', onChange: () => {} })).firstChild as HTMLInputElement + await type(field, 'a') + expect(field.value).toBe('a') +}) + +it.each([false, true])('restores checked=%s on a checkbox when onChange ignores the click', async checked => { + const seen: boolean[] = [] + const box = mount(h('input', { + type: 'checkbox', + checked, + onChange: (event: any) => seen.push(event.target.checked), + })).firstChild as HTMLInputElement + await click(box) + expect(seen).toEqual([!checked]) + expect(box.checked).toBe(checked) +}) + +it('restores the checked radio of a group when onChange ignores the click', async () => { + const container = mount(h('div', null, + h('input', { type: 'radio', name: 'size', value: 's', checked: true, onChange: () => {} }), + h('input', { type: 'radio', name: 'size', value: 'm', checked: false, onChange: () => {} }), + )) + const [small, medium] = [...container.querySelectorAll('input')] as [HTMLInputElement, HTMLInputElement] + await click(medium) + expect(small.checked).toBe(true) + expect(medium.checked).toBe(false) +}) + +it('follows a radio group that onChange accepts', async () => { + function Sizes() { + const [size, setSize] = useState('s') + return h('div', null, ['s', 'm'].map(value => h('input', { + key: value, type: 'radio', name: 'size', value, + checked: size === value, + onChange: (event: any) => setSize(event.target.value), + }))) + } + const [small, medium] = [...mount(h(Sizes)).querySelectorAll('input')] as [HTMLInputElement, HTMLInputElement] + await click(medium) + expect(small.checked).toBe(false) + expect(medium.checked).toBe(true) +}) + +it('restores after updates that act renders, keeping the caret', async () => { + const digits = mount(h(Digits)).firstChild as HTMLInputElement + for (const value of ['1', '1a']) { + await act(async () => { + digits.value = value + digits.dispatchEvent(new Event('input', { bubbles: true })) + }) + } + expect(digits.value).toBe('1') + const text = mount(h(Text)).firstChild as HTMLInputElement + await act(async () => { + text.value = 'abc' + text.setSelectionRange(2, 2) + text.dispatchEvent(new Event('input', { bubbles: true })) + }) + expect(text.selectionStart).toBe(2) +}) + +it.each(['input', 'textarea'])('restores a hydrated %s', async tag => { + const field = (await hydrate(h(Digits, { tag }))).firstChild as HTMLInputElement + await type(field, 'a') + expect(field.value).toBe('') + await type(field, '1') + expect(field.value).toBe('1') +}) + +it('restores a hydrated radio group', async () => { + const container = await hydrate(h('div', null, + h('input', { type: 'radio', name: 'size', value: 's', checked: true, onChange: () => {} }), + h('input', { type: 'radio', name: 'size', value: 'm', checked: false, onChange: () => {} }), + )) + const [small, medium] = [...container.querySelectorAll('input')] as [HTMLInputElement, HTMLInputElement] + await click(medium) + expect(small.checked).toBe(true) + expect(medium.checked).toBe(false) +}) + +it('restores a value rejected by onInput alone', async () => { + const field = mount(h('input', { value: 'x', onInput: () => {} })).firstChild as HTMLInputElement + await type(field, 'xy') + expect(field.value).toBe('x') +}) + +it('restores a controlled select when onChange ignores the choice', async () => { + const select = mount(h('select', { value: 'a', onChange: () => {} }, + h('option', { value: 'a' }, 'A'), + h('option', { value: 'b' }, 'B'), + )).firstChild as HTMLSelectElement + select.value = 'b' + select.dispatchEvent(new Event('change', { bubbles: true })) + await settle() + expect(select.value).toBe('a') +}) + +it('restores when onChange stops propagation', async () => { + const field = mount(h('input', { value: 'x', onChange: (event: any) => event.stopPropagation() })).firstChild as HTMLInputElement + await type(field, 'xy') + expect(field.value).toBe('x') +}) + +it('restores when onChangeCapture stops propagation before onChange', async () => { + const seen: string[] = [] + const field = mount(h('input', { + value: 'x', + onChangeCapture: (event: any) => event.stopPropagation(), + onChange: () => seen.push('bubble'), + })).firstChild as HTMLInputElement + await type(field, 'xy') + expect(seen).toEqual([]) + expect(field.value).toBe('x') +}) + +it('restores when an ancestor\'s onInput stops propagation', async () => { + const container = mount(h('div', { onInput: (event: any) => event.stopPropagation() }, + h('input', { value: 'x', onChange: () => {} }), + )) + const field = container.querySelector('input')! + await type(field, 'xy') + expect(field.value).toBe('x') +}) + +it('restores when a native listener on the container stops propagation', async () => { + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + container.addEventListener('input', event => event.stopPropagation()) + flushSync(() => root.render(h('input', { value: 'x', onChange: () => {} }))) + cleanups.push(() => { flushSync(() => root.unmount()); container.remove() }) + const field = container.firstChild as HTMLInputElement + await type(field, 'xy') + expect(field.value).toBe('x') +}) + +it('restores after an input event that does not bubble', async () => { + const field = mount(h('input', { value: 'x', onChange: () => {} })).firstChild as HTMLInputElement + field.value = 'xy' + field.dispatchEvent(new Event('input')) + await settle() + expect(field.value).toBe('x') +}) + +it('restores a checkbox rendered in a shadow root', async () => { + const host = document.createElement('div') + document.body.appendChild(host) + const container = host.attachShadow({ mode: 'open' }).appendChild(document.createElement('div')) + const root = createRoot(container) + flushSync(() => root.render(h('input', { type: 'checkbox', checked: false, onChange: () => {} }))) + cleanups.push(() => { flushSync(() => root.unmount()); host.remove() }) + const box = container.firstChild as HTMLInputElement + await click(box) + expect(box.checked).toBe(false) +}) + +it('restores an input portaled into a detached container', async () => { + const target = document.createElement('div') + mount(createPortal(h('input', { value: 'x', onChange: () => {} }), target)) + const field = target.firstChild as HTMLInputElement + await type(field, 'xy') + expect(field.value).toBe('x') +}) + +it('restores a checkbox whose only handler is onInput', async () => { + const box = mount(h('input', { type: 'checkbox', checked: false, onInput: () => {} })).firstChild as HTMLInputElement + await click(box) + expect(box.checked).toBe(false) +}) + +it.each(['input', 'textarea'])('keeps a controlled %s without a change handler read-only', async tag => { + const field = mount(h(tag, { value: 'x' })).firstChild as HTMLInputElement + await type(field, 'xy') + expect(field.value).toBe('x') +}) + +it.each([false, true])('keeps checked=%s on a checkbox without onChange', async checked => { + const box = mount(h('input', { type: 'checkbox', checked, onClick: () => {} })).firstChild as HTMLInputElement + await click(box) + expect(box.checked).toBe(checked) +}) + +it('keeps a controlled select without onChange', async () => { + const select = mount(h('select', { value: 'a' }, + h('option', { value: 'a' }, 'A'), + h('option', { value: 'b' }, 'B'), + )).firstChild as HTMLSelectElement + select.value = 'b' + select.dispatchEvent(new Event('change', { bubbles: true })) + await settle() + expect(select.value).toBe('a') +}) + +it('restores the group when a radio without onChange is clicked', async () => { + const seen: string[] = [] + const container = mount(h('div', null, + h('input', { type: 'radio', name: 'size', value: 's', checked: true, onChange: () => seen.push('s') }), + h('input', { type: 'radio', name: 'size', value: 'm', checked: false }), + )) + const [small, medium] = [...container.querySelectorAll('input')] as [HTMLInputElement, HTMLInputElement] + await click(medium) + expect(small.checked).toBe(true) + expect(medium.checked).toBe(false) + expect(seen).toEqual([]) +}) + +it('keeps a hydrated controlled input without a change handler read-only', async () => { + const field = (await hydrate(h('input', { value: 'x' }))).firstChild as HTMLInputElement + await type(field, 'xy') + expect(field.value).toBe('x') +}) + +it('leaves an uncontrolled input without a change handler alone', async () => { + const field = mount(h('input', { defaultValue: '' })).firstChild as HTMLInputElement + await type(field, 'a') + expect(field.value).toBe('a') +})