diff --git a/compat/src/index.d.ts b/compat/src/index.d.ts index 45b7a177de..eaf108fee1 100644 --- a/compat/src/index.d.ts +++ b/compat/src/index.d.ts @@ -24,6 +24,7 @@ declare namespace React { export import useDebugValue = _hooks.useDebugValue; export import useEffect = _hooks.useEffect; export import useImperativeHandle = _hooks.useImperativeHandle; + export import useId = _hooks.useId; export import useLayoutEffect = _hooks.useLayoutEffect; export import useMemo = _hooks.useMemo; export import useReducer = _hooks.useReducer; diff --git a/compat/src/index.js b/compat/src/index.js index 49c67a7aa8..6b9988951c 100644 --- a/compat/src/index.js +++ b/compat/src/index.js @@ -9,6 +9,7 @@ import { } from 'preact'; import { useState, + useId, useReducer, useEffect, useLayoutEffect, @@ -201,6 +202,7 @@ export { // React copies the named exports to the default one. export default { useState, + useId, useReducer, useEffect, useLayoutEffect, diff --git a/hooks/src/index.d.ts b/hooks/src/index.d.ts index 72322bd434..dfec612eaa 100644 --- a/hooks/src/index.d.ts +++ b/hooks/src/index.d.ts @@ -137,3 +137,5 @@ export function useDebugValue(value: T, formatter?: (value: T) => any): void; export function useErrorBoundary( callback?: (error: any, errorInfo: ErrorInfo) => Promise | void ): [any, () => void]; + +export function useId(): string; diff --git a/hooks/src/index.js b/hooks/src/index.js index ffb7899822..40e1366cb0 100644 --- a/hooks/src/index.js +++ b/hooks/src/index.js @@ -1,4 +1,4 @@ -import { options } from 'preact'; +import { Fragment, options } from 'preact'; /** @type {number} */ let currentIndex; @@ -27,6 +27,21 @@ const RAF_TIMEOUT = 100; let prevRaf; options._diff = vnode => { + if ( + typeof vnode.type === 'function' && + !vnode._mask && + vnode.type !== Fragment + ) { + vnode._mask = + (vnode._parent && vnode._parent._mask ? vnode._parent._mask : '') + + (vnode._parent && vnode._parent._children + ? vnode._parent._children.indexOf(vnode) + : 0); + } else if (!vnode._mask) { + vnode._mask = + vnode._parent && vnode._parent._mask ? vnode._parent._mask : ''; + } + currentComponent = null; if (oldBeforeDiff) oldBeforeDiff(vnode); }; @@ -366,6 +381,23 @@ export function useErrorBoundary(cb) { ]; } +function hash(s) { + let h = 0, + i = s.length; + while (i > 0) { + h = ((h << 5) - h + s.charCodeAt(--i)) | 0; + } + return h; +} + +export function useId() { + const state = getHookState(currentIndex++, 11); + if (!state._value) { + state._value = 'P' + hash(currentComponent._vnode._mask) + currentIndex; + } + + return state._value; +} /** * After paint effects consumer. */ diff --git a/hooks/src/internal.d.ts b/hooks/src/internal.d.ts index 1bbfaaf093..58a2741384 100644 --- a/hooks/src/internal.d.ts +++ b/hooks/src/internal.d.ts @@ -1,7 +1,8 @@ import { Component as PreactComponent, PreactContext, - ErrorInfo + ErrorInfo, + VNode as PreactVNode } from '../../src/internal'; import { Reducer } from '.'; @@ -37,6 +38,10 @@ export interface Component extends PreactComponent { __hooks?: ComponentHooks; } +export interface VNode extends PreactVNode { + _mask?: string; +} + export type HookState = | EffectHookState | MemoHookState diff --git a/hooks/test/browser/useId.test.js b/hooks/test/browser/useId.test.js new file mode 100644 index 0000000000..53f3b893af --- /dev/null +++ b/hooks/test/browser/useId.test.js @@ -0,0 +1,132 @@ +import { createElement, render } from 'preact'; +import { useId, useState } from 'preact/hooks'; +import { setupRerender } from 'preact/test-utils'; +import { setupScratch, teardown } from '../../../test/_util/helpers'; + +/** @jsx createElement */ + +describe('useId', () => { + /** @type {HTMLDivElement} */ + let scratch, rerender; + + beforeEach(() => { + scratch = setupScratch(); + rerender = setupRerender(); + }); + + afterEach(() => { + teardown(scratch); + }); + + it('keeps the id consistent after an update', () => { + function Comp() { + const id = useId(); + return
; + } + + render(, scratch); + const id = scratch.firstChild.getAttribute('id'); + expect(scratch.firstChild.getAttribute('id')).to.equal(id); + + render(, scratch); + expect(scratch.firstChild.getAttribute('id')).to.equal(id); + }); + + it('ids are unique according to dom-depth', () => { + function Child() { + const id = useId(); + const spanId = useId(); + return ( +
+ h +
+ ); + } + + function Comp() { + const id = useId(); + return ( +
+ +
+ ); + } + + render(, scratch); + expect(scratch.innerHTML).to.equal( + '
h
' + ); + + render(, scratch); + expect(scratch.innerHTML).to.equal( + '
h
' + ); + }); + + it('ids are unique across siblings', () => { + function Child() { + const id = useId(); + return h; + } + + function Comp() { + const id = useId(); + return ( +
+ + + +
+ ); + } + + render(, scratch); + expect(scratch.innerHTML).to.equal( + '
hhh
' + ); + + render(, scratch); + expect(scratch.innerHTML).to.equal( + '
hhh
' + ); + }); + + it('correctly handles new elements', () => { + let set; + function Child() { + const id = useId(); + return h; + } + + function Stateful() { + const [state, setState] = useState(false); + set = setState; + return ( +
+ + {state && } +
+ ); + } + + function Comp() { + const id = useId(); + return ( +
+ +
+ ); + } + + render(, scratch); + expect(scratch.innerHTML).to.equal( + '
h
' + ); + + set(true); + rerender(); + expect(scratch.innerHTML).to.equal( + '
hh
' + ); + }); +});