Skip to content

Instantly share code, notes, and snippets.

@mischnic
Last active November 26, 2021 00:37
Show Gist options
  • Save mischnic/15d81e667cda5c6fa7ab60d0bc77d34b to your computer and use it in GitHub Desktop.
Save mischnic/15d81e667cda5c6fa7ab60d0bc77d34b to your computer and use it in GitHub Desktop.
RSP Preact

https://github.com/adobe/react-spectrum/tree/preact

The problems $X are refered to in some TODO comments

Summary of differences

  • 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

"Fixed"

❗flushSync

(ListBox, Table)

Scrolling in Table: flushSync is missing: preactjs/preact#2636

Solution

let flushSync = (cb) => cb();

seems to work correctly?

❗modal/popup infinite loop

(Picker, ComboBox, DatePicker, Table CRUD)

PopoverWrapper is continuously rerendered

Somehow caused by useModal() in PopoverWrapper

Problem

I think this is because Preact batches calls that set state differently (not at all in this case):

preactjs/preact#2715

Solution

Wrap context value in useMemo.

❗isReadOnly only half working

(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

Problem

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

-> preactjs/preact#2714

-> preactjs/preact#2721

Solution/Workaround

add onClick={(e) => e.preventDefault()}

❗Picker/Virtualizer onBlur: ref is null

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

Solution

if(ref.current != null) ...

🧪 React normalizes pointer events

(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
});

Solution

Manually fire all events in the tests

🧪 $6: Preact testing: onmouseenter not for parents

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

Solution

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'));

Reported

🧪 $3: autoFocus not working in tests

Preact relies on the DOM's <input autofocus> handling, doesn't imperatively call focus() like React does:

testing-library/preact-testing-library#27

jsdom/jsdom#3041

https://codesandbox.io/s/jsdom-autofocus-m01ww

Resolution

Actually adding this feature to JSDOM is probably too much work

picker: doesn't open with space

https://codesandbox.io/s/preact-on-capture-overwrite-e8dfk?file=/src/index.js

preactjs/preact#2739

Resolution

Fix is merged already 🎉

🧪 $8: false negative tests of useAsyncList

testing-library/preact-hooks-testing-library#3

preactjs/preact#2750

// 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

🧪$1: preventDefault doesn't work with Preact & JSDOM

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);
});

❗$4: virtual triggerPress calls in usePress break switch

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.

preactjs/preact#2745

$4.1: usePress breaks Radio

The Radio can have a default value test is broken.

Problem

The calls to setPressed() in usePress cause a state update, overwriting the radio change.

Triaged

❗$2: Clicking on the button of an uncontrolled Combobox doesn't work

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

Problem

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

Solution

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

$7: Preact inputs are essentially always uncontrolled

Impact: TODODODODO

https://codesandbox.io/s/preact-usecontrolledstate-controlled-j5n8y

preactjs/preact#1899

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];
}

Possible Solutions

  1. Somehow force rerender to overwrite the change
			<input
        ref={ref}
        value={actualState}
        onChange={(e) => {
          setState(e.target.value);
          this.forceUpdate();
        }}
      />
  1. 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)

WIP

❗$5: focused out-of-view table cells aren't remembered

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, so relatedTarget isn't null.

  • probably caused by an incorrect isFocusWithin.current value in useVirtualizer, useEffect

❗$9: Tray onClose with shouldCloseOnBlur isn't called

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?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment