https://github.com/adobe/react-spectrum/tree/preact
The problems $X
are refered to in some TODO comments
- state updates are batch less aggressively
- less mature testing libraries (and/or React normalizes more DOM behavior)
- there are no controlled components in the DOM, Preact's compat layer doesn't cover 100% (yet?)
Also in React 17:
- onFocus bubbling vs onFocusIn
- stopPropagation of React events doesn't stop DOM events
(ListBox, Table)
Scrolling in Table: flushSync is missing: preactjs/preact#2636
let flushSync = (cb) => cb();
seems to work correctly?
(Picker, ComboBox, DatePicker, Table CRUD)
PopoverWrapper is continuously rerendered
Somehow caused by useModal()
in PopoverWrapper
I think this is because Preact batches calls that set state differently (not at all in this case):
Wrap context value in useMemo
.
(Slider, RadioGroup)
http://localhost:9004/?path=/story/provider--isreadonly http://localhost:9004/?path=/story/switch--isreadonly-true-isselected-true http://localhost:9004/?path=/story/switch--isselected-true http://localhost:9004/?path=/story/radiogroup--isreadonly
The input changes and then changes back when moving the cursor away
In Preact, <input type="checkbox" checked={true} />
is an uncontrolled component while it's controlled in React:
https://codesandbox.io/s/preact-input-uncontrolled-qis0i
add onClick={(e) => e.preventDefault()}
http://localhost:9004/?path=/story/picker--picker-closes-on-scroll
onfocusout also triggers on unmount (because it fires before the focus is taken away)
onBlur doesn't fire on unmount
https://codesandbox.io/s/preact-focusout-unmount-pwgqv?file=/src/index.js
if(ref.current != null) ...
(useOnPress)
// import React, { useState } from "react";
// import { render, act, fireEvent } from "@testing-library/react";
import React, { useState } from "preact/compat";
import { render, act, fireEvent } from "@testing-library/preact";
import userEvent from "@testing-library/user-event";
global.PointerEvent = class FakePointerEvent extends MouseEvent {
constructor(name, init) {
super(name, init);
this._init = init;
}
get pointerType() {
return this._init.pointerType;
}
};
document.defaultView.Element.prototype.onpointercancel = null;
document.defaultView.Element.prototype.onpointerdown = null;
document.defaultView.Element.prototype.onpointerenter = null;
document.defaultView.Element.prototype.onpointerleave = null;
document.defaultView.Element.prototype.onpointermove = null;
document.defaultView.Element.prototype.onpointerout = null;
document.defaultView.Element.prototype.onpointerover = null;
document.defaultView.Element.prototype.onpointerup = null;
test("test", () => {
let tree = render(
<input
onPointerCancel={(e) => console.log("onpointercancel")}
onPointerDown={(e) => console.log("onpointerdown")}
onPointerEnter={(e) => console.log("onpointerenter")}
onPointerLeave={(e) => console.log("onpointerleave")}
onPointerMove={(e) => console.log("onpointermove")}
onPointerOut={(e) => console.log("onpointerout")}
onPointerOver={(e) => console.log("onpointerover")}
onPointerUp={(e) => console.log("onpointerup")}
data-testid="input"
/>
);
let el = tree.getByTestId("input");
fireEvent.pointerOver(el, { pointerType: "mouse" });
fireEvent.pointerOut(el, { pointerType: "mouse" });
// Preact: onpointerover, onpointerout
// React: onpointerover, onpointerenter, onpointerout, onpointerleave
});
Manually fire all events in the tests
RangeCalendar selects a range with the mouse (uncontrolled)
preact testing library: onmouseenter isn't called for parent elements
https://developer.mozilla.org/en-US/docs/Web/API/Element/mouseenter_event
Pending PR: testing-library/user-event#451
Merged & Released!
// import React, { useState } from "react";
// import { render, act, fireEvent } from "@testing-library/react";
import React, { useState } from "preact/compat";
import { render, act, fireEvent } from "@testing-library/preact";
import userEvent from "@testing-library/user-event";
test("test", () => {
let inner = jest.fn();
let outer = jest.fn();
let tree = render(
<div
onMouseEnter={() => {
console.log("ENTER CELL 2");
inner();
}}
>
<input
onMouseEnter={() => {
console.log("ENTER CELL 1");
outer();
}}
data-testid="input"
/>
</div>
);
let el = tree.getByTestId("input");
act(() => {
userEvent.hover(el);
});
expect(inner).toHaveBeenCalledTimes(1);
expect(outer).toHaveBeenCalledTimes(1);
});
const { JSDOM } = require('jsdom');
const { window } = new JSDOM(
`
<!DOCTYPE html>
<div>
<input/>
</div>
`,
{ runScripts: 'dangerously' }
);
let { document } = window;
document
.querySelector('div')
.addEventListener('mouseenter', () => console.log('div enter'));
document
.querySelector('input')
.addEventListener('mouseenter', () => console.log('input enter'));
document
.querySelector('input')
.dispatchEvent(new window.MouseEvent('mouseenter'));
Preact relies on the DOM's <input autofocus>
handling, doesn't imperatively call focus()
like React does:
testing-library/preact-testing-library#27
https://codesandbox.io/s/jsdom-autofocus-m01ww
Actually adding this feature to JSDOM is probably too much work
https://codesandbox.io/s/preact-on-capture-overwrite-e8dfk?file=/src/index.js
Fix is merged already 🎉
testing-library/preact-hooks-testing-library#3
// timeout:
import React from "preact/compat";
import { act, renderHook } from "@testing-library/preact-hooks";
// works:
// import React from "react";
// import { act, renderHook } from "@testing-library/react-hooks";
function useTest() {
let [state, dispatch] = React.useReducer((state, action) => {
throw new Error("X");
});
return {
async load() {
await new Promise((res) => setTimeout(res, 100));
dispatch();
},
};
}
test("test", async () => {
let { result, waitForNextUpdate } = renderHook(() => useTest());
await act(async () => {
result.current.load();
await waitForNextUpdate();
});
expect(result.error.message).toBe("X");
});
testing-library/preact-hooks-testing-library#4
https://codesandbox.io/s/preact-preventdefault-focus-button-2oqde?file=/src/index.js
https://codesandbox.io/s/mousedown-preventdefault-focus-3psiz?file=/src/index.js
testing-library/preact-testing-library#30
testing-library/preact-testing-library#31
// fails:
import React from "preact/compat";
import { act, render, fireEvent } from "@testing-library/preact";
// works:
// import React from "react";
// import { act, render, fireEvent } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
function ComboBox() {
let inputRef = React.useRef();
let buttonProps = {
onMouseDown: (e) => {
e.preventDefault();
console.log("onPressStart 1", document.activeElement.outerHTML);
inputRef.current.focus();
console.log("onPressStart 2", document.activeElement.outerHTML);
},
};
return (
<div>
<input ref={inputRef} />
<button {...buttonProps} />
</div>
);
}
test("test", () => {
const { getAllByRole, queryByRole } = render(<ComboBox />);
let button = queryByRole("button");
let combobox = queryByRole("combobox");
act(() => {
userEvent.click(button);
});
expect(document.activeElement).toBe(combobox);
});
Impact: Some Input components (Switch/Radio) don't work at all or only via mouse
Impact (workaround): ???
Using Spacebar to toggle focused Switch doesn't work.
These calls in usePress
ignoreStateUpdate = true;
triggerPressStart(e, 'virtual');
triggerPressUp(e, 'virtual');
triggerPressEnd(e, 'virtual');
ignoreStateUpdate = false;
trigger a state update, overwriting the input change.
The Radio can have a default value
test is broken.
The calls to setPressed()
in usePress
cause a state update, overwriting the radio change.
Impact: Combobox cannot be closed via button
React 17 behaves like Preact, Rob was working on this....
http://localhost:9004/?path=/story/combobox--defaultopen
React's e.stopPropagation() doesn't stop propagation of the DOM event, only its internal bubbling
So calling stopPropagation() in usePress: pressProps.onPointerDown
breaks useInteractOutside's event listener on the document
ComboBox -> FieldButton -> useButton -> usePress: pressProps.onPointerDown -> stopPropagation()
prevents bubbling of pointerdown
up to useInteractOutside, so
Popover -> useOverlay -> useInteractOutside
doesn't close it when clicking on the button
https://codesandbox.io/s/preact-stoppropagation-document-kuxm8?file=/src/index.js
Calling stopPropagation
apparently only stops the React bubbling, not the DOM event bubbling
Rob is working on solving this anyway for React 17
import React from "preact/compat";
import ReactDOM from "preact/compat";
/*/
import React from "react";
import ReactDOM from "react-dom";
*/
function App() {
React.useEffect(() => {
function onPointerDown() {
console.log("document onPointerDown");
}
document.addEventListener("pointerdown", onPointerDown, false);
return () => {
document.removeEventListener("pointerdown", onPointerDown, false);
};
});
return (
<div
style={{ width: 100, height: 100, background: "red" }}
onPointerDown={(e) => {
console.log("div onPointerDown");
e.stopPropagation();
}}
/>
);
}
ReactDOM.render(<App />, document.getElementById("root"));
// React:
// div onPointerDown
// document onPointerDown
//
// Preact:
// div onPointerDown
https://codesandbox.io/s/preact-usecontrolledstate-controlled-j5n8y
import React from "preact/compat";
import ReactDOM from "preact/compat";
/*/
import React from "react";
import ReactDOM from "react-dom";
*/
const { useState, useRef, useCallback } = React;
function App() {
let [actualState, setActualState] = useState("Two");
let [state, setState] = useControlledState("Two", "One", (v) => {
console.log("change", v);
//setActualState(v);
});
console.log("render", actualState);
return (
<input
value={actualState}
onChange={(e) => {
setState(e.target.value);
}}
/>
);
}
ReactDOM.render(<App />, document.getElementById("root"));
// Preact: input works as an uncontrolled iput
// React: controlled = nothing happens when typing
// Copied from the source:
function useControlledState<T>(
value: T,
defaultValue: T,
onChange: (value: T, ...args: any[]) => void
): [T, (value: T | ((prevState: T) => T), ...args: any[]) => void] {
let [stateValue, setStateValue] = useState(value || defaultValue);
let ref = useRef(value !== undefined);
let wasControlled = ref.current;
let isControlled = value !== undefined;
// Internal state reference for useCallback
let stateRef = useRef(stateValue);
if (wasControlled !== isControlled) {
console.warn(
`WARN: A component changed from ${
wasControlled ? "controlled" : "uncontrolled"
} to ${isControlled ? "controlled" : "uncontrolled"}.`
);
}
ref.current = isControlled;
let setValue = useCallback(
(value, ...args) => {
let onChangeCaller = (value, ...onChangeArgs) => {
if (onChange) {
if (stateRef.current !== value) {
onChange(value, ...onChangeArgs);
}
}
if (!isControlled) {
stateRef.current = value;
}
};
if (typeof value === "function") {
// this supports functional updates https://reactjs.org/docs/hooks-reference.html#functional-updates
// when someone using useControlledState calls setControlledState(myFunc)
// this will call our useState setState with a function as well which invokes myFunc and calls onChange with the value from myFunc
// if we're in an uncontrolled state, then we also return the value of myFunc which to setState looks as though it was just called with myFunc from the beginning
// otherwise we just return the controlled value, which won't cause a rerender because React knows to bail out when the value is the same
let updateFunction = (oldValue, ...functionArgs) => {
let interceptedValue = value(
isControlled ? stateRef.current : oldValue,
...functionArgs
);
onChangeCaller(interceptedValue, ...args);
if (!isControlled) {
return interceptedValue;
}
return oldValue;
};
setStateValue(updateFunction);
} else {
if (!isControlled) {
setStateValue(value);
}
onChangeCaller(value, ...args);
}
},
[isControlled, onChange]
);
// If a controlled component's value prop changes, we need to update stateRef
if (isControlled) {
stateRef.current = value;
} else {
value = stateValue;
}
return [value, setValue];
}
- Somehow force rerender to overwrite the change
<input
ref={ref}
value={actualState}
onChange={(e) => {
setState(e.target.value);
this.forceUpdate();
}}
/>
- Prevent the update (what React does under the hood)
But: Preact doesn't polyfill onBeforeInput for Firefox (preactjs/preact#1422) and reconstructing the new input value is difficult (to use onBeforeInput instead of onChange/onInput)
Impact: selecting a Table cell, scrolling it out of view and then using the arrow keys to jump back to it doesn't work
see TODO in useSelectableCollection.ts#onBlur
-
event.relatedTarget
is null. So where the focus is now. -
document.activeElement
is the body after this event fired. With React, it's the<div role="grid">
, which is focusable, sorelatedTarget
isn't null. -
probably caused by an incorrect
isFocusWithin.current
value inuseVirtualizer
,useEffect
Impact: onClose is called once instead of twice?
(Tray.test.js: hides the tray on blur when shouldCloseOnBlur is true
)
Storybook React: onClose called onMouseDown & onMouseUp Storybook Preact: onClose called onMouseUp
Tests React: onClose called twice Tests Preact: onClose not called
Removing the FocusScope
wrapper in Dialog.tsx
makes Preact behave like React (both Storybook & Test). I think Rob has encountered this as well?