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 fe32b22d52..7577d2e7a5 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..62b2439f72 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,10 +120,12 @@ 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; + parentDom = component._parentDom, + commitQueue = [], + refQueue = []; if (parentDom) { const newVNode = assign({}, oldVNode); @@ -145,14 +147,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 +221,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..b5bbb49db6 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]); } diff --git a/src/diff/props.js b/src/diff/props.js index 239ca51569..3a16fef7bb 100644 --- a/src/diff/props.js +++ b/src/diff/props.js @@ -67,7 +67,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 = {}; 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
' + ); + }); });