summaryrefslogtreecommitdiff
path: root/preact/hooks
diff options
context:
space:
mode:
Diffstat (limited to 'preact/hooks')
-rw-r--r--preact/hooks/LICENSE21
-rw-r--r--preact/hooks/mangle.json21
-rw-r--r--preact/hooks/package.json26
-rw-r--r--preact/hooks/src/index.d.ts133
-rw-r--r--preact/hooks/src/index.js386
-rw-r--r--preact/hooks/src/internal.d.ts75
-rw-r--r--preact/hooks/test/_util/useEffectUtil.js10
-rw-r--r--preact/hooks/test/browser/combinations.test.js301
-rw-r--r--preact/hooks/test/browser/errorBoundary.test.js92
-rw-r--r--preact/hooks/test/browser/hooks.options.test.js154
-rw-r--r--preact/hooks/test/browser/useCallback.test.js41
-rw-r--r--preact/hooks/test/browser/useContext.test.js351
-rw-r--r--preact/hooks/test/browser/useDebugValue.test.js71
-rw-r--r--preact/hooks/test/browser/useEffect.test.js373
-rw-r--r--preact/hooks/test/browser/useEffectAssertions.test.js142
-rw-r--r--preact/hooks/test/browser/useImperativeHandle.test.js182
-rw-r--r--preact/hooks/test/browser/useLayoutEffect.test.js326
-rw-r--r--preact/hooks/test/browser/useMemo.test.js125
-rw-r--r--preact/hooks/test/browser/useReducer.test.js214
-rw-r--r--preact/hooks/test/browser/useRef.test.js50
-rw-r--r--preact/hooks/test/browser/useState.test.js214
21 files changed, 3308 insertions, 0 deletions
diff --git a/preact/hooks/LICENSE b/preact/hooks/LICENSE
new file mode 100644
index 0000000..da5389a
--- /dev/null
+++ b/preact/hooks/LICENSE
@@ -0,0 +1,21 @@
+The MIT License (MIT)
+
+Copyright (c) 2015-present Jason Miller
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/preact/hooks/mangle.json b/preact/hooks/mangle.json
new file mode 100644
index 0000000..506a6a4
--- /dev/null
+++ b/preact/hooks/mangle.json
@@ -0,0 +1,21 @@
+{
+ "help": {
+ "what is this file?": "It controls protected/private property mangling so that minified builds have consistent property names.",
+ "why are there duplicate minified properties?": "Most properties are only used on one type of objects, so they can have the same name since they will never collide. Doing this reduces size."
+ },
+ "minify": {
+ "mangle": {
+ "properties": {
+ "regex": "^_[^_]",
+ "reserved": [
+ "__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED",
+ "__REACT_DEVTOOLS_GLOBAL_HOOK__",
+ "__PREACT_DEVTOOLS__",
+ "_renderers",
+ "__source",
+ "__self"
+ ]
+ }
+ }
+ }
+} \ No newline at end of file
diff --git a/preact/hooks/package.json b/preact/hooks/package.json
new file mode 100644
index 0000000..1e8d66a
--- /dev/null
+++ b/preact/hooks/package.json
@@ -0,0 +1,26 @@
+{
+ "name": "preact-hooks",
+ "amdName": "preactHooks",
+ "version": "0.1.0",
+ "private": true,
+ "description": "Hook addon for Preact",
+ "main": "dist/hooks.js",
+ "module": "dist/hooks.module.js",
+ "umd:main": "dist/hooks.umd.js",
+ "source": "src/index.js",
+ "license": "MIT",
+ "types": "src/index.d.ts",
+ "scripts": {
+ "build": "microbundle build --raw",
+ "dev": "microbundle watch --raw --format cjs",
+ "test": "npm-run-all build --parallel test:karma",
+ "test:karma": "karma start test/karma.conf.js --single-run",
+ "test:karma:watch": "karma start test/karma.conf.js --no-single-run"
+ },
+ "peerDependencies": {
+ "preact": "^10.0.0"
+ },
+ "mangle": {
+ "regex": "^_"
+ }
+}
diff --git a/preact/hooks/src/index.d.ts b/preact/hooks/src/index.d.ts
new file mode 100644
index 0000000..c9f6788
--- /dev/null
+++ b/preact/hooks/src/index.d.ts
@@ -0,0 +1,133 @@
+import { PreactContext, Ref as PreactRef, RefObject } from '../..';
+
+type Inputs = ReadonlyArray<unknown>;
+
+export type StateUpdater<S> = (value: S | ((prevState: S) => S)) => void;
+/**
+ * Returns a stateful value, and a function to update it.
+ * @param initialState The initial value (or a function that returns the initial value)
+ */
+export function useState<S>(initialState: S | (() => S)): [S, StateUpdater<S>];
+
+export function useState<S = undefined>(): [
+ S | undefined,
+ StateUpdater<S | undefined>
+];
+
+export type Reducer<S, A> = (prevState: S, action: A) => S;
+/**
+ * An alternative to `useState`.
+ *
+ * `useReducer` is usually preferable to `useState` when you have complex state logic that involves
+ * multiple sub-values. It also lets you optimize performance for components that trigger deep
+ * updates because you can pass `dispatch` down instead of callbacks.
+ * @param reducer Given the current state and an action, returns the new state
+ * @param initialState The initial value to store as state
+ */
+export function useReducer<S, A>(
+ reducer: Reducer<S, A>,
+ initialState: S
+): [S, (action: A) => void];
+
+/**
+ * An alternative to `useState`.
+ *
+ * `useReducer` is usually preferable to `useState` when you have complex state logic that involves
+ * multiple sub-values. It also lets you optimize performance for components that trigger deep
+ * updates because you can pass `dispatch` down instead of callbacks.
+ * @param reducer Given the current state and an action, returns the new state
+ * @param initialArg The initial argument to pass to the `init` function
+ * @param init A function that, given the `initialArg`, returns the initial value to store as state
+ */
+export function useReducer<S, A, I>(
+ reducer: Reducer<S, A>,
+ initialArg: I,
+ init: (arg: I) => S
+): [S, (action: A) => void];
+
+/** @deprecated Use the `Ref` type instead. */
+type PropRef<T> = { current: T };
+type Ref<T> = { current: T };
+
+/**
+ * `useRef` returns a mutable ref object whose `.current` property is initialized to the passed argument
+ * (`initialValue`). The returned object will persist for the full lifetime of the component.
+ *
+ * Note that `useRef()` is useful for more than the `ref` attribute. It’s handy for keeping any mutable
+ * value around similar to how you’d use instance fields in classes.
+ *
+ * @param initialValue the initial value to store in the ref object
+ */
+export function useRef<T>(initialValue: null): RefObject<T>;
+export function useRef<T>(initialValue: T): Ref<T>;
+export function useRef<T>(): Ref<T | undefined>;
+
+type EffectCallback = () => void | (() => void);
+/**
+ * Accepts a function that contains imperative, possibly effectful code.
+ * The effects run after browser paint, without blocking it.
+ *
+ * @param effect Imperative function that can return a cleanup function
+ * @param inputs If present, effect will only activate if the values in the list change (using ===).
+ */
+export function useEffect(effect: EffectCallback, inputs?: Inputs): void;
+
+type CreateHandle = () => object;
+
+/**
+ * @param ref The ref that will be mutated
+ * @param create The function that will be executed to get the value that will be attached to
+ * ref.current
+ * @param inputs If present, effect will only activate if the values in the list change (using ===).
+ */
+export function useImperativeHandle<T, R extends T>(
+ ref: PreactRef<T>,
+ create: () => R,
+ inputs?: Inputs
+): void;
+
+/**
+ * Accepts a function that contains imperative, possibly effectful code.
+ * Use this to read layout from the DOM and synchronously re-render.
+ * Updates scheduled inside `useLayoutEffect` will be flushed synchronously, after all DOM mutations but before the browser has a chance to paint.
+ * Prefer the standard `useEffect` hook when possible to avoid blocking visual updates.
+ *
+ * @param effect Imperative function that can return a cleanup function
+ * @param inputs If present, effect will only activate if the values in the list change (using ===).
+ */
+export function useLayoutEffect(effect: EffectCallback, inputs?: Inputs): void;
+
+/**
+ * Returns a memoized version of the callback that only changes if one of the `inputs`
+ * has changed (using ===).
+ */
+export function useCallback<T extends Function>(callback: T, inputs: Inputs): T;
+
+/**
+ * Pass a factory function and an array of inputs.
+ * useMemo will only recompute the memoized value when one of the inputs has changed.
+ * This optimization helps to avoid expensive calculations on every render.
+ * If no array is provided, a new value will be computed whenever a new function instance is passed as the first argument.
+ */
+// for `inputs`, allow undefined, but don't make it optional as that is very likely a mistake
+export function useMemo<T>(factory: () => T, inputs: Inputs | undefined): T;
+
+/**
+ * Returns the current context value, as given by the nearest context provider for the given context.
+ * When the provider updates, this Hook will trigger a rerender with the latest context value.
+ *
+ * @param context The context you want to use
+ */
+export function useContext<T>(context: PreactContext<T>): T;
+
+/**
+ * Customize the displayed value in the devtools panel.
+ *
+ * @param value Custom hook name or object that is passed to formatter
+ * @param formatter Formatter to modify value before sending it to the devtools
+ */
+export function useDebugValue<T>(value: T, formatter?: (value: T) => any): void;
+
+export function useErrorBoundary(
+ callback?: (error: any) => Promise<void> | void
+): [any, () => void];
diff --git a/preact/hooks/src/index.js b/preact/hooks/src/index.js
new file mode 100644
index 0000000..e3840a2
--- /dev/null
+++ b/preact/hooks/src/index.js
@@ -0,0 +1,386 @@
+import { options } from 'preact';
+
+/** @type {number} */
+let currentIndex;
+
+/** @type {import('./internal').Component} */
+let currentComponent;
+/**
+ * Keep track of the previous component so that we can set
+ * `currentComponent` to `null` and throw when a hook is invoked
+ * outside of render
+ * @type {import('./internal').Component}
+ */
+let previousComponent;
+
+/** @type {number} */
+let currentHook = 0;
+
+/** @type {Array<import('./internal').Component>} */
+let afterPaintEffects = [];
+
+let oldBeforeDiff = options._diff;
+let oldBeforeRender = options._render;
+let oldAfterDiff = options.diffed;
+let oldCommit = options._commit;
+let oldBeforeUnmount = options.unmount;
+
+const RAF_TIMEOUT = 100;
+let prevRaf;
+
+options._diff = vnode => {
+ currentComponent = null;
+ if (oldBeforeDiff) oldBeforeDiff(vnode);
+};
+
+options._render = vnode => {
+ if (oldBeforeRender) oldBeforeRender(vnode);
+
+ currentComponent = vnode._component;
+ currentIndex = 0;
+
+ const hooks = currentComponent.__hooks;
+ if (hooks) {
+ hooks._pendingEffects.forEach(invokeCleanup);
+ hooks._pendingEffects.forEach(invokeEffect);
+ hooks._pendingEffects = [];
+ }
+};
+
+options.diffed = vnode => {
+ if (oldAfterDiff) oldAfterDiff(vnode);
+
+ const c = vnode._component;
+ if (c && c.__hooks && c.__hooks._pendingEffects.length) {
+ afterPaint(afterPaintEffects.push(c));
+ }
+ currentComponent = previousComponent;
+};
+
+options._commit = (vnode, commitQueue) => {
+ commitQueue.some(component => {
+ try {
+ component._renderCallbacks.forEach(invokeCleanup);
+ component._renderCallbacks = component._renderCallbacks.filter(cb =>
+ cb._value ? invokeEffect(cb) : true
+ );
+ } catch (e) {
+ commitQueue.some(c => {
+ if (c._renderCallbacks) c._renderCallbacks = [];
+ });
+ commitQueue = [];
+ options._catchError(e, component._vnode);
+ }
+ });
+
+ if (oldCommit) oldCommit(vnode, commitQueue);
+};
+
+options.unmount = vnode => {
+ if (oldBeforeUnmount) oldBeforeUnmount(vnode);
+
+ const c = vnode._component;
+ if (c && c.__hooks) {
+ try {
+ c.__hooks._list.forEach(invokeCleanup);
+ } catch (e) {
+ options._catchError(e, c._vnode);
+ }
+ }
+};
+
+/**
+ * Get a hook's state from the currentComponent
+ * @param {number} index The index of the hook to get
+ * @param {number} type The index of the hook to get
+ * @returns {any}
+ */
+function getHookState(index, type) {
+ if (options._hook) {
+ options._hook(currentComponent, index, currentHook || type);
+ }
+ currentHook = 0;
+
+ // Largely inspired by:
+ // * https://github.com/michael-klein/funcy.js/blob/f6be73468e6ec46b0ff5aa3cc4c9baf72a29025a/src/hooks/core_hooks.mjs
+ // * https://github.com/michael-klein/funcy.js/blob/650beaa58c43c33a74820a3c98b3c7079cf2e333/src/renderer.mjs
+ // Other implementations to look at:
+ // * https://codesandbox.io/s/mnox05qp8
+ const hooks =
+ currentComponent.__hooks ||
+ (currentComponent.__hooks = {
+ _list: [],
+ _pendingEffects: []
+ });
+
+ if (index >= hooks._list.length) {
+ hooks._list.push({});
+ }
+ return hooks._list[index];
+}
+
+/**
+ * @param {import('./index').StateUpdater<any>} [initialState]
+ */
+export function useState(initialState) {
+ currentHook = 1;
+ return useReducer(invokeOrReturn, initialState);
+}
+
+/**
+ * @param {import('./index').Reducer<any, any>} reducer
+ * @param {import('./index').StateUpdater<any>} initialState
+ * @param {(initialState: any) => void} [init]
+ * @returns {[ any, (state: any) => void ]}
+ */
+export function useReducer(reducer, initialState, init) {
+ /** @type {import('./internal').ReducerHookState} */
+ const hookState = getHookState(currentIndex++, 2);
+ hookState._reducer = reducer;
+ if (!hookState._component) {
+ hookState._value = [
+ !init ? invokeOrReturn(undefined, initialState) : init(initialState),
+
+ action => {
+ const nextValue = hookState._reducer(hookState._value[0], action);
+ if (hookState._value[0] !== nextValue) {
+ hookState._value = [nextValue, hookState._value[1]];
+ hookState._component.setState({});
+ }
+ }
+ ];
+
+ hookState._component = currentComponent;
+ }
+
+ return hookState._value;
+}
+
+/**
+ * @param {import('./internal').Effect} callback
+ * @param {any[]} args
+ */
+export function useEffect(callback, args) {
+ /** @type {import('./internal').EffectHookState} */
+ const state = getHookState(currentIndex++, 3);
+ if (!options._skipEffects && argsChanged(state._args, args)) {
+ state._value = callback;
+ state._args = args;
+
+ currentComponent.__hooks._pendingEffects.push(state);
+ }
+}
+
+/**
+ * @param {import('./internal').Effect} callback
+ * @param {any[]} args
+ */
+export function useLayoutEffect(callback, args) {
+ /** @type {import('./internal').EffectHookState} */
+ const state = getHookState(currentIndex++, 4);
+ if (!options._skipEffects && argsChanged(state._args, args)) {
+ state._value = callback;
+ state._args = args;
+
+ currentComponent._renderCallbacks.push(state);
+ }
+}
+
+export function useRef(initialValue) {
+ currentHook = 5;
+ return useMemo(() => ({ current: initialValue }), []);
+}
+
+/**
+ * @param {object} ref
+ * @param {() => object} createHandle
+ * @param {any[]} args
+ */
+export function useImperativeHandle(ref, createHandle, args) {
+ currentHook = 6;
+ useLayoutEffect(
+ () => {
+ if (typeof ref == 'function') ref(createHandle());
+ else if (ref) ref.current = createHandle();
+ },
+ args == null ? args : args.concat(ref)
+ );
+}
+
+/**
+ * @param {() => any} factory
+ * @param {any[]} args
+ */
+export function useMemo(factory, args) {
+ /** @type {import('./internal').MemoHookState} */
+ const state = getHookState(currentIndex++, 7);
+ if (argsChanged(state._args, args)) {
+ state._value = factory();
+ state._args = args;
+ state._factory = factory;
+ }
+
+ return state._value;
+}
+
+/**
+ * @param {() => void} callback
+ * @param {any[]} args
+ */
+export function useCallback(callback, args) {
+ currentHook = 8;
+ return useMemo(() => callback, args);
+}
+
+/**
+ * @param {import('./internal').PreactContext} context
+ */
+export function useContext(context) {
+ const provider = currentComponent.context[context._id];
+ // We could skip this call here, but than we'd not call
+ // `options._hook`. We need to do that in order to make
+ // the devtools aware of this hook.
+ /** @type {import('./internal').ContextHookState} */
+ const state = getHookState(currentIndex++, 9);
+ // The devtools needs access to the context object to
+ // be able to pull of the default value when no provider
+ // is present in the tree.
+ state._context = context;
+ if (!provider) return context._defaultValue;
+ // This is probably not safe to convert to "!"
+ if (state._value == null) {
+ state._value = true;
+ provider.sub(currentComponent);
+ }
+ return provider.props.value;
+}
+
+/**
+ * Display a custom label for a custom hook for the devtools panel
+ * @type {<T>(value: T, cb?: (value: T) => string | number) => void}
+ */
+export function useDebugValue(value, formatter) {
+ if (options.useDebugValue) {
+ options.useDebugValue(formatter ? formatter(value) : value);
+ }
+}
+
+/**
+ * @param {(error: any) => void} cb
+ */
+export function useErrorBoundary(cb) {
+ /** @type {import('./internal').ErrorBoundaryHookState} */
+ const state = getHookState(currentIndex++, 10);
+ const errState = useState();
+ state._value = cb;
+ if (!currentComponent.componentDidCatch) {
+ currentComponent.componentDidCatch = err => {
+ if (state._value) state._value(err);
+ errState[1](err);
+ };
+ }
+ return [
+ errState[0],
+ () => {
+ errState[1](undefined);
+ }
+ ];
+}
+
+/**
+ * After paint effects consumer.
+ */
+function flushAfterPaintEffects() {
+ afterPaintEffects.forEach(component => {
+ if (component._parentDom) {
+ try {
+ component.__hooks._pendingEffects.forEach(invokeCleanup);
+ component.__hooks._pendingEffects.forEach(invokeEffect);
+ component.__hooks._pendingEffects = [];
+ } catch (e) {
+ component.__hooks._pendingEffects = [];
+ options._catchError(e, component._vnode);
+ }
+ }
+ });
+ afterPaintEffects = [];
+}
+
+let HAS_RAF = typeof requestAnimationFrame == 'function';
+
+/**
+ * Schedule a callback to be invoked after the browser has a chance to paint a new frame.
+ * Do this by combining requestAnimationFrame (rAF) + setTimeout to invoke a callback after
+ * the next browser frame.
+ *
+ * Also, schedule a timeout in parallel to the the rAF to ensure the callback is invoked
+ * even if RAF doesn't fire (for example if the browser tab is not visible)
+ *
+ * @param {() => void} callback
+ */
+function afterNextFrame(callback) {
+ const done = () => {
+ clearTimeout(timeout);
+ if (HAS_RAF) cancelAnimationFrame(raf);
+ setTimeout(callback);
+ };
+ const timeout = setTimeout(done, RAF_TIMEOUT);
+
+ let raf;
+ if (HAS_RAF) {
+ raf = requestAnimationFrame(done);
+ }
+}
+
+// Note: if someone used options.debounceRendering = requestAnimationFrame,
+// then effects will ALWAYS run on the NEXT frame instead of the current one, incurring a ~16ms delay.
+// Perhaps this is not such a big deal.
+/**
+ * Schedule afterPaintEffects flush after the browser paints
+ * @param {number} newQueueLength
+ */
+function afterPaint(newQueueLength) {
+ if (newQueueLength === 1 || prevRaf !== options.requestAnimationFrame) {
+ prevRaf = options.requestAnimationFrame;
+ (prevRaf || afterNextFrame)(flushAfterPaintEffects);
+ }
+}
+
+/**
+ * @param {import('./internal').EffectHookState} hook
+ */
+function invokeCleanup(hook) {
+ // A hook cleanup can introduce a call to render which creates a new root, this will call options.vnode
+ // and move the currentComponent away.
+ const comp = currentComponent;
+ if (typeof hook._cleanup == 'function') hook._cleanup();
+ currentComponent = comp;
+}
+
+/**
+ * Invoke a Hook's effect
+ * @param {import('./internal').EffectHookState} hook
+ */
+function invokeEffect(hook) {
+ // A hook call can introduce a call to render which creates a new root, this will call options.vnode
+ // and move the currentComponent away.
+ const comp = currentComponent;
+ hook._cleanup = hook._value();
+ currentComponent = comp;
+}
+
+/**
+ * @param {any[]} oldArgs
+ * @param {any[]} newArgs
+ */
+function argsChanged(oldArgs, newArgs) {
+ return (
+ !oldArgs ||
+ oldArgs.length !== newArgs.length ||
+ newArgs.some((arg, index) => arg !== oldArgs[index])
+ );
+}
+
+function invokeOrReturn(arg, f) {
+ return typeof f == 'function' ? f(arg) : f;
+}
diff --git a/preact/hooks/src/internal.d.ts b/preact/hooks/src/internal.d.ts
new file mode 100644
index 0000000..e6b51fb
--- /dev/null
+++ b/preact/hooks/src/internal.d.ts
@@ -0,0 +1,75 @@
+import {
+ Component as PreactComponent,
+ PreactContext
+} from '../../src/internal';
+import { Reducer } from '.';
+
+export { PreactContext };
+
+/**
+ * The type of arguments passed to a Hook function. While this type is not
+ * strictly necessary, they are given a type name to make it easier to read
+ * the following types and trace the flow of data.
+ */
+export type HookArgs = any;
+
+/**
+ * The return type of a Hook function. While this type is not
+ * strictly necessary, they are given a type name to make it easier to read
+ * the following types and trace the flow of data.
+ */
+export type HookReturnValue = any;
+
+/** The public function a user invokes to use a Hook */
+export type Hook = (...args: HookArgs[]) => HookReturnValue;
+
+// Hook tracking
+
+export interface ComponentHooks {
+ /** The list of hooks a component uses */
+ _list: HookState[];
+ /** List of Effects to be invoked after the next frame is rendered */
+ _pendingEffects: EffectHookState[];
+}
+
+export interface Component extends PreactComponent<any, any> {
+ __hooks?: ComponentHooks;
+}
+
+export type HookState =
+ | EffectHookState
+ | MemoHookState
+ | ReducerHookState
+ | ContextHookState
+ | ErrorBoundaryHookState;
+
+export type Effect = () => void | Cleanup;
+export type Cleanup = () => void;
+
+export interface EffectHookState {
+ _value?: Effect;
+ _args?: any[];
+ _cleanup?: Cleanup | void;
+}
+
+export interface MemoHookState {
+ _value?: any;
+ _args?: any[];
+ _factory?: () => any;
+}
+
+export interface ReducerHookState {
+ _value?: any;
+ _component?: Component;
+ _reducer?: Reducer<any, any>;
+}
+
+export interface ContextHookState {
+ /** Whether this hooks as subscribed to updates yet */
+ _value?: boolean;
+ _context?: PreactContext;
+}
+
+export interface ErrorBoundaryHookState {
+ _value?: (error: any) => void;
+}
diff --git a/preact/hooks/test/_util/useEffectUtil.js b/preact/hooks/test/_util/useEffectUtil.js
new file mode 100644
index 0000000..f04edc2
--- /dev/null
+++ b/preact/hooks/test/_util/useEffectUtil.js
@@ -0,0 +1,10 @@
+export function scheduleEffectAssert(assertFn) {
+ return new Promise(resolve => {
+ requestAnimationFrame(() =>
+ setTimeout(() => {
+ assertFn();
+ resolve();
+ }, 0)
+ );
+ });
+}
diff --git a/preact/hooks/test/browser/combinations.test.js b/preact/hooks/test/browser/combinations.test.js
new file mode 100644
index 0000000..986533c
--- /dev/null
+++ b/preact/hooks/test/browser/combinations.test.js
@@ -0,0 +1,301 @@
+import { setupRerender, act } from 'preact/test-utils';
+import { createElement, render, Component } from 'preact';
+import { setupScratch, teardown } from '../../../test/_util/helpers';
+import {
+ useState,
+ useReducer,
+ useEffect,
+ useLayoutEffect,
+ useRef
+} from 'preact/hooks';
+import { scheduleEffectAssert } from '../_util/useEffectUtil';
+
+/** @jsx createElement */
+
+describe('combinations', () => {
+ /** @type {HTMLDivElement} */
+ let scratch;
+
+ /** @type {() => void} */
+ let rerender;
+
+ beforeEach(() => {
+ scratch = setupScratch();
+ rerender = setupRerender();
+ });
+
+ afterEach(() => {
+ teardown(scratch);
+ });
+
+ it('can mix useState hooks', () => {
+ const states = {};
+ const setStates = {};
+
+ function Parent() {
+ const [state1, setState1] = useState(1);
+ const [state2, setState2] = useState(2);
+
+ Object.assign(states, { state1, state2 });
+ Object.assign(setStates, { setState1, setState2 });
+
+ return <Child />;
+ }
+
+ function Child() {
+ const [state3, setState3] = useState(3);
+ const [state4, setState4] = useState(4);
+
+ Object.assign(states, { state3, state4 });
+ Object.assign(setStates, { setState3, setState4 });
+
+ return null;
+ }
+
+ render(<Parent />, scratch);
+ expect(states).to.deep.equal({
+ state1: 1,
+ state2: 2,
+ state3: 3,
+ state4: 4
+ });
+
+ setStates.setState2(n => n * 10);
+ setStates.setState3(n => n * 10);
+ rerender();
+ expect(states).to.deep.equal({
+ state1: 1,
+ state2: 20,
+ state3: 30,
+ state4: 4
+ });
+ });
+
+ it('can rerender asynchronously from within an effect', () => {
+ const didRender = sinon.spy();
+
+ function Comp() {
+ const [counter, setCounter] = useState(0);
+
+ useEffect(() => {
+ if (counter === 0) setCounter(1);
+ });
+
+ didRender(counter);
+ return null;
+ }
+
+ render(<Comp />, scratch);
+
+ return scheduleEffectAssert(() => {
+ rerender();
+ expect(didRender).to.have.been.calledTwice.and.calledWith(1);
+ });
+ });
+
+ it('can rerender synchronously from within a layout effect', () => {
+ const didRender = sinon.spy();
+
+ function Comp() {
+ const [counter, setCounter] = useState(0);
+
+ useLayoutEffect(() => {
+ if (counter === 0) setCounter(1);
+ });
+
+ didRender(counter);
+ return null;
+ }
+
+ render(<Comp />, scratch);
+ rerender();
+
+ expect(didRender).to.have.been.calledTwice.and.calledWith(1);
+ });
+
+ it('can access refs from within a layout effect callback', () => {
+ let refAtLayoutTime;
+
+ function Comp() {
+ const input = useRef();
+
+ useLayoutEffect(() => {
+ refAtLayoutTime = input.current;
+ });
+
+ return <input ref={input} value="hello" />;
+ }
+
+ render(<Comp />, scratch);
+
+ expect(refAtLayoutTime.value).to.equal('hello');
+ });
+
+ it('can use multiple useState and useReducer hooks', () => {
+ let states = [];
+ let dispatchState4;
+
+ function reducer1(state, action) {
+ switch (action.type) {
+ case 'increment':
+ return state + action.count;
+ }
+ }
+
+ function reducer2(state, action) {
+ switch (action.type) {
+ case 'increment':
+ return state + action.count * 2;
+ }
+ }
+
+ function Comp() {
+ const [state1] = useState(0);
+ const [state2] = useReducer(reducer1, 10);
+ const [state3] = useState(1);
+ const [state4, dispatch] = useReducer(reducer2, 20);
+
+ dispatchState4 = dispatch;
+ states.push(state1, state2, state3, state4);
+
+ return null;
+ }
+
+ render(<Comp />, scratch);
+
+ expect(states).to.deep.equal([0, 10, 1, 20]);
+
+ states = [];
+
+ dispatchState4({ type: 'increment', count: 10 });
+ rerender();
+
+ expect(states).to.deep.equal([0, 10, 1, 40]);
+ });
+
+ it('ensures useEffect always schedule after the next paint following a redraw effect, when using the default debounce strategy', () => {
+ let effectCount = 0;
+
+ function Comp() {
+ const [counter, setCounter] = useState(0);
+
+ useEffect(() => {
+ if (counter === 0) setCounter(1);
+ effectCount++;
+ });
+
+ return null;
+ }
+
+ render(<Comp />, scratch);
+
+ return scheduleEffectAssert(() => {
+ expect(effectCount).to.equal(1);
+ });
+ });
+
+ it('should not reuse functional components with hooks', () => {
+ let updater = { first: undefined, second: undefined };
+ function Foo(props) {
+ let [v, setter] = useState(0);
+ updater[props.id] = () => setter(++v);
+ return <div>{v}</div>;
+ }
+
+ let updateParent;
+ class App extends Component {
+ constructor(props) {
+ super(props);
+ this.state = { active: true };
+ updateParent = () => this.setState(p => ({ active: !p.active }));
+ }
+
+ render() {
+ return (
+ <div>
+ {this.state.active && <Foo id="first" />}
+ <Foo id="second" />
+ </div>
+ );
+ }
+ }
+
+ render(<App />, scratch);
+ act(() => updater.second());
+ expect(scratch.textContent).to.equal('01');
+
+ updateParent();
+ rerender();
+ expect(scratch.textContent).to.equal('1');
+
+ updateParent();
+ rerender();
+
+ expect(scratch.textContent).to.equal('01');
+ });
+
+ it('should have a right call order with correct dom ref', () => {
+ let i = 0,
+ set;
+ const calls = [];
+
+ function Inner() {
+ useLayoutEffect(() => {
+ calls.push('layout inner call ' + scratch.innerHTML);
+ return () => calls.push('layout inner dispose ' + scratch.innerHTML);
+ });
+ useEffect(() => {
+ calls.push('effect inner call ' + scratch.innerHTML);
+ return () => calls.push('effect inner dispose ' + scratch.innerHTML);
+ });
+ return <span>hello {i}</span>;
+ }
+
+ function Outer() {
+ i++;
+ const [state, setState] = useState(false);
+ set = () => setState(!state);
+ useLayoutEffect(() => {
+ calls.push('layout outer call ' + scratch.innerHTML);
+ return () => calls.push('layout outer dispose ' + scratch.innerHTML);
+ });
+ useEffect(() => {
+ calls.push('effect outer call ' + scratch.innerHTML);
+ return () => calls.push('effect outer dispose ' + scratch.innerHTML);
+ });
+ return <Inner />;
+ }
+
+ act(() => render(<Outer />, scratch));
+ expect(calls).to.deep.equal([
+ 'layout inner call <span>hello 1</span>',
+ 'layout outer call <span>hello 1</span>',
+ 'effect inner call <span>hello 1</span>',
+ 'effect outer call <span>hello 1</span>'
+ ]);
+
+ // NOTE: this order is (at the time of writing) intentionally different from
+ // React. React calls all disposes across all components, and then invokes all
+ // effects across all components. We call disposes and effects in order of components:
+ // for each component, call its disposes and then its effects. If presented with a
+ // compelling use case to support inter-component dispose dependencies, then rewrite this
+ // test to test React's order. In other words, if there is a use case to support calling
+ // all disposes across components then re-order the lines below to demonstrate the desired behavior.
+
+ act(() => set());
+ expect(calls).to.deep.equal([
+ 'layout inner call <span>hello 1</span>',
+ 'layout outer call <span>hello 1</span>',
+ 'effect inner call <span>hello 1</span>',
+ 'effect outer call <span>hello 1</span>',
+ 'layout inner dispose <span>hello 2</span>',
+ 'layout inner call <span>hello 2</span>',
+ 'layout outer dispose <span>hello 2</span>',
+ 'layout outer call <span>hello 2</span>',
+ 'effect inner dispose <span>hello 2</span>',
+ 'effect inner call <span>hello 2</span>',
+ 'effect outer dispose <span>hello 2</span>',
+ 'effect outer call <span>hello 2</span>'
+ ]);
+ });
+});
diff --git a/preact/hooks/test/browser/errorBoundary.test.js b/preact/hooks/test/browser/errorBoundary.test.js
new file mode 100644
index 0000000..4c2f7f3
--- /dev/null
+++ b/preact/hooks/test/browser/errorBoundary.test.js
@@ -0,0 +1,92 @@
+import { createElement, render } from 'preact';
+import { setupScratch, teardown } from '../../../test/_util/helpers';
+import { useErrorBoundary } from 'preact/hooks';
+import { setupRerender } from 'preact/test-utils';
+
+/** @jsx createElement */
+
+describe('errorBoundary', () => {
+ /** @type {HTMLDivElement} */
+ let scratch, rerender;
+
+ beforeEach(() => {
+ scratch = setupScratch();
+ rerender = setupRerender();
+ });
+
+ afterEach(() => {
+ teardown(scratch);
+ });
+
+ it('catches errors', () => {
+ let resetErr,
+ success = false;
+ const Throws = () => {
+ throw new Error('test');
+ };
+
+ const App = props => {
+ const [err, reset] = useErrorBoundary();
+ resetErr = reset;
+ return err ? <p>Error</p> : success ? <p>Success</p> : <Throws />;
+ };
+
+ render(<App />, scratch);
+ rerender();
+ expect(scratch.innerHTML).to.equal('<p>Error</p>');
+
+ success = true;
+ resetErr();
+ rerender();
+ expect(scratch.innerHTML).to.equal('<p>Success</p>');
+ });
+
+ it('calls the errorBoundary callback', () => {
+ const spy = sinon.spy();
+ const error = new Error('test');
+ const Throws = () => {
+ throw error;
+ };
+
+ const App = props => {
+ const [err] = useErrorBoundary(spy);
+ return err ? <p>Error</p> : <Throws />;
+ };
+
+ render(<App />, scratch);
+ rerender();
+ expect(scratch.innerHTML).to.equal('<p>Error</p>');
+ expect(spy).to.be.calledOnce;
+ expect(spy).to.be.calledWith(error);
+ });
+
+ it('does not leave a stale closure', () => {
+ const spy = sinon.spy(),
+ spy2 = sinon.spy();
+ let resetErr;
+ const error = new Error('test');
+ const Throws = () => {
+ throw error;
+ };
+
+ const App = props => {
+ const [err, reset] = useErrorBoundary(props.onError);
+ resetErr = reset;
+ return err ? <p>Error</p> : <Throws />;
+ };
+
+ render(<App onError={spy} />, scratch);
+ rerender();
+ expect(scratch.innerHTML).to.equal('<p>Error</p>');
+ expect(spy).to.be.calledOnce;
+ expect(spy).to.be.calledWith(error);
+
+ resetErr();
+ render(<App onError={spy2} />, scratch);
+ rerender();
+ expect(scratch.innerHTML).to.equal('<p>Error</p>');
+ expect(spy).to.be.calledOnce;
+ expect(spy2).to.be.calledOnce;
+ expect(spy2).to.be.calledWith(error);
+ });
+});
diff --git a/preact/hooks/test/browser/hooks.options.test.js b/preact/hooks/test/browser/hooks.options.test.js
new file mode 100644
index 0000000..ca88d1f
--- /dev/null
+++ b/preact/hooks/test/browser/hooks.options.test.js
@@ -0,0 +1,154 @@
+import {
+ afterDiffSpy,
+ beforeRenderSpy,
+ unmountSpy,
+ hookSpy
+} from '../../../test/_util/optionSpies';
+
+import { setupRerender, act } from 'preact/test-utils';
+import { createElement, render, createContext, options } from 'preact';
+import { setupScratch, teardown } from '../../../test/_util/helpers';
+import {
+ useState,
+ useReducer,
+ useEffect,
+ useLayoutEffect,
+ useRef,
+ useImperativeHandle,
+ useMemo,
+ useCallback,
+ useContext,
+ useErrorBoundary
+} from 'preact/hooks';
+
+/** @jsx createElement */
+
+describe('hook options', () => {
+ /** @type {HTMLDivElement} */
+ let scratch;
+
+ /** @type {() => void} */
+ let rerender;
+
+ /** @type {() => void} */
+ let increment;
+
+ beforeEach(() => {
+ scratch = setupScratch();
+ rerender = setupRerender();
+
+ afterDiffSpy.resetHistory();
+ unmountSpy.resetHistory();
+ beforeRenderSpy.resetHistory();
+ hookSpy.resetHistory();
+ });
+
+ afterEach(() => {
+ teardown(scratch);
+ });
+
+ function App() {
+ const [count, setCount] = useState(0);
+ increment = () => setCount(prevCount => prevCount + 1);
+ return <div>{count}</div>;
+ }
+
+ it('should call old options on mount', () => {
+ render(<App />, scratch);
+
+ expect(beforeRenderSpy).to.have.been.called;
+ expect(afterDiffSpy).to.have.been.called;
+ });
+
+ it('should call old options.diffed on update', () => {
+ render(<App />, scratch);
+
+ increment();
+ rerender();
+
+ expect(beforeRenderSpy).to.have.been.called;
+ expect(afterDiffSpy).to.have.been.called;
+ });
+
+ it('should call old options on unmount', () => {
+ render(<App />, scratch);
+ render(null, scratch);
+
+ expect(unmountSpy).to.have.been.called;
+ });
+
+ it('should detect hooks', () => {
+ const USE_STATE = 1;
+ const USE_REDUCER = 2;
+ const USE_EFFECT = 3;
+ const USE_LAYOUT_EFFECT = 4;
+ const USE_REF = 5;
+ const USE_IMPERATIVE_HANDLE = 6;
+ const USE_MEMO = 7;
+ const USE_CALLBACK = 8;
+ const USE_CONTEXT = 9;
+ const USE_ERROR_BOUNDARY = 10;
+
+ const Ctx = createContext(null);
+
+ function App() {
+ useState(0);
+ useReducer(x => x, 0);
+ useEffect(() => null, []);
+ useLayoutEffect(() => null, []);
+ const ref = useRef(null);
+ useImperativeHandle(ref, () => null);
+ useMemo(() => null, []);
+ useCallback(() => null, []);
+ useContext(Ctx);
+ useErrorBoundary(() => null);
+ }
+
+ render(
+ <Ctx.Provider value="a">
+ <App />
+ </Ctx.Provider>,
+ scratch
+ );
+
+ expect(hookSpy.args.map(arg => [arg[1], arg[2]])).to.deep.equal([
+ [0, USE_STATE],
+ [1, USE_REDUCER],
+ [2, USE_EFFECT],
+ [3, USE_LAYOUT_EFFECT],
+ [4, USE_REF],
+ [5, USE_IMPERATIVE_HANDLE],
+ [6, USE_MEMO],
+ [7, USE_CALLBACK],
+ [8, USE_CONTEXT],
+ [9, USE_ERROR_BOUNDARY],
+ // Belongs to useErrorBoundary that uses multiple native hooks.
+ [10, USE_STATE]
+ ]);
+ });
+
+ describe('Effects', () => {
+ beforeEach(() => {
+ options._skipEffects = options.__s = true;
+ });
+
+ afterEach(() => {
+ options._skipEffects = options.__s = false;
+ });
+
+ it('should skip effect hooks', () => {
+ const spy = sinon.spy();
+ function App() {
+ useEffect(spy, []);
+ useLayoutEffect(spy, []);
+ return null;
+ }
+
+ act(() => {
+ render(<App />, scratch);
+ });
+
+ expect(spy.callCount).to.equal(0);
+ });
+ });
+});
diff --git a/preact/hooks/test/browser/useCallback.test.js b/preact/hooks/test/browser/useCallback.test.js
new file mode 100644
index 0000000..151fed9
--- /dev/null
+++ b/preact/hooks/test/browser/useCallback.test.js
@@ -0,0 +1,41 @@
+import { createElement, render } from 'preact';
+import { setupScratch, teardown } from '../../../test/_util/helpers';
+import { useCallback } from 'preact/hooks';
+
+/** @jsx createElement */
+
+describe('useCallback', () => {
+ /** @type {HTMLDivElement} */
+ let scratch;
+
+ beforeEach(() => {
+ scratch = setupScratch();
+ });
+
+ afterEach(() => {
+ teardown(scratch);
+ });
+
+ it('only recomputes the callback when inputs change', () => {
+ const callbacks = [];
+
+ function Comp({ a, b }) {
+ const cb = useCallback(() => a + b, [a, b]);
+ callbacks.push(cb);
+ return null;
+ }
+
+ render(<Comp a={1} b={1} />, scratch);
+ render(<Comp a={1} b={1} />, scratch);
+
+ expect(callbacks[0]).to.equal(callbacks[1]);
+ expect(callbacks[0]()).to.equal(2);
+
+ render(<Comp a={1} b={2} />, scratch);
+ render(<Comp a={1} b={2} />, scratch);
+
+ expect(callbacks[1]).to.not.equal(callbacks[2]);
+ expect(callbacks[2]).to.equal(callbacks[3]);
+ expect(callbacks[2]()).to.equal(3);
+ });
+});
diff --git a/preact/hooks/test/browser/useContext.test.js b/preact/hooks/test/browser/useContext.test.js
new file mode 100644
index 0000000..67b7851
--- /dev/null
+++ b/preact/hooks/test/browser/useContext.test.js
@@ -0,0 +1,351 @@
+import { createElement, render, createContext, Component } from 'preact';
+import { act } from 'preact/test-utils';
+import { setupScratch, teardown } from '../../../test/_util/helpers';
+import { useContext, useEffect, useState } from 'preact/hooks';
+
+/** @jsx createElement */
+
+describe('useContext', () => {
+ /** @type {HTMLDivElement} */
+ let scratch;
+
+ beforeEach(() => {
+ scratch = setupScratch();
+ });
+
+ afterEach(() => {
+ teardown(scratch);
+ });
+
+ it('gets values from context', () => {
+ const values = [];
+ const Context = createContext(13);
+
+ function Comp() {
+ const value = useContext(Context);
+ values.push(value);
+ return null;
+ }
+
+ render(<Comp />, scratch);
+ render(
+ <Context.Provider value={42}>
+ <Comp />
+ </Context.Provider>,
+ scratch
+ );
+ render(
+ <Context.Provider value={69}>
+ <Comp />
+ </Context.Provider>,
+ scratch
+ );
+
+ expect(values).to.deep.equal([13, 42, 69]);
+ });
+
+ it('should use default value', () => {
+ const Foo = createContext(42);
+ const spy = sinon.spy();
+
+ function App() {
+ spy(useContext(Foo));
+ return <div />;
+ }
+
+ render(<App />, scratch);
+ expect(spy).to.be.calledWith(42);
+ });
+
+ it('should update when value changes with nonUpdating Component on top', done => {
+ const spy = sinon.spy();
+ const Ctx = createContext(0);
+
+ class NoUpdate extends Component {
+ shouldComponentUpdate() {
+ return false;
+ }
+ render() {
+ return this.props.children;
+ }
+ }
+
+ function App(props) {
+ return (
+ <Ctx.Provider value={props.value}>
+ <NoUpdate>
+ <Comp />
+ </NoUpdate>
+ </Ctx.Provider>
+ );
+ }
+
+ function Comp() {
+ const value = useContext(Ctx);
+ spy(value);
+ return <h1>{value}</h1>;
+ }
+
+ render(<App value={0} />, scratch);
+ expect(spy).to.be.calledOnce;
+ expect(spy).to.be.calledWith(0);
+ render(<App value={1} />, scratch);
+
+ // Wait for enqueued hook update
+ setTimeout(() => {
+ // Should not be called a third time
+ expect(spy).to.be.calledTwice;
+ expect(spy).to.be.calledWith(1);
+ done();
+ }, 0);
+ });
+
+ it('should only update when value has changed', done => {
+ const spy = sinon.spy();
+ const Ctx = createContext(0);
+
+ function App(props) {
+ return (
+ <Ctx.Provider value={props.value}>
+ <Comp />
+ </Ctx.Provider>
+ );
+ }
+
+ function Comp() {
+ const value = useContext(Ctx);
+ spy(value);
+ return <h1>{value}</h1>;
+ }
+
+ render(<App value={0} />, scratch);
+ expect(spy).to.be.calledOnce;
+ expect(spy).to.be.calledWith(0);
+ render(<App value={1} />, scratch);
+
+ expect(spy).to.be.calledTwice;
+ expect(spy).to.be.calledWith(1);
+
+ // Wait for enqueued hook update
+ setTimeout(() => {
+ // Should not be called a third time
+ expect(spy).to.be.calledTwice;
+ done();
+ }, 0);
+ });
+
+ it('should allow multiple context hooks at the same time', () => {
+ const Foo = createContext(0);
+ const Bar = createContext(10);
+ const spy = sinon.spy();
+ const unmountspy = sinon.spy();
+
+ function Comp() {
+ const foo = useContext(Foo);
+ const bar = useContext(Bar);
+ spy(foo, bar);
+ useEffect(() => () => unmountspy());
+
+ return <div />;
+ }
+
+ render(
+ <Foo.Provider value={0}>
+ <Bar.Provider value={10}>
+ <Comp />
+ </Bar.Provider>
+ </Foo.Provider>,
+ scratch
+ );
+
+ expect(spy).to.be.calledOnce;
+ expect(spy).to.be.calledWith(0, 10);
+
+ render(
+ <Foo.Provider value={11}>
+ <Bar.Provider value={42}>
+ <Comp />
+ </Bar.Provider>
+ </Foo.Provider>,
+ scratch
+ );
+
+ expect(spy).to.be.calledTwice;
+ expect(unmountspy).not.to.be.called;
+ });
+
+ it('should only subscribe a component once', () => {
+ const values = [];
+ const Context = createContext(13);
+ let provider, subSpy;
+
+ function Comp() {
+ const value = useContext(Context);
+ values.push(value);
+ return null;
+ }
+
+ render(<Comp />, scratch);
+
+ render(
+ <Context.Provider ref={p => (provider = p)} value={42}>
+ <Comp />
+ </Context.Provider>,
+ scratch
+ );
+ subSpy = sinon.spy(provider, 'sub');
+
+ render(
+ <Context.Provider value={69}>
+ <Comp />
+ </Context.Provider>,
+ scratch
+ );
+ expect(subSpy).to.not.have.been.called;
+
+ expect(values).to.deep.equal([13, 42, 69]);
+ });
+
+ it('should maintain context', done => {
+ const context = createContext(null);
+ const { Provider } = context;
+ const first = { name: 'first' };
+ const second = { name: 'second' };
+
+ const Input = () => {
+ const config = useContext(context);
+
+ // Avoid eslint complaining about unused first value
+ const state = useState('initial');
+ const set = state[1];
+
+ useEffect(() => {
+ // Schedule the update on the next frame
+ requestAnimationFrame(() => {
+ set('irrelevant');
+ });
+ }, [config]);
+
+ return <div>{config.name}</div>;
+ };
+
+ const App = props => {
+ const [config, setConfig] = useState({});
+
+ useEffect(() => {
+ setConfig(props.config);
+ }, [props.config]);
+
+ return (
+ <Provider value={config}>
+ <Input />
+ </Provider>
+ );
+ };
+
+ act(() => {
+ render(<App config={first} />, scratch);
+
+ // Create a new div to append the `second` case
+ const div = scratch.appendChild(document.createElement('div'));
+ render(<App config={second} />, div);
+ });
+
+ // Push the expect into the next frame
+ requestAnimationFrame(() => {
+ expect(scratch.innerHTML).equal(
+ '<div>first</div><div><div>second</div></div>'
+ );
+ done();
+ });
+ });
+
+ it('should not rerender consumers that have been unmounted', () => {
+ const context = createContext(0);
+ const Provider = context.Provider;
+
+ const Inner = sinon.spy(() => {
+ const value = useContext(context);
+ return <div>{value}</div>;
+ });
+
+ let toggleConsumer;
+ let changeValue;
+ class App extends Component {
+ constructor() {
+ super();
+
+ this.state = { value: 0, show: true };
+ changeValue = value => this.setState({ value });
+ toggleConsumer = () => this.setState(({ show }) => ({ show: !show }));
+ }
+ render(props, state) {
+ return (
+ <Provider value={state.value}>
+ <div>{state.show ? <Inner /> : null}</div>
+ </Provider>
+ );
+ }
+ }
+
+ render(<App />, scratch);
+ expect(scratch.innerHTML).to.equal('<div><div>0</div></div>');
+ expect(Inner).to.have.been.calledOnce;
+
+ act(() => changeValue(1));
+ expect(scratch.innerHTML).to.equal('<div><div>1</div></div>');
+ expect(Inner).to.have.been.calledTwice;
+
+ act(() => toggleConsumer());
+ expect(scratch.innerHTML).to.equal('<div></div>');
+ expect(Inner).to.have.been.calledTwice;
+
+ act(() => changeValue(2));
+ expect(scratch.innerHTML).to.equal('<div></div>');
+ expect(Inner).to.have.been.calledTwice;
+ });
+
+ it('should rerender when reset to defaultValue', () => {
+ const defaultValue = { state: 'hi' };
+ const context = createContext(defaultValue);
+ let set;
+
+ const Consumer = () => {
+ const ctx = useContext(context);
+ return <p>{ctx.state}</p>;
+ };
+
+ class NoUpdate extends Component {
+ shouldComponentUpdate() {
+ return false;
+ }
+
+ render() {
+ return <Consumer />;
+ }
+ }
+
+ const Provider = () => {
+ const [state, setState] = useState(defaultValue);
+ set = setState;
+ return (
+ <context.Provider value={state}>
+ <NoUpdate />
+ </context.Provider>
+ );
+ };
+
+ render(<Provider />, scratch);
+ expect(scratch.innerHTML).to.equal('<p>hi</p>');
+
+ act(() => {
+ set({ state: 'bye' });
+ });
+ expect(scratch.innerHTML).to.equal('<p>bye</p>');
+
+ act(() => {
+ set(defaultValue);
+ });
+ expect(scratch.innerHTML).to.equal('<p>hi</p>');
+ });
+});
diff --git a/preact/hooks/test/browser/useDebugValue.test.js b/preact/hooks/test/browser/useDebugValue.test.js
new file mode 100644
index 0000000..d19ff6b
--- /dev/null
+++ b/preact/hooks/test/browser/useDebugValue.test.js
@@ -0,0 +1,71 @@
+import { createElement, render, options } from 'preact';
+import { setupScratch, teardown } from '../../../test/_util/helpers';
+import { useDebugValue, useState } from 'preact/hooks';
+
+/** @jsx createElement */
+
+describe('useDebugValue', () => {
+ /** @type {HTMLDivElement} */
+ let scratch;
+
+ beforeEach(() => {
+ scratch = setupScratch();
+ });
+
+ afterEach(() => {
+ teardown(scratch);
+ delete options.useDebugValue;
+ });
+
+ it('should do nothing when no options hook is present', () => {
+ function useFoo() {
+ useDebugValue('foo');
+ return useState(0);
+ }
+
+ function App() {
+ let [v] = useFoo();
+ return <div>{v}</div>;
+ }
+
+ expect(() => render(<App />, scratch)).to.not.throw();
+ });
+
+ it('should call options hook with value', () => {
+ let spy = (options.useDebugValue = sinon.spy());
+
+ function useFoo() {
+ useDebugValue('foo');
+ return useState(0);
+ }
+
+ function App() {
+ let [v] = useFoo();
+ return <div>{v}</div>;
+ }
+
+ render(<App />, scratch);
+
+ expect(spy).to.be.calledOnce;
+ expect(spy).to.be.calledWith('foo');
+ });
+
+ it('should apply optional formatter', () => {
+ let spy = (options.useDebugValue = sinon.spy());
+
+ function useFoo() {
+ useDebugValue('foo', x => x + 'bar');
+ return useState(0);
+ }
+
+ function App() {
+ let [v] = useFoo();
+ return <div>{v}</div>;
+ }
+
+ render(<App />, scratch);
+
+ expect(spy).to.be.calledOnce;
+ expect(spy).to.be.calledWith('foobar');
+ });
+});
diff --git a/preact/hooks/test/browser/useEffect.test.js b/preact/hooks/test/browser/useEffect.test.js
new file mode 100644
index 0000000..a16d3fc
--- /dev/null
+++ b/preact/hooks/test/browser/useEffect.test.js
@@ -0,0 +1,373 @@
+import { act } from 'preact/test-utils';
+import { createElement, render, Fragment, Component } from 'preact';
+import { useEffect, useState, useRef } from 'preact/hooks';
+import { setupScratch, teardown } from '../../../test/_util/helpers';
+import { useEffectAssertions } from './useEffectAssertions.test';
+import { scheduleEffectAssert } from '../_util/useEffectUtil';
+
+/** @jsx createElement */
+
+describe('useEffect', () => {
+ /** @type {HTMLDivElement} */
+ let scratch;
+
+ beforeEach(() => {
+ scratch = setupScratch();
+ });
+
+ afterEach(() => {
+ teardown(scratch);
+ });
+
+ useEffectAssertions(useEffect, scheduleEffectAssert);
+
+ it('calls the effect immediately if another render is about to start', () => {
+ const cleanupFunction = sinon.spy();
+ const callback = sinon.spy(() => cleanupFunction);
+
+ function Comp() {
+ useEffect(callback);
+ return null;
+ }
+
+ render(<Comp />, scratch);
+ render(<Comp />, scratch);
+
+ expect(cleanupFunction).to.be.not.called;
+ expect(callback).to.be.calledOnce;
+
+ render(<Comp />, scratch);
+
+ expect(cleanupFunction).to.be.calledOnce;
+ expect(callback).to.be.calledTwice;
+ });
+
+ it('cancels the effect when the component get unmounted before it had the chance to run it', () => {
+ const cleanupFunction = sinon.spy();
+ const callback = sinon.spy(() => cleanupFunction);
+
+ function Comp() {
+ useEffect(callback);
+ return null;
+ }
+
+ render(<Comp />, scratch);
+ render(null, scratch);
+
+ return scheduleEffectAssert(() => {
+ expect(cleanupFunction).to.not.be.called;
+ expect(callback).to.not.be.called;
+ });
+ });
+
+ it('should execute multiple effects in same component in the right order', () => {
+ let executionOrder = [];
+ const App = ({ i }) => {
+ executionOrder = [];
+ useEffect(() => {
+ executionOrder.push('action1');
+ return () => executionOrder.push('cleanup1');
+ }, [i]);
+ useEffect(() => {
+ executionOrder.push('action2');
+ return () => executionOrder.push('cleanup2');
+ }, [i]);
+ return <p>Test</p>;
+ };
+ act(() => render(<App i={0} />, scratch));
+ act(() => render(<App i={2} />, scratch));
+ expect(executionOrder).to.deep.equal([
+ 'cleanup1',
+ 'cleanup2',
+ 'action1',
+ 'action2'
+ ]);
+ });
+
+ it('should execute effects in parent if child throws in effect', async () => {
+ let executionOrder = [];
+
+ const Child = () => {
+ useEffect(() => {
+ executionOrder.push('child');
+ throw new Error('test');
+ }, []);
+
+ useEffect(() => {
+ executionOrder.push('child after throw');
+ return () => executionOrder.push('child after throw cleanup');
+ }, []);
+
+ return <p>Test</p>;
+ };
+
+ const Parent = () => {
+ useEffect(() => {
+ executionOrder.push('parent');
+ return () => executionOrder.push('parent cleanup');
+ }, []);
+ return <Child />;
+ };
+
+ class ErrorBoundary extends Component {
+ componentDidCatch(error) {
+ this.setState({ error });
+ }
+
+ render({ children }, { error }) {
+ return error ? <div>error</div> : children;
+ }
+ }
+
+ act(() =>
+ render(
+ <ErrorBoundary>
+ <Parent />
+ </ErrorBoundary>,
+ scratch
+ )
+ );
+
+ expect(executionOrder).to.deep.equal(['child', 'parent', 'parent cleanup']);
+ expect(scratch.innerHTML).to.equal('<div>error</div>');
+ });
+
+ it('should throw an error upwards', () => {
+ const spy = sinon.spy();
+ let errored = false;
+
+ const Page1 = () => {
+ const [state, setState] = useState('loading');
+ useEffect(() => {
+ setState('loaded');
+ }, []);
+ return <p>{state}</p>;
+ };
+
+ const Page2 = () => {
+ useEffect(() => {
+ throw new Error('err');
+ }, []);
+ return <p>invisible</p>;
+ };
+
+ class App extends Component {
+ componentDidCatch(err) {
+ spy();
+ errored = err;
+ this.forceUpdate();
+ }
+
+ render(props, state) {
+ if (errored) {
+ return <p>Error</p>;
+ }
+
+ return <Fragment>{props.page === 1 ? <Page1 /> : <Page2 />}</Fragment>;
+ }
+ }
+
+ act(() => render(<App page={1} />, scratch));
+ expect(spy).to.not.be.called;
+ expect(scratch.innerHTML).to.equal('<p>loaded</p>');
+
+ act(() => render(<App page={2} />, scratch));
+ expect(spy).to.be.calledOnce;
+ expect(scratch.innerHTML).to.equal('<p>Error</p>');
+ errored = false;
+
+ act(() => render(<App page={1} />, scratch));
+ expect(spy).to.be.calledOnce;
+ expect(scratch.innerHTML).to.equal('<p>loaded</p>');
+ });
+
+ it('should throw an error upwards from return', () => {
+ const spy = sinon.spy();
+ let errored = false;
+
+ const Page1 = () => {
+ const [state, setState] = useState('loading');
+ useEffect(() => {
+ setState('loaded');
+ }, []);
+ return <p>{state}</p>;
+ };
+
+ const Page2 = () => {
+ useEffect(() => {
+ return () => {
+ throw new Error('err');
+ };
+ }, []);
+ return <p>Load</p>;
+ };
+
+ class App extends Component {
+ componentDidCatch(err) {
+ spy();
+ errored = err;
+ this.forceUpdate();
+ }
+
+ render(props, state) {
+ if (errored) {
+ return <p>Error</p>;
+ }
+
+ return <Fragment>{props.page === 1 ? <Page1 /> : <Page2 />}</Fragment>;
+ }
+ }
+
+ act(() => render(<App page={2} />, scratch));
+ expect(scratch.innerHTML).to.equal('<p>Load</p>');
+
+ act(() => render(<App page={1} />, scratch));
+ expect(spy).to.be.calledOnce;
+ expect(scratch.innerHTML).to.equal('<p>Error</p>');
+ });
+
+ it('catches errors when error is invoked during render', () => {
+ const spy = sinon.spy();
+ let errored;
+
+ function Comp() {
+ useEffect(() => {
+ throw new Error('hi');
+ });
+ return null;
+ }
+
+ class App extends Component {
+ componentDidCatch(err) {
+ spy();
+ errored = err;
+ this.forceUpdate();
+ }
+
+ render(props, state) {
+ if (errored) {
+ return <p>Error</p>;
+ }
+
+ return <Comp />;
+ }
+ }
+
+ render(<App />, scratch);
+ act(() => {
+ render(<App />, scratch);
+ });
+ expect(spy).to.be.calledOnce;
+ expect(errored)
+ .to.be.an('Error')
+ .with.property('message', 'hi');
+ expect(scratch.innerHTML).to.equal('<p>Error</p>');
+ });
+
+ it('should allow creating a new root', () => {
+ const root = document.createElement('div');
+ const global = document.createElement('div');
+ scratch.appendChild(root);
+ scratch.appendChild(global);
+
+ const Modal = props => {
+ let [, setCanProceed] = useState(true);
+ let ChildProp = props.content;
+
+ return (
+ <div>
+ <ChildProp setCanProceed={setCanProceed} />
+ </div>
+ );
+ };
+
+ const Inner = () => {
+ useEffect(() => {
+ render(<div>global</div>, global);
+ }, []);
+
+ return <div>Inner</div>;
+ };
+
+ act(() => {
+ render(
+ <Modal
+ content={props => {
+ props.setCanProceed(false);
+ return <Inner />;
+ }}
+ />,
+ root
+ );
+ });
+
+ expect(scratch.innerHTML).to.equal(
+ '<div><div><div>Inner</div></div></div><div><div>global</div></div>'
+ );
+ });
+
+ it('should not crash when effect returns truthy non-function value', () => {
+ const callback = sinon.spy(() => 'truthy');
+ function Comp() {
+ useEffect(callback);
+ return null;
+ }
+
+ render(<Comp />, scratch);
+ render(<Comp />, scratch);
+
+ expect(callback).to.have.been.calledOnce;
+
+ render(<div>Replacement</div>, scratch);
+ });
+
+ it('support render roots from an effect', async () => {
+ let promise, increment;
+
+ const Counter = () => {
+ const [count, setCount] = useState(0);
+ const renderRoot = useRef();
+ useEffect(() => {
+ if (count > 0) {
+ const div = renderRoot.current;
+ return () => render(<Dummy />, div);
+ }
+ return () => 'test';
+ }, [count]);
+
+ increment = () => {
+ setCount(x => x + 1);
+ promise = new Promise(res => {
+ setTimeout(() => {
+ setCount(x => x + 1);
+ res();
+ });
+ });
+ };
+
+ return (
+ <div>
+ <div>Count: {count}</div>
+ <div ref={renderRoot} />
+ </div>
+ );
+ };
+
+ const Dummy = () => <div>dummy</div>;
+
+ render(<Counter />, scratch);
+
+ expect(scratch.innerHTML).to.equal(
+ '<div><div>Count: 0</div><div></div></div>'
+ );
+
+ act(() => {
+ increment();
+ });
+ await promise;
+ act(() => {});
+ expect(scratch.innerHTML).to.equal(
+ '<div><div>Count: 2</div><div><div>dummy</div></div></div>'
+ );
+ });
+});
diff --git a/preact/hooks/test/browser/useEffectAssertions.test.js b/preact/hooks/test/browser/useEffectAssertions.test.js
new file mode 100644
index 0000000..74ba232
--- /dev/null
+++ b/preact/hooks/test/browser/useEffectAssertions.test.js
@@ -0,0 +1,142 @@
+import { setupRerender } from 'preact/test-utils';
+import { createElement, render } from 'preact';
+import { setupScratch, teardown } from '../../../test/_util/helpers';
+
+/** @jsx createElement */
+
+// Common behaviors between all effect hooks
+export function useEffectAssertions(useEffect, scheduleEffectAssert) {
+ /** @type {HTMLDivElement} */
+ let scratch;
+
+ /** @type {() => void} */
+ let rerender;
+
+ beforeEach(() => {
+ scratch = setupScratch();
+ rerender = setupRerender();
+ });
+
+ afterEach(() => {
+ teardown(scratch);
+ });
+
+ it('performs the effect after every render by default', () => {
+ const callback = sinon.spy();
+
+ function Comp() {
+ useEffect(callback);
+ return null;
+ }
+
+ render(<Comp />, scratch);
+
+ return scheduleEffectAssert(() => expect(callback).to.be.calledOnce)
+ .then(() => scheduleEffectAssert(() => expect(callback).to.be.calledOnce))
+ .then(() => render(<Comp />, scratch))
+ .then(() =>
+ scheduleEffectAssert(() => expect(callback).to.be.calledTwice)
+ );
+ });
+
+ it('performs the effect only if one of the inputs changed', () => {
+ const callback = sinon.spy();
+
+ function Comp(props) {
+ useEffect(callback, [props.a, props.b]);
+ return null;
+ }
+
+ render(<Comp a={1} b={2} />, scratch);
+
+ return scheduleEffectAssert(() => expect(callback).to.be.calledOnce)
+ .then(() => render(<Comp a={1} b={2} />, scratch))
+ .then(() => scheduleEffectAssert(() => expect(callback).to.be.calledOnce))
+ .then(() => render(<Comp a={2} b={2} />, scratch))
+ .then(() =>
+ scheduleEffectAssert(() => expect(callback).to.be.calledTwice)
+ )
+ .then(() => render(<Comp a={2} b={2} />, scratch))
+ .then(() =>
+ scheduleEffectAssert(() => expect(callback).to.be.calledTwice)
+ );
+ });
+
+ it('performs the effect at mount time and never again if an empty input Array is passed', () => {
+ const callback = sinon.spy();
+
+ function Comp() {
+ useEffect(callback, []);
+ return null;
+ }
+
+ render(<Comp />, scratch);
+ render(<Comp />, scratch);
+
+ expect(callback).to.be.calledOnce;
+
+ return scheduleEffectAssert(() => expect(callback).to.be.calledOnce)
+ .then(() => render(<Comp />, scratch))
+ .then(() =>
+ scheduleEffectAssert(() => expect(callback).to.be.calledOnce)
+ );
+ });
+
+ it('calls the cleanup function followed by the effect after each render', () => {
+ const cleanupFunction = sinon.spy();
+ const callback = sinon.spy(() => cleanupFunction);
+
+ function Comp() {
+ useEffect(callback);
+ return null;
+ }
+
+ render(<Comp />, scratch);
+
+ return scheduleEffectAssert(() => {
+ expect(cleanupFunction).to.be.not.called;
+ expect(callback).to.be.calledOnce;
+ })
+ .then(() => scheduleEffectAssert(() => expect(callback).to.be.calledOnce))
+ .then(() => render(<Comp />, scratch))
+ .then(() =>
+ scheduleEffectAssert(() => {
+ expect(cleanupFunction).to.be.calledOnce;
+ expect(callback).to.be.calledTwice;
+ expect(callback.lastCall.calledAfter(cleanupFunction.lastCall));
+ })
+ );
+ });
+
+ it('cleanups the effect when the component get unmounted if the effect was called before', () => {
+ const cleanupFunction = sinon.spy();
+ const callback = sinon.spy(() => cleanupFunction);
+
+ function Comp() {
+ useEffect(callback);
+ return null;
+ }
+
+ render(<Comp />, scratch);
+
+ return scheduleEffectAssert(() => {
+ render(null, scratch);
+ rerender();
+ expect(cleanupFunction).to.be.calledOnce;
+ });
+ });
+
+ it('works with closure effect callbacks capturing props', () => {
+ const values = [];
+
+ function Comp(props) {
+ useEffect(() => values.push(props.value));
+ return null;
+ }
+
+ render(<Comp value={1} />, scratch);
+ render(<Comp value={2} />, scratch);
+
+ return scheduleEffectAssert(() => expect(values).to.deep.equal([1, 2]));
+ });
+}
diff --git a/preact/hooks/test/browser/useImperativeHandle.test.js b/preact/hooks/test/browser/useImperativeHandle.test.js
new file mode 100644
index 0000000..52cc073
--- /dev/null
+++ b/preact/hooks/test/browser/useImperativeHandle.test.js
@@ -0,0 +1,182 @@
+import { createElement, render } from 'preact';
+import { setupScratch, teardown } from '../../../test/_util/helpers';
+import { useImperativeHandle, useRef, useState } from 'preact/hooks';
+import { setupRerender } from 'preact/test-utils';
+
+/** @jsx createElement */
+
+describe('useImperativeHandle', () => {
+ /** @type {HTMLDivElement} */
+ let scratch;
+
+ /** @type {() => void} */
+ let rerender;
+
+ beforeEach(() => {
+ scratch = setupScratch();
+ rerender = setupRerender();
+ });
+
+ afterEach(() => {
+ teardown(scratch);
+ });
+
+ it('Mutates given ref', () => {
+ let ref;
+
+ function Comp() {
+ ref = useRef({});
+ useImperativeHandle(ref, () => ({ test: () => 'test' }), []);
+ return <p>Test</p>;
+ }
+
+ render(<Comp />, scratch);
+ expect(ref.current).to.have.property('test');
+ expect(ref.current.test()).to.equal('test');
+ });
+
+ it('calls createHandle after every render by default', () => {
+ let ref,
+ createHandleSpy = sinon.spy();
+
+ function Comp() {
+ ref = useRef({});
+ useImperativeHandle(ref, createHandleSpy);
+ return <p>Test</p>;
+ }
+
+ render(<Comp />, scratch);
+ expect(createHandleSpy).to.have.been.calledOnce;
+
+ render(<Comp />, scratch);
+ expect(createHandleSpy).to.have.been.calledTwice;
+
+ render(<Comp />, scratch);
+ expect(createHandleSpy).to.have.been.calledThrice;
+ });
+
+ it('calls createHandle only on mount if an empty array is passed', () => {
+ let ref,
+ createHandleSpy = sinon.spy();
+
+ function Comp() {
+ ref = useRef({});
+ useImperativeHandle(ref, createHandleSpy, []);
+ return <p>Test</p>;
+ }
+
+ render(<Comp />, scratch);
+ expect(createHandleSpy).to.have.been.calledOnce;
+
+ render(<Comp />, scratch);
+ expect(createHandleSpy).to.have.been.calledOnce;
+ });
+
+ it('Updates given ref when args change', () => {
+ let ref,
+ createHandleSpy = sinon.spy();
+
+ function Comp({ a }) {
+ ref = useRef({});
+ useImperativeHandle(
+ ref,
+ () => {
+ createHandleSpy();
+ return { test: () => 'test' + a };
+ },
+ [a]
+ );
+ return <p>Test</p>;
+ }
+
+ render(<Comp a={0} />, scratch);
+ expect(createHandleSpy).to.have.been.calledOnce;
+ expect(ref.current).to.have.property('test');
+ expect(ref.current.test()).to.equal('test0');
+
+ render(<Comp a={1} />, scratch);
+ expect(createHandleSpy).to.have.been.calledTwice;
+ expect(ref.current).to.have.property('test');
+ expect(ref.current.test()).to.equal('test1');
+
+ render(<Comp a={0} />, scratch);
+ expect(createHandleSpy).to.have.been.calledThrice;
+ expect(ref.current).to.have.property('test');
+ expect(ref.current.test()).to.equal('test0');
+ });
+
+ it('Updates given ref when passed-in ref changes', () => {
+ let ref1, ref2;
+
+ /** @type {(arg: any) => void} */
+ let setRef;
+
+ /** @type {() => void} */
+ let updateState;
+
+ const createHandleSpy = sinon.spy(() => ({
+ test: () => 'test'
+ }));
+
+ function Comp() {
+ ref1 = useRef({});
+ ref2 = useRef({});
+
+ const [ref, setRefInternal] = useState(ref1);
+ setRef = setRefInternal;
+
+ let [value, setState] = useState(0);
+ updateState = () => setState((value + 1) % 2);
+
+ useImperativeHandle(ref, createHandleSpy, []);
+ return <p>Test</p>;
+ }
+
+ render(<Comp a={0} />, scratch);
+ expect(createHandleSpy).to.have.been.calledOnce;
+
+ updateState();
+ rerender();
+ expect(createHandleSpy).to.have.been.calledOnce;
+
+ setRef(ref2);
+ rerender();
+ expect(createHandleSpy).to.have.been.calledTwice;
+
+ updateState();
+ rerender();
+ expect(createHandleSpy).to.have.been.calledTwice;
+
+ setRef(ref1);
+ rerender();
+ expect(createHandleSpy).to.have.been.calledThrice;
+ });
+
+ it('should not update ref when args have not changed', () => {
+ let ref,
+ createHandleSpy = sinon.spy(() => ({ test: () => 'test' }));
+
+ function Comp() {
+ ref = useRef({});
+ useImperativeHandle(ref, createHandleSpy, [1]);
+ return <p>Test</p>;
+ }
+
+ render(<Comp />, scratch);
+ expect(createHandleSpy).to.have.been.calledOnce;
+ expect(ref.current.test()).to.equal('test');
+
+ render(<Comp />, scratch);
+ expect(createHandleSpy).to.have.been.calledOnce;
+ expect(ref.current.test()).to.equal('test');
+ });
+
+ it('should not throw with nullish ref', () => {
+ function Comp() {
+ useImperativeHandle(null, () => ({ test: () => 'test' }), [1]);
+ return <p>Test</p>;
+ }
+
+ expect(() => render(<Comp />, scratch)).to.not.throw();
+ });
+});
diff --git a/preact/hooks/test/browser/useLayoutEffect.test.js b/preact/hooks/test/browser/useLayoutEffect.test.js
new file mode 100644
index 0000000..72ab949
--- /dev/null
+++ b/preact/hooks/test/browser/useLayoutEffect.test.js
@@ -0,0 +1,326 @@
+import { act } from 'preact/test-utils';
+import { createElement, render, Fragment, Component } from 'preact';
+import {
+ setupScratch,
+ teardown,
+ serializeHtml
+} from '../../../test/_util/helpers';
+import { useEffectAssertions } from './useEffectAssertions.test';
+import { useLayoutEffect, useRef, useState } from 'preact/hooks';
+
+/** @jsx createElement */
+
+describe('useLayoutEffect', () => {
+ /** @type {HTMLDivElement} */
+ let scratch;
+
+ beforeEach(() => {
+ scratch = setupScratch();
+ });
+
+ afterEach(() => {
+ teardown(scratch);
+ });
+
+ // Layout effects fire synchronously
+ const scheduleEffectAssert = assertFn =>
+ new Promise(resolve => {
+ assertFn();
+ resolve();
+ });
+
+ useEffectAssertions(useLayoutEffect, scheduleEffectAssert);
+
+ it('calls the effect immediately after render', () => {
+ const cleanupFunction = sinon.spy();
+ const callback = sinon.spy(() => cleanupFunction);
+
+ function Comp() {
+ useLayoutEffect(callback);
+ return null;
+ }
+
+ render(<Comp />, scratch);
+ render(<Comp />, scratch);
+
+ expect(cleanupFunction).to.be.calledOnce;
+ expect(callback).to.be.calledTwice;
+
+ render(<Comp />, scratch);
+
+ expect(cleanupFunction).to.be.calledTwice;
+ expect(callback).to.be.calledThrice;
+ });
+
+ it('works on a nested component', () => {
+ const callback = sinon.spy();
+
+ function Parent() {
+ return (
+ <div>
+ <Child />
+ </div>
+ );
+ }
+
+ function Child() {
+ useLayoutEffect(callback);
+ return null;
+ }
+
+ render(<Parent />, scratch);
+
+ expect(callback).to.be.calledOnce;
+ });
+
+ it('should execute multiple layout effects in same component in the right order', () => {
+ let executionOrder = [];
+ const App = ({ i }) => {
+ executionOrder = [];
+ useLayoutEffect(() => {
+ executionOrder.push('action1');
+ return () => executionOrder.push('cleanup1');
+ }, [i]);
+ useLayoutEffect(() => {
+ executionOrder.push('action2');
+ return () => executionOrder.push('cleanup2');
+ }, [i]);
+ return <p>Test</p>;
+ };
+ render(<App i={0} />, scratch);
+ render(<App i={2} />, scratch);
+ expect(executionOrder).to.deep.equal([
+ 'cleanup1',
+ 'cleanup2',
+ 'action1',
+ 'action2'
+ ]);
+ });
+
+ it('should correctly display DOM', () => {
+ function AutoResizeTextareaLayoutEffect(props) {
+ const ref = useRef(null);
+ useLayoutEffect(() => {
+ // IE & Edge put textarea's value as child of textarea when reading innerHTML so use
+ // cross browser serialize helper
+ const actualHtml = serializeHtml(scratch);
+ const expectedHTML = `<div class="${props.value}"><p>${props.value}</p><textarea></textarea></div>`;
+ expect(actualHtml).to.equal(expectedHTML);
+ expect(document.body.contains(ref.current)).to.equal(true);
+ });
+ return (
+ <Fragment>
+ <p>{props.value}</p>
+ <textarea ref={ref} value={props.value} onChange={props.onChange} />
+ </Fragment>
+ );
+ }
+
+ function App(props) {
+ return (
+ <div class={props.value}>
+ <AutoResizeTextareaLayoutEffect {...props} />
+ </div>
+ );
+ }
+
+ render(<App value="hi" />, scratch);
+ render(<App value="hii" />, scratch);
+ });
+
+ it('should invoke layout effects after subtree is fully connected', () => {
+ let ref;
+ let layoutEffect = sinon.spy(() => {
+ const isConnected = document.body.contains(ref.current);
+ expect(isConnected).to.equal(true, 'isConnected');
+ });
+
+ function Inner() {
+ ref = useRef(null);
+ useLayoutEffect(layoutEffect);
+ return (
+ <Fragment>
+ <textarea ref={ref} />
+ <span>hello</span>;
+ </Fragment>
+ );
+ }
+
+ function Outer() {
+ return (
+ <div>
+ <Inner />
+ </div>
+ );
+ }
+
+ render(<Outer />, scratch);
+ expect(layoutEffect).to.have.been.calledOnce;
+ });
+
+ // TODO: Make this test pass to resolve issue #1886
+ it.skip('should call effects correctly when unmounting', () => {
+ let onClick, calledFoo, calledBar, calledFooCleanup, calledBarCleanup;
+
+ const Foo = () => {
+ useLayoutEffect(() => {
+ if (!calledFoo) calledFoo = scratch.innerHTML;
+ return () => {
+ if (!calledFooCleanup) calledFooCleanup = scratch.innerHTML;
+ };
+ }, []);
+
+ return (
+ <div>
+ <p>Foo</p>
+ </div>
+ );
+ };
+
+ const Bar = () => {
+ useLayoutEffect(() => {
+ if (!calledBar) calledBar = scratch.innerHTML;
+ return () => {
+ if (!calledBarCleanup) calledBarCleanup = scratch.innerHTML;
+ };
+ }, []);
+
+ return (
+ <div>
+ <p>Bar</p>
+ </div>
+ );
+ };
+
+ function App() {
+ const [current, setCurrent] = useState('/foo');
+
+ onClick = () => setCurrent(current === '/foo' ? '/bar' : '/foo');
+
+ return (
+ <Fragment>
+ <button onClick={onClick}>next</button>
+
+ {current === '/foo' && <Foo />}
+ {current === '/bar' && <Bar />}
+ </Fragment>
+ );
+ }
+
+ render(<App />, scratch);
+ expect(calledFoo).to.equal(
+ '<button>next</button><div><p>Foo</p></div>',
+ 'calledFoo'
+ );
+
+ act(() => onClick());
+ expect(calledFooCleanup).to.equal(
+ '<button>next</button><div><p>Bar</p></div>',
+ 'calledFooCleanup'
+ );
+ expect(calledBar).to.equal(
+ '<button>next</button><div><p>Bar</p></div>',
+ 'calledBar'
+ );
+
+ act(() => onClick());
+ expect(calledBarCleanup).to.equal(
+ '<button>next</button><div><p>Foo</p></div>',
+ 'calledBarCleanup'
+ );
+ });
+
+ it('should throw an error upwards', () => {
+ const spy = sinon.spy();
+ let errored = false;
+
+ const Page1 = () => {
+ const [state, setState] = useState('loading');
+ useLayoutEffect(() => {
+ setState('loaded');
+ }, []);
+ return <p>{state}</p>;
+ };
+
+ const Page2 = () => {
+ useLayoutEffect(() => {
+ throw new Error('err');
+ }, []);
+ return <p>invisible</p>;
+ };
+
+ class App extends Component {
+ componentDidCatch(err) {
+ spy();
+ errored = err;
+ this.forceUpdate();
+ }
+
+ render(props, state) {
+ if (errored) {
+ return <p>Error</p>;
+ }
+
+ return <Fragment>{props.page === 1 ? <Page1 /> : <Page2 />}</Fragment>;
+ }
+ }
+
+ act(() => render(<App page={1} />, scratch));
+ expect(spy).to.not.be.called;
+ expect(scratch.innerHTML).to.equal('<p>loaded</p>');
+
+ act(() => render(<App page={2} />, scratch));
+ expect(spy).to.be.calledOnce;
+ expect(scratch.innerHTML).to.equal('<p>Error</p>');
+ errored = false;
+
+ act(() => render(<App page={1} />, scratch));
+ expect(spy).to.be.calledOnce;
+ expect(scratch.innerHTML).to.equal('<p>loaded</p>');
+ });
+
+ it('should throw an error upwards from return', () => {
+ const spy = sinon.spy();
+ let errored = false;
+
+ const Page1 = () => {
+ const [state, setState] = useState('loading');
+ useLayoutEffect(() => {
+ setState('loaded');
+ }, []);
+ return <p>{state}</p>;
+ };
+
+ const Page2 = () => {
+ useLayoutEffect(() => {
+ return () => {
+ throw new Error('err');
+ };
+ }, []);
+ return <p>Load</p>;
+ };
+
+ class App extends Component {
+ componentDidCatch(err) {
+ spy();
+ errored = err;
+ this.forceUpdate();
+ }
+
+ render(props, state) {
+ if (errored) {
+ return <p>Error</p>;
+ }
+
+ return <Fragment>{props.page === 1 ? <Page1 /> : <Page2 />}</Fragment>;
+ }
+ }
+
+ act(() => render(<App page={2} />, scratch));
+ expect(scratch.innerHTML).to.equal('<p>Load</p>');
+
+ act(() => render(<App page={1} />, scratch));
+ expect(spy).to.be.calledOnce;
+ expect(scratch.innerHTML).to.equal('<p>Error</p>');
+ });
+});
diff --git a/preact/hooks/test/browser/useMemo.test.js b/preact/hooks/test/browser/useMemo.test.js
new file mode 100644
index 0000000..68fb72e
--- /dev/null
+++ b/preact/hooks/test/browser/useMemo.test.js
@@ -0,0 +1,125 @@
+import { createElement, render } from 'preact';
+import { setupScratch, teardown } from '../../../test/_util/helpers';
+import { useMemo } from 'preact/hooks';
+
+/** @jsx createElement */
+
+describe('useMemo', () => {
+ /** @type {HTMLDivElement} */
+ let scratch;
+
+ beforeEach(() => {
+ scratch = setupScratch();
+ });
+
+ afterEach(() => {
+ teardown(scratch);
+ });
+
+ it('only recomputes the result when inputs change', () => {
+ let memoFunction = sinon.spy((a, b) => a + b);
+ const results = [];
+
+ function Comp({ a, b }) {
+ const result = useMemo(() => memoFunction(a, b), [a, b]);
+ results.push(result);
+ return null;
+ }
+
+ render(<Comp a={1} b={1} />, scratch);
+ render(<Comp a={1} b={1} />, scratch);
+
+ expect(results).to.deep.equal([2, 2]);
+ expect(memoFunction).to.have.been.calledOnce;
+
+ render(<Comp a={1} b={2} />, scratch);
+ render(<Comp a={1} b={2} />, scratch);
+
+ expect(results).to.deep.equal([2, 2, 3, 3]);
+ expect(memoFunction).to.have.been.calledTwice;
+ });
+
+ it('should rerun when deps length changes', () => {
+ let memoFunction = sinon.spy(() => 1 + 2);
+
+ function Comp({ all }) {
+ const deps = [1, all && 2].filter(Boolean);
+ const result = useMemo(() => memoFunction(), deps);
+ return result;
+ }
+
+ render(<Comp all />, scratch);
+ expect(memoFunction).to.have.been.calledOnce;
+ render(<Comp all={false} />, scratch);
+ expect(memoFunction).to.have.been.calledTwice;
+ });
+
+ it('should rerun when first run threw an error', () => {
+ let hasThrown = false;
+ let memoFunction = sinon.spy(() => {
+ if (!hasThrown) {
+ hasThrown = true;
+ throw new Error('test');
+ } else {
+ return 3;
+ }
+ });
+
+ function Comp() {
+ const result = useMemo(() => memoFunction(), []);
+ return result;
+ }
+
+ expect(() => render(<Comp />, scratch)).to.throw('test');
+ expect(memoFunction).to.have.been.calledOnce;
+ expect(() => render(<Comp />, scratch)).not.to.throw();
+ expect(memoFunction).to.have.been.calledTwice;
+ });
+
+ it('short circuits diffing for memoized components', () => {
+ let spy = sinon.spy();
+ let spy2 = sinon.spy();
+ const X = ({ count }) => {
+ spy();
+ return <span>{count}</span>;
+ };
+
+ const Y = ({ count }) => {
+ spy2();
+ return <p>{count}</p>;
+ };
+
+ const App = ({ x }) => {
+ const y = useMemo(() => <Y count={x} />, [x]);
+ return (
+ <div>
+ <X count={x} />
+ {y}
+ </div>
+ );
+ };
+
+ render(<App x={0} />, scratch);
+ expect(spy).to.be.calledOnce;
+ expect(spy2).to.be.calledOnce;
+ expect(scratch.innerHTML).to.equal('<div><span>0</span><p>0</p></div>');
+
+ render(<App x={0} />, scratch);
+ expect(spy).to.be.calledTwice;
+ expect(spy2).to.be.calledOnce;
+ expect(scratch.innerHTML).to.equal('<div><span>0</span><p>0</p></div>');
+
+ render(<App x={1} />, scratch);
+ expect(spy).to.be.calledThrice;
+ expect(spy2).to.be.calledTwice;
+ expect(scratch.innerHTML).to.equal('<div><span>1</span><p>1</p></div>');
+
+ render(<App x={1} />, scratch);
+ expect(spy2).to.be.calledTwice;
+ expect(scratch.innerHTML).to.equal('<div><span>1</span><p>1</p></div>');
+
+ render(<App x={2} />, scratch);
+ expect(spy2).to.be.calledThrice;
+ expect(scratch.innerHTML).to.equal('<div><span>2</span><p>2</p></div>');
+ });
+});
diff --git a/preact/hooks/test/browser/useReducer.test.js b/preact/hooks/test/browser/useReducer.test.js
new file mode 100644
index 0000000..4b9d393
--- /dev/null
+++ b/preact/hooks/test/browser/useReducer.test.js
@@ -0,0 +1,214 @@
+import { setupRerender, act } from 'preact/test-utils';
+import { createElement, render, createContext } from 'preact';
+import { setupScratch, teardown } from '../../../test/_util/helpers';
+import { useReducer, useEffect, useContext } from 'preact/hooks';
+
+/** @jsx createElement */
+
+describe('useReducer', () => {
+ /** @type {HTMLDivElement} */
+ let scratch;
+
+ /** @type {() => void} */
+ let rerender;
+
+ beforeEach(() => {
+ scratch = setupScratch();
+ rerender = setupRerender();
+ });
+
+ afterEach(() => {
+ teardown(scratch);
+ });
+
+ it('rerenders when dispatching an action', () => {
+ const states = [];
+ let _dispatch;
+
+ const initState = { count: 0 };
+
+ function reducer(state, action) {
+ switch (action.type) {
+ case 'increment':
+ return { count: state.count + action.by };
+ }
+ }
+
+ function Comp() {
+ const [state, dispatch] = useReducer(reducer, initState);
+ _dispatch = dispatch;
+ states.push(state);
+ return null;
+ }
+
+ render(<Comp />, scratch);
+
+ _dispatch({ type: 'increment', by: 10 });
+ rerender();
+
+ expect(states).to.deep.equal([{ count: 0 }, { count: 10 }]);
+ });
+
+ it('can be dispatched by another component', () => {
+ const initState = { count: 0 };
+
+ function reducer(state, action) {
+ switch (action.type) {
+ case 'increment':
+ return { count: state.count + action.by };
+ }
+ }
+
+ function ReducerComponent() {
+ const [state, dispatch] = useReducer(reducer, initState);
+ return (
+ <div>
+ <p>Count: {state.count}</p>
+ <DispatchComponent dispatch={dispatch} />
+ </div>
+ );
+ }
+
+ function DispatchComponent(props) {
+ return (
+ <button onClick={() => props.dispatch({ type: 'increment', by: 10 })}>
+ Increment
+ </button>
+ );
+ }
+
+ render(<ReducerComponent />, scratch);
+ expect(scratch.textContent).to.include('Count: 0');
+
+ const button = scratch.querySelector('button');
+ button.click();
+
+ rerender();
+ expect(scratch.textContent).to.include('Count: 10');
+ });
+
+ it('can lazily initialize its state with an action', () => {
+ const states = [];
+ let _dispatch;
+
+ function init(initialCount) {
+ return { count: initialCount };
+ }
+
+ function reducer(state, action) {
+ switch (action.type) {
+ case 'increment':
+ return { count: state.count + action.by };
+ }
+ }
+
+ function Comp({ initCount }) {
+ const [state, dispatch] = useReducer(reducer, initCount, init);
+ _dispatch = dispatch;
+ states.push(state);
+ return null;
+ }
+
+ render(<Comp initCount={10} />, scratch);
+
+ _dispatch({ type: 'increment', by: 10 });
+ rerender();
+
+ expect(states).to.deep.equal([{ count: 10 }, { count: 20 }]);
+ });
+
+ it('provides a stable reference for dispatch', () => {
+ const dispatches = [];
+ let _dispatch;
+
+ const initState = { count: 0 };
+
+ function reducer(state, action) {
+ switch (action.type) {
+ case 'increment':
+ return { count: state.count + action.by };
+ }
+ }
+
+ function Comp() {
+ const [, dispatch] = useReducer(reducer, initState);
+ _dispatch = dispatch;
+ dispatches.push(dispatch);
+ return null;
+ }
+
+ render(<Comp />, scratch);
+
+ _dispatch({ type: 'increment', by: 10 });
+ rerender();
+
+ expect(dispatches[0]).to.equal(dispatches[1]);
+ });
+
+ it('uses latest reducer', () => {
+ const states = [];
+ let _dispatch;
+
+ const initState = { count: 0 };
+
+ function Comp({ increment }) {
+ const [state, dispatch] = useReducer(function(state, action) {
+ switch (action.type) {
+ case 'increment':
+ return { count: state.count + increment };
+ }
+ }, initState);
+ _dispatch = dispatch;
+ states.push(state);
+ return null;
+ }
+
+ render(<Comp increment={10} />, scratch);
+
+ render(<Comp increment={20} />, scratch);
+
+ _dispatch({ type: 'increment' });
+ rerender();
+
+ expect(states).to.deep.equal([{ count: 0 }, { count: 0 }, { count: 20 }]);
+ });
+
+ // Relates to #2549
+ it('should not mutate the hookState', () => {
+ const reducer = (state, action) => ({
+ ...state,
+ innerMessage: action.payload
+ });
+
+ const ContextMessage = ({ context }) => {
+ const [{ innerMessage }, dispatch] = useContext(context);
+ useEffect(() => {
+ dispatch({ payload: 'message' });
+ }, []);
+
+ return innerMessage && <p>{innerMessage}</p>;
+ };
+
+ const Wrapper = ({ children }) => <div>{children}</div>;
+
+ const badContextDefault = {};
+ const BadContext = createContext({});
+
+ const Abstraction = ({ reducer, defaultState, children }) => (
+ <BadContext.Provider value={useReducer(reducer, defaultState)}>
+ <Wrapper>{children}</Wrapper>
+ </BadContext.Provider>
+ );
+
+ const App = () => (
+ <Abstraction reducer={reducer} defaultState={badContextDefault}>
+ <ContextMessage context={BadContext} />
+ </Abstraction>
+ );
+
+ act(() => {
+ render(<App />, scratch);
+ });
+ expect(scratch.innerHTML).to.equal('<div><p>message</p></div>');
+ });
+});
diff --git a/preact/hooks/test/browser/useRef.test.js b/preact/hooks/test/browser/useRef.test.js
new file mode 100644
index 0000000..7d7a657
--- /dev/null
+++ b/preact/hooks/test/browser/useRef.test.js
@@ -0,0 +1,50 @@
+import { createElement, render } from 'preact';
+import { setupScratch, teardown } from '../../../test/_util/helpers';
+import { useRef } from 'preact/hooks';
+
+/** @jsx createElement */
+
+describe('useRef', () => {
+ /** @type {HTMLDivElement} */
+ let scratch;
+
+ beforeEach(() => {
+ scratch = setupScratch();
+ });
+
+ afterEach(() => {
+ teardown(scratch);
+ });
+
+ it('provides a stable reference', () => {
+ const values = [];
+
+ function Comp() {
+ const ref = useRef(1);
+ values.push(ref.current);
+ ref.current = 2;
+ return null;
+ }
+
+ render(<Comp />, scratch);
+ render(<Comp />, scratch);
+
+ expect(values).to.deep.equal([1, 2]);
+ });
+
+ it('defaults to undefined', () => {
+ const values = [];
+
+ function Comp() {
+ const ref = useRef();
+ values.push(ref.current);
+ ref.current = 2;
+ return null;
+ }
+
+ render(<Comp />, scratch);
+ render(<Comp />, scratch);
+
+ expect(values).to.deep.equal([undefined, 2]);
+ });
+});
diff --git a/preact/hooks/test/browser/useState.test.js b/preact/hooks/test/browser/useState.test.js
new file mode 100644
index 0000000..c65a21b
--- /dev/null
+++ b/preact/hooks/test/browser/useState.test.js
@@ -0,0 +1,214 @@
+import { setupRerender } from 'preact/test-utils';
+import { createElement, render } from 'preact';
+import { setupScratch, teardown } from '../../../test/_util/helpers';
+import { useState } from 'preact/hooks';
+
+/** @jsx createElement */
+
+describe('useState', () => {
+ /** @type {HTMLDivElement} */
+ let scratch;
+
+ /** @type {() => void} */
+ let rerender;
+
+ beforeEach(() => {
+ scratch = setupScratch();
+ rerender = setupRerender();
+ });
+
+ afterEach(() => {
+ teardown(scratch);
+ });
+
+ it('serves the same state across render calls', () => {
+ const stateHistory = [];
+
+ function Comp() {
+ const [state] = useState({ a: 1 });
+ stateHistory.push(state);
+ return null;
+ }
+
+ render(<Comp />, scratch);
+ render(<Comp />, scratch);
+
+ expect(stateHistory).to.deep.equal([{ a: 1 }, { a: 1 }]);
+ expect(stateHistory[0]).to.equal(stateHistory[1]);
+ });
+
+ it('can initialize the state via a function', () => {
+ const initState = sinon.spy(() => 1);
+
+ function Comp() {
+ useState(initState);
+ return null;
+ }
+
+ render(<Comp />, scratch);
+ render(<Comp />, scratch);
+
+ expect(initState).to.be.calledOnce;
+ });
+
+ it('does not rerender on equal state', () => {
+ let lastState;
+ let doSetState;
+
+ const Comp = sinon.spy(() => {
+ const [state, setState] = useState(0);
+ lastState = state;
+ doSetState = setState;
+ return null;
+ });
+
+ render(<Comp />, scratch);
+ expect(lastState).to.equal(0);
+ expect(Comp).to.be.calledOnce;
+
+ doSetState(0);
+ rerender();
+ expect(lastState).to.equal(0);
+ expect(Comp).to.be.calledOnce;
+
+ doSetState(() => 0);
+ rerender();
+ expect(lastState).to.equal(0);
+ expect(Comp).to.be.calledOnce;
+ });
+
+ it('rerenders when setting the state', () => {
+ let lastState;
+ let doSetState;
+
+ const Comp = sinon.spy(() => {
+ const [state, setState] = useState(0);
+ lastState = state;
+ doSetState = setState;
+ return null;
+ });
+
+ render(<Comp />, scratch);
+ expect(lastState).to.equal(0);
+ expect(Comp).to.be.calledOnce;
+
+ doSetState(1);
+ rerender();
+ expect(lastState).to.equal(1);
+ expect(Comp).to.be.calledTwice;
+
+ // Updater function style
+ doSetState(current => current * 10);
+ rerender();
+ expect(lastState).to.equal(10);
+ expect(Comp).to.be.calledThrice;
+ });
+
+ it('can be set by another component', () => {
+ function StateContainer() {
+ const [count, setCount] = useState(0);
+ return (
+ <div>
+ <p>Count: {count}</p>
+ <Increment increment={() => setCount(c => c + 10)} />
+ </div>
+ );
+ }
+
+ function Increment(props) {
+ return <button onClick={props.increment}>Increment</button>;
+ }
+
+ render(<StateContainer />, scratch);
+ expect(scratch.textContent).to.include('Count: 0');
+
+ const button = scratch.querySelector('button');
+ button.click();
+
+ rerender();
+ expect(scratch.textContent).to.include('Count: 10');
+ });
+
+ it('should correctly initialize', () => {
+ let scopedThing = 'hi';
+ let arg;
+
+ function useSomething() {
+ const args = useState(setup);
+ function setup(thing = scopedThing) {
+ arg = thing;
+ return thing;
+ }
+ return args;
+ }
+
+ const App = () => {
+ const [state] = useSomething();
+ return <p>{state}</p>;
+ };
+
+ render(<App />, scratch);
+
+ expect(arg).to.equal('hi');
+ expect(scratch.innerHTML).to.equal('<p>hi</p>');
+ });
+
+ it('should correctly re-initialize when first run threw an error', () => {
+ let hasThrown = false;
+ let setup = sinon.spy(() => {
+ if (!hasThrown) {
+ hasThrown = true;
+ throw new Error('test');
+ } else {
+ return 'hi';
+ }
+ });
+
+ const App = () => {
+ const state = useState(setup)[0];
+ return <p>{state}</p>;
+ };
+
+ expect(() => render(<App />, scratch)).to.throw('test');
+ expect(setup).to.have.been.calledOnce;
+ expect(() => render(<App />, scratch)).not.to.throw();
+ expect(setup).to.have.been.calledTwice;
+ expect(scratch.innerHTML).to.equal('<p>hi</p>');
+ });
+
+ it('should handle queued useState', () => {
+ function Message({ message, onClose }) {
+ const [isVisible, setVisible] = useState(Boolean(message));
+ const [prevMessage, setPrevMessage] = useState(message);
+
+ if (message !== prevMessage) {
+ setPrevMessage(message);
+ setVisible(Boolean(message));
+ }
+
+ if (!isVisible) {
+ return null;
+ }
+ return <p onClick={onClose}>{message}</p>;
+ }
+
+ function App() {
+ const [message, setMessage] = useState('Click Here!!');
+ return (
+ <Message
+ onClose={() => {
+ setMessage('');
+ }}
+ message={message}
+ />
+ );
+ }
+
+ render(<App />, scratch);
+ expect(scratch.textContent).to.equal('Click Here!!');
+ const text = scratch.querySelector('p');
+ text.click();
+ rerender();
+ expect(scratch.innerHTML).to.equal('');
+ });
+});