diff options
Diffstat (limited to 'preact/hooks')
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(''); + }); +}); |