Skip to content

Commit

Permalink
Add deeper controlled input support in compat (#3926)
Browse files Browse the repository at this point in the history
* Add deeper controlled input support in compat

* Add controlled inputs demo

* Replace _evented option hook with calling a callback in a microtask in the event option hook

* Use a callback instead of args array

* Remove _evented from mangle.json
  • Loading branch information
andrewiggins authored Apr 6, 2023
1 parent a9d9c64 commit 59f7f7c
Show file tree
Hide file tree
Showing 6 changed files with 345 additions and 438 deletions.
61 changes: 60 additions & 1 deletion compat/src/render.js
Original file line number Diff line number Diff line change
Expand Up @@ -86,12 +86,45 @@ export function hydrate(vnode, parent, callback) {
let oldEventHook = options.event;
options.event = e => {
if (oldEventHook) e = oldEventHook(e);

/** @type {ControlledTarget} */
const target = e.currentTarget;
const eventType = e.type;
if (
(eventType === 'input' || eventType === 'change') &&
target._isControlled
) {
// Note: We can't just send the event to the afterEvent function because
// some properties on the event (e.g. currentTarget) will be changed by the
// time afterEvent is called. `currentTarget` will be `null` at that point.
// The browser reuses event objects for event handlers and just modifies the
// relevant properties before invoking the next handler. So whenever we call
// afterEvent, if we were to inspect the original Event object, we would see
// that the currentTarget is null. So instead we pass the event type and the
// target to afterEvent.
Promise.resolve().then(() => afterEvent(eventType, target));
}

e.persist = empty;
e.isPropagationStopped = isPropagationStopped;
e.isDefaultPrevented = isDefaultPrevented;
return (e.nativeEvent = e);
};

/**
* @typedef {EventTarget & {value: any; checked: any; _isControlled: boolean; _prevValue: any}} ControlledTarget
* @param {string} eventType
* @param {ControlledTarget} target
*/
function afterEvent(eventType, target) {
if (target.value != null) {
target.value = target._prevValue;
}
if (eventType === 'change' && target.checked != null) {
target.checked = target._prevValue;
}
}

function empty() {}

function isPropagationStopped() {
Expand Down Expand Up @@ -239,14 +272,40 @@ options._render = function (vnode) {
};

const oldDiffed = options.diffed;
/** @type {(vnode: import('./internal').VNode)} */
/** @type {(vnode: import('./internal').VNode) => void} */
options.diffed = function (vnode) {
if (oldDiffed) {
oldDiffed(vnode);
}

const type = vnode.type;
const props = vnode.props;
const dom = vnode._dom;
const isControlled = dom && dom._isControlled;

if (
dom != null &&
(type === 'input' || type === 'textarea' || type === 'select')
) {
if (isControlled === false) {
} else if (
isControlled ||
props.oninput ||
props.onchange ||
props.onChange
) {
if (props.value != null) {
dom._isControlled = true;
dom._prevValue = props.value;
} else if (props.checked != null) {
dom._isControlled = true;
dom._prevValue = props.checked;
} else {
dom._isControlled = false;
}
}
}

if (
dom != null &&
vnode.type === 'textarea' &&
Expand Down
214 changes: 214 additions & 0 deletions compat/test/browser/controlledInput.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
import {
setupScratch,
teardown,
createEvent
} from '../../../test/_util/helpers';

import React, {
render,
createElement,
Component,
useState
} from 'preact/compat';

describe('preact/compat controlled inputs', () => {
/** @type {HTMLDivElement} */
let scratch;

/**
* @param {EventTarget} on
* @param {string} type
* @returns {Promise<void>}
*/
function fireEvent(on, type) {
let e = createEvent(type);
on.dispatchEvent(e);
// Flush the microtask queue after dispatching an event by returning a
// Promise to mimic what the browser would do after invoking event handlers.
// Technically, this test does it only after all event handlers have been
// invoked, whereas a real event dispatched by a browser would do it after
// each event handler.
return Promise.resolve();
}

beforeEach(() => {
scratch = setupScratch();
});

afterEach(() => {
teardown(scratch);
});

it('should support controlled inputs', async () => {
const calls = [];
class Input extends Component {
constructor(props) {
super(props);
this.state = { text: '' };
this.onInput = this.onInput.bind(this);
}

onInput(e) {
calls.push(e.target.value);
if (e.target.value.length > 3) return;
this.setState({ text: e.target.value });
}

render() {
return <input onInput={this.onInput} value={this.state.text} />;
}
}

render(<Input />, scratch);

scratch.firstChild.value = 'hii';
await fireEvent(scratch.firstChild, 'input');
expect(calls).to.deep.equal(['hii']);
expect(scratch.firstChild.value).to.equal('hii');

scratch.firstChild.value = 'hiii';
await fireEvent(scratch.firstChild, 'input');
expect(calls).to.deep.equal(['hii', 'hiii']);
expect(scratch.firstChild.value).to.equal('hii');
});

it('should support controlled inputs with bailed out rerenders', async () => {
const calls = [];
function Input() {
const [value, setValue] = useState('');
return (
<input
value={value}
onInput={e => {
calls.push(e.target.value);
setValue(e.target.value.toUpperCase().slice(0, 3));
}}
/>
);
}

render(<Input />, scratch);

scratch.firstChild.value = 'hii';
await fireEvent(scratch.firstChild, 'input');
expect(calls).to.deep.equal(['hii']);
expect(scratch.firstChild.value).to.equal('HII');

scratch.firstChild.value = 'hiii';
await fireEvent(scratch.firstChild, 'input');
expect(calls).to.deep.equal(['hii', 'hiii']);
expect(scratch.firstChild.value).to.equal('HII');

scratch.firstChild.value = 'ahiii';
await fireEvent(scratch.firstChild, 'input');
expect(calls).to.deep.equal(['hii', 'hiii', 'ahiii']);
expect(scratch.firstChild.value).to.equal('AHI');
});

it('should support controlled textareas', async () => {
const calls = [];
class Input extends Component {
constructor(props) {
super(props);
this.state = { text: '' };
this.onInput = this.onInput.bind(this);
}

onInput(e) {
calls.push(e.target.value);
if (e.target.value.length > 3) return;
this.setState({ text: e.target.value });
}

render() {
return <textarea onInput={this.onInput} value={this.state.text} />;
}
}

render(<Input />, scratch);

scratch.firstChild.value = 'hii';
await fireEvent(scratch.firstChild, 'input');
expect(calls).to.deep.equal(['hii']);
expect(scratch.firstChild.value).to.equal('hii');

scratch.firstChild.value = 'hiii';
await fireEvent(scratch.firstChild, 'input');
expect(calls).to.deep.equal(['hii', 'hiii']);
expect(scratch.firstChild.value).to.equal('hii');
});

it('should support controlled selects', async () => {
const calls = [];
class Input extends Component {
constructor(props) {
super(props);
this.state = { value: 'B' };
this.onChange = this.onChange.bind(this);
}

onChange(e) {
calls.push(e.target.value);
if (e.target.value === 'C') return;

this.setState({ value: e.target.value });
}

render() {
return (
<select value={this.state.value} onChange={this.onChange}>
<option value="A">A</option>
<option value="B">B</option>
<option value="C">C</option>
</select>
);
}
}

render(<Input />, scratch);

scratch.firstChild.value = 'A';
fireEvent(scratch.firstChild, 'change');
expect(calls).to.deep.equal(['A']);
expect(scratch.firstChild.value).to.equal('A');

scratch.firstChild.value = 'C';
await fireEvent(scratch.firstChild, 'change');
expect(calls).to.deep.equal(['A', 'C']);
expect(scratch.firstChild.value).to.equal('A');
});

it('should support controlled checkboxes', async () => {
const calls = [];
class Input extends Component {
constructor(props) {
super(props);
this.state = { checked: true };
this.onInput = this.onInput.bind(this);
}

onInput(e) {
calls.push(e.target.checked);
if (e.target.checked === false) return;
this.setState({ checked: e.target.checked });
}

render() {
return (
<input
type="checkbox"
onChange={this.onInput}
checked={this.state.checked}
/>
);
}
}

render(<Input />, scratch);

scratch.firstChild.checked = false;
await fireEvent(scratch.firstChild, 'change');
expect(calls).to.deep.equal([false]);
expect(scratch.firstChild.checked).to.equal(true);
});
});
21 changes: 21 additions & 0 deletions demo/controlled-inputs.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import React, { useState } from 'react';

/** @jsx React.createElement */

const ControlledInputs = () => {
const [value, set] = useState('');
const onChange = e => {
set(e.target.value.slice(0, 3));
};

return (
<div>
<label>
Max 3 characters:{' '}
<input type="text" value={value} onChange={onChange} />
</label>
</div>
);
};

export default ControlledInputs;
5 changes: 5 additions & 0 deletions demo/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import SuspenseRouterBug from './suspense-router';
import NestedSuspenseBug from './nested-suspense';
import Contenteditable from './contenteditable';
import { MobXDemo } from './mobx';
import ControlledInputs from './controlled-inputs';

let isBenchmark = /(\/spiral|\/pythagoras|[#&]bench)/g.test(
window.location.href
Expand Down Expand Up @@ -135,6 +136,9 @@ class App extends Component {
<Link href="/contenteditable" activeClassName="active">
contenteditable
</Link>
<Link href="/controlled-inputs" activeClassName="active">
Controlled Inputs
</Link>
</nav>
</header>
<main>
Expand Down Expand Up @@ -165,6 +169,7 @@ class App extends Component {
<SuspenseRouterBug path="/suspense-router" />
<NestedSuspenseBug path="/nested-suspense" />
<Contenteditable path="/contenteditable" />
<ControlledInputs path="/controlled-inputs" />
</Router>
</main>
</div>
Expand Down
Loading

0 comments on commit 59f7f7c

Please sign in to comment.