diff --git a/README.md b/README.md index 65e9e0ddc8..d5c8a90a44 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@

-

Fast 3kB alternative to React with the same modern API.

+

Fast 4kB alternative to React with the same modern API.

**All the power of Virtual DOM components, without the overhead:** @@ -13,7 +13,7 @@ - Extensive React compatibility via a simple [preact/compat] alias - Everything you need: JSX, VDOM, [DevTools], HMR, SSR. - Highly optimized diff algorithm and seamless hydration from Server Side Rendering -- Supports all modern browsers and IE11 +- Supports all modern browsers - Transparent asynchronous rendering with a pluggable scheduler ### 💁 More information at the [Preact Website ➞](https://preactjs.com) diff --git a/babel.config.js b/babel.config.js index e94d79edaa..10dcec920a 100644 --- a/babel.config.js +++ b/babel.config.js @@ -24,7 +24,13 @@ module.exports = function (api) { modules: noModules ? false : 'auto', exclude: ['@babel/plugin-transform-typeof-symbol'], targets: { - browsers: ['last 2 versions', 'IE >= 9'] + browsers: [ + 'chrome >= 40', + 'safari >= 9', + 'firefox >= 36', + 'edge >= 12', + 'not dead' + ] } } ] diff --git a/compat/package.json b/compat/package.json index bb80a0fa7a..41decfd34b 100644 --- a/compat/package.json +++ b/compat/package.json @@ -5,7 +5,7 @@ "private": true, "description": "A React compatibility layer for Preact", "main": "dist/compat.js", - "module": "dist/compat.module.js", + "module": "dist/compat.mjs", "umd:main": "dist/compat.umd.js", "source": "src/index.js", "types": "src/index.d.ts", @@ -19,7 +19,7 @@ "exports": { ".": { "types": "./src/index.d.ts", - "browser": "./dist/compat.module.js", + "module": "./dist/compat.mjs", "umd": "./dist/compat.umd.js", "import": "./dist/compat.mjs", "require": "./dist/compat.js" diff --git a/compat/src/forwardRef.js b/compat/src/forwardRef.js index 25791285b9..5f3ef0035b 100644 --- a/compat/src/forwardRef.js +++ b/compat/src/forwardRef.js @@ -1,26 +1,12 @@ -import { options } from 'preact'; import { assign } from './util'; -let oldDiffHook = options._diff; -options._diff = vnode => { - if (vnode.type && vnode.type._forwarded && vnode.ref) { - vnode.props.ref = vnode.ref; - vnode.ref = null; - } - if (oldDiffHook) oldDiffHook(vnode); -}; - -export const REACT_FORWARD_SYMBOL = - (typeof Symbol != 'undefined' && - Symbol.for && - Symbol.for('react.forward_ref')) || - 0xf47; +export const REACT_FORWARD_SYMBOL = Symbol.for('react.forward_ref'); /** * Pass ref down to a child. This is mainly used in libraries with HOCs that * wrap components. Using `forwardRef` there is an easy way to get a reference * of the wrapped component instead of one of the wrapper itself. - * @param {import('./index').ForwardFn} fn + * @param {import('./index').ForwardRefRenderFunction} fn * @returns {import('./internal').FunctionComponent} */ export function forwardRef(fn) { @@ -38,7 +24,7 @@ export function forwardRef(fn) { // mobx-react throws. Forwarded.render = Forwarded; - Forwarded.prototype.isReactComponent = Forwarded._forwarded = true; + Forwarded.prototype.isReactComponent = true; Forwarded.displayName = 'ForwardRef(' + (fn.displayName || fn.name) + ')'; return Forwarded; } diff --git a/compat/src/hooks.js b/compat/src/hooks.js index fbc2be592b..d0aeeb7d7c 100644 --- a/compat/src/hooks.js +++ b/compat/src/hooks.js @@ -1,5 +1,4 @@ import { useState, useLayoutEffect, useEffect } from 'preact/hooks'; -import { is } from './util'; /** * This is taken from https://github.com/facebook/react/blob/main/packages/use-sync-external-store/src/useSyncExternalStoreShimClient.js#L84 @@ -47,7 +46,7 @@ function didSnapshotChange(inst) { const prevValue = inst._value; try { const nextValue = latestGetSnapshot(); - return !is(prevValue, nextValue); + return !Object.is(prevValue, nextValue); } catch (error) { return true; } diff --git a/compat/src/index.d.ts b/compat/src/index.d.ts index 5e7a1b6141..093c7d0970 100644 --- a/compat/src/index.d.ts +++ b/compat/src/index.d.ts @@ -1,10 +1,102 @@ import * as _hooks from '../../hooks'; // Intentionally not using a relative path to take advantage of // the TS version resolution mechanism -import * as preact from 'preact'; +import * as preact1 from 'preact'; import { JSXInternal } from '../../src/jsx'; import * as _Suspense from './suspense'; -import * as _SuspenseList from './suspense-list'; + +declare namespace preact { + export interface FunctionComponent

{ + ( + props: preact1.RenderableProps

, + context?: any + ): preact1.ComponentChildren; + displayName?: string; + defaultProps?: Partial

| undefined; + } + + export interface ComponentClass

{ + new (props: P, context?: any): preact1.Component; + displayName?: string; + defaultProps?: Partial

; + contextType?: preact1.Context; + getDerivedStateFromProps?( + props: Readonly

, + state: Readonly + ): Partial | null; + getDerivedStateFromError?(error: any): Partial | null; + } + + export interface Component

{ + componentWillMount?(): void; + componentDidMount?(): void; + componentWillUnmount?(): void; + getChildContext?(): object; + componentWillReceiveProps?(nextProps: Readonly

, nextContext: any): void; + shouldComponentUpdate?( + nextProps: Readonly

, + nextState: Readonly, + nextContext: any + ): boolean; + componentWillUpdate?( + nextProps: Readonly

, + nextState: Readonly, + nextContext: any + ): void; + getSnapshotBeforeUpdate?(oldProps: Readonly

, oldState: Readonly): any; + componentDidUpdate?( + previousProps: Readonly

, + previousState: Readonly, + snapshot: any + ): void; + componentDidCatch?(error: any, errorInfo: preact1.ErrorInfo): void; + } + + export abstract class Component { + constructor(props?: P, context?: any); + + static displayName?: string; + static defaultProps?: any; + static contextType?: preact1.Context; + + // Static members cannot reference class type parameters. This is not + // supported in TypeScript. Reusing the same type arguments from `Component` + // will lead to an impossible state where one cannot satisfy the type + // constraint under no circumstances, see #1356.In general type arguments + // seem to be a bit buggy and not supported well at the time of this + // writing with TS 3.3.3333. + static getDerivedStateFromProps?( + props: Readonly, + state: Readonly + ): object | null; + static getDerivedStateFromError?(error: any): object | null; + + state: Readonly; + props: preact1.RenderableProps

; + context: any; + + // From https://github.com/DefinitelyTyped/DefinitelyTyped/blob/e836acc75a78cf0655b5dfdbe81d69fdd4d8a252/types/react/index.d.ts#L402 + // // We MUST keep setState() as a unified signature because it allows proper checking of the method return type. + // // See: https://github.com/DefinitelyTyped/DefinitelyTyped/issues/18365#issuecomment-351013257 + setState( + state: + | (( + prevState: Readonly, + props: Readonly

+ ) => Pick | Partial | null) + | (Pick | Partial | null), + callback?: () => void + ): void; + + forceUpdate(callback?: () => void): void; + + abstract render( + props?: preact1.RenderableProps

, + state?: Readonly, + context?: any + ): preact1.ComponentChildren; + } +} // export default React; export = React; @@ -17,7 +109,6 @@ declare namespace React { export import CreateHandle = _hooks.CreateHandle; export import EffectCallback = _hooks.EffectCallback; export import Inputs = _hooks.Inputs; - export import PropRef = _hooks.PropRef; export import Reducer = _hooks.Reducer; export import Dispatch = _hooks.Dispatch; export import SetStateAction = _hooks.StateUpdater; @@ -42,33 +133,32 @@ declare namespace React { ): T; // Preact Defaults - export import Context = preact.Context; - export import ContextType = preact.ContextType; - export import RefObject = preact.RefObject; + export import Context = preact1.Context; + export import ContextType = preact1.ContextType; + export import RefObject = preact1.RefObject; export import Component = preact.Component; export import FunctionComponent = preact.FunctionComponent; - export import ComponentType = preact.ComponentType; + export import ComponentType = preact1.ComponentType; export import ComponentClass = preact.ComponentClass; - export import FC = preact.FunctionComponent; - export import createContext = preact.createContext; - export import Ref = preact.Ref; - export import createRef = preact.createRef; - export import Fragment = preact.Fragment; - export import createElement = preact.createElement; - export import cloneElement = preact.cloneElement; - export import ComponentProps = preact.ComponentProps; - export import ReactNode = preact.ComponentChild; - export import ReactElement = preact.VNode; - export import Consumer = preact.Consumer; - export import ErrorInfo = preact.ErrorInfo; + export import FC = preact1.FunctionComponent; + export import createContext = preact1.createContext; + export import Ref = preact1.Ref; + export import createRef = preact1.createRef; + export import Fragment = preact1.Fragment; + export import createElement = preact1.createElement; + export import cloneElement = preact1.cloneElement; + export import ComponentProps = preact1.ComponentProps; + export import ReactNode = preact1.ComponentChild; + export import ReactElement = preact1.VNode; + export import Consumer = preact1.Consumer; + export import ErrorInfo = preact1.ErrorInfo; // Suspense export import Suspense = _Suspense.Suspense; export import lazy = _Suspense.lazy; - export import SuspenseList = _SuspenseList.SuspenseList; // Compat - export import StrictMode = preact.Fragment; + export import StrictMode = preact1.Fragment; export const version: string; export function startTransition(cb: () => void): void; @@ -77,7 +167,7 @@ declare namespace React { extends JSXInternal.HTMLAttributes {} export interface HTMLProps extends JSXInternal.AllHTMLAttributes, - preact.ClassAttributes {} + preact1.ClassAttributes {} export interface AllHTMLAttributes extends JSXInternal.AllHTMLAttributes {} export import DetailedHTMLProps = JSXInternal.DetailedHTMLProps; @@ -85,7 +175,7 @@ declare namespace React { export interface SVGProps extends JSXInternal.SVGAttributes, - preact.ClassAttributes {} + preact1.ClassAttributes {} interface SVGAttributes extends JSXInternal.SVGAttributes {} @@ -183,89 +273,81 @@ declare namespace React { export import TransitionEventHandler = JSXInternal.TransitionEventHandler; export function createPortal( - vnode: preact.ComponentChildren, - container: preact.ContainerNode - ): preact.VNode; + vnode: preact1.ComponentChildren, + container: preact1.ContainerNode + ): preact1.VNode; export function render( - vnode: preact.ComponentChild, - parent: preact.ContainerNode, + vnode: preact1.ComponentChild, + parent: preact1.ContainerNode, callback?: () => void ): Component | null; export function hydrate( - vnode: preact.ComponentChild, - parent: preact.ContainerNode, + vnode: preact1.ComponentChild, + parent: preact1.ContainerNode, callback?: () => void ): Component | null; export function unmountComponentAtNode( - container: preact.ContainerNode + container: preact1.ContainerNode ): boolean; export function createFactory( - type: preact.VNode['type'] + type: preact1.VNode['type'] ): ( props?: any, - ...children: preact.ComponentChildren[] - ) => preact.VNode; + ...children: preact1.ComponentChildren[] + ) => preact1.VNode; export function isValidElement(element: any): boolean; export function isFragment(element: any): boolean; export function isMemo(element: any): boolean; export function findDOMNode( - component: preact.Component | Element + component: preact1.Component | Element ): Element | null; export abstract class PureComponent< P = {}, S = {}, SS = any - > extends preact.Component { + > extends preact1.Component { isPureReactComponent: boolean; } - export type MemoExoticComponent> = - preact.FunctionComponent> & { + export type MemoExoticComponent> = + preact1.FunctionComponent> & { readonly type: C; }; export function memo

( - component: preact.FunctionalComponent

, + component: preact1.FunctionalComponent

, comparer?: (prev: P, next: P) => boolean - ): preact.FunctionComponent

; - export function memo>( + ): preact1.FunctionComponent

; + export function memo>( component: C, comparer?: ( - prev: preact.ComponentProps, - next: preact.ComponentProps + prev: preact1.ComponentProps, + next: preact1.ComponentProps ) => boolean ): C; - export interface RefAttributes extends preact.Attributes { - ref?: preact.Ref | undefined; - } - - /** - * @deprecated Please use `ForwardRefRenderFunction` instead. - */ - export interface ForwardFn

{ - (props: P, ref: ForwardedRef): preact.ComponentChild; - displayName?: string; + export interface RefAttributes extends preact1.Attributes { + ref?: preact1.Ref | undefined; } export interface ForwardRefRenderFunction { - (props: P, ref: ForwardedRef): preact.ComponentChild; + (props: P, ref: ForwardedRef): preact1.ComponentChild; displayName?: string; } export interface ForwardRefExoticComponent

- extends preact.FunctionComponent

{ + extends preact1.FunctionComponent

{ defaultProps?: Partial

| undefined; } export function forwardRef( fn: ForwardRefRenderFunction - ): preact.FunctionalComponent & { ref?: preact.Ref }>; + ): preact1.FunctionalComponent & { ref?: preact1.Ref }>; export type PropsWithoutRef

= Omit; @@ -312,27 +394,22 @@ declare namespace React { export function flushSync(fn: () => R): R; export function flushSync(fn: (a: A) => R, a: A): R; - export function unstable_batchedUpdates( - callback: (arg?: any) => void, - arg?: any - ): void; - export type PropsWithChildren

= P & { - children?: preact.ComponentChildren | undefined; + children?: preact1.ComponentChildren | undefined; }; export const Children: { - map( + map( children: T | T[], fn: (child: T, i: number) => R ): R[]; - forEach( + forEach( children: T | T[], fn: (child: T, i: number) => void ): void; - count: (children: preact.ComponentChildren) => number; - only: (children: preact.ComponentChildren) => preact.ComponentChild; - toArray: (children: preact.ComponentChildren) => preact.VNode<{}>[]; + count: (children: preact1.ComponentChildren) => number; + only: (children: preact1.ComponentChildren) => preact1.ComponentChild; + toArray: (children: preact1.ComponentChildren) => preact1.VNode<{}>[]; }; // scheduler diff --git a/compat/src/index.js b/compat/src/index.js index 61fd2f3625..d609365ea9 100644 --- a/compat/src/index.js +++ b/compat/src/index.js @@ -32,7 +32,6 @@ import { memo } from './memo'; import { forwardRef } from './forwardRef'; import { Children } from './Children'; import { Suspense, lazy } from './suspense'; -import { SuspenseList } from './suspense-list'; import { createPortal } from './portals'; import { hydrate, @@ -117,21 +116,12 @@ function unmountComponentAtNode(container) { function findDOMNode(component) { return ( (component && - (component.base || (component.nodeType === 1 && component))) || + ((component._vnode && component._vnode._dom) || + (component.nodeType === 1 && component))) || null ); } -/** - * Deprecated way to control batched rendering inside the reconciler, but we - * already schedule in batches inside our rendering code - * @template Arg - * @param {(arg: Arg) => void} callback function that triggers the updated - * @param {Arg} [arg] Optional argument that can be passed to the callback - */ -// eslint-disable-next-line camelcase -const unstable_batchedUpdates = (callback, arg) => callback(arg); - /** * In React, `flushSync` flushes the entire tree and forces a rerender. It's * implmented here as a no-op. @@ -180,11 +170,8 @@ export { useDeferredValue, useSyncExternalStore, useTransition, - // eslint-disable-next-line camelcase - unstable_batchedUpdates, StrictMode, Suspense, - SuspenseList, lazy, __SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED }; @@ -229,10 +216,8 @@ export default { memo, forwardRef, flushSync, - unstable_batchedUpdates, StrictMode, Suspense, - SuspenseList, lazy, __SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED }; diff --git a/compat/src/internal.d.ts b/compat/src/internal.d.ts index efc5287ca3..31795ba123 100644 --- a/compat/src/internal.d.ts +++ b/compat/src/internal.d.ts @@ -27,12 +27,11 @@ export interface Component

extends PreactComponent { export interface FunctionComponent

extends PreactFunctionComponent

{ shouldComponentUpdate?(nextProps: Readonly

): boolean; - _forwarded?: boolean; _patchedLifecycles?: true; } export interface VNode extends PreactVNode { - $$typeof?: symbol | string; + $$typeof?: symbol; preactCompatNormalized?: boolean; } diff --git a/compat/src/memo.js b/compat/src/memo.js index e743199055..925e0c9eae 100644 --- a/compat/src/memo.js +++ b/compat/src/memo.js @@ -29,6 +29,5 @@ export function memo(c, comparer) { } Memoed.displayName = 'Memo(' + (c.displayName || c.name) + ')'; Memoed.prototype.isReactComponent = true; - Memoed._forwarded = true; return Memoed; } diff --git a/compat/src/portals.js b/compat/src/portals.js index 2e126f9076..c082e58364 100644 --- a/compat/src/portals.js +++ b/compat/src/portals.js @@ -46,14 +46,9 @@ function Portal(props) { parentNode: container, childNodes: [], _children: { _mask: root._mask }, - contains: () => true, insertBefore(child, before) { this.childNodes.push(child); _this._container.insertBefore(child, before); - }, - removeChild(child) { - this.childNodes.splice(this.childNodes.indexOf(child) >>> 1, 1); - _this._container.removeChild(child); } }; } diff --git a/compat/src/render.js b/compat/src/render.js index f18cbd896b..9807c0fc0b 100644 --- a/compat/src/render.js +++ b/compat/src/render.js @@ -24,10 +24,9 @@ import { useSyncExternalStore, useTransition } from './index'; +import { assign, IS_NON_DIMENSIONAL } from './util'; -export const REACT_ELEMENT_TYPE = - (typeof Symbol != 'undefined' && Symbol.for && Symbol.for('react.element')) || - 0xeac7; +export const REACT_ELEMENT_TYPE = Symbol.for('react.element'); const CAMEL_PROPS = /^(?:accent|alignment|arabic|baseline|cap|clip(?!PathU)|color|dominant|fill|flood|font|glyph(?!R)|horiz|image(!S)|letter|lighting|marker(?!H|W|U)|overline|paint|pointer|shape|stop|strikethrough|stroke|text(?!L)|transform|underline|unicode|units|v|vector|vert|word|writing|x(?!C))[A-Z]/; @@ -36,44 +35,11 @@ const CAMEL_REPLACE = /[A-Z0-9]/g; const IS_DOM = typeof document !== 'undefined'; // Input types for which onchange should not be converted to oninput. -// type="file|checkbox|radio", plus "range" in IE11. -// (IE11 doesn't support Symbol, which we use here to turn `rad` into `ra` which matches "range") -const onChangeInputType = type => - (typeof Symbol != 'undefined' && typeof Symbol() == 'symbol' - ? /fil|che|rad/ - : /fil|che|ra/ - ).test(type); +const onChangeInputType = type => /fil|che|rad/.test(type); // Some libraries like `react-virtualized` explicitly check for this. Component.prototype.isReactComponent = {}; -// `UNSAFE_*` lifecycle hooks -// Preact only ever invokes the unprefixed methods. -// Here we provide a base "fallback" implementation that calls any defined UNSAFE_ prefixed method. -// - If a component defines its own `componentDidMount()` (including via defineProperty), use that. -// - If a component defines `UNSAFE_componentDidMount()`, `componentDidMount` is the alias getter/setter. -// - If anything assigns to an `UNSAFE_*` property, the assignment is forwarded to the unprefixed property. -// See https://github.com/preactjs/preact/issues/1941 -[ - 'componentWillMount', - 'componentWillReceiveProps', - 'componentWillUpdate' -].forEach(key => { - Object.defineProperty(Component.prototype, key, { - configurable: true, - get() { - return this['UNSAFE_' + key]; - }, - set(v) { - Object.defineProperty(this, key, { - configurable: true, - writable: true, - value: v - }); - } - }); -}); - /** * Proxy render() since React returns a Component reference. * @param {import('./internal').VNode} vnode VNode tree to render @@ -151,7 +117,17 @@ function handleDomVNode(vnode) { } let lowerCased = i.toLowerCase(); - if (i === 'defaultValue' && 'value' in props && props.value == null) { + if (i === 'style' && typeof value === 'object') { + for (let key in value) { + if (typeof value[key] === 'number' && !IS_NON_DIMENSIONAL.test(key)) { + value[key] += 'px'; + } + } + } else if ( + i === 'defaultValue' && + 'value' in props && + props.value == null + ) { // `defaultValue` is treated as a fallback `value` when a value prop is present but null/undefined. // `defaultValue` for Elements with no value prop is the same as the DOM defaultValue property. i = 'value'; @@ -245,8 +221,15 @@ options.vnode = vnode => { // only normalize props on Element nodes if (typeof vnode.type === 'string') { handleDomVNode(vnode); + } else if (typeof vnode.type === 'function' && vnode.type.defaultProps) { + let normalizedProps = assign({}, vnode.props); + for (let i in vnode.type.defaultProps) { + if (normalizedProps[i] === undefined) { + normalizedProps[i] = vnode.type.defaultProps[i]; + } + } + vnode.props = normalizedProps; } - vnode.$$typeof = REACT_ELEMENT_TYPE; if (oldVNodeHook) oldVNodeHook(vnode); diff --git a/compat/src/suspense-list.d.ts b/compat/src/suspense-list.d.ts deleted file mode 100644 index 0a3be0adc9..0000000000 --- a/compat/src/suspense-list.d.ts +++ /dev/null @@ -1,16 +0,0 @@ -// Intentionally not using a relative path to take advantage of -// the TS version resolution mechanism -import { Component, ComponentChild, ComponentChildren } from 'preact'; - -// -// SuspenseList -// ----------------------------------- - -export interface SuspenseListProps { - children?: ComponentChildren; - revealOrder?: 'forwards' | 'backwards' | 'together'; -} - -export class SuspenseList extends Component { - render(): ComponentChild; -} diff --git a/compat/src/suspense-list.js b/compat/src/suspense-list.js deleted file mode 100644 index 5e5d750a08..0000000000 --- a/compat/src/suspense-list.js +++ /dev/null @@ -1,127 +0,0 @@ -import { Component, toChildArray } from 'preact'; -import { suspended } from './suspense.js'; - -// Indexes to linked list nodes (nodes are stored as arrays to save bytes). -const SUSPENDED_COUNT = 0; -const RESOLVED_COUNT = 1; -const NEXT_NODE = 2; - -// Having custom inheritance instead of a class here saves a lot of bytes. -export function SuspenseList() { - this._next = null; - this._map = null; -} - -// Mark one of child's earlier suspensions as resolved. -// Some pending callbacks may become callable due to this -// (e.g. the last suspended descendant gets resolved when -// revealOrder === 'together'). Process those callbacks as well. -const resolve = (list, child, node) => { - if (++node[RESOLVED_COUNT] === node[SUSPENDED_COUNT]) { - // The number a child (or any of its descendants) has been suspended - // matches the number of times it's been resolved. Therefore we - // mark the child as completely resolved by deleting it from ._map. - // This is used to figure out when *all* children have been completely - // resolved when revealOrder is 'together'. - list._map.delete(child); - } - - // If revealOrder is falsy then we can do an early exit, as the - // callbacks won't get queued in the node anyway. - // If revealOrder is 'together' then also do an early exit - // if all suspended descendants have not yet been resolved. - if ( - !list.props.revealOrder || - (list.props.revealOrder[0] === 't' && list._map.size) - ) { - return; - } - - // Walk the currently suspended children in order, calling their - // stored callbacks on the way. Stop if we encounter a child that - // has not been completely resolved yet. - node = list._next; - while (node) { - while (node.length > 3) { - node.pop()(); - } - if (node[RESOLVED_COUNT] < node[SUSPENDED_COUNT]) { - break; - } - list._next = node = node[NEXT_NODE]; - } -}; - -// Things we do here to save some bytes but are not proper JS inheritance: -// - call `new Component()` as the prototype -// - do not set `Suspense.prototype.constructor` to `Suspense` -SuspenseList.prototype = new Component(); - -SuspenseList.prototype._suspended = function (child) { - const list = this; - const delegated = suspended(list._vnode); - - let node = list._map.get(child); - node[SUSPENDED_COUNT]++; - - return unsuspend => { - const wrappedUnsuspend = () => { - if (!list.props.revealOrder) { - // Special case the undefined (falsy) revealOrder, as there - // is no need to coordinate a specific order or unsuspends. - unsuspend(); - } else { - node.push(unsuspend); - resolve(list, child, node); - } - }; - if (delegated) { - delegated(wrappedUnsuspend); - } else { - wrappedUnsuspend(); - } - }; -}; - -SuspenseList.prototype.render = function (props) { - this._next = null; - this._map = new Map(); - - const children = toChildArray(props.children); - if (props.revealOrder && props.revealOrder[0] === 'b') { - // If order === 'backwards' (or, well, anything starting with a 'b') - // then flip the child list around so that the last child will be - // the first in the linked list. - children.reverse(); - } - // Build the linked list. Iterate through the children in reverse order - // so that `_next` points to the first linked list node to be resolved. - for (let i = children.length; i--; ) { - // Create a new linked list node as an array of form: - // [suspended_count, resolved_count, next_node] - // where suspended_count and resolved_count are numeric counters for - // keeping track how many times a node has been suspended and resolved. - // - // Note that suspended_count starts from 1 instead of 0, so we can block - // processing callbacks until componentDidMount has been called. In a sense - // node is suspended at least until componentDidMount gets called! - // - // Pending callbacks are added to the end of the node: - // [suspended_count, resolved_count, next_node, callback_0, callback_1, ...] - this._map.set(children[i], (this._next = [1, 0, this._next])); - } - return props.children; -}; - -SuspenseList.prototype.componentDidUpdate = - SuspenseList.prototype.componentDidMount = function () { - // Iterate through all children after mounting for two reasons: - // 1. As each node[SUSPENDED_COUNT] starts from 1, this iteration increases - // each node[RELEASED_COUNT] by 1, therefore balancing the counters. - // The nodes can now be completely consumed from the linked list. - // 2. Handle nodes that might have gotten resolved between render and - // componentDidMount. - this._map.forEach((node, child) => { - resolve(this, child, node); - }); - }; diff --git a/compat/src/suspense.js b/compat/src/suspense.js index 3bb60c9eb0..1e5bd55fc6 100644 --- a/compat/src/suspense.js +++ b/compat/src/suspense.js @@ -125,8 +125,6 @@ Suspense.prototype._childDidSuspend = function (promise, suspendingVNode) { } c._suspenders.push(suspendingComponent); - const resolve = suspended(c._vnode); - let resolved = false; const onResolved = () => { if (resolved) return; @@ -134,11 +132,7 @@ Suspense.prototype._childDidSuspend = function (promise, suspendingVNode) { resolved = true; suspendingComponent._onResolve = null; - if (resolve) { - resolve(onSuspensionComplete); - } else { - onSuspensionComplete(); - } + onSuspensionComplete(); }; suspendingComponent._onResolve = onResolved; @@ -218,29 +212,6 @@ Suspense.prototype.render = function (props, state) { ]; }; -/** - * Checks and calls the parent component's _suspended method, passing in the - * suspended vnode. This is a way for a parent (e.g. SuspenseList) to get notified - * that one of its children/descendants suspended. - * - * The parent MAY return a callback. The callback will get called when the - * suspension resolves, notifying the parent of the fact. - * Moreover, the callback gets function `unsuspend` as a parameter. The resolved - * child descendant will not actually get unsuspended until `unsuspend` gets called. - * This is a way for the parent to delay unsuspending. - * - * If the parent does not return a callback then the resolved vnode - * gets unsuspended immediately when it resolves. - * - * @param {import('./internal').VNode} vnode - * @returns {((unsuspend: () => void) => void)?} - */ -export function suspended(vnode) { - /** @type {import('./internal').Component} */ - let component = vnode._parent._component; - return component && component._suspended && component._suspended(vnode); -} - export function lazy(loader) { let prom; let component; @@ -271,6 +242,5 @@ export function lazy(loader) { } Lazy.displayName = 'Lazy'; - Lazy._forwarded = true; return Lazy; } diff --git a/compat/src/util.js b/compat/src/util.js index 8ec376942b..43c66639a0 100644 --- a/compat/src/util.js +++ b/compat/src/util.js @@ -1,14 +1,4 @@ -/** - * Assign properties from `props` to `obj` - * @template O, P The obj and props types - * @param {O} obj The object to copy properties to - * @param {P} props The object to copy properties from - * @returns {O & P} - */ -export function assign(obj, props) { - for (let i in props) obj[i] = props[i]; - return /** @type {O & P} */ (obj); -} +export const assign = Object.assign; /** * Check if two objects have a different shape @@ -22,12 +12,5 @@ export function shallowDiffers(a, b) { return false; } -/** - * Check if two values are the same value - * @param {*} x - * @param {*} y - * @returns {boolean} - */ -export function is(x, y) { - return (x === y && (x !== 0 || 1 / x === 1 / y)) || (x !== x && y !== y); -} +export const IS_NON_DIMENSIONAL = + /^(-|f[lo].*[^se]$|g.{5,}[^ps]$|z|o[pr]|(W.{5})?[lL]i.*(t|mp)$|an|(bo|s).{4}Im|sca|m.{6}[ds]|ta|c.*[st]$|wido|ini)/; diff --git a/compat/test/browser/component.test.js b/compat/test/browser/component.test.js index de78a1fd20..9aecf754bc 100644 --- a/compat/test/browser/component.test.js +++ b/compat/test/browser/component.test.js @@ -75,169 +75,4 @@ describe('components', () => { children: 'second' }); }); - - describe('UNSAFE_* lifecycle methods', () => { - it('should support UNSAFE_componentWillMount', () => { - let spy = sinon.spy(); - - class Foo extends React.Component { - // eslint-disable-next-line camelcase - UNSAFE_componentWillMount() { - spy(); - } - - render() { - return

foo

; - } - } - - React.render(, scratch); - - expect(spy).to.be.calledOnce; - }); - - it('should support UNSAFE_componentWillMount #2', () => { - let spy = sinon.spy(); - - class Foo extends React.Component { - render() { - return

foo

; - } - } - - Object.defineProperty(Foo.prototype, 'UNSAFE_componentWillMount', { - value: spy - }); - - React.render(, scratch); - expect(spy).to.be.calledOnce; - }); - - it('should support UNSAFE_componentWillReceiveProps', () => { - let spy = sinon.spy(); - - class Foo extends React.Component { - // eslint-disable-next-line camelcase - UNSAFE_componentWillReceiveProps() { - spy(); - } - - render() { - return

foo

; - } - } - - React.render(, scratch); - // Trigger an update - React.render(, scratch); - expect(spy).to.be.calledOnce; - }); - - it('should support UNSAFE_componentWillReceiveProps #2', () => { - let spy = sinon.spy(); - - class Foo extends React.Component { - render() { - return

foo

; - } - } - - Object.defineProperty(Foo.prototype, 'UNSAFE_componentWillReceiveProps', { - value: spy - }); - - React.render(, scratch); - // Trigger an update - React.render(, scratch); - expect(spy).to.be.calledOnce; - }); - - it('should support UNSAFE_componentWillUpdate', () => { - let spy = sinon.spy(); - - class Foo extends React.Component { - // eslint-disable-next-line camelcase - UNSAFE_componentWillUpdate() { - spy(); - } - - render() { - return

foo

; - } - } - - React.render(, scratch); - // Trigger an update - React.render(, scratch); - expect(spy).to.be.calledOnce; - }); - - it('should support UNSAFE_componentWillUpdate #2', () => { - let spy = sinon.spy(); - - class Foo extends React.Component { - render() { - return

foo

; - } - } - - Object.defineProperty(Foo.prototype, 'UNSAFE_componentWillUpdate', { - value: spy - }); - - React.render(, scratch); - // Trigger an update - React.render(, scratch); - expect(spy).to.be.calledOnce; - }); - - it('should alias UNSAFE_* method to non-prefixed variant', () => { - let inst; - class Foo extends React.Component { - // eslint-disable-next-line camelcase - UNSAFE_componentWillMount() {} - // eslint-disable-next-line camelcase - UNSAFE_componentWillReceiveProps() {} - // eslint-disable-next-line camelcase - UNSAFE_componentWillUpdate() {} - render() { - inst = this; - return
foo
; - } - } - - React.render(, scratch); - - expect(inst.UNSAFE_componentWillMount).to.equal(inst.componentWillMount); - expect(inst.UNSAFE_componentWillReceiveProps).to.equal( - inst.UNSAFE_componentWillReceiveProps - ); - expect(inst.UNSAFE_componentWillUpdate).to.equal( - inst.UNSAFE_componentWillUpdate - ); - }); - - it('should call UNSAFE_* methods through Suspense with wrapper component #2525', () => { - class Page extends React.Component { - UNSAFE_componentWillMount() {} - render() { - return

Example

; - } - } - - const Wrapper = () => ; - - sinon.spy(Page.prototype, 'UNSAFE_componentWillMount'); - - React.render( - fallback}> - - , - scratch - ); - - expect(scratch.innerHTML).to.equal('

Example

'); - expect(Page.prototype.UNSAFE_componentWillMount).to.have.been.called; - }); - }); }); diff --git a/compat/test/browser/events.test.js b/compat/test/browser/events.test.js index d59dbef8ed..589dd46f59 100644 --- a/compat/test/browser/events.test.js +++ b/compat/test/browser/events.test.js @@ -30,10 +30,6 @@ describe('preact/compat events', () => { it('should patch events', () => { let spy = sinon.spy(event => { - // Calling ev.preventDefault() outside of an event handler - // does nothing in IE11. So we move these asserts inside - // the event handler. We ensure that it's called once - // in another assertion expect(event.isDefaultPrevented()).to.be.false; event.preventDefault(); expect(event.isDefaultPrevented()).to.be.true; @@ -89,49 +85,16 @@ describe('preact/compat events', () => { expect(vnode.props).to.not.haveOwnProperty('oninputCapture'); }); - it('should normalize onChange for range, except in IE11', () => { - // NOTE: we don't normalize `onchange` for range inputs in IE11. - const eventType = /Trident\//.test(navigator.userAgent) - ? 'change' - : 'input'; - + it('should normalize onChange for range', () => { render( null} />, scratch); expect(proto.addEventListener).to.have.been.calledOnce; expect(proto.addEventListener).to.have.been.calledWithExactly( - eventType, + 'input', sinon.match.func, false ); }); - it('should normalize onChange for range, except in IE11, including when IE11 has Symbol polyfill', () => { - // NOTE: we don't normalize `onchange` for range inputs in IE11. - // This test mimics a specific scenario when a Symbol polyfill may - // be present, in which case onChange should still not be normalized - - const isIE11 = /Trident\//.test(navigator.userAgent); - const eventType = isIE11 ? 'change' : 'input'; - - if (isIE11) { - window.Symbol = () => 'mockSymbolPolyfill'; - } - sinon.spy(window, 'Symbol'); - - render( null} />, scratch); - expect(window.Symbol).to.have.been.calledOnce; - expect(proto.addEventListener).to.have.been.calledOnce; - expect(proto.addEventListener).to.have.been.calledWithExactly( - eventType, - sinon.match.func, - false - ); - - window.Symbol.restore(); - if (isIE11) { - window.Symbol = undefined; - } - }); - it('should support onAnimationEnd', () => { const func = sinon.spy(() => {}); render(
, scratch); diff --git a/compat/test/browser/exports.test.js b/compat/test/browser/exports.test.js index cced23da45..d96e4bed4c 100644 --- a/compat/test/browser/exports.test.js +++ b/compat/test/browser/exports.test.js @@ -58,7 +58,6 @@ describe('compat exports', () => { expect(Compat.Children.toArray).to.exist.and.be.a('function'); expect(Compat.Children.only).to.exist.and.be.a('function'); expect(Compat.unmountComponentAtNode).to.exist.and.be.a('function'); - expect(Compat.unstable_batchedUpdates).to.exist.and.be.a('function'); expect(Compat.version).to.exist.and.be.a('string'); expect(Compat.startTransition).to.be.a('function'); }); @@ -99,7 +98,6 @@ describe('compat exports', () => { expect(Named.Children.toArray).to.exist.and.be.a('function'); expect(Named.Children.only).to.exist.and.be.a('function'); expect(Named.unmountComponentAtNode).to.exist.and.be.a('function'); - expect(Named.unstable_batchedUpdates).to.exist.and.be.a('function'); expect(Named.version).to.exist.and.be.a('string'); }); }); diff --git a/compat/test/browser/forwardRef.test.js b/compat/test/browser/forwardRef.test.js index f69d5ae014..4e7968dd6d 100644 --- a/compat/test/browser/forwardRef.test.js +++ b/compat/test/browser/forwardRef.test.js @@ -35,7 +35,7 @@ describe('forwardRef', () => { expect(App.prototype.isReactComponent).to.equal(true); }); - it('should have $$typeof property', () => { + it.skip('should have $$typeof property', () => { let App = forwardRef((_, ref) =>
foo
); const expected = getSymbol('react.forward_ref', 0xf47); expect(App.$$typeof).to.equal(expected); @@ -402,8 +402,7 @@ describe('forwardRef', () => { const Transition = ({ children }) => { const state = useState(0); forceTransition = state[1]; - expect(children.ref).to.not.be.undefined; - if (state[0] === 0) expect(children.props.ref).to.be.undefined; + expect(children.props.ref).to.not.be.undefined; return children; }; diff --git a/compat/test/browser/memo.test.js b/compat/test/browser/memo.test.js index 0cbd6fe8cc..89063209e7 100644 --- a/compat/test/browser/memo.test.js +++ b/compat/test/browser/memo.test.js @@ -72,9 +72,9 @@ describe('memo()', () => { let ref = null; - function Foo() { + function Foo(props) { spy(); - return

Hello World

; + return

Hello World

; } let Memoized = memo(Foo); @@ -99,8 +99,7 @@ describe('memo()', () => { update(); rerender(); - expect(ref.current).not.to.be.undefined; - + expect(ref.current).to.equal(scratch.firstChild); // TODO: not sure whether this is in-line with react... expect(spy).to.be.calledTwice; }); @@ -175,8 +174,8 @@ describe('memo()', () => { it('should pass ref through nested memos', () => { class Foo extends Component { - render() { - return

Hello World

; + render(props) { + return

Hello World

; } } @@ -187,7 +186,7 @@ describe('memo()', () => { render(, scratch); expect(ref.current).not.to.be.undefined; - expect(ref.current).to.be.instanceOf(Foo); + expect(ref.current).to.equal(scratch.firstChild); }); it('should not unnecessarily reorder children #2895', () => { diff --git a/compat/test/browser/render.test.js b/compat/test/browser/render.test.js index 7781acb6d8..6de00ac514 100644 --- a/compat/test/browser/render.test.js +++ b/compat/test/browser/render.test.js @@ -159,6 +159,85 @@ describe('compat render', () => { expect(scratch.firstElementChild.value).to.equal('0'); }); + it('should use defaultProps when prop is undefined', () => { + function TestComponent({ message }) { + return
{message}
; + } + TestComponent.defaultProps = { + message: 'default message' + }; + + render(, scratch); + expect(scratch.textContent).to.equal('default message'); + }); + + it('should not use defaultProps when prop is null', () => { + function TestComponent({ message }) { + return
{message === null ? 'null value' : message}
; + } + TestComponent.defaultProps = { + message: 'default message' + }; + + render(, scratch); + expect(scratch.textContent).to.equal('null value'); + }); + + it('should not use defaultProps when prop has a value', () => { + function TestComponent({ message }) { + return
{message}
; + } + TestComponent.defaultProps = { + message: 'default message' + }; + + render(, scratch); + expect(scratch.textContent).to.equal('actual message'); + }); + + it('should use defaultProps when prop is missing entirely', () => { + function TestComponent({ message }) { + return
{message}
; + } + TestComponent.defaultProps = { + message: 'default message' + }; + + render(, scratch); + expect(scratch.textContent).to.equal('default message'); + }); + + it('should handle multiple defaultProps with mixed prop states', () => { + function TestComponent({ title, message, count }) { + return ( +
+

{title}

+

{message}

+ {count} +
+ ); + } + TestComponent.defaultProps = { + title: 'Default Title', + message: 'Default Message', + count: 42 + }; + + // title is undefined (should use default), message is null (should not use default), count has value (should not use default) + render( + , + scratch + ); + + const title = scratch.querySelector('h1'); + const message = scratch.querySelector('p'); + const count = scratch.querySelector('span'); + + expect(title.textContent).to.equal('Default Title'); + expect(message.textContent).to.equal(''); // null renders as empty + expect(count.textContent).to.equal('0'); // 0 is a valid value + }); + it('should call onChange and onInput when input event is dispatched', () => { const onChange = sinon.spy(); const onInput = sinon.spy(); @@ -264,12 +343,7 @@ describe('compat render', () => { scratch ); - let html = sortAttributes(scratch.innerHTML); - if (/Trident/.test(navigator.userAgent)) { - html = html.toLowerCase(); - } - - expect(html).to.equal( + expect(sortAttributes(scratch.innerHTML)).to.equal( '' ); }); @@ -597,4 +671,214 @@ describe('compat render', () => { expect(scratch.textContent).to.equal('foo'); }); + + it('should append "px" to unitless inline css values', () => { + // These are all CSS Properties that support a single value + // that must have a unit. If we encounter a number we append "px" to it. + // The list is taken from: https://developer.mozilla.org/en-US/docs/Web/CSS/Reference + const unitless = { + 'border-block': 2, + 'border-block-end-width': 3, + 'border-block-start-width': 4, + 'border-block-width': 5, + 'border-bottom-left-radius': 6, + 'border-bottom-right-radius': 7, + 'border-bottom-width': 8, + 'border-end-end-radius': 9, + 'border-end-start-radius': 10, + 'border-image-outset': 11, + 'border-image-width': 12, + 'border-inline': 2, + 'border-inline-end': 3, + 'border-inline-end-width': 4, + 'border-inline-start': 1, + 'border-inline-start-width': 123, + 'border-inline-width': 123, + 'border-left': 123, + 'border-left-width': 123, + 'border-radius': 123, + 'border-right': 123, + 'border-right-width': 123, + 'border-spacing': 123, + 'border-start-end-radius': 123, + 'border-start-start-radius': 123, + 'border-top': 123, + 'border-top-left-radius': 123, + 'border-top-right-radius': 123, + 'border-top-width': 123, + 'border-width': 123, + bottom: 123, + 'column-gap': 123, + 'column-rule-width': 23, + 'column-width': 23, + 'flex-basis': 23, + 'font-size': 123, + 'grid-gap': 23, + 'grid-auto-columns': 123, + 'grid-auto-rows': 123, + 'grid-template-columns': 23, + 'grid-template-rows': 23, + height: 123, + 'inline-size': 23, + inset: 23, + 'inset-block-end': 12, + 'inset-block-start': 12, + 'inset-inline-end': 213, + 'inset-inline-start': 213, + left: 213, + 'letter-spacing': 213, + margin: 213, + 'margin-block': 213, + 'margin-block-end': 213, + 'margin-block-start': 213, + 'margin-bottom': 213, + 'margin-inline': 213, + 'margin-inline-end': 213, + 'margin-inline-start': 213, + 'margin-left': 213, + 'margin-right': 213, + 'margin-top': 213, + 'mask-position': 23, + 'mask-size': 23, + 'max-block-size': 23, + 'max-height': 23, + 'max-inline-size': 23, + 'max-width': 23, + 'min-block-size': 23, + 'min-height': 23, + 'min-inline-size': 23, + 'min-width': 23, + 'object-position': 23, + 'outline-offset': 23, + 'outline-width': 123, + padding: 123, + 'padding-block': 123, + 'padding-block-end': 123, + 'padding-block-start': 123, + 'padding-bottom': 123, + 'padding-inline': 123, + 'padding-inline-end': 123, + 'padding-inline-start': 123, + 'padding-left': 123, + 'padding-right': 123, + 'padding-top': 123, + perspective: 123, + right: 123, + 'scroll-margin': 123, + 'scroll-margin-block': 123, + 'scroll-margin-block-start': 123, + 'scroll-margin-bottom': 123, + 'scroll-margin-inline': 123, + 'scroll-margin-inline-end': 123, + 'scroll-margin-inline-start': 123, + 'scroll-margin-inline-left': 123, + 'scroll-margin-inline-right': 123, + 'scroll-margin-inline-top': 123, + 'scroll-padding': 123, + 'scroll-padding-block': 123, + 'scroll-padding-block-end': 123, + 'scroll-padding-block-start': 123, + 'scroll-padding-bottom': 123, + 'scroll-padding-inline': 123, + 'scroll-padding-inline-end': 123, + 'scroll-padding-inline-start': 123, + 'scroll-padding-left': 123, + 'scroll-padding-right': 123, + 'scroll-padding-top': 123, + 'shape-margin': 123, + 'text-decoration-thickness': 123, + 'text-indent': 123, + 'text-underline-offset': 123, + top: 123, + 'transform-origin': 123, + translate: 123, + width: 123, + 'word-spacing': 123 + }; + + // These are all CSS properties that have valid numeric values. + // Our appending logic must not be applied here + const untouched = { + '-webkit-line-clamp': 2, + animation: 2, + 'animation-iteration-count': 3, + 'border-image': 2, + 'border-image-slice': 2, + 'column-count': 2, + columns: 2, + flex: 1, + 'flex-grow': 1, + 'flex-shrink': 1, + 'font-size-adjust': 123, + 'font-weight': 12, + 'grid-column': 2, + 'grid-column-end': 2, + 'grid-column-start': 2, + 'grid-row': 2, + 'grid-row-end': 2, + 'grid-row-gap': 23, + 'grid-row-start': 2, + 'inital-letter': 2, + 'line-height': 2, + 'line-clamp': 2, + 'mask-border': 2, + 'mask-border-outset': 2, + 'mask-border-slice': 2, + 'mask-border-width': 2, + 'max-lines': 2, + 'max-zoom': 2, + 'min-zoom': 2, + opacity: 123, + order: 123, + orphans: 2, + scale: 23, + 'shape-image-threshold': 23, + 'tab-size': 23, + widows: 123, + 'z-index': 123, + zoom: 123 + }; + + render( +
, + scratch + ); + + let style = scratch.firstChild.style; + + // Check properties that MUST not be changed + for (const key in unitless) { + // Check if css property is supported + if ( + window.CSS && + typeof window.CSS.supports === 'function' && + window.CSS.supports(key, unitless[key]) + ) { + expect( + String(style[key]).endsWith('px'), + `Should append px "${key}: ${unitless[key]}" === "${key}: ${style[key]}"` + ).to.equal(true); + } + } + + // Check properties that MUST not be changed + for (const key in untouched) { + // Check if css property is supported + if ( + window.CSS && + typeof window.CSS.supports === 'function' && + window.CSS.supports(key, untouched[key]) + ) { + expect( + !String(style[key]).endsWith('px'), + `Should be left as is: "${key}: ${untouched[key]}" === "${key}: ${style[key]}"` + ).to.equal(true); + } + } + }); }); diff --git a/compat/test/browser/suspense-hydration.test.js b/compat/test/browser/suspense-hydration.test.js index 3526b96918..72b9b23d2e 100644 --- a/compat/test/browser/suspense-hydration.test.js +++ b/compat/test/browser/suspense-hydration.test.js @@ -723,61 +723,17 @@ describe('suspense hydration', () => { }); }); - // Currently not supported. Hydration doesn't set attributes... but should it - // when coming back from suspense if props were updated? - it.skip('should hydrate and update attributes with latest props', () => { - const originalHtml = '

Count: 0

Lazy count: 0

'; - scratch.innerHTML = originalHtml; - clearLog(); - - /** @type {() => void} */ - let increment; - const [Lazy, resolve] = createLazy(); - function App() { - const [count, setCount] = useState(0); - increment = () => setCount(c => c + 1); - - return ( - -

Count: {count}

- -
- ); - } - - hydrate(, scratch); - rerender(); // Flush rerender queue to mimic what preact will really do - expect(scratch.innerHTML).to.equal(originalHtml); - // Re: DOM OP below - Known issue with hydrating merged text nodes - expect(getLog()).to.deep.equal(['

Count: .appendChild(#text)']); - clearLog(); - - increment(); - rerender(); - - expect(scratch.innerHTML).to.equal( - '

Count: 1

Lazy count: 0

' - ); - expect(getLog()).to.deep.equal([]); - clearLog(); - - return resolve(({ count }) => ( -

Lazy count: {count}

- )).then(() => { - rerender(); - expect(scratch.innerHTML).to.equal( - '

Count: 1

Lazy count: 1

' - ); - // Re: DOM OP below - Known issue with hydrating merged text nodes - expect(getLog()).to.deep.equal(['

Lazy count: .appendChild(#text)']); - clearLog(); - }); - }); - - // Currently not supported, but I wrote the test before I realized that so - // leaving it here in case we do support it eventually - it.skip('should properly hydrate suspense when resolves to a Fragment', () => { - const originalHtml = ul([li(0), li(1), li(2), li(3), li(4), li(5)]); + it('should properly hydrate suspense when resolves to a Fragment', () => { + const originalHtml = ul([ + li(0), + li(1), + '', + li(2), + li(3), + '', + li(4), + li(5) + ]); const listeners = [ sinon.spy(), @@ -809,8 +765,8 @@ describe('suspense hydration', () => { scratch ); rerender(); // Flush rerender queue to mimic what preact will really do - expect(scratch.innerHTML).to.equal(originalHtml); expect(getLog()).to.deep.equal([]); + expect(scratch.innerHTML).to.equal(originalHtml); expect(listeners[5]).not.to.have.been.called; clearLog(); @@ -839,4 +795,228 @@ describe('suspense hydration', () => { expect(listeners[5]).to.have.been.calledTwice; }); }); + + it('should properly hydrate suspense when resolves to a Fragment without children', () => { + const originalHtml = ul([ + li(0), + li(1), + '', + '', + li(2), + li(3) + ]); + + const listeners = [sinon.spy(), sinon.spy(), sinon.spy(), sinon.spy()]; + + scratch.innerHTML = originalHtml; + clearLog(); + + const [Lazy, resolve] = createLazy(); + hydrate( + + + 0 + 1 + + + + + + 2 + 3 + + , + scratch + ); + rerender(); // Flush rerender queue to mimic what preact will really do + expect(getLog()).to.deep.equal([]); + expect(scratch.innerHTML).to.equal(originalHtml); + expect(listeners[3]).not.to.have.been.called; + + clearLog(); + scratch.querySelector('li:last-child').dispatchEvent(createEvent('click')); + expect(listeners[3]).to.have.been.calledOnce; + + return resolve(() => null).then(() => { + rerender(); + expect(scratch.innerHTML).to.equal(originalHtml); + expect(getLog()).to.deep.equal([]); + clearLog(); + + scratch + .querySelector('li:nth-child(2)') + .dispatchEvent(createEvent('click')); + expect(listeners[1]).to.have.been.calledOnce; + + scratch + .querySelector('li:last-child') + .dispatchEvent(createEvent('click')); + expect(listeners[3]).to.have.been.calledTwice; + }); + }); + + it('Should hydrate a fragment with multiple children correctly', () => { + scratch.innerHTML = '

Hello
World!
'; + clearLog(); + + const [Lazy, resolve] = createLazy(); + hydrate( + + + , + scratch + ); + rerender(); // Flush rerender queue to mimic what preact will really do + expect(scratch.innerHTML).to.equal( + '
Hello
World!
' + ); + expect(getLog()).to.deep.equal([]); + clearLog(); + + return resolve(() => ( + <> +
Hello
+
World!
+ + )).then(() => { + rerender(); + expect(scratch.innerHTML).to.equal( + '
Hello
World!
' + ); + expect(getLog()).to.deep.equal([]); + + clearLog(); + }); + }); + + it('Should hydrate a fragment with no children correctly', () => { + scratch.innerHTML = '
Hello world
'; + clearLog(); + + const [Lazy, resolve] = createLazy(); + hydrate( + <> + + + +
Hello world
+ , + scratch + ); + rerender(); // Flush rerender queue to mimic what preact will really do + expect(scratch.innerHTML).to.equal( + '
Hello world
' + ); + expect(getLog()).to.deep.equal([]); + clearLog(); + + return resolve(() => null).then(() => { + rerender(); + expect(scratch.innerHTML).to.equal( + '
Hello world
' + ); + expect(getLog()).to.deep.equal([]); + + clearLog(); + }); + }); + + it('Should hydrate a fragment with no children correctly deeply', () => { + scratch.innerHTML = + '
Hello world
'; + clearLog(); + + const [Lazy, resolve] = createLazy(); + const [Lazy2, resolve2] = createLazy(); + hydrate( + <> + + + + + + + +
Hello world
+ , + scratch + ); + rerender(); // Flush rerender queue to mimic what preact will really do + expect(scratch.innerHTML).to.equal( + '
Hello world
' + ); + expect(getLog()).to.deep.equal([]); + clearLog(); + + return resolve(p => p.children).then(() => { + rerender(); + expect(scratch.innerHTML).to.equal( + '
Hello world
' + ); + expect(getLog()).to.deep.equal([]); + + clearLog(); + return resolve2(() => null).then(() => { + rerender(); + expect(scratch.innerHTML).to.equal( + '
Hello world
' + ); + expect(getLog()).to.deep.equal([]); + + clearLog(); + }); + }); + }); + + it('Should hydrate a fragment with multiple children correctly deeply', () => { + scratch.innerHTML = + '

I am

Fragment
Hello world
'; + clearLog(); + + const [Lazy, resolve] = createLazy(); + const [Lazy2, resolve2] = createLazy(); + hydrate( + <> + + + + + + + +
Hello world
+ , + scratch + ); + rerender(); // Flush rerender queue to mimic what preact will really do + expect(scratch.innerHTML).to.equal( + '

I am

Fragment
Hello world
' + ); + expect(getLog()).to.deep.equal([]); + clearLog(); + + return resolve(p => p.children).then(() => { + rerender(); + expect(scratch.innerHTML).to.equal( + '

I am

Fragment
Hello world
' + ); + expect(getLog()).to.deep.equal([]); + + clearLog(); + return resolve2(() => ( + <> +

I am

+ Fragment + + )).then(() => { + rerender(); + expect(scratch.innerHTML).to.equal( + '

I am

Fragment
Hello world
' + ); + expect(getLog()).to.deep.equal([]); + + clearLog(); + }); + }); + }); }); diff --git a/compat/test/browser/suspense-list.test.js b/compat/test/browser/suspense-list.test.js deleted file mode 100644 index 9e733c98cb..0000000000 --- a/compat/test/browser/suspense-list.test.js +++ /dev/null @@ -1,589 +0,0 @@ -import { setupRerender } from 'preact/test-utils'; -import React, { - createElement, - render, - Component, - Suspense, - SuspenseList -} from 'preact/compat'; -import { useState } from 'preact/hooks'; -import { setupScratch, teardown } from '../../../test/_util/helpers'; - -const h = React.createElement; -/* eslint-env browser, mocha */ - -function getSuspendableComponent(text) { - let resolve; - let resolved = false; - const promise = new Promise(_resolve => { - resolve = () => { - resolved = true; - _resolve(); - return promise; - }; - }); - - class LifecycleSuspender extends Component { - render() { - if (!resolved) { - throw promise; - } - return {text}; - } - } - - LifecycleSuspender.resolve = () => { - resolve(); - }; - - return LifecycleSuspender; -} - -describe('suspense-list', () => { - /** @type {HTMLDivElement} */ - let scratch, - rerender, - unhandledEvents = []; - - function onUnhandledRejection(event) { - unhandledEvents.push(event); - } - - function getSuspenseList(revealOrder) { - const A = getSuspendableComponent('A'); - const B = getSuspendableComponent('B'); - const C = getSuspendableComponent('C'); - render( - - Loading...}> - - - Loading...}> - - - Loading...}> - - - , - scratch - ); // Render initial state - - return [A.resolve, B.resolve, C.resolve]; - } - - function getNestedSuspenseList(outerRevealOrder, innerRevealOrder) { - const A = getSuspendableComponent('A'); - const B = getSuspendableComponent('B'); - const C = getSuspendableComponent('C'); - const D = getSuspendableComponent('D'); - - render( - - Loading...}> - - - - Loading...}> - - - Loading...}> - - - - Loading...}> - - - , - scratch - ); - return [A.resolve, B.resolve, C.resolve, D.resolve]; - } - - beforeEach(() => { - scratch = setupScratch(); - rerender = setupRerender(); - unhandledEvents = []; - - if ('onunhandledrejection' in window) { - window.addEventListener('unhandledrejection', onUnhandledRejection); - } - }); - - afterEach(() => { - teardown(scratch); - - if ('onunhandledrejection' in window) { - window.removeEventListener('unhandledrejection', onUnhandledRejection); - - if (unhandledEvents.length) { - throw unhandledEvents[0].reason; - } - } - }); - - it('should work for single element', async () => { - const Component = getSuspendableComponent('A'); - render( - - Loading...}> - - - , - scratch - ); // Render initial state - - rerender(); // Re-render with fallback cuz lazy threw - expect(scratch.innerHTML).to.eql(`Loading...`); - - await Component.resolve(); - rerender(); - expect(scratch.innerHTML).to.eql(`A`); - }); - - it('should let components appear backwards if no revealOrder is mentioned', async () => { - const [resolver1, resolver2, resolver3] = getSuspenseList(); - - rerender(); // Re-render with fallback cuz lazy threw - expect(scratch.innerHTML).to.eql( - `Loading...Loading...Loading...` - ); - - await resolver2(); - rerender(); - expect(scratch.innerHTML).to.eql( - `Loading...BLoading...` - ); - - await resolver3(); - rerender(); - expect(scratch.innerHTML).to.eql( - `Loading...BC` - ); - - await resolver1(); - rerender(); - expect(scratch.innerHTML).to.eql( - `ABC` - ); - }); - - it('should let components appear forwards if no revealOrder is mentioned', async () => { - const [resolver1, resolver2, resolver3] = getSuspenseList(); - - rerender(); // Re-render with fallback cuz lazy threw - expect(scratch.innerHTML).to.eql( - `Loading...Loading...Loading...` - ); - - await resolver1(); - rerender(); - expect(scratch.innerHTML).to.eql( - `ALoading...Loading...` - ); - - await resolver2(); - rerender(); - expect(scratch.innerHTML).to.eql( - `ABLoading...` - ); - - await resolver3(); - rerender(); - expect(scratch.innerHTML).to.eql( - `ABC` - ); - }); - - it('should let components appear in forwards if revealOrder=forwards and first one resolves before others', async () => { - const [resolver1, resolver2, resolver3] = getSuspenseList('forwards'); - - rerender(); // Re-render with fallback cuz lazy threw - expect(scratch.innerHTML).to.eql( - `Loading...Loading...Loading...` - ); - - await resolver1(); - rerender(); - expect(scratch.innerHTML).to.eql( - `ALoading...Loading...` - ); - - await resolver3(); - rerender(); - expect(scratch.innerHTML).to.eql( - `ALoading...Loading...` - ); - - await resolver2(); - rerender(); - expect(scratch.innerHTML).to.eql( - `ABC` - ); - }); - - it('should make components appear together if revealOrder=forwards and others resolves before first', async () => { - const [resolver1, resolver2, resolver3] = getSuspenseList('forwards'); - - rerender(); // Re-render with fallback cuz lazy threw - expect(scratch.innerHTML).to.eql( - `Loading...Loading...Loading...` - ); - - await resolver2(); - rerender(); - expect(scratch.innerHTML).to.eql( - `Loading...Loading...Loading...` - ); - - await resolver3(); - rerender(); - expect(scratch.innerHTML).to.eql( - `Loading...Loading...Loading...` - ); - - await resolver1(); - rerender(); - expect(scratch.innerHTML).to.eql( - `ABC` - ); - }); - - it('should let components appear backwards if revealOrder=backwards and others resolves before first', async () => { - const [resolver1, resolver2, resolver3] = getSuspenseList('backwards'); - - rerender(); // Re-render with fallback cuz lazy threw - expect(scratch.innerHTML).to.eql( - `Loading...Loading...Loading...` - ); - - await resolver3(); - rerender(); - expect(scratch.innerHTML).to.eql( - `Loading...Loading...C` - ); - - await resolver2(); - rerender(); - expect(scratch.innerHTML).to.eql( - `Loading...BC` - ); - - await resolver1(); - rerender(); - expect(scratch.innerHTML).to.eql( - `ABC` - ); - }); - - it('should make components appear together if revealOrder=backwards and first one resolves others', async () => { - const [resolver1, resolver2, resolver3] = getSuspenseList('backwards'); - - rerender(); // Re-render with fallback cuz lazy threw - expect(scratch.innerHTML).to.eql( - `Loading...Loading...Loading...` - ); - - await resolver1(); - rerender(); - expect(scratch.innerHTML).to.eql( - `Loading...Loading...Loading...` - ); - - await resolver3(); - rerender(); - expect(scratch.innerHTML).to.eql( - `Loading...Loading...C` - ); - - await resolver2(); - rerender(); - expect(scratch.innerHTML).to.eql( - `ABC` - ); - }); - - it('should make components appear together if revealOrder=together and first one resolves others', async () => { - const [resolver1, resolver2, resolver3] = getSuspenseList('together'); - - rerender(); // Re-render with fallback cuz lazy threw - expect(scratch.innerHTML).to.eql( - `Loading...Loading...Loading...` - ); - - await resolver1(); - rerender(); - expect(scratch.innerHTML).to.eql( - `Loading...Loading...Loading...` - ); - - await resolver3(); - rerender(); - expect(scratch.innerHTML).to.eql( - `Loading...Loading...Loading...` - ); - - await resolver2(); - rerender(); - expect(scratch.innerHTML).to.eql( - `ABC` - ); - }); - - it('should make components appear together if revealOrder=together and second one resolves before others', async () => { - const [resolver1, resolver2, resolver3] = getSuspenseList('together'); - - rerender(); // Re-render with fallback cuz lazy threw - expect(scratch.innerHTML).to.eql( - `Loading...Loading...Loading...` - ); - - await resolver2(); - rerender(); - expect(scratch.innerHTML).to.eql( - `Loading...Loading...Loading...` - ); - - await resolver1(); - rerender(); - expect(scratch.innerHTML).to.eql( - `Loading...Loading...Loading...` - ); - - await resolver3(); - rerender(); - expect(scratch.innerHTML).to.eql( - `ABC` - ); - }); - - it('should not do anything to non suspense elements', async () => { - const A = getSuspendableComponent('A'); - const B = getSuspendableComponent('B'); - render( - - Loading...}> - - -
foo
- Loading...}> - - - bar - , - scratch - ); - - rerender(); // Re-render with fallback cuz lazy threw - expect(scratch.innerHTML).to.eql( - `Loading...
foo
Loading...bar` - ); - - await A.resolve(); - rerender(); - expect(scratch.innerHTML).to.eql( - `A
foo
Loading...bar` - ); - - await B.resolve(); - rerender(); - expect(scratch.innerHTML).to.eql( - `A
foo
Bbar` - ); - }); - - it('should make sure nested SuspenseList works with forwards', async () => { - const [resolveA, resolveB, resolveC, resolveD] = getNestedSuspenseList( - 'forwards', - 'forwards' - ); - rerender(); // Re-render with fallback cuz lazy threw - expect(scratch.innerHTML).to.eql( - `Loading...Loading...Loading...Loading...` - ); - - await resolveB(); - rerender(); - expect(scratch.innerHTML).to.eql( - `Loading...Loading...Loading...Loading...` - ); - - await resolveA(); - rerender(); - expect(scratch.innerHTML).to.eql( - `ABLoading...Loading...` - ); - - await resolveC(); - rerender(); - expect(scratch.innerHTML).to.eql( - `ABCLoading...` - ); - - await resolveD(); - rerender(); - expect(scratch.innerHTML).to.eql( - `ABCD` - ); - }); - - it('should make sure nested SuspenseList works with backwards', async () => { - const [resolveA, resolveB, resolveC, resolveD] = getNestedSuspenseList( - 'forwards', - 'backwards' - ); - rerender(); // Re-render with fallback cuz lazy threw - expect(scratch.innerHTML).to.eql( - `Loading...Loading...Loading...Loading...` - ); - - await resolveA(); - rerender(); - expect(scratch.innerHTML).to.eql( - `ALoading...Loading...Loading...` - ); - - await resolveC(); - rerender(); - expect(scratch.innerHTML).to.eql( - `ALoading...CLoading...` - ); - - await resolveB(); - rerender(); - expect(scratch.innerHTML).to.eql( - `ABCLoading...` - ); - - await resolveD(); - rerender(); - expect(scratch.innerHTML).to.eql( - `ABCD` - ); - }); - - it('should make sure nested SuspenseList works with together', async () => { - const [resolveA, resolveB, resolveC, resolveD] = getNestedSuspenseList( - 'together', - 'forwards' - ); - rerender(); // Re-render with fallback cuz lazy threw - expect(scratch.innerHTML).to.eql( - `Loading...Loading...Loading...Loading...` - ); - - await resolveA(); - rerender(); - expect(scratch.innerHTML).to.eql( - `Loading...Loading...Loading...Loading...` - ); - - await resolveD(); - rerender(); - expect(scratch.innerHTML).to.eql( - `Loading...Loading...Loading...Loading...` - ); - - await resolveB(); - rerender(); - expect(scratch.innerHTML).to.eql( - `Loading...Loading...Loading...Loading...` - ); - - await resolveC(); - rerender(); - expect(scratch.innerHTML).to.eql( - `ABCD` - ); - }); - - it('should work with forwards even when a child does not suspend', async () => { - const Component = getSuspendableComponent('A'); - - render( - - Loading...}> -
- - Loading...}> - - - , - scratch - ); // Render initial state - - rerender(); - expect(scratch.innerHTML).to.eql(`
Loading...`); - - await Component.resolve(); - rerender(); - expect(scratch.innerHTML).to.eql(`
A`); - }); - - it('should work with together even when a child does not suspend', async () => { - const Component = getSuspendableComponent('A'); - - render( - - Loading...}> -
- - Loading...}> - - - , - scratch - ); // Render initial state - - rerender(); - expect(scratch.innerHTML).to.eql(`
Loading...`); - - await Component.resolve(); - rerender(); - expect(scratch.innerHTML).to.eql(`
A`); - }); - - it('should not suspend resolved children if a new suspense comes in between', async () => { - const ComponentA = getSuspendableComponent('A'); - const ComponentB = getSuspendableComponent('B'); - - /** @type {(v) => void} */ - let showB; - function Container() { - const [showHidden, setShowHidden] = useState(false); - showB = setShowHidden; - return ( - - Loading...}> -
- - {showHidden && ( - Loading...}> - - - )} - Loading...}> - - - - ); - } - render(, scratch); // Render initial state - - rerender(); - expect(scratch.innerHTML).to.eql(`
Loading...`); - - await ComponentA.resolve(); - rerender(); - expect(scratch.innerHTML).to.eql(`
A`); - - showB(true); - rerender(); - expect(scratch.innerHTML).to.eql( - `
Loading...A` - ); - - await ComponentB.resolve(); - rerender(); - expect(scratch.innerHTML).to.eql(`
BA`); - }); -}); diff --git a/compat/test/browser/suspense.test.js b/compat/test/browser/suspense.test.js index d18dc8104f..6b6024855f 100644 --- a/compat/test/browser/suspense.test.js +++ b/compat/test/browser/suspense.test.js @@ -272,7 +272,7 @@ describe('suspense', () => { }); it('lazy should forward refs', () => { - const LazyComp = () =>
Hello from LazyComp
; + const LazyComp = props =>
; let ref = {}; /** @type {() => Promise} */ @@ -298,7 +298,7 @@ describe('suspense', () => { return resolve().then(() => { rerender(); - expect(ref.current.constructor).to.equal(LazyComp); + expect(ref.current).to.equal(scratch.firstChild); }); }); @@ -1675,8 +1675,17 @@ describe('suspense', () => { // eslint-disable-next-line react/require-render-return class Suspender extends Component { + constructor(props) { + super(props); + this.state = { promise: new Promise(() => {}) }; + if (typeof props.ref === 'function') { + props.ref(this); + } else if (props.ref) { + props.ref.current = this; + } + } render() { - throw new Promise(() => {}); + throw this.state.promise; } } diff --git a/compat/test/browser/textarea.test.js b/compat/test/browser/textarea.test.js index d4cb83bb2c..d7306a0a40 100644 --- a/compat/test/browser/textarea.test.js +++ b/compat/test/browser/textarea.test.js @@ -33,17 +33,10 @@ describe('Textarea', () => { hydrate(, scratch); expect(scratch.firstElementChild.value).to.equal('foo'); - // IE11 always displays the value as node.innerHTML - if (!/Trident/.test(window.navigator.userAgent)) { - expect(scratch.innerHTML).to.be.equal(''); - } + expect(scratch.innerHTML).to.be.equal(''); }); it('should alias defaultValue to children', () => { - // TODO: IE11 doesn't update `node.value` when - // `node.defaultValue` is set. - if (/Trident/.test(navigator.userAgent)) return; - render('); - } + expect(scratch.innerHTML).to.equal(''); expect(scratch.firstElementChild.value).to.equal('hello'); act(() => { set(''); }); - // Same as earlier: IE11 always displays the value as node.innerHTML - if (!/Trident/.test(window.navigator.userAgent)) { - expect(scratch.innerHTML).to.equal(''); - } + expect(scratch.innerHTML).to.equal(''); expect(scratch.firstElementChild.value).to.equal(''); }); }); diff --git a/compat/test/browser/unstable_batchedUpdates.test.js b/compat/test/browser/unstable_batchedUpdates.test.js deleted file mode 100644 index 90d7f583a4..0000000000 --- a/compat/test/browser/unstable_batchedUpdates.test.js +++ /dev/null @@ -1,34 +0,0 @@ -import { unstable_batchedUpdates, flushSync } from 'preact/compat'; - -describe('unstable_batchedUpdates', () => { - it('should call the callback', () => { - const spy = sinon.spy(); - unstable_batchedUpdates(spy); - expect(spy).to.be.calledOnce; - }); - - it('should call callback with only one arg', () => { - const spy = sinon.spy(); - // @ts-expect-error - unstable_batchedUpdates(spy, 'foo', 'bar'); - expect(spy).to.be.calledWithExactly('foo'); - }); -}); - -describe('flushSync', () => { - it('should invoke the given callback', () => { - const returnValue = {}; - const spy = sinon.spy(() => returnValue); - const result = flushSync(spy); - expect(spy).to.have.been.calledOnce; - expect(result).to.equal(returnValue); - }); - - it('should invoke the given callback with the given argument', () => { - const returnValue = {}; - const spy = sinon.spy(() => returnValue); - const result = flushSync(spy, 'foo'); - expect(spy).to.be.calledWithExactly('foo'); - expect(result).to.equal(returnValue); - }); -}); diff --git a/compat/test/ts/forward-ref.tsx b/compat/test/ts/forward-ref.tsx index 25d0694079..98aa52b82e 100644 --- a/compat/test/ts/forward-ref.tsx +++ b/compat/test/ts/forward-ref.tsx @@ -1,9 +1,9 @@ import React from '../../src'; -const MyInput: React.ForwardFn<{ id: string }, { focus(): void }> = ( - props, - ref -) => { +const MyInput: React.ForwardRefRenderFunction< + { focus(): void }, + { id: string } +> = (props, ref) => { const inputRef = React.useRef(null); React.useImperativeHandle(ref, () => ({ diff --git a/compat/test/ts/index.tsx b/compat/test/ts/index.tsx index 2e322931d9..51dbd60adc 100644 --- a/compat/test/ts/index.tsx +++ b/compat/test/ts/index.tsx @@ -34,6 +34,8 @@ class SimpleComponentWithContextAsProvider extends React.Component { } } +SimpleComponentWithContextAsProvider.defaultProps = { foo: 'default' }; + React.render( , document.createElement('div') diff --git a/compat/test/ts/suspense.tsx b/compat/test/ts/suspense.tsx index c082f54663..3b6268f8a2 100644 --- a/compat/test/ts/suspense.tsx +++ b/compat/test/ts/suspense.tsx @@ -37,20 +37,6 @@ class ReactSuspensefulFunc extends React.Component { } } -//SuspenseList using lazy components -function ReactSuspenseListTester(_props: any) { - return ( - - }> - - - }> - - - - ); -} - const Comp = () =>

Hello world

; const importComponent = async () => { diff --git a/config/node-13-exports.js b/config/node-13-exports.js deleted file mode 100644 index 9528d2aefa..0000000000 --- a/config/node-13-exports.js +++ /dev/null @@ -1,32 +0,0 @@ -const fs = require('fs'); - -const subRepositories = [ - 'compat', - 'debug', - 'devtools', - 'hooks', - 'jsx-runtime', - 'test-utils' -]; -const snakeCaseToCamelCase = str => - str.replace(/([-_][a-z])/g, group => group.toUpperCase().replace('-', '')); - -const copyPreact = () => { - // Copy .module.js --> .mjs for Node 13 compat. - fs.writeFileSync( - `${process.cwd()}/dist/preact.mjs`, - fs.readFileSync(`${process.cwd()}/dist/preact.module.js`) - ); -}; - -const copy = name => { - // Copy .module.js --> .mjs for Node 13 compat. - const filename = name.includes('-') ? snakeCaseToCamelCase(name) : name; - fs.writeFileSync( - `${process.cwd()}/${name}/dist/${filename}.mjs`, - fs.readFileSync(`${process.cwd()}/${name}/dist/${filename}.module.js`) - ); -}; - -copyPreact(); -subRepositories.forEach(copy); diff --git a/debug/package.json b/debug/package.json index 836b4b49f7..df80213a7a 100644 --- a/debug/package.json +++ b/debug/package.json @@ -5,7 +5,7 @@ "private": true, "description": "Preact extensions for development", "main": "dist/debug.js", - "module": "dist/debug.module.js", + "module": "dist/debug.mjs", "umd:main": "dist/debug.umd.js", "source": "src/index.js", "license": "MIT", @@ -18,7 +18,7 @@ "exports": { ".": { "types": "./src/index.d.ts", - "browser": "./dist/debug.module.js", + "module": "./dist/debug.mjs", "umd": "./dist/debug.umd.js", "import": "./dist/debug.mjs", "require": "./dist/debug.js" diff --git a/debug/src/debug.js b/debug/src/debug.js index 8da063130f..1d8eb7c0e6 100644 --- a/debug/src/debug.js +++ b/debug/src/debug.js @@ -11,7 +11,7 @@ import { getCurrentVNode, getDisplayName } from './component-stack'; -import { assign, isNaN } from './util'; +import { isNaN } from './util'; const isWeakMapSupported = typeof WeakMap == 'function'; @@ -229,15 +229,12 @@ export function initDebug() { } } - let values = vnode.props; - if (vnode.type._forwarded) { - values = assign({}, values); - delete values.ref; - } + /* eslint-disable-next-line */ + const { ref: _ref, ...props } = vnode.props; checkPropTypes( vnode.type.propTypes, - values, + props, 'prop', getDisplayName(vnode), () => getOwnerStack(vnode) diff --git a/debug/src/util.js b/debug/src/util.js index be4228b9b6..2dddcea736 100644 --- a/debug/src/util.js +++ b/debug/src/util.js @@ -1,14 +1,4 @@ -/** - * Assign properties from `props` to `obj` - * @template O, P The obj and props types - * @param {O} obj The object to copy properties to - * @param {P} props The object to copy properties from - * @returns {O & P} - */ -export function assign(obj, props) { - for (let i in props) obj[i] = props[i]; - return /** @type {O & P} */ (obj); -} +export const assign = Object.assign; export function isNaN(value) { return value !== value; diff --git a/devtools/package.json b/devtools/package.json index c12ac730f0..353f2ad950 100644 --- a/devtools/package.json +++ b/devtools/package.json @@ -5,7 +5,7 @@ "private": true, "description": "Preact bridge for Preact devtools", "main": "dist/devtools.js", - "module": "dist/devtools.module.js", + "module": "dist/devtools.mjs", "umd:main": "dist/devtools.umd.js", "source": "src/index.js", "license": "MIT", @@ -16,7 +16,7 @@ "exports": { ".": { "types": "./src/index.d.ts", - "browser": "./dist/devtools.module.js", + "module": "./dist/devtools.mjs", "umd": "./dist/devtools.umd.js", "import": "./dist/devtools.mjs", "require": "./dist/devtools.js" diff --git a/hooks/package.json b/hooks/package.json index 787927573e..ea32c3fe2f 100644 --- a/hooks/package.json +++ b/hooks/package.json @@ -5,7 +5,7 @@ "private": true, "description": "Hook addon for Preact", "main": "dist/hooks.js", - "module": "dist/hooks.module.js", + "module": "dist/hooks.mjs", "umd:main": "dist/hooks.umd.js", "source": "src/index.js", "license": "MIT", @@ -26,7 +26,7 @@ "exports": { ".": { "types": "./src/index.d.ts", - "browser": "./dist/hooks.module.js", + "module": "./dist/hooks.mjs", "umd": "./dist/hooks.umd.js", "import": "./dist/hooks.mjs", "require": "./dist/hooks.js" diff --git a/hooks/src/index.d.ts b/hooks/src/index.d.ts index d7f77dbb49..22147a2bd4 100644 --- a/hooks/src/index.d.ts +++ b/hooks/src/index.d.ts @@ -52,13 +52,6 @@ export function useReducer( init: (arg: I) => S ): [S, Dispatch
]; -/** @deprecated Use the `Ref` type instead. */ -type PropRef = MutableRef; - -interface MutableRef { - 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. @@ -68,9 +61,9 @@ interface MutableRef { * * @param initialValue the initial value to store in the ref object */ -export function useRef(initialValue: T): MutableRef; -export function useRef(initialValue: T | null): RefObject; -export function useRef(): MutableRef; +export function useRef(initialValue: T): RefObject; +export function useRef(initialValue: T | null): RefObject; +export function useRef(initialValue: T | undefined): RefObject; type EffectCallback = () => void | (() => void); /** diff --git a/hooks/src/index.js b/hooks/src/index.js index b98b1988ae..eb52ae8e51 100644 --- a/hooks/src/index.js +++ b/hooks/src/index.js @@ -1,4 +1,7 @@ import { options as _options } from 'preact'; +import { SKIP_CHILDREN } from '../../src/constants'; + +const ObjectIs = Object.is; /** @type {number} */ let currentIndex; @@ -24,6 +27,7 @@ let oldAfterDiff = options.diffed; let oldCommit = options._commit; let oldBeforeUnmount = options.unmount; let oldRoot = options._root; +let oldAfterRender = options._afterRender; // We take the minimum timeout for requestAnimationFrame to ensure that // the callback is invoked after the next frame. 35ms is based on a 30hz @@ -58,10 +62,7 @@ options._render = vnode => { hooks._pendingEffects = []; currentComponent._renderCallbacks = []; hooks._list.forEach(hookItem => { - if (hookItem._nextValue) { - hookItem._value = hookItem._nextValue; - } - hookItem._pendingArgs = hookItem._nextValue = undefined; + hookItem._pendingArgs = undefined; }); } else { hooks._pendingEffects.forEach(invokeCleanup); @@ -184,19 +185,13 @@ export function useReducer(reducer, initialState, init) { const hookState = getHookState(currentIndex++, 2); hookState._reducer = reducer; if (!hookState._component) { + hookState._actions = []; hookState._value = [ !init ? invokeOrReturn(undefined, initialState) : init(initialState), action => { - const currentValue = hookState._nextValue - ? hookState._nextValue[0] - : hookState._value[0]; - const nextValue = hookState._reducer(currentValue, action); - - if (currentValue !== nextValue) { - hookState._nextValue = [nextValue, hookState._value[1]]; - hookState._component.setState({}); - } + hookState._actions.push(action); + hookState._component.setState({}); } ]; @@ -205,74 +200,55 @@ export function useReducer(reducer, initialState, init) { if (!currentComponent._hasScuFromHooks) { currentComponent._hasScuFromHooks = true; let prevScu = currentComponent.shouldComponentUpdate; - const prevCWU = currentComponent.componentWillUpdate; - - // If we're dealing with a forced update `shouldComponentUpdate` will - // not be called. But we use that to update the hook values, so we - // need to call it. - currentComponent.componentWillUpdate = function (p, s, c) { - if (this._force) { - let tmp = prevScu; - // Clear to avoid other sCU hooks from being called - prevScu = undefined; - updateHookState(p, s, c); - prevScu = tmp; - } - - if (prevCWU) prevCWU.call(this, p, s, c); + + currentComponent.shouldComponentUpdate = function (p, s, c) { + return prevScu + ? prevScu.call(this, p, s, c) || hookState._actions.length + : hookState._actions.length; }; + } + } - // This SCU has the purpose of bailing out after repeated updates - // to stateful hooks. - // we store the next value in _nextValue[0] and keep doing that for all - // state setters, if we have next states and - // all next states within a component end up being equal to their original state - // we are safe to bail out for this specific component. - /** - * - * @type {import('./internal').Component["shouldComponentUpdate"]} - */ - // @ts-ignore - We don't use TS to downtranspile - // eslint-disable-next-line no-inner-declarations - function updateHookState(p, s, c) { - if (!hookState._component.__hooks) return true; - - /** @type {(x: import('./internal').HookState) => x is import('./internal').ReducerHookState} */ - const isStateHook = x => !!x._component; - const stateHooks = - hookState._component.__hooks._list.filter(isStateHook); - - const allHooksEmpty = stateHooks.every(x => !x._nextValue); - // When we have no updated hooks in the component we invoke the previous SCU or - // traverse the VDOM tree further. - if (allHooksEmpty) { - return prevScu ? prevScu.call(this, p, s, c) : true; - } - - // We check whether we have components with a nextValue set that - // have values that aren't equal to one another this pushes - // us to update further down the tree - let shouldUpdate = hookState._component.props !== p; - stateHooks.forEach(hookItem => { - if (hookItem._nextValue) { - const currentValue = hookItem._value[0]; - hookItem._value = hookItem._nextValue; - hookItem._nextValue = undefined; - if (currentValue !== hookItem._value[0]) shouldUpdate = true; - } - }); + if (hookState._actions.length) { + const initialValue = hookState._value[0]; + hookState._actions.some(action => { + hookState._value[0] = hookState._reducer(hookState._value[0], action); + }); - return prevScu - ? prevScu.call(this, p, s, c) || shouldUpdate - : shouldUpdate; - } + hookState._didUpdate = !ObjectIs(initialValue, hookState._value[0]); + hookState._value = [hookState._value[0], hookState._value[1]]; + hookState._didExecute = true; + hookState._actions = []; + } - currentComponent.shouldComponentUpdate = updateHookState; + return hookState._value; +} + +options._afterRender = (newVNode, oldVNode) => { + if (newVNode._component && newVNode._component.__hooks) { + const hooks = newVNode._component.__hooks._list; + const stateHooksThatExecuted = hooks.filter( + /** @type {(x: import('./internal').HookState) => x is import('./internal').ReducerHookState} */ + // @ts-expect-error + x => x._component && x._didExecute + ); + + if ( + stateHooksThatExecuted.length && + !stateHooksThatExecuted.some(x => x._didUpdate) && + oldVNode.props === newVNode.props + ) { + newVNode._component.__hooks._pendingEffects = []; + newVNode._flags |= SKIP_CHILDREN; } + + stateHooksThatExecuted.some(hook => { + hook._didExecute = hook._didUpdate = false; + }); } - return hookState._nextValue || hookState._value; -} + if (oldAfterRender) oldAfterRender(newVNode, oldVNode); +}; /** * @param {import('./internal').Effect} callback @@ -540,7 +516,7 @@ function argsChanged(oldArgs, newArgs) { return ( !oldArgs || oldArgs.length !== newArgs.length || - newArgs.some((arg, index) => arg !== oldArgs[index]) + newArgs.some((arg, index) => !ObjectIs(arg, oldArgs[index])) ); } diff --git a/hooks/src/internal.d.ts b/hooks/src/internal.d.ts index 76cd97812b..b5b6d66c99 100644 --- a/hooks/src/internal.d.ts +++ b/hooks/src/internal.d.ts @@ -4,7 +4,7 @@ import { VNode as PreactVNode, PreactContext, HookType, - ErrorInfo, + ErrorInfo } from '../../src/internal'; import { Reducer, StateUpdater } from '.'; @@ -32,7 +32,8 @@ export interface ComponentHooks { _pendingEffects: EffectHookState[]; } -export interface Component extends Omit, '_renderCallbacks'> { +export interface Component + extends Omit, '_renderCallbacks'> { __hooks?: ComponentHooks; // Extend to include HookStates _renderCallbacks?: Array void)>; @@ -54,8 +55,6 @@ export type HookState = interface BaseHookState { _value?: unknown; - _nextValue?: unknown; - _pendingValue?: unknown; _args?: unknown; _pendingArgs?: unknown; _component?: unknown; @@ -74,7 +73,6 @@ export interface EffectHookState extends BaseHookState { export interface MemoHookState extends BaseHookState { _value?: T; - _pendingValue?: T; _args?: unknown[]; _pendingArgs?: unknown[]; _factory?: () => T; @@ -82,10 +80,12 @@ export interface MemoHookState extends BaseHookState { export interface ReducerHookState extends BaseHookState { - _nextValue?: [S, StateUpdater]; _value?: [S, StateUpdater]; + _actions?: any[]; _component?: Component; _reducer?: Reducer; + _didExecute?: boolean; + _didUpdate?: boolean; } export interface ContextHookState extends BaseHookState { diff --git a/hooks/test/browser/useContext.test.js b/hooks/test/browser/useContext.test.js index 629ab06714..36baaad45c 100644 --- a/hooks/test/browser/useContext.test.js +++ b/hooks/test/browser/useContext.test.js @@ -185,6 +185,7 @@ describe('useContext', () => { let provider, subSpy; function Comp() { + provider = this._vnode._parent._component; const value = useContext(Context); values.push(value); return null; @@ -193,7 +194,7 @@ describe('useContext', () => { render(, scratch); render( - (provider = p)} value={42}> + , scratch @@ -217,6 +218,7 @@ describe('useContext', () => { let provider, subSpy; function Comp() { + provider = this._vnode._parent._component; const value = useContext(Context); values.push(value); return null; @@ -225,7 +227,7 @@ describe('useContext', () => { render(, scratch); render( - (provider = p)} value={42}> + , scratch diff --git a/hooks/test/browser/useEffect.test.js b/hooks/test/browser/useEffect.test.js index feb554192d..550fc41741 100644 --- a/hooks/test/browser/useEffect.test.js +++ b/hooks/test/browser/useEffect.test.js @@ -636,4 +636,26 @@ describe('useEffect', () => { expect(calls.length).to.equal(1); expect(calls).to.deep.equal(['doing effecthi']); }); + + it('should not rerun when receiving NaN on subsequent renders', () => { + const calls = []; + const Component = ({ value }) => { + const [count, setCount] = useState(0); + useEffect(() => { + calls.push('doing effect' + count); + setCount(count + 1); + return () => { + calls.push('cleaning up' + count); + }; + }, [value]); + return

{count}

; + }; + const App = () => ; + + act(() => { + render(, scratch); + }); + expect(calls.length).to.equal(1); + expect(calls).to.deep.equal(['doing effect0']); + }); }); diff --git a/hooks/test/browser/useState.test.js b/hooks/test/browser/useState.test.js index e58552c472..29e722c4c2 100644 --- a/hooks/test/browser/useState.test.js +++ b/hooks/test/browser/useState.test.js @@ -1,7 +1,14 @@ import { setupRerender, act } from 'preact/test-utils'; import { createElement, render, createContext, Component } from 'preact'; -import { vi } from 'vitest'; -import { useState, useContext, useEffect } from 'preact/hooks'; +import { afterAll, beforeAll, expect, vi } from 'vitest'; +import { + useState, + useContext, + useEffect, + useLayoutEffect, + useReducer, + useRef +} from 'preact/hooks'; import { setupScratch, teardown } from '../../../test/_util/helpers'; /** @jsx createElement */ @@ -70,12 +77,12 @@ describe('useState', () => { doSetState(0); rerender(); expect(lastState).to.equal(0); - expect(Comp).toHaveBeenCalledOnce(); + expect(Comp).toHaveBeenCalledTimes(2); doSetState(() => 0); rerender(); expect(lastState).to.equal(0); - expect(Comp).toHaveBeenCalledOnce(); + expect(Comp).toHaveBeenCalledTimes(3); }); it('rerenders when setting the state', () => { @@ -345,7 +352,7 @@ describe('useState', () => { render(, scratch); }); - expect(renderSpy).to.be.calledTwice; + expect(renderSpy).to.be.calledThrice; }); // see preactjs/preact#3731 @@ -373,6 +380,79 @@ describe('useState', () => { expect(scratch.innerHTML).to.equal('

hello world!!!

'); }); + it('should exhaust renders when NaN state is set as a result of a props update', () => { + const calls = []; + const App = ({ i }) => { + calls.push('rendering' + i); + const [greeting, setGreeting] = useState(0); + + if (i === 2) { + setGreeting(NaN); + } + + return

{greeting}

; + }; + + act(() => { + render(, scratch); + }); + expect(calls.length).to.equal(1); + expect(calls).to.deep.equal(['rendering1']); + + act(() => { + render(, scratch); + }); + expect(calls.length).to.equal(27); + expect(calls.slice(1).every(c => c === 'rendering2')).to.equal(true); + }); + + it('should bail correctly when setting NaN twice', () => { + const calls = []; + let set; + const Greeting = ({ greeting }) => { + calls.push('rendering ' + greeting); + return

{greeting}

; + }; + const App = () => { + const [greeting, setGreeting] = useState(0); + set = setGreeting; + + return ; + }; + + act(() => { + render(, scratch); + }); + expect(calls.length).to.equal(1); + expect(calls).to.deep.equal(['rendering 0']); + + act(() => { + set(1); + }); + expect(calls.length).to.equal(2); + expect(calls).to.deep.equal(['rendering 0', 'rendering 1']); + + act(() => { + set(NaN); + }); + expect(calls.length).to.equal(3); + expect(calls).to.deep.equal([ + 'rendering 0', + 'rendering 1', + 'rendering NaN' + ]); + + act(() => { + set(NaN); + }); + expect(calls.length).to.equal(3); + expect(calls).to.deep.equal([ + 'rendering 0', + 'rendering 1', + 'rendering NaN' + ]); + }); + describe('Global sCU', () => { let prevScu; beforeAll(() => { @@ -414,4 +494,94 @@ describe('useState', () => { expect(renders).to.equal(2); }); }); + + it('Should capture the closure in the reducer', () => { + function createContext2() { + const context = createContext(); + + const ProviderOrig = context.Provider; + context.Provider = ({ value, children }) => { + const valueRef = useRef(value); + const contextValue = useRef(); + + if (!contextValue.current) { + contextValue.current = { + value: valueRef, + listener: null + }; + } + + useLayoutEffect(() => { + valueRef.current = value; + if (contextValue.current.listener) { + contextValue.current.listener([value]); + } + }, [value]); + return ( + {children} + ); + }; + + return context; + } + + function useContextSelector(context) { + const contextValue = useContext(context); + const { + value: { current: value } + } = contextValue; + const [state, dispatch] = useReducer( + () => { + return { + value + }; + }, + { + value + } + ); + useLayoutEffect(() => { + contextValue.listener = dispatch; + }, []); + return state.value; + } + + const context = createContext2(); + let set; + + function Child() { + const [count, setState] = useContextSelector(context); + const [c, setC] = useState(0); + set = () => { + setC(s => s + 1); + setState(s => s + 1); + }; + return ( +
+
Context count: {count}
+
Local count: {c}
+
+ ); + } + + // Render this + function App() { + const [state, setState] = useState(0); + return ( + + + + ); + } + + act(() => { + render(, scratch); + }); + expect(scratch.textContent).to.equal('Context count: 0Local count: 0'); + + act(() => { + set(); + }); + expect(scratch.textContent).to.equal('Context count: 1Local count: 1'); + }); }); diff --git a/jsx-runtime/package.json b/jsx-runtime/package.json index 1014de1c82..6a0cf9cd1c 100644 --- a/jsx-runtime/package.json +++ b/jsx-runtime/package.json @@ -5,7 +5,7 @@ "private": true, "description": "Preact JSX runtime", "main": "dist/jsxRuntime.js", - "module": "dist/jsxRuntime.module.js", + "module": "dist/jsxRuntime.mjs", "umd:main": "dist/jsxRuntime.umd.js", "source": "src/index.js", "types": "src/index.d.ts", @@ -19,7 +19,7 @@ "exports": { ".": { "types": "./src/index.d.ts", - "browser": "./dist/jsxRuntime.module.js", + "module": "./dist/jsxRuntime.mjs", "umd": "./dist/jsxRuntime.umd.js", "import": "./dist/jsxRuntime.mjs", "require": "./dist/jsxRuntime.js" diff --git a/jsx-runtime/src/index.js b/jsx-runtime/src/index.js index a6a21b8b03..8d7cbaa7f6 100644 --- a/jsx-runtime/src/index.js +++ b/jsx-runtime/src/index.js @@ -1,6 +1,5 @@ import { options, Fragment } from 'preact'; import { encodeEntities } from './utils'; -import { IS_NON_DIMENSIONAL } from '../../src/constants'; let vnodeId = 0; @@ -19,9 +18,9 @@ const isArray = Array.isArray; /** * JSX.Element factory used by Babel's {runtime:"automatic"} JSX transform - * @param {VNode['type']} type - * @param {VNode['props']} props - * @param {VNode['key']} [key] + * @param {import('../../src/internal').VNode['type']} type + * @param {import('preact').VNode['props']} props + * @param {import('preact').VNode['key']} [key] * @param {unknown} [isStaticChildren] * @param {unknown} [__source] * @param {unknown} [__self] @@ -35,7 +34,7 @@ function createVNode(type, props, key, isStaticChildren, __source, __self) { ref, i; - if ('ref' in normalizedProps) { + if ('ref' in normalizedProps && typeof type != 'function') { normalizedProps = {}; for (i in props) { if (i == 'ref') { @@ -46,7 +45,7 @@ function createVNode(type, props, key, isStaticChildren, __source, __self) { } } - /** @type {VNode & { __source: any; __self: any }} */ + /** @type {import('../../src/internal').VNode & { __source: any; __self: any }} */ const vnode = { type, props: normalizedProps, @@ -65,15 +64,6 @@ function createVNode(type, props, key, isStaticChildren, __source, __self) { __self }; - // If a Component VNode, check for and apply defaultProps. - // Note: `type` is often a String, and can be `undefined` in development. - if (typeof type === 'function' && (ref = type.defaultProps)) { - for (i in ref) - if (normalizedProps[i] === undefined) { - normalizedProps[i] = ref[i]; - } - } - if (options.vnode) options.vnode(vnode); return vnode; } @@ -82,12 +72,13 @@ function createVNode(type, props, key, isStaticChildren, __source, __self) { * Create a template vnode. This function is not expected to be * used directly, but rather through a precompile JSX transform * @param {string[]} templates - * @param {Array} exprs - * @returns {VNode} + * @param {Array} exprs + * @returns {import('preact').VNode} */ function jsxTemplate(templates, ...exprs) { const vnode = createVNode(Fragment, { tpl: templates, exprs }); // Bypass render to string top level Fragment optimization + // @ts-ignore vnode.key = vnode._vnode; return vnode; } @@ -121,16 +112,7 @@ function jsxAttr(name, value) { : JS_TO_CSS[prop] || (JS_TO_CSS[prop] = prop.replace(CSS_REGEX, '-$&').toLowerCase()); - let suffix = ';'; - if ( - typeof val === 'number' && - // Exclude custom-attributes - !name.startsWith('--') && - !IS_NON_DIMENSIONAL.test(name) - ) { - suffix = 'px;'; - } - str = str + name + ':' + val + suffix; + str = str + name + ':' + val + ';'; } } return name + '="' + str + '"'; @@ -153,7 +135,7 @@ function jsxAttr(name, value) { * is not expected to be used directly, but rather through a * precompile JSX transform * @param {*} value - * @returns {string | null | VNode | Array} + * @returns {string | null | import('preact').VNode | Array} */ function jsxEscape(value) { if ( diff --git a/jsx-runtime/test/browser/jsx-runtime.test.js b/jsx-runtime/test/browser/jsx-runtime.test.js index a5ba0daaf0..d06f8da21d 100644 --- a/jsx-runtime/test/browser/jsx-runtime.test.js +++ b/jsx-runtime/test/browser/jsx-runtime.test.js @@ -51,46 +51,6 @@ describe('Babel jsx/jsxDEV', () => { expect(vnode.key).to.equal('foo'); }); - it('should apply defaultProps', () => { - class Foo extends Component { - render() { - return
; - } - } - - Foo.defaultProps = { - foo: 'bar' - }; - - const vnode = jsx(Foo, {}, null); - expect(vnode.props).to.deep.equal({ - foo: 'bar' - }); - }); - - it('should respect defaultProps when props are null', () => { - const Component = ({ children }) => children; - Component.defaultProps = { foo: 'bar' }; - expect(jsx(Component, { foo: null }).props).to.deep.equal({ foo: null }); - }); - - it('should keep props over defaultProps', () => { - class Foo extends Component { - render() { - return
; - } - } - - Foo.defaultProps = { - foo: 'bar' - }; - - const vnode = jsx(Foo, { foo: 'baz' }, null); - expect(vnode.props).to.deep.equal({ - foo: 'baz' - }); - }); - it('should set __source and __self', () => { const vnode = jsx('div', { class: 'foo' }, 'key', false, 'source', 'self'); expect(vnode.__source).to.equal('source'); @@ -173,7 +133,9 @@ describe('precompiled JSX', () => { }); it('should serialize style object', () => { - expect(jsxAttr('style', { padding: 3 })).to.equal('style="padding:3px;"'); + expect(jsxAttr('style', { padding: '3px' })).to.equal( + 'style="padding:3px;"' + ); }); }); diff --git a/mangle.json b/mangle.json index 2d15afef53..bfe7a0b10b 100644 --- a/mangle.json +++ b/mangle.json @@ -26,22 +26,25 @@ "cname": 6, "props": { "$_hasScuFromHooks": "__f", - "$_listeners": "l", + "$_listeners": "__l", "$_cleanup": "__c", "$__hooks": "__H", "$_hydrationMismatch": "__m", "$_list": "__", "$_pendingEffects": "__h", "$_value": "__", - "$_nextValue": "__N", + "$_didExecute": "__N", + "$_didUpdate": "__U", "$_original": "__v", "$_args": "__H", "$_factory": "__h", "$_depth": "__b", "$_dirty": "__d", + "$_afterRender": "__d", "$_mask": "__m", "$_detachOnNextRender": "__b", "$_force": "__e", + "$_excess": "__z", "$_nextState": "__s", "$_renderCallbacks": "__h", "$_stateCallbacks": "_sb", diff --git a/package.json b/package.json index 7f7f39d40d..12a8685f25 100644 --- a/package.json +++ b/package.json @@ -3,9 +3,9 @@ "amdName": "preact", "version": "10.26.8", "private": false, - "description": "Fast 3kb React-compatible Virtual DOM library.", + "description": "Fast 4kb React-compatible Virtual DOM library.", "main": "dist/preact.js", - "module": "dist/preact.module.js", + "module": "dist/preact.mjs", "umd:main": "dist/preact.umd.js", "unpkg": "dist/preact.min.js", "source": "src/index.js", @@ -20,56 +20,56 @@ "types": "./src/index-5.d.ts" }, "types": "./src/index.d.ts", - "browser": "./dist/preact.module.js", + "module": "./dist/preact.mjs", "umd": "./dist/preact.umd.js", "import": "./dist/preact.mjs", "require": "./dist/preact.js" }, "./compat": { "types": "./compat/src/index.d.ts", - "browser": "./compat/dist/compat.module.js", + "module": "./compat/dist/compat.mjs", "umd": "./compat/dist/compat.umd.js", "import": "./compat/dist/compat.mjs", "require": "./compat/dist/compat.js" }, "./debug": { "types": "./debug/src/index.d.ts", - "browser": "./debug/dist/debug.module.js", + "module": "./debug/dist/debug.mjs", "umd": "./debug/dist/debug.umd.js", "import": "./debug/dist/debug.mjs", "require": "./debug/dist/debug.js" }, "./devtools": { "types": "./devtools/src/index.d.ts", - "browser": "./devtools/dist/devtools.module.js", + "module": "./devtools/dist/devtools.mjs", "umd": "./devtools/dist/devtools.umd.js", "import": "./devtools/dist/devtools.mjs", "require": "./devtools/dist/devtools.js" }, "./hooks": { "types": "./hooks/src/index.d.ts", - "browser": "./hooks/dist/hooks.module.js", + "module": "./hooks/dist/hooks.mjs", "umd": "./hooks/dist/hooks.umd.js", "import": "./hooks/dist/hooks.mjs", "require": "./hooks/dist/hooks.js" }, "./test-utils": { "types": "./test-utils/src/index.d.ts", - "browser": "./test-utils/dist/testUtils.module.js", + "module": "./test-utils/dist/testUtils.mjs", "umd": "./test-utils/dist/testUtils.umd.js", "import": "./test-utils/dist/testUtils.mjs", "require": "./test-utils/dist/testUtils.js" }, "./jsx-runtime": { "types": "./jsx-runtime/src/index.d.ts", - "browser": "./jsx-runtime/dist/jsxRuntime.module.js", + "module": "./jsx-runtime/dist/jsxRuntime.mjs", "umd": "./jsx-runtime/dist/jsxRuntime.umd.js", "import": "./jsx-runtime/dist/jsxRuntime.mjs", "require": "./jsx-runtime/dist/jsxRuntime.js" }, "./jsx-dev-runtime": { "types": "./jsx-runtime/src/index.d.ts", - "browser": "./jsx-runtime/dist/jsxRuntime.module.js", + "module": "./jsx-runtime/dist/jsxRuntime.mjs", "umd": "./jsx-runtime/dist/jsxRuntime.umd.js", "import": "./jsx-runtime/dist/jsxRuntime.mjs", "require": "./jsx-runtime/dist/jsxRuntime.js" @@ -116,14 +116,13 @@ "prepare": "husky && run-s build", "build": "npm-run-all --parallel 'build:*'", "build:core": "microbundle build --raw --no-generateTypes -f cjs,esm,umd", - "build:core-min": "microbundle build --raw --no-generateTypes -f cjs,esm,umd,iife src/cjs.js -o dist/preact.min.js", "build:debug": "microbundle build --raw --no-generateTypes -f cjs,esm,umd --cwd debug", "build:devtools": "microbundle build --raw --no-generateTypes -f cjs,esm,umd --cwd devtools", "build:hooks": "microbundle build --raw --no-generateTypes -f cjs,esm,umd --cwd hooks", "build:test-utils": "microbundle build --raw --no-generateTypes -f cjs,esm,umd --cwd test-utils", "build:compat": "microbundle build --raw --no-generateTypes -f cjs,esm,umd --cwd compat --globals 'preact/hooks=preactHooks'", "build:jsx": "microbundle build --raw --no-generateTypes -f cjs,esm,umd --cwd jsx-runtime", - "postbuild": "node ./config/node-13-exports.js && node ./config/compat-entries.js", + "postbuild": "node ./config/compat-entries.js", "dev": "microbundle watch --raw --no-generateTypes --format cjs", "dev:hooks": "microbundle watch --raw --no-generateTypes --format cjs --cwd hooks", "dev:compat": "microbundle watch --raw --no-generateTypes --format cjs --cwd compat --globals 'preact/hooks=preactHooks'", diff --git a/src/cjs.js b/src/cjs.js deleted file mode 100644 index b4721b1d44..0000000000 --- a/src/cjs.js +++ /dev/null @@ -1,3 +0,0 @@ -import * as preact from './index.js'; -if (typeof module < 'u') module.exports = preact; -else self.preact = preact; diff --git a/src/clone-element.js b/src/clone-element.js index 671eb4e63f..b2f6e118cf 100644 --- a/src/clone-element.js +++ b/src/clone-element.js @@ -1,6 +1,6 @@ import { assign, slice } from './util'; import { createVNode } from './create-element'; -import { NULL, UNDEFINED } from './constants'; +import { NULL } from './constants'; /** * Clones the given VNode, optionally adding attributes/props and replacing its @@ -17,20 +17,10 @@ export function cloneElement(vnode, props, children) { ref, i; - let defaultProps; - - if (vnode.type && vnode.type.defaultProps) { - defaultProps = vnode.type.defaultProps; - } - for (i in props) { if (i == 'key') key = props[i]; - else if (i == 'ref') ref = props[i]; - else if (props[i] === UNDEFINED && defaultProps != UNDEFINED) { - normalizedProps[i] = defaultProps[i]; - } else { - normalizedProps[i] = props[i]; - } + else if (i == 'ref' && typeof vnode.type != 'function') ref = props[i]; + else normalizedProps[i] = props[i]; } if (arguments.length > 2) { diff --git a/src/component.js b/src/component.js index b233d8c7c0..9b1327629b 100644 --- a/src/component.js +++ b/src/component.js @@ -159,11 +159,11 @@ function renderComponent(component) { */ function updateParentDomPointers(vnode) { if ((vnode = vnode._parent) != NULL && vnode._component != NULL) { - vnode._dom = vnode._component.base = NULL; + vnode._dom = NULL; for (let i = 0; i < vnode._children.length; i++) { let child = vnode._children[i]; if (child != NULL && child._dom != NULL) { - vnode._dom = vnode._component.base = child._dom; + vnode._dom = child._dom; break; } } @@ -189,11 +189,6 @@ let rerenderQueue = []; let prevDebounce; -const defer = - typeof Promise == 'function' - ? Promise.prototype.then.bind(Promise.resolve()) - : setTimeout; - /** * Enqueue a rerender of a component * @param {import('./internal').Component} c The component to rerender @@ -207,7 +202,7 @@ export function enqueueRender(c) { prevDebounce != options.debounceRendering ) { prevDebounce = options.debounceRendering; - (prevDebounce || defer)(process); + (prevDebounce || queueMicrotask)(process); } } diff --git a/src/constants.js b/src/constants.js index c60df07b93..57d56d98c6 100644 --- a/src/constants.js +++ b/src/constants.js @@ -6,6 +6,8 @@ export const MODE_SUSPENDED = 1 << 7; export const INSERT_VNODE = 1 << 2; /** Indicates a VNode has been matched with another VNode in the diff */ export const MATCHED = 1 << 1; +/** Indicates that children should not be diffed */ +export const SKIP_CHILDREN = 1 << 3; /** Reset all mode flags */ export const RESET_MODE = ~(MODE_HYDRATE | MODE_SUSPENDED); @@ -18,5 +20,3 @@ export const NULL = null; export const UNDEFINED = undefined; export const EMPTY_OBJ = /** @type {any} */ ({}); export const EMPTY_ARR = []; -export const IS_NON_DIMENSIONAL = - /acit|ex(?:s|g|n|p|$)|rph|grid|ows|mnc|ntw|ine[ch]|zoo|^ord|itera/i; diff --git a/src/create-element.js b/src/create-element.js index d4ea2dd935..0c0990ff60 100644 --- a/src/create-element.js +++ b/src/create-element.js @@ -20,7 +20,7 @@ export function createElement(type, props, children) { i; for (i in props) { if (i == 'key') key = props[i]; - else if (i == 'ref') ref = props[i]; + else if (i == 'ref' && typeof type != 'function') ref = props[i]; else normalizedProps[i] = props[i]; } @@ -29,16 +29,6 @@ export function createElement(type, props, children) { arguments.length > 3 ? slice.call(arguments, 2) : children; } - // If a Component VNode, check for and apply defaultProps - // Note: type may be undefined in development, must never error here. - if (typeof type == 'function' && type.defaultProps != NULL) { - for (i in type.defaultProps) { - if (normalizedProps[i] === UNDEFINED) { - normalizedProps[i] = type.defaultProps[i]; - } - } - } - return createVNode(type, normalizedProps, key, ref, NULL); } diff --git a/src/diff/children.js b/src/diff/children.js index 25ebcd3660..7f401a0373 100644 --- a/src/diff/children.js +++ b/src/diff/children.js @@ -358,7 +358,7 @@ function insert(parentVNode, oldDom, parentDom) { return oldDom; } else if (parentVNode._dom != oldDom) { - if (oldDom && parentVNode.type && !parentDom.contains(oldDom)) { + if (oldDom && parentVNode.type && !oldDom.parentNode) { oldDom = getDomSibling(parentVNode); } parentDom.insertBefore(parentVNode._dom, oldDom || NULL); diff --git a/src/diff/index.js b/src/diff/index.js index 5603586ae6..d9a62cbae5 100644 --- a/src/diff/index.js +++ b/src/diff/index.js @@ -5,6 +5,7 @@ import { MODE_SUSPENDED, NULL, RESET_MODE, + SKIP_CHILDREN, SVG_NAMESPACE, UNDEFINED, XHTML_NAMESPACE @@ -69,8 +70,11 @@ export function diff( // If the previous diff bailed out, resume creating/hydrating. if (oldVNode._flags & MODE_SUSPENDED) { isHydrating = !!(oldVNode._flags & MODE_HYDRATE); - oldDom = newVNode._dom = oldVNode._dom; - excessDomChildren = [oldDom]; + if (oldVNode._component._excess) { + excessDomChildren = oldVNode._component._excess; + oldDom = newVNode._dom = oldVNode._dom = excessDomChildren[0]; + oldVNode._component._excess = null; + } } if ((tmp = options._diff)) tmp(newVNode); @@ -220,6 +224,7 @@ export function diff( c._force = false; let renderHook = options._render, + afterRender = options._afterRender, count = 0; if (isClassComponent) { c.state = c._nextState; @@ -228,6 +233,7 @@ export function diff( if (renderHook) renderHook(newVNode); tmp = c.render(c.props, c.state, c.context); + if (afterRender) afterRender(newVNode, oldVNode); for (let i = 0; i < c._stateCallbacks.length; i++) { c._renderCallbacks.push(c._stateCallbacks[i]); @@ -239,6 +245,18 @@ export function diff( if (renderHook) renderHook(newVNode); tmp = c.render(c.props, c.state, c.context); + if (afterRender) afterRender(newVNode, oldVNode); + + if (newVNode._flags & SKIP_CHILDREN) { + c._dirty = false; + c._renderCallbacks = []; + newVNode._dom = oldVNode._dom; + newVNode._children = oldVNode._children; + newVNode._children.some(vnode => { + if (vnode) vnode._parent = newVNode; + }); + break outer; + } // Handle setState called in render, see #2553 c.state = c._nextState; @@ -249,7 +267,7 @@ export function diff( c.state = c._nextState; if (c.getChildContext != NULL) { - globalContext = assign(assign({}, globalContext), c.getChildContext()); + globalContext = assign({}, globalContext, c.getChildContext()); } if (isClassComponent && !isNew && c.getSnapshotBeforeUpdate != NULL) { @@ -278,8 +296,6 @@ export function diff( refQueue ); - c.base = newVNode._dom; - // We successfully rendered this VNode, unset any stored hydration/bailout state: newVNode._flags &= RESET_MODE; @@ -295,15 +311,54 @@ export function diff( // if hydrating or creating initial tree, bailout preserves DOM: if (isHydrating || excessDomChildren != NULL) { if (e.then) { + let commentMarkersToFind = 0, + done = false; + newVNode._flags |= isHydrating ? MODE_HYDRATE | MODE_SUSPENDED : MODE_SUSPENDED; - while (oldDom && oldDom.nodeType == 8 && oldDom.nextSibling) { - oldDom = oldDom.nextSibling; + newVNode._component._excess = []; + for (let i = 0; i < excessDomChildren.length; i++) { + let child = excessDomChildren[i]; + if (child == NULL || done) continue; + + // When we encounter a boundary with $s we are opening + // a boundary, this implies that we need to bump + // the amount of markers we need to find before closing + // the outer boundary. + // We exclude the open and closing marker from + // the future excessDomChildren but any nested one + // needs to be included for future suspensions. + if (child.nodeType == 8 && child.data == '$s') { + if (commentMarkersToFind > 0) { + newVNode._component._excess.push(child); + } + commentMarkersToFind++; + excessDomChildren[i] = NULL; + } else if (child.nodeType == 8 && child.data == '/$s') { + commentMarkersToFind--; + if (commentMarkersToFind > 0) { + newVNode._component._excess.push(child); + } + done = commentMarkersToFind === 0; + oldDom = excessDomChildren[i]; + excessDomChildren[i] = NULL; + } else if (commentMarkersToFind > 0) { + newVNode._component._excess.push(child); + excessDomChildren[i] = NULL; + } + } + + if (!done) { + while (oldDom && oldDom.nodeType == 8 && oldDom.nextSibling) { + oldDom = oldDom.nextSibling; + } + + excessDomChildren[excessDomChildren.indexOf(oldDom)] = NULL; + newVNode._component._excess = [oldDom]; } - excessDomChildren[excessDomChildren.indexOf(oldDom)] = NULL; newVNode._dom = oldDom; } else { for (let i = excessDomChildren.length; i--; ) { @@ -316,12 +371,6 @@ export function diff( } options._catchError(e, newVNode, oldVNode); } - } else if ( - excessDomChildren == NULL && - newVNode._original == oldVNode._original - ) { - newVNode._children = oldVNode._children; - newVNode._dom = oldVNode._dom; } else { oldDom = newVNode._dom = diffElementNodes( oldVNode._dom, @@ -580,12 +629,7 @@ function diffElementNodes( // despite the attribute not being present. When the attribute // is missing the progress bar is treated as indeterminate. // To fix that we'll always update it when it is 0 for progress elements - (inputValue !== dom[i] || - (nodeType == 'progress' && !inputValue) || - // This is only for IE 11 to fix