diff --git a/compat/src/index.d.ts b/compat/src/index.d.ts index 80a930d024..3a054da47c 100644 --- a/compat/src/index.d.ts +++ b/compat/src/index.d.ts @@ -115,14 +115,16 @@ declare namespace React { ) => preact.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 ): Element | null; - export abstract class PureComponent

extends preact.Component< - P, - S - > { + export abstract class PureComponent< + P = {}, + S = {}, + SS = any + > extends preact.Component { isPureReactComponent: boolean; } @@ -174,9 +176,9 @@ declare namespace React { export type ComponentPropsWithRef< C extends ComponentType | keyof JSXInternal.IntrinsicElements - > = C extends (new(props: infer P) => Component) - ? PropsWithoutRef

& RefAttributes> - : ComponentProps; + > = C extends new (props: infer P) => Component + ? PropsWithoutRef

& RefAttributes> + : ComponentProps; export function flushSync(fn: () => R): R; export function flushSync(fn: (a: A) => R, a: A): R; diff --git a/compat/src/index.js b/compat/src/index.js index 830d34dd24..f08b89b03d 100644 --- a/compat/src/index.js +++ b/compat/src/index.js @@ -63,6 +63,21 @@ function isFragment(element) { return isValidElement(element) && element.type === Fragment; } +/** + * Check if the passed element is a Memo node. + * @param {*} element The element to check + * @returns {boolean} + */ +function isMemo(element) { + return ( + !!element && + !!element.displayName && + (typeof element.displayName === 'string' || + element.displayName instanceof String) && + element.displayName.startsWith('Memo(') + ); +} + /** * Wrap `cloneElement` to abort if the passed element is not a valid element and apply * all vnode normalizations. @@ -215,6 +230,7 @@ export { Fragment, isValidElement, isFragment, + isMemo, findDOMNode, Component, PureComponent, @@ -263,6 +279,7 @@ export default { isValidElement, isElement, isFragment, + isMemo, findDOMNode, Component, PureComponent, diff --git a/compat/test/browser/isMemo.test.js b/compat/test/browser/isMemo.test.js new file mode 100644 index 0000000000..a11c1a9823 --- /dev/null +++ b/compat/test/browser/isMemo.test.js @@ -0,0 +1,37 @@ +import { createElement as preactCreateElement, Fragment } from 'preact'; +import React, { createElement, isMemo, memo } from 'preact/compat'; + +describe('isMemo', () => { + it('should check return false for invalid arguments', () => { + expect(isMemo(null)).to.equal(false); + expect(isMemo(false)).to.equal(false); + expect(isMemo(true)).to.equal(false); + expect(isMemo('foo')).to.equal(false); + expect(isMemo(123)).to.equal(false); + expect(isMemo([])).to.equal(false); + expect(isMemo({})).to.equal(false); + }); + + it('should detect a preact memo', () => { + function Foo() { + return

Hello World

; + } + let App = memo(Foo); + expect(isMemo(App)).to.equal(true); + }); + + it('should not detect a normal element', () => { + function Foo() { + return

Hello World

; + } + expect(isMemo(Foo)).to.equal(false); + }); + + it('should detect a preact vnode as false', () => { + expect(isMemo(preactCreateElement(Fragment, {}))).to.equal(false); + }); + + it('should detect a compat vnode as false', () => { + expect(isMemo(React.createElement(Fragment, {}))).to.equal(false); + }); +}); diff --git a/compat/test/browser/useSyncExternalStore.test.js b/compat/test/browser/useSyncExternalStore.test.js index c51760cd24..2f5ab16a03 100644 --- a/compat/test/browser/useSyncExternalStore.test.js +++ b/compat/test/browser/useSyncExternalStore.test.js @@ -658,7 +658,10 @@ describe('useSyncExternalStore', () => { await act(() => { store.set(1); }); - assertLog([1, 1, 'Reset back to 0', 0, 0]); + // Preact logs differ from React here cuz of how we do rerendering. We + // rerender subtrees and then commit effects so Child2 never sees the + // update to 1 cuz Child1 rerenders and runs its layout effects first. + assertLog([1, /*1,*/ 'Reset back to 0', 0, 0]); expect(container.textContent).to.equal('00'); }); diff --git a/devtools/src/devtools.js b/devtools/src/devtools.js index 2bc6e72ff1..078e309f0b 100644 --- a/devtools/src/devtools.js +++ b/devtools/src/devtools.js @@ -2,7 +2,7 @@ import { options, Fragment, Component } from 'preact'; export function initDevTools() { if (typeof window != 'undefined' && window.__PREACT_DEVTOOLS__) { - window.__PREACT_DEVTOOLS__.attachPreact('10.19.6', options, { + window.__PREACT_DEVTOOLS__.attachPreact('10.20.1', options, { Fragment, Component }); diff --git a/hooks/src/index.d.ts b/hooks/src/index.d.ts index 561f034943..3ec8999ca6 100644 --- a/hooks/src/index.d.ts +++ b/hooks/src/index.d.ts @@ -2,20 +2,24 @@ import { ErrorInfo, PreactContext, Ref as PreactRef } from '../..'; type Inputs = ReadonlyArray; -export type StateUpdater = (value: S | ((prevState: S) => S)) => void; +export type Dispatch = (value: A) => void; +export type StateUpdater = S | ((prevState: S) => S); + /** * Returns a stateful value, and a function to update it. * @param initialState The initial value (or a function that returns the initial value) */ -export function useState(initialState: S | (() => S)): [S, StateUpdater]; +export function useState( + initialState: S | (() => S) +): [S, Dispatch>]; export function useState(): [ S | undefined, - StateUpdater + Dispatch> ]; export type Reducer = (prevState: S, action: A) => S; -export type Dispatch = (action: A) => void; + /** * An alternative to `useState`. * diff --git a/hooks/src/index.js b/hooks/src/index.js index f2a0c12922..094410d7a3 100644 --- a/hooks/src/index.js +++ b/hooks/src/index.js @@ -167,7 +167,7 @@ function getHookState(index, type) { /** * @template {unknown} S - * @param {import('./index').StateUpdater} [initialState] + * @param {import('./index').Dispatch>} [initialState] * @returns {[S, (state: S) => void]} */ export function useState(initialState) { @@ -179,7 +179,7 @@ export function useState(initialState) { * @template {unknown} S * @template {unknown} A * @param {import('./index').Reducer} reducer - * @param {import('./index').StateUpdater} initialState + * @param {import('./index').Dispatch>} initialState * @param {(initialState: any) => void} [init] * @returns {[ S, (state: S) => void ]} */ diff --git a/package-lock.json b/package-lock.json index 3582cb76ec..684998501b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "preact", - "version": "10.19.6", + "version": "10.20.1", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "preact", - "version": "10.19.6", + "version": "10.20.1", "license": "MIT", "devDependencies": { "@actions/github": "^5.0.0", diff --git a/package.json b/package.json index 77bc89d011..75c556394e 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "preact", "amdName": "preact", - "version": "10.19.6", + "version": "10.20.1", "private": false, "description": "Fast 3kb React-compatible Virtual DOM library.", "main": "dist/preact.js", diff --git a/src/component.js b/src/component.js index d38f60ca36..4f4e5b2744 100644 --- a/src/component.js +++ b/src/component.js @@ -2,7 +2,7 @@ import { assign } from './util'; import { diff, commitRoot } from './diff/index'; import options from './options'; import { Fragment } from './create-element'; -import { EMPTY_ARR, MODE_HYDRATE } from './constants'; +import { MODE_HYDRATE } from './constants'; /** * Base Component class. Provides `setState()` and `forceUpdate()`, which @@ -120,22 +120,23 @@ export function getDomSibling(vnode, childIndex) { * Trigger in-place re-rendering of a component. * @param {Component} component The component to rerender */ -function renderComponent(component, commitQueue, refQueue) { +function renderComponent(component) { let oldVNode = component._vnode, oldDom = oldVNode._dom, - parentDom = component._parentDom; + commitQueue = [], + refQueue = []; - if (parentDom) { + if (component._parentDom) { const newVNode = assign({}, oldVNode); newVNode._original = oldVNode._original + 1; if (options.vnode) options.vnode(newVNode); diff( - parentDom, + component._parentDom, newVNode, oldVNode, component._globalContext, - parentDom.ownerSVGElement !== undefined, + component._parentDom.ownerSVGElement !== undefined, oldVNode._flags & MODE_HYDRATE ? [oldDom] : null, commitQueue, oldDom == null ? getDomSibling(oldVNode) : oldDom, @@ -145,14 +146,11 @@ function renderComponent(component, commitQueue, refQueue) { newVNode._original = oldVNode._original; newVNode._parent._children[newVNode._index] = newVNode; - - newVNode._nextDom = undefined; + commitRoot(commitQueue, newVNode, refQueue); if (newVNode._dom != oldDom) { updateParentDomPointers(newVNode); } - - return newVNode; } } @@ -222,33 +220,21 @@ const depthSort = (a, b) => a._vnode._depth - b._vnode._depth; /** Flush the render queue by rerendering all queued components */ function process() { let c; - let commitQueue = []; - let refQueue = []; - let root; rerenderQueue.sort(depthSort); // Don't update `renderCount` yet. Keep its value non-zero to prevent unnecessary // process() calls from getting scheduled while `queue` is still being consumed. while ((c = rerenderQueue.shift())) { if (c._dirty) { let renderQueueLength = rerenderQueue.length; - root = renderComponent(c, commitQueue, refQueue) || root; - // If this WAS the last component in the queue, run commit callbacks *before* we exit the tight loop. - // This is required in order for `componentDidMount(){this.setState()}` to be batched into one flush. - // Otherwise, also run commit callbacks if the render queue was mutated. - if (renderQueueLength === 0 || rerenderQueue.length > renderQueueLength) { - commitRoot(commitQueue, root, refQueue); - refQueue.length = commitQueue.length = 0; - root = undefined; + renderComponent(c); + if (rerenderQueue.length > renderQueueLength) { // When i.e. rerendering a provider additional new items can be injected, we want to // keep the order from top to bottom with those new items so we can handle them in a // single pass rerenderQueue.sort(depthSort); - } else if (root) { - if (options._commit) options._commit(root, EMPTY_ARR); } } } - if (root) commitRoot(commitQueue, root, refQueue); process._rerenderCount = 0; } diff --git a/src/diff/children.js b/src/diff/children.js index b44d5168e8..eba002d56d 100644 --- a/src/diff/children.js +++ b/src/diff/children.js @@ -116,6 +116,9 @@ export function diffChildren( childVNode._flags & INSERT_VNODE || oldVNode._children === childVNode._children ) { + if (!newDom && oldVNode._dom == oldDom) { + oldDom = getDomSibling(oldVNode); + } oldDom = insert(childVNode, oldDom, parentDom); } else if ( typeof childVNode.type == 'function' && @@ -241,6 +244,7 @@ function constructNewChildrenArray(newParentVNode, renderResult, oldChildren) { if (oldVNode._dom == newParentVNode._nextDom) { newParentVNode._nextDom = getDomSibling(oldVNode); } + unmount(oldVNode, oldVNode, false); // Explicitly nullify this position in oldChildren instead of just diff --git a/src/diff/index.js b/src/diff/index.js index 16b07395a2..6289a9bd4d 100644 --- a/src/diff/index.js +++ b/src/diff/index.js @@ -311,6 +311,8 @@ export function diff( * @param {VNode} root */ export function commitRoot(commitQueue, root, refQueue) { + root._nextDom = undefined; + for (let i = 0; i < refQueue.length; i++) { applyRef(refQueue[i], refQueue[++i], refQueue[++i]); } @@ -577,7 +579,6 @@ export function unmount(vnode, parentVNode, skipRemove) { } r.base = r._parentDom = null; - vnode._component = undefined; } if ((r = vnode._children)) { @@ -586,7 +587,7 @@ export function unmount(vnode, parentVNode, skipRemove) { unmount( r[i], parentVNode, - skipRemove || typeof vnode.type !== 'function' + skipRemove || typeof vnode.type != 'function' ); } } @@ -598,7 +599,7 @@ export function unmount(vnode, parentVNode, skipRemove) { // Must be set to `undefined` to properly clean up `_nextDom` // for which `null` is a valid value. See comment in `create-element.js` - vnode._parent = vnode._dom = vnode._nextDom = undefined; + vnode._component = vnode._parent = vnode._dom = vnode._nextDom = undefined; } /** The `.render()` method for a PFC backing instance. */ diff --git a/src/diff/props.js b/src/diff/props.js index 75016c4025..722f1a5061 100644 --- a/src/diff/props.js +++ b/src/diff/props.js @@ -13,6 +13,19 @@ function setStyle(style, key, value) { } } +// A logical clock to solve issues like https://github.com/preactjs/preact/issues/3927. +// When the DOM performs an event it leaves micro-ticks in between bubbling up which means that +// an event can trigger on a newly reated DOM-node while the event bubbles up. +// +// Originally inspired by Vue +// (https://github.com/vuejs/core/blob/caeb8a68811a1b0f79/packages/runtime-dom/src/modules/events.ts#L90-L101), +// but modified to use a logical clock instead of Date.now() in case event handlers get attached +// and events get dispatched during the same millisecond. +// +// The clock is incremented after each new event dispatch. This allows 1 000 000 new events +// per second for over 280 years before the value reaches Number.MAX_SAFE_INTEGER (2**53 - 1). +let eventClock = 0; + /** * Set a property value on a DOM node * @param {PreactElement} dom The DOM node to modify @@ -55,7 +68,12 @@ export function setProperty(dom, name, value, oldValue, isSvg) { name !== (name = name.replace(/(PointerCapture)$|Capture$/i, '$1')); // Infer correct casing for DOM built-in events: - if (name.toLowerCase() in dom) name = name.toLowerCase().slice(2); + if ( + name.toLowerCase() in dom || + name === 'onFocusOut' || + name === 'onFocusIn' + ) + name = name.toLowerCase().slice(2); else name = name.slice(2); if (!dom._listeners) dom._listeners = {}; @@ -63,15 +81,21 @@ export function setProperty(dom, name, value, oldValue, isSvg) { if (value) { if (!oldValue) { - value._attached = Date.now(); - const handler = useCapture ? eventProxyCapture : eventProxy; - dom.addEventListener(name, handler, useCapture); + value._attached = eventClock; + dom.addEventListener( + name, + useCapture ? eventProxyCapture : eventProxy, + useCapture + ); } else { value._attached = oldValue._attached; } } else { - const handler = useCapture ? eventProxyCapture : eventProxy; - dom.removeEventListener(name, handler, useCapture); + dom.removeEventListener( + name, + useCapture ? eventProxyCapture : eventProxy, + useCapture + ); } } else { if (isSvg) { @@ -80,18 +104,18 @@ export function setProperty(dom, name, value, oldValue, isSvg) { // - className --> class name = name.replace(/xlink(H|:h)/, 'h').replace(/sName$/, 's'); } else if ( - name !== 'width' && - name !== 'height' && - name !== 'href' && - name !== 'list' && - name !== 'form' && + name != 'width' && + name != 'height' && + name != 'href' && + name != 'list' && + name != 'form' && // Default value in browsers is `-1` and an empty string is // cast to `0` instead - name !== 'tabIndex' && - name !== 'download' && - name !== 'rowSpan' && - name !== 'colSpan' && - name !== 'role' && + name != 'tabIndex' && + name != 'download' && + name != 'rowSpan' && + name != 'colSpan' && + name != 'role' && name in dom ) { try { @@ -119,38 +143,32 @@ export function setProperty(dom, name, value, oldValue, isSvg) { } /** - * Proxy an event to hooked event handlers - * @param {PreactEvent} e The event object from the browser + * Create an event proxy function. + * @param {boolean} useCapture Is the event handler for the capture phase. * @private */ -function eventProxy(e) { - if (this._listeners) { - const eventHandler = this._listeners[e.type + false]; - /** - * This trick is inspired by Vue https://github.com/vuejs/core/blob/main/packages/runtime-dom/src/modules/events.ts#L90-L101 - * when the dom performs an event it leaves micro-ticks in between bubbling up which means that an event can trigger on a newly - * created DOM-node while the event bubbles up, this can cause quirky behavior as seen in https://github.com/preactjs/preact/issues/3927 - */ - if (!e._dispatched) { - // When an event has no _dispatched we know this is the first event-target in the chain - // so we set the initial dispatched time. - e._dispatched = Date.now(); - // When the _dispatched is smaller than the time when the targetted event handler was attached - // we know we have bubbled up to an element that was added during patching the dom. - } else if (e._dispatched <= eventHandler._attached) { - return; +function createEventProxy(useCapture) { + /** + * Proxy an event to hooked event handlers + * @param {PreactEvent} e The event object from the browser + * @private + */ + return function (e) { + if (this._listeners) { + const eventHandler = this._listeners[e.type + useCapture]; + if (e._dispatched == null) { + e._dispatched = eventClock++; + + // When `e._dispatched` is smaller than the time when the targeted event + // handler was attached we know we have bubbled up to an element that was added + // during patching the DOM. + } else if (e._dispatched < eventHandler._attached) { + return; + } + return eventHandler(options.event ? options.event(e) : e); } - return eventHandler(options.event ? options.event(e) : e); - } + }; } -/** - * Proxy an event to hooked event handlers - * @param {PreactEvent} e The event object from the browser - * @private - */ -function eventProxyCapture(e) { - if (this._listeners) { - return this._listeners[e.type + true](options.event ? options.event(e) : e); - } -} +const eventProxy = createEventProxy(false); +const eventProxyCapture = createEventProxy(true); diff --git a/src/jsx.d.ts b/src/jsx.d.ts index c58ac48e69..2accfbac89 100644 --- a/src/jsx.d.ts +++ b/src/jsx.d.ts @@ -1563,10 +1563,10 @@ export namespace JSXInternal { // Focus Events onFocus?: FocusEventHandler | undefined; onFocusCapture?: FocusEventHandler | undefined; - onfocusin?: FocusEventHandler | undefined; - onfocusinCapture?: FocusEventHandler | undefined; - onfocusout?: FocusEventHandler | undefined; - onfocusoutCapture?: FocusEventHandler | undefined; + onFocusIn?: FocusEventHandler | undefined; + onFocusInCapture?: FocusEventHandler | undefined; + onFocusOut?: FocusEventHandler | undefined; + onFocusOutCapture?: FocusEventHandler | undefined; onBlur?: FocusEventHandler | undefined; onBlurCapture?: FocusEventHandler | undefined; @@ -1718,6 +1718,7 @@ export namespace JSXInternal { // UI Events onScroll?: UIEventHandler | undefined; + onScrollEnd?: UIEventHandler | undefined; onScrollCapture?: UIEventHandler | undefined; // Wheel Events diff --git a/src/render.js b/src/render.js index b550ef96af..1ee326bc92 100644 --- a/src/render.js +++ b/src/render.js @@ -59,7 +59,7 @@ export function render(vnode, parentDom, replaceNode) { refQueue ); - vnode._nextDom = undefined; + // Flush all queued effects commitRoot(commitQueue, vnode, refQueue); } diff --git a/test/browser/events.test.js b/test/browser/events.test.js index 8a2732adf9..ef4f990e49 100644 --- a/test/browser/events.test.js +++ b/test/browser/events.test.js @@ -227,4 +227,12 @@ describe('event handling', () => { .to.have.been.calledTwice.and.to.have.been.calledWith('gotpointercapture') .and.calledWith('lostpointercapture'); }); + + it('should support camel-case focus event names', () => { + render(
{}} onFocusOut={() => {}} />, scratch); + + expect(proto.addEventListener) + .to.have.been.calledTwice.and.to.have.been.calledWith('focusin') + .and.calledWith('focusout'); + }); }); diff --git a/test/browser/render.test.js b/test/browser/render.test.js index ae42ce2720..822f864f21 100644 --- a/test/browser/render.test.js +++ b/test/browser/render.test.js @@ -1481,8 +1481,8 @@ describe('render()', () => { expect(serializeHtml(scratch)).to.equal( '

_B1

_B2

_B3

_B4

_B5

_B6

_B7

_B8

_B9

_B10

_B11

_B12

_B13

' - ); - }); + ); + }); it('should not crash or repeatedly add the same child when replacing a matched vnode with null (mixed dom-types)', () => { const B = () =>
B
; @@ -1537,4 +1537,48 @@ describe('render()', () => { '
A
B
C
' ); }); + + it('should shrink lists', () => { + function RenderedItem({ item }) { + if (item.renderAsNullInComponent) { + return null; + } + + return
{item.id}
; + } + + function App({ list }) { + return ( +
+ {list.map(item => ( + + ))} +
+ ); + } + + const firstList = [ + { id: 'One' }, + { id: 'Two' }, + { id: 'Three' }, + { id: 'Four' } + ]; + + const secondList = [ + { id: 'One' }, + { id: 'Four', renderAsNullInComponent: true }, + { id: 'Six' }, + { id: 'Seven' } + ]; + + render(, scratch); + expect(scratch.innerHTML).to.equal( + '
One
Two
Three
Four
' + ); + + render(, scratch); + expect(scratch.innerHTML).to.equal( + '
One
Six
Seven
' + ); + }); });