diff --git a/.dumirc.ts b/.dumirc.ts index 6d269dd..72baed9 100644 --- a/.dumirc.ts +++ b/.dumirc.ts @@ -2,7 +2,7 @@ import { defineConfig } from 'dumi'; export default defineConfig({ themeConfig: { - name: 'Align', + name: 'Context', }, mfsu: false, }); \ No newline at end of file diff --git a/HISTORY.md b/HISTORY.md deleted file mode 100644 index 383ae19..0000000 --- a/HISTORY.md +++ /dev/null @@ -1,14 +0,0 @@ -# History ----- - -## 2.4.0 / 2018-06-04 - -- support point align - -## 2.3.4 / 2017-04-17 - -- fix `createClass` and `PropTypes` warning. - -## 2.3.0 / 2016-05-26 - -- add forceAlign method diff --git a/docs/demo/follow.md b/docs/demo/follow.md deleted file mode 100644 index c75cf5b..0000000 --- a/docs/demo/follow.md +++ /dev/null @@ -1,8 +0,0 @@ ---- -title: Follow -nav: - title: Demo - path: /demo ---- - - \ No newline at end of file diff --git a/docs/demo/immutable.md b/docs/demo/immutable.md new file mode 100644 index 0000000..c268809 --- /dev/null +++ b/docs/demo/immutable.md @@ -0,0 +1,8 @@ +--- +title: Immutable +nav: + title: Demo + path: /demo +--- + + \ No newline at end of file diff --git a/docs/demo/point.md b/docs/demo/point.md deleted file mode 100644 index 1a62de8..0000000 --- a/docs/demo/point.md +++ /dev/null @@ -1,8 +0,0 @@ ---- -title: Point -nav: - title: Demo - path: /demo ---- - - \ No newline at end of file diff --git a/docs/examples/follow.tsx b/docs/examples/follow.tsx deleted file mode 100644 index 63928d5..0000000 --- a/docs/examples/follow.tsx +++ /dev/null @@ -1,92 +0,0 @@ -import React from 'react'; -import Align from '../../src'; - -const Demo = () => { - const [width, setWidth] = React.useState(100); - const [height, setHeight] = React.useState(100); - const [left, setLeft] = React.useState(100); - const [top, setTop] = React.useState(100); - const [visible, setVisible] = React.useState(true); - const [svg, setSvg] = React.useState(false); - - const sharedStyle: React.CSSProperties = { - width, - height, - position: 'absolute', - left, - top, - display: visible ? 'flex' : 'none', - }; - - return ( -
- - - - -
- {svg ? ( - - - - ) : ( -
- Content -
- )} - - document.getElementById('content')} align={{ points: ['tc', 'bc'] }}> -
- Popup -
-
-
-
- ); -}; - -export default Demo; diff --git a/docs/examples/immutable.tsx b/docs/examples/immutable.tsx new file mode 100644 index 0000000..46d5cd6 --- /dev/null +++ b/docs/examples/immutable.tsx @@ -0,0 +1,66 @@ +import { + createContext, + makeImmutable, + responseImmutable, + useContext, +} from '@rc-component/context'; +import React from 'react'; +import useRenderTimes from './useRenderTimes'; + +const AppContext = createContext<{ + appCnt: number; + appUpdateCnt: number; +}>(); + +const MyApp = ({ rootCnt, children }: { rootCnt: number; children?: React.ReactNode }) => { + const [appCnt, setAppCnt] = React.useState(0); + const [appUpdateCnt, setAppUpdateCnt] = React.useState(0); + const renderTimes = useRenderTimes(); + + return ( + + + + App Render Times: {renderTimes} / Root CNT: {rootCnt} +
{children}
+
+ ); +}; +const ImmutableMyApp = makeImmutable(MyApp); + +const MyComponent = ({ name }: { name: any }) => { + const renderTimes = useRenderTimes(); + const value = useContext(AppContext, name); + + return ( +
+ {name}: {value} / Component Render Times: {renderTimes} +
+ ); +}; +const ImmutableMyComponent = responseImmutable(MyComponent); + +export default () => { + const [rootCnt, setRootCnt] = React.useState(0); + const renderTimes = useRenderTimes(); + + return ( + <> + {' '} + + Root RenderTimes: {renderTimes}{' '} +
+ + + + +
+ + ); +}; diff --git a/docs/examples/point.tsx b/docs/examples/point.tsx deleted file mode 100644 index 9623b1f..0000000 --- a/docs/examples/point.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import React, { Component } from 'react'; -import Align from '../../src'; - -const align = { - points: ['cc', 'cc'], -}; - -class Demo extends Component { - state = { - point: null, - }; - - onClick = ({ pageX, pageY }) => { - this.setState({ point: { pageX, pageY } }); - }; - - render() { - return ( -
-
- Click this region please : ) -
- - -
- Align -
-
-
- ); - } -} - -export default Demo; diff --git a/docs/examples/simple.tsx b/docs/examples/simple.tsx index 05c6be9..7ef11db 100644 --- a/docs/examples/simple.tsx +++ b/docs/examples/simple.tsx @@ -1,176 +1,40 @@ -import Align, { type RefAlign } from 'rc-align'; -import React, { Component } from 'react'; - -const allPoints = ['tl', 'tc', 'tr', 'cl', 'cc', 'cr', 'bl', 'bc', 'br']; - -interface TestState { - monitor: boolean; - random: boolean; - disabled: boolean; - randomWidth: number; - align: any; - sourceWidth: number; -} - -class Test extends Component<{}, TestState> { - state = { - monitor: true, - random: false, - disabled: false, - randomWidth: 100, - align: { - points: ['cc', 'cc'], - }, - sourceWidth: 50, - }; - - id: NodeJS.Timer; - $container: HTMLElement; - $align: RefAlign; - - componentDidMount() { - this.id = setInterval(() => { - const { random } = this.state; - if (random) { - this.setState({ - randomWidth: 60 + 40 * Math.random(), - }); - } - }, 1000); - } - - componentWillUnmount() { - clearInterval(this.id); - } - - getTarget = () => { - if (!this.$container) { - // parent ref not attached - this.$container = document.getElementById('container'); - } - return this.$container; - }; - - containerRef = ele => { - this.$container = ele; - }; - - alignRef = node => { - this.$align = node; - }; - - toggleMonitor = () => { - this.setState(({ monitor }) => ({ - monitor: !monitor, - })); - }; - - toggleRandom = () => { - this.setState(({ random }) => ({ - random: !random, - })); - }; - - toggleDisabled = () => { - this.setState(({ disabled }) => ({ - disabled: !disabled, - })); - }; - - randomAlign = () => { - const randomPoints = []; - randomPoints.push(allPoints[Math.floor(Math.random() * 100) % allPoints.length]); - randomPoints.push(allPoints[Math.floor(Math.random() * 100) % allPoints.length]); - this.setState({ - align: { - points: randomPoints, - }, - }); - }; - - forceAlign = () => { - this.$align.forceAlign(); - }; - - toggleSourceSize = () => { - this.setState({ - // eslint-disable-next-line react/no-access-state-in-setstate - sourceWidth: this.state.sourceWidth + 10, - }); - }; - - render() { - const { random, randomWidth } = this.state; - - return ( -
-

- -     - -     - -     - - - -

-
- -
- -
-
-
-
- ); - } -} - -export default Test; +import { createContext, useContext } from '@rc-component/context'; +import React from 'react'; +import useRenderTimes from './useRenderTimes'; + +const CountContext = createContext<{ + cnt1: number; + cnt2: number; +}>(); + +const MyConsumer = React.memo(({ name }: { name: any }) => { + const value = useContext(CountContext, name); + const renderTimes = useRenderTimes(); + + return ( +
+ + {value} ({renderTimes} times) +
+ ); +}); + +export default () => { + const [cnt1, setCnt1] = React.useState(0); + const [cnt2, setCnt2] = React.useState(0); + const renderTimes = useRenderTimes(); + + return ( + + + + {renderTimes} times + + + + ); +}; diff --git a/docs/examples/useRenderTimes.ts b/docs/examples/useRenderTimes.ts new file mode 100644 index 0000000..d9bfa0f --- /dev/null +++ b/docs/examples/useRenderTimes.ts @@ -0,0 +1,10 @@ +import React from 'react'; + +const useRenderTimes = () => { + const renderRef = React.useRef(0); + renderRef.current += 1; + + return renderRef.current; +}; + +export default useRenderTimes; diff --git a/docs/index.md b/docs/index.md index 59385d5..4f52b4f 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,7 +1,7 @@ --- hero: - title: rc-align - description: align ui component for react + title: @rc-component/context + description: Context Selector for perf enhancement --- \ No newline at end of file diff --git a/index.js b/index.js deleted file mode 100644 index 79eb820..0000000 --- a/index.js +++ /dev/null @@ -1,5 +0,0 @@ -'use strict'; - -// export this package's api -import Align from './src/'; -export default Align; diff --git a/now.json b/now.json index 15f6864..0b0bc3f 100644 --- a/now.json +++ b/now.json @@ -1,6 +1,6 @@ { "version": 2, - "name": "rc-align", + "name": "rc-context", "builds": [ { "src": "package.json", diff --git a/package.json b/package.json index 8d8fcec..40e6443 100644 --- a/package.json +++ b/package.json @@ -1,20 +1,19 @@ { - "name": "rc-align", - "version": "4.0.15", - "description": "align ui component for react", + "name": "@rc-component/context", + "version": "1.0.0-alpha.0", + "description": "React way perf context selector", "keywords": [ "react", "react-component", - "react-align", - "align" + "context" ], - "homepage": "http://github.com/react-component/align", + "homepage": "http://github.com/react-component/context", "bugs": { - "url": "http://github.com/react-component/align/issues" + "url": "http://github.com/react-component/context/issues" }, "repository": { "type": "git", - "url": "git@github.com:react-component/align.git" + "url": "git@github.com:react-component/context.git" }, "license": "MIT", "author": "", @@ -37,10 +36,7 @@ }, "dependencies": { "@babel/runtime": "^7.10.1", - "classnames": "2.x", - "dom-align": "^1.7.0", - "rc-util": "^5.26.0", - "resize-observer-polyfill": "^1.5.1" + "rc-util": "^5.27.0" }, "devDependencies": { "@rc-component/father-plugin": "^1.0.0", diff --git a/src/Align.tsx b/src/Align.tsx deleted file mode 100644 index 3a075da..0000000 --- a/src/Align.tsx +++ /dev/null @@ -1,187 +0,0 @@ -/** - * Removed props: - * - childrenProps - */ - -import { alignElement, alignPoint } from 'dom-align'; -import isEqual from 'rc-util/lib/isEqual'; -import addEventListener from 'rc-util/lib/Dom/addEventListener'; -import isVisible from 'rc-util/lib/Dom/isVisible'; -import useLayoutEffect from 'rc-util/lib/hooks/useLayoutEffect'; -import { composeRef } from 'rc-util/lib/ref'; -import React from 'react'; - -import useBuffer from './hooks/useBuffer'; -import type { AlignResult, AlignType, TargetPoint, TargetType } from './interface'; -import { isSamePoint, monitorResize, restoreFocus } from './util'; - -type OnAlign = (source: HTMLElement, result: AlignResult) => void; - -export interface AlignProps { - align: AlignType; - target: TargetType; - onAlign?: OnAlign; - monitorBufferTime?: number; - monitorWindowResize?: boolean; - disabled?: boolean; - children: React.ReactElement; -} - -export interface RefAlign { - forceAlign: () => void; -} - -function getElement(func: TargetType) { - if (typeof func !== 'function') return null; - return func(); -} - -function getPoint(point: TargetType) { - if (typeof point !== 'object' || !point) return null; - return point; -} - -const Align: React.ForwardRefRenderFunction = ( - { children, disabled, target, align, onAlign, monitorWindowResize, monitorBufferTime = 0 }, - ref, -) => { - const cacheRef = React.useRef<{ element?: HTMLElement; point?: TargetPoint; align?: AlignType }>( - {}, - ); - - /** Popup node ref */ - const nodeRef = React.useRef(); - let childNode = React.Children.only(children); - - // ===================== Align ====================== - // We save the props here to avoid closure makes props ood - const forceAlignPropsRef = React.useRef<{ - disabled?: boolean; - target?: TargetType; - align?: AlignType; - onAlign?: OnAlign; - }>({}); - forceAlignPropsRef.current.disabled = disabled; - forceAlignPropsRef.current.target = target; - forceAlignPropsRef.current.align = align; - forceAlignPropsRef.current.onAlign = onAlign; - - const [forceAlign, cancelForceAlign] = useBuffer(() => { - const { - disabled: latestDisabled, - target: latestTarget, - align: latestAlign, - onAlign: latestOnAlign, - } = forceAlignPropsRef.current; - - const source = nodeRef.current; - - if (!latestDisabled && latestTarget && source) { - let result: AlignResult; - const element = getElement(latestTarget); - const point = getPoint(latestTarget); - - cacheRef.current.element = element; - cacheRef.current.point = point; - cacheRef.current.align = latestAlign; - - // IE lose focus after element realign - // We should record activeElement and restore later - const { activeElement } = document; - - // We only align when element is visible - if (element && isVisible(element)) { - result = alignElement(source, element, latestAlign); - } else if (point) { - result = alignPoint(source, point, latestAlign); - } - - restoreFocus(activeElement, source); - - if (latestOnAlign && result) { - latestOnAlign(source, result); - } - - return true; - } - - return false; - }, monitorBufferTime); - - // ===================== Effect ===================== - // Handle props change - const [element, setElement] = React.useState(); - const [point, setPoint] = React.useState(); - - useLayoutEffect(() => { - setElement(getElement(target)); - setPoint(getPoint(target)); - }); - - React.useEffect(() => { - if ( - cacheRef.current.element !== element || - !isSamePoint(cacheRef.current.point, point) || - !isEqual(cacheRef.current.align, align) - ) { - forceAlign(); - } - }); - - // Watch popup element resize - React.useEffect(() => { - const cancelFn = monitorResize(nodeRef.current, forceAlign); - return cancelFn; - }, [nodeRef.current]); - - // Watch target element resize - React.useEffect(() => { - const cancelFn = monitorResize(element, forceAlign); - return cancelFn; - }, [element]); - - // Listen for disabled change - React.useEffect(() => { - if (!disabled) { - forceAlign(); - } else { - cancelForceAlign(); - } - }, [disabled]); - - // Listen for window resize - React.useEffect(() => { - if (monitorWindowResize) { - const cancelFn = addEventListener(window, 'resize', forceAlign); - - return cancelFn.remove; - } - }, [monitorWindowResize]); - - // Clear all if unmount - React.useEffect( - () => () => { - cancelForceAlign(); - }, - [], - ); - - // ====================== Ref ======================= - React.useImperativeHandle(ref, () => ({ - forceAlign: () => forceAlign(true), - })); - - // ===================== Render ===================== - if (React.isValidElement(childNode)) { - childNode = React.cloneElement(childNode, { - ref: composeRef((childNode as any).ref, nodeRef), - }); - } - - return childNode; -}; - -const RcAlign = React.forwardRef(Align); -RcAlign.displayName = 'Align'; - -export default RcAlign; diff --git a/src/Immutable.tsx b/src/Immutable.tsx new file mode 100644 index 0000000..64cd9f1 --- /dev/null +++ b/src/Immutable.tsx @@ -0,0 +1,56 @@ +import { supportRef } from 'rc-util/lib/ref'; +import * as React from 'react'; + +const RenderContext = React.createContext(0); + +/** + * Wrapped Component will be marked as Immutable. + * When Component parent trigger render, + * it will notice children component (use with `responseImmutable`) node that parent has updated. + */ +export function makeImmutable>(Component: T): T { + const refAble = supportRef(Component); + + const ImmutableComponent = function (props: any, ref: any) { + const refProps = refAble ? { ref } : {}; + const renderTimesRef = React.useRef(0); + renderTimesRef.current += 1; + + return ( + + + + ); + }; + + if (process.env.NODE_ENV !== 'production') { + ImmutableComponent.displayName = `ImmutableRoot(${Component.displayName || Component.name})`; + } + + return refAble ? React.forwardRef(ImmutableComponent) : (ImmutableComponent as any); +} + +/** + * Wrapped Component with `React.memo`. + * But will rerender when parent with `makeImmutable` rerender. + */ +export function responseImmutable>(Component: T): T { + const refAble = supportRef(Component); + + const ImmutableComponent = function (props: any, ref: any) { + const refProps = refAble ? { ref } : {}; + React.useContext(RenderContext); + + return ; + }; + + if (process.env.NODE_ENV !== 'production') { + ImmutableComponent.displayName = `ImmutableResponse(${ + Component.displayName || Component.name + })`; + } + + return refAble + ? React.memo(React.forwardRef(ImmutableComponent)) + : (React.memo(ImmutableComponent) as any); +} diff --git a/src/context.tsx b/src/context.tsx new file mode 100644 index 0000000..68f6c57 --- /dev/null +++ b/src/context.tsx @@ -0,0 +1,130 @@ +import useEvent from 'rc-util/lib/hooks/useEvent'; +import useLayoutEffect from 'rc-util/lib/hooks/useLayoutEffect'; +import isEqual from 'rc-util/lib/isEqual'; +import * as React from 'react'; +import { unstable_batchedUpdates } from 'react-dom'; + +export type Selector = ( + value: ContextProps, +) => SelectorValue; + +export type Trigger = (value: ContextProps) => void; + +export type Listeners = Set>; + +export interface Context { + getValue: () => ContextProps; + listeners: Listeners; +} + +export interface ContextSelectorProviderProps { + value: T; + children?: React.ReactNode; +} + +export interface SelectorContext { + Context: React.Context>; + Provider: React.ComponentType>; + defaultValue?: ContextProps; +} + +export function createContext( + defaultValue?: ContextProps, +): SelectorContext { + const Context = React.createContext>(undefined); + + const Provider = ({ value, children }: ContextSelectorProviderProps) => { + const valueRef = React.useRef(value); + valueRef.current = value; + + const [context] = React.useState>(() => ({ + getValue: () => valueRef.current, + listeners: new Set(), + })); + + useLayoutEffect(() => { + unstable_batchedUpdates(() => { + context.listeners.forEach(listener => { + listener(value); + }); + }); + }, [value]); + + return {children}; + }; + + return { Context, Provider, defaultValue }; +} + +/** e.g. useSelect(userContext) => user */ +export function useContext(holder: SelectorContext): ContextProps; + +/** e.g. useSelect(userContext, user => user.name) => user.name */ +export function useContext( + holder: SelectorContext, + selector: Selector, +): SelectorValue; + +/** e.g. useSelect(userContext, ['name', 'age']) => user { name, age } */ +export function useContext>( + holder: SelectorContext, + selector: (keyof ContextProps)[], +): SelectorValue; + +/** e.g. useSelect(userContext, 'name') => user.name */ +export function useContext( + holder: SelectorContext, + selector: PropName, +): ContextProps[PropName]; + +export function useContext( + holder: SelectorContext, + selector?: Selector | (keyof ContextProps)[] | keyof ContextProps, +) { + const eventSelector = useEvent>( + typeof selector === 'function' + ? selector + : ctx => { + if (selector === undefined) { + return ctx; + } + + if (!Array.isArray(selector)) { + return ctx[selector]; + } + + const obj = {} as SelectorValue; + selector.forEach(key => { + (obj as any)[key] = ctx[key]; + }); + return obj; + }, + ); + const context = React.useContext(holder?.Context); + const { listeners, getValue } = context || {}; + + const valueRef = React.useRef(); + valueRef.current = eventSelector(context ? getValue() : holder?.defaultValue); + const [, forceUpdate] = React.useState({}); + + useLayoutEffect(() => { + if (!context) { + return; + } + + function trigger(nextValue: ContextProps) { + const nextSelectorValue = eventSelector(nextValue); + if (!isEqual(valueRef.current, nextSelectorValue, true)) { + forceUpdate({}); + } + } + + listeners.add(trigger); + + return () => { + listeners.delete(trigger); + }; + }, [context]); + + return valueRef.current; +} diff --git a/src/hooks/useBuffer.tsx b/src/hooks/useBuffer.tsx deleted file mode 100644 index 72cc711..0000000 --- a/src/hooks/useBuffer.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import React from 'react'; - -export default (callback: (force?: boolean) => boolean, buffer: number) => { - const calledRef = React.useRef(false); - const timeoutRef = React.useRef(null); - - function cancelTrigger() { - window.clearTimeout(timeoutRef.current); - } - - function trigger(force?: boolean) { - cancelTrigger(); - - if (!calledRef.current || force === true) { - if (callback(force) === false) { - // Not delay since callback cancelled self - return; - } - - calledRef.current = true; - timeoutRef.current = window.setTimeout(() => { - calledRef.current = false; - }, buffer); - } else { - timeoutRef.current = window.setTimeout(() => { - calledRef.current = false; - trigger(); - }, buffer); - } - } - - return [ - trigger, - () => { - calledRef.current = false; - cancelTrigger(); - }, - ]; -}; diff --git a/src/index.ts b/src/index.ts index 020f8e8..0adb1aa 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,6 @@ -// export this package's api -import Align from './Align'; +import type { SelectorContext } from './context'; +import { createContext, useContext } from './context'; +import { makeImmutable, responseImmutable } from './Immutable'; -export type { RefAlign } from './Align'; - -export default Align; +export { createContext, useContext, makeImmutable, responseImmutable }; +export type { SelectorContext }; diff --git a/src/interface.ts b/src/interface.ts deleted file mode 100644 index ab91f17..0000000 --- a/src/interface.ts +++ /dev/null @@ -1,60 +0,0 @@ -/** Two char of 't' 'b' 'c' 'l' 'r'. Example: 'lt' */ -export type AlignPoint = string; - -export interface AlignType { - /** - * move point of source node to align with point of target node. - * Such as ['tr','cc'], align top right point of source node with center point of target node. - * Point can be 't'(top), 'b'(bottom), 'c'(center), 'l'(left), 'r'(right) */ - points?: AlignPoint[]; - /** - * offset source node by offset[0] in x and offset[1] in y. - * If offset contains percentage string value, it is relative to sourceNode region. - */ - offset?: number[]; - /** - * offset target node by offset[0] in x and offset[1] in y. - * If targetOffset contains percentage string value, it is relative to targetNode region. - */ - targetOffset?: number[]; - /** - * If adjustX field is true, will adjust source node in x direction if source node is invisible. - * If adjustY field is true, will adjust source node in y direction if source node is invisible. - */ - overflow?: { - adjustX?: boolean | number; - adjustY?: boolean | number; - }; - /** - * Whether use css right instead of left to position - */ - useCssRight?: boolean; - /** - * Whether use css bottom instead of top to position - */ - useCssBottom?: boolean; - /** - * Whether use css transform instead of left/top/right/bottom to position if browser supports. - * Defaults to false. - */ - useCssTransform?: boolean; -} - -export interface AlignResult { - points: AlignPoint[]; - offset: number[]; - targetOffset: number[]; - overflow: { - adjustX: boolean | number; - adjustY: boolean | number; - }; -} - -export interface TargetPoint { - clientX?: number; - clientY?: number; - pageX?: number; - pageY?: number; -} - -export type TargetType = (() => HTMLElement) | TargetPoint; diff --git a/src/util.ts b/src/util.ts deleted file mode 100644 index 30b1f4b..0000000 --- a/src/util.ts +++ /dev/null @@ -1,60 +0,0 @@ -import ResizeObserver from 'resize-observer-polyfill'; -import contains from 'rc-util/lib/Dom/contains'; -import type { TargetPoint } from './interface'; - -export function isSamePoint(prev: TargetPoint, next: TargetPoint) { - if (prev === next) return true; - if (!prev || !next) return false; - - if ('pageX' in next && 'pageY' in next) { - return prev.pageX === next.pageX && prev.pageY === next.pageY; - } - - if ('clientX' in next && 'clientY' in next) { - return prev.clientX === next.clientX && prev.clientY === next.clientY; - } - - return false; -} - -export function restoreFocus(activeElement, container) { - // Focus back if is in the container - if ( - activeElement !== document.activeElement && - contains(container, activeElement) && - typeof activeElement.focus === 'function' - ) { - activeElement.focus(); - } -} - -export function monitorResize(element: HTMLElement, callback: Function) { - let prevWidth: number = null; - let prevHeight: number = null; - - function onResize([{ target }]: ResizeObserverEntry[]) { - if (!document.documentElement.contains(target)) return; - const { width, height } = target.getBoundingClientRect(); - const fixedWidth = Math.floor(width); - const fixedHeight = Math.floor(height); - - if (prevWidth !== fixedWidth || prevHeight !== fixedHeight) { - // https://webkit.org/blog/9997/resizeobserver-in-webkit/ - Promise.resolve().then(() => { - callback({ width: fixedWidth, height: fixedHeight }); - }); - } - - prevWidth = fixedWidth; - prevHeight = fixedHeight; - } - - const resizeObserver = new ResizeObserver(onResize); - if (element) { - resizeObserver.observe(element); - } - - return () => { - resizeObserver.disconnect(); - }; -} diff --git a/tests/common.tsx b/tests/common.tsx new file mode 100644 index 0000000..1bae1bb --- /dev/null +++ b/tests/common.tsx @@ -0,0 +1,28 @@ +import React from 'react'; + +export const useRenderTimes = () => { + const renderRef = React.useRef(0); + renderRef.current += 1; + + return renderRef.current; +}; + +export function RenderTimer({ id }: { id?: string }) { + const renderTimes = useRenderTimes(); + + return ( +
+ {renderTimes} +
+ ); +} + +export function Value({ id, value }: { id?: string; value: any }) { + const str = JSON.stringify(value); + + return ( +
+ {str.replace(/^"/, '').replace(/"$/, '')} +
+ ); +} diff --git a/tests/context.test.tsx b/tests/context.test.tsx new file mode 100644 index 0000000..172caee --- /dev/null +++ b/tests/context.test.tsx @@ -0,0 +1,201 @@ +import { fireEvent, render } from '@testing-library/react'; +import React from 'react'; +import { createContext, useContext } from '../src'; +import { RenderTimer, Value } from './common'; + +describe('Basic', () => { + interface User { + name: string; + age: number; + } + + const UserContext = createContext(); + + const Root = ({ children }: { children?: React.ReactNode }) => { + const [name, setName] = React.useState('bamboo'); + const [age, setAge] = React.useState(30); + + return ( + + setName(e.target.value)} /> + setAge(Number(e.target.value))} /> + + {children} + + ); + }; + + function changeValue(container: HTMLElement, id: string, value: string) { + fireEvent.change(container.querySelector(`#${id}`), { + target: { + value, + }, + }); + } + + it('raw', () => { + const Raw = () => { + const user = useContext(UserContext); + + return ( + <> + + + + ); + }; + + const { container } = render( + + + , + ); + + // Mount + expect(container.querySelector('#raw')!.textContent).toEqual('1'); + + // Update `name`: Full Update + changeValue(container, 'name', 'light'); + expect(container.querySelector('#raw')!.textContent).toEqual('2'); + expect(container.querySelector('#value')!.textContent).toEqual( + JSON.stringify({ + name: 'light', + age: 30, + }), + ); + + // Update `age`: Full Update + changeValue(container, 'age', '20'); + expect(container.querySelector('#raw')!.textContent).toEqual('3'); + expect(container.querySelector('#value')!.textContent).toEqual( + JSON.stringify({ + name: 'light', + age: 20, + }), + ); + }); + + it('PropName', () => { + const PropName = ({ name }: { name: keyof User }) => { + const value = useContext(UserContext, name); + + return ( + <> + + + + ); + }; + + const { container } = render( + + + + , + ); + + // Mount + expect(container.querySelector('#name-times')!.textContent).toEqual('1'); + expect(container.querySelector('#age-times')!.textContent).toEqual('1'); + + // Update `name`: Partial Update + changeValue(container, 'name', 'light'); + expect(container.querySelector('#name-times')!.textContent).toEqual('2'); + expect(container.querySelector('#name-value')!.textContent).toEqual('light'); + expect(container.querySelector('#age-times')!.textContent).toEqual('1'); + + // Update `age`: Partial Update + changeValue(container, 'age', '20'); + expect(container.querySelector('#name-times')!.textContent).toEqual('2'); + expect(container.querySelector('#age-times')!.textContent).toEqual('2'); + expect(container.querySelector('#age-value')!.textContent).toEqual('20'); + }); + + it('PropNameArray', () => { + const PropName = ({ name }: { name: (keyof User)[] }) => { + const value = useContext(UserContext, name); + + return ( + <> + + + + ); + }; + + const { container } = render( + + + + , + ); + + // Mount + expect(container.querySelector('#name-times')!.textContent).toEqual('1'); + expect(container.querySelector('#name_age-times')!.textContent).toEqual('1'); + + // Update `name`: Partial Update + changeValue(container, 'name', 'light'); + expect(container.querySelector('#name-times')!.textContent).toEqual('2'); + expect(container.querySelector('#name-value')!.textContent).toEqual( + JSON.stringify({ name: 'light' }), + ); + expect(container.querySelector('#name_age-times')!.textContent).toEqual('2'); + expect(container.querySelector('#name_age-value')!.textContent).toEqual( + JSON.stringify({ name: 'light', age: 30 }), + ); + + // Update `age`: Partial Update + changeValue(container, 'age', '20'); + expect(container.querySelector('#name-times')!.textContent).toEqual('2'); + expect(container.querySelector('#name_age-times')!.textContent).toEqual('3'); + expect(container.querySelector('#name_age-value')!.textContent).toEqual( + JSON.stringify({ name: 'light', age: 20 }), + ); + }); + + it('function', () => { + const Func = () => { + const value = useContext(UserContext, v => v.name); + + return ( + <> + + + + ); + }; + + const { container } = render( + + + , + ); + + // Mount + expect(container.querySelector('#times')!.textContent).toEqual('1'); + + // Update `name`: Update + changeValue(container, 'name', 'light'); + expect(container.querySelector('#times')!.textContent).toEqual('2'); + expect(container.querySelector('#value')!.textContent).toEqual('light'); + + // Update `age`: Not Update + changeValue(container, 'age', '20'); + expect(container.querySelector('#times')!.textContent).toEqual('2'); + expect(container.querySelector('#value')!.textContent).toEqual('light'); + }); + + it('defaultValue', () => { + const DefaultContext = createContext('little'); + const Demo = () => ( + <> + + + ); + + const { container } = render(); + + expect(container.querySelector('#value')!.textContent).toEqual('little'); + }); +}); diff --git a/tests/element.test.tsx b/tests/element.test.tsx deleted file mode 100644 index 102765f..0000000 --- a/tests/element.test.tsx +++ /dev/null @@ -1,162 +0,0 @@ -/* eslint-disable class-methods-use-this */ -import { render } from '@testing-library/react'; -import { spyElementPrototype } from 'rc-util/lib/test/domHook'; -import React from 'react'; -import { renderToString } from 'react-dom/server'; -import Align from '../src'; - -describe('element align', () => { - beforeAll(() => { - spyElementPrototype(HTMLElement, 'offsetParent', { - get: () => ({}), - }); - }); - - beforeEach(() => { - jest.useFakeTimers(); - }); - - afterEach(() => { - jest.clearAllTimers(); - jest.useRealTimers(); - }); - - const align = { - points: ['bc', 'tc'], - }; - - class Test extends React.Component { - $target: any; - - getTarget = () => this.$target; - - targetRef = ele => { - this.$target = ele; - }; - - render() { - return ( -
-
- target -
- -
- source -
-
-
- ); - } - } - - it('resize', () => { - const onAlign = jest.fn(); - - const { unmount, rerender } = render(); - expect(onAlign).toHaveBeenCalled(); - - // Window resize - onAlign.mockReset(); - window.dispatchEvent(new Event('resize')); - jest.runAllTimers(); - expect(onAlign).toHaveBeenCalled(); - - // Not listen resize - onAlign.mockReset(); - rerender(); - window.dispatchEvent(new Event('resize')); - jest.runAllTimers(); - expect(onAlign).not.toHaveBeenCalled(); - - // Remove should not crash - rerender(); - unmount(); - }); - - it('disabled should trigger align', () => { - const onAlign = jest.fn(); - - const { rerender } = render(); - expect(onAlign).not.toHaveBeenCalled(); - - rerender(); - jest.runAllTimers(); - expect(onAlign).toHaveBeenCalled(); - }); - - // https://github.com/ant-design/ant-design/issues/31717 - it('changing align should trigger onAlign', () => { - const onAlign = jest.fn(); - const { rerender } = render(); - expect(onAlign).toHaveBeenCalledTimes(1); - expect(onAlign).toHaveBeenLastCalledWith( - expect.any(HTMLElement), - expect.objectContaining({ points: ['cc', 'cc'] }), - ); - // wrapper.setProps({ align: { points: ['cc', 'tl'] } }); - rerender(); - jest.runAllTimers(); - expect(onAlign).toHaveBeenCalledTimes(2); - expect(onAlign).toHaveBeenLastCalledWith( - expect.any(HTMLElement), - expect.objectContaining({ points: ['cc', 'tl'] }), - ); - }); - - it('should switch to the correct align callback after starting the timers', () => { - // This test case is tricky. An error occurs if the following things happen - // exactly in this order: - // * Render with `onAlign1`. - // * The callback in useBuffer is queued using setTimeout, to trigger after - // `monitorBufferTime` ms (which even when it's set to 0 is queued and - // not synchronously executed). - // * The onAlign prop is changed to `onAlign2`. - // * The callback from useBuffer is called. The now correct onAlign - // callback would be `onAlign2`, and `onAlign1` should not be called. - // This changing of the prop in between a 0 ms timeout is extremely rare. - // It does however occur more often in real-world applications with - // react-component/trigger, when its requestAnimationFrame and this timeout - // race against each other. - - const onAlign1 = jest.fn(); - const onAlign2 = jest.fn(); - - const { rerender } = render(); - - // Make sure the initial render's call to onAlign does not matter. - onAlign1.mockReset(); - onAlign2.mockReset(); - - // Re-render the component with the new callback. Expect from here on all - // callbacks to call the new onAlign2. - rerender(); - - // Now the timeout is executed, and we expect the onAlign2 callback to - // receive the call, not onAlign1. - jest.runAllTimers(); - - expect(onAlign1).not.toHaveBeenCalled(); - expect(onAlign2).toHaveBeenCalled(); - }); - - it('SSR no break', () => { - const str = renderToString( - { - throw new Error('Not Call In Render'); - }} - />, - ); - expect(str).toBeTruthy(); - }); -}); -/* eslint-enable */ diff --git a/tests/immutable.test.tsx b/tests/immutable.test.tsx new file mode 100644 index 0000000..da355bf --- /dev/null +++ b/tests/immutable.test.tsx @@ -0,0 +1,98 @@ +import { fireEvent, render } from '@testing-library/react'; +import React from 'react'; +import { createContext, makeImmutable, responseImmutable, useContext } from '../src'; +import { RenderTimer, Value } from './common'; + +describe('Immutable', () => { + const CountContext = createContext(); + + describe('makeImmutable', () => { + const Root = ({ children }: { children?: React.ReactNode; trigger?: string }) => { + const [count, setCount] = React.useState(0); + const [selfState, setSelfState] = React.useState(0); + + return ( + +