diff --git a/.cursor/rules/demo.mdc b/.cursor/rules/demo.mdc
new file mode 100644
index 0000000000..7b5e3a2428
--- /dev/null
+++ b/.cursor/rules/demo.mdc
@@ -0,0 +1,43 @@
+---
+description:
+globs: components/*/demo/**
+alwaysApply: false
+---
+
+# Demo 规范
+
+- demo 代码尽可能简洁
+- 避免冗余代码,方便用户复制到项目直接使用
+- 每个 demo 聚焦展示一个功能点
+- 提供中英文两个版本的说明
+- demo 文件命名:
+ - 英文 demo: index.en-US.md
+ - 中文 demo: index.zh-CN.md
+- 确保 demo 在各种尺寸下都能正常展示
+- 对于复杂交互提供必要的操作说明
+
+## 文件组织
+
+- 每个组件演示包含 `.md`(说明文档)和 `.ts`(实际代码)两部分
+- 位置:hooks 目录下的 `src` 子目录,如 `packages/hooks/src/useHover`
+- 文件名应简洁地描述示例内容
+
+## MD 文档规范
+
+- 必须包含 `## zh-CN` 和 `## en-US` 两种语言说明
+- 内容简洁明了,突出组件特性和用法
+- 避免冗长段落,必要时使用列表或粗体
+- 标注注意事项和实验性功能
+
+## 代码质量
+
+- 实用且专注于单一功能
+- 关键处添加简洁注释
+- 使用有意义的数据和变量
+- 优先使用 ahooks 内置 hook 或者公共方法,减少外部依赖
+
+## 质量要求
+
+- 确保代码运行正常,无控制台错误
+- 适配常见浏览器
+- 避免过时 API,及时更新到新推荐用法
diff --git a/.cursor/rules/docs.mdc b/.cursor/rules/docs.mdc
new file mode 100644
index 0000000000..a6d7475c13
--- /dev/null
+++ b/.cursor/rules/docs.mdc
@@ -0,0 +1,38 @@
+---
+description: 规范项目文档和 Changelog
+globs: ["**/CHANGELOG*.md", "components/**/index.*.md"]
+alwaysApply: false
+---
+
+# Changelog Emoji 规范
+
+- 🐞 Bug 修复
+- 💄 样式更新或 token 更新
+- 🆕 新增特性,新增属性
+- 🔥 极其值得关注的新增特性
+- 🇺🇸🇨🇳🇬🇧 国际化改动
+- 📖 📝 文档或网站改进
+- ✅ 新增或更新测试用例
+- 🛎 更新警告/提示信息
+- ⌨️ ♿ 可访问性增强
+- 🗑 废弃或移除
+- 🛠 重构或工具链优化
+- ⚡️ 性能提升
+
+# 文档规范
+
+- 提供中英文两个版本
+- 新属性需声明可用的版本号
+- 属性命名符合 API 命名规则
+- hook 文档包含:使用场景、基础用法、API 说明
+- 文档示例应简洁明了
+- 属性的描述应清晰易懂
+- 对复杂功能提供详细说明
+- 加入 TypeScript 定义
+- 提供常见问题解答
+- 更新文档时同步更新中英文版本
+
+## 其他要求
+
+- 新增属性时,建议用易于理解的语言描述用户可以感知的变化
+- 存在破坏性改动时,尽量给出原始的 PR 链接,社区提交的 PR 改动加上提交者的链接
diff --git a/.cursor/rules/git.mdc b/.cursor/rules/git.mdc
new file mode 100644
index 0000000000..b1df1bea6f
--- /dev/null
+++ b/.cursor/rules/git.mdc
@@ -0,0 +1,109 @@
+---
+description:
+globs:
+alwaysApply: true
+---
+# Git 规范
+
+## 开发流程
+
+1. 从保护分支(通常是 `master`)创建新的功能分支
+2. 在新分支上进行开发
+3. 提交 Pull Request 到目标分支
+4. 等待 Code Review 和 CI 通过
+5. 合并到目标分支
+
+## 分支命名规范
+
+- 功能开发:`feat/description-of-feature`
+ - 例如:`feat/add-dark-mode`
+ - 例如:`feat/improve-table-performance`
+- 问题修复:`fix/issue-number-or-description`
+ - 例如:`fix/button-style-issue`
+ - 例如:`fix/issue-1234`
+- 文档更新:`docs/what-is-changed`
+ - 例如:`docs/update-api-reference`
+ - 例如:`docs/fix-typos`
+- 代码重构:`refactor/what-is-changed`
+ - 例如:`refactor/button-component`
+ - 例如:`refactor/remove-deprecated-api`
+- 样式修改:`style/what-is-changed`
+ - 例如:`style/update-button-tokens`
+ - 例如:`style/improve-mobile-layout`
+- 测试相关:`test/what-is-changed`
+ - 例如:`test/add-button-test`
+ - 例如:`test/improve-coverage`
+- 构建相关:`build/what-is-changed`
+ - 例如:`build/upgrade-webpack`
+ - 例如:`build/fix-ts-config`
+- 持续集成:`ci/what-is-changed`
+ - 例如:`ci/add-e2e-test`
+ - 例如:`ci/fix-deploy-script`
+- 性能优化:`perf/what-is-changed`
+ - 例如:`perf/optimize-render`
+ - 例如:`perf/reduce-bundle-size`
+- 依赖升级:`deps/package-name-version`
+ - 例如:`deps/upgrade-react-19`
+ - 例如:`deps/update-dependencies`
+
+## 分支命名注意事项
+
+1. 使用小写字母
+2. 使用连字符(-)分隔单词
+3. 简短但具有描述性
+4. 避免使用下划线或其他特殊字符
+5. 如果与 Issue 关联,可以包含 Issue 编号
+
+## Pull Request 规范
+
+### PR 标题
+
+- PR 标题始终使用英文
+- 遵循格式:`类型: 简短描述`
+- 例如:`fix: fix button style issues in Safari browser`
+- 例如:`feat: add dark mode support`
+
+### PR 内容
+
+- PR 内容默认使用英文
+- 尽量简洁清晰地描述改动内容和目的
+- 可以视需要在英文描述后附上中文说明
+
+### PR 提交注意事项
+
+1. **审核流程**:
+
+ - PR 需要由至少一名维护者审核通过后才能合并
+ - 确保所有 CI 检查都通过
+ - 解决所有 Code Review 中提出的问题
+
+2. **PR 质量要求**:
+
+ - 确保代码符合项目代码风格
+ - 添加必要的测试用例
+ - 更新相关文档
+ - 大型改动需要更详细的说明和更多的审核者参与
+
+3. **工具标注**:
+ - 如果是用 Cursor 提交的代码,请在 PR body 末尾进行标注:`> Submitted by Cursor`
+
+## 新增内容
+
+- Pull Request 标题格式:[组件名]: 描述
+- 从 master 分支创建新分支
+- 分支命名规范:
+ - feature/xxx:新特性
+ - fix/xxx:Bug 修复
+ - docs/xxx:文档更新
+- PR 说明中选择改动类型:
+ - 🆕 新特性提交
+ - 🐞 Bug 修复
+ - 📝 文档改进
+ - 📽️ 演示代码改进
+ - 💄 样式/交互改进
+ - 🤖 TypeScript 更新
+ - 📦 包体积优化
+ - ⚡️ 性能优化
+ - 🌐 国际化改进
+- 提供改动背景和解决方案
+- 更新日志同时提供英文和中文版本
diff --git a/.cursor/rules/project.mdc b/.cursor/rules/project.mdc
new file mode 100644
index 0000000000..33aeb172eb
--- /dev/null
+++ b/.cursor/rules/project.mdc
@@ -0,0 +1,24 @@
+---
+description:
+globs:
+alwaysApply: true
+---
+ # 项目背景
+
+这是由蚂蚁团队开发的一个高质量、可靠的 React Hooks 库。
+
+- 易学易用
+- 支持 SSR
+- 对输入输出函数做了特殊处理,避免闭包问题
+- 包含大量提炼自业务的高级 Hooks
+- 包含丰富的基础 Hooks
+- 使用 TypeScript 构建,提供完整的类型定义文件
+
+# 编码规范
+
+- 使用 TypeScript 和 React 书写
+- 避免引入新依赖,严控打包体积
+- 兼容现代浏览器
+- 支持服务端渲染
+- 保持向下兼容,避免 breaking change
+- 合理使用 React.memo、useMemo 和 useCallback 优化性能
diff --git a/.cursor/rules/testing.mdc b/.cursor/rules/testing.mdc
new file mode 100644
index 0000000000..0fe7d60847
--- /dev/null
+++ b/.cursor/rules/testing.mdc
@@ -0,0 +1,10 @@
+---
+description:
+globs: **/__tests__/**,**/*.test.tsx,**/*.test.ts
+alwaysApply: false
+---
+ # 测试规范
+
+- 使用 vitest 和 @testing-library/react 编写单元测试
+- 测试覆盖率要求 100%
+- 测试文件放在 __tests__ 目录,命名格式为:index.spec.ts 或 xxx.spec.ts
diff --git a/.cursor/rules/typescript.mdc b/.cursor/rules/typescript.mdc
new file mode 100644
index 0000000000..1ee44e0d67
--- /dev/null
+++ b/.cursor/rules/typescript.mdc
@@ -0,0 +1,74 @@
+# TypeScript 规范
+
+## 基本原则
+
+- 所有组件和函数必须提供准确的类型定义
+- 尽量避免使用 `any` 类型,尽可能精确地定义类型
+- 使用接口而非类型别名定义对象结构
+- 导出所有公共接口类型,方便用户使用
+- 严格遵循 TypeScript 类型设计原则,确保类型安全
+- 确保编译无任何类型错误或警告
+
+## hook 类型定义
+
+- 复杂的数据结构应拆分为多个接口定义
+- 所有函数类型应明确定义参数和返回值
+
+## 泛型使用
+
+- 适当使用泛型增强类型灵活性
+- 为泛型参数提供合理的默认类型和约束
+- 避免过度使用泛型导致类型复杂化
+- 在泛型参数上应用限制条件(constraints)确保类型安全
+- 为复杂泛型提供类型别名以提高可读性
+
+## 类型合并与扩展
+
+- 使用交叉类型(&)合并多个类型
+- 使用 Partial、Pick、Omit 等工具类型修改现有类型
+- 扩展原生 DOM 元素属性时,继承相应的内置类型
+- 使用 type 定义联合类型和交叉类型
+- 优先使用自带的工具类型,避免重复定义
+
+## 枚举和常量
+
+- 使用字面量联合类型定义有限的选项集合
+- 为复杂的枚举值提供类型守卫函数
+- 避免使用 `enum`,优先使用联合类型和 `as const`
+- 对于关键常量,使用 `as const` 断言确保类型严格
+- 为联合类型中的每个值提供适当的注释
+
+## 类型推断与断言
+
+- 尽可能依赖 TypeScript 的类型推断
+- 只在必要时使用类型断言(as)
+- 使用类型守卫函数进行运行时类型检查
+- 尽量避免使用非空断言操作符(!)
+- 使用 `instanceof` 和 `typeof` 进行类型守卫
+- 为自定义类型创建类型谓词(type predicates)函数
+
+## JSDoc 注释
+
+- 为复杂的类型、函数、hook 添加 JSDoc 注释
+- 使用 `@deprecated` 标记已废弃的 API
+- 在注释中提供使用示例
+- 说明参数和返回值的含义与约束
+- 在 interface 和重要类型定义上添加文档注释
+
+## 类型兼容性
+
+- 确保类型定义兼容不同版本的 React
+- 避免使用实验性或不稳定的 TypeScript 特性
+- 为第三方库未提供的类型编写声明文件
+- 使用条件类型处理复杂的类型逻辑
+- 验证类型在不同 TypeScript 版本下的兼容性
+
+## 严格使用 TypeScript 类型
+
+- 导出组件类型和接口
+- 避免使用 any,优先使用 unknown
+- 组件 Props 使用 interface 定义
+- 使用明确的命名约定
+- 合理使用泛型提高复用性
+- 导出类型时使用 export type
+- 组件属性使用 JSDoc 注释说明用途
diff --git a/.eslintrc.js b/.eslintrc.js
deleted file mode 100644
index fe75bf1c08..0000000000
--- a/.eslintrc.js
+++ /dev/null
@@ -1,28 +0,0 @@
-const fabric = require('@umijs/fabric');
-
-module.exports = {
- ...fabric.default,
- rules: {
- ...fabric.default.rules,
- 'no-restricted-syntax': 'off',
- 'no-plusplus': 'off',
- 'no-console': 'off',
- 'no-underscore-dangle': 'off',
- 'consistent-return': 'off',
- '@typescript-eslint/ban-ts-ignore': 'off',
- '@typescript-eslint/no-object-literal-type-assertion': 'off',
- '@typescript-eslint/no-parameter-properties': 'off',
- 'consistent-return': 'off',
- 'import/no-useless-path-segments': 'off',
- 'no-unused-expressions': 'off',
- 'react-hooks/rules-of-hooks': 'error',
- 'react-hooks/exhaustive-deps': 'off',
- 'no-await-in-loop': 'off',
- 'no-constant-condition': ['warn', { checkLoops: false }],
- },
- plugins: [...fabric.default.plugins, 'react-hooks'],
- parserOptions: {
- ...fabric.default.parserOptions,
- project: './packages/**/tsconfig.json',
- },
-};
diff --git a/.github/workflows/gitleaks.yml b/.github/workflows/gitleaks.yml
index ab51b2dc28..34c9b860f6 100644
--- a/.github/workflows/gitleaks.yml
+++ b/.github/workflows/gitleaks.yml
@@ -6,7 +6,7 @@ jobs:
gitleaks:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v3
+ - uses: actions/checkout@v4
- name: wget
uses: wei/wget@v1
with:
diff --git a/.github/workflows/issue-close-require.yml b/.github/workflows/issue-close-require.yml
index 7e434c6df2..6699d90be2 100644
--- a/.github/workflows/issue-close-require.yml
+++ b/.github/workflows/issue-close-require.yml
@@ -9,18 +9,18 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: need reproduce
- uses: actions-cool/issues-helper@v1.7
+ uses: actions-cool/issues-helper@v3
with:
actions: 'close-issues'
labels: '🤔 Need Reproduce'
inactive-day: 3
- name: needs more info
- uses: actions-cool/issues-helper@v1.7
+ uses: actions-cool/issues-helper@v3
with:
actions: 'close-issues'
- labels: 'needs-more-info'
+ labels: 'needs more info'
inactive-day: 3
body: |
- Since the issue was labeled with `needs-more-info`, but no response in 3 days. This issue will be closed. If you have any questions, you can comment and reply.
+ Since the issue was labeled with `needs more info`, but no response in 3 days. This issue will be closed. If you have any questions, you can comment and reply.
由于该 issue 被标记为需要更多信息,却 3 天未收到回应。现关闭 issue,若有任何问题,可评论回复。
diff --git a/.github/workflows/issue-reply.yml b/.github/workflows/issue-reply.yml
index 396c67c5a8..c5d676ebf6 100644
--- a/.github/workflows/issue-reply.yml
+++ b/.github/workflows/issue-reply.yml
@@ -10,7 +10,7 @@ jobs:
steps:
- name: help wanted
if: github.event.label.name == 'help wanted'
- uses: actions-cool/issues-helper@v1.2
+ uses: actions-cool/issues-helper@v3
with:
actions: 'create-comment'
issue-number: ${{ github.event.issue.number }}
@@ -23,7 +23,7 @@ jobs:
- name: 🤔 Need Reproduce
if: github.event.label.name == '🤔 Need Reproduce'
- uses: actions-cool/issues-helper@v1.2
+ uses: actions-cool/issues-helper@v3
with:
actions: 'create-comment'
issue-number: ${{ github.event.issue.number }}
diff --git a/.github/workflows/node-ci.yml b/.github/workflows/node-ci.yml
deleted file mode 100644
index 7033a94303..0000000000
--- a/.github/workflows/node-ci.yml
+++ /dev/null
@@ -1,31 +0,0 @@
-name: Node CI
-
-on: [push, pull_request]
-
-jobs:
- build:
- runs-on: ubuntu-latest
-
- strategy:
- matrix:
- node-version: [14.x, 16.x]
-
- steps:
- - uses: actions/checkout@v3
-
- - name: Install pnpm
- uses: pnpm/action-setup@v2.2.4
- with:
- version: 7
-
- - name: Use Node.js ${{ matrix.node-version }}
- uses: actions/setup-node@v3
- with:
- node-version: ${{ matrix.node-version }}
-
- - name: pnpm run intall, build, and test
- run: |
- pnpm run init
- pnpm run test
- env:
- CI: true
diff --git a/.github/workflows/pkg.pr.new.yml b/.github/workflows/pkg.pr.new.yml
new file mode 100644
index 0000000000..6c35337bbc
--- /dev/null
+++ b/.github/workflows/pkg.pr.new.yml
@@ -0,0 +1,37 @@
+name: Publish Any Commit
+
+on:
+ push:
+ branches:
+ - master
+ pull_request:
+ types: [opened, synchronize, reopened]
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ if: github.repository == 'alibaba/hooks'
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - uses: pnpm/action-setup@v4
+ name: Install pnpm
+ with:
+ run_install: false
+
+ - name: Install Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: 20
+ cache: 'pnpm'
+
+ - name: Install dependencies
+ run: pnpm install
+
+ - name: Build
+ run: pnpm build
+
+ # https://github.com/stackblitz-labs/pkg.pr.new#readme
+ - run: pnpx pkg-pr-new publish './packages/*' --no-template --compact
diff --git a/.github/workflows/static.yml b/.github/workflows/static.yml
new file mode 100644
index 0000000000..52d64842bd
--- /dev/null
+++ b/.github/workflows/static.yml
@@ -0,0 +1,70 @@
+name: Deploy static content to Pages
+
+on:
+ # Runs on pushes targeting the default branch
+ push:
+ branches: ["master"]
+
+ # Allows you to run this workflow manually from the Actions tab
+ workflow_dispatch:
+
+permissions:
+ contents: write
+ pages: write
+ id-token: write
+
+# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued.
+# However, do NOT cancel in-progress runs as we want to allow these production deployments to complete.
+concurrency:
+ group: "pages"
+ cancel-in-progress: false
+
+jobs:
+ deploy:
+ environment:
+ name: github-pages
+ url: ${{ steps.deployment.outputs.page_url }}
+ runs-on: ubuntu-latest
+ env:
+ NODE_OPTIONS: --openssl-legacy-provider
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Set up Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: 22
+
+ - name: Setup pnpm
+ run: |
+ npm install --global corepack@latest
+ corepack enable
+ corepack prepare pnpm@latest --activate
+ echo "$(pnpm bin --global)" >> $GITHUB_PATH
+
+ - name: Install dependencies
+ run: pnpm install
+
+ - name: Cache pnpm dependencies
+ uses: actions/cache@v4
+ with:
+ path: |
+ ~/.pnpm-store
+ node_modules
+ key: ${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }}
+ restore-keys: |
+ ${{ runner.os }}-pnpm-
+
+ - name: Build documentation
+ run: npm run build:doc
+
+ - name: Setup Pages
+ uses: actions/configure-pages@v5
+
+ - name: Deploy to GitHub Pages
+ uses: peaceiris/actions-gh-pages@v4
+ with:
+ github_token: ${{ secrets.GITHUB_TOKEN }}
+ publish_dir: ./dist # 构建后的静态文件目录
+ force_orphan: true
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
new file mode 100644
index 0000000000..f472001658
--- /dev/null
+++ b/.github/workflows/test.yml
@@ -0,0 +1,57 @@
+name: Test CI
+
+on: [push, pull_request]
+
+jobs:
+ lint:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: pnpm/action-setup@v4
+ - run: pnpm install
+ - run: pnpm run tsc
+ test:
+ runs-on: ubuntu-latest
+
+ strategy:
+ matrix:
+ mode: ['normal', 'strict']
+ node-version: [20, 22]
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Install pnpm
+ uses: pnpm/action-setup@v4
+
+ - name: Get pnpm store directory
+ id: pnpm-cache
+ run: |
+ echo "pnpm_cache_dir=$(pnpm store path)" >> "$GITHUB_OUTPUT"
+
+ - name: Setup pnpm cache
+ uses: actions/cache@v4
+ with:
+ path: ${{ steps.pnpm-cache.outputs.pnpm_cache_dir }}
+ key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
+ restore-keys: |
+ ${{ runner.os }}-pnpm-store-
+
+ - name: Use Node.js ${{ matrix.node-version }}
+ uses: actions/setup-node@v4
+ with:
+ node-version: ${{ matrix.node-version }}
+
+ - name: pnpm run install, build
+ run: |
+ pnpm run init
+
+ - name: test with react normal mode
+ if: ${{ matrix.mode == 'normal' }}
+ run: |
+ pnpm run test
+
+ - name: test with react strict mode
+ if: ${{ matrix.mode == 'strict' }}
+ run: |
+ pnpm run test:strict
diff --git a/.gitignore b/.gitignore
index 52a57c884d..7b2f232f19 100755
--- a/.gitignore
+++ b/.gitignore
@@ -18,3 +18,4 @@ packages/hooks/README.md
yarn-error.log
package-lock.json
metadata.json
+.eslintcache
diff --git a/.husky/pre-commit b/.husky/pre-commit
index 47646a694b..7e83bacc99 100755
--- a/.husky/pre-commit
+++ b/.husky/pre-commit
@@ -1,4 +1,4 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
-npm run pretty
+npm run pretty
\ No newline at end of file
diff --git a/.npmrc b/.npmrc
index 009aa06dd7..c483022c0a 100644
--- a/.npmrc
+++ b/.npmrc
@@ -1,2 +1 @@
-shamefully-hoist=true
-auto-install-peers=true
+shamefully-hoist=true
\ No newline at end of file
diff --git a/.prettierignore b/.prettierignore
deleted file mode 100644
index 73157fd376..0000000000
--- a/.prettierignore
+++ /dev/null
@@ -1,4 +0,0 @@
-packages/**/*.mdx
-package.json
-.umi
-.umi-production
\ No newline at end of file
diff --git a/.prettierrc b/.prettierrc
deleted file mode 100644
index a1c94a1cf1..0000000000
--- a/.prettierrc
+++ /dev/null
@@ -1,19 +0,0 @@
-{
- "singleQuote": true,
- "trailingComma": "all",
- "printWidth": 100,
- "overrides": [
- {
- "files": ".prettierrc",
- "options": {
- "parser": "json"
- }
- },
- {
- "files": ["*.md"],
- "options": {
- "embeddedLanguageFormatting": "off"
- }
- }
- ]
-}
diff --git a/LICENSE b/LICENSE
index 35d401d35a..aebeda7fec 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,6 +1,6 @@
MIT License
-Copyright (c) 2020 ahooks
+Copyright (c) 2019-present ahooks
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/README.md b/README.md
index 00b6cad08c..0d602a509e 100644
--- a/README.md
+++ b/README.md
@@ -14,8 +14,8 @@ A high-quality & reliable React Hooks library.
[](https://github.com/alibaba/hooks/issues)
[](https://coveralls.io/github/alibaba/hooks?branch=master)

-[](http://isitmaintained.com/project/alibaba/hooks 'Percentage of issues still open')
-[](http://isitmaintained.com/project/alibaba/hooks 'Average time to resolve an issue')
+[](http://isitmaintained.com/project/alibaba/hooks "Percentage of issues still open")
+[](http://isitmaintained.com/project/alibaba/hooks "Average time to resolve an issue")

English | [简体中文](https://github.com/alibaba/hooks/blob/master/README.zh-CN.md)
@@ -27,6 +27,12 @@ English | [简体中文](https://github.com/alibaba/hooks/blob/master/README.zh-
- [English](https://ahooks.js.org/)
- [中文](https://ahooks.js.org/zh-CN/)
+> Notice
+>
+> `use-url-state` is now published as `@ahooks.js/use-url-state`.
+>
+> If you are installing or upgrading this package, please use the new package name.
+
## ✨ Features
- Easy to learn and use
@@ -44,12 +50,14 @@ $ npm install --save ahooks
$ yarn add ahooks
# or
$ pnpm add ahooks
+# or
+$ bun add ahooks
```
## 🔨 Usage
```ts
-import { useRequest } from 'ahooks';
+import { useRequest } from "ahooks";
```
## 💻 Online Demo
@@ -77,7 +85,9 @@ Thanks to all the contributors:
## 👥 Discuss
-
+
+
+
[1]: https://www.npmjs.com/package/ahooks
[2]: https://npmjs.org/package/ahooks
diff --git a/README.zh-CN.md b/README.zh-CN.md
index 35baf4c71e..288a980a3a 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -14,8 +14,8 @@
[](https://coveralls.io/github/alibaba/hooks?branch=master)
[](https://github.com/alibaba/hooks/issues)

-[](http://isitmaintained.com/project/alibaba/hooks 'Percentage of issues still open')
-[](http://isitmaintained.com/project/alibaba/hooks 'Average time to resolve an issue')
+[](http://isitmaintained.com/project/alibaba/hooks "Percentage of issues still open")
+[](http://isitmaintained.com/project/alibaba/hooks "Average time to resolve an issue")

[English](https://github.com/alibaba/hooks/blob/master/README.md) | 简体中文
@@ -27,6 +27,12 @@
- [English](https://ahooks.js.org/)
- [中文](https://ahooks.js.org/zh-CN/)
+> 公告
+>
+> `use-url-state` 已改为使用新包名 `@ahooks.js/use-url-state` 发布。
+>
+> 如果你正在安装或升级这个包,请使用新的包名。
+
## ✨ 特性
- 易学易用
@@ -44,12 +50,14 @@ $ npm install --save ahooks
$ yarn add ahooks
# or
$ pnpm add ahooks
+# or
+$ bun add ahooks
```
## 🔨 使用
```js
-import { useRequest } from 'ahooks';
+import { useRequest } from "ahooks";
```
## 💻 在线体验
@@ -77,7 +85,9 @@ $ pnpm start
## 👥 交流讨论
-
+
+
+
[1]: https://www.npmjs.com/package/ahooks
[2]: https://npmjs.org/package/ahooks
diff --git a/biome.json b/biome.json
new file mode 100644
index 0000000000..8a065ce3d6
--- /dev/null
+++ b/biome.json
@@ -0,0 +1,47 @@
+{
+ "$schema": "./node_modules/@biomejs/biome/configuration_schema.json",
+ "files": {
+ "ignoreUnknown": true
+ },
+ "vcs": {
+ "enabled": true,
+ "clientKind": "git",
+ "useIgnoreFile": true
+ },
+ "linter": {
+ "rules": {
+ "style": {
+ "noNonNullAssertion": "off"
+ },
+ "correctness": {
+ "useHookAtTopLevel": "error"
+ },
+ "suspicious": {
+ "noExplicitAny": "off"
+ }
+ }
+ },
+ "formatter": {
+ "lineWidth": 100,
+ "indentStyle": "space"
+ },
+ "javascript": {
+ "parser": {
+ "unsafeParameterDecoratorsEnabled": true
+ },
+ "formatter": {
+ "quoteStyle": "single"
+ }
+ },
+ "css": {
+ "parser": {
+ "cssModules": true
+ },
+ "formatter": {
+ "enabled": true
+ },
+ "linter": {
+ "enabled": true
+ }
+ }
+}
diff --git a/config/config.ts b/config/config.ts
index b2cf6c1405..0ded793b50 100644
--- a/config/config.ts
+++ b/config/config.ts
@@ -9,6 +9,8 @@ export default {
type: 'none',
exclude: [],
},
+ // https://github.com/alibaba/hooks/issues/2155
+ extraBabelIncludes: ['filter-obj'],
extraBabelPlugins: [
[
'babel-plugin-import',
@@ -35,9 +37,10 @@ export default {
dynamicImport: {},
manifest: {},
hash: true,
+ publicPath: '/',
alias: {
ahooks: process.cwd() + '/packages/hooks/src/index.ts',
- '@ahooksjs/use-url-state': process.cwd() + '/packages/use-url-state/src/index.ts',
+ '@ahooks.js/use-url-state': process.cwd() + '/packages/use-url-state/src/index.ts',
},
resolve: {
includes: ['docs', 'packages/hooks/src', 'packages/use-url-state'],
@@ -67,7 +70,7 @@ export default {
],
},
{ title: '更新日志', path: 'https://github.com/alibaba/hooks/releases' },
- { title: '国内镜像', path: 'https://ahooks.gitee.io/zh-CN' },
+ { title: '备用镜像', path: 'https://alibaba.github.io/hooks/' },
{ title: 'GitHub', path: 'https://github.com/alibaba/hooks' },
],
'en-US': [
@@ -87,7 +90,7 @@ export default {
],
},
{ title: 'Releases', path: 'https://github.com/alibaba/hooks/releases' },
- { title: '国内镜像', path: 'https://ahooks.gitee.io/zh-CN' },
+ { title: '国内镜像', path: 'https://alibaba.github.io/hooks/' },
{ title: 'GitHub', path: 'https://github.com/alibaba/hooks' },
],
},
@@ -180,20 +183,27 @@ export default {
scripts: [
'https://s4.cnzz.com/z_stat.php?id=1278992092&web_id=1278992092',
`
- const insertVersion = function(){
+ const insertVersion = function() {
+ const logo = document.querySelector('.__dumi-default-navbar-logo');
+ if (!logo) return;
const dom = document.createElement('span');
dom.id = 'logo-version';
dom.innerHTML = '${packages.version}';
- const logo = document.querySelector('.__dumi-default-navbar-logo');
- if(logo){
- logo.parentNode.insertBefore(dom, logo.nextSibling);
- }else{
- setTimeout(()=>{
- insertVersion();
- }, 1000)
+ logo.parentNode.insertBefore(dom, logo.nextSibling);
+ };
+ const observer = new MutationObserver((mutationsList, observer) => {
+ for (const mutation of mutationsList) {
+ if (mutation.type === 'childList') {
+ const logoVersion = document.querySelector('#logo-version');
+ if (logoVersion) {
+ observer.disconnect();
+ } else {
+ insertVersion();
+ }
+ }
}
- }
- insertVersion();
+ });
+ observer.observe(document.body, { childList: true, subtree: true });
`,
],
};
diff --git a/config/hooks.ts b/config/hooks.ts
index fa74274cf2..26ffff428a 100644
--- a/config/hooks.ts
+++ b/config/hooks.ts
@@ -31,6 +31,7 @@ export const menus = [
'useCounter',
'useTextSelection',
'useWebSocket',
+ 'useTheme',
],
},
{
diff --git a/docs/guide/index.en-US.md b/docs/guide/index.en-US.md
index 41f2a45e60..77b03a4dff 100644
--- a/docs/guide/index.en-US.md
+++ b/docs/guide/index.en-US.md
@@ -19,6 +19,8 @@ $ npm install --save ahooks
$ yarn add ahooks
# or
$ pnpm add ahooks
+# or
+$ bun add ahooks
```
## Usage
diff --git a/docs/guide/index.zh-CN.md b/docs/guide/index.zh-CN.md
index 27ff40b100..520f2f1a55 100644
--- a/docs/guide/index.zh-CN.md
+++ b/docs/guide/index.zh-CN.md
@@ -19,6 +19,8 @@ $ npm install --save ahooks
$ yarn add ahooks
# or
$ pnpm add ahooks
+# or
+$ bun add ahooks
```
## 使用
diff --git a/docs/guide/upgrade.en-US.md b/docs/guide/upgrade.en-US.md
index 991704f02b..8a3adaed47 100644
--- a/docs/guide/upgrade.en-US.md
+++ b/docs/guide/upgrade.en-US.md
@@ -20,6 +20,16 @@ npm install ahooks-v2 --save
npm install ahooks --save
```
+## `use-url-state` package rename
+
+The standalone `use-url-state` package is now published as `@ahooks.js/use-url-state`.
+
+If you are installing this package for the first time or upgrading from the previous package name, please use the new package name:
+
+```bash
+npm install @ahooks.js/use-url-state --save
+```
+
## New useRequest
useRequest has been rewritten:
@@ -36,7 +46,7 @@ useRequest has been rewritten:
- Removed `pagination` related options, it is recommended to use `usePagination` or `useAntdTable` to achieve paging ability.
- Removed `loadMore` related options, it is recommended to use `useInfiniteScroll` to achieve unlimited loading ability.
- Removed `fetchKey`, that is, deleted concurrent request.
-- Removed `formatResult`, `initialData`, and `thrownError`.
+- Removed `formatResult`, `initialData`, and `throwOnError`.
- The request library is no longer integrated by default, and `service` no longer supports string or object.
- Added `runAsync` and `refreshAsync`, the original `run` no longer returns Promise.
- Added error retry ability.
diff --git a/docs/guide/upgrade.zh-CN.md b/docs/guide/upgrade.zh-CN.md
index ab37a65a0f..a468845896 100644
--- a/docs/guide/upgrade.zh-CN.md
+++ b/docs/guide/upgrade.zh-CN.md
@@ -20,6 +20,16 @@ npm install ahooks-v2 --save
npm install ahooks --save
```
+## `use-url-state` 包名变更
+
+独立包 `use-url-state` 现已使用新包名 `@ahooks.js/use-url-state` 发布。
+
+如果你是首次安装,或者正在从旧包名升级,请改用新的包名:
+
+```bash
+npm install @ahooks.js/use-url-state --save
+```
+
## 全新的 useRequest
useRequest 完全进行了重写:
@@ -36,7 +46,7 @@ useRequest 完全进行了重写:
- 删除了 `pagination` 相关属性,建议使用 `usePagination` 或 `useAntdTable` 来实现分页能力。
- 删除了 `loadMore` 相关属性,建议使用 `useInfiniteScroll` 来实现无限加载能力。
- 删除了 `fetchKey`,也就是删除了并行能力。
-- 删除了 `formatResult`、`initialData`、`thrownError`。
+- 删除了 `formatResult`、`initialData`、`throwOnError`。
- 不再默认集成请求库,`service` 不再支持字符或对象。
- 新增了 `runAsync` 和 `refreshAsync`,原来的 `run` 不再返回 Promise。
- 新增了错误重试能力。
diff --git a/docs/index.en-US.md b/docs/index.en-US.md
index 858d77b6a5..82b0c7a13b 100644
--- a/docs/index.en-US.md
+++ b/docs/index.en-US.md
@@ -17,8 +17,8 @@ footer: Open-source MIT Licensed | Copyright © 2019-present Powered by [du
[](https://github.com/alibaba/hooks/issues)
[](https://coveralls.io/github/alibaba/hooks?branch=master)

-[](http://isitmaintained.com/project/alibaba/hooks 'Percentage of issues still open')
-[](http://isitmaintained.com/project/alibaba/hooks 'Average time to resolve an issue')
+[](http://isitmaintained.com/project/alibaba/hooks "Percentage of issues still open")
+[](http://isitmaintained.com/project/alibaba/hooks "Average time to resolve an issue")

## ✨ Features
@@ -30,6 +30,7 @@ footer: Open-source MIT Licensed | Copyright © 2019-present Powered by [du
- Contains a comprehensive collection of basic Hooks
- Written in TypeScript with predictable static types
+
## 📦 Install
```bash
@@ -38,12 +39,14 @@ $ npm install --save ahooks
$ yarn add ahooks
# or
$ pnpm add ahooks
+# or
+$ bun add ahooks
```
## 🔨 Usage
```ts
-import { useRequest } from 'ahooks';
+import { useRequest } from "ahooks";
```
## 💻 Online Demo
@@ -71,7 +74,9 @@ Thanks to all the contributors:
## 👥 Discuss
-
+
+
+
[1]: https://www.npmjs.com/package/ahooks
[2]: https://npmjs.org/package/ahooks
diff --git a/docs/index.zh-CN.md b/docs/index.zh-CN.md
index 7460ea9e7c..2abe2d8950 100644
--- a/docs/index.zh-CN.md
+++ b/docs/index.zh-CN.md
@@ -17,8 +17,8 @@ footer: Open-source MIT Licensed | Copyright © 2019-present Powered by [du
[](https://github.com/alibaba/hooks/issues)
[](https://coveralls.io/github/alibaba/hooks?branch=master)

-[](http://isitmaintained.com/project/alibaba/hooks 'Percentage of issues still open')
-[](http://isitmaintained.com/project/alibaba/hooks 'Average time to resolve an issue')
+[](http://isitmaintained.com/project/alibaba/hooks "Percentage of issues still open")
+[](http://isitmaintained.com/project/alibaba/hooks "Average time to resolve an issue")

## ✨ 特性
@@ -36,12 +36,16 @@ footer: Open-source MIT Licensed | Copyright © 2019-present Powered by [du
$ npm install --save ahooks
# or
$ yarn add ahooks
+# or
+$ pnpm add ahooks
+# or
+$ bun add ahooks
```
## 🔨 使用
```ts
-import { useRequest } from 'ahooks';
+import { useRequest } from "ahooks";
```
## 💻 在线体验
@@ -69,7 +73,9 @@ $ pnpm start
## 👥 交流讨论
-
+
+
+
[1]: https://www.npmjs.com/package/ahooks
[2]: https://npmjs.org/package/ahooks
diff --git a/example/.gitkeep b/example/.gitkeep
new file mode 100644
index 0000000000..517c2d3237
--- /dev/null
+++ b/example/.gitkeep
@@ -0,0 +1,15 @@
+import React from 'react';
+import { useBoolean } from 'ahooks';
+
+export default function Demo() {
+ const [state, { toggle, setTrue, setFalse }] = useBoolean(false);
+
+ return (
+
+
Current state: {state ? 'ON' : 'OFF'}
+
Toggle
+
Set True
+
Set False
+
+ );
+}
diff --git a/example/basic/.editorconfig b/example/basic/.editorconfig
deleted file mode 100755
index 7e3649acc2..0000000000
--- a/example/basic/.editorconfig
+++ /dev/null
@@ -1,16 +0,0 @@
-# http://editorconfig.org
-root = true
-
-[*]
-indent_style = space
-indent_size = 2
-end_of_line = lf
-charset = utf-8
-trim_trailing_whitespace = true
-insert_final_newline = true
-
-[*.md]
-trim_trailing_whitespace = false
-
-[Makefile]
-indent_style = tab
diff --git a/example/basic/.gitignore b/example/basic/.gitignore
deleted file mode 100644
index bee1cf61ce..0000000000
--- a/example/basic/.gitignore
+++ /dev/null
@@ -1,20 +0,0 @@
-# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
-
-# dependencies
-/node_modules
-/npm-debug.log*
-/yarn-error.log
-/yarn.lock
-/package-lock.json
-
-# production
-/dist
-
-# misc
-.DS_Store
-
-# umi
-/src/.umi
-/src/.umi-production
-/src/.umi-test
-/.env.local
diff --git a/example/basic/.prettierignore b/example/basic/.prettierignore
deleted file mode 100644
index 0d4222f544..0000000000
--- a/example/basic/.prettierignore
+++ /dev/null
@@ -1,8 +0,0 @@
-**/*.md
-**/*.svg
-**/*.ejs
-**/*.html
-package.json
-.umi
-.umi-production
-.umi-test
diff --git a/example/basic/.prettierrc b/example/basic/.prettierrc
deleted file mode 100644
index 94beb14840..0000000000
--- a/example/basic/.prettierrc
+++ /dev/null
@@ -1,11 +0,0 @@
-{
- "singleQuote": true,
- "trailingComma": "all",
- "printWidth": 80,
- "overrides": [
- {
- "files": ".prettierrc",
- "options": { "parser": "json" }
- }
- ]
-}
diff --git a/example/basic/.umirc.ts b/example/basic/.umirc.ts
deleted file mode 100644
index e21d25ee86..0000000000
--- a/example/basic/.umirc.ts
+++ /dev/null
@@ -1,10 +0,0 @@
-import { defineConfig } from 'umi';
-
-export default defineConfig({
- nodeModulesTransform: {
- type: 'none',
- },
- routes: [{ path: '/', component: '@/pages/index' }],
- fastRefresh: {},
- ssr: {},
-});
diff --git a/example/basic/README.md b/example/basic/README.md
deleted file mode 100644
index 07afeb7fd6..0000000000
--- a/example/basic/README.md
+++ /dev/null
@@ -1,15 +0,0 @@
-# umi project
-
-## Getting Started
-
-Install dependencies,
-
-```bash
-$ yarn
-```
-
-Start the dev server,
-
-```bash
-$ yarn start
-```
diff --git a/example/basic/mock/.gitkeep b/example/basic/mock/.gitkeep
deleted file mode 100644
index e69de29bb2..0000000000
diff --git a/example/basic/package.json b/example/basic/package.json
deleted file mode 100644
index 6f69a31d77..0000000000
--- a/example/basic/package.json
+++ /dev/null
@@ -1,38 +0,0 @@
-{
- "private": true,
- "scripts": {
- "start": "umi dev",
- "build": "umi build",
- "postinstall": "umi generate tmp",
- "prettier": "prettier --write '**/*.{js,jsx,tsx,ts,less,md,json}'",
- "test": "umi-test",
- "test:coverage": "umi-test --coverage"
- },
- "gitHooks": {
- "pre-commit": "lint-staged"
- },
- "lint-staged": {
- "*.{js,jsx,less,md,json}": [
- "prettier --write"
- ],
- "*.ts?(x)": [
- "prettier --parser=typescript --write"
- ]
- },
- "dependencies": {
- "@ant-design/pro-layout": "^6.5.0",
- "@umijs/preset-react": "1.x",
- "umi": "^3.4.20"
- },
- "devDependencies": {
- "@types/react": "^17.0.0",
- "@types/react-dom": "^17.0.0",
- "@umijs/test": "^3.4.20",
- "lint-staged": "^10.0.7",
- "prettier": "^2.2.0",
- "react": "17.x",
- "react-dom": "17.x",
- "typescript": "^4.1.2",
- "yorkie": "^2.0.0"
- }
-}
diff --git a/example/basic/src/pages/index.less b/example/basic/src/pages/index.less
deleted file mode 100644
index 586302bfc8..0000000000
--- a/example/basic/src/pages/index.less
+++ /dev/null
@@ -1,3 +0,0 @@
-.title {
- background: rgb(121, 242, 157);
-}
diff --git a/example/basic/src/pages/index.tsx b/example/basic/src/pages/index.tsx
deleted file mode 100644
index ebdd169f7d..0000000000
--- a/example/basic/src/pages/index.tsx
+++ /dev/null
@@ -1,9 +0,0 @@
-import styles from './index.less';
-
-export default function IndexPage() {
- return (
-
-
Page index
-
- );
-}
diff --git a/example/basic/tsconfig.json b/example/basic/tsconfig.json
deleted file mode 100644
index bc5250b036..0000000000
--- a/example/basic/tsconfig.json
+++ /dev/null
@@ -1,36 +0,0 @@
-{
- "compilerOptions": {
- "target": "esnext",
- "module": "esnext",
- "moduleResolution": "node",
- "importHelpers": true,
- "jsx": "react-jsx",
- "esModuleInterop": true,
- "sourceMap": true,
- "baseUrl": "./",
- "strict": true,
- "paths": {
- "@/*": ["src/*"],
- "@@/*": ["src/.umi/*"]
- },
- "allowSyntheticDefaultImports": true
- },
- "include": [
- "mock/**/*",
- "src/**/*",
- "config/**/*",
- ".umirc.ts",
- "typings.d.ts"
- ],
- "exclude": [
- "node_modules",
- "lib",
- "es",
- "dist",
- "typings",
- "**/__test__",
- "test",
- "docs",
- "tests"
- ]
-}
diff --git a/example/basic/typings.d.ts b/example/basic/typings.d.ts
deleted file mode 100644
index 06c8a5b8ca..0000000000
--- a/example/basic/typings.d.ts
+++ /dev/null
@@ -1,10 +0,0 @@
-declare module '*.css';
-declare module '*.less';
-declare module '*.png';
-declare module '*.svg' {
- export function ReactComponent(
- props: React.SVGProps,
- ): React.ReactElement;
- const url: string;
- export default url;
-}
diff --git a/example/taro/.editorconfig b/example/taro/.editorconfig
deleted file mode 100644
index 5760be5836..0000000000
--- a/example/taro/.editorconfig
+++ /dev/null
@@ -1,12 +0,0 @@
-# http://editorconfig.org
-root = true
-
-[*]
-indent_style = space
-indent_size = 2
-charset = utf-8
-trim_trailing_whitespace = true
-insert_final_newline = true
-
-[*.md]
-trim_trailing_whitespace = false
diff --git a/example/taro/.eslintrc b/example/taro/.eslintrc
deleted file mode 100644
index 7809e66a31..0000000000
--- a/example/taro/.eslintrc
+++ /dev/null
@@ -1,7 +0,0 @@
-{
- "extends": ["taro/react"],
- "rules": {
- "react/jsx-uses-react": "off",
- "react/react-in-jsx-scope": "off"
- }
-}
diff --git a/example/taro/.gitignore b/example/taro/.gitignore
deleted file mode 100644
index 2cea3efd8c..0000000000
--- a/example/taro/.gitignore
+++ /dev/null
@@ -1,6 +0,0 @@
-dist/
-deploy_versions/
-.temp/
-.rn_temp/
-node_modules/
-.DS_Store
diff --git a/example/taro/.npmrc b/example/taro/.npmrc
deleted file mode 100644
index 7147182c1c..0000000000
--- a/example/taro/.npmrc
+++ /dev/null
@@ -1,10 +0,0 @@
-registry=https://registry.npmmirror.com
-disturl=https://npmmirror.com/dist
-sass_binary_site=https://npmmirror.com/mirrors/node-sass/
-phantomjs_cdnurl=https://npmmirror.com/mirrors/phantomjs/
-electron_mirror=https://npmmirror.com/mirrors/electron/
-chromedriver_cdnurl=https://npmmirror.com/mirrors/chromedriver
-operadriver_cdnurl=https://npmmirror.com/mirrors/operadriver
-selenium_cdnurl=https://npmmirror.com/mirrors/selenium
-node_inspector_cdnurl=https://npmmirror.com/mirrors/node-inspector
-fsevents_binary_host_mirror=https://npmmirror.com/mirrors/fsevents/
diff --git a/example/taro/README.md b/example/taro/README.md
deleted file mode 100644
index b4d3cad8ed..0000000000
--- a/example/taro/README.md
+++ /dev/null
@@ -1,23 +0,0 @@
-## CLI 工具安装
-
-```bash
-$ tnpm install -g @tarojs/cli
-```
-
-## 运行
-
-支付宝环境运行
-
-```bash
-$ npm run dev:alipay
-```
-
-### 支付宝小程序 IDE 下载
-
-下载并打开[https://render.alipay.com/p/f/fd-jwq8nu2a/pages/home/index.html](支付宝小程序开发者工具),然后选择项目根目录下 dist 目录(根目录 config 中的 outputRoot 设置的目录)进行预览。
-
-> 支付宝小程序开发者工具推荐使用 Lite 模式
-
-## 编码地址
-
-测试文件用 `pages/index/index.tsx`
diff --git a/example/taro/babel.config.js b/example/taro/babel.config.js
deleted file mode 100644
index 12f3043195..0000000000
--- a/example/taro/babel.config.js
+++ /dev/null
@@ -1,13 +0,0 @@
-// babel-preset-taro 更多选项和默认值:
-// https://github.com/NervJS/taro/blob/next/packages/babel-preset-taro/README.md
-module.exports = {
- presets: [
- [
- 'taro',
- {
- framework: 'react',
- ts: true,
- },
- ],
- ],
-};
diff --git a/example/taro/config/dev.js b/example/taro/config/dev.js
deleted file mode 100644
index ff3c9441e9..0000000000
--- a/example/taro/config/dev.js
+++ /dev/null
@@ -1,8 +0,0 @@
-module.exports = {
- env: {
- NODE_ENV: '"development"',
- },
- defineConstants: {},
- mini: {},
- h5: {},
-};
diff --git a/example/taro/config/index.js b/example/taro/config/index.js
deleted file mode 100644
index e2959e44a1..0000000000
--- a/example/taro/config/index.js
+++ /dev/null
@@ -1,64 +0,0 @@
-const config = {
- projectName: 'taro',
- date: '2021-6-7',
- designWidth: 750,
- deviceRatio: {
- 640: 2.34 / 2,
- 750: 1,
- 828: 1.81 / 2,
- },
- sourceRoot: 'src',
- outputRoot: 'dist',
- plugins: [],
- defineConstants: {},
- copy: {
- patterns: [],
- options: {},
- },
- framework: 'react',
- mini: {
- postcss: {
- pxtransform: {
- enable: true,
- config: {},
- },
- url: {
- enable: true,
- config: {
- limit: 1024, // 设定转换尺寸上限
- },
- },
- cssModules: {
- enable: false, // 默认为 false,如需使用 css modules 功能,则设为 true
- config: {
- namingPattern: 'module', // 转换模式,取值为 global/module
- generateScopedName: '[name]__[local]___[hash:base64:5]',
- },
- },
- },
- },
- h5: {
- publicPath: '/',
- staticDirectory: 'static',
- postcss: {
- autoprefixer: {
- enable: true,
- config: {},
- },
- cssModules: {
- enable: false, // 默认为 false,如需使用 css modules 功能,则设为 true
- config: {
- namingPattern: 'module', // 转换模式,取值为 global/module
- generateScopedName: '[name]__[local]___[hash:base64:5]',
- },
- },
- },
- },
-};
-
-module.exports = function (merge) {
- if (process.env.NODE_ENV === 'development') {
- return merge({}, config, require('./dev'));
- }
- return merge({}, config, require('./prod'));
-};
diff --git a/example/taro/config/prod.js b/example/taro/config/prod.js
deleted file mode 100644
index 8221c9f5e8..0000000000
--- a/example/taro/config/prod.js
+++ /dev/null
@@ -1,17 +0,0 @@
-module.exports = {
- env: {
- NODE_ENV: '"production"',
- },
- defineConstants: {},
- mini: {},
- h5: {
- /**
- * 如果h5端编译后体积过大,可以使用webpack-bundle-analyzer插件对打包体积进行分析。
- * 参考代码如下:
- * webpackChain (chain) {
- * chain.plugin('analyzer')
- * .use(require('webpack-bundle-analyzer').BundleAnalyzerPlugin, [])
- * }
- */
- },
-};
diff --git a/example/taro/global.d.ts b/example/taro/global.d.ts
deleted file mode 100644
index b04f7dca8c..0000000000
--- a/example/taro/global.d.ts
+++ /dev/null
@@ -1,18 +0,0 @@
-///
-
-declare module '*.png';
-declare module '*.gif';
-declare module '*.jpg';
-declare module '*.jpeg';
-declare module '*.svg';
-declare module '*.css';
-declare module '*.less';
-declare module '*.scss';
-declare module '*.sass';
-declare module '*.styl';
-
-declare namespace NodeJS {
- interface ProcessEnv {
- TARO_ENV: 'weapp' | 'swan' | 'alipay' | 'h5' | 'rn' | 'tt' | 'quickapp' | 'qq' | 'jd';
- }
-}
diff --git a/example/taro/package.json b/example/taro/package.json
deleted file mode 100644
index 2e58f54752..0000000000
--- a/example/taro/package.json
+++ /dev/null
@@ -1,63 +0,0 @@
-{
- "name": "taro",
- "version": "1.0.0",
- "private": true,
- "description": "ahooks taro 测试环境",
- "templateInfo": {
- "name": "default",
- "typescript": true,
- "css": "sass"
- },
- "scripts": {
- "build:weapp": "taro build --type weapp",
- "build:swan": "taro build --type swan",
- "build:alipay": "taro build --type alipay",
- "build:tt": "taro build --type tt",
- "build:h5": "taro build --type h5",
- "build:rn": "taro build --type rn",
- "build:qq": "taro build --type qq",
- "build:jd": "taro build --type jd",
- "build:quickapp": "taro build --type quickapp",
- "dev:weapp": "npm run build:weapp -- --watch",
- "dev:swan": "npm run build:swan -- --watch",
- "dev:alipay": "npm run build:alipay -- --watch",
- "dev:tt": "npm run build:tt -- --watch",
- "dev:h5": "npm run build:h5 -- --watch",
- "dev:rn": "npm run build:rn -- --watch",
- "dev:qq": "npm run build:qq -- --watch",
- "dev:jd": "npm run build:jd -- --watch",
- "dev:quickapp": "npm run build:quickapp -- --watch"
- },
- "browserslist": [
- "last 3 versions",
- "Android >= 4.1",
- "ios >= 8"
- ],
- "author": "",
- "dependencies": {
- "@babel/runtime": "^7.7.7",
- "@tarojs/components": "3.2.9",
- "@tarojs/runtime": "3.2.9",
- "@tarojs/taro": "3.2.9",
- "@tarojs/react": "3.2.9",
- "react-dom": "^17.0.0",
- "react": "^17.0.0"
- },
- "devDependencies": {
- "@types/webpack-env": "^1.13.6",
- "@types/react": "^17.0.2",
- "@tarojs/mini-runner": "3.2.9",
- "@babel/core": "^7.8.0",
- "@tarojs/webpack-runner": "3.2.9",
- "babel-preset-taro": "3.2.9",
- "eslint-config-taro": "3.2.9",
- "eslint": "^6.8.0",
- "eslint-plugin-react": "^7.8.2",
- "eslint-plugin-import": "^2.12.0",
- "eslint-plugin-react-hooks": "^4.2.0",
- "stylelint": "9.3.0",
- "@typescript-eslint/parser": "^4.15.1",
- "@typescript-eslint/eslint-plugin": "^4.15.1",
- "typescript": "^4.1.0"
- }
-}
diff --git a/example/taro/project.config.json b/example/taro/project.config.json
deleted file mode 100644
index ec13320b36..0000000000
--- a/example/taro/project.config.json
+++ /dev/null
@@ -1,13 +0,0 @@
-{
- "miniprogramRoot": "./dist",
- "projectname": "taro",
- "description": "ahooks taro 测试环境",
- "appid": "touristappid",
- "setting": {
- "urlCheck": true,
- "es6": false,
- "postcss": false,
- "minified": false
- },
- "compileType": "miniprogram"
-}
diff --git a/example/taro/project.tt.json b/example/taro/project.tt.json
deleted file mode 100644
index 2ccd1811f7..0000000000
--- a/example/taro/project.tt.json
+++ /dev/null
@@ -1,9 +0,0 @@
-{
- "miniprogramRoot": "./",
- "projectname": "taro",
- "appid": "touristappid",
- "setting": {
- "es6": false,
- "minified": false
- }
-}
diff --git a/example/taro/src/app.config.ts b/example/taro/src/app.config.ts
deleted file mode 100644
index ab42835af5..0000000000
--- a/example/taro/src/app.config.ts
+++ /dev/null
@@ -1,9 +0,0 @@
-export default {
- pages: ['pages/index/index'],
- window: {
- backgroundTextStyle: 'light',
- navigationBarBackgroundColor: '#fff',
- navigationBarTitleText: 'WeChat',
- navigationBarTextStyle: 'black',
- },
-};
diff --git a/example/taro/src/app.scss b/example/taro/src/app.scss
deleted file mode 100644
index e69de29bb2..0000000000
diff --git a/example/taro/src/app.ts b/example/taro/src/app.ts
deleted file mode 100644
index 82bb720c04..0000000000
--- a/example/taro/src/app.ts
+++ /dev/null
@@ -1,19 +0,0 @@
-import { Component } from 'react';
-import './app.scss';
-
-class App extends Component {
- componentDidMount() {}
-
- componentDidShow() {}
-
- componentDidHide() {}
-
- componentDidCatchError() {}
-
- // this.props.children 是将要会渲染的页面
- render() {
- return this.props.children;
- }
-}
-
-export default App;
diff --git a/example/taro/src/index.html b/example/taro/src/index.html
deleted file mode 100644
index e857596e2b..0000000000
--- a/example/taro/src/index.html
+++ /dev/null
@@ -1,48 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/example/taro/src/pages/index/index.config.ts b/example/taro/src/pages/index/index.config.ts
deleted file mode 100644
index e9c431b3b6..0000000000
--- a/example/taro/src/pages/index/index.config.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-export default {
- navigationBarTitleText: '首页',
-};
diff --git a/example/taro/src/pages/index/index.scss b/example/taro/src/pages/index/index.scss
deleted file mode 100644
index e69de29bb2..0000000000
diff --git a/example/taro/src/pages/index/index.tsx b/example/taro/src/pages/index/index.tsx
deleted file mode 100644
index 587c5e4745..0000000000
--- a/example/taro/src/pages/index/index.tsx
+++ /dev/null
@@ -1,9 +0,0 @@
-import { Component } from 'react';
-import { View } from '@tarojs/components';
-import './index.scss';
-
-const Index = () => {
- return Taro 测试 ;
-};
-
-export default Index;
diff --git a/example/taro/tsconfig.json b/example/taro/tsconfig.json
deleted file mode 100644
index 72c432c7a5..0000000000
--- a/example/taro/tsconfig.json
+++ /dev/null
@@ -1,25 +0,0 @@
-{
- "compilerOptions": {
- "target": "es2017",
- "module": "commonjs",
- "removeComments": false,
- "preserveConstEnums": true,
- "moduleResolution": "node",
- "experimentalDecorators": true,
- "noImplicitAny": false,
- "allowSyntheticDefaultImports": true,
- "outDir": "lib",
- "noUnusedLocals": true,
- "noUnusedParameters": true,
- "strictNullChecks": true,
- "sourceMap": true,
- "baseUrl": ".",
- "rootDir": ".",
- "jsx": "react-jsx",
- "allowJs": true,
- "resolveJsonModule": true,
- "typeRoots": ["node_modules/@types", "global.d.ts"]
- },
- "exclude": ["node_modules", "dist"],
- "compileOnSave": false
-}
diff --git a/gulpfile.js b/gulpfile.js
index 740f80cf66..5ddb4d5adb 100644
--- a/gulpfile.js
+++ b/gulpfile.js
@@ -3,31 +3,44 @@ const babel = require('gulp-babel');
const ts = require('gulp-typescript');
const del = require('del');
-gulp.task('clean', async function () {
+gulp.task('clean', async () => {
await del('lib/**');
await del('es/**');
await del('dist/**');
});
-gulp.task('cjs', function () {
- return gulp
+gulp.task('cjs', () =>
+ gulp
.src(['./es/**/*.js'])
.pipe(
babel({
configFile: '../../.babelrc',
}),
)
- .pipe(gulp.dest('lib/'));
-});
+ .pipe(gulp.dest('lib/')),
+);
-gulp.task('es', function () {
- const tsProject = ts.createProject('tsconfig.pro.json', {
- module: 'ESNext',
- });
- return tsProject.src().pipe(tsProject()).pipe(babel()).pipe(gulp.dest('es/'));
+gulp.task('es', async () => {
+ const { execSync } = require('child_process');
+
+ // 使用 tsc 直接编译
+ console.log('Running TypeScript compilation...');
+ execSync('npx tsc --project tsconfig.pro.json --outDir es --module esnext', { stdio: 'inherit' });
+ console.log('TypeScript compilation completed');
+
+ // 然后运行 babel 转换
+ console.log('Running Babel transformation...');
+ return gulp
+ .src(['es/**/*.js'])
+ .pipe(
+ babel({
+ configFile: './.babelrc',
+ }),
+ )
+ .pipe(gulp.dest('es/'));
});
-gulp.task('declaration', function () {
+gulp.task('declaration', () => {
const tsProject = ts.createProject('tsconfig.pro.json', {
declaration: true,
emitDeclarationOnly: true,
@@ -35,7 +48,7 @@ gulp.task('declaration', function () {
return tsProject.src().pipe(tsProject()).pipe(gulp.dest('es/')).pipe(gulp.dest('lib/'));
});
-gulp.task('copyReadme', async function () {
+gulp.task('copyReadme', async () => {
await gulp.src('../../README.md').pipe(gulp.dest('../../packages/hooks'));
});
diff --git a/jest.config.js b/jest.config.js
deleted file mode 100644
index ffa689389f..0000000000
--- a/jest.config.js
+++ /dev/null
@@ -1,23 +0,0 @@
-module.exports = {
- preset: 'ts-jest/presets/js-with-ts',
- testEnvironment: 'jsdom',
- clearMocks: true,
- testPathIgnorePatterns: ['/.history/'],
- modulePathIgnorePatterns: ['/package.json'],
- resetMocks: false,
- setupFiles: ['./jest.setup.ts', 'jest-localstorage-mock'],
- globals: {
- 'ts-jest': {
- tsconfig: 'tsconfig.json',
- },
- },
- collectCoverageFrom: [
- '/**/src/**/*.{js,jsx,ts,tsx}',
- '!**/demo/**',
- '!**/example/**',
- '!**/es/**',
- '!**/lib/**',
- '!**/dist/**',
- ],
- transformIgnorePatterns: ['^.+\\.js$'],
-};
diff --git a/jest.setup.ts b/jest.setup.ts
deleted file mode 100644
index eb3a7bf649..0000000000
--- a/jest.setup.ts
+++ /dev/null
@@ -1,14 +0,0 @@
-// mock screen full events
-// https://github.com/sindresorhus/screenfull/blob/main/index.js
-const screenfullMethods = [
- 'requestFullscreen',
- 'exitFullscreen',
- 'fullscreenElement',
- 'fullscreenEnabled',
- 'fullscreenchange',
- 'fullscreenerror',
-];
-screenfullMethods.forEach((item) => {
- document[item] = () => {};
- HTMLElement.prototype[item] = () => {};
-});
diff --git a/package.json b/package.json
index b885810bbc..89d394fa73 100755
--- a/package.json
+++ b/package.json
@@ -1,6 +1,10 @@
{
"name": "ahooks",
"private": true,
+ "packageManager": "pnpm@10.12.4",
+ "engines": {
+ "pnpm": ">=7 <=10"
+ },
"repository": {
"type": "git",
"url": "git+https://github.com/alibaba/hooks.git"
@@ -8,49 +12,51 @@
"scripts": {
"init": "pnpm install && pnpm run build",
"start": "pnpm run dev",
- "dev": "dumi dev",
+ "dev": "cross-env NODE_OPTIONS=--openssl-legacy-provider dumi dev",
"clean-dist": "rimraf 'packages/*/{lib,es,node_modules,dist}'",
"clean": "pnpm run clean-dist && rimraf node_modules",
"build": "pnpm -r --filter=./packages/* run build",
- "test": "jest",
- "coveralls": "jest --coverage --coverageReporters=text-lcov | coveralls",
- "lint": "eslint --ignore-pattern **/__tests__/* --ignore-pattern **/demo/* \"packages/*/src/**/*.{ts,tsx}\"",
- "pretty": "pretty-quick --staged",
- "build:doc": "dumi build",
+ "test": "pnpm --filter=./packages/* test",
+ "test:strict": "cross-env REACT_MODE=strict pnpm --filter=./packages/* test:cov",
+ "coveralls": "vitest run --coverage | coveralls",
+ "lint": "biome lint --fix",
+ "pretty": "biome format --fix --no-errors-on-unmatched",
+ "build:doc": "cross-env NODE_OPTIONS=--openssl-legacy-provider dumi build",
+ "build:doc-github": "node scripts/build-with-relative-paths.js",
"pub:doc-surge": "surge ./dist --domain ahooks.js.org",
- "pub:doc-gitee": "cd ./dist && rm -rf .git && touch .spa && touch .nojekyll && git init && git remote add origin git@gitee.com:ahooks/ahooks.git && git add -A && git commit -m \"publish docs\" && git push origin master -f && echo https://gitee.com/ahooks/ahooks/pages",
- "pub:doc": "pnpm run build:doc && pnpm run pub:doc-surge && pnpm run pub:doc-gitee",
+ "pub:doc-gitee": "cd ./dist && rm -rf .git && touch .spa && touch .nojekyll && git init && git remote add origin git@gitee.com:ahooks/ahooks.git && git add -A && git commit -m \"publish docs\" && git push origin main -f && echo https://gitee.com/ahooks/ahooks/pages",
+ "pub:doc": "pnpm run build:doc && pnpm run pub:doc-surge && pnpm run build:doc-github",
"pub": "pnpm run build && pnpm -r --filter=./packages/* publish",
"pub:beta": "pnpm run build && pnpm -r --filter=./packages/* publish --tag beta",
"preinstall": "npx only-allow pnpm",
- "prepare": "husky install"
+ "prepare": "husky install",
+ "commit": "git add -A && czg",
+ "tsc": "pnpm --filter=./packages/* tsc"
},
"devDependencies": {
- "@ant-design/icons": "^4.6.2",
+ "@alifd/next": "^1.27.32",
+ "@ant-design/icons": "^5.6.1",
"@babel/cli": "^7.10.1",
"@babel/core": "^7.10.2",
"@babel/plugin-transform-runtime": "^7.19.6",
+ "@biomejs/biome": "^2.0.6",
"@commitlint/cli": "^17.1.2",
"@commitlint/config-conventional": "^17.1.0",
- "@testing-library/react": "^10.0.4",
- "@testing-library/react-hooks": "^8.0.1",
- "@types/jest": "^27.4.1",
- "@types/lodash.debounce": "^4.0.6",
- "@types/lodash.isequal": "^4.5.5",
- "@types/lodash.throttle": "^4.1.6",
- "@types/react-router": "^5.1.18",
+ "@testing-library/react": "^16.3.0",
+ "@types/lodash": "^4.17.20",
+ "@types/mockjs": "^1.0.7",
+ "@types/react": "^19.1.8",
+ "@types/react-dom": "^19.1.6",
+ "@types/react-router": "^5.1.19",
"@umijs/fabric": "^2.1.0",
- "@umijs/plugin-sass": "^1.1.1",
- "antd": "^4.3.3",
- "babel-loader": "^8.1.0",
+ "@vitest/coverage-istanbul": "^3.2.4",
+ "antd": "^5.26.3",
"babel-plugin-import": "^1.12.0",
- "babel-plugin-transform-async-to-promises": "^0.8.15",
"coveralls": "^3.1.1",
+ "cross-env": "^7.0.3",
+ "czg": "^1.12.0",
"del": "^5.1.0",
- "dumi": "^1.1.48",
- "enzyme": "^3.10.0",
- "eslint": "^7.2.0",
- "eslint-plugin-react-hooks": "^4.0.8",
+ "dumi": "^1.1.54",
"fast-glob": "^3.2.11",
"fs-extra": "^10.0.1",
"gray-matter": "^4.0.3",
@@ -58,29 +64,26 @@
"gulp-babel": "^8.0.0",
"gulp-typescript": "^6.0.0-alpha.1",
"husky": "^8.0.0",
- "jest": "^27.5.1",
- "jest-fetch-mock": "^3.0.3",
- "jest-localstorage-mock": "^2.4.18",
+ "jsdom": "^26.1.0",
"mockjs": "^1.1.0",
- "prettier": "^2.0.5",
- "pretty-quick": "^3.1.3",
- "react": "^16.8.6",
- "react-dom": "^16.8.6",
+ "react": "^19.1.0",
+ "react-dom": "^19.1.0",
"react-drag-listview": "^0.1.6",
- "react-shadow": "^19.0.3",
- "react-test-renderer": "^16.13.1",
+ "react-json-view": "^1.21.3",
+ "react-router": "^6.4.2",
+ "react-shadow": "^20.6.0",
"rimraf": "^3.0.2",
"surge": "^0.21.3",
- "ts-jest": "^27.1.5",
- "typescript": "^4.8.4",
- "umi-request": "^1.2.18",
- "webpack": "^4.43.0",
- "webpack-cli": "^3.3.10",
- "webpack-merge": "^4.2.2"
+ "typescript": "^5.8.3",
+ "vitest": "^3.2.4",
+ "vitest-websocket-mock": "^0.5.0",
+ "webpack": "^5.99.9",
+ "webpack-cli": "^6.0.1",
+ "webpack-merge": "^6.0.1"
},
"commitlint": {
"extends": [
"@commitlint/config-conventional"
]
}
-}
\ No newline at end of file
+}
diff --git a/packages/hooks/package.json b/packages/hooks/package.json
index cc1d40ca46..5895137a61 100644
--- a/packages/hooks/package.json
+++ b/packages/hooks/package.json
@@ -1,6 +1,6 @@
{
"name": "ahooks",
- "version": "3.7.2",
+ "version": "3.9.7",
"description": "react hooks library",
"keywords": [
"ahooks",
@@ -22,7 +22,10 @@
"repository": "https://github.com/alibaba/hooks",
"homepage": "https://github.com/alibaba/hooks",
"scripts": {
- "build": "gulp && webpack-cli"
+ "build": "gulp && webpack-cli",
+ "test": "vitest run --color",
+ "test:cov": "vitest run --color --coverage",
+ "tsc": "tsc --noEmit"
},
"files": [
"dist",
@@ -33,31 +36,20 @@
"README.md"
],
"dependencies": {
- "@types/js-cookie": "^2.x.x",
- "ahooks-v3-count": "^1.0.0",
+ "@types/js-cookie": "^3.0.6",
+ "@babel/runtime": "^7.21.0",
"dayjs": "^1.9.1",
"intersection-observer": "^0.12.0",
- "js-cookie": "^2.x.x",
+ "js-cookie": "^3.0.5",
"lodash": "^4.17.21",
+ "react-fast-compare": "^3.2.2",
"resize-observer-polyfill": "^1.5.1",
- "screenfull": "^5.0.0"
+ "screenfull": "^5.0.0",
+ "tslib": "^2.4.1"
},
"peerDependencies": {
- "react": "^16.8.0 || ^17.0.0 || ^18.0.0"
- },
- "devDependencies": {
- "@alifd/next": "^1.20.6",
- "@ant-design/icons": "^4.6.2",
- "@types/enzyme": "^3.10.5",
- "antd": "^4.16.9",
- "enzyme-adapter-react-16": "^1.15.4",
- "jest-websocket-mock": "^2.1.0",
- "mock-socket": "^9.0.3",
- "mockjs": "^1.1.0",
- "react-drag-listview": "^0.1.6"
- },
- "engines": {
- "node": ">=8.0.0"
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
+ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
},
"license": "MIT",
"gitHead": "11f6ad571bd365c95ecb9409ca3050cbbfc9b34a"
diff --git a/packages/hooks/src/__tests__/index.test.ts b/packages/hooks/src/__tests__/index.test.ts
deleted file mode 100644
index 533c859ee6..0000000000
--- a/packages/hooks/src/__tests__/index.test.ts
+++ /dev/null
@@ -1,9 +0,0 @@
-import * as ahooks from '..';
-
-describe('ahooks', () => {
- test('exports modules should be defined', () => {
- Object.keys(ahooks).forEach((module) => {
- expect(ahooks[module]).toBeDefined();
- });
- });
-});
diff --git a/packages/hooks/src/createDeepCompareEffect/__tests__/index.test.ts b/packages/hooks/src/createDeepCompareEffect/__tests__/index.spec.ts
similarity index 85%
rename from packages/hooks/src/createDeepCompareEffect/__tests__/index.test.ts
rename to packages/hooks/src/createDeepCompareEffect/__tests__/index.spec.ts
index 59f18152b0..1356e8c452 100644
--- a/packages/hooks/src/createDeepCompareEffect/__tests__/index.test.ts
+++ b/packages/hooks/src/createDeepCompareEffect/__tests__/index.spec.ts
@@ -1,9 +1,10 @@
-import { act, renderHook } from '@testing-library/react-hooks';
+import { act, renderHook } from '@testing-library/react';
import { useEffect, useLayoutEffect, useState } from 'react';
+import { describe, expect, test } from 'vitest';
import { createDeepCompareEffect } from '../index';
describe('createDeepCompareEffect', () => {
- it('should work for useEffect', async () => {
+ test('should work for useEffect', async () => {
const useDeepCompareEffect = createDeepCompareEffect(useEffect);
const hook = renderHook(() => {
@@ -30,7 +31,7 @@ describe('createDeepCompareEffect', () => {
expect(hook.result.current.x).toBe(2);
});
- it('should work for useLayoutEffect', async () => {
+ test('should work for useLayoutEffect', async () => {
const useDeepCompareLayoutEffect = createDeepCompareEffect(useLayoutEffect);
const hook = renderHook(() => {
@@ -57,7 +58,7 @@ describe('createDeepCompareEffect', () => {
expect(hook.result.current.x).toBe(2);
});
- it('deps is undefined should rerender in useEffect', async () => {
+ test('deps is undefined should rerender in useEffect', async () => {
const useDeepCompareLayoutEffect = createDeepCompareEffect(useEffect);
let count = 0;
const hook = renderHook(() => {
@@ -73,7 +74,7 @@ describe('createDeepCompareEffect', () => {
expect(count).toBe(3);
});
- it('deps is undefined should rerender in useLayoutEffect', async () => {
+ test('deps is undefined should rerender in useLayoutEffect', async () => {
const useDeepCompareLayoutEffect = createDeepCompareEffect(useLayoutEffect);
let count = 0;
const hook = renderHook(() => {
diff --git a/packages/hooks/src/createDeepCompareEffect/index.ts b/packages/hooks/src/createDeepCompareEffect/index.ts
index b641b52e52..afccc6d373 100644
--- a/packages/hooks/src/createDeepCompareEffect/index.ts
+++ b/packages/hooks/src/createDeepCompareEffect/index.ts
@@ -1,22 +1,17 @@
import { useRef } from 'react';
import type { DependencyList, useEffect, useLayoutEffect } from 'react';
-import isEqual from 'lodash/isEqual';
+import { depsEqual } from '../utils/depsEqual';
type EffectHookType = typeof useEffect | typeof useLayoutEffect;
-type CreateUpdateEffect = (hook: EffectHookType) => EffectHookType;
-const depsEqual = (aDeps: DependencyList = [], bDeps: DependencyList = []) => {
- return isEqual(aDeps, bDeps);
-};
+type CreateUpdateEffect = (hook: EffectHookType) => EffectHookType;
export const createDeepCompareEffect: CreateUpdateEffect = (hook) => (effect, deps) => {
- const ref = useRef();
+ const ref = useRef(undefined);
const signalRef = useRef(0);
-
if (deps === undefined || !depsEqual(deps, ref.current)) {
- ref.current = deps;
signalRef.current += 1;
}
-
+ ref.current = deps;
hook(effect, [signalRef.current]);
};
diff --git a/packages/hooks/src/createUpdateEffect/__tests__/index.test.ts b/packages/hooks/src/createUpdateEffect/__tests__/index.spec.ts
similarity index 64%
rename from packages/hooks/src/createUpdateEffect/__tests__/index.test.ts
rename to packages/hooks/src/createUpdateEffect/__tests__/index.spec.ts
index 7da736fd42..25bb50ee08 100644
--- a/packages/hooks/src/createUpdateEffect/__tests__/index.test.ts
+++ b/packages/hooks/src/createUpdateEffect/__tests__/index.spec.ts
@@ -1,9 +1,10 @@
-import { renderHook } from '@testing-library/react-hooks';
+import { renderHook } from '@testing-library/react';
import { useEffect, useLayoutEffect } from 'react';
+import { describe, expect, test } from 'vitest';
import { createUpdateEffect } from '../index';
describe('createUpdateEffect', () => {
- it('should work for useEffect', () => {
+ test('should work for useEffect', () => {
const useUpdateEffect = createUpdateEffect(useEffect);
let mountedState = 1;
@@ -12,12 +13,12 @@ describe('createUpdateEffect', () => {
mountedState = 2;
}),
);
- expect(mountedState).toEqual(1);
+ expect(mountedState).toBe(1);
hook.rerender();
- expect(mountedState).toEqual(2);
+ expect(mountedState).toBe(2);
});
- it('should work for useLayoutEffect', () => {
+ test('should work for useLayoutEffect', () => {
const useUpdateLayoutEffect = createUpdateEffect(useLayoutEffect);
let mountedState = 1;
@@ -26,8 +27,8 @@ describe('createUpdateEffect', () => {
mountedState = 2;
}),
);
- expect(mountedState).toEqual(1);
+ expect(mountedState).toBe(1);
hook.rerender();
- expect(mountedState).toEqual(2);
+ expect(mountedState).toBe(2);
});
});
diff --git a/packages/hooks/src/createUseStorageState/__tests__/index.spec.ts b/packages/hooks/src/createUseStorageState/__tests__/index.spec.ts
new file mode 100644
index 0000000000..4a5a0f5984
--- /dev/null
+++ b/packages/hooks/src/createUseStorageState/__tests__/index.spec.ts
@@ -0,0 +1,144 @@
+import { act, renderHook } from '@testing-library/react';
+import { createElement } from 'react';
+import { renderToString } from 'react-dom/server';
+import { describe, expect, test, vi } from 'vitest';
+import type { Options } from '../index';
+import { createUseStorageState } from '../index';
+
+class TestStorage implements Storage {
+ [name: string]: any;
+
+ length = 0;
+
+ _values = new Map();
+
+ clear(): void {
+ this._values.clear();
+ this.length = 0;
+ }
+
+ getItem(key: string): string | null {
+ return this._values.get(key) || null;
+ }
+
+ key(index: number): string | null {
+ if (index >= this._values.size) {
+ return null;
+ }
+
+ return Array.from(this._values.keys())[index];
+ }
+
+ removeItem(key: string): void {
+ if (this._values.delete(key)) {
+ this.length -= 1;
+ }
+ }
+
+ setItem(key: string, value: string): void {
+ if (!this._values.has(key)) {
+ this.length += 1;
+ }
+
+ this._values.set(key, value);
+ }
+}
+
+interface StorageStateProps extends Pick, 'defaultValue'> {
+ key: string;
+}
+
+describe('useStorageState', () => {
+ const setUp = (props: StorageStateProps) => {
+ const storage = new TestStorage();
+ const useStorageState = createUseStorageState(() => storage);
+
+ return renderHook(
+ ({ key, defaultValue }: StorageStateProps) => {
+ const [state, setState] = useStorageState(key, { defaultValue });
+
+ return { state, setState };
+ },
+ {
+ initialProps: props,
+ },
+ );
+ };
+
+ test('should get defaultValue for a given key', () => {
+ const hook = setUp({ key: 'key1', defaultValue: 'value1' });
+ expect(hook.result.current.state).toBe('value1');
+
+ hook.rerender({ key: 'key2', defaultValue: 'value2' });
+ expect(hook.result.current.state).toBe('value2');
+ });
+
+ test('should get default and set value for a given key', () => {
+ const hook = setUp({ key: 'key', defaultValue: 'defaultValue' });
+ expect(hook.result.current.state).toBe('defaultValue');
+ act(() => {
+ hook.result.current.setState('setValue');
+ });
+ expect(hook.result.current.state).toBe('setValue');
+ hook.rerender({ key: 'key' });
+ expect(hook.result.current.state).toBe('setValue');
+ });
+
+ test('should remove value for a given key', () => {
+ const hook = setUp({ key: 'key' });
+ act(() => {
+ hook.result.current.setState('value');
+ });
+ expect(hook.result.current.state).toBe('value');
+ act(() => {
+ hook.result.current.setState(undefined);
+ });
+ expect(hook.result.current.state).toBeUndefined();
+
+ act(() => hook.result.current.setState('value'));
+ expect(hook.result.current.state).toBe('value');
+ act(() => hook.result.current.setState(undefined));
+ expect(hook.result.current.state).toBeUndefined();
+ });
+
+ test('should not read storage in SSR when getInitialValueInEffect is true', () => {
+ const storage = new TestStorage();
+ storage.setItem('key', JSON.stringify('stored-value'));
+ const getItemSpy = vi.spyOn(storage, 'getItem');
+ const useStorageState = createUseStorageState(() => storage);
+
+ const Demo = () => {
+ const [state] = useStorageState('key', {
+ defaultValue: 'default-value',
+ getInitialValueInEffect: true,
+ });
+
+ return createElement('span', null, state);
+ };
+
+ const html = renderToString(createElement(Demo));
+
+ expect(html).toContain('default-value');
+ expect(getItemSpy).not.toHaveBeenCalled();
+ });
+
+ test('should read storage in SSR when getInitialValueInEffect is false', () => {
+ const storage = new TestStorage();
+ storage.setItem('key', JSON.stringify('stored-value'));
+ const getItemSpy = vi.spyOn(storage, 'getItem');
+ const useStorageState = createUseStorageState(() => storage);
+
+ const Demo = () => {
+ const [state] = useStorageState('key', {
+ defaultValue: 'default-value',
+ });
+
+ return createElement('span', null, state);
+ };
+
+ const html = renderToString(createElement(Demo));
+
+ expect(html).toContain('stored-value');
+ expect(getItemSpy).toHaveBeenCalledWith('key');
+ });
+});
diff --git a/packages/hooks/src/createUseStorageState/__tests__/index.test.ts b/packages/hooks/src/createUseStorageState/__tests__/index.test.ts
deleted file mode 100644
index 937558b1b3..0000000000
--- a/packages/hooks/src/createUseStorageState/__tests__/index.test.ts
+++ /dev/null
@@ -1,95 +0,0 @@
-import { act, renderHook } from '@testing-library/react-hooks';
-import { IFuncUpdater, createUseStorageState } from '../index';
-
-class TestStorage implements Storage {
- [name: string]: any;
-
- length: number = 0;
-
- _values = new Map();
-
- clear(): void {
- this._values.clear();
- this.length = 0;
- }
-
- getItem(key: string): string | null {
- return this._values.get(key) || null;
- }
-
- key(index: number): string | null {
- if (index >= this._values.size) {
- return null;
- }
-
- return Array.from(this._values.keys())[index];
- }
-
- removeItem(key: string): void {
- if (this._values.delete(key)) {
- this.length -= 1;
- }
- }
-
- setItem(key: string, value: string): void {
- if (!this._values.has(key)) {
- this.length += 1;
- }
-
- this._values.set(key, value);
- }
-}
-
-interface StorageStateProps {
- key: string;
- defaultValue?: T | IFuncUpdater;
-}
-
-describe('useStorageState', () => {
- const setUp = (props: StorageStateProps) => {
- const storage = new TestStorage();
- const useStorageState = createUseStorageState(() => storage);
-
- return renderHook(
- ({ key, defaultValue }: StorageStateProps) => {
- const [state, setState] = useStorageState(key, { defaultValue });
-
- return { state, setState };
- },
- {
- initialProps: props,
- },
- );
- };
-
- it('should get defaultValue for a given key', () => {
- const hook = setUp({ key: 'key1', defaultValue: 'value1' });
- expect(hook.result.current.state).toEqual('value1');
-
- hook.rerender({ key: 'key2', defaultValue: 'value2' });
- expect(hook.result.current.state).toEqual('value2');
- });
-
- it('should get default and set value for a given key', () => {
- const hook = setUp({ key: 'key', defaultValue: 'defaultValue' });
- expect(hook.result.current.state).toEqual('defaultValue');
- act(() => {
- hook.result.current.setState('setValue');
- });
- expect(hook.result.current.state).toEqual('setValue');
- hook.rerender({ key: 'key' });
- expect(hook.result.current.state).toEqual('setValue');
- });
-
- it('should remove value for a given key', () => {
- const hook = setUp({ key: 'key' });
- act(() => {
- hook.result.current.setState('value');
- });
- expect(hook.result.current.state).toEqual('value');
- act(() => {
- hook.result.current.setState(undefined);
- });
- expect(hook.result.current.state).toBeUndefined();
- });
-});
diff --git a/packages/hooks/src/createUseStorageState/index.ts b/packages/hooks/src/createUseStorageState/index.ts
index 83696798b8..a0effaeb8f 100644
--- a/packages/hooks/src/createUseStorageState/index.ts
+++ b/packages/hooks/src/createUseStorageState/index.ts
@@ -1,84 +1,163 @@
-/* eslint-disable no-empty */
-import { useState } from 'react';
+import { useRef, useState } from 'react';
+import useEventListener from '../useEventListener';
import useMemoizedFn from '../useMemoizedFn';
+import useMount from '../useMount';
import useUpdateEffect from '../useUpdateEffect';
import { isFunction, isUndef } from '../utils';
-export interface IFuncUpdater {
- (previousState?: T): T;
-}
-export interface IFuncStorage {
- (): Storage;
-}
+export const SYNC_STORAGE_EVENT_NAME = 'AHOOKS_SYNC_STORAGE_EVENT_NAME';
+
+export type SetState = S | ((prevState?: S) => S);
export interface Options {
+ defaultValue?: T | (() => T);
+ getInitialValueInEffect?: boolean;
+ listenStorageChange?: boolean;
serializer?: (value: T) => string;
deserializer?: (value: string) => T;
- defaultValue?: T | IFuncUpdater;
+ onError?: (error: unknown) => void;
}
-export function createUseStorageState(getStorage: () => Storage | undefined) {
- function useStorageState(key: string, options?: Options) {
+export const createUseStorageState = (getStorage: () => Storage | undefined) => {
+ const useStorageState = (key: string, options: Options = {}) => {
let storage: Storage | undefined;
+ const { listenStorageChange = false, getInitialValueInEffect = false } = options;
+
+ const serializer = isFunction(options.serializer) ? options.serializer : JSON.stringify;
+
+ const deserializer = isFunction(options.deserializer) ? options.deserializer : JSON.parse;
+
+ const onError = isFunction(options.onError) ? options.onError : console.error;
// https://github.com/alibaba/hooks/issues/800
try {
storage = getStorage();
} catch (err) {
- console.error(err);
+ onError(err);
}
- const serializer = (value: T) => {
- if (options?.serializer) {
- return options?.serializer(value);
- }
- return JSON.stringify(value);
- };
-
- const deserializer = (value: string) => {
- if (options?.deserializer) {
- return options?.deserializer(value);
- }
- return JSON.parse(value);
- };
-
- function getStoredValue() {
+ const getStoredValue = () => {
try {
const raw = storage?.getItem(key);
if (raw) {
return deserializer(raw);
}
} catch (e) {
- console.error(e);
+ onError(e);
}
- if (isFunction(options?.defaultValue)) {
- return options?.defaultValue();
- }
- return options?.defaultValue;
+ return getDefaultValue();
}
- const [state, setState] = useState(() => getStoredValue());
+ function getDefaultValue() {
+ if (isFunction(options.defaultValue)) {
+ return options.defaultValue();
+ }
+
+ return options.defaultValue;
+ };
+
+ const [state, setState] = useState(() => {
+ if (getInitialValueInEffect) {
+ return getDefaultValue();
+ }
+
+ return getStoredValue();
+ });
+
+ useMount(() => {
+ if (!getInitialValueInEffect) {
+ return;
+ }
- useUpdateEffect(() => {
setState(getStoredValue());
+ });
+
+ const stateRef = useRef(state);
+ stateRef.current = state;
+
+ useUpdateEffect(() => {
+ const nextState = getStoredValue();
+ if (Object.is(nextState, stateRef.current)) {
+ return; // 新旧状态相同,不更新 state,避免 setState 带来不必要的 re-render
+ }
+ stateRef.current = nextState;
+ setState(nextState);
}, [key]);
- const updateState = (value: T | IFuncUpdater) => {
- const currentState = isFunction(value) ? value(state) : value;
- setState(currentState);
-
- if (isUndef(currentState)) {
- storage?.removeItem(key);
- } else {
- try {
- storage?.setItem(key, serializer(currentState));
- } catch (e) {
- console.error(e);
+ const updateState = (value: SetState) => {
+ const previousState = stateRef.current;
+ const currentState = isFunction(value) ? value(previousState) : value;
+
+ if (Object.is(currentState, previousState)) {
+ return; // 新旧状态相同,不更新 state,避免 setState 带来不必要的 re-render
+ }
+
+ if (!listenStorageChange) {
+ stateRef.current = currentState;
+ setState(currentState);
+ }
+
+ try {
+ let newValue: string | null;
+ const oldValue = storage?.getItem(key);
+
+ if (isUndef(currentState)) {
+ newValue = null;
+ storage?.removeItem(key);
+ } else {
+ newValue = serializer(currentState);
+ storage?.setItem(key, newValue);
}
+
+ dispatchEvent(
+ // send custom event to communicate within same page
+ // importantly this should not be a StorageEvent since those cannot
+ // be constructed with a non-built-in storage area
+ new CustomEvent(SYNC_STORAGE_EVENT_NAME, {
+ detail: {
+ key,
+ newValue,
+ oldValue,
+ storageArea: storage,
+ },
+ }),
+ );
+ } catch (e) {
+ onError(e);
+ }
+ };
+
+ const syncState = (event: StorageEvent) => {
+ if (event.key !== key || event.storageArea !== storage) {
+ return;
+ }
+
+ const nextState = getStoredValue();
+
+ if (Object.is(nextState, stateRef.current)) {
+ return; // 新旧状态相同,不更新 state,避免 setState 带来不必要的 re-render
}
+
+ stateRef.current = nextState;
+ setState(nextState);
};
+ const syncStateFromCustomEvent = (event: CustomEvent) => {
+ syncState(event.detail);
+ };
+
+ // from another document
+ useEventListener('storage', syncState, {
+ enable: listenStorageChange,
+ });
+
+ // from the same document but different hooks
+ useEventListener(SYNC_STORAGE_EVENT_NAME, syncStateFromCustomEvent, {
+ enable: listenStorageChange,
+ });
+
return [state, useMemoizedFn(updateState)] as const;
- }
+ };
+
return useStorageState;
-}
+};
diff --git a/packages/hooks/src/index.spec.ts b/packages/hooks/src/index.spec.ts
new file mode 100644
index 0000000000..55c80f44ec
--- /dev/null
+++ b/packages/hooks/src/index.spec.ts
@@ -0,0 +1,10 @@
+import { describe, expect, test } from 'vitest';
+import * as ahooks from '.';
+
+describe('ahooks', () => {
+ test('exports modules should be defined', () => {
+ Object.entries(ahooks).forEach(([key, value]) => {
+ expect(value).toBeDefined();
+ });
+ });
+});
diff --git a/packages/hooks/src/index.ts b/packages/hooks/src/index.ts
index 58cefd9c79..55c7232b0d 100644
--- a/packages/hooks/src/index.ts
+++ b/packages/hooks/src/index.ts
@@ -50,7 +50,7 @@ import useRafTimeout from './useRafTimeout';
import useReactive from './useReactive';
import useRequest, { clearCache } from './useRequest';
import useResetState from './useResetState';
-import { configResponsive, useResponsive } from './useResponsive';
+import useResponsive, { configResponsive } from './useResponsive';
import useSafeState from './useSafeState';
import useScroll from './useScroll';
import useSelections from './useSelections';
@@ -75,6 +75,7 @@ import useVirtualList from './useVirtualList';
import useWebSocket from './useWebSocket';
import useWhyDidYouUpdate from './useWhyDidYouUpdate';
import useMutationObserver from './useMutationObserver';
+import useTheme from './useTheme';
export {
useRequest,
@@ -156,4 +157,5 @@ export {
useRafTimeout,
useResetState,
useMutationObserver,
+ useTheme,
};
diff --git a/packages/hooks/src/useAntdTable/__tests__/index.spec.ts b/packages/hooks/src/useAntdTable/__tests__/index.spec.ts
new file mode 100644
index 0000000000..365b71a873
--- /dev/null
+++ b/packages/hooks/src/useAntdTable/__tests__/index.spec.ts
@@ -0,0 +1,382 @@
+import type { RenderHookResult } from '@testing-library/react';
+import { act, renderHook, waitFor } from '@testing-library/react';
+import { Form } from 'antd';
+import { useEffect } from 'react';
+import { describe, expect, test } from 'vitest';
+import { sleep } from '../../utils/testingHelpers';
+import useAntdTable from '../index';
+
+interface Query {
+ current: number;
+ pageSize: number;
+
+ [key: string]: any;
+}
+
+describe('useAntdTable', () => {
+ let queryArgs: any;
+ const asyncFn = (query: Query, formData: any = {}) => {
+ queryArgs = { ...query, ...formData };
+ return Promise.resolve({
+ total: 20,
+ list: [],
+ });
+ };
+
+ let searchType = 'simple';
+
+ const form = {
+ getInternalHooks: () => {},
+ initialValue: {
+ name: 'default name',
+ },
+ fieldsValue: {
+ name: 'default name',
+ },
+ getFieldsValue() {
+ if (searchType === 'simple') {
+ return {
+ name: this.fieldsValue.name,
+ };
+ }
+ return this.fieldsValue;
+ },
+ setFieldsValue(values: object) {
+ this.fieldsValue = {
+ ...this.fieldsValue,
+ ...values,
+ };
+ },
+ resetFields() {
+ this.fieldsValue = { ...this.initialValue };
+ },
+ validateFields(fields: any[]) {
+ const targetFields: Record = {};
+ fields.forEach((field: string | number) => {
+ targetFields[field] = (this.fieldsValue as any)[field];
+ });
+ return Promise.resolve(targetFields);
+ },
+ };
+
+ const changeSearchType = (type: any) => {
+ searchType = type;
+ };
+
+ const setUp = (
+ service: Parameters[0],
+ options: Parameters[1],
+ ) => renderHook((o) => useAntdTable(service, o || options));
+
+ let hook: RenderHookResult;
+
+ test('should fetch after first render', async () => {
+ queryArgs = undefined;
+ form.resetFields();
+ changeSearchType('simple');
+
+ act(() => {
+ hook = setUp(asyncFn, {});
+ });
+
+ expect(hook.result.current.tableProps.loading).toBe(false);
+ expect(hook.result.current.tableProps.pagination.current).toBe(1);
+ expect(hook.result.current.tableProps.pagination.pageSize).toBe(10);
+ await waitFor(() => expect(hook.result.current.tableProps.pagination.total).toBe(20));
+ });
+
+ test('should defaultParams work', async () => {
+ queryArgs = undefined;
+ form.resetFields();
+ changeSearchType('advance');
+ act(() => {
+ hook = setUp(asyncFn, {
+ form,
+ defaultParams: [
+ {
+ current: 2,
+ pageSize: 10,
+ },
+ { name: 'hello', phone: '123' },
+ ],
+ defaultType: 'advance',
+ });
+ });
+ const { search } = hook.result.current;
+ expect(hook.result.current.tableProps.loading).toBe(false);
+ await waitFor(() => expect(queryArgs.current).toBe(2));
+ expect(queryArgs.pageSize).toBe(10);
+ expect(queryArgs.name).toBe('hello');
+ expect(queryArgs.phone).toBe('123');
+ expect(search.type).toBe('advance');
+ });
+
+ test('should stop the query when validate fields failed', async () => {
+ queryArgs = undefined;
+ form.resetFields();
+ changeSearchType('advance');
+ act(() => {
+ hook = setUp(asyncFn, {
+ form: { ...form, validateFields: () => Promise.reject() },
+ defaultParams: [
+ {
+ current: 2,
+ pageSize: 10,
+ },
+ { name: 'hello', phone: '123' },
+ ],
+ defaultType: 'advance',
+ });
+ });
+
+ await sleep(1);
+ expect(queryArgs).toBeUndefined();
+ });
+
+ test('should ready work', async () => {
+ queryArgs = undefined;
+ form.resetFields();
+ changeSearchType('advance');
+
+ act(() => {
+ hook = setUp(asyncFn, {
+ ready: false,
+ form,
+ defaultParams: [
+ {
+ current: 2,
+ pageSize: 10,
+ },
+ { name: 'hello', phone: '123' },
+ ],
+ defaultType: 'advance',
+ });
+ });
+ await sleep(1);
+ expect(queryArgs).toBeUndefined();
+
+ hook.rerender({
+ ready: true,
+ form,
+ defaultParams: [
+ {
+ current: 2,
+ pageSize: 10,
+ },
+ { name: 'hello', phone: '456' },
+ ],
+ defaultType: 'advance',
+ });
+
+ const { search } = hook.result.current;
+ expect(hook.result.current.tableProps.loading).toBe(false);
+ await waitFor(() => expect(queryArgs.current).toBe(2));
+ expect(queryArgs.pageSize).toBe(10);
+ expect(queryArgs.name).toBe('hello');
+ expect(queryArgs.phone).toBe('456');
+ expect(search.type).toBe('advance');
+ });
+
+ test('should antd v3 work', async () => {
+ queryArgs = undefined;
+ form.resetFields();
+ changeSearchType('simple');
+
+ const v3Form = {
+ ...form,
+ getInternalHooks: undefined,
+ validateFields: function (fields: any[], callback: (arg0: undefined, arg1: {}) => void) {
+ const targetFields: Record = {};
+ fields.forEach((field: string | number) => {
+ targetFields[field] = (this.fieldsValue as any)[field];
+ });
+ callback(undefined, targetFields);
+ },
+ getFieldInstance(key: string) {
+ // 根据不同的 type 返回不同的 fieldsValues
+ if (searchType === 'simple') {
+ return ['name'].includes(key) as any;
+ }
+ return ['name', 'email', 'phone'].includes(key) as any;
+ },
+ };
+
+ act(() => {
+ hook = setUp(asyncFn, { form: v3Form });
+ });
+ const { search } = hook.result.current;
+ expect(hook.result.current.tableProps.loading).toBe(false);
+ await waitFor(() => expect(queryArgs.current).toBe(1));
+ expect(queryArgs.pageSize).toBe(10);
+ expect(queryArgs.name).toBe('default name');
+ expect(search.type).toBe('simple');
+
+ // /* 切换 分页 */
+ act(() => {
+ hook.result.current.tableProps.onChange({
+ current: 2,
+ pageSize: 5,
+ });
+ });
+ await waitFor(() => expect(queryArgs.current).toBe(2));
+ expect(queryArgs.pageSize).toBe(5);
+ expect(queryArgs.name).toBe('default name');
+
+ /* 改变 name,提交表单 */
+ v3Form.fieldsValue.name = 'change name';
+ act(() => {
+ search.submit();
+ });
+ await waitFor(() => expect(queryArgs.current).toBe(1));
+ expect(queryArgs.current).toBe(1);
+ // expect(queryArgs.pageSize).toBe(5);
+ expect(queryArgs.name).toBe('change name');
+ });
+
+ test('should reset pageSize in defaultParams', async () => {
+ queryArgs = undefined;
+ form.resetFields();
+ act(() => {
+ hook = setUp(asyncFn, {
+ form,
+ defaultParams: [
+ {
+ current: 1,
+ pageSize: 10,
+ },
+ ],
+ });
+ });
+
+ const { search, tableProps } = hook.result.current;
+ expect(tableProps.loading).toBe(false);
+ await waitFor(() => expect(queryArgs.current).toBe(1));
+ expect(queryArgs.pageSize).toBe(10);
+
+ // change params
+ act(() => {
+ tableProps.onChange({
+ current: 2,
+ pageSize: 5,
+ });
+ });
+
+ await waitFor(() => {
+ expect(queryArgs.current).toBe(2);
+ expect(queryArgs.pageSize).toBe(5);
+ });
+
+ // reset params
+ act(() => {
+ search.reset();
+ });
+
+ await waitFor(() => {
+ expect(queryArgs.current).toBe(1);
+ expect(queryArgs.pageSize).toBe(10);
+ });
+ });
+
+ test('should reset pageSize in defaultPageSize', async () => {
+ queryArgs = undefined;
+ form.resetFields();
+ act(() => {
+ hook = setUp(asyncFn, {
+ form,
+ defaultParams: {
+ current: 1,
+ pageSize: 10,
+ } as any,
+ defaultPageSize: 20,
+ });
+ });
+
+ const { search, tableProps } = hook.result.current;
+ expect(tableProps.loading).toBe(false);
+ await waitFor(() => expect(queryArgs.current).toBe(1));
+ expect(queryArgs.pageSize).toBe(20);
+
+ // change params
+ act(() => {
+ tableProps.onChange({
+ current: 2,
+ pageSize: 5,
+ });
+ });
+
+ await waitFor(() => {
+ expect(queryArgs.current).toBe(2);
+ expect(queryArgs.pageSize).toBe(5);
+ });
+
+ // reset params
+ act(() => {
+ search.reset();
+ });
+
+ await waitFor(() => {
+ expect(queryArgs.current).toBe(1);
+ expect(queryArgs.pageSize).toBe(20);
+ });
+ });
+
+ test('search submit use default params', async () => {
+ queryArgs = undefined;
+ form.resetFields();
+ act(() => {
+ hook = setUp(asyncFn, {
+ form,
+ defaultParams: [
+ {
+ current: 2,
+ pageSize: 100,
+ },
+ ],
+ });
+ });
+
+ const { search } = hook.result.current;
+
+ act(() => {
+ search.submit();
+ });
+
+ await waitFor(() => {
+ expect(queryArgs.current).toBe(2);
+ expect(queryArgs.pageSize).toBe(100);
+ });
+ });
+
+ test('should defaultParams work with manual is true', async () => {
+ queryArgs = undefined;
+ form.resetFields();
+ changeSearchType('advance');
+
+ act(() => {
+ renderHook((o) => {
+ const [myForm] = Form.useForm();
+
+ useAntdTable(
+ asyncFn,
+ o || {
+ form: myForm,
+ defaultParams: [
+ {
+ current: 2,
+ pageSize: 10,
+ },
+ { name: 'hello', phone: '123' },
+ ],
+ defaultType: 'advance',
+ },
+ );
+
+ useEffect(() => {
+ // defaultParams works
+ expect(myForm.getFieldValue('name')).toBe('hello');
+ expect(queryArgs).toBe(undefined);
+ }, []);
+ });
+ });
+ });
+});
diff --git a/packages/hooks/src/useAntdTable/__tests__/index.test.ts b/packages/hooks/src/useAntdTable/__tests__/index.test.ts
deleted file mode 100644
index 83f5e083eb..0000000000
--- a/packages/hooks/src/useAntdTable/__tests__/index.test.ts
+++ /dev/null
@@ -1,384 +0,0 @@
-import { act, renderHook } from '@testing-library/react-hooks';
-import { sleep } from '../../utils/testingHelpers';
-import useAntdTable from '../index';
-
-interface Query {
- current: number;
- pageSize: number;
- [key: string]: any;
-}
-
-describe('useAntdTable', () => {
- // jest.useFakeTimers();
-
- let queryArgs: any;
- const asyncFn = (query: Query, formData: any = {}) => {
- queryArgs = { ...query, ...formData };
- return Promise.resolve({
- total: 20,
- list: [],
- });
- };
-
- let searchType = 'simple';
-
- const form: any = {
- getInternalHooks: () => {},
- initialValue: {
- name: 'default name',
- },
- fieldsValue: {
- name: 'default name',
- },
- getFieldsValue() {
- if (searchType === 'simple') {
- return {
- name: this.fieldsValue.name,
- };
- }
- return this.fieldsValue;
- },
- setFieldsValue(values: object) {
- this.fieldsValue = {
- ...this.fieldsValue,
- ...values,
- };
- },
- resetFields() {
- this.fieldsValue = { ...this.initialValue };
- },
- validateFields(fields) {
- const targetFileds = {};
- fields.forEach((field) => {
- targetFileds[field] = this.fieldsValue[field];
- });
- return Promise.resolve(targetFileds);
- },
- };
-
- const changeSearchType = (type: any) => {
- searchType = type;
- };
-
- const setUp = (service, options) => renderHook((o) => useAntdTable(service, o || options));
-
- let hook: any;
-
- // afterEach(() => {
- // form.resetFields();
- // changeSearchType('simple');
- // hook?.unmount();
- // });
-
- it('should fetch after first render', async () => {
- queryArgs = undefined;
- form.resetFields();
- changeSearchType('simple');
-
- act(() => {
- hook = setUp(asyncFn, {});
- });
- await hook.waitForNextUpdate();
- expect(hook.result.current.tableProps.loading).toEqual(false);
- expect(hook.result.current.tableProps.pagination.current).toEqual(1);
- expect(hook.result.current.tableProps.pagination.pageSize).toEqual(10);
- expect(hook.result.current.tableProps.pagination.total).toEqual(20);
- hook.unmount();
- });
-
- // it('should form, defaultPageSize, cacheKey work', async () => {
- // queryArgs = undefined;
- // form.resetFields();
- // changeSearchType('simple');
- // act(() => {
- // hook = setUp(asyncFn, { form, defaultPageSize: 5, cacheKey: 'tableId' });
- // });
- // await hook.waitForNextUpdate();
- // const { search } = hook.result.current;
- // expect(hook.result.current.tableProps.loading).toEqual(false);
- // expect(queryArgs.current).toEqual(1);
- // expect(queryArgs.pageSize).toEqual(5);
- // expect(queryArgs.name).toEqual('default name');
- // expect(search.type).toEqual('simple');
-
- // // /* 切换 分页 */
- // act(() => {
- // hook.result.current.tableProps.onChange({
- // current: 2,
- // pageSize: 5,
- // });
- // });
- // await hook.waitForNextUpdate();
- // expect(queryArgs.current).toEqual(2);
- // expect(queryArgs.pageSize).toEqual(5);
- // expect(queryArgs.name).toEqual('default name');
-
- // /* 改变 name, 提交表单 */
- // form.fieldsValue.name = 'change name';
- // act(() => {
- // search.submit();
- // });
- // await hook.waitForNextUpdate();
- // expect(queryArgs.current).toEqual(1);
- // expect(queryArgs.pageSize).toEqual(5);
- // expect(queryArgs.name).toEqual('change name');
-
- // // /* 切换 searchType 到 advance */
- // act(() => {
- // if (search) {
- // search.changeType();
- // changeSearchType('advance');
- // }
- // });
- // expect(hook.result.current.search.type).toEqual('advance');
- // act(() => {
- // hook.result.current.search.submit();
- // });
- // await hook.waitForNextUpdate();
-
- // expect(queryArgs.current).toEqual(1);
- // expect(queryArgs.name).toEqual('change name');
-
- // // /* 手动改变其他两个字段的值 */
- // form.fieldsValue.phone = '13344556677';
- // form.fieldsValue.email = 'x@qq.com';
-
- // act(() => {
- // hook.result.current.search.submit();
- // });
- // await hook.waitForNextUpdate();
- // expect(queryArgs.current).toEqual(1);
- // expect(queryArgs.name).toEqual('change name');
- // expect(queryArgs.phone).toEqual('13344556677');
- // expect(queryArgs.email).toEqual('x@qq.com');
-
- // // /* 改变 name,但是不提交,切换到 simple 去 */
- // form.fieldsValue.name = 'change name 2';
- // act(() => {
- // hook.result.current.search.changeType();
- // changeSearchType('simple');
- // });
- // expect(hook.result.current.search.type).toEqual('simple');
- // expect(form.fieldsValue.name).toEqual('change name 2');
- // // /* 提交 */
- // act(() => {
- // hook.result.current.search.submit();
- // });
- // await hook.waitForNextUpdate();
-
- // expect(queryArgs.name).toEqual('change name 2');
- // expect(queryArgs.phone).toBeUndefined();
- // expect(queryArgs.email).toBeUndefined();
-
- // // /* 切换回 advance,恢复之前的条件 */
- // act(() => {
- // hook.result.current.search.changeType();
- // changeSearchType('advance');
- // });
-
- // if (hook.result.current.search) {
- // expect(hook.result.current.search.type).toEqual('advance');
- // }
- // expect(form.fieldsValue.name).toEqual('change name 2');
- // expect(form.fieldsValue.phone).toEqual('13344556677');
- // expect(form.fieldsValue.email).toEqual('x@qq.com');
-
- // act(() => {
- // hook.result.current.tableProps.onChange({
- // current: 3,
- // pageSize: 5,
- // });
- // });
- // await hook.waitForNextUpdate();
- // // /* 卸载重装 */
- // form.fieldsValue = {
- // name: '',
- // phone: '',
- // email: '',
- // };
- // act(() => {
- // hook.unmount();
- // });
- // act(() => {
- // hook = setUp(asyncFn, { form, defaultPageSize: 5, cacheKey: 'tableId' });
- // });
- // await hook.waitForNextUpdate();
- // expect(hook.result.current.search.type).toEqual('simple');
- // expect(hook.result.current.tableProps.pagination.current).toEqual(3);
- // expect(form.fieldsValue.name).toEqual('change name 2');
- // expect(form.fieldsValue.phone).toEqual('13344556677');
- // expect(form.fieldsValue.email).toEqual('x@qq.com');
-
- // /* refresh */
- // act(() => {
- // hook.result.current.refresh();
- // });
- // // expect(hook.result.current.tableProps.loading).toEqual(true);
- // await hook.waitForNextUpdate();
- // /* reset */
- // act(() => {
- // hook.result.current.search.reset();
- // });
-
- // expect(form.fieldsValue.name).toEqual('default name');
- // expect(form.fieldsValue.phone).toBeUndefined();
- // expect(form.fieldsValue.email).toBeUndefined();
- // hook.unmount();
- // }, 60000);
-
- it('should defaultParams work', async () => {
- queryArgs = undefined;
- form.resetFields();
- changeSearchType('advance');
- act(() => {
- hook = setUp(asyncFn, {
- form,
- defaultParams: [
- {
- current: 2,
- pageSize: 10,
- },
- { name: 'hello', phone: '123' },
- ],
- defaultType: 'advance',
- });
- });
- await hook.waitForNextUpdate();
- const { search } = hook.result.current;
- expect(hook.result.current.tableProps.loading).toEqual(false);
- expect(queryArgs.current).toEqual(2);
- expect(queryArgs.pageSize).toEqual(10);
- expect(queryArgs.name).toEqual('hello');
- expect(queryArgs.phone).toEqual('123');
- expect(search.type).toEqual('advance');
- hook.unmount();
- });
-
- it('should stop the query when validate fields failed', async () => {
- queryArgs = undefined;
- form.resetFields();
- changeSearchType('advance');
- act(() => {
- hook = setUp(asyncFn, {
- form: { ...form, validateFields: () => Promise.reject() },
- defaultParams: [
- {
- current: 2,
- pageSize: 10,
- },
- { name: 'hello', phone: '123' },
- ],
- defaultType: 'advance',
- });
- });
-
- await sleep(1);
- expect(queryArgs).toEqual(undefined);
- hook.unmount();
- });
-
- it('should ready work', async () => {
- queryArgs = undefined;
- form.resetFields();
- changeSearchType('advance');
-
- act(() => {
- hook = setUp(asyncFn, {
- ready: false,
- form,
- defaultParams: [
- {
- current: 2,
- pageSize: 10,
- },
- { name: 'hello', phone: '123' },
- ],
- defaultType: 'advance',
- });
- });
- await sleep(1);
- expect(queryArgs).toEqual(undefined);
-
- hook.rerender({
- ready: true,
- form,
- defaultParams: [
- {
- current: 2,
- pageSize: 10,
- },
- { name: 'hello', phone: '456' },
- ],
- defaultType: 'advance',
- });
-
- await hook.waitForNextUpdate();
- const { search } = hook.result.current;
- expect(hook.result.current.tableProps.loading).toEqual(false);
- expect(queryArgs.current).toEqual(2);
- expect(queryArgs.pageSize).toEqual(10);
- expect(queryArgs.name).toEqual('hello');
- expect(queryArgs.phone).toEqual('456');
- expect(search.type).toEqual('advance');
- hook.unmount();
- });
-
- it('should antd v3 work', async () => {
- queryArgs = undefined;
- form.resetFields();
- changeSearchType('simple');
-
- const v3Form = {
- ...form,
- getInternalHooks: undefined,
- validateFields: function (fields, callback) {
- const targetFileds = {};
- fields.forEach((field) => {
- targetFileds[field] = this.fieldsValue[field];
- });
- callback(undefined, targetFileds);
- },
- getFieldInstance(key: string) {
- // 根据不同的 type 返回不同的 fieldsValues
- if (searchType === 'simple') {
- return ['name'].includes(key);
- }
- return ['name', 'email', 'phone'].includes(key);
- },
- };
-
- act(() => {
- hook = setUp(asyncFn, { form: v3Form });
- });
- await hook.waitForNextUpdate();
- const { search } = hook.result.current;
- expect(hook.result.current.tableProps.loading).toEqual(false);
- expect(queryArgs.current).toEqual(1);
- expect(queryArgs.pageSize).toEqual(10);
- expect(queryArgs.name).toEqual('default name');
- expect(search.type).toEqual('simple');
-
- // /* 切换 分页 */
- act(() => {
- hook.result.current.tableProps.onChange({
- current: 2,
- pageSize: 5,
- });
- });
- await hook.waitForNextUpdate();
- expect(queryArgs.current).toEqual(2);
- expect(queryArgs.pageSize).toEqual(5);
- expect(queryArgs.name).toEqual('default name');
-
- /* 改变 name, 提交表单 */
- v3Form.fieldsValue.name = 'change name';
- act(() => {
- search.submit();
- });
- await hook.waitForNextUpdate();
- expect(queryArgs.current).toEqual(1);
- expect(queryArgs.pageSize).toEqual(5);
- expect(queryArgs.name).toEqual('change name');
- hook.unmount();
- });
-});
diff --git a/packages/hooks/src/useAntdTable/demo/cache.tsx b/packages/hooks/src/useAntdTable/demo/cache.tsx
index 232f56c0fe..f001404a7d 100644
--- a/packages/hooks/src/useAntdTable/demo/cache.tsx
+++ b/packages/hooks/src/useAntdTable/demo/cache.tsx
@@ -1,6 +1,7 @@
-import React, { useState } from 'react';
+import { useState } from 'react';
import { Button, Col, Form, Input, Row, Table, Select } from 'antd';
import { useAntdTable, clearCache } from 'ahooks';
+import ReactJson from 'react-json-view';
const { Option } = Select;
@@ -19,10 +20,22 @@ interface Result {
}
const getTableData = (
- { current, pageSize, sorter, filters },
- formData: Object,
+ {
+ current,
+ pageSize,
+ sorter,
+ filters,
+ extra,
+ }: {
+ current: number;
+ pageSize: number;
+ sorter: any;
+ filters: any;
+ extra: any;
+ },
+ formData: Record,
): Promise => {
- console.log(sorter, filters);
+ console.log(sorter, filters, extra);
let query = `page=${current}&size=${pageSize}`;
Object.entries(formData).forEach(([key, value]) => {
if (value) {
@@ -135,11 +148,13 @@ const UserList = () => {
return (
{type === 'simple' ? searchForm : advanceSearchForm}
-
+
-
Current Table: {JSON.stringify(params[0])}
-
Current Form: {JSON.stringify(params[1])}
+
Current Table:
+
+
Current Form:
+
);
diff --git a/packages/hooks/src/useAntdTable/demo/form.tsx b/packages/hooks/src/useAntdTable/demo/form.tsx
index db41cc997e..fc1a4a6c25 100644
--- a/packages/hooks/src/useAntdTable/demo/form.tsx
+++ b/packages/hooks/src/useAntdTable/demo/form.tsx
@@ -1,6 +1,6 @@
-import React from 'react';
import { Button, Col, Form, Input, Row, Table, Select } from 'antd';
import { useAntdTable } from 'ahooks';
+import ReactJson from 'react-json-view';
const { Option } = Select;
@@ -18,7 +18,16 @@ interface Result {
list: Item[];
}
-const getTableData = ({ current, pageSize }, formData: Object): Promise => {
+const getTableData = (
+ {
+ current,
+ pageSize,
+ }: {
+ current: number;
+ pageSize: number;
+ },
+ formData: Object,
+): Promise => {
let query = `page=${current}&size=${pageSize}`;
Object.entries(formData).forEach(([key, value]) => {
if (value) {
@@ -121,11 +130,13 @@ export default () => {
return (
{type === 'simple' ? searchForm : advanceSearchForm}
-
+
-
Current Table: {JSON.stringify(params[0])}
-
Current Form: {JSON.stringify(params[1])}
+
Current Table:
+
+
Current Form:
+
);
diff --git a/packages/hooks/src/useAntdTable/demo/init.tsx b/packages/hooks/src/useAntdTable/demo/init.tsx
index cc1e5549a6..30888f0c31 100644
--- a/packages/hooks/src/useAntdTable/demo/init.tsx
+++ b/packages/hooks/src/useAntdTable/demo/init.tsx
@@ -1,6 +1,6 @@
-import React from 'react';
import { Button, Col, Form, Input, Row, Table, Select } from 'antd';
import { useAntdTable } from 'ahooks';
+import ReactJson from 'react-json-view';
const { Option } = Select;
@@ -18,7 +18,16 @@ interface Result {
list: Item[];
}
-const getTableData = ({ current, pageSize }, formData: Object): Promise => {
+const getTableData = (
+ {
+ current,
+ pageSize,
+ }: {
+ current: number;
+ pageSize: number;
+ },
+ formData: Object,
+): Promise => {
let query = `page=${current}&size=${pageSize}`;
Object.entries(formData).forEach(([key, value]) => {
if (value) {
@@ -37,7 +46,7 @@ const getTableData = ({ current, pageSize }, formData: Object): Promise
export default () => {
const [form] = Form.useForm();
- const { loading, tableProps, search, params } = useAntdTable(getTableData, {
+ const { tableProps, search, params } = useAntdTable(getTableData, {
form,
defaultParams: [
{ current: 2, pageSize: 5 },
@@ -125,11 +134,13 @@ export default () => {
return (
{type === 'simple' ? searchForm : advanceSearchForm}
-
+
-
Current Table: {JSON.stringify(params[0])}
-
Current Form: {JSON.stringify(params[1])}
+
Current Table:
+
+
Current Form:
+
);
diff --git a/packages/hooks/src/useAntdTable/demo/ready.tsx b/packages/hooks/src/useAntdTable/demo/ready.tsx
index 00e627bd09..9732649452 100644
--- a/packages/hooks/src/useAntdTable/demo/ready.tsx
+++ b/packages/hooks/src/useAntdTable/demo/ready.tsx
@@ -1,6 +1,7 @@
-import React, { useState } from 'react';
+import { useState } from 'react';
import { Button, Col, Form, Input, Row, Table, Select } from 'antd';
import { useAntdTable } from 'ahooks';
+import ReactJson from 'react-json-view';
const { Option } = Select;
@@ -18,7 +19,16 @@ interface Result {
list: Item[];
}
-const getTableData = ({ current, pageSize }, formData: Object): Promise => {
+const getTableData = (
+ {
+ current,
+ pageSize,
+ }: {
+ current: number;
+ pageSize: number;
+ },
+ formData: Object,
+): Promise => {
let query = `page=${current}&size=${pageSize}`;
Object.entries(formData).forEach(([key, value]) => {
if (value) {
@@ -130,11 +140,13 @@ export default () => {
setReady((r) => !r)}>toggle ready
{type === 'simple' ? searchForm : advanceSearchForm}
-
+
-
Current Table: {JSON.stringify(params[0])}
-
Current Form: {JSON.stringify(params[1])}
+
Current Table:
+
+
Current Form:
+
);
diff --git a/packages/hooks/src/useAntdTable/demo/table.tsx b/packages/hooks/src/useAntdTable/demo/table.tsx
index ecfa26698d..d93d679d55 100644
--- a/packages/hooks/src/useAntdTable/demo/table.tsx
+++ b/packages/hooks/src/useAntdTable/demo/table.tsx
@@ -1,5 +1,4 @@
import { Table } from 'antd';
-import React from 'react';
import { useAntdTable } from 'ahooks';
interface Item {
@@ -16,7 +15,13 @@ interface Result {
list: Item[];
}
-const getTableData = ({ current, pageSize }): Promise => {
+const getTableData = ({
+ current,
+ pageSize,
+}: {
+ current: number;
+ pageSize: number;
+}): Promise => {
const query = `page=${current}&size=${pageSize}`;
return fetch(`https://randomuser.me/api?results=55&${query}`)
@@ -49,5 +54,5 @@ export default () => {
},
];
- return ;
+ return ;
};
diff --git a/packages/hooks/src/useAntdTable/demo/validate.tsx b/packages/hooks/src/useAntdTable/demo/validate.tsx
index 1cc5ab63de..2a0dd3f76d 100644
--- a/packages/hooks/src/useAntdTable/demo/validate.tsx
+++ b/packages/hooks/src/useAntdTable/demo/validate.tsx
@@ -1,6 +1,6 @@
import { Form, Input, Select, Table } from 'antd';
-import React from 'react';
import { useAntdTable } from 'ahooks';
+import ReactJson from 'react-json-view';
const { Option } = Select;
@@ -18,7 +18,16 @@ interface Result {
list: Item[];
}
-const getTableData = ({ current, pageSize }, formData: Object): Promise => {
+const getTableData = (
+ {
+ current,
+ pageSize,
+ }: {
+ current: number;
+ pageSize: number;
+ },
+ formData: Object,
+): Promise => {
let query = `page=${current}&size=${pageSize}`;
Object.entries(formData).forEach(([key, value]) => {
if (value) {
@@ -87,11 +96,13 @@ export default () => {
return (
{searchForm}
-
+
-
Current Table: {JSON.stringify(params[0])}
-
Current Form: {JSON.stringify(params[1])}
+
Current Table:
+
+
Current Form:
+
);
diff --git a/packages/hooks/src/useAntdTable/index.en-US.md b/packages/hooks/src/useAntdTable/index.en-US.md
index cbe4be447d..5b2ae41fbc 100644
--- a/packages/hooks/src/useAntdTable/index.en-US.md
+++ b/packages/hooks/src/useAntdTable/index.en-US.md
@@ -9,7 +9,7 @@ nav:
Before using it, you need to understand a few points that are different from `useRequest`:
-1. `service` receives two parameters, the first parameter is the paging data `{ current, pageSize, sorter, filters }`, and the second parameter is the form data.
+1. `service` receives two parameters, the first parameter is the paging data `{ current, pageSize, sorter, filters, extra }`, and the second parameter is the form data.
2. The data structure returned by `service` must be `{ total: number, list: Item[] }`.
3. Additional `tableProps` and `search` fields will be returned to manage tables and forms.
4. When `refreshDeps` changes, it will reset `current` to the first page and re-initiate the request.
@@ -72,7 +72,7 @@ All parameters and returned results of `useRequest` are applicable to `useAntdTa
```typescript
type Data = { total: number; list: any[] };
-type Params = [{ current: number; pageSize: number, filter?: any, sorter?: any }, { [key: string]: any }];
+type Params = [{ current: number; pageSize: number, filters?: any, sorter?: any, extra?: any }, { [key: string]: any }];
const {
...,
@@ -83,6 +83,7 @@ const {
pagination: any,
filters?: any,
sorter?: any,
+ extra?: any,
) => void;
pagination: {
current: number;
diff --git a/packages/hooks/src/useAntdTable/index.tsx b/packages/hooks/src/useAntdTable/index.tsx
index 8671fc82db..b6341cea9d 100644
--- a/packages/hooks/src/useAntdTable/index.tsx
+++ b/packages/hooks/src/useAntdTable/index.tsx
@@ -27,8 +27,14 @@ const useAntdTable = (
} = options;
const result = usePagination(service, {
+ ready,
manual: true,
...rest,
+ onSuccess(...args) {
+ // eslint-disable-next-line @typescript-eslint/no-use-before-define
+ runSuccessRef.current = true;
+ rest.onSuccess?.(...args);
+ },
});
const { params = [], run } = result;
@@ -39,6 +45,7 @@ const useAntdTable = (
const allFormDataRef = useRef>({});
const defaultDataSourceRef = useRef([]);
+ const runSuccessRef = useRef(false);
const isAntdV4 = !!form?.getInternalHooks;
@@ -55,7 +62,7 @@ const useAntdTable = (
// antd 3
const allFieldsValue = form.getFieldsValue();
- const activeFieldsValue = {};
+ const activeFieldsValue: Record = {};
Object.keys(allFieldsValue).forEach((key: string) => {
if (form.getFieldInstance ? form.getFieldInstance(key) : true) {
activeFieldsValue[key] = allFieldsValue[key];
@@ -99,7 +106,7 @@ const useAntdTable = (
}
// antd v3
- const activeFieldsValue = {};
+ const activeFieldsValue: Record = {};
Object.keys(allFormDataRef.current).forEach((key) => {
if (form.getFieldInstance ? form.getFieldInstance(key) : true) {
activeFieldsValue[key] = allFormDataRef.current[key];
@@ -114,7 +121,7 @@ const useAntdTable = (
...allFormDataRef.current,
...activeFieldsValue,
};
- setType((t) => (t === 'simple' ? 'advance' : 'simple'));
+ setType((t: string) => (t === 'simple' ? 'advance' : 'simple'));
};
const _submit = (initPagination?: TParams[0]) => {
@@ -155,15 +162,27 @@ const useAntdTable = (
if (form) {
form.resetFields();
}
- _submit();
+ _submit({
+ ...(defaultParams?.[0] || {}),
+ pageSize: options.defaultPageSize || options.defaultParams?.[0]?.pageSize || 10,
+ current: 1,
+ });
};
const submit = (e?: any) => {
e?.preventDefault?.();
- _submit();
+ _submit(
+ runSuccessRef.current
+ ? undefined
+ : {
+ pageSize: options.defaultPageSize || options.defaultParams?.[0]?.pageSize || 10,
+ current: 1,
+ ...(defaultParams?.[0] || {}),
+ },
+ );
};
- const onTableChange = (pagination: any, filters: any, sorter: any) => {
+ const onTableChange = (pagination: any, filters: any, sorter: any, extra: any) => {
const [oldPaginationParams, ...restParams] = params || [];
run(
// @ts-ignore
@@ -173,6 +192,7 @@ const useAntdTable = (
pageSize: pagination.pageSize,
filters,
sorter,
+ extra,
},
...restParams,
);
@@ -188,10 +208,12 @@ const useAntdTable = (
run(...params);
return;
}
- if (!manual && ready) {
+ if (ready) {
allFormDataRef.current = defaultParams?.[1] || {};
restoreForm();
- _submit(defaultParams?.[0]);
+ if (!manual) {
+ _submit(defaultParams?.[0]);
+ }
}
}, []);
@@ -228,7 +250,11 @@ const useAntdTable = (
}
if (!manual) {
hasAutoRun.current = true;
- result.pagination.changeCurrent(1);
+ if (options.refreshDepsAction) {
+ options.refreshDepsAction();
+ } else {
+ result.pagination.changeCurrent(1);
+ }
}
}, [...refreshDeps]);
diff --git a/packages/hooks/src/useAntdTable/index.zh-CN.md b/packages/hooks/src/useAntdTable/index.zh-CN.md
index 95f314dc31..6f18abceb8 100644
--- a/packages/hooks/src/useAntdTable/index.zh-CN.md
+++ b/packages/hooks/src/useAntdTable/index.zh-CN.md
@@ -10,7 +10,7 @@ nav:
在使用之前,你需要了解它与 `useRequest` 不同的几个点:
-1. `service` 接收两个参数,第一个参数为分页数据 `{ current, pageSize, sorter, filters }`,第二个参数为表单数据。
+1. `service` 接收两个参数,第一个参数为分页数据 `{ current, pageSize, sorter, filters, extra }`,第二个参数为表单数据。
2. `service` 返回的数据结构为 `{ total: number, list: Item[] }`。
3. 会额外返回 `tableProps` 和 `search` 字段,管理表格和表单。
4. `refreshDeps` 变化,会重置 `current` 到第一页,并重新发起请求。
@@ -73,7 +73,7 @@ nav:
```typescript
type Data = { total: number; list: any[] };
-type Params = [{ current: number; pageSize: number, filter?: any, sorter?: any }, { [key: string]: any }];
+type Params = [{ current: number; pageSize: number, filters?: any, sorter?: any, extra?: any }, { [key: string]: any }];
const {
...,
@@ -84,6 +84,7 @@ const {
pagination: any,
filters?: any,
sorter?: any,
+ extra?: any,
) => void;
pagination: {
current: number;
diff --git a/packages/hooks/src/useAntdTable/types.ts b/packages/hooks/src/useAntdTable/types.ts
index 536a1d7eff..f366c21d44 100644
--- a/packages/hooks/src/useAntdTable/types.ts
+++ b/packages/hooks/src/useAntdTable/types.ts
@@ -7,7 +7,8 @@ export type Params = [
current: number;
pageSize: number;
sorter?: any;
- filter?: any;
+ filters?: any;
+ extra?: any;
[key: string]: any;
},
...any[],
@@ -19,7 +20,7 @@ export type Service = (
export type Antd3ValidateFields = (
fieldNames: string[],
- callback: (errors, values: Record) => void,
+ callback: (errors: any, values: Record) => void,
) => void;
export type Antd4ValidateFields = (fieldNames?: string[]) => Promise>;
diff --git a/packages/hooks/src/useAsyncEffect/__tests__/index.test.ts b/packages/hooks/src/useAsyncEffect/__tests__/index.spec.ts
similarity index 87%
rename from packages/hooks/src/useAsyncEffect/__tests__/index.test.ts
rename to packages/hooks/src/useAsyncEffect/__tests__/index.spec.ts
index e6107d731c..bf62143e7d 100644
--- a/packages/hooks/src/useAsyncEffect/__tests__/index.test.ts
+++ b/packages/hooks/src/useAsyncEffect/__tests__/index.spec.ts
@@ -1,10 +1,11 @@
-import { renderHook, act } from '@testing-library/react-hooks';
-import useAsyncEffect from '../index';
+import { act, renderHook } from '@testing-library/react';
import { useState } from 'react';
+import { describe, expect, test } from 'vitest';
import { sleep } from '../../utils/testingHelpers';
+import useAsyncEffect from '../index';
describe('useAsyncEffect', () => {
- it('should work without clean up', async () => {
+ test('should work without clean up', async () => {
const hook = renderHook(() => {
const [x, setX] = useState(0);
useAsyncEffect(async () => {
@@ -20,7 +21,7 @@ describe('useAsyncEffect', () => {
expect(hook.result.current).toBe(1);
});
- it('should work with yield break', async () => {
+ test('should work with yield break', async () => {
const hook = renderHook(() => {
const [x, setX] = useState(1);
const [y, setY] = useState(0);
diff --git a/packages/hooks/src/useAsyncEffect/demo/demo1.tsx b/packages/hooks/src/useAsyncEffect/demo/demo1.tsx
index 75f06152bd..e0f38a0a42 100644
--- a/packages/hooks/src/useAsyncEffect/demo/demo1.tsx
+++ b/packages/hooks/src/useAsyncEffect/demo/demo1.tsx
@@ -7,7 +7,7 @@
*/
import { useAsyncEffect } from 'ahooks';
-import React, { useState } from 'react';
+import { useState } from 'react';
function mockCheck(): Promise {
return new Promise((resolve) => {
diff --git a/packages/hooks/src/useAsyncEffect/demo/demo2.tsx b/packages/hooks/src/useAsyncEffect/demo/demo2.tsx
index a84b931ac6..e54edaf199 100644
--- a/packages/hooks/src/useAsyncEffect/demo/demo2.tsx
+++ b/packages/hooks/src/useAsyncEffect/demo/demo2.tsx
@@ -6,7 +6,7 @@
* desc.zh-CN: 通过 `yield` 语句可以增加一些检查点,如果发现当前 effect 已经被清理,会停止继续往下执行。
*/
-import React, { useState } from 'react';
+import { useState } from 'react';
import { useAsyncEffect } from 'ahooks';
function mockCheck(val: string): Promise {
diff --git a/packages/hooks/src/useAsyncEffect/index.ts b/packages/hooks/src/useAsyncEffect/index.ts
index 3e6ca1e92f..c15f49a894 100644
--- a/packages/hooks/src/useAsyncEffect/index.ts
+++ b/packages/hooks/src/useAsyncEffect/index.ts
@@ -2,15 +2,16 @@ import type { DependencyList } from 'react';
import { useEffect } from 'react';
import { isFunction } from '../utils';
+function isAsyncGenerator(
+ val: AsyncGenerator | Promise,
+): val is AsyncGenerator {
+ return isFunction((val as any)[Symbol.asyncIterator]);
+}
+
function useAsyncEffect(
effect: () => AsyncGenerator | Promise,
deps?: DependencyList,
) {
- function isAsyncGenerator(
- val: AsyncGenerator | Promise,
- ): val is AsyncGenerator {
- return isFunction(val[Symbol.asyncIterator]);
- }
useEffect(() => {
const e = effect();
let cancelled = false;
diff --git a/packages/hooks/src/useBoolean/__tests__/index.test.ts b/packages/hooks/src/useBoolean/__tests__/index.spec.ts
similarity index 63%
rename from packages/hooks/src/useBoolean/__tests__/index.test.ts
rename to packages/hooks/src/useBoolean/__tests__/index.spec.ts
index 3a235c8255..2498d3fa62 100644
--- a/packages/hooks/src/useBoolean/__tests__/index.test.ts
+++ b/packages/hooks/src/useBoolean/__tests__/index.spec.ts
@@ -1,10 +1,11 @@
-import { renderHook, act } from '@testing-library/react-hooks';
+import { act, renderHook } from '@testing-library/react';
+import { describe, expect, test } from 'vitest';
import useBoolean from '../index';
const setUp = (defaultValue?: boolean) => renderHook(() => useBoolean(defaultValue));
describe('useBoolean', () => {
- it('test on methods', async () => {
+ test('test on methods', async () => {
const { result } = setUp();
expect(result.current[0]).toBe(false);
act(() => {
@@ -43,8 +44,19 @@ describe('useBoolean', () => {
expect(result.current[0]).toBe(true);
});
- it('test on default value', () => {
- const hook = setUp(true);
- expect(hook.result.current[0]).toBe(true);
+ test('test on default value', () => {
+ const hook1 = setUp(true);
+ expect(hook1.result.current[0]).toBe(true);
+ const hook2 = setUp();
+ expect(hook2.result.current[0]).toBe(false);
+ // @ts-ignore
+ const hook3 = setUp(0);
+ expect(hook3.result.current[0]).toBe(false);
+ // @ts-ignore
+ const hook4 = setUp('');
+ expect(hook4.result.current[0]).toBe(false);
+ // @ts-ignore
+ const hook5 = setUp('hello');
+ expect(hook5.result.current[0]).toBe(true);
});
});
diff --git a/packages/hooks/src/useBoolean/demo/demo1.tsx b/packages/hooks/src/useBoolean/demo/demo1.tsx
index b50a11571a..a3e2a97b1a 100644
--- a/packages/hooks/src/useBoolean/demo/demo1.tsx
+++ b/packages/hooks/src/useBoolean/demo/demo1.tsx
@@ -6,7 +6,6 @@
* desc.zh-CN: 切换 boolean,可以接收默认值。
*/
-import React from 'react';
import { useBoolean } from 'ahooks';
export default () => {
diff --git a/packages/hooks/src/useBoolean/index.ts b/packages/hooks/src/useBoolean/index.ts
index b27114605a..d0a2b7a12a 100644
--- a/packages/hooks/src/useBoolean/index.ts
+++ b/packages/hooks/src/useBoolean/index.ts
@@ -9,7 +9,7 @@ export interface Actions {
}
export default function useBoolean(defaultValue = false): [boolean, Actions] {
- const [state, { toggle, set }] = useToggle(defaultValue);
+ const [state, { toggle, set }] = useToggle(!!defaultValue);
const actions: Actions = useMemo(() => {
const setTrue = () => set(true);
diff --git a/packages/hooks/src/useClickAway/__tests__/index.test.ts b/packages/hooks/src/useClickAway/__tests__/index.spec.ts
similarity index 71%
rename from packages/hooks/src/useClickAway/__tests__/index.test.ts
rename to packages/hooks/src/useClickAway/__tests__/index.spec.ts
index 2bfbc70edc..41abfcee5f 100644
--- a/packages/hooks/src/useClickAway/__tests__/index.test.ts
+++ b/packages/hooks/src/useClickAway/__tests__/index.spec.ts
@@ -1,4 +1,5 @@
-import { renderHook } from '@testing-library/react-hooks';
+import { renderHook } from '@testing-library/react';
+import { afterEach, beforeEach, describe, expect, test } from 'vitest';
import useClickAway from '../index';
describe('useClickAway', () => {
@@ -18,7 +19,7 @@ describe('useClickAway', () => {
document.body.removeChild(container1);
});
- it('test on dom optional', async () => {
+ test('test on dom optional', async () => {
let state: number = 0;
const { rerender, unmount } = renderHook((dom: any) =>
useClickAway(() => {
@@ -28,22 +29,22 @@ describe('useClickAway', () => {
rerender(container);
container.click();
- expect(state).toEqual(0);
+ expect(state).toBe(0);
document.body.click();
- expect(state).toEqual(1);
+ expect(state).toBe(1);
rerender(container1);
container1.click();
- expect(state).toEqual(1);
+ expect(state).toBe(1);
document.body.click();
- expect(state).toEqual(2);
+ expect(state).toBe(2);
unmount();
document.body.click();
- expect(state).toEqual(2);
+ expect(state).toBe(2);
});
- it('should works on multiple target', async () => {
+ test('should works on multiple target', async () => {
let state: number = 0;
const { rerender, unmount } = renderHook((dom: any) =>
useClickAway(() => {
@@ -53,14 +54,14 @@ describe('useClickAway', () => {
rerender([container, container1]);
container.click();
- expect(state).toEqual(0);
+ expect(state).toBe(0);
container1.click();
- expect(state).toEqual(0);
+ expect(state).toBe(0);
document.body.click();
- expect(state).toEqual(1);
+ expect(state).toBe(1);
unmount();
document.body.click();
- expect(state).toEqual(1);
+ expect(state).toBe(1);
});
});
diff --git a/packages/hooks/src/useClickAway/demo/demo1.tsx b/packages/hooks/src/useClickAway/demo/demo1.tsx
index 6232f37399..bdf227b8f6 100644
--- a/packages/hooks/src/useClickAway/demo/demo1.tsx
+++ b/packages/hooks/src/useClickAway/demo/demo1.tsx
@@ -6,7 +6,7 @@
* desc.zh-CN: 请点击按钮或按钮外查看效果。
*/
-import React, { useState, useRef } from 'react';
+import { useState, useRef } from 'react';
import { useClickAway } from 'ahooks';
export default () => {
diff --git a/packages/hooks/src/useClickAway/demo/demo2.tsx b/packages/hooks/src/useClickAway/demo/demo2.tsx
index 99eb632cc4..bde14f71a7 100644
--- a/packages/hooks/src/useClickAway/demo/demo2.tsx
+++ b/packages/hooks/src/useClickAway/demo/demo2.tsx
@@ -6,7 +6,7 @@
* desc.zh-CN: 支持直接传入 DOM 对象或 function。
*/
-import React, { useState } from 'react';
+import { useState } from 'react';
import { useClickAway } from 'ahooks';
export default () => {
diff --git a/packages/hooks/src/useClickAway/demo/demo3.tsx b/packages/hooks/src/useClickAway/demo/demo3.tsx
index c7c0cdd5bd..b5f2f67d5c 100644
--- a/packages/hooks/src/useClickAway/demo/demo3.tsx
+++ b/packages/hooks/src/useClickAway/demo/demo3.tsx
@@ -6,7 +6,7 @@
* desc.zh-CN: 支持传入多个目标对象。
*/
-import React, { useState, useRef } from 'react';
+import { useState, useRef } from 'react';
import { useClickAway } from 'ahooks';
export default () => {
diff --git a/packages/hooks/src/useClickAway/demo/demo4.tsx b/packages/hooks/src/useClickAway/demo/demo4.tsx
index b1fb69f832..a910da64d4 100644
--- a/packages/hooks/src/useClickAway/demo/demo4.tsx
+++ b/packages/hooks/src/useClickAway/demo/demo4.tsx
@@ -6,7 +6,7 @@
* desc.zh-CN: 通过设置 eventName,可以指定需要监听的事件,试试点击鼠标右键。
*/
-import React, { useState, useRef } from 'react';
+import { useState, useRef } from 'react';
import { useClickAway } from 'ahooks';
export default () => {
diff --git a/packages/hooks/src/useClickAway/demo/demo5.tsx b/packages/hooks/src/useClickAway/demo/demo5.tsx
index 78a1f443fe..7cd2fc1cf7 100644
--- a/packages/hooks/src/useClickAway/demo/demo5.tsx
+++ b/packages/hooks/src/useClickAway/demo/demo5.tsx
@@ -6,7 +6,7 @@
* desc.zh-CN: 设置了多个事件,你可以试试用鼠标左键或者右键。
*/
-import React, { useState, useRef } from 'react';
+import { useState, useRef } from 'react';
import { useClickAway } from 'ahooks';
export default () => {
diff --git a/packages/hooks/src/useClickAway/demo/demo6.tsx b/packages/hooks/src/useClickAway/demo/demo6.tsx
index 233b472d2e..70ac74b002 100644
--- a/packages/hooks/src/useClickAway/demo/demo6.tsx
+++ b/packages/hooks/src/useClickAway/demo/demo6.tsx
@@ -3,10 +3,10 @@
* desc: Add the addEventListener to shadow DOM root instead of the document
*
* title.zh-CN: 支持 shadow DOM
- * desc.zh-CN: 将 addEventListener 添加到 shadow DOM root
+ * desc.zh-CN: 将 addEventListener 添加到 shadow DOM root
*/
-import React, { useState, useRef } from 'react';
+import { useState, useRef } from 'react';
import { useClickAway } from 'ahooks';
import root from 'react-shadow';
diff --git a/packages/hooks/src/useClickAway/index.en-US.md b/packages/hooks/src/useClickAway/index.en-US.md
index c629cf00d7..a4407251f6 100644
--- a/packages/hooks/src/useClickAway/index.en-US.md
+++ b/packages/hooks/src/useClickAway/index.en-US.md
@@ -37,18 +37,19 @@ Listen for click events outside the target element.
```typescript
type Target = Element | (() => Element) | React.MutableRefObject;
+type DocumentEventKey = keyof DocumentEventMap;
useClickAway(
onClickAway: (event: T) => void,
target: Target | Target[],
- eventName?: string | string[]
+ eventName?: DocumentEventKey | DocumentEventKey[]
);
```
### Params
-| Property | Description | Type | Default |
-| ----------- | ------------------------------------------- | ---------------------- | ------- |
-| onClickAway | Trigger Function | `(event: T) => void` | - |
-| target | DOM elements or Ref, support array | `Target` \| `Target[]` | - |
-| eventName | Set the event to be listened, support array | `string` \| `string[]` | `click` |
+| Property | Description | Type | Default |
+| ----------- | ---------------------------------------------- | ------------------------------------------ | ------- |
+| onClickAway | Trigger Function | `(event: T) => void` | - |
+| target | DOM elements or Ref or Function, support array | `Target` \| `Target[]` | - |
+| eventName | Set the event to be listened, support array | `DocumentEventKey` \| `DocumentEventKey[]` | `click` |
diff --git a/packages/hooks/src/useClickAway/index.ts b/packages/hooks/src/useClickAway/index.ts
index c888b8f13d..92f1a7ba54 100644
--- a/packages/hooks/src/useClickAway/index.ts
+++ b/packages/hooks/src/useClickAway/index.ts
@@ -4,10 +4,12 @@ import { getTargetElement } from '../utils/domTarget';
import getDocumentOrShadow from '../utils/getDocumentOrShadow';
import useEffectWithTarget from '../utils/useEffectWithTarget';
+type DocumentEventKey = keyof DocumentEventMap;
+
export default function useClickAway(
onClickAway: (event: T) => void,
target: BasicTarget | BasicTarget[],
- eventName: string | string[] = 'click',
+ eventName: DocumentEventKey | DocumentEventKey[] = 'click',
) {
const onClickAwayRef = useLatest(onClickAway);
diff --git a/packages/hooks/src/useClickAway/index.zh-CN.md b/packages/hooks/src/useClickAway/index.zh-CN.md
index 917112a07a..3bfbe4cedb 100644
--- a/packages/hooks/src/useClickAway/index.zh-CN.md
+++ b/packages/hooks/src/useClickAway/index.zh-CN.md
@@ -37,18 +37,19 @@ nav:
```typescript
type Target = Element | (() => Element) | React.MutableRefObject;
+type DocumentEventKey = keyof DocumentEventMap;
useClickAway(
onClickAway: (event: T) => void,
target: Target | Target[],
- eventName?: string | string[]
+ eventName?: DocumentEventKey | DocumentEventKey[]
);
```
### Params
-| 参数 | 说明 | 类型 | 默认值 |
-| ----------- | ---------------------------- | ---------------------- | ------- |
-| onClickAway | 触发函数 | `(event: T) => void` | - |
-| target | DOM 节点或者 Ref,支持数组 | `Target` \| `Target[]` | - |
-| eventName | 指定需要监听的事件,支持数组 | `string` \| `string[]` | `click` |
+| 参数 | 说明 | 类型 | 默认值 |
+| ----------- | ----------------------------------- | ------------------------------------------ | ------- |
+| onClickAway | 触发函数 | `(event: T) => void` | - |
+| target | DOM 节点或者 Ref 或者函数,支持数组 | `Target` \| `Target[]` | - |
+| eventName | 指定需要监听的事件,支持数组 | `DocumentEventKey` \| `DocumentEventKey[]` | `click` |
diff --git a/packages/hooks/src/useControllableValue/__tests__/index.test.ts b/packages/hooks/src/useControllableValue/__tests__/index.spec.ts
similarity index 58%
rename from packages/hooks/src/useControllableValue/__tests__/index.test.ts
rename to packages/hooks/src/useControllableValue/__tests__/index.spec.ts
index 272472504e..35a75ca2e2 100644
--- a/packages/hooks/src/useControllableValue/__tests__/index.test.ts
+++ b/packages/hooks/src/useControllableValue/__tests__/index.spec.ts
@@ -1,57 +1,59 @@
-import { act, renderHook } from '@testing-library/react-hooks';
-import useControllableValue, { Options, Props } from '../index';
+import { act, renderHook } from '@testing-library/react';
+import { describe, expect, test } from 'vitest';
+import type { Options, Props } from '../index';
+import useControllableValue from '../index';
describe('useControllableValue', () => {
- const setUp = (props?: Props, options?: Options): any =>
+ const setUp = (props?: Props, options?: Options) =>
renderHook(() => useControllableValue(props, options));
- it('defaultValue should work', () => {
+ test('defaultValue should work', () => {
const hook = setUp({ defaultValue: 1 });
- expect(hook.result.current[0]).toEqual(1);
+ expect(hook.result.current[0]).toBe(1);
});
- it('value should work', () => {
+ test('value should work', () => {
const hook = setUp({ defaultValue: 1, value: 2 });
- expect(hook.result.current[0]).toEqual(2);
+ expect(hook.result.current[0]).toBe(2);
});
- it('state should be undefined', () => {
+ test('state should be undefined', () => {
const hook = setUp();
expect(hook.result.current[0]).toBeUndefined();
});
- it('onChange should work', () => {
+ test('onChange should work', () => {
let extraParam: string = '';
const props = {
value: 2,
- onChange(v: any, extra) {
+ onChange(v: any, extra: any) {
this.value = v;
extraParam = extra;
},
};
const hook = setUp(props);
- expect(hook.result.current[0]).toEqual(2);
+ expect(hook.result.current[0]).toBe(2);
act(() => {
hook.result.current[1](3, 'extraParam');
});
- expect(props.value).toEqual(3);
- expect(extraParam).toEqual('extraParam');
+ expect(props.value).toBe(3);
+ expect(extraParam).toBe('extraParam');
});
- it('test on state update', () => {
+ test('test on state update', () => {
const props: any = {
value: 1,
};
const { result, rerender } = setUp(props);
props.value = 2;
rerender(props);
- expect(result.current[0]).toEqual(2);
+ expect(result.current[0]).toBe(2);
props.value = 3;
rerender(props);
- expect(result.current[0]).toEqual(3);
+ expect(result.current[0]).toBe(3);
});
- it('test set state', async () => {
+ test('test set state', async () => {
const { result } = setUp({
newValue: 1,
});
@@ -63,13 +65,13 @@ describe('useControllableValue', () => {
expect(result.current[0]).toBeNull();
act(() => setValue(55));
- expect(result.current[0]).toEqual(55);
+ expect(result.current[0]).toBe(55);
- act(() => setValue((prevState) => prevState + 1));
- expect(result.current[0]).toEqual(56);
+ act(() => setValue((prevState: number) => prevState + 1));
+ expect(result.current[0]).toBe(56);
});
- it('type inference should work', async () => {
+ test('type inference should work', async () => {
type Value = {
foo: number;
};
diff --git a/packages/hooks/src/useControllableValue/demo/demo1.tsx b/packages/hooks/src/useControllableValue/demo/demo1.tsx
index 2339afef4c..b1ac92cc5c 100644
--- a/packages/hooks/src/useControllableValue/demo/demo1.tsx
+++ b/packages/hooks/src/useControllableValue/demo/demo1.tsx
@@ -5,7 +5,6 @@
* title.zh-CN: 非受控组件
* desc.zh-CN: 如果 props 中没有 value,则组件内部自己管理 state
*/
-import React from 'react';
import { useControllableValue } from 'ahooks';
export default (props: any) => {
diff --git a/packages/hooks/src/useControllableValue/demo/demo2.tsx b/packages/hooks/src/useControllableValue/demo/demo2.tsx
index 7daec7022f..6a7e4fb588 100644
--- a/packages/hooks/src/useControllableValue/demo/demo2.tsx
+++ b/packages/hooks/src/useControllableValue/demo/demo2.tsx
@@ -6,7 +6,7 @@
* desc.zh-CN: 如果 props 有 value 字段,则由父级接管控制 state
*/
-import React, { useState } from 'react';
+import { useState } from 'react';
import { useControllableValue } from 'ahooks';
const ControllableComponent = (props: any) => {
diff --git a/packages/hooks/src/useControllableValue/demo/demo3.tsx b/packages/hooks/src/useControllableValue/demo/demo3.tsx
index 67d0615380..2d48de2189 100644
--- a/packages/hooks/src/useControllableValue/demo/demo3.tsx
+++ b/packages/hooks/src/useControllableValue/demo/demo3.tsx
@@ -6,7 +6,7 @@
* desc.zh-CN: 只要 props 中有 onChange 字段,则在 state 变化时,就会触发 onChange 函数
*/
-import React, { useState } from 'react';
+import { useState } from 'react';
import { useControllableValue } from 'ahooks';
const ControllableComponent = (props: any) => {
diff --git a/packages/hooks/src/useControllableValue/index.en-US.md b/packages/hooks/src/useControllableValue/index.en-US.md
index 113ee49db9..63c126f913 100644
--- a/packages/hooks/src/useControllableValue/index.en-US.md
+++ b/packages/hooks/src/useControllableValue/index.en-US.md
@@ -46,6 +46,6 @@ const [state, setState] = useControllableValue(props: Record, optio
| Property | Description | Type | Default |
| -------------------- | ------------------------------------------------------------------------------- | -------- | -------------- |
| defaultValue | The default value, will be overridden by `props.defaultValue` and `props.value` | - | - |
-| defaultValuePropName | Custom defaultVlue attribute name | `string` | `defaultValue` |
+| defaultValuePropName | Custom defaultValue attribute name | `string` | `defaultValue` |
| valuePropName | Custom value attribute name | `string` | `value` |
| trigger | Custom trigger attribute name | `string` | `onChange` |
diff --git a/packages/hooks/src/useControllableValue/index.ts b/packages/hooks/src/useControllableValue/index.ts
index f9e7759990..446dd3de51 100644
--- a/packages/hooks/src/useControllableValue/index.ts
+++ b/packages/hooks/src/useControllableValue/index.ts
@@ -26,7 +26,9 @@ function useControllableValue(
props?: Props,
options?: Options,
): [T, (v: SetStateAction, ...args: any[]) => void];
-function useControllableValue(props: Props = {}, options: Options = {}) {
+function useControllableValue(defaultProps?: Props, options: Options = {}) {
+ const props = defaultProps ?? {};
+
const {
defaultValue,
defaultValuePropName = 'defaultValue',
@@ -35,13 +37,13 @@ function useControllableValue(props: Props = {}, options: Options =
} = options;
const value = props[valuePropName] as T;
- const isControlled = props.hasOwnProperty(valuePropName);
+ const isControlled = Object.prototype.hasOwnProperty.call(props, valuePropName);
const initialValue = useMemo(() => {
if (isControlled) {
return value;
}
- if (props.hasOwnProperty(defaultValuePropName)) {
+ if (Object.prototype.hasOwnProperty.call(props, defaultValuePropName)) {
return props[defaultValuePropName];
}
return defaultValue;
diff --git a/packages/hooks/src/useCookieState/__tests__/index.spec.tsx b/packages/hooks/src/useCookieState/__tests__/index.spec.tsx
new file mode 100644
index 0000000000..431c1c5bb9
--- /dev/null
+++ b/packages/hooks/src/useCookieState/__tests__/index.spec.tsx
@@ -0,0 +1,103 @@
+import { act, renderHook } from '@testing-library/react';
+import Cookies from 'js-cookie';
+import { describe, expect, test } from 'vitest';
+import type { Options } from '../index';
+import useCookieState from '../index';
+
+describe('useCookieState', () => {
+ const setUp = (key: string, options: Options) =>
+ renderHook(() => {
+ const [state, setState] = useCookieState(key, options);
+ return {
+ state,
+ setState,
+ } as const;
+ });
+
+ test('getKey should work', () => {
+ const COOKIE = 'test-key';
+ const hook = setUp(COOKIE, {
+ defaultValue: 'A',
+ });
+ expect(hook.result.current.state).toBe('A');
+ act(() => {
+ hook.result.current.setState('B');
+ });
+ expect(hook.result.current.state).toBe('B');
+ const anotherHook = setUp(COOKIE, {
+ defaultValue: 'A',
+ });
+ expect(anotherHook.result.current.state).toBe('B');
+ act(() => {
+ anotherHook.result.current.setState('C');
+ });
+ expect(anotherHook.result.current.state).toBe('C');
+ expect(hook.result.current.state).toBe('B');
+ expect(Cookies.get(COOKIE)).toBe('C');
+ });
+
+ test('should support undefined', () => {
+ const COOKIE = 'test-boolean-key-with-undefined';
+ const hook = setUp(COOKIE, {
+ defaultValue: 'undefined',
+ });
+ expect(hook.result.current.state).toBe('undefined');
+ act(() => {
+ hook.result.current.setState(undefined);
+ });
+ expect(hook.result.current.state).toBeUndefined();
+ const anotherHook = setUp(COOKIE, {
+ defaultValue: 'false',
+ });
+ expect(anotherHook.result.current.state).toBe('false');
+ expect(Cookies.get(COOKIE)).toBeUndefined();
+ act(() => {
+ // @ts-ignore
+ hook.result.current.setState();
+ });
+ expect(hook.result.current.state).toBeUndefined();
+ expect(Cookies.get(COOKIE)).toBeUndefined();
+ });
+
+ test('should support empty string', () => {
+ Cookies.set('test-key-empty-string', '');
+ expect(Cookies.get('test-key-empty-string')).toBe('');
+ const COOKIE = 'test-key-empty-string';
+ const hook = setUp(COOKIE, {
+ defaultValue: 'hello',
+ });
+ expect(hook.result.current.state).toBe('');
+ });
+
+ test('should support function updater', () => {
+ const COOKIE = 'test-func-updater';
+ const hook = setUp(COOKIE, {
+ defaultValue: () => 'hello world',
+ });
+ expect(hook.result.current.state).toBe('hello world');
+ act(() => {
+ hook.result.current.setState((state) => `${state}, zhangsan`);
+ });
+ expect(hook.result.current.state).toBe('hello world, zhangsan');
+ });
+
+ test('using the same cookie name', () => {
+ const COOKIE_NAME = 'test-same-cookie-name';
+ const { result: result1 } = setUp(COOKIE_NAME, { defaultValue: 'A' });
+ const { result: result2 } = setUp(COOKIE_NAME, { defaultValue: 'B' });
+ expect(result1.current.state).toBe('A');
+ expect(result2.current.state).toBe('B');
+ act(() => {
+ result1.current.setState('C');
+ });
+ expect(result1.current.state).toBe('C');
+ expect(result2.current.state).toBe('B');
+ expect(Cookies.get(COOKIE_NAME)).toBe('C');
+ act(() => {
+ result2.current.setState('D');
+ });
+ expect(result1.current.state).toBe('C');
+ expect(result2.current.state).toBe('D');
+ expect(Cookies.get(COOKIE_NAME)).toBe('D');
+ });
+});
diff --git a/packages/hooks/src/useCookieState/__tests__/index.test.ts b/packages/hooks/src/useCookieState/__tests__/index.test.ts
deleted file mode 100644
index 5eff730a81..0000000000
--- a/packages/hooks/src/useCookieState/__tests__/index.test.ts
+++ /dev/null
@@ -1,73 +0,0 @@
-import { renderHook, act } from '@testing-library/react-hooks';
-import useCookieState, { Options } from '../index';
-import Cookies from 'js-cookie';
-
-describe('useCookieState', () => {
- const setUp = (key: string, options: Options) =>
- renderHook(() => {
- const [state, setState] = useCookieState(key, options);
- return {
- state,
- setState,
- } as const;
- });
-
- it('getKey should work', () => {
- const COOKIE_KEY = 'test-key';
- const hook = setUp(COOKIE_KEY, {
- defaultValue: 'A',
- });
- expect(hook.result.current.state).toEqual('A');
- act(() => {
- hook.result.current.setState('B');
- });
- expect(hook.result.current.state).toEqual('B');
- const anotherHook = setUp(COOKIE_KEY, {
- defaultValue: 'A',
- });
- expect(anotherHook.result.current.state).toEqual('B');
- act(() => {
- anotherHook.result.current.setState('C');
- });
- expect(anotherHook.result.current.state).toEqual('C');
- expect(hook.result.current.state).toEqual('B');
- });
-
- it('should support undefined', () => {
- const COOKIE_KEY = 'test-boolean-key-with-undefined';
- const hook = setUp(COOKIE_KEY, {
- defaultValue: 'undefined',
- });
- expect(hook.result.current.state).toEqual('undefined');
- act(() => {
- hook.result.current.setState(undefined);
- });
- expect(hook.result.current.state).toEqual(undefined);
- const anotherHook = setUp(COOKIE_KEY, {
- defaultValue: 'false',
- });
- expect(anotherHook.result.current.state).toEqual('false');
- });
-
- it('should support empty string', () => {
- Cookies.set('test-key-empty-string', '');
- expect(Cookies.get('test-key-empty-string')).toBe('');
- const COOKIE_KEY = 'test-key-empty-string';
- const hook = setUp(COOKIE_KEY, {
- defaultValue: 'hello',
- });
- expect(hook.result.current.state).toEqual('');
- });
-
- it('should support function updater', () => {
- const COOKIE_KEY = 'test-func-updater';
- const hook = setUp(COOKIE_KEY, {
- defaultValue: () => 'hello world',
- });
- expect(hook.result.current.state).toEqual('hello world');
- act(() => {
- hook.result.current.setState((state) => `${state}, zhangsan`);
- });
- expect(hook.result.current.state).toEqual('hello world, zhangsan');
- });
-});
diff --git a/packages/hooks/src/useCookieState/demo/demo1.tsx b/packages/hooks/src/useCookieState/demo/demo1.tsx
index 94c2cb11e9..abae8408e3 100644
--- a/packages/hooks/src/useCookieState/demo/demo1.tsx
+++ b/packages/hooks/src/useCookieState/demo/demo1.tsx
@@ -6,7 +6,6 @@
* desc.zh-CN: 刷新页面后,可以看到输入框中的内容被从 Cookie 中恢复了。
*/
-import React from 'react';
import { useCookieState } from 'ahooks';
export default () => {
diff --git a/packages/hooks/src/useCookieState/demo/demo2.tsx b/packages/hooks/src/useCookieState/demo/demo2.tsx
index dc84161bf9..eb2baf967d 100644
--- a/packages/hooks/src/useCookieState/demo/demo2.tsx
+++ b/packages/hooks/src/useCookieState/demo/demo2.tsx
@@ -6,7 +6,6 @@
* desc.zh-CN: useCookieState 的 setState 可以接收 function updater,就像 useState 那样。
*/
-import React from 'react';
import { useCookieState } from 'ahooks';
export default function App() {
diff --git a/packages/hooks/src/useCookieState/demo/demo3.tsx b/packages/hooks/src/useCookieState/demo/demo3.tsx
index a10b816699..fdf5725a9e 100644
--- a/packages/hooks/src/useCookieState/demo/demo3.tsx
+++ b/packages/hooks/src/useCookieState/demo/demo3.tsx
@@ -6,7 +6,6 @@
* desc.zh-CN: 可配置属性:默认值、有效时间、路径、域名、协议、跨域等,详见 Options 文档。
*/
-import React from 'react';
import { useCookieState } from 'ahooks';
export default function App() {
diff --git a/packages/hooks/src/useCookieState/index.ts b/packages/hooks/src/useCookieState/index.ts
index 57c0c44a83..e6a603d3cb 100644
--- a/packages/hooks/src/useCookieState/index.ts
+++ b/packages/hooks/src/useCookieState/index.ts
@@ -13,7 +13,9 @@ function useCookieState(cookieKey: string, options: Options = {}) {
const [state, setState] = useState(() => {
const cookieValue = Cookies.get(cookieKey);
- if (isString(cookieValue)) return cookieValue;
+ if (isString(cookieValue)) {
+ return cookieValue;
+ }
if (isFunction(options.defaultValue)) {
return options.defaultValue();
@@ -27,16 +29,17 @@ function useCookieState(cookieKey: string, options: Options = {}) {
newValue: State | ((prevState: State) => State),
newOptions: Cookies.CookieAttributes = {},
) => {
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
const { defaultValue, ...restOptions } = { ...options, ...newOptions };
- setState((prevState) => {
- const value = isFunction(newValue) ? newValue(prevState) : newValue;
- if (value === undefined) {
- Cookies.remove(cookieKey);
- } else {
- Cookies.set(cookieKey, value, restOptions);
- }
- return value;
- });
+ const value = isFunction(newValue) ? newValue(state) : newValue;
+
+ setState(value);
+
+ if (value === undefined) {
+ Cookies.remove(cookieKey);
+ } else {
+ Cookies.set(cookieKey, value, restOptions);
+ }
},
);
diff --git a/packages/hooks/src/useCountDown/__tests__/index.test.ts b/packages/hooks/src/useCountDown/__tests__/index.spec.ts
similarity index 65%
rename from packages/hooks/src/useCountDown/__tests__/index.test.ts
rename to packages/hooks/src/useCountDown/__tests__/index.spec.ts
index f5324923ea..8b703134c9 100644
--- a/packages/hooks/src/useCountDown/__tests__/index.test.ts
+++ b/packages/hooks/src/useCountDown/__tests__/index.spec.ts
@@ -1,20 +1,22 @@
-import { act, renderHook } from '@testing-library/react-hooks';
-import useCountDown, { Options } from '../index';
+import { act, renderHook } from '@testing-library/react';
+import { afterAll, beforeAll, describe, expect, test, vi } from 'vitest';
+import type { Options } from '../index';
+import useCountDown from '../index';
const setup = (options: Options = {}) =>
renderHook((props: Options = options) => useCountDown(props));
describe('useCountDown', () => {
beforeAll(() => {
- jest.useFakeTimers('modern');
- jest.setSystemTime(1479427200000);
+ vi.useFakeTimers();
+ vi.setSystemTime(1479427200000);
});
afterAll(() => {
- jest.useRealTimers();
+ vi.useRealTimers();
});
- it('should initialize correctly with undefined targetDate', () => {
+ test('should initialize correctly with undefined targetDate', () => {
const { result } = setup();
const [count, formattedRes] = result.current;
@@ -29,7 +31,7 @@ describe('useCountDown', () => {
});
});
- it('should initialize correctly with correct targetDate', () => {
+ test('should initialize correctly with correct targetDate', () => {
const { result } = setup({
targetDate: Date.now() + 5000,
interval: 1000,
@@ -40,7 +42,7 @@ describe('useCountDown', () => {
expect(formattedRes.milliseconds).toBe(0);
});
- it('should work manually', () => {
+ test('should work manually', () => {
const { result, rerender } = setup({ interval: 100 });
rerender({ targetDate: Date.now() + 5000, interval: 1000 });
@@ -48,26 +50,26 @@ describe('useCountDown', () => {
expect(result.current[1].seconds).toBe(5);
act(() => {
- jest.advanceTimersByTime(1000);
+ vi.advanceTimersByTime(1000);
});
expect(result.current[0]).toBe(4000);
expect(result.current[1].seconds).toBe(4);
act(() => {
- jest.advanceTimersByTime(4000);
+ vi.advanceTimersByTime(4000);
});
- expect(result.current[0]).toEqual(0);
+ expect(result.current[0]).toBe(0);
expect(result.current[1].seconds).toBe(0);
act(() => {
- jest.advanceTimersByTime(1000);
+ vi.advanceTimersByTime(1000);
});
- expect(result.current[0]).toEqual(0);
+ expect(result.current[0]).toBe(0);
expect(result.current[1].seconds).toBe(0);
});
- it('should work automatically', () => {
+ test('should work automatically', () => {
const { result } = setup({
targetDate: Date.now() + 5000,
interval: 1000,
@@ -77,19 +79,19 @@ describe('useCountDown', () => {
expect(result.current[1].seconds).toBe(5);
act(() => {
- jest.advanceTimersByTime(1000);
+ vi.advanceTimersByTime(1000);
});
expect(result.current[0]).toBe(4000);
expect(result.current[1].seconds).toBe(4);
act(() => {
- jest.advanceTimersByTime(4000);
+ vi.advanceTimersByTime(4000);
});
expect(result.current[0]).toBe(0);
expect(result.current[1].seconds).toBe(0);
});
- it('should work stop', () => {
+ test('should work stop', () => {
const { result, rerender } = setup({
targetDate: Date.now() + 5000,
interval: 1000,
@@ -103,7 +105,7 @@ describe('useCountDown', () => {
expect(result.current[1].seconds).toBe(5);
act(() => {
- jest.advanceTimersByTime(1000);
+ vi.advanceTimersByTime(1000);
});
expect(result.current[0]).toBe(4000);
expect(result.current[1].seconds).toBe(4);
@@ -115,27 +117,27 @@ describe('useCountDown', () => {
expect(result.current[1].seconds).toBe(0);
});
- it('it onEnd should work', () => {
- const onEnd = jest.fn();
+ test('it onEnd should work', () => {
+ const onEnd = vi.fn();
setup({
targetDate: Date.now() + 5000,
interval: 1000,
onEnd,
});
act(() => {
- jest.advanceTimersByTime(6000);
+ vi.advanceTimersByTime(6000);
});
expect(onEnd).toBeCalled();
});
- it('timeLeft should be 0 when target date less than current time', () => {
+ test('timeLeft should be 0 when target date less than current time', () => {
const { result } = setup({
targetDate: Date.now() - 5000,
});
expect(result.current[0]).toBe(0);
});
- it('should initialize correctly with undefined leftTime', () => {
+ test('should initialize correctly with undefined leftTime', () => {
const { result } = setup();
const [count, formattedRes] = result.current;
@@ -150,7 +152,7 @@ describe('useCountDown', () => {
});
});
- it('should initialize correctly with correct leftTime', () => {
+ test('should initialize correctly with correct leftTime', () => {
const { result } = setup({ leftTime: 5 * 1000, interval: 1000 });
const [count, formattedRes] = result.current;
expect(count).toBe(5000);
@@ -158,7 +160,7 @@ describe('useCountDown', () => {
expect(formattedRes.milliseconds).toBe(0);
});
- it('should work manually', () => {
+ test('should work manually', () => {
const { result, rerender } = setup({ interval: 100 });
rerender({ leftTime: 5 * 1000, interval: 1000 });
@@ -166,45 +168,45 @@ describe('useCountDown', () => {
expect(result.current[1].seconds).toBe(5);
act(() => {
- jest.advanceTimersByTime(1000);
+ vi.advanceTimersByTime(1000);
});
expect(result.current[0]).toBe(4000);
expect(result.current[1].seconds).toBe(4);
act(() => {
- jest.advanceTimersByTime(4000);
+ vi.advanceTimersByTime(4000);
});
- expect(result.current[0]).toEqual(0);
+ expect(result.current[0]).toBe(0);
expect(result.current[1].seconds).toBe(0);
act(() => {
- jest.advanceTimersByTime(1000);
+ vi.advanceTimersByTime(1000);
});
- expect(result.current[0]).toEqual(0);
+ expect(result.current[0]).toBe(0);
expect(result.current[1].seconds).toBe(0);
});
- it('should work automatically', () => {
+ test('should work automatically', () => {
const { result } = setup({ leftTime: 5 * 1000, interval: 1000 });
expect(result.current[0]).toBe(5000);
expect(result.current[1].seconds).toBe(5);
act(() => {
- jest.advanceTimersByTime(1000);
+ vi.advanceTimersByTime(1000);
});
expect(result.current[0]).toBe(4000);
expect(result.current[1].seconds).toBe(4);
act(() => {
- jest.advanceTimersByTime(4000);
+ vi.advanceTimersByTime(4000);
});
expect(result.current[0]).toBe(0);
expect(result.current[1].seconds).toBe(0);
});
- it('should work stop', () => {
+ test('should work stop', () => {
const { result, rerender } = setup({ leftTime: 5 * 1000, interval: 1000 });
rerender({ leftTime: 5 * 1000, interval: 1000 });
@@ -212,7 +214,7 @@ describe('useCountDown', () => {
expect(result.current[1].seconds).toBe(5);
act(() => {
- jest.advanceTimersByTime(1000);
+ vi.advanceTimersByTime(1000);
});
expect(result.current[0]).toBe(4000);
expect(result.current[1].seconds).toBe(4);
@@ -222,17 +224,43 @@ describe('useCountDown', () => {
expect(result.current[1].seconds).toBe(0);
});
- it('it onEnd should work', () => {
- const onEnd = jest.fn();
+ test('it onEnd should work', () => {
+ const onEnd = vi.fn();
setup({ leftTime: 5 * 1000, interval: 1000, onEnd });
act(() => {
- jest.advanceTimersByTime(6000);
+ vi.advanceTimersByTime(6000);
});
expect(onEnd).toBeCalled();
});
- it('timeLeft should be 0 when leftTime less than current time', () => {
+ test('timeLeft should be 0 when leftTime less than current time', () => {
const { result } = setup({ leftTime: -5 * 1000 });
expect(result.current[0]).toBe(0);
});
+
+ test('run with timeLeft should not be reset after targetDate changed', async () => {
+ let targetDate = Date.now() + 8000;
+
+ const { result, rerender } = setup({
+ leftTime: 6000,
+ targetDate,
+ });
+ expect(result.current[0]).toBe(6000);
+
+ act(() => {
+ vi.advanceTimersByTime(2000);
+ });
+ rerender({
+ leftTime: 6000,
+ targetDate: targetDate,
+ });
+ expect(result.current[0]).toBe(4000);
+
+ targetDate = Date.now() + 9000;
+ rerender({
+ leftTime: 6000,
+ targetDate: targetDate,
+ });
+ expect(result.current[0]).toBe(4000);
+ });
});
diff --git a/packages/hooks/src/useCountDown/demo/demo1.tsx b/packages/hooks/src/useCountDown/demo/demo1.tsx
index 019f67e356..0eaae0362b 100644
--- a/packages/hooks/src/useCountDown/demo/demo1.tsx
+++ b/packages/hooks/src/useCountDown/demo/demo1.tsx
@@ -6,21 +6,17 @@
* desc.zh-CN: 基础的倒计时管理。
*/
-import React from 'react';
import { useCountDown } from 'ahooks';
export default () => {
- const [countdown, formattedRes] = useCountDown({
- targetDate: '2022-12-31 24:00:00',
+ const [, formattedRes] = useCountDown({
+ targetDate: `${new Date().getFullYear()}-12-31 23:59:59`,
});
const { days, hours, minutes, seconds, milliseconds } = formattedRes;
-
return (
- <>
-
- There are {days} days {hours} hours {minutes} minutes {seconds} seconds {milliseconds}{' '}
- milliseconds until 2022-12-31 24:00:00
-
- >
+
+ There are {days} days {hours} hours {minutes} minutes {seconds} seconds {milliseconds}{' '}
+ milliseconds until {new Date().getFullYear()}-12-31 23:59:59
+
);
};
diff --git a/packages/hooks/src/useCountDown/demo/demo2.tsx b/packages/hooks/src/useCountDown/demo/demo2.tsx
index 9d0af362cb..dc0223102b 100644
--- a/packages/hooks/src/useCountDown/demo/demo2.tsx
+++ b/packages/hooks/src/useCountDown/demo/demo2.tsx
@@ -6,7 +6,7 @@
* desc.zh-CN: 动态变更配置项, 适用于验证码或类似场景,时间结束后会触发 onEnd 回调。
*/
-import React, { useState } from 'react';
+import { useState } from 'react';
import { useCountDown } from 'ahooks';
export default () => {
diff --git a/packages/hooks/src/useCountDown/index.ts b/packages/hooks/src/useCountDown/index.ts
index 6f64a9711f..bcb8866b76 100644
--- a/packages/hooks/src/useCountDown/index.ts
+++ b/packages/hooks/src/useCountDown/index.ts
@@ -42,13 +42,11 @@ const parseMs = (milliseconds: number): FormattedRes => {
const useCountdown = (options: Options = {}) => {
const { leftTime, targetDate, interval = 1000, onEnd } = options || {};
- const target = useMemo(() => {
- if ('leftTime' in options) {
- return isNumber(leftTime) && leftTime > 0 ? Date.now() + leftTime : undefined;
- } else {
- return targetDate;
- }
- }, [leftTime, targetDate]);
+ const memoLeftTime = useMemo(() => {
+ return isNumber(leftTime) && leftTime > 0 ? Date.now() + leftTime : undefined;
+ }, [leftTime]);
+
+ const target = 'leftTime' in options ? memoLeftTime : targetDate;
const [timeLeft, setTimeLeft] = useState(() => calcLeft(target));
diff --git a/packages/hooks/src/useCounter/__tests__/index.spec.ts b/packages/hooks/src/useCounter/__tests__/index.spec.ts
new file mode 100644
index 0000000000..e095e2e89d
--- /dev/null
+++ b/packages/hooks/src/useCounter/__tests__/index.spec.ts
@@ -0,0 +1,51 @@
+import { act, renderHook } from '@testing-library/react';
+import { describe, expect, test } from 'vitest';
+import type { Options } from '../index';
+import useCounter from '../index';
+
+const setUp = (init?: number, options?: Options) => renderHook(() => useCounter(init, options));
+
+describe('useCounter', () => {
+ test('should init counter', () => {
+ const { result } = setUp(100);
+ const [current] = result.current;
+ expect(current).toBe(100);
+ });
+
+ test('should max, min, actions work', () => {
+ const { result } = setUp(100, { max: 10, min: 1 });
+ const [current, { inc, dec, reset, set }] = result.current;
+ expect(current).toBe(10);
+ act(() => {
+ inc(1);
+ });
+ expect(result.current[0]).toBe(10);
+ act(() => {
+ dec(100);
+ });
+ expect(result.current[0]).toBe(1);
+ act(() => {
+ inc();
+ });
+ expect(result.current[0]).toBe(2);
+ act(() => {
+ reset();
+ });
+ expect(result.current[0]).toBe(10);
+ act(() => {
+ set(-1000);
+ });
+ expect(result.current[0]).toBe(1);
+ act(() => {
+ set((c) => c + 2);
+ });
+ expect(result.current[0]).toBe(3);
+
+ act(() => {
+ inc();
+ inc();
+ inc();
+ });
+ expect(result.current[0]).toBe(6);
+ });
+});
diff --git a/packages/hooks/src/useCounter/__tests__/index.test.ts b/packages/hooks/src/useCounter/__tests__/index.test.ts
deleted file mode 100644
index d2e0bdaad6..0000000000
--- a/packages/hooks/src/useCounter/__tests__/index.test.ts
+++ /dev/null
@@ -1,47 +0,0 @@
-import { act, renderHook } from '@testing-library/react-hooks';
-import useCounter, { Options } from '../index';
-
-const setUp = (init?: number, options?: Options) => renderHook(() => useCounter(init, options));
-
-it('should init counter', () => {
- const { result } = setUp(100);
- const [current] = result.current;
- expect(current).toEqual(100);
-});
-
-it('should max, min, actions work', () => {
- const { result } = setUp(100, { max: 10, min: 1 });
- const [current, { inc, dec, reset, set }] = result.current;
- expect(current).toEqual(10);
- act(() => {
- inc(1);
- });
- expect(result.current[0]).toEqual(10);
- act(() => {
- dec(100);
- });
- expect(result.current[0]).toEqual(1);
- act(() => {
- inc();
- });
- expect(result.current[0]).toEqual(2);
- act(() => {
- reset();
- });
- expect(result.current[0]).toEqual(10);
- act(() => {
- set(-1000);
- });
- expect(result.current[0]).toEqual(1);
- act(() => {
- set((c) => c + 2);
- });
- expect(result.current[0]).toEqual(3);
-
- act(() => {
- inc();
- inc();
- inc();
- });
- expect(result.current[0]).toEqual(6);
-});
diff --git a/packages/hooks/src/useCounter/demo/demo1.tsx b/packages/hooks/src/useCounter/demo/demo1.tsx
index 7f10abc05d..f6de445ae7 100644
--- a/packages/hooks/src/useCounter/demo/demo1.tsx
+++ b/packages/hooks/src/useCounter/demo/demo1.tsx
@@ -6,7 +6,6 @@
* desc.zh-CN: 简单的 counter 管理示例。
*/
-import React from 'react';
import { useCounter } from 'ahooks';
export default () => {
diff --git a/packages/hooks/src/useCreation/__tests__/index.test.ts b/packages/hooks/src/useCreation/__tests__/index.spec.ts
similarity index 82%
rename from packages/hooks/src/useCreation/__tests__/index.test.ts
rename to packages/hooks/src/useCreation/__tests__/index.spec.ts
index e4f945383c..341c493ea0 100644
--- a/packages/hooks/src/useCreation/__tests__/index.test.ts
+++ b/packages/hooks/src/useCreation/__tests__/index.spec.ts
@@ -1,5 +1,6 @@
-import { renderHook, act } from '@testing-library/react-hooks';
+import { act, renderHook } from '@testing-library/react';
import { useState } from 'react';
+import { describe, expect, test } from 'vitest';
import useCreation from '../index';
describe('useCreation', () => {
@@ -11,7 +12,7 @@ describe('useCreation', () => {
data: number;
}
- const setUp = (): any =>
+ const setUp = () =>
renderHook(() => {
const [count, setCount] = useState(0);
const [, setFlag] = useState({});
@@ -24,7 +25,7 @@ describe('useCreation', () => {
};
});
- it('should work', () => {
+ test('should work', () => {
const hook = setUp();
const { foo } = hook.result.current;
act(() => {
diff --git a/packages/hooks/src/useCreation/demo/demo1.tsx b/packages/hooks/src/useCreation/demo/demo1.tsx
index 2c61a094e9..6cce230e5a 100644
--- a/packages/hooks/src/useCreation/demo/demo1.tsx
+++ b/packages/hooks/src/useCreation/demo/demo1.tsx
@@ -6,7 +6,7 @@
* desc.zh-CN: 点击 "Rerender" 按钮,触发组件的更新,但 Foo 的实例会保持不变
*/
-import React, { useState } from 'react';
+import { useState } from 'react';
import { useCreation } from 'ahooks';
class Foo {
diff --git a/packages/hooks/src/useCreation/index.ts b/packages/hooks/src/useCreation/index.ts
index ca8bf4a813..944d22b7aa 100644
--- a/packages/hooks/src/useCreation/index.ts
+++ b/packages/hooks/src/useCreation/index.ts
@@ -2,10 +2,10 @@ import type { DependencyList } from 'react';
import { useRef } from 'react';
import depsAreSame from '../utils/depsAreSame';
-export default function useCreation(factory: () => T, deps: DependencyList) {
+const useCreation = (factory: () => T, deps: DependencyList) => {
const { current } = useRef({
deps,
- obj: undefined as undefined | T,
+ obj: undefined as T,
initialized: false,
});
if (current.initialized === false || !depsAreSame(current.deps, deps)) {
@@ -13,5 +13,7 @@ export default function useCreation(factory: () => T, deps: DependencyList) {
current.obj = factory();
current.initialized = true;
}
- return current.obj as T;
-}
+ return current.obj;
+};
+
+export default useCreation;
diff --git a/packages/hooks/src/useCreation/index.zh-CN.md b/packages/hooks/src/useCreation/index.zh-CN.md
index 437119cb80..5359eb662c 100644
--- a/packages/hooks/src/useCreation/index.zh-CN.md
+++ b/packages/hooks/src/useCreation/index.zh-CN.md
@@ -7,7 +7,7 @@ nav:
`useCreation` 是 `useMemo` 或 `useRef` 的替代品。
-因为 `useMemo` 不能保证被 memo 的值一定不会被重计算,而 `useCreation` 可以保证这一点。以下为 React 官方文档中的介绍:
+因为 `useMemo` 不能保证被 memo 的值一定不会被重新计算,而 `useCreation` 可以保证这一点。以下为 React 官方文档中的介绍:
> **You may rely on useMemo as a performance optimization, not as a semantic guarantee.** In the future, React may choose to “forget” some previously memoized values and recalculate them on next render, e.g. to free memory for offscreen components. Write your code so that it still works without `useMemo` — and then add it to optimize performance.
diff --git a/packages/hooks/src/useDebounce/__tests__/index.spec.ts b/packages/hooks/src/useDebounce/__tests__/index.spec.ts
new file mode 100644
index 0000000000..8b5883e1a5
--- /dev/null
+++ b/packages/hooks/src/useDebounce/__tests__/index.spec.ts
@@ -0,0 +1,34 @@
+import { act, renderHook } from '@testing-library/react';
+import { describe, expect, test } from 'vitest';
+import { sleep } from '../../utils/testingHelpers';
+import useDebounce from '../index';
+
+describe('useDebounce', () => {
+ test('useDebounce wait:200ms', async () => {
+ let mountedState = 0;
+ const { result, rerender } = renderHook(() => useDebounce(mountedState, { wait: 200 }));
+ expect(result.current).toBe(0);
+
+ mountedState = 1;
+ rerender();
+ await sleep(50);
+ expect(result.current).toBe(0);
+
+ mountedState = 2;
+ rerender();
+ await sleep(100);
+ expect(result.current).toBe(0);
+
+ mountedState = 3;
+ rerender();
+ await sleep(150);
+ expect(result.current).toBe(0);
+
+ mountedState = 4;
+ rerender();
+ await act(async () => {
+ await sleep(250);
+ });
+ expect(result.current).toBe(4);
+ });
+});
diff --git a/packages/hooks/src/useDebounce/__tests__/index.test.ts b/packages/hooks/src/useDebounce/__tests__/index.test.ts
deleted file mode 100644
index 7455a053a7..0000000000
--- a/packages/hooks/src/useDebounce/__tests__/index.test.ts
+++ /dev/null
@@ -1,33 +0,0 @@
-import { act, renderHook } from '@testing-library/react-hooks';
-import useDebounce from '../index';
-import { sleep } from '../../utils/testingHelpers';
-
-describe('useDebounce', () => {
- it('useDebounce wait:200ms', async () => {
- let mountedState = 0;
- const { result, rerender } = renderHook(() => useDebounce(mountedState, { wait: 200 }));
- expect(result.current).toEqual(0);
-
- await act(async () => {
- mountedState = 1;
- rerender();
- await sleep(50);
- expect(result.current).toEqual(0);
-
- mountedState = 2;
- rerender();
- await sleep(100);
- expect(result.current).toEqual(0);
-
- mountedState = 3;
- rerender();
- await sleep(150);
- expect(result.current).toEqual(0);
-
- mountedState = 4;
- rerender();
- await sleep(250);
- expect(result.current).toEqual(4);
- });
- });
-});
diff --git a/packages/hooks/src/useDebounce/demo/demo1.tsx b/packages/hooks/src/useDebounce/demo/demo1.tsx
index 4605f03a46..faa14547e5 100644
--- a/packages/hooks/src/useDebounce/demo/demo1.tsx
+++ b/packages/hooks/src/useDebounce/demo/demo1.tsx
@@ -6,7 +6,7 @@
* desc.zh-CN: DebouncedValue 只会在输入结束 500ms 后变化。
*/
-import React, { useState } from 'react';
+import { useState } from 'react';
import { useDebounce } from 'ahooks';
export default () => {
diff --git a/packages/hooks/src/useDebounceEffect/__tests__/index.spec.ts b/packages/hooks/src/useDebounceEffect/__tests__/index.spec.ts
new file mode 100644
index 0000000000..f30e058377
--- /dev/null
+++ b/packages/hooks/src/useDebounceEffect/__tests__/index.spec.ts
@@ -0,0 +1,97 @@
+import { act, type RenderHookResult, renderHook } from '@testing-library/react';
+import { describe, expect, test, vi } from 'vitest';
+import { sleep } from '../../utils/testingHelpers';
+import useDebounceEffect from '../index';
+
+let hook: RenderHookResult;
+
+describe('useDebounceEffect', () => {
+ test('useDebounceEffect should work', async () => {
+ let mountedState = 1;
+ const mockEffect = vi.fn(() => {});
+ const mockCleanUp = vi.fn(() => {});
+ act(() => {
+ hook = renderHook(() =>
+ useDebounceEffect(
+ () => {
+ mockEffect();
+ return () => {
+ mockCleanUp();
+ };
+ },
+ [mountedState],
+ { wait: 200 },
+ ),
+ );
+ });
+
+ expect(mockEffect.mock.calls.length).toBe(0);
+ expect(mockCleanUp.mock.calls.length).toBe(0);
+ mountedState = 2;
+ hook.rerender();
+ await sleep(50);
+ mountedState = 3;
+ hook.rerender();
+ expect(mockEffect.mock.calls.length).toBe(0);
+ expect(mockCleanUp.mock.calls.length).toBe(0);
+ await act(async () => {
+ await sleep(300);
+ });
+ expect(mockEffect.mock.calls.length).toBe(1);
+ expect(mockCleanUp.mock.calls.length).toBe(0);
+ mountedState = 4;
+ hook.rerender();
+ expect(mockEffect.mock.calls.length).toBe(1);
+ expect(mockCleanUp.mock.calls.length).toBe(0);
+ await act(async () => {
+ await sleep(300);
+ });
+ expect(mockEffect.mock.calls.length).toBe(2);
+ expect(mockCleanUp.mock.calls.length).toBe(1);
+ });
+
+ test('should cancel timeout on unmount', async () => {
+ const mockEffect = vi.fn(() => {});
+ const mockCleanUp = vi.fn(() => {});
+
+ const hook2 = renderHook(
+ (props) =>
+ useDebounceEffect(
+ () => {
+ mockEffect();
+ return () => {
+ mockCleanUp();
+ };
+ },
+ [props],
+ { wait: 200 },
+ ),
+ { initialProps: 0 },
+ );
+
+ expect(mockEffect.mock.calls.length).toBe(0);
+ expect(mockCleanUp.mock.calls.length).toBe(0);
+
+ hook2.rerender(1);
+ await sleep(50);
+ expect(mockEffect.mock.calls.length).toBe(0);
+ expect(mockCleanUp.mock.calls.length).toBe(0);
+
+ await act(async () => {
+ await sleep(300);
+ });
+ expect(mockEffect.mock.calls.length).toBe(1);
+ expect(mockCleanUp.mock.calls.length).toBe(0);
+
+ hook2.rerender(2);
+ await act(async () => {
+ await sleep(300);
+ });
+ expect(mockEffect.mock.calls.length).toBe(2);
+ expect(mockCleanUp.mock.calls.length).toBe(1);
+
+ hook2.unmount();
+ expect(mockEffect.mock.calls.length).toBe(2);
+ expect(mockCleanUp.mock.calls.length).toBe(2);
+ });
+});
diff --git a/packages/hooks/src/useDebounceEffect/__tests__/index.test.ts b/packages/hooks/src/useDebounceEffect/__tests__/index.test.ts
deleted file mode 100644
index 5d8de49c08..0000000000
--- a/packages/hooks/src/useDebounceEffect/__tests__/index.test.ts
+++ /dev/null
@@ -1,96 +0,0 @@
-import { act, renderHook, RenderHookResult } from '@testing-library/react-hooks';
-import useDebounceEffect from '../index';
-import { sleep } from '../../utils/testingHelpers';
-
-interface ParamsObj {
- value: any;
- wait: number;
-}
-
-let hook: RenderHookResult;
-
-describe('useDebounceEffect', () => {
- it('useDebounceEffect should work', async () => {
- let mountedState = 1;
- const mockEffect = jest.fn(() => {});
- const mockCleanUp = jest.fn(() => {});
- act(() => {
- hook = renderHook(() =>
- useDebounceEffect(
- () => {
- mockEffect();
- return () => {
- mockCleanUp();
- };
- },
- [mountedState],
- { wait: 200 },
- ),
- );
- });
- await act(async () => {
- expect(mockEffect.mock.calls.length).toEqual(0);
- expect(mockCleanUp.mock.calls.length).toEqual(0);
- mountedState = 2;
- hook.rerender();
- await sleep(50);
- mountedState = 3;
- hook.rerender();
- expect(mockEffect.mock.calls.length).toEqual(0);
- expect(mockCleanUp.mock.calls.length).toEqual(0);
- await sleep(300);
- expect(mockEffect.mock.calls.length).toEqual(1);
- expect(mockCleanUp.mock.calls.length).toEqual(0);
- mountedState = 4;
- hook.rerender();
- expect(mockEffect.mock.calls.length).toEqual(1);
- expect(mockCleanUp.mock.calls.length).toEqual(0);
- await sleep(300);
- expect(mockEffect.mock.calls.length).toEqual(2);
- expect(mockCleanUp.mock.calls.length).toEqual(1);
- });
- });
-
- it('should cancel timeout on unmount', async () => {
- const mockEffect = jest.fn(() => {});
- const mockCleanUp = jest.fn(() => {});
-
- const hook = renderHook(
- (props) =>
- useDebounceEffect(
- () => {
- mockEffect();
- return () => {
- mockCleanUp();
- };
- },
- [props],
- { wait: 200 },
- ),
- { initialProps: 0 },
- );
-
- await act(async () => {
- expect(mockEffect.mock.calls.length).toEqual(0);
- expect(mockCleanUp.mock.calls.length).toEqual(0);
-
- hook.rerender(1);
- await sleep(50);
- expect(mockEffect.mock.calls.length).toEqual(0);
- expect(mockCleanUp.mock.calls.length).toEqual(0);
-
- await sleep(300);
- expect(mockEffect.mock.calls.length).toEqual(1);
- expect(mockCleanUp.mock.calls.length).toEqual(0);
-
- hook.rerender(2);
- await sleep(300);
- expect(mockEffect.mock.calls.length).toEqual(2);
- expect(mockCleanUp.mock.calls.length).toEqual(1);
-
- hook.unmount();
- expect(mockEffect.mock.calls.length).toEqual(2);
- expect(mockCleanUp.mock.calls.length).toEqual(2);
- });
- });
-});
diff --git a/packages/hooks/src/useDebounceEffect/demo/demo1.tsx b/packages/hooks/src/useDebounceEffect/demo/demo1.tsx
index edb8bc15ee..a54baab6f4 100644
--- a/packages/hooks/src/useDebounceEffect/demo/demo1.tsx
+++ b/packages/hooks/src/useDebounceEffect/demo/demo1.tsx
@@ -1,5 +1,5 @@
import { useDebounceEffect } from 'ahooks';
-import React, { useState } from 'react';
+import { useState } from 'react';
export default () => {
const [value, setValue] = useState('hello');
diff --git a/packages/hooks/src/useDebounceFn/__tests__/index.test.ts b/packages/hooks/src/useDebounceFn/__tests__/index.spec.ts
similarity index 69%
rename from packages/hooks/src/useDebounceFn/__tests__/index.test.ts
rename to packages/hooks/src/useDebounceFn/__tests__/index.spec.ts
index 919ba45c5b..53649b843f 100644
--- a/packages/hooks/src/useDebounceFn/__tests__/index.test.ts
+++ b/packages/hooks/src/useDebounceFn/__tests__/index.spec.ts
@@ -1,4 +1,5 @@
-import { act, renderHook, RenderHookResult } from '@testing-library/react-hooks';
+import { act, type RenderHookResult, renderHook } from '@testing-library/react';
+import { describe, expect, test } from 'vitest';
import { sleep } from '../../utils/testingHelpers';
import useDebounceFn from '../index';
@@ -15,10 +16,10 @@ const debounceFn = (gap: number) => {
const setUp = ({ fn, wait }: ParamsObj) => renderHook(() => useDebounceFn(fn, { wait }));
-let hook: RenderHookResult>;
+let hook: RenderHookResult;
describe('useDebounceFn', () => {
- it('run, cancel and flush should work', async () => {
+ test('run, cancel and flush should work', async () => {
act(() => {
hook = setUp({
fn: debounceFn,
@@ -54,11 +55,4 @@ describe('useDebounceFn', () => {
expect(count).toBe(7);
});
});
-
- it('should output error when fn is not a function', () => {
- const errSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
- renderHook(() => useDebounceFn(1 as any));
- expect(errSpy).toBeCalledWith('useDebounceFn expected parameter is a function, got number');
- errSpy.mockRestore();
- });
});
diff --git a/packages/hooks/src/useDebounceFn/demo/demo1.tsx b/packages/hooks/src/useDebounceFn/demo/demo1.tsx
index aa64c20521..529d2eb14b 100644
--- a/packages/hooks/src/useDebounceFn/demo/demo1.tsx
+++ b/packages/hooks/src/useDebounceFn/demo/demo1.tsx
@@ -7,7 +7,7 @@
*/
import { useDebounceFn } from 'ahooks';
-import React, { useState } from 'react';
+import { useState } from 'react';
export default () => {
const [value, setValue] = useState(0);
diff --git a/packages/hooks/src/useDebounceFn/index.en-US.md b/packages/hooks/src/useDebounceFn/index.en-US.md
index 7d9344e30f..42198da4d6 100644
--- a/packages/hooks/src/useDebounceFn/index.en-US.md
+++ b/packages/hooks/src/useDebounceFn/index.en-US.md
@@ -46,6 +46,6 @@ const {
| Property | Description | Type |
| -------- | ------------------------------------------------------ | ------------------------- |
-| run | Invode and pass parameters to fn. | `(...args: any[]) => any` |
+| run | invoke and pass parameters to fn. | `(...args: any[]) => any` |
| cancel | Cancel the invocation of currently debounced function. | `() => void` |
| flush | Immediately invoke currently debounced function. | `() => void` |
diff --git a/packages/hooks/src/useDebounceFn/index.ts b/packages/hooks/src/useDebounceFn/index.ts
index 0728071ca6..24910f4b37 100644
--- a/packages/hooks/src/useDebounceFn/index.ts
+++ b/packages/hooks/src/useDebounceFn/index.ts
@@ -1,4 +1,4 @@
-import debounce from 'lodash/debounce';
+import { debounce } from '../utils/lodash-polyfill';
import { useMemo } from 'react';
import type { DebounceOptions } from '../useDebounce/debounceOptions';
import useLatest from '../useLatest';
diff --git a/packages/hooks/src/useDeepCompareEffect/__tests__/index.test.ts b/packages/hooks/src/useDeepCompareEffect/__tests__/index.spec.ts
similarity index 71%
rename from packages/hooks/src/useDeepCompareEffect/__tests__/index.test.ts
rename to packages/hooks/src/useDeepCompareEffect/__tests__/index.spec.ts
index 0e4f3aa4c8..d820491da2 100644
--- a/packages/hooks/src/useDeepCompareEffect/__tests__/index.test.ts
+++ b/packages/hooks/src/useDeepCompareEffect/__tests__/index.spec.ts
@@ -1,14 +1,15 @@
-import { renderHook, act } from '@testing-library/react-hooks';
+import { act, renderHook } from '@testing-library/react';
import { useState } from 'react';
+import { describe, expect, test } from 'vitest';
import useDeepCompareEffect from '../index';
describe('useDeepCompareEffect', () => {
- it('test deep compare', async () => {
+ test('test deep compare', async () => {
const hook = renderHook(() => {
const [x, setX] = useState(0);
const [y, setY] = useState({});
useDeepCompareEffect(() => {
- setX((x) => x + 1);
+ setX((prevState) => prevState + 1);
}, [y]);
return { x, setY };
});
diff --git a/packages/hooks/src/useDeepCompareEffect/demo/demo1.tsx b/packages/hooks/src/useDeepCompareEffect/demo/demo1.tsx
index 209af76428..49ac4fe0b7 100644
--- a/packages/hooks/src/useDeepCompareEffect/demo/demo1.tsx
+++ b/packages/hooks/src/useDeepCompareEffect/demo/demo1.tsx
@@ -1,8 +1,8 @@
import { useDeepCompareEffect } from 'ahooks';
-import React, { useEffect, useState, useRef } from 'react';
+import { useEffect, useState, useRef } from 'react';
export default () => {
- const [count, setCount] = useState(0);
+ const [, setCount] = useState(0);
const effectCountRef = useRef(0);
const deepCompareCountRef = useRef(0);
diff --git a/packages/hooks/src/useDeepCompareEffect/index.en-US.md b/packages/hooks/src/useDeepCompareEffect/index.en-US.md
index b8a8ad4047..28e9d3898d 100644
--- a/packages/hooks/src/useDeepCompareEffect/index.en-US.md
+++ b/packages/hooks/src/useDeepCompareEffect/index.en-US.md
@@ -5,7 +5,7 @@ nav:
# useDeepCompareEffect
-Usage is the same as `useEffect`, but deps are compared with [lodash.isEqual](https://lodash.com/docs/4.17.15#isEqual).
+Usage is the same as `useEffect`, but deps are compared with [react-fast-compare](https://www.npmjs.com/package/react-fast-compare).
## Examples
diff --git a/packages/hooks/src/useDeepCompareEffect/index.zh-CN.md b/packages/hooks/src/useDeepCompareEffect/index.zh-CN.md
index 056a2be0dd..d96fe9f218 100644
--- a/packages/hooks/src/useDeepCompareEffect/index.zh-CN.md
+++ b/packages/hooks/src/useDeepCompareEffect/index.zh-CN.md
@@ -5,7 +5,7 @@ nav:
# useDeepCompareEffect
-用法与 useEffect 一致,但 deps 通过 [lodash isEqual](https://lodash.com/docs/4.17.15#isEqual) 进行深比较。
+用法与 useEffect 一致,但 deps 通过 [react-fast-compare](https://www.npmjs.com/package/react-fast-compare) 进行深比较。
## 代码演示
diff --git a/packages/hooks/src/useDeepCompareLayoutEffect/__tests__/index.test.ts b/packages/hooks/src/useDeepCompareLayoutEffect/__tests__/index.spec.ts
similarity index 78%
rename from packages/hooks/src/useDeepCompareLayoutEffect/__tests__/index.test.ts
rename to packages/hooks/src/useDeepCompareLayoutEffect/__tests__/index.spec.ts
index d593e06b81..4907a3d998 100644
--- a/packages/hooks/src/useDeepCompareLayoutEffect/__tests__/index.test.ts
+++ b/packages/hooks/src/useDeepCompareLayoutEffect/__tests__/index.spec.ts
@@ -1,9 +1,10 @@
-import { renderHook, act } from '@testing-library/react-hooks';
+import { act, renderHook } from '@testing-library/react';
import { useState } from 'react';
+import { describe, expect, test } from 'vitest';
import useDeepCompareLayoutEffect from '../index';
describe('useDeepCompareLayoutEffect', () => {
- it('test deep compare', async () => {
+ test('test deep compare', async () => {
const hook = renderHook(() => {
const [x, setX] = useState(0);
const [y, setY] = useState({});
diff --git a/packages/hooks/src/useDeepCompareLayoutEffect/demo/demo1.tsx b/packages/hooks/src/useDeepCompareLayoutEffect/demo/demo1.tsx
index 58c9d65c29..a0a37f54b5 100644
--- a/packages/hooks/src/useDeepCompareLayoutEffect/demo/demo1.tsx
+++ b/packages/hooks/src/useDeepCompareLayoutEffect/demo/demo1.tsx
@@ -1,5 +1,5 @@
import { useDeepCompareLayoutEffect } from 'ahooks';
-import React, { useLayoutEffect, useState, useRef } from 'react';
+import { useLayoutEffect, useState, useRef } from 'react';
export default () => {
const [, setCount] = useState(0);
diff --git a/packages/hooks/src/useDeepCompareLayoutEffect/index.en-US.md b/packages/hooks/src/useDeepCompareLayoutEffect/index.en-US.md
index 03d25e7b96..0b1e8cd48b 100644
--- a/packages/hooks/src/useDeepCompareLayoutEffect/index.en-US.md
+++ b/packages/hooks/src/useDeepCompareLayoutEffect/index.en-US.md
@@ -5,7 +5,7 @@ nav:
# useDeepCompareLayoutEffect
-Usage is the same as `useLayoutEffect`, but deps are compared with [lodash.isEqual](https://lodash.com/docs/4.17.15#isEqual).
+Usage is the same as `useLayoutEffect`, but deps are compared with [react-fast-compare](https://www.npmjs.com/package/react-fast-compare).
## Examples
diff --git a/packages/hooks/src/useDeepCompareLayoutEffect/index.zh-CN.md b/packages/hooks/src/useDeepCompareLayoutEffect/index.zh-CN.md
index 1816813a25..3d5fb32cef 100644
--- a/packages/hooks/src/useDeepCompareLayoutEffect/index.zh-CN.md
+++ b/packages/hooks/src/useDeepCompareLayoutEffect/index.zh-CN.md
@@ -5,7 +5,7 @@ nav:
# useDeepCompareLayoutEffect
-用法与 useLayoutEffect 一致,但 deps 通过 [lodash isEqual](https://lodash.com/docs/4.17.15#isEqual) 进行深比较。
+用法与 useLayoutEffect 一致,但 deps 通过 [react-fast-compare](https://www.npmjs.com/package/react-fast-compare) 进行深比较。
## 代码演示
diff --git a/packages/hooks/src/useDocumentVisibility/__tests__/index.test.ts b/packages/hooks/src/useDocumentVisibility/__tests__/index.spec.ts
similarity index 65%
rename from packages/hooks/src/useDocumentVisibility/__tests__/index.test.ts
rename to packages/hooks/src/useDocumentVisibility/__tests__/index.spec.ts
index 9639f93dd6..8358c34086 100644
--- a/packages/hooks/src/useDocumentVisibility/__tests__/index.test.ts
+++ b/packages/hooks/src/useDocumentVisibility/__tests__/index.spec.ts
@@ -1,10 +1,11 @@
+import { act, renderHook } from '@testing-library/react';
+import { afterAll, describe, expect, test, vi } from 'vitest';
import useDocumentVisibility from '../index';
-import { renderHook, act } from '@testing-library/react-hooks';
-const mockIsBrowser = jest.fn();
-const mockDocumentVisibilityState = jest.spyOn(document, 'visibilityState', 'get');
+const mockIsBrowser = vi.fn();
+const mockDocumentVisibilityState = vi.spyOn(document, 'visibilityState', 'get');
-jest.mock('../../utils/isBrowser', () => {
+vi.mock('../../utils/isBrowser', () => {
return {
__esModule: true,
get default() {
@@ -14,29 +15,29 @@ jest.mock('../../utils/isBrowser', () => {
});
afterAll(() => {
- jest.clearAllMocks();
+ vi.clearAllMocks();
});
describe('useDocumentVisibility', () => {
- it('isBrowser effect corrent', async () => {
+ test('isBrowser effect correct', async () => {
mockDocumentVisibilityState.mockReturnValue('hidden');
// Object.defineProperty(document, 'visibilityState', { value: 'hidden', writable: true });
mockIsBrowser.mockReturnValue(false);
const { result } = renderHook(() => useDocumentVisibility());
- expect(result.current).toEqual('visible');
+ expect(result.current).toBe('visible');
});
- it('visibilitychange update correct ', async () => {
+ test('visibilitychange update correct ', async () => {
mockDocumentVisibilityState.mockReturnValue('hidden');
// Object.defineProperty(document, 'visibilityState', { value: 'hidden', writable: true });
mockIsBrowser.mockReturnValue(true);
const { result } = renderHook(() => useDocumentVisibility());
- expect(result.current).toEqual('hidden');
+ expect(result.current).toBe('hidden');
mockDocumentVisibilityState.mockReturnValue('visible');
// Object.defineProperty(document, 'visibilityState', { value: 'visible', writable: true });
act(() => {
document.dispatchEvent(new Event('visibilitychange'));
});
- expect(result.current).toEqual('visible');
+ expect(result.current).toBe('visible');
});
});
diff --git a/packages/hooks/src/useDocumentVisibility/demo/demo1.tsx b/packages/hooks/src/useDocumentVisibility/demo/demo1.tsx
index c442103e2c..ee88b7de74 100644
--- a/packages/hooks/src/useDocumentVisibility/demo/demo1.tsx
+++ b/packages/hooks/src/useDocumentVisibility/demo/demo1.tsx
@@ -6,7 +6,7 @@
* desc.zh-CN: 监听 document 的可见状态
*/
-import React, { useEffect } from 'react';
+import { useEffect } from 'react';
import { useDocumentVisibility } from 'ahooks';
export default () => {
diff --git a/packages/hooks/src/useDocumentVisibility/index.ts b/packages/hooks/src/useDocumentVisibility/index.ts
index e5f294c969..d8fea6302b 100644
--- a/packages/hooks/src/useDocumentVisibility/index.ts
+++ b/packages/hooks/src/useDocumentVisibility/index.ts
@@ -12,7 +12,7 @@ const getVisibility = () => {
};
function useDocumentVisibility(): VisibilityState {
- const [documentVisibility, setDocumentVisibility] = useState(() => getVisibility());
+ const [documentVisibility, setDocumentVisibility] = useState(getVisibility);
useEventListener(
'visibilitychange',
diff --git a/packages/hooks/src/useDocumentVisibility/index.zh-CN.md b/packages/hooks/src/useDocumentVisibility/index.zh-CN.md
index 0526c8e1fc..349930a52e 100644
--- a/packages/hooks/src/useDocumentVisibility/index.zh-CN.md
+++ b/packages/hooks/src/useDocumentVisibility/index.zh-CN.md
@@ -21,6 +21,6 @@ const documentVisibility = useDocumentVisibility();
### Result
-| 参数 | 说明 | 类型 |
-| ------------------ | ------------------------------------ | -------------------------------------------------- |
-| documentVisibility | 判断 document 是否在是否处于可见状态 | `visible`\| `hidden` \| `prerender` \| `undefined` |
+| 参数 | 说明 | 类型 |
+| ------------------ | ------------------------------ | -------------------------------------------------- |
+| documentVisibility | 判断 document 是否处于可见状态 | `visible`\| `hidden` \| `prerender` \| `undefined` |
diff --git a/packages/hooks/src/useDrag/__tests__/index.test.ts b/packages/hooks/src/useDrag/__tests__/index.spec.ts
similarity index 75%
rename from packages/hooks/src/useDrag/__tests__/index.test.ts
rename to packages/hooks/src/useDrag/__tests__/index.spec.ts
index f185d88a1f..ab7251f172 100644
--- a/packages/hooks/src/useDrag/__tests__/index.test.ts
+++ b/packages/hooks/src/useDrag/__tests__/index.spec.ts
@@ -1,24 +1,28 @@
-import { renderHook } from '@testing-library/react-hooks';
+import { renderHook } from '@testing-library/react';
+import { beforeEach, describe, expect, test, vi } from 'vitest';
+import type { BasicTarget } from '../../utils/domTarget';
import type { Options } from '../index';
import useDrag from '../index';
-import type { BasicTarget } from '../../utils/domTarget';
const setup = (data: T, target: BasicTarget, options?: Options) =>
renderHook((newData: T) => useDrag(newData ? newData : data, target, options));
const events: Record void> = {};
const mockTarget = {
- addEventListener: jest.fn((event, callback) => {
+ addEventListener: vi.fn((event, callback) => {
events[event] = callback;
}),
- removeEventListener: jest.fn((event) => {
+ removeEventListener: vi.fn((event) => {
Reflect.deleteProperty(events, event);
}),
- setAttribute: jest.fn(),
+ setAttribute: vi.fn(),
};
describe('useDrag', () => {
- it('should add/remove listener on mount/unmount', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+ test('should add/remove listener on mount/unmount', () => {
const { unmount } = setup(1, mockTarget as any);
expect(mockTarget.addEventListener).toBeCalled();
expect(mockTarget.addEventListener.mock.calls[0][0]).toBe('dragstart');
@@ -28,12 +32,12 @@ describe('useDrag', () => {
expect(mockTarget.removeEventListener).toBeCalled();
});
- it('should triggle drag callback', () => {
- const onDragStart = jest.fn();
- const onDragEnd = jest.fn();
+ test('should trigger drag callback', () => {
+ const onDragStart = vi.fn();
+ const onDragEnd = vi.fn();
const mockEvent = {
dataTransfer: {
- setData: jest.fn(),
+ setData: vi.fn(),
},
};
const hook = setup(1, mockTarget as any, {
@@ -55,7 +59,7 @@ describe('useDrag', () => {
expect(onDragEnd).toBeCalled();
});
- it(`should not work when target don't support addEventListener method`, () => {
+ test(`should not work when target don't support addEventListener method`, () => {
Object.defineProperty(mockTarget, 'addEventListener', {
get() {
return false;
diff --git a/packages/hooks/src/useDrag/index.ts b/packages/hooks/src/useDrag/index.ts
index 28301e5f68..2d2f091470 100644
--- a/packages/hooks/src/useDrag/index.ts
+++ b/packages/hooks/src/useDrag/index.ts
@@ -1,4 +1,7 @@
+import { useRef } from 'react';
import useLatest from '../useLatest';
+import useMount from '../useMount';
+import { isString } from '../utils';
import type { BasicTarget } from '../utils/domTarget';
import { getTargetElement } from '../utils/domTarget';
import useEffectWithTarget from '../utils/useEffectWithTarget';
@@ -6,11 +9,35 @@ import useEffectWithTarget from '../utils/useEffectWithTarget';
export interface Options {
onDragStart?: (event: React.DragEvent) => void;
onDragEnd?: (event: React.DragEvent) => void;
+ dragImage?: {
+ image: string | Element;
+ offsetX?: number;
+ offsetY?: number;
+ };
}
const useDrag = (data: T, target: BasicTarget, options: Options = {}) => {
const optionsRef = useLatest(options);
const dataRef = useLatest(data);
+ const imageElementRef = useRef(undefined);
+
+ const { dragImage } = optionsRef.current;
+
+ useMount(() => {
+ if (dragImage?.image) {
+ const { image } = dragImage;
+
+ if (isString(image)) {
+ const imageElement = new Image();
+
+ imageElement.src = image;
+ imageElementRef.current = imageElement;
+ } else {
+ imageElementRef.current = image;
+ }
+ }
+ });
+
useEffectWithTarget(
() => {
const targetElement = getTargetElement(target);
@@ -21,6 +48,12 @@ const useDrag = (data: T, target: BasicTarget, options: Options = {}) => {
const onDragStart = (event: React.DragEvent) => {
optionsRef.current.onDragStart?.(event);
event.dataTransfer.setData('custom', JSON.stringify(dataRef.current));
+
+ if (dragImage?.image && imageElementRef.current) {
+ const { offsetX = 0, offsetY = 0 } = dragImage;
+
+ event.dataTransfer.setDragImage(imageElementRef.current, offsetX, offsetY);
+ }
};
const onDragEnd = (event: React.DragEvent) => {
diff --git a/packages/hooks/src/useDrop/__tests__/index.test.ts b/packages/hooks/src/useDrop/__tests__/index.spec.ts
similarity index 65%
rename from packages/hooks/src/useDrop/__tests__/index.test.ts
rename to packages/hooks/src/useDrop/__tests__/index.spec.ts
index 9c01a32666..e0ffb25f11 100644
--- a/packages/hooks/src/useDrop/__tests__/index.test.ts
+++ b/packages/hooks/src/useDrop/__tests__/index.spec.ts
@@ -1,16 +1,18 @@
-import { renderHook } from '@testing-library/react-hooks';
-import useDrop, { Options } from '../index';
+import { renderHook } from '@testing-library/react';
+import { describe, expect, test, vi } from 'vitest';
import type { BasicTarget } from '../../utils/domTarget';
+import type { Options } from '../index';
+import useDrop from '../index';
const setup = (target: unknown, options?: Options) =>
renderHook(() => useDrop(target as BasicTarget, options));
-const events = {};
+const events: Record void> = {};
const mockTarget = {
- addEventListener: jest.fn((event, callback) => {
+ addEventListener: vi.fn((event: string, callback: (event?: any) => void) => {
events[event] = callback;
}),
- removeEventListener: jest.fn((event) => {
+ removeEventListener: vi.fn((event) => {
Reflect.deleteProperty(events, event);
}),
};
@@ -34,12 +36,12 @@ const mockEvent = {
return [] as unknown[];
},
},
- preventDefault: jest.fn(),
- stopPropagation: jest.fn(),
+ preventDefault: vi.fn(),
+ stopPropagation: vi.fn(),
};
describe('useDrop', () => {
- it(`should not work when target don't support addEventListener method`, () => {
+ test(`should not work when target don't support addEventListener method`, () => {
const originAddEventListener = mockTarget.addEventListener;
Object.defineProperty(mockTarget, 'addEventListener', {
value: false,
@@ -51,7 +53,7 @@ describe('useDrop', () => {
});
});
- it('should add/remove listener on mount/unmount', () => {
+ test('should add/remove listener on mount/unmount', () => {
const { unmount } = setup(mockTarget);
const eventNames = ['dragenter', 'dragover', 'dragleave', 'drop', 'paste'];
expect(mockTarget.addEventListener).toBeCalledTimes(eventNames.length);
@@ -65,12 +67,12 @@ describe('useDrop', () => {
});
});
- it('should call callback', () => {
- const onDragEnter = jest.fn();
- const onDragOver = jest.fn();
- const onDragLeave = jest.fn();
- const onDrop = jest.fn();
- const onPaste = jest.fn();
+ test('should call callback', () => {
+ const onDragEnter = vi.fn();
+ const onDragOver = vi.fn();
+ const onDragLeave = vi.fn();
+ const onDrop = vi.fn();
+ const onPaste = vi.fn();
setup(mockTarget, {
onDragEnter,
@@ -87,16 +89,16 @@ describe('useDrop', () => {
callbacks.forEach((callback) => expect(callback).toBeCalled());
});
- it('should call onText on drop', async () => {
- jest.spyOn(mockEvent.dataTransfer, 'items', 'get').mockReturnValue([
+ test('should call onText on drop', async () => {
+ vi.spyOn(mockEvent.dataTransfer, 'items', 'get').mockReturnValue([
{
- getAsString: (callback) => {
+ getAsString: (callback: (text: string) => void) => {
callback('drop text');
},
},
]);
- const onText = jest.fn();
+ const onText = vi.fn();
setup(mockTarget, {
onText,
});
@@ -105,10 +107,10 @@ describe('useDrop', () => {
expect(onText.mock.calls[0][0]).toBe('drop text');
});
- it('should call onFiles on drop', async () => {
+ test('should call onFiles on drop', async () => {
const file = new File(['hello'], 'hello.png');
- jest.spyOn(mockEvent.dataTransfer, 'files', 'get').mockReturnValue([file]);
- const onFiles = jest.fn();
+ vi.spyOn(mockEvent.dataTransfer, 'files', 'get').mockReturnValue([file]);
+ const onFiles = vi.fn();
setup(mockTarget, {
onFiles,
});
@@ -117,13 +119,14 @@ describe('useDrop', () => {
expect(onFiles.mock.calls[0][0]).toHaveLength(1);
});
- it('should call onUri on drop', async () => {
+ test('should call onUri on drop', async () => {
const url = 'https://alipay.com';
- jest.spyOn(mockEvent.dataTransfer, 'getData').mockImplementation((format: string) => {
+ vi.spyOn(mockEvent.dataTransfer, 'getData').mockImplementation((format?: string) => {
if (format === 'text/uri-list') return url;
+ return undefined;
});
- const onUri = jest.fn();
+ const onUri = vi.fn();
setup(mockTarget, {
onUri,
});
@@ -132,15 +135,16 @@ describe('useDrop', () => {
expect(onUri.mock.calls[0][0]).toBe(url);
});
- it('should call onDom on drop', async () => {
+ test('should call onDom on drop', async () => {
const data = {
value: 'mock',
};
- jest.spyOn(mockEvent.dataTransfer, 'getData').mockImplementation((format: string) => {
+ vi.spyOn(mockEvent.dataTransfer, 'getData').mockImplementation((format?: string) => {
if (format === 'custom') return data;
+ return undefined;
});
- const onDom = jest.fn();
+ const onDom = vi.fn();
setup(mockTarget, {
onDom,
});
@@ -149,24 +153,25 @@ describe('useDrop', () => {
expect(onDom.mock.calls[0][0]).toMatchObject(data);
// catch JSON.parse error
- jest.spyOn(mockEvent.dataTransfer, 'getData').mockImplementation((format: string) => {
+ vi.spyOn(mockEvent.dataTransfer, 'getData').mockImplementation((format?: string) => {
if (format === 'custom') return {};
+ return undefined;
});
events['dragenter'](mockEvent);
events['drop'](mockEvent);
expect(onDom.mock.calls[0][0]).toMatchObject({});
});
- it('should call onText on paste', async () => {
- jest.spyOn(mockEvent.clipboardData, 'items', 'get').mockReturnValue([
+ test('should call onText on paste', async () => {
+ vi.spyOn(mockEvent.clipboardData, 'items', 'get').mockReturnValue([
{
- getAsString: (callback) => {
+ getAsString: (callback: (text: string) => void) => {
callback('paste text');
},
},
]);
- const onText = jest.fn();
+ const onText = vi.fn();
setup(mockTarget, {
onText,
});
diff --git a/packages/hooks/src/useDrop/demo/demo1.tsx b/packages/hooks/src/useDrop/demo/demo1.tsx
index bcbf2032b7..2ee2c0fba3 100644
--- a/packages/hooks/src/useDrop/demo/demo1.tsx
+++ b/packages/hooks/src/useDrop/demo/demo1.tsx
@@ -6,10 +6,10 @@
* desc.zh-CN: 拖拽区域可以接受文件,链接,文字,和下方的 box 节点。
*/
-import React, { useRef, useState } from 'react';
+import { useRef, useState } from 'react';
import { useDrop, useDrag } from 'ahooks';
-const DragItem = ({ data }) => {
+const DragItem = ({ data }: { data: string }) => {
const dragRef = useRef(null);
const [dragging, setDragging] = useState(false);
@@ -70,8 +70,8 @@ export default () => {
{isHovering ? 'release here' : 'drop here'}
-
- {['1', '2', '3', '4', '5'].map((e, i) => (
+
+ {['1', '2', '3', '4', '5'].map((e) => (
))}
diff --git a/packages/hooks/src/useDrop/demo/demo2.tsx b/packages/hooks/src/useDrop/demo/demo2.tsx
new file mode 100644
index 0000000000..7e0776d92b
--- /dev/null
+++ b/packages/hooks/src/useDrop/demo/demo2.tsx
@@ -0,0 +1,36 @@
+/**
+ * title: Customize Image
+ * desc: Customize image that follow the mouse pointer during dragging.
+ *
+ * title.zh-CN: 自定义拖拽图像
+ * desc.zh-CN: 自定义拖拽过程中跟随鼠标指针的图像。
+ */
+
+import { useRef } from 'react';
+import { useDrag } from 'ahooks';
+
+const COMMON_STYLE: React.CSSProperties = {
+ border: '1px solid #e8e8e8',
+ height: '50px',
+ lineHeight: '50px',
+ padding: '16px',
+ textAlign: 'center',
+ marginRight: '16px',
+};
+
+export default () => {
+ const dragRef = useRef(null);
+
+ useDrag('', dragRef, {
+ dragImage: {
+ image: '/logo.svg',
+ },
+ });
+
+ return (
+
+
+
drag me
+
+ );
+};
diff --git a/packages/hooks/src/useDrop/index.en-US.md b/packages/hooks/src/useDrop/index.en-US.md
index c56a93fd45..a6755c292c 100644
--- a/packages/hooks/src/useDrop/index.en-US.md
+++ b/packages/hooks/src/useDrop/index.en-US.md
@@ -19,6 +19,10 @@ A pair of hooks to help you manage data transfer between drag and drop
+### Customize Image
+
+
+
## API
### useDrag
@@ -41,10 +45,19 @@ useDrag
(
#### DragOptions
-| Property | Description | Type | Default |
-| ----------- | ---------------------- | ------------------------------ | ------- |
-| onDragStart | On drag start callback | `(e: React.DragEvent) => void` | - |
-| onDragEnd | On drag end callback | `(e: React.DragEvent) => void` | - |
+| Property | Description | Type | Default |
+| ----------- | ------------------------------------------------------------- | ------------------------------ | ------- |
+| onDragStart | On drag start callback | `(e: React.DragEvent) => void` | - |
+| onDragEnd | On drag end callback | `(e: React.DragEvent) => void` | - |
+| dragImage | Customize image that follow the mouse pointer during dragging | `DragImageOptions` | - |
+
+#### DragImageOptions
+
+| 参数 | 说明 | 类型 | 默认值 |
+| ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------- | ------ |
+| image | An image Element element to use for the drag feedback image. The image will typically be an [` `](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/img) element but it can also be a [``](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/canvas) or any other visible element | `string \| Element` | - |
+| offsetX | the horizontal offset within the image | `number` | 0 |
+| offsetY | the vertical offset within the image | `number` | 0 |
### useDrop
diff --git a/packages/hooks/src/useDrop/index.ts b/packages/hooks/src/useDrop/index.ts
index a340cb4f0b..6a82f935e7 100644
--- a/packages/hooks/src/useDrop/index.ts
+++ b/packages/hooks/src/useDrop/index.ts
@@ -20,7 +20,7 @@ const useDrop = (target: BasicTarget, options: Options = {}) => {
const optionsRef = useLatest(options);
// https://stackoverflow.com/a/26459269
- const dragEnterTarget = useRef();
+ const dragEnterTarget = useRef(undefined);
useEffectWithTarget(
() => {
@@ -40,7 +40,7 @@ const useDrop = (target: BasicTarget, options: Options = {}) => {
let data = dom;
try {
data = JSON.parse(dom);
- } catch (e) {
+ } catch {
data = dom;
}
optionsRef.current.onDom(data, event as React.DragEvent);
diff --git a/packages/hooks/src/useDrop/index.zh-CN.md b/packages/hooks/src/useDrop/index.zh-CN.md
index c0a422cac5..c1dc96d4ae 100644
--- a/packages/hooks/src/useDrop/index.zh-CN.md
+++ b/packages/hooks/src/useDrop/index.zh-CN.md
@@ -19,6 +19,10 @@ nav:
+### 自定义拖拽图像
+
+
+
## API
### useDrag
@@ -41,10 +45,19 @@ useDrag(
#### DragOptions
-| 参数 | 说明 | 类型 | 默认值 |
-| ----------- | -------------- | ------------------------------ | ------ |
-| onDragStart | 开始拖拽的回调 | `(e: React.DragEvent) => void` | - |
-| onDragEnd | 结束拖拽的回调 | `(e: React.DragEvent) => void` | - |
+| 参数 | 说明 | 类型 | 默认值 |
+| ----------- | ---------------------------------- | ------------------------------ | ------ |
+| onDragStart | 开始拖拽的回调 | `(e: React.DragEvent) => void` | - |
+| onDragEnd | 结束拖拽的回调 | `(e: React.DragEvent) => void` | - |
+| dragImage | 自定义拖拽过程中跟随鼠标指针的图像 | `DragImageOptions` | - |
+
+#### DragImageOptions
+
+| 参数 | 说明 | 类型 | 默认值 |
+| ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------- | ------ |
+| image | 拖拽过程中跟随鼠标指针的图像。图像通常是一个 [` `](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/img) 元素,但也可以是 [``](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/canvas) 或任何其他图像元素。 | `string \| Element` | - |
+| offsetX | 水平偏移 | `number` | 0 |
+| offsetY | 垂直偏移 | `number` | 0 |
### useDrop
diff --git a/packages/hooks/src/useDynamicList/__tests__/index.spec.ts b/packages/hooks/src/useDynamicList/__tests__/index.spec.ts
new file mode 100644
index 0000000000..00d54a0fc2
--- /dev/null
+++ b/packages/hooks/src/useDynamicList/__tests__/index.spec.ts
@@ -0,0 +1,203 @@
+import { act, renderHook } from '@testing-library/react';
+import { afterAll, afterEach, describe, expect, test, vi } from 'vitest';
+import useDynamicList from '../index';
+
+describe('useDynamicList', () => {
+ const setUp = (props: any): any => renderHook(() => useDynamicList(props));
+ const warnSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+
+ afterEach(() => {
+ warnSpy.mockReset();
+ });
+
+ afterAll(() => {
+ warnSpy.mockRestore();
+ });
+
+ test('getKey should work', () => {
+ const hook = setUp([1, 2, 3]);
+ expect(hook.result.current.list[0]).toBe(1);
+ expect(hook.result.current.getKey(0)).toBe(0);
+ expect(hook.result.current.getKey(1)).toBe(1);
+ expect(hook.result.current.getKey(2)).toBe(2);
+ });
+
+ test('methods should work', () => {
+ const hook = setUp([
+ { name: 'aaa', age: 18 },
+ { name: 'bbb', age: 19 },
+ { name: 'ccc', age: 20 },
+ ]);
+
+ expect(hook.result.current.list[0].age).toBe(18);
+ expect(hook.result.current.list[1].age).toBe(19);
+ expect(hook.result.current.list[2].age).toBe(20);
+
+ expect(hook.result.current.getKey(0)).toBe(0);
+ expect(hook.result.current.getKey(1)).toBe(1);
+ expect(hook.result.current.getKey(2)).toBe(2);
+
+ // unshift
+ act(() => {
+ hook.result.current.unshift({ name: 'ddd', age: 21 });
+ });
+
+ expect(hook.result.current.list[0].name).toBe('ddd');
+ expect(hook.result.current.getKey(0)).toBe(3);
+
+ // push
+ act(() => {
+ hook.result.current.push({ name: 'ddd', age: 21 });
+ });
+
+ expect(hook.result.current.list[4].name).toBe('ddd');
+ expect(hook.result.current.getKey(0)).toBe(3);
+ expect(hook.result.current.getKey(4)).toBe(4);
+
+ // insert
+ act(() => {
+ hook.result.current.insert(1, { name: 'eee', age: 22 });
+ });
+ expect(hook.result.current.list[1].name).toBe('eee');
+ expect(hook.result.current.getKey(1)).toBe(5);
+
+ // merge
+ act(() => {
+ hook.result.current.merge(0, [1, 2, 3, 4]);
+ });
+ expect(hook.result.current.list[0]).toBe(1);
+ expect(hook.result.current.getKey(0)).toBe(6);
+
+ // move
+ act(() => {
+ hook.result.current.move(0, 1);
+ });
+ expect(hook.result.current.list[0]).toBe(2);
+ expect(hook.result.current.getKey(0)).toBe(7);
+
+ // move without changes
+ act(() => {
+ hook.result.current.move(2, 2);
+ });
+ expect(hook.result.current.list[0]).toBe(2);
+ expect(hook.result.current.getKey(0)).toBe(7);
+
+ // shift
+ act(() => {
+ hook.result.current.shift();
+ });
+ expect(hook.result.current.list[0]).toBe(1);
+ expect(hook.result.current.getKey(0)).toBe(6);
+ expect(hook.result.current.list.length).toBe(9);
+
+ // pop
+ act(() => {
+ hook.result.current.pop();
+ });
+ expect(hook.result.current.list.length).toBe(8);
+
+ // replace
+ act(() => {
+ hook.result.current.replace(7, { value: 8 });
+ });
+ expect(hook.result.current.list[7].value).toBe(8);
+
+ // remove
+ act(() => {
+ hook.result.current.remove(7);
+ });
+ expect(hook.result.current.list.length).toBe(7);
+
+ // batch remove
+ act(() => {
+ hook.result.current.batchRemove(1);
+ });
+ expect(warnSpy).toHaveBeenCalledWith(
+ '`indexes` parameter of `batchRemove` function expected to be an array, but got "number".',
+ );
+ act(() => {
+ hook.result.current.batchRemove([0, 1, 2]);
+ });
+ expect(hook.result.current.list.length).toBe(4);
+ });
+
+ test('same items should have different keys', () => {
+ const hook = setUp([1, 1, 1, 1]);
+ expect(hook.result.current.getKey(0)).toBe(0);
+ expect(hook.result.current.getKey(1)).toBe(1);
+ expect(hook.result.current.getKey(2)).toBe(2);
+ expect(hook.result.current.getKey(3)).toBe(3);
+
+ act(() => {
+ hook.result.current.push(1);
+ });
+
+ expect(hook.result.current.getKey(4)).toBe(4);
+ const testObj = {};
+
+ act(() => {
+ hook.result.current.push({});
+ hook.result.current.push(testObj);
+ hook.result.current.push(testObj);
+ });
+
+ expect(hook.result.current.getKey(5)).toBe(5);
+ expect(hook.result.current.getKey(6)).toBe(6);
+ expect(hook.result.current.getKey(7)).toBe(7);
+ });
+
+ test('initialValue changes', () => {
+ const hook = renderHook(({ initialValue }) => useDynamicList(initialValue), {
+ initialProps: {
+ initialValue: [1],
+ },
+ });
+ expect(hook.result.current.list[0]).toBe(1);
+ expect(hook.result.current.getKey(0)).toBe(0);
+
+ act(() => {
+ hook.result.current.resetList([2]);
+ });
+
+ expect(hook.result.current.list[0]).toBe(2);
+ expect(hook.result.current.getKey(0)).toBe(1);
+
+ act(() => {
+ hook.result.current.resetList([3]);
+ });
+
+ expect(hook.result.current.list[0]).toBe(3);
+ expect(hook.result.current.getKey(0)).toBe(2);
+ });
+
+ test('sortList', () => {
+ const hook = setUp([1, 2, 3, 4]);
+ const formData = [
+ {
+ name: 'my bro',
+ age: '23',
+ memo: "he's my bro",
+ },
+ {
+ name: 'my sis',
+ age: '21',
+ memo: "she's my sis",
+ },
+ null,
+ {
+ name: '新增行',
+ age: '25',
+ },
+ ];
+
+ let sorted = hook.result.current.sortList(formData);
+ expect(sorted.length).toBe(3);
+ expect(sorted[0].name).toBe('my bro');
+
+ act(() => {
+ hook.result.current.move(3, 0);
+ });
+ sorted = hook.result.current.sortList(formData);
+ expect(sorted[0].name).toBe('新增行');
+ });
+});
diff --git a/packages/hooks/src/useDynamicList/__tests__/index.test.ts b/packages/hooks/src/useDynamicList/__tests__/index.test.ts
deleted file mode 100644
index 7025d87091..0000000000
--- a/packages/hooks/src/useDynamicList/__tests__/index.test.ts
+++ /dev/null
@@ -1,181 +0,0 @@
-import { renderHook, act } from '@testing-library/react-hooks';
-import useDynamicList from '../index';
-
-describe('useDynamicList', () => {
- const setUp = (props: any): any => renderHook(() => useDynamicList(props));
-
- it('getKey should work', () => {
- const hook = setUp([1, 2, 3]);
- expect(hook.result.current.list[0]).toEqual(1);
- expect(hook.result.current.getKey(0)).toEqual(0);
- expect(hook.result.current.getKey(1)).toEqual(1);
- expect(hook.result.current.getKey(2)).toEqual(2);
- });
-
- it('methods should work', () => {
- const hook = setUp([
- { name: 'aaa', age: 18 },
- { name: 'bbb', age: 19 },
- { name: 'ccc', age: 20 },
- ]);
-
- expect(hook.result.current.list[0].age).toEqual(18);
- expect(hook.result.current.list[1].age).toEqual(19);
- expect(hook.result.current.list[2].age).toEqual(20);
-
- expect(hook.result.current.getKey(0)).toEqual(0);
- expect(hook.result.current.getKey(1)).toEqual(1);
- expect(hook.result.current.getKey(2)).toEqual(2);
-
- // unshift
- act(() => {
- hook.result.current.unshift({ name: 'ddd', age: 21 });
- });
-
- expect(hook.result.current.list[0].name).toEqual('ddd');
- expect(hook.result.current.getKey(0)).toEqual(3);
-
- // push
- act(() => {
- hook.result.current.push({ name: 'ddd', age: 21 });
- });
-
- expect(hook.result.current.list[4].name).toEqual('ddd');
- expect(hook.result.current.getKey(0)).toEqual(3);
- expect(hook.result.current.getKey(4)).toEqual(4);
-
- // insert
- act(() => {
- hook.result.current.insert(1, { name: 'eee', age: 22 });
- });
- expect(hook.result.current.list[1].name).toEqual('eee');
- expect(hook.result.current.getKey(1)).toEqual(5);
-
- // merge
- act(() => {
- hook.result.current.merge(0, [1, 2, 3, 4]);
- });
- expect(hook.result.current.list[0]).toEqual(1);
- expect(hook.result.current.getKey(0)).toEqual(6);
-
- // move
- act(() => {
- hook.result.current.move(0, 1);
- });
- expect(hook.result.current.list[0]).toEqual(2);
- expect(hook.result.current.getKey(0)).toEqual(7);
-
- // move without changes
- act(() => {
- hook.result.current.move(2, 2);
- });
- expect(hook.result.current.list[0]).toEqual(2);
- expect(hook.result.current.getKey(0)).toEqual(7);
-
- // shift
- act(() => {
- hook.result.current.shift();
- });
- expect(hook.result.current.list[0]).toEqual(1);
- expect(hook.result.current.getKey(0)).toEqual(6);
- expect(hook.result.current.list.length).toEqual(9);
-
- // pop
- act(() => {
- hook.result.current.pop();
- });
- expect(hook.result.current.list.length).toEqual(8);
-
- // replace
- act(() => {
- hook.result.current.replace(7, { value: 8 });
- });
- expect(hook.result.current.list[7].value).toEqual(8);
-
- // remove
- act(() => {
- hook.result.current.remove(7);
- });
- expect(hook.result.current.list.length).toEqual(7);
- });
-
- it('same items should have different keys', () => {
- const hook = setUp([1, 1, 1, 1]);
- expect(hook.result.current.getKey(0)).toEqual(0);
- expect(hook.result.current.getKey(1)).toEqual(1);
- expect(hook.result.current.getKey(2)).toEqual(2);
- expect(hook.result.current.getKey(3)).toEqual(3);
-
- act(() => {
- hook.result.current.push(1);
- });
-
- expect(hook.result.current.getKey(4)).toEqual(4);
- const testObj = {};
-
- act(() => {
- hook.result.current.push({});
- hook.result.current.push(testObj);
- hook.result.current.push(testObj);
- });
-
- expect(hook.result.current.getKey(5)).toEqual(5);
- expect(hook.result.current.getKey(6)).toEqual(6);
- expect(hook.result.current.getKey(7)).toEqual(7);
- });
-
- it('initialValue changes', () => {
- const hook = renderHook(({ initialValue }) => useDynamicList(initialValue), {
- initialProps: {
- initialValue: [1],
- },
- });
- expect(hook.result.current.list[0]).toEqual(1);
- expect(hook.result.current.getKey(0)).toEqual(0);
-
- act(() => {
- hook.result.current.resetList([2]);
- });
-
- expect(hook.result.current.list[0]).toEqual(2);
- expect(hook.result.current.getKey(0)).toEqual(1);
-
- act(() => {
- hook.result.current.resetList([3]);
- });
-
- expect(hook.result.current.list[0]).toEqual(3);
- expect(hook.result.current.getKey(0)).toEqual(2);
- });
-
- it('sortList', () => {
- const hook = setUp([1, 2, 3, 4]);
- const formData = [
- {
- name: 'my bro',
- age: '23',
- memo: "he's my bro",
- },
- {
- name: 'my sis',
- age: '21',
- memo: "she's my sis",
- },
- null,
- {
- name: '新增行',
- age: '25',
- },
- ];
-
- let sorted = hook.result.current.sortList(formData);
- expect(sorted.length).toEqual(3);
- expect(sorted[0].name).toEqual('my bro');
-
- act(() => {
- hook.result.current.move(3, 0);
- });
- sorted = hook.result.current.sortList(formData);
- expect(sorted[0].name).toEqual('新增行');
- });
-});
diff --git a/packages/hooks/src/useDynamicList/demo/demo1.tsx b/packages/hooks/src/useDynamicList/demo/demo1.tsx
index e7045a086c..0318872fe3 100644
--- a/packages/hooks/src/useDynamicList/demo/demo1.tsx
+++ b/packages/hooks/src/useDynamicList/demo/demo1.tsx
@@ -8,11 +8,11 @@
import { MinusCircleOutlined, PlusCircleOutlined } from '@ant-design/icons';
import { useDynamicList } from 'ahooks';
-import { Input } from 'antd';
-import React from 'react';
+import { Button, Input, Space } from 'antd';
export default () => {
- const { list, remove, getKey, insert, replace } = useDynamicList(['David', 'Jack']);
+ const { list, remove, batchRemove, getKey, insert, replace } = useDynamicList(['David', 'Jack']);
+ const listIndexes = list.map((item, index) => index);
const Row = (index: number, item: any) => (
@@ -44,6 +44,23 @@ export default () => {
<>
{list.map((ele, index) => Row(index, ele))}
+
+ <= 1}
+ onClick={() => batchRemove(listIndexes.filter((index) => index % 2 === 0))}
+ >
+ Remove odd items
+
+ <= 1}
+ onClick={() => batchRemove(listIndexes.filter((index) => index % 2 !== 0))}
+ >
+ Remove even items
+
+
+
{JSON.stringify([list])}
>
);
diff --git a/packages/hooks/src/useDynamicList/demo/demo2.tsx b/packages/hooks/src/useDynamicList/demo/demo2.tsx
index e7bf41c150..b9b06a36d7 100644
--- a/packages/hooks/src/useDynamicList/demo/demo2.tsx
+++ b/packages/hooks/src/useDynamicList/demo/demo2.tsx
@@ -9,7 +9,7 @@
import { MinusCircleOutlined, PlusCircleOutlined } from '@ant-design/icons';
import { useDynamicList } from 'ahooks';
import { Button, Form, Input } from 'antd';
-import React, { useEffect, useState } from 'react';
+import { useEffect, useState } from 'react';
const DynamicInputs = ({
value = [],
diff --git a/packages/hooks/src/useDynamicList/demo/demo3.tsx b/packages/hooks/src/useDynamicList/demo/demo3.tsx
index b5c319fab4..bf3976a88c 100644
--- a/packages/hooks/src/useDynamicList/demo/demo3.tsx
+++ b/packages/hooks/src/useDynamicList/demo/demo3.tsx
@@ -6,7 +6,7 @@
* desc.zh-CN: 注意 sortList 的使用,antd Form 获取的数据排序不对,通过 sortList 可以校准排序。
*/
-import React, { useState } from 'react';
+import { useState } from 'react';
import { Form, Button, Input } from 'antd';
import { MinusCircleOutlined, PlusCircleOutlined } from '@ant-design/icons';
import { useDynamicList } from 'ahooks';
diff --git a/packages/hooks/src/useDynamicList/demo/demo4.tsx b/packages/hooks/src/useDynamicList/demo/demo4.tsx
index ef09146e67..6c762162d1 100644
--- a/packages/hooks/src/useDynamicList/demo/demo4.tsx
+++ b/packages/hooks/src/useDynamicList/demo/demo4.tsx
@@ -8,7 +8,7 @@
import { DragOutlined } from '@ant-design/icons';
import { Button, Form, Input, Table } from 'antd';
-import React, { useState } from 'react';
+import { useState } from 'react';
import ReactDragListView from 'react-drag-listview';
import { useDynamicList } from 'ahooks';
@@ -75,6 +75,7 @@ export default () => {
return (
);
diff --git a/packages/hooks/src/useFusionTable/demo/form.tsx b/packages/hooks/src/useFusionTable/demo/form.tsx
index 2f2c066c70..a2717b4082 100644
--- a/packages/hooks/src/useFusionTable/demo/form.tsx
+++ b/packages/hooks/src/useFusionTable/demo/form.tsx
@@ -1,6 +1,6 @@
-import React from 'react';
-import { Table, Pagination, Field, Form, Input, Button, Select, Icon } from '@alifd/next';
+import { Table, Pagination, Field, Form, Input, Button, Icon } from '@alifd/next';
import { useFusionTable } from 'ahooks';
+import ReactJson from 'react-json-view';
interface Item {
name: {
@@ -16,7 +16,10 @@ interface Result {
list: Item[];
}
-const getTableData = ({ current, pageSize }, formData: Object): Promise
=> {
+const getTableData = (
+ { current, pageSize }: { current: number; pageSize: number },
+ formData: Object,
+): Promise => {
let query = `page=${current}&size=${pageSize}`;
Object.entries(formData).forEach(([key, value]) => {
if (value) {
@@ -33,7 +36,7 @@ const getTableData = ({ current, pageSize }, formData: Object): Promise
};
const AppList = () => {
- const field = Field.useField([]);
+ const field = Field.useField({} as any);
const { paginationProps, tableProps, search, loading, params } = useFusionTable(getTableData, {
field,
});
@@ -43,7 +46,11 @@ const AppList = () => {
@@ -79,17 +86,13 @@ const AppList = () => {
-
- all
- male
- female
-
-
-
{
-
Current Table: {JSON.stringify(params[0])}
-
Current Form: {JSON.stringify(params[1])}
+
Current Table:
+
+
Current Form:
+
>
);
diff --git a/packages/hooks/src/useFusionTable/demo/init.tsx b/packages/hooks/src/useFusionTable/demo/init.tsx
index 43b8fc52e8..ccbd2499fd 100644
--- a/packages/hooks/src/useFusionTable/demo/init.tsx
+++ b/packages/hooks/src/useFusionTable/demo/init.tsx
@@ -1,6 +1,6 @@
import { Button, Field, Form, Icon, Input, Pagination, Select, Table } from '@alifd/next';
-import React from 'react';
import { useFusionTable } from 'ahooks';
+import ReactJson from 'react-json-view';
interface Item {
name: {
@@ -16,7 +16,10 @@ interface Result {
list: Item[];
}
-const getTableData = ({ current, pageSize }, formData: Object): Promise => {
+const getTableData = (
+ { current, pageSize }: { current: number; pageSize: number },
+ formData: Object,
+): Promise => {
let query = `page=${current}&size=${pageSize}`;
Object.entries(formData).forEach(([key, value]) => {
if (value) {
@@ -33,7 +36,7 @@ const getTableData = ({ current, pageSize }, formData: Object): Promise
};
const AppList = () => {
- const field = Field.useField([]);
+ const field = Field.useField({} as any);
const { paginationProps, tableProps, search, loading, params } = useFusionTable(getTableData, {
field,
defaultParams: [
@@ -124,8 +127,10 @@ const AppList = () => {
-
Current Table: {JSON.stringify(params[0])}
-
Current Form: {JSON.stringify(params[1])}
+
Current Table:
+
+
Current Form:
+
>
);
diff --git a/packages/hooks/src/useFusionTable/demo/table.tsx b/packages/hooks/src/useFusionTable/demo/table.tsx
index de53a66f08..92f5f8388e 100644
--- a/packages/hooks/src/useFusionTable/demo/table.tsx
+++ b/packages/hooks/src/useFusionTable/demo/table.tsx
@@ -1,5 +1,4 @@
import { Pagination, Table } from '@alifd/next';
-import React from 'react';
import { useFusionTable } from 'ahooks';
interface Item {
@@ -16,7 +15,13 @@ interface Result {
list: Item[];
}
-const getTableData = ({ current, pageSize }): Promise => {
+const getTableData = ({
+ current,
+ pageSize,
+}: {
+ current: number;
+ pageSize: number;
+}): Promise => {
const query = `page=${current}&size=${pageSize}`;
return fetch(`https://randomuser.me/api?results=${pageSize}&${query}`)
diff --git a/packages/hooks/src/useFusionTable/demo/validate.tsx b/packages/hooks/src/useFusionTable/demo/validate.tsx
index 0ee1fefe0d..f729cd4283 100644
--- a/packages/hooks/src/useFusionTable/demo/validate.tsx
+++ b/packages/hooks/src/useFusionTable/demo/validate.tsx
@@ -1,6 +1,6 @@
-import React from 'react';
-import { Table, Pagination, Field, Form, Input, Select, Icon } from '@alifd/next';
+import { Table, Pagination, Field, Form, Input, Icon } from '@alifd/next';
import { useFusionTable } from 'ahooks';
+import ReactJson from 'react-json-view';
interface Item {
name: {
@@ -16,7 +16,10 @@ interface Result {
list: Item[];
}
-const getTableData = ({ current, pageSize }, formData: Object): Promise => {
+const getTableData = (
+ { current, pageSize }: { current: number; pageSize: number },
+ formData: Object,
+): Promise => {
let query = `page=${current}&size=${pageSize}`;
Object.entries(formData).forEach(([key, value]) => {
if (value) {
@@ -33,7 +36,7 @@ const getTableData = ({ current, pageSize }, formData: Object): Promise
};
const AppList = () => {
- const field = Field.useField([]);
+ const field = Field.useField({} as any);
const { paginationProps, tableProps, search, params } = useFusionTable(getTableData, {
field,
defaultParams: [{ current: 1, pageSize: 10 }, { name: 'hello' }],
@@ -71,8 +74,10 @@ const AppList = () => {
-
Current Table: {JSON.stringify(params[0])}
-
Current Form: {JSON.stringify(params[1])}
+
Current Table:
+
+
Current Form:
+
>
);
diff --git a/packages/hooks/src/useFusionTable/fusionAdapter.ts b/packages/hooks/src/useFusionTable/fusionAdapter.ts
index 47b9291558..73f456bb08 100644
--- a/packages/hooks/src/useFusionTable/fusionAdapter.ts
+++ b/packages/hooks/src/useFusionTable/fusionAdapter.ts
@@ -10,7 +10,7 @@ export const fieldAdapter = (field: Field) =>
validateFields: (fields, callback) => {
field.validate(fields, callback);
},
- } as AntdFormUtils);
+ }) as AntdFormUtils;
export const resultAdapter = (result: any) => {
const tableProps = {
@@ -26,7 +26,7 @@ export const resultAdapter = (result: any) => {
},
);
},
- onFilter: (filterParams: Object) => {
+ onFilter: (filterParams: Record) => {
result.tableProps.onChange(
{ current: result.pagination.current, pageSize: result.pagination.pageSize },
filterParams,
diff --git a/packages/hooks/src/useFusionTable/types.ts b/packages/hooks/src/useFusionTable/types.ts
index d10c656608..e3df65b247 100644
--- a/packages/hooks/src/useFusionTable/types.ts
+++ b/packages/hooks/src/useFusionTable/types.ts
@@ -1,11 +1,11 @@
import type { AntdTableOptions, AntdTableResult, Data, Params } from '../useAntdTable/types';
export interface Field {
- getFieldInstance?: (name: string) => {};
+ getFieldInstance?: (name: string) => Record;
setValues: (value: Record) => void;
getValues: (...args: any) => Record;
reset: (...args: any) => void;
- validate: (fields: any, callback: (errors, values) => void) => void;
+ validate: (fields: any, callback: (errors: any, values: any) => void) => void;
[key: string]: any;
}
diff --git a/packages/hooks/src/useGetState/__tests__/index.test.ts b/packages/hooks/src/useGetState/__tests__/index.spec.ts
similarity index 59%
rename from packages/hooks/src/useGetState/__tests__/index.test.ts
rename to packages/hooks/src/useGetState/__tests__/index.spec.ts
index bf8dce4371..697ee8d4a1 100644
--- a/packages/hooks/src/useGetState/__tests__/index.test.ts
+++ b/packages/hooks/src/useGetState/__tests__/index.spec.ts
@@ -1,4 +1,5 @@
-import { act, renderHook } from '@testing-library/react-hooks';
+import { act, renderHook } from '@testing-library/react';
+import { describe, expect, test } from 'vitest';
import useGetState from '../index';
describe('useGetState', () => {
@@ -12,25 +13,25 @@ describe('useGetState', () => {
} as const;
});
- it('should support initialValue', () => {
+ test('should support initialValue', () => {
const hook = setUp(() => 0);
- expect(hook.result.current.state).toEqual(0);
+ expect(hook.result.current.state).toBe(0);
});
- it('should support update', () => {
+ test('should support update', () => {
const hook = setUp(0);
act(() => {
hook.result.current.setState(1);
});
- expect(hook.result.current.getState()).toEqual(1);
+ expect(hook.result.current.getState()).toBe(1);
});
- it('should getState frozen', () => {
+ test('should getState frozen', () => {
const hook = setUp(0);
const prevGetState = hook.result.current.getState;
act(() => {
hook.result.current.setState(1);
});
- expect(hook.result.current.getState).toEqual(prevGetState);
+ expect(hook.result.current.getState).toBe(prevGetState);
});
});
diff --git a/packages/hooks/src/useGetState/demo/demo1.tsx b/packages/hooks/src/useGetState/demo/demo1.tsx
index 119712b36e..2046fb982f 100644
--- a/packages/hooks/src/useGetState/demo/demo1.tsx
+++ b/packages/hooks/src/useGetState/demo/demo1.tsx
@@ -6,7 +6,7 @@
* desc.zh-CN: 计数器每 3 秒打印一次值
*/
-import React, { useEffect } from 'react';
+import { useEffect } from 'react';
import { useGetState } from 'ahooks';
export default () => {
diff --git a/packages/hooks/src/useGetState/index.ts b/packages/hooks/src/useGetState/index.ts
index 51f76c61aa..f1799ac031 100644
--- a/packages/hooks/src/useGetState/index.ts
+++ b/packages/hooks/src/useGetState/index.ts
@@ -1,5 +1,6 @@
import type { Dispatch, SetStateAction } from 'react';
-import { useState, useRef, useCallback } from 'react';
+import { useState, useCallback } from 'react';
+import useLatest from '../useLatest';
type GetStateAction = () => S;
@@ -13,8 +14,7 @@ function useGetState(): [
];
function useGetState(initialState?: S) {
const [state, setState] = useState(initialState);
- const stateRef = useRef(state);
- stateRef.current = state;
+ const stateRef = useLatest(state);
const getState = useCallback(() => stateRef.current, []);
diff --git a/packages/hooks/src/useHistoryTravel/__tests__/index.test.ts b/packages/hooks/src/useHistoryTravel/__tests__/index.spec.ts
similarity index 51%
rename from packages/hooks/src/useHistoryTravel/__tests__/index.test.ts
rename to packages/hooks/src/useHistoryTravel/__tests__/index.spec.ts
index 041701f0c5..e0601fef2b 100644
--- a/packages/hooks/src/useHistoryTravel/__tests__/index.test.ts
+++ b/packages/hooks/src/useHistoryTravel/__tests__/index.spec.ts
@@ -1,61 +1,62 @@
-import { renderHook, act } from '@testing-library/react-hooks';
+import { act, renderHook } from '@testing-library/react';
+import { describe, expect, test } from 'vitest';
import useHistoryTravel from '../index';
describe('useHistoryTravel', () => {
- it('should work without initial value', async () => {
+ test('should work without initial value', async () => {
const hook = renderHook(() => useHistoryTravel());
- expect(hook.result.current.value).toEqual(undefined);
+ expect(hook.result.current.value).toBeUndefined();
act(() => {
hook.result.current.setValue('test');
});
- expect(hook.result.current.value).toEqual('test');
+ expect(hook.result.current.value).toBe('test');
});
- it('should work with null and undefined without initial value', async () => {
+ test('should work with null and undefined without initial value', async () => {
const nullHook = renderHook(() => useHistoryTravel());
- expect(nullHook.result.current.value).toEqual(undefined);
+ expect(nullHook.result.current.value).toBeUndefined();
act(() => {
nullHook.result.current.setValue(null);
});
- expect(nullHook.result.current.value).toEqual(null);
+ expect(nullHook.result.current.value).toBeNull();
const undefHook = renderHook(() => useHistoryTravel());
- expect(undefHook.result.current.value).toEqual(undefined);
+ expect(undefHook.result.current.value).toBeUndefined();
act(() => {
undefHook.result.current.setValue('def');
});
act(() => {
undefHook.result.current.setValue(undefined);
});
- expect(undefHook.result.current.value).toEqual(undefined);
- expect(undefHook.result.current.backLength).toEqual(2);
+ expect(undefHook.result.current.value).toBeUndefined();
+ expect(undefHook.result.current.backLength).toBe(2);
});
- it('should work with initial value', async () => {
+ test('should work with initial value', async () => {
const hook = renderHook(() => useHistoryTravel('abc'));
- expect(hook.result.current.value).toEqual('abc');
+ expect(hook.result.current.value).toBe('abc');
act(() => {
hook.result.current.setValue('def');
});
- expect(hook.result.current.value).toEqual('def');
+ expect(hook.result.current.value).toBe('def');
});
- it('should work with null and undefined with initial value', async () => {
+ test('should work with null and undefined with initial value', async () => {
const nullHook = renderHook(() => useHistoryTravel('abc'));
act(() => {
nullHook.result.current.setValue(null);
});
- expect(nullHook.result.current.value).toEqual(null);
+ expect(nullHook.result.current.value).toBeNull();
const undefHook = renderHook(() => useHistoryTravel('abc'));
act(() => {
undefHook.result.current.setValue(undefined);
});
- expect(undefHook.result.current.value).toEqual(undefined);
- expect(undefHook.result.current.backLength).toEqual(1);
+ expect(undefHook.result.current.value).toBeUndefined();
+ expect(undefHook.result.current.backLength).toBe(1);
});
- it('back and forward should work', () => {
+ test('back and forward should work', () => {
const hook = renderHook(() => useHistoryTravel());
act(() => {
hook.result.current.setValue('ddd');
@@ -63,22 +64,22 @@ describe('useHistoryTravel', () => {
act(() => {
hook.result.current.setValue('abc');
});
- expect(hook.result.current.value).toEqual('abc');
+ expect(hook.result.current.value).toBe('abc');
act(() => {
hook.result.current.setValue('def');
});
- expect(hook.result.current.value).toEqual('def');
+ expect(hook.result.current.value).toBe('def');
act(() => {
hook.result.current.back();
});
- expect(hook.result.current.value).toEqual('abc');
+ expect(hook.result.current.value).toBe('abc');
act(() => {
hook.result.current.forward();
});
- expect(hook.result.current.value).toEqual('def');
+ expect(hook.result.current.value).toBe('def');
});
- it('go should work for negative step', () => {
+ test('go should work for negative step', () => {
const hook = renderHook(() => useHistoryTravel('init'));
act(() => {
hook.result.current.setValue('abc');
@@ -92,14 +93,14 @@ describe('useHistoryTravel', () => {
act(() => {
hook.result.current.go(-2);
});
- expect(hook.result.current.value).toEqual('abc');
+ expect(hook.result.current.value).toBe('abc');
act(() => {
hook.result.current.go(-100);
});
- expect(hook.result.current.value).toEqual('init');
+ expect(hook.result.current.value).toBe('init');
});
- it('go should work for positive step', () => {
+ test('go should work for positive step', () => {
const hook = renderHook(() => useHistoryTravel('init'));
act(() => {
hook.result.current.setValue('abc');
@@ -113,18 +114,18 @@ describe('useHistoryTravel', () => {
act(() => {
hook.result.current.go(-3);
});
- expect(hook.result.current.value).toEqual('init');
+ expect(hook.result.current.value).toBe('init');
act(() => {
hook.result.current.go(2);
});
- expect(hook.result.current.value).toEqual('def');
+ expect(hook.result.current.value).toBe('def');
act(() => {
hook.result.current.go(100);
});
- expect(hook.result.current.value).toEqual('hij');
+ expect(hook.result.current.value).toBe('hij');
});
- it('reset should reset state to initial by default', () => {
+ test('reset should reset state to initial by default', () => {
const hook = renderHook(() => useHistoryTravel('init'));
act(() => {
hook.result.current.setValue('abc');
@@ -138,17 +139,17 @@ describe('useHistoryTravel', () => {
act(() => {
hook.result.current.go(-1);
});
- expect(hook.result.current.backLength).toEqual(2);
- expect(hook.result.current.forwardLength).toEqual(1);
+ expect(hook.result.current.backLength).toBe(2);
+ expect(hook.result.current.forwardLength).toBe(1);
act(() => {
hook.result.current.reset();
});
- expect(hook.result.current.value).toEqual('init');
- expect(hook.result.current.backLength).toEqual(0);
- expect(hook.result.current.forwardLength).toEqual(0);
+ expect(hook.result.current.value).toBe('init');
+ expect(hook.result.current.backLength).toBe(0);
+ expect(hook.result.current.forwardLength).toBe(0);
});
- it('reset should reset state to new initial if provided', () => {
+ test('reset should reset state to new initial if provided', () => {
const hook = renderHook(() => useHistoryTravel('init'));
act(() => {
hook.result.current.setValue('abc');
@@ -162,17 +163,17 @@ describe('useHistoryTravel', () => {
act(() => {
hook.result.current.go(-1);
});
- expect(hook.result.current.backLength).toEqual(2);
- expect(hook.result.current.forwardLength).toEqual(1);
+ expect(hook.result.current.backLength).toBe(2);
+ expect(hook.result.current.forwardLength).toBe(1);
act(() => {
hook.result.current.reset('new init');
});
- expect(hook.result.current.value).toEqual('new init');
- expect(hook.result.current.backLength).toEqual(0);
- expect(hook.result.current.forwardLength).toEqual(0);
+ expect(hook.result.current.value).toBe('new init');
+ expect(hook.result.current.backLength).toBe(0);
+ expect(hook.result.current.forwardLength).toBe(0);
});
- it('reset new initial value should work with undefined', () => {
+ test('reset new initial value should work with undefined', () => {
const hook = renderHook(() => useHistoryTravel('init'));
act(() => {
hook.result.current.setValue('abc');
@@ -186,60 +187,60 @@ describe('useHistoryTravel', () => {
act(() => {
hook.result.current.go(-1);
});
- expect(hook.result.current.backLength).toEqual(2);
- expect(hook.result.current.forwardLength).toEqual(1);
+ expect(hook.result.current.backLength).toBe(2);
+ expect(hook.result.current.forwardLength).toBe(1);
act(() => {
hook.result.current.reset(undefined);
});
- expect(hook.result.current.value).toEqual(undefined);
- expect(hook.result.current.backLength).toEqual(0);
- expect(hook.result.current.forwardLength).toEqual(0);
+ expect(hook.result.current.value).toBeUndefined();
+ expect(hook.result.current.backLength).toBe(0);
+ expect(hook.result.current.forwardLength).toBe(0);
});
- it('should work without max length', async () => {
+ test('should work without max length', async () => {
const hook = renderHook(() => useHistoryTravel());
- expect(hook.result.current.backLength).toEqual(0);
+ expect(hook.result.current.backLength).toBe(0);
for (let i = 1; i <= 100; i++) {
act(() => {
hook.result.current.setValue(i);
});
}
- expect(hook.result.current.forwardLength).toEqual(0);
- expect(hook.result.current.backLength).toEqual(100);
- expect(hook.result.current.value).toEqual(100);
+ expect(hook.result.current.forwardLength).toBe(0);
+ expect(hook.result.current.backLength).toBe(100);
+ expect(hook.result.current.value).toBe(100);
});
- it('should work with max length', async () => {
+ test('should work with max length', async () => {
const hook = renderHook(() => useHistoryTravel(0, 10));
- expect(hook.result.current.backLength).toEqual(0);
+ expect(hook.result.current.backLength).toBe(0);
for (let i = 1; i <= 100; i++) {
act(() => {
hook.result.current.setValue(i);
});
}
- expect(hook.result.current.forwardLength).toEqual(0);
- expect(hook.result.current.backLength).toEqual(10);
- expect(hook.result.current.value).toEqual(100);
+ expect(hook.result.current.forwardLength).toBe(0);
+ expect(hook.result.current.backLength).toBe(10);
+ expect(hook.result.current.value).toBe(100);
act(() => {
hook.result.current.go(-5);
});
- expect(hook.result.current.forwardLength).toEqual(5);
- expect(hook.result.current.backLength).toEqual(5);
- expect(hook.result.current.value).toEqual(95);
+ expect(hook.result.current.forwardLength).toBe(5);
+ expect(hook.result.current.backLength).toBe(5);
+ expect(hook.result.current.value).toBe(95);
act(() => {
hook.result.current.go(5);
});
- expect(hook.result.current.forwardLength).toEqual(0);
- expect(hook.result.current.backLength).toEqual(10);
- expect(hook.result.current.value).toEqual(100);
+ expect(hook.result.current.forwardLength).toBe(0);
+ expect(hook.result.current.backLength).toBe(10);
+ expect(hook.result.current.value).toBe(100);
act(() => {
hook.result.current.go(-50);
});
- expect(hook.result.current.forwardLength).toEqual(10);
- expect(hook.result.current.backLength).toEqual(0);
- expect(hook.result.current.value).toEqual(90);
+ expect(hook.result.current.forwardLength).toBe(10);
+ expect(hook.result.current.backLength).toBe(0);
+ expect(hook.result.current.value).toBe(90);
});
});
diff --git a/packages/hooks/src/useHistoryTravel/demo/demo1.tsx b/packages/hooks/src/useHistoryTravel/demo/demo1.tsx
index 7a9b7dea93..88399fadc2 100644
--- a/packages/hooks/src/useHistoryTravel/demo/demo1.tsx
+++ b/packages/hooks/src/useHistoryTravel/demo/demo1.tsx
@@ -7,8 +7,6 @@
*/
import { useHistoryTravel } from 'ahooks';
-import React from 'react';
-
export default () => {
const { value, setValue, backLength, forwardLength, back, forward } = useHistoryTravel();
diff --git a/packages/hooks/src/useHistoryTravel/demo/demo2.tsx b/packages/hooks/src/useHistoryTravel/demo/demo2.tsx
index 060d7a0d3a..4c1e5bc5a6 100644
--- a/packages/hooks/src/useHistoryTravel/demo/demo2.tsx
+++ b/packages/hooks/src/useHistoryTravel/demo/demo2.tsx
@@ -7,7 +7,7 @@
*/
import { useHistoryTravel } from 'ahooks';
-import React, { useState } from 'react';
+import { useState } from 'react';
export default () => {
const {
diff --git a/packages/hooks/src/useHistoryTravel/demo/demo3.tsx b/packages/hooks/src/useHistoryTravel/demo/demo3.tsx
index 2a62778d56..582dcdb28e 100644
--- a/packages/hooks/src/useHistoryTravel/demo/demo3.tsx
+++ b/packages/hooks/src/useHistoryTravel/demo/demo3.tsx
@@ -7,8 +7,6 @@
*/
import { useHistoryTravel } from 'ahooks';
-import React from 'react';
-
export default () => {
const maxLength = 3;
const { value, setValue, backLength, forwardLength, back, forward } = useHistoryTravel(
diff --git a/packages/hooks/src/useHover/__tests__/index.spec.tsx b/packages/hooks/src/useHover/__tests__/index.spec.tsx
new file mode 100644
index 0000000000..d4db08d330
--- /dev/null
+++ b/packages/hooks/src/useHover/__tests__/index.spec.tsx
@@ -0,0 +1,30 @@
+import { act, fireEvent, render, renderHook } from '@testing-library/react';
+import { describe, expect, test } from 'vitest';
+import useHover from '../index';
+
+describe('useHover', () => {
+ test('should work', () => {
+ const { getByText } = render(Hover );
+ let trigger = 0;
+ const { result } = renderHook(() =>
+ useHover(getByText('Hover'), {
+ onEnter: () => {
+ trigger++;
+ },
+ onLeave: () => {
+ trigger++;
+ },
+ }),
+ );
+
+ expect(result.current).toBe(false);
+
+ act(() => void fireEvent.mouseEnter(getByText('Hover')));
+ expect(result.current).toBe(true);
+ expect(trigger).toBe(1);
+
+ act(() => void fireEvent.mouseLeave(getByText('Hover')));
+ expect(result.current).toBe(false);
+ expect(trigger).toBe(2);
+ });
+});
diff --git a/packages/hooks/src/useHover/__tests__/index.test.tsx b/packages/hooks/src/useHover/__tests__/index.test.tsx
deleted file mode 100644
index 2da79b60d6..0000000000
--- a/packages/hooks/src/useHover/__tests__/index.test.tsx
+++ /dev/null
@@ -1,38 +0,0 @@
-// write your test cases here
-import React from 'react';
-import { renderHook, act } from '@testing-library/react-hooks';
-import { render, fireEvent } from '@testing-library/react';
-import useHover from '../index';
-
-describe('useHover', () => {
- it('should work', () => {
- const { getByText } = render(Hover );
- let trigger = 0;
- const { result } = renderHook(() =>
- useHover(getByText('Hover'), {
- onEnter: () => {
- trigger++;
- },
- onLeave: () => {
- trigger++;
- },
- }),
- );
-
- expect(result.current).toBe(false);
-
- act(() => {
- fireEvent.mouseOver(getByText('Hover'), () => {
- expect(result.current).toBe(true);
- expect(trigger).toBe(1);
- });
- });
-
- act(() => {
- fireEvent.mouseLeave(getByText('Hover'), () => {
- expect(result.current).toBe(false);
- expect(trigger).toBe(2);
- });
- });
- });
-});
diff --git a/packages/hooks/src/useHover/demo/demo1.tsx b/packages/hooks/src/useHover/demo/demo1.tsx
index 36ffb0fb4d..3a8cc7f8e0 100644
--- a/packages/hooks/src/useHover/demo/demo1.tsx
+++ b/packages/hooks/src/useHover/demo/demo1.tsx
@@ -6,7 +6,7 @@
* desc.zh-CN: 使用 ref 设置需要监听的元素。
*/
-import React, { useRef } from 'react';
+import { useRef } from 'react';
import { useHover } from 'ahooks';
export default () => {
diff --git a/packages/hooks/src/useHover/demo/demo2.tsx b/packages/hooks/src/useHover/demo/demo2.tsx
index 3d86c391ca..daf05bc0ba 100644
--- a/packages/hooks/src/useHover/demo/demo2.tsx
+++ b/packages/hooks/src/useHover/demo/demo2.tsx
@@ -6,7 +6,6 @@
* desc.zh-CN: 传入 function 并返回一个 dom 元素。
*/
-import React from 'react';
import { useHover } from 'ahooks';
export default () => {
@@ -17,7 +16,7 @@ export default () => {
onLeave: () => {
console.log('onLeave');
},
- onChange: isHover => {
+ onChange: (isHover) => {
console.log('onChange', isHover);
},
});
diff --git a/packages/hooks/src/useInViewport/__tests__/index.spec.ts b/packages/hooks/src/useInViewport/__tests__/index.spec.ts
new file mode 100644
index 0000000000..6cb8799681
--- /dev/null
+++ b/packages/hooks/src/useInViewport/__tests__/index.spec.ts
@@ -0,0 +1,87 @@
+import { act, renderHook } from '@testing-library/react';
+import { describe, expect, test, vi } from 'vitest';
+
+import useInViewport from '../index';
+
+const targetEl = document.createElement('div');
+document.body.appendChild(targetEl);
+
+const observe = vi.fn();
+const disconnect = vi.fn();
+
+const mockIntersectionObserver = vi.fn().mockReturnValue({
+ observe,
+ disconnect,
+});
+
+window.IntersectionObserver = mockIntersectionObserver;
+
+describe('useInViewport', () => {
+ test('should work when target is in viewport', async () => {
+ const { result } = renderHook(() => useInViewport(targetEl));
+ const calls = mockIntersectionObserver.mock.calls;
+ const [onChange] = calls[calls.length - 1];
+
+ act(() => {
+ onChange([
+ {
+ targetEl,
+ isIntersecting: true,
+ intersectionRatio: 0.5,
+ },
+ ]);
+ });
+
+ const [inViewport, ratio] = result.current;
+ expect(inViewport).toBeTruthy();
+ expect(ratio).toBe(0.5);
+ });
+
+ test('should work when target array is in viewport and has a callback', async () => {
+ const targetEls: HTMLDivElement[] = [];
+ const callback = vi.fn();
+ for (let i = 0; i < 2; i++) {
+ const target = document.createElement('div');
+ document.body.appendChild(target);
+ targetEls.push(target);
+ }
+
+ const getValue = (isIntersecting: any, intersectionRatio: any) => ({
+ isIntersecting,
+ intersectionRatio,
+ });
+
+ const { result } = renderHook(() => useInViewport(targetEls, { callback }));
+ const calls = mockIntersectionObserver.mock.calls;
+ const [observerCallback] = calls[calls.length - 1];
+
+ const target = getValue(false, 0);
+ act(() => observerCallback([target]));
+ expect(callback).toHaveBeenCalledWith(target);
+ expect(result.current[0]).toBe(false);
+ expect(result.current[1]).toBe(0);
+
+ const target1 = getValue(true, 0.5);
+ act(() => observerCallback([target1]));
+ expect(callback).toHaveBeenCalledWith(target1);
+ expect(result.current[0]).toBe(true);
+ expect(result.current[1]).toBe(0.5);
+ });
+
+ test('should not work when target is null', async () => {
+ const previousCallsLength = mockIntersectionObserver.mock.calls.length;
+ renderHook(() => useInViewport(null));
+ const currentCallsLength = mockIntersectionObserver.mock.calls.length;
+ expect(currentCallsLength).toBe(previousCallsLength);
+ });
+
+ test('should disconnect when unmount', async () => {
+ mockIntersectionObserver.mockReturnValue({
+ observe: () => null,
+ disconnect,
+ });
+ const { unmount } = renderHook(() => useInViewport(targetEl));
+ unmount();
+ expect(disconnect).toBeCalled();
+ });
+});
diff --git a/packages/hooks/src/useInViewport/__tests__/index.test.ts b/packages/hooks/src/useInViewport/__tests__/index.test.ts
deleted file mode 100644
index 51ba4bcdeb..0000000000
--- a/packages/hooks/src/useInViewport/__tests__/index.test.ts
+++ /dev/null
@@ -1,51 +0,0 @@
-import useInViewport from '../index';
-import { renderHook, act } from '@testing-library/react-hooks';
-
-const targetEl = document.createElement('div');
-document.body.appendChild(targetEl);
-
-const mockIntersectionObserver = jest.fn().mockReturnValue({
- observe: () => null,
- disconnect: () => null,
-});
-
-window.IntersectionObserver = mockIntersectionObserver;
-
-describe('useInViewport', () => {
- it('should work when target is in viewport', async () => {
- const { result } = renderHook(() => useInViewport(targetEl));
- const calls = mockIntersectionObserver.mock.calls;
- const [onChange] = calls[calls.length - 1];
-
- act(() => {
- onChange([
- {
- targetEl,
- isIntersecting: true,
- intersectionRatio: 0.5,
- },
- ]);
- });
-
- const [inViewport, ratio] = result.current;
- expect(inViewport).toBeTruthy();
- expect(ratio).toBe(0.5);
- });
-
- it('should not work when target is null', async () => {
- renderHook(() => useInViewport(null));
- const calls = mockIntersectionObserver.mock.calls;
- expect(calls[calls.length - 1]).toBeUndefined();
- });
-
- it('should disconnect when unmount', async () => {
- const disconnect = jest.fn();
- mockIntersectionObserver.mockReturnValue({
- observe: () => null,
- disconnect,
- });
- const { unmount } = renderHook(() => useInViewport(targetEl));
- unmount();
- expect(disconnect).toBeCalled();
- });
-});
diff --git a/packages/hooks/src/useInViewport/demo/demo1.tsx b/packages/hooks/src/useInViewport/demo/demo1.tsx
index fc518cf312..89cb774bd2 100644
--- a/packages/hooks/src/useInViewport/demo/demo1.tsx
+++ b/packages/hooks/src/useInViewport/demo/demo1.tsx
@@ -6,7 +6,7 @@
* desc.zh-CN: 监听元素是否在可见区域内
*/
-import React, { useRef } from 'react';
+import { useRef } from 'react';
import { useInViewport } from 'ahooks';
export default () => {
diff --git a/packages/hooks/src/useInViewport/demo/demo2.tsx b/packages/hooks/src/useInViewport/demo/demo2.tsx
index 47787ccde6..ec5b3c35f1 100644
--- a/packages/hooks/src/useInViewport/demo/demo2.tsx
+++ b/packages/hooks/src/useInViewport/demo/demo2.tsx
@@ -6,7 +6,6 @@
* desc.zh-CN: 传入 `options.threshold`, 可以控制在可见区域达到该比例时触发 ratio 更新。 `options.root` 可以控制相对父级元素,在这个例子中,不会相对浏览器视窗变化。
*/
-import React from 'react';
import { useInViewport } from 'ahooks';
export default () => {
diff --git a/packages/hooks/src/useInViewport/demo/demo3.tsx b/packages/hooks/src/useInViewport/demo/demo3.tsx
new file mode 100644
index 0000000000..fdb837b7ac
--- /dev/null
+++ b/packages/hooks/src/useInViewport/demo/demo3.tsx
@@ -0,0 +1,92 @@
+/**
+ * title: Listening content scrolling selection menu
+ * desc: Pass the `callback` that is triggered when the callback of `IntersectionObserver` is called, so you can do some customization.
+ *
+ * title.zh-CN: 监听内容滚动选中菜单
+ * desc.zh-CN: 传入 `callback`, 使得 `IntersectionObserver` 的回调被调用时,用户可以做一些自定义操作。
+ */
+import { useInViewport, useMemoizedFn } from 'ahooks';
+import { useRef, useState } from 'react';
+
+const menus = ['menu-1', 'menu-2', 'menu-3'];
+const content = {
+ 'menu-1': 'Content for menus 1',
+ 'menu-2': 'Content for menus 2',
+ 'menu-3': 'Content for menus 3',
+};
+
+export default () => {
+ const menuRef = useRef([]);
+
+ const [activeMenu, setActiveMenu] = useState(menus[0]);
+
+ const callback = useMemoizedFn((entry) => {
+ if (entry.isIntersecting) {
+ const active = entry.target.getAttribute('id') || '';
+ setActiveMenu(active);
+ }
+ });
+
+ const handleMenuClick = (index: number) => {
+ const contentEl = document.getElementById('content-scroll');
+ const top = menuRef.current[index]?.offsetTop;
+
+ contentEl?.scrollTo({
+ top,
+ behavior: 'smooth',
+ });
+ };
+
+ useInViewport(menuRef.current, {
+ callback,
+ root: () => document.getElementById('parent-scroll'),
+ rootMargin: '-50% 0px -50% 0px',
+ });
+
+ return (
+
+ );
+};
diff --git a/packages/hooks/src/useInViewport/index.en-US.md b/packages/hooks/src/useInViewport/index.en-US.md
index 6024dd8e1b..0c2d4ae8d5 100644
--- a/packages/hooks/src/useInViewport/index.en-US.md
+++ b/packages/hooks/src/useInViewport/index.en-US.md
@@ -17,21 +17,27 @@ Observe whether the element is in the visible area, and the visible area ratio o
+### Listening content scrolling selection menu
+
+
+
## API
```typescript
+type Target = Element | (() => Element) | React.MutableRefObject;
+
const [inViewport, ratio] = useInViewport(
- target,
+ target: Target | Target[],
options?: Options
);
```
### Params
-| Property | Description | Type | Default |
-| -------- | ------------------ | ----------------------------------------------------------- | ------- |
-| target | DOM element or ref | `Element` \| `() => Element` \| `MutableRefObject` | - |
-| options | Setting | `Options` | - |
+| Property | Description | Type | Default |
+| -------- | ---------------------------------- | ------------------------ | ------- |
+| target | DOM elements or Ref, support array | `Target` \| `Target[]` | - |
+| options | Setting | `Options` \| `undefined` | - |
### Options
@@ -42,6 +48,7 @@ More information refer to [Intersection Observer API](https://developer.mozilla.
| threshold | Either a single number or an array of numbers which indicate at what percentage of the target's visibility the ratio should be executed | `number` \| `number[]` | - |
| rootMargin | Margin around the root | `string` | - |
| root | The element that is used as the viewport for checking visibility of the target. Must be the ancestor of the target. Defaults to the browser viewport if not specified or if null. | `Element` \| `Document` \| `() => (Element/Document)` \| `MutableRefObject` | - |
+| callback | Triggered when the callback of `IntersectionObserver` is called | `(entry: IntersectionObserverEntry) => void` | - |
### Result
diff --git a/packages/hooks/src/useInViewport/index.ts b/packages/hooks/src/useInViewport/index.ts
index d00930aed5..dbddf13688 100644
--- a/packages/hooks/src/useInViewport/index.ts
+++ b/packages/hooks/src/useInViewport/index.ts
@@ -4,20 +4,27 @@ import type { BasicTarget } from '../utils/domTarget';
import { getTargetElement } from '../utils/domTarget';
import useEffectWithTarget from '../utils/useEffectWithTarget';
+type CallbackType = (entry: IntersectionObserverEntry) => void;
+
export interface Options {
rootMargin?: string;
threshold?: number | number[];
root?: BasicTarget;
+ callback?: CallbackType;
}
-function useInViewport(target: BasicTarget, options?: Options) {
+function useInViewport(target: BasicTarget | BasicTarget[], options?: Options) {
+ const { callback, ...option } = options || {};
+
const [state, setState] = useState();
const [ratio, setRatio] = useState();
useEffectWithTarget(
() => {
- const el = getTargetElement(target);
- if (!el) {
+ const targets = Array.isArray(target) ? target : [target];
+ const els = targets.map((element) => getTargetElement(element)).filter(Boolean);
+
+ if (!els.length) {
return;
}
@@ -26,21 +33,22 @@ function useInViewport(target: BasicTarget, options?: Options) {
for (const entry of entries) {
setRatio(entry.intersectionRatio);
setState(entry.isIntersecting);
+ callback?.(entry);
}
},
{
- ...options,
+ ...option,
root: getTargetElement(options?.root),
},
);
- observer.observe(el);
+ els.forEach((el) => observer.observe(el!));
return () => {
observer.disconnect();
};
},
- [],
+ [options?.rootMargin, options?.threshold, callback],
target,
);
diff --git a/packages/hooks/src/useInViewport/index.zh-CN.md b/packages/hooks/src/useInViewport/index.zh-CN.md
index bc06a6992b..3ed14dc2c1 100644
--- a/packages/hooks/src/useInViewport/index.zh-CN.md
+++ b/packages/hooks/src/useInViewport/index.zh-CN.md
@@ -17,21 +17,27 @@ nav:
+### 监听内容滚动选中菜单
+
+
+
## API
```typescript
+type Target = Element | (() => Element) | React.MutableRefObject;
+
const [inViewport, ratio] = useInViewport(
- target,
+ target: Target | Target[],
options?: Options
);
```
### Params
-| 参数 | 说明 | 类型 | 默认值 |
-| ------- | ---------------- | ----------------------------------------------------------- | ------ |
-| target | DOM 节点或者 ref | `Element` \| `() => Element` \| `MutableRefObject` | - |
-| options | 设置 | `Options` | - |
+| 参数 | 说明 | 类型 | 默认值 |
+| ------- | -------------------------- | ------------------------ | ------ |
+| target | DOM 节点或者 Ref,支持数组 | `Target` \| `Target[]` | - |
+| options | 设置 | `Options` \| `undefined` | - |
### Options
@@ -42,6 +48,7 @@ const [inViewport, ratio] = useInViewport(
| threshold | 可以是单一的 number 也可以是 number 数组,target 元素和 root 元素相交程度达到该值的时候 ratio 会被更新 | `number` \| `number[]` | - |
| rootMargin | 根(root)元素的外边距 | `string` | - |
| root | 指定根(root)元素,用于检查目标的可见性。必须是目标元素的父级元素,如果未指定或者为 null,则默认为浏览器视窗。 | `Element` \| `Document` \| `() => (Element/Document)` \| `MutableRefObject` | - |
+| callback | `IntersectionObserver` 的回调被调用时触发 | `(entry: IntersectionObserverEntry) => void` | - |
### Result
diff --git a/packages/hooks/src/useInfiniteScroll/__tests__/index.spec.ts b/packages/hooks/src/useInfiniteScroll/__tests__/index.spec.ts
new file mode 100644
index 0000000000..cbbb3903dc
--- /dev/null
+++ b/packages/hooks/src/useInfiniteScroll/__tests__/index.spec.ts
@@ -0,0 +1,535 @@
+import { act, renderHook } from '@testing-library/react';
+import { useState } from 'react';
+import { afterAll, beforeAll, beforeEach, describe, expect, test, vi } from 'vitest';
+import { sleep } from '../../utils/testingHelpers';
+import useInfiniteScroll from '..';
+import type { Data, InfiniteScrollOptions, Service } from '../types';
+
+let count = 0;
+export async function mockRequest() {
+ await sleep(1000);
+ if (count >= 1) {
+ return { list: [4, 5, 6] };
+ }
+ count++;
+ return {
+ list: [1, 2, 3],
+ nextId: count,
+ };
+}
+
+const targetEl = document.createElement('div');
+
+// set target property
+function setTargetInfo(key: 'scrollTop', value: any) {
+ Object.defineProperty(targetEl, key, {
+ value,
+ configurable: true,
+ });
+}
+
+const setup = (service: Service, options?: InfiniteScrollOptions) =>
+ renderHook(() => useInfiniteScroll(service, options));
+
+describe('useInfiniteScroll', () => {
+ let mockRaf: ReturnType;
+
+ beforeEach(() => {
+ count = 0;
+ });
+
+ beforeAll(() => {
+ vi.useFakeTimers();
+ // Mock requestAnimationFrame to execute callbacks immediately
+ mockRaf = vi
+ .spyOn(window, 'requestAnimationFrame')
+ .mockImplementation((cb: FrameRequestCallback) => {
+ cb(0);
+ return 0;
+ }) as ReturnType;
+ });
+
+ afterAll(() => {
+ mockRaf.mockRestore();
+ vi.useRealTimers();
+ });
+
+ test('should auto load', async () => {
+ const { result } = setup(mockRequest);
+ expect(result.current.loading).toBe(true);
+ await act(async () => {
+ vi.advanceTimersByTime(1000);
+ });
+ expect(result.current.loading).toBe(false);
+ });
+
+ test('loadMore should be work', async () => {
+ const { result } = setup(mockRequest, { manual: true });
+ const { loadMore, loading } = result.current;
+ expect(loading).toBe(false);
+ act(() => {
+ loadMore();
+ });
+ expect(result.current.loadingMore).toBe(true);
+ await act(async () => {
+ vi.advanceTimersByTime(1000);
+ });
+ expect(result.current.loadingMore).toBe(false);
+ });
+
+ test('noMore should be true when isNoMore is true', async () => {
+ const { result } = setup(mockRequest, {
+ isNoMore: (d) => d?.nextId === undefined,
+ });
+ const { loadMore } = result.current;
+ await act(async () => {
+ vi.advanceTimersByTime(1000);
+ });
+
+ expect(result.current.noMore).toBe(false);
+ act(() => loadMore());
+ await act(async () => {
+ vi.advanceTimersByTime(1000);
+ });
+ expect(result.current.noMore).toBe(true);
+ });
+
+ test('should auto load when scroll to bottom', async () => {
+ const events: Record = {};
+ const mockAddEventListener = vi
+ .spyOn(targetEl, 'addEventListener')
+ .mockImplementation((eventName: string, callback: any) => {
+ events[eventName] = callback;
+ });
+ const { result } = setup(mockRequest, {
+ target: targetEl,
+ isNoMore: (d) => d?.nextId === undefined,
+ });
+ // not work when loading
+ expect(result.current.loading).toBe(true);
+ events['scroll']();
+ await act(async () => {
+ vi.advanceTimersByTime(1000);
+ });
+ expect(result.current.loading).toBe(false);
+ const scrollHeightSpy = vi.spyOn(targetEl, 'scrollHeight', 'get').mockImplementation(() => 150);
+ const clientHeightSpy = vi.spyOn(targetEl, 'clientHeight', 'get').mockImplementation(() => 300);
+ setTargetInfo('scrollTop', 100);
+ act(() => {
+ events['scroll']();
+ });
+ expect(result.current.loadingMore).toBe(true);
+ await act(async () => {
+ vi.advanceTimersByTime(1000);
+ });
+ expect(result.current.loadingMore).toBe(false);
+
+ // not work when no more
+ expect(result.current.noMore).toBe(true);
+ act(() => {
+ events['scroll']();
+ });
+ expect(result.current.loadingMore).toBe(false);
+ // get list by order
+ expect(result.current.data?.list).toMatchObject([1, 2, 3, 4, 5, 6]);
+
+ mockAddEventListener.mockRestore();
+ scrollHeightSpy.mockRestore();
+ clientHeightSpy.mockRestore();
+ });
+
+ test('should auto load when scroll to top', async () => {
+ const events: Record = {};
+ const mockAddEventListener = vi
+ .spyOn(targetEl, 'addEventListener')
+ .mockImplementation((eventName: string, callback: any) => {
+ events[eventName] = callback;
+ });
+ // Mock scrollTo using Object.defineProperty
+ Object.defineProperty(targetEl, 'scrollTo', {
+ value: (x: number, y: number) => {
+ setTargetInfo('scrollTop', y);
+ },
+ writable: true,
+ });
+
+ const { result } = setup(mockRequest, {
+ target: targetEl,
+ direction: 'top',
+ isNoMore: (d) => d?.nextId === undefined,
+ });
+ // not work when loading
+ expect(result.current.loading).toBe(true);
+ events['scroll']();
+ await act(async () => {
+ vi.advanceTimersByTime(1000);
+ });
+ expect(result.current.loading).toBe(false);
+
+ // mock first scroll
+ const scrollHeightSpy = vi.spyOn(targetEl, 'scrollHeight', 'get').mockImplementation(() => 150);
+ const clientHeightSpy = vi.spyOn(targetEl, 'clientHeight', 'get').mockImplementation(() => 500);
+ setTargetInfo('scrollTop', 300);
+
+ act(() => {
+ events['scroll']();
+ });
+ // mock scroll upward
+ setTargetInfo('scrollTop', 50);
+
+ act(() => {
+ events['scroll']();
+ });
+
+ expect(result.current.loadingMore).toBe(true);
+ await act(async () => {
+ vi.advanceTimersByTime(1000);
+ });
+
+ expect(result.current.loadingMore).toBe(false);
+ //reverse order
+ expect(result.current.data?.list).toMatchObject([4, 5, 6, 1, 2, 3]);
+
+ // not work when no more
+ expect(result.current.noMore).toBe(true);
+ act(() => {
+ events['scroll']();
+ });
+ expect(result.current.loadingMore).toBe(false);
+
+ mockAddEventListener.mockRestore();
+ scrollHeightSpy.mockRestore();
+ clientHeightSpy.mockRestore();
+ });
+
+ test('reload should be work', async () => {
+ const fn = vi.fn(() => Promise.resolve({ list: [] }));
+ const { result } = setup(fn);
+ const { reload } = result.current;
+ expect(fn).toBeCalledTimes(1);
+ act(() => reload());
+ expect(fn).toBeCalledTimes(2);
+ await act(async () => {
+ Promise.resolve();
+ });
+ });
+
+ test('reload should be triggered when reloadDeps change', async () => {
+ const fn = vi.fn(() => Promise.resolve({ list: [] }));
+ const { result } = renderHook(() => {
+ const [value, setValue] = useState('');
+ const res = useInfiniteScroll(fn, {
+ reloadDeps: [value],
+ });
+ return {
+ ...res,
+ setValue,
+ };
+ });
+ expect(fn).toBeCalledTimes(1);
+ act(() => {
+ result.current.setValue('ahooks');
+ });
+ expect(fn).toBeCalledTimes(2);
+ await act(async () => {
+ Promise.resolve();
+ });
+ });
+
+ test('reload data should be latest', async () => {
+ let listCount = 5;
+ const mockRequestFn = async () => {
+ await sleep(1000);
+ return {
+ list: Array.from({
+ length: listCount,
+ }).map((_, index) => index + 1),
+ nextId: listCount,
+ hasMore: listCount > 2,
+ };
+ };
+
+ const { result } = setup(mockRequestFn);
+
+ await act(async () => {
+ vi.advanceTimersByTime(1000);
+ });
+ expect(result.current.data).toMatchObject({ list: [1, 2, 3, 4, 5], nextId: 5 });
+
+ listCount = 3;
+ await act(async () => {
+ result.current.reload();
+ vi.advanceTimersByTime(1000);
+ });
+
+ expect(result.current.data).toMatchObject({ list: [1, 2, 3], nextId: 3 });
+ });
+
+ test('mutate should be work', async () => {
+ const { result } = setup(mockRequest);
+ const { mutate } = result.current;
+ await act(async () => {
+ vi.advanceTimersByTime(1000);
+ });
+ expect(result.current.data).toMatchObject({ list: [1, 2, 3], nextId: 1 });
+ const newData = {
+ list: [1, 2],
+ nextId: 1,
+ };
+ act(() => mutate(newData));
+ expect(result.current.data).toMatchObject(newData);
+ });
+
+ test('cancel should be work', () => {
+ const onSuccess = vi.fn();
+ const { result } = setup(mockRequest, {
+ onSuccess,
+ });
+ const { cancel } = result.current;
+ expect(result.current.loading).toBe(true);
+ act(() => cancel());
+ expect(result.current.loading).toBe(false);
+ expect(onSuccess).not.toBeCalled();
+ });
+
+ test('onBefore/onSuccess/onFinally should be called', async () => {
+ const onBefore = vi.fn();
+ const onSuccess = vi.fn();
+ const onFinally = vi.fn();
+ setup(mockRequest, {
+ onBefore,
+ onSuccess,
+ onFinally,
+ });
+ await act(async () => {
+ vi.advanceTimersByTime(1000);
+ });
+ expect(onBefore).toBeCalled();
+ expect(onSuccess).toBeCalled();
+ expect(onFinally).toBeCalled();
+ });
+
+ test('onError should be called when throw error', async () => {
+ const onError = vi.fn();
+ const mockRequestError = () => {
+ return Promise.reject('error');
+ };
+ setup(mockRequestError, {
+ onError,
+ });
+ await act(async () => {
+ Promise.resolve();
+ });
+ expect(onError).toBeCalled();
+ });
+
+ test('loadMoreAsync should be work', async () => {
+ const { result } = setup(mockRequest, {
+ manual: true,
+ });
+ const { loadMoreAsync } = result.current;
+ act(() => {
+ loadMoreAsync().then((res) => {
+ expect(res).toMatchObject({ list: [1, 2, 3], nextId: 1 });
+ expect(result.current.loading).toBe(false);
+ });
+ });
+ await act(async () => {
+ vi.advanceTimersByTime(1000);
+ });
+ });
+
+ test('reloadAsync should be work', async () => {
+ const fn = vi.fn(() => Promise.resolve({ list: [] }));
+ const { result } = setup(fn);
+ const { reloadAsync } = result.current;
+ expect(fn).toBeCalledTimes(1);
+
+ act(() => {
+ reloadAsync().then(() => {
+ expect(fn).toBeCalledTimes(2);
+ });
+ });
+ await act(async () => {
+ Promise.resolve();
+ });
+ });
+
+ test('loading should be true when reload after loadMore', async () => {
+ const { result } = setup(mockRequest);
+ expect(result.current.loading).toBeTruthy();
+ const { reload, loadMore } = result.current;
+ await act(async () => {
+ vi.advanceTimersByTime(1000);
+ });
+
+ expect(result.current.loading).toBeFalsy();
+
+ act(() => {
+ loadMore();
+ reload();
+ });
+ expect(result.current.loading).toBeTruthy();
+
+ await act(async () => {
+ vi.advanceTimersByTime(1000);
+ });
+
+ expect(result.current.loading).toBeFalsy();
+ });
+
+ test('loading should be true when reloadAsync after loadMore', async () => {
+ const { result } = setup(mockRequest);
+ expect(result.current.loading).toBeTruthy();
+ const { reloadAsync, loadMore } = result.current;
+ await act(async () => {
+ vi.advanceTimersByTime(1000);
+ });
+
+ expect(result.current.loading).toBeFalsy();
+
+ act(() => {
+ loadMore();
+ reloadAsync();
+ });
+ expect(result.current.loading).toBeTruthy();
+
+ await act(async () => {
+ vi.advanceTimersByTime(1000);
+ });
+
+ expect(result.current.loading).toBeFalsy();
+ });
+
+ test('list can be null or undefined', async () => {
+ // @ts-ignore
+ const { result } = setup(async () => {
+ await sleep(1000);
+ count++;
+ return {
+ list: Math.random() < 0.5 ? null : undefined,
+ nextId: count,
+ };
+ });
+
+ expect(result.current.loading).toBeTruthy();
+
+ const { loadMore } = result.current;
+ await act(async () => {
+ vi.advanceTimersByTime(1000);
+ });
+
+ expect(result.current.loading).toBeFalsy();
+
+ act(() => {
+ loadMore();
+ });
+ });
+
+ test('error result', async () => {
+ const { result } = setup(async () => {
+ throw new Error('error message');
+ });
+ await act(async () => {
+ vi.advanceTimersByTime(1000);
+ });
+
+ expect(result.current.error?.message).toBe('error message');
+ });
+
+ test('reloadAsync should reset data and restart from page=1', async () => {
+ const PAGE_SIZE = 2;
+
+ // Vitest 的 mock
+ const getLoadMoreListMock = vi.fn((page: number, pageSize: number) => {
+ const start = (page - 1) * pageSize + 1;
+ const list = Array.from({ length: pageSize }, (_, i) => start + i);
+ return Promise.resolve({ list });
+ });
+
+ const { result } = renderHook(() =>
+ useInfiniteScroll((d) => {
+ const page = d ? Math.ceil(d.list.length / PAGE_SIZE) + 1 : 1;
+ return getLoadMoreListMock(page, PAGE_SIZE);
+ }),
+ );
+
+ await act(async () => {
+ await result.current.loadMoreAsync();
+ });
+ expect(getLoadMoreListMock).toHaveBeenLastCalledWith(1, PAGE_SIZE);
+ expect(result.current.data?.list.length).toBe(2);
+
+ await act(async () => {
+ await result.current.loadMoreAsync();
+ });
+ expect(getLoadMoreListMock).toHaveBeenLastCalledWith(2, PAGE_SIZE);
+ expect(result.current.data?.list.length).toBe(4);
+
+ await act(async () => {
+ await result.current.reloadAsync();
+ });
+ expect(getLoadMoreListMock).toHaveBeenLastCalledWith(1, PAGE_SIZE);
+ expect(result.current.data?.list.length).toBe(2);
+ expect(result.current.data?.list).toEqual([1, 2]);
+ });
+
+ test('service should be called only once when scrolling to bottom multiple times quickly', async () => {
+ const mockService = vi.fn(async () => {
+ await sleep(1000);
+ return { list: [1, 2, 3], nextId: 1 };
+ });
+
+ const events: Record = {};
+ const mockAddEventListener = vi
+ .spyOn(targetEl, 'addEventListener')
+ .mockImplementation((eventName: string, callback: any) => {
+ events[eventName] = callback;
+ });
+
+ const scrollHeightSpy = vi.spyOn(targetEl, 'scrollHeight', 'get').mockImplementation(() => 150);
+ const clientHeightSpy = vi.spyOn(targetEl, 'clientHeight', 'get').mockImplementation(() => 100);
+
+ const { result } = setup(mockService, {
+ target: targetEl,
+ isNoMore: (d) => d?.nextId === undefined,
+ });
+
+ // Wait for initial load to complete
+ await act(async () => {
+ vi.advanceTimersByTime(1000);
+ });
+ expect(result.current.loading).toBe(false);
+ expect(mockService).toHaveBeenCalledTimes(1);
+
+ // Set scroll position to bottom (scrollHeight - scrollTop <= clientHeight + threshold)
+ // 150 - 50 = 100 <= 100 + 100 = 200, so it should trigger loadMore
+ setTargetInfo('scrollTop', 50);
+
+ // Trigger scroll event multiple times quickly (before first request completes)
+ act(() => {
+ events['scroll']();
+ });
+
+ // Service should be called once more (total 2 times: initial + loadMore)
+ expect(mockService).toHaveBeenCalledTimes(2);
+
+ // Trigger more scroll events while loading
+ act(() => {
+ events['scroll']();
+ });
+ act(() => {
+ events['scroll']();
+ });
+ act(() => {
+ events['scroll']();
+ });
+
+ // Service should still only be called twice (no additional calls during loading)
+ expect(mockService).toHaveBeenCalledTimes(2);
+
+ mockAddEventListener.mockRestore();
+ scrollHeightSpy.mockRestore();
+ clientHeightSpy.mockRestore();
+ });
+});
diff --git a/packages/hooks/src/useInfiniteScroll/__tests__/index.test.ts b/packages/hooks/src/useInfiniteScroll/__tests__/index.test.ts
deleted file mode 100644
index 5eabca5264..0000000000
--- a/packages/hooks/src/useInfiniteScroll/__tests__/index.test.ts
+++ /dev/null
@@ -1,251 +0,0 @@
-import { useState } from 'react';
-import { renderHook, act } from '@testing-library/react-hooks';
-import useInfiniteScroll from '..';
-import type { Data, Service, InfiniteScrollOptions } from '../types';
-import { sleep } from '../../utils/testingHelpers';
-
-let count = 0;
-export async function mockRequest() {
- await sleep(1000);
- if (count >= 1) {
- return { list: [] };
- }
- count++;
- return {
- list: [1, 2, 3],
- nextId: count,
- };
-}
-
-const targetEl = document.createElement('div');
-
-const setup = (service: Service, options?: InfiniteScrollOptions) =>
- renderHook(() => useInfiniteScroll(service, options));
-
-describe('useInfiniteScroll', () => {
- beforeEach(() => {
- count = 0;
- });
-
- beforeAll(() => {
- jest.useFakeTimers();
- });
-
- afterAll(() => {
- jest.useRealTimers();
- });
-
- it('should auto load', async () => {
- const { result } = setup(mockRequest);
- expect(result.current.loading).toBeTruthy();
- await act(async () => {
- jest.advanceTimersByTime(1000);
- });
- expect(result.current.loading).toBeFalsy();
- });
-
- it('loadMore should be work', async () => {
- const { result } = setup(mockRequest, { manual: true });
- const { loadMore, loading } = result.current;
- expect(loading).toBeFalsy();
- act(() => {
- loadMore();
- });
- expect(result.current.loadingMore).toBeTruthy();
- await act(async () => {
- jest.advanceTimersByTime(1000);
- });
- expect(result.current.loadingMore).toBeFalsy();
- });
-
- it('noMore should be true when isNoMore is true', async () => {
- const { result } = setup(mockRequest, {
- isNoMore: (d) => d?.nextId === undefined,
- });
- const { loadMore } = result.current;
- await act(async () => {
- jest.advanceTimersByTime(1000);
- });
-
- expect(result.current.noMore).toBeFalsy();
- act(() => loadMore());
- await act(async () => {
- jest.advanceTimersByTime(1000);
- });
- expect(result.current.noMore).toBeTruthy();
- });
-
- it('should auto load when scroll to bottom', async () => {
- const events = {};
- const mockAddEventListener = jest
- .spyOn(targetEl, 'addEventListener')
- .mockImplementation((eventName, callback) => {
- events[eventName] = callback;
- });
- const { result } = setup(mockRequest, {
- target: targetEl,
- isNoMore: (d) => d?.nextId === undefined,
- });
- // not work when loading
- expect(result.current.loading).toBeTruthy();
- events['scroll']();
- await act(async () => {
- jest.advanceTimersByTime(1000);
- });
- expect(result.current.loading).toBeFalsy();
-
- // mock scroll
- Object.defineProperties(targetEl, {
- clientHeight: {
- value: 150,
- },
- scrollHeight: {
- value: 300,
- },
- scrollTop: {
- value: 100,
- },
- });
- act(() => {
- events['scroll']();
- });
- expect(result.current.loadingMore).toBeTruthy();
- await act(async () => {
- jest.advanceTimersByTime(1000);
- });
- expect(result.current.loadingMore).toBeFalsy();
-
- // not work when no more
- expect(result.current.noMore).toBeTruthy();
- act(() => {
- events['scroll']();
- });
- expect(result.current.loadingMore).toBeFalsy();
-
- mockAddEventListener.mockRestore();
- });
-
- it('reload should be work', async () => {
- const fn = jest.fn(() => Promise.resolve({ list: [] }));
- const { result } = setup(fn);
- const { reload } = result.current;
- expect(fn).toBeCalledTimes(1);
- act(() => reload());
- expect(fn).toBeCalledTimes(2);
- await act(async () => {
- Promise.resolve();
- });
- });
-
- it('reload should be triggered when reloadDeps change', async () => {
- const fn = jest.fn(() => Promise.resolve({ list: [] }));
- const { result } = renderHook(() => {
- const [value, setValue] = useState('');
- const res = useInfiniteScroll(fn, {
- reloadDeps: [value],
- });
- return {
- ...res,
- setValue,
- };
- });
- expect(fn).toBeCalledTimes(1);
- act(() => {
- result.current.setValue('ahooks');
- });
- expect(fn).toBeCalledTimes(2);
- await act(async () => {
- Promise.resolve();
- });
- });
-
- it('mutate should be work', async () => {
- const { result } = setup(mockRequest);
- const { mutate } = result.current;
- await act(async () => {
- jest.advanceTimersByTime(1000);
- });
- expect(result.current.data).toMatchObject({ list: [1, 2, 3], nextId: 1 });
- const newData = {
- list: [1, 2],
- nextId: 1,
- };
- act(() => mutate(newData));
- expect(result.current.data).toMatchObject(newData);
- });
-
- it('cancel should be work', () => {
- const onSuccess = jest.fn();
- const { result } = setup(mockRequest, {
- onSuccess,
- });
- const { cancel } = result.current;
- expect(result.current.loading).toBeTruthy();
- act(() => cancel());
- expect(result.current.loading).toBeFalsy();
- expect(onSuccess).not.toBeCalled();
- });
-
- it('onBefore/onSuccess/onFinally should be called', async () => {
- const onBefore = jest.fn();
- const onSuccess = jest.fn();
- const onFinally = jest.fn();
- const { result } = setup(mockRequest, {
- onBefore,
- onSuccess,
- onFinally,
- });
- await act(async () => {
- jest.advanceTimersByTime(1000);
- });
- expect(onBefore).toBeCalled();
- expect(onSuccess).toBeCalled();
- expect(onFinally).toBeCalled();
- });
-
- it('onError should be called when throw error', async () => {
- const onError = jest.fn();
- const mockRequestError = () => {
- return Promise.reject('error');
- };
- setup(mockRequestError, {
- onError,
- });
- await act(async () => {
- Promise.resolve();
- });
- expect(onError).toBeCalled();
- });
-
- it('loadMoreAsync should be work', async () => {
- const { result } = setup(mockRequest, {
- manual: true,
- });
- const { loadMoreAsync } = result.current;
- act(() => {
- loadMoreAsync().then((res) => {
- expect(res).toMatchObject({ list: [1, 2, 3], nextId: 1 });
- expect(result.current.loading).toBeFalsy();
- });
- });
- await act(async () => {
- jest.advanceTimersByTime(1000);
- });
- });
-
- it('reloadAsync should be work', async () => {
- const fn = jest.fn(() => Promise.resolve({ list: [] }));
- const { result } = setup(fn);
- const { reloadAsync } = result.current;
- expect(fn).toBeCalledTimes(1);
-
- act(() => {
- reloadAsync().then(() => {
- expect(fn).toBeCalledTimes(2);
- });
- });
- await act(async () => {
- Promise.resolve();
- });
- });
-});
diff --git a/packages/hooks/src/useInfiniteScroll/demo/default.tsx b/packages/hooks/src/useInfiniteScroll/demo/default.tsx
index e25257d5a3..424555d457 100644
--- a/packages/hooks/src/useInfiniteScroll/demo/default.tsx
+++ b/packages/hooks/src/useInfiniteScroll/demo/default.tsx
@@ -1,4 +1,3 @@
-import React from 'react';
import { useInfiniteScroll } from 'ahooks';
interface Result {
diff --git a/packages/hooks/src/useInfiniteScroll/demo/mutate.tsx b/packages/hooks/src/useInfiniteScroll/demo/mutate.tsx
index 73c6c97d70..476923d2c6 100644
--- a/packages/hooks/src/useInfiniteScroll/demo/mutate.tsx
+++ b/packages/hooks/src/useInfiniteScroll/demo/mutate.tsx
@@ -1,4 +1,3 @@
-import React from 'react';
import { useInfiniteScroll, useRequest } from 'ahooks';
interface Result {
diff --git a/packages/hooks/src/useInfiniteScroll/demo/pagination.tsx b/packages/hooks/src/useInfiniteScroll/demo/pagination.tsx
index 747a2f8c6a..d7f583f786 100644
--- a/packages/hooks/src/useInfiniteScroll/demo/pagination.tsx
+++ b/packages/hooks/src/useInfiniteScroll/demo/pagination.tsx
@@ -1,4 +1,3 @@
-import React from 'react';
import { useInfiniteScroll } from 'ahooks';
interface Result {
diff --git a/packages/hooks/src/useInfiniteScroll/demo/reload.tsx b/packages/hooks/src/useInfiniteScroll/demo/reload.tsx
index d78c3de18e..d850dfd666 100644
--- a/packages/hooks/src/useInfiniteScroll/demo/reload.tsx
+++ b/packages/hooks/src/useInfiniteScroll/demo/reload.tsx
@@ -1,4 +1,4 @@
-import React, { useState } from 'react';
+import { useState } from 'react';
import { useInfiniteScroll } from 'ahooks';
interface Result {
diff --git a/packages/hooks/src/useInfiniteScroll/demo/scroll.tsx b/packages/hooks/src/useInfiniteScroll/demo/scroll.tsx
index d0a04643e8..aa1681eddb 100644
--- a/packages/hooks/src/useInfiniteScroll/demo/scroll.tsx
+++ b/packages/hooks/src/useInfiniteScroll/demo/scroll.tsx
@@ -1,4 +1,4 @@
-import React, { useRef } from 'react';
+import { useRef } from 'react';
import { useInfiniteScroll } from 'ahooks';
interface Result {
diff --git a/packages/hooks/src/useInfiniteScroll/demo/scrollTop.tsx b/packages/hooks/src/useInfiniteScroll/demo/scrollTop.tsx
new file mode 100644
index 0000000000..000e85ebc8
--- /dev/null
+++ b/packages/hooks/src/useInfiniteScroll/demo/scrollTop.tsx
@@ -0,0 +1,95 @@
+import { useRef } from 'react';
+import { useInfiniteScroll } from 'ahooks';
+
+interface Result {
+ list: string[];
+ nextId: string | undefined;
+}
+
+const resultData = [
+ '15',
+ '14',
+ '13',
+ '12',
+ '11',
+ '10',
+ '9',
+ '8',
+ '7',
+ '6',
+ '5',
+ '4',
+ '3',
+ '2',
+ '1',
+ '0',
+];
+
+function getLoadMoreList(nextId: string | undefined, limit: number): Promise {
+ let start = 0;
+ if (nextId) {
+ start = resultData.findIndex((i) => i === nextId);
+ }
+ const end = start + limit;
+ const list = resultData.slice(start, end).reverse();
+ const nId = resultData.length >= end ? resultData[end] : undefined;
+ return new Promise((resolve) => {
+ setTimeout(() => {
+ resolve({
+ list,
+ nextId: nId,
+ });
+ }, 1000);
+ });
+}
+
+export default () => {
+ const ref = useRef(null);
+ const isFirstIn = useRef(true);
+
+ const { data, loading, loadMore, loadingMore, noMore } = useInfiniteScroll(
+ (d) => getLoadMoreList(d?.nextId, 5),
+ {
+ target: ref,
+ direction: 'top',
+ threshold: 0,
+ isNoMore: (d) => d?.nextId === undefined,
+ onSuccess() {
+ if (isFirstIn.current) {
+ isFirstIn.current = false;
+ setTimeout(() => {
+ const el = ref.current;
+ if (el) {
+ el.scrollTo(0, 999999);
+ }
+ });
+ }
+ },
+ },
+ );
+
+ return (
+
+ {loading ? (
+
loading
+ ) : (
+
+
+ {!noMore && (
+
+ {loadingMore ? 'Loading more...' : 'Click to load more'}
+
+ )}
+
+ {noMore && No more data }
+
+ {data?.list?.map((item) => (
+
+ item-{item}
+
+ ))}
+
+ )}
+
+ );
+};
diff --git a/packages/hooks/src/useInfiniteScroll/index.en-US.md b/packages/hooks/src/useInfiniteScroll/index.en-US.md
index f5bbdd1a99..9d3a58fd33 100644
--- a/packages/hooks/src/useInfiniteScroll/index.en-US.md
+++ b/packages/hooks/src/useInfiniteScroll/index.en-US.md
@@ -36,9 +36,14 @@ In the infinite scrolling scenario, the most common case is to automatically loa
- `options.target` specifies the parent element, The parent element needs to set a fixed height and support internal scrolling
- `options.isNoMore` determines if there is no more data
+- `options.direction` determines the direction of scrolling, the default is `bottom`
+the scroll to bottom demo
+the scroll to top demo
+
+
## Data reset
The data can be reset by `reload`. The following example shows that after the `filter` changes, the data is reset to the first page.
@@ -69,6 +74,7 @@ const {
data: TData;
loading: boolean;
loadingMore: boolean;
+ error?: Error;
noMore: boolean;
loadMore: () => void;
loadMoreAsync: () => Promise;
@@ -100,6 +106,7 @@ const {
| loading | Is the first request in progress | `boolean` |
| loadingMore | Is more data request in progress | `boolean` |
| noMore | Whether there is no more data, it will take effect after configuring `options.isNoMore` | `boolean` |
+| error | Request error message | `Error` |
| loadMore | Load more data, it will automatically catch the exception, and handle it through `options.onError` | `() => void` |
| loadMoreAsync | Load more data, which is consistent with the behavior of `loadMore`, but returns Promise, so you need to handle the exception yourself | `() => Promise` |
| reload | Load the first page of data, it will automatically catch the exception, and handle it through `options.onError` | `() => void` |
@@ -109,14 +116,15 @@ const {
### Options
-| Property | Description | Type | Default |
-| ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------- | ------- |
-| target | specifies the parent element. If it exists, it will trigger the `loadMore` when scrolling to the bottom. Needs to work with `isNoMore` to know when there is no more data to load | `() => Element` \| `Element` \| `MutableRefObject` | - |
-| isNoMore | determines if there is no more data, the input parameter is the latest merged `data` | `(data?: TData) => boolean` | - |
-| threshold | The pixel threshold to the bottom for the scrolling to load | `number` | `100` |
-| reloadDeps | When the content of the array changes, `reload` will be triggered | `any[]` | - |
-| manual | The default is `false`. That is, the service is automatically executed during initialization. If set to `true`, you need to manually call `run` or `runAsync` to trigger execution | `boolean` | `false` |
-| onBefore | Triggered before service execution | `() => void` | - |
-| onSuccess | Triggered when service resolve | `(data: TData) => void` | - |
-| onError | Triggered when service reject | `(e: Error) => void` | - |
-| onFinally | Triggered when service execution is complete | `(data?: TData, e?: Error) => void` | - |
+| Property | Description | Type | Default |
+| ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------- | -------- |
+| target | specifies the parent element. If it exists, it will trigger the `loadMore` when scrolling to the bottom. Needs to work with `isNoMore` to know when there is no more data to load. **when target is document, it is defined as the entire viewport** | `() => Element` \| `Element` \| `MutableRefObject` | - |
+| isNoMore | determines if there is no more data, the input parameter is the latest merged `data` | `(data?: TData) => boolean` | - |
+| threshold | The pixel threshold to the bottom for the scrolling to load | `number` | `100` |
+| direction | The direction of the scrolling | `bottom` \|`top` | `bottom` |
+| reloadDeps | When the content of the array changes, `reload` will be triggered | `any[]` | - |
+| manual | The default is `false`. That is, the service is automatically executed during initialization. If set to `true`, you need to manually call `run` or `runAsync` to trigger execution | `boolean` | `false` |
+| onBefore | Triggered before service execution | `() => void` | - |
+| onSuccess | Triggered when service resolve | `(data: TData) => void` | - |
+| onError | Triggered when service reject | `(e: Error) => void` | - |
+| onFinally | Triggered when service execution is complete | `(data?: TData, e?: Error) => void` | - |
diff --git a/packages/hooks/src/useInfiniteScroll/index.tsx b/packages/hooks/src/useInfiniteScroll/index.tsx
index abc0735dd5..ba74035b7e 100644
--- a/packages/hooks/src/useInfiniteScroll/index.tsx
+++ b/packages/hooks/src/useInfiniteScroll/index.tsx
@@ -1,4 +1,4 @@
-import { useMemo, useState } from 'react';
+import { useMemo, useRef, useState } from 'react';
import useEventListener from '../useEventListener';
import useMemoizedFn from '../useMemoizedFn';
import useRequest from '../useRequest';
@@ -15,6 +15,7 @@ const useInfiniteScroll = (
target,
isNoMore,
threshold = 100,
+ direction = 'bottom',
reloadDeps = [],
manual,
onBefore,
@@ -25,58 +26,99 @@ const useInfiniteScroll = (
const [finalData, setFinalData] = useState();
const [loadingMore, setLoadingMore] = useState(false);
+ const isScrollToTop = direction === 'top';
+ // lastScrollTop is used to determine whether the scroll direction is up or down
+ const lastScrollTop = useRef(undefined);
+ // scrollBottom is used to record the distance from the bottom of the scroll bar
+ const scrollBottom = useRef(0);
const noMore = useMemo(() => {
- if (!isNoMore) return false;
+ if (!isNoMore) {
+ return false;
+ }
return isNoMore(finalData);
}, [finalData]);
- const { loading, run, runAsync, cancel } = useRequest(
+ const { loading, error, run, runAsync, cancel } = useRequest(
async (lastData?: TData) => {
const currentData = await service(lastData);
- if (!lastData) {
- setFinalData(currentData);
- } else {
- setFinalData({
- ...currentData,
- // @ts-ignore
- list: [...lastData.list, ...currentData.list],
- });
- }
- return currentData;
+ return { currentData, lastData };
},
{
manual,
onFinally: (_, d, e) => {
setLoadingMore(false);
- onFinally?.(d, e);
+ onFinally?.(d?.currentData, e);
},
onBefore: () => onBefore?.(),
onSuccess: (d) => {
+ if (!d.lastData) {
+ setFinalData({
+ ...d.currentData,
+ list: [...(d.currentData.list ?? [])],
+ });
+ } else {
+ setFinalData({
+ ...d.currentData,
+ list: isScrollToTop
+ ? [...d.currentData.list, ...(d.lastData.list ?? [])]
+ : [...(d.lastData.list ?? []), ...d.currentData.list],
+ });
+ }
+
setTimeout(() => {
- // eslint-disable-next-line @typescript-eslint/no-use-before-define
- scrollMethod();
+ // use requestAnimationFrame to ensure the scroll position is updated (To ensure compatibility react 19)
+ requestAnimationFrame(() => {
+ if (isScrollToTop) {
+ let el = getTargetElement(target);
+ el = el === document ? document.documentElement : el;
+ if (el) {
+ const scrollHeight = getScrollHeight(el);
+ (el as Element).scrollTo(0, scrollHeight - scrollBottom.current);
+ }
+ } else {
+ // eslint-disable-next-line @typescript-eslint/no-use-before-define
+ scrollMethod();
+ }
+ });
});
- onSuccess?.(d);
+
+ onSuccess?.(d.currentData);
},
onError: (e) => onError?.(e),
},
);
- const loadMore = () => {
- if (noMore) return;
+ const loadMore = useMemoizedFn(() => {
+ if (noMore) {
+ return;
+ }
setLoadingMore(true);
run(finalData);
+ });
+
+ const runAsyncForCurrent = async (data?: TData) => {
+ const res = await runAsync(data);
+ return res.currentData;
};
- const loadMoreAsync = () => {
- if (noMore) return Promise.reject();
+ const loadMoreAsync = useMemoizedFn(() => {
+ if (noMore) {
+ return Promise.reject();
+ }
setLoadingMore(true);
- return runAsync(finalData);
+ return runAsyncForCurrent(finalData);
+ });
+
+ const reload = () => {
+ setLoadingMore(false);
+ return run();
};
- const reload = () => run();
- const reloadAsync = () => runAsync();
+ const reloadAsync = () => {
+ setLoadingMore(false);
+ return runAsyncForCurrent();
+ };
const scrollMethod = () => {
const el = getTargetElement(target);
@@ -84,11 +126,22 @@ const useInfiniteScroll = (
return;
}
- const scrollTop = getScrollTop(el);
- const scrollHeight = getScrollHeight(el);
- const clientHeight = getClientHeight(el);
+ const targetEl = el === document ? document.documentElement : el;
+ const scrollTop = getScrollTop(targetEl);
+ const scrollHeight = getScrollHeight(targetEl);
+ const clientHeight = getClientHeight(targetEl);
- if (scrollHeight - scrollTop <= clientHeight + threshold) {
+ if (isScrollToTop) {
+ if (
+ lastScrollTop.current !== undefined &&
+ lastScrollTop.current > scrollTop &&
+ scrollTop <= threshold
+ ) {
+ loadMore();
+ }
+ lastScrollTop.current = scrollTop;
+ scrollBottom.current = scrollHeight - scrollTop;
+ } else if (scrollHeight - scrollTop <= clientHeight + threshold) {
loadMore();
}
};
@@ -111,11 +164,12 @@ const useInfiniteScroll = (
return {
data: finalData,
loading: !loadingMore && loading,
+ error,
loadingMore,
noMore,
- loadMore: useMemoizedFn(loadMore),
- loadMoreAsync: useMemoizedFn(loadMoreAsync),
+ loadMore,
+ loadMoreAsync,
reload: useMemoizedFn(reload),
reloadAsync: useMemoizedFn(reloadAsync),
mutate: setFinalData,
diff --git a/packages/hooks/src/useInfiniteScroll/index.zh-CN.md b/packages/hooks/src/useInfiniteScroll/index.zh-CN.md
index b84350e409..e60dcc2a83 100644
--- a/packages/hooks/src/useInfiniteScroll/index.zh-CN.md
+++ b/packages/hooks/src/useInfiniteScroll/index.zh-CN.md
@@ -16,7 +16,7 @@ useInfiniteScroll 的第一个参数 `service` 是一个异步函数,对这个
1. `service` 返回的数据必须包含 `list` 数组,类型为 `{ list: any[], ...rest }`
2. `service` 的入参为整合后的最新 `data`
-假如第一次请求返回数据为 `{ list: [1, 2, 3], nextId: 4 }`, 第二次返回的数据为 `{ list: [4, 5, 6], nextId: 7 }`, 则我们会自动合并 `list`,整合后的的 `data` 为 `{ list: [1, 2, 3, 4, 5, 6], nextId: 7 }`。
+假如第一次请求返回数据为 `{ list: [1, 2, 3], nextId: 4 }`, 第二次返回的数据为 `{ list: [4, 5, 6], nextId: 7 }`, 则我们会自动合并 `list`,整合后的 `data` 为 `{ list: [1, 2, 3, 4, 5, 6], nextId: 7 }`。
## 基础用法
@@ -36,9 +36,14 @@ useInfiniteScroll 的第一个参数 `service` 是一个异步函数,对这个
- `options.target` 指定父级元素(父级元素需设置固定高度,且支持内部滚动)
- `options.isNoMore` 判断是不是没有更多数据了
+- `options.direction` 滚动的方向,默认为向下滚动
+向下滚动示例
+向上滚动示例
+
+
## 数据重置
通过 `reload` 即可实现数据重置,下面示例我们演示在 `filter` 变化后,重置数据到第一页。
@@ -69,6 +74,7 @@ const {
data: TData;
loading: boolean;
loadingMore: boolean;
+ error?: Error;
noMore: boolean;
loadMore: () => void;
loadMoreAsync: () => Promise;
@@ -100,6 +106,7 @@ const {
| loading | 是否正在进行首次请求 | `boolean` |
| loadingMore | 是否正在进行更多数据请求 | `boolean` |
| noMore | 是否没有更多数据了,配置 `options.isNoMore` 后生效 | `boolean` |
+| error | 请求错误消息 | `Error` |
| loadMore | 加载更多数据,会自动捕获异常,通过 `options.onError` 处理 | `() => void` |
| loadMoreAsync | 加载更多数据,与 `loadMore` 行为一致,但返回的是 Promise,需要自行处理异常 | `() => Promise` |
| reload | 加载第一页数据,会自动捕获异常,通过 `options.onError` 处理 | `() => void` |
@@ -109,14 +116,15 @@ const {
### Options
-| 参数 | 说明 | 类型 | 默认值 |
-| ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------- | ------- |
-| target | 父级容器,如果存在,则在滚动到底部时,自动触发 `loadMore`。需要配合 `isNoMore` 使用,以便知道什么时候到最后一页了。 | `() => Element` \| `Element` \| `MutableRefObject` | - |
-| isNoMore | 是否有最后一页的判断逻辑,入参为当前聚合后的 `data` | `(data?: TData) => boolean` | - |
-| threshold | 下拉自动加载,距离底部距离阈值 | `number` | `100` |
-| reloadDeps | 变化后,会自动触发 `reload` | `any[]` | - |
-| manual | 默认 `false`。 即在初始化时自动执行 service。 如果设置为 `true`,则需要手动调用 `reload` 或 `reloadAsync` 触发执行。 | `boolean` | `false` |
-| onBefore | service 执行前触发 | `() => void` | - |
-| onSuccess | service resolve 时触发 | `(data: TData) => void` | - |
-| onError | service reject 时触发 | `(e: Error) => void` | - |
-| onFinally | service 执行完成时触发 | `(data?: TData, e?: Error) => void` | - |
+| 参数 | 说明 | 类型 | 默认值 |
+| ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------- | -------- |
+| target | 父级容器,如果存在,则在滚动到底部时,自动触发 `loadMore`。需要配合 `isNoMore` 使用,以便知道什么时候到最后一页了。 **当 target 为 document 时,定义为整个视口** | `() => Element` \| `Element` \| `MutableRefObject` | - |
+| isNoMore | 是否有最后一页的判断逻辑,入参为当前聚合后的 `data` | `(data?: TData) => boolean` | - |
+| threshold | 下拉自动加载,距离底部距离阈值 | `number` | `100` |
+| direction | 滚动的方向 | `bottom` \| `top` | `bottom` |
+| reloadDeps | 变化后,会自动触发 `reload` | `any[]` | - |
+| manual | 默认 `false`。 即在初始化时自动执行 service。 如果设置为 `true`,则需要手动调用 `reload` 或 `reloadAsync` 触发执行。 | `boolean` | `false` |
+| onBefore | service 执行前触发 | `() => void` | - |
+| onSuccess | service resolve 时触发 | `(data: TData) => void` | - |
+| onError | service reject 时触发 | `(e: Error) => void` | - |
+| onFinally | service 执行完成时触发 | `(data?: TData, e?: Error) => void` | - |
diff --git a/packages/hooks/src/useInfiniteScroll/types.ts b/packages/hooks/src/useInfiniteScroll/types.ts
index 18e5b9da3a..251e81130c 100644
--- a/packages/hooks/src/useInfiniteScroll/types.ts
+++ b/packages/hooks/src/useInfiniteScroll/types.ts
@@ -3,12 +3,13 @@ import type { BasicTarget } from '../utils/domTarget';
export type Data = { list: any[]; [key: string]: any };
-export type Service = (currentData?: Data) => Promise;
+export type Service = (currentData?: TData) => Promise;
export interface InfiniteScrollResult {
data: TData;
loading: boolean;
loadingMore: boolean;
+ error?: Error;
noMore: boolean;
loadMore: () => void;
@@ -23,6 +24,7 @@ export interface InfiniteScrollOptions {
target?: BasicTarget;
isNoMore?: (data?: TData) => boolean;
threshold?: number;
+ direction?: 'bottom' | 'top';
manual?: boolean;
reloadDeps?: DependencyList;
diff --git a/packages/hooks/src/useInterval/__tests__/index.test.ts b/packages/hooks/src/useInterval/__tests__/index.spec.ts
similarity index 63%
rename from packages/hooks/src/useInterval/__tests__/index.test.ts
rename to packages/hooks/src/useInterval/__tests__/index.spec.ts
index 4797e1733d..64745c8437 100644
--- a/packages/hooks/src/useInterval/__tests__/index.test.ts
+++ b/packages/hooks/src/useInterval/__tests__/index.spec.ts
@@ -1,4 +1,5 @@
-import { renderHook } from '@testing-library/react-hooks';
+import { renderHook } from '@testing-library/react';
+import { describe, expect, test, vi } from 'vitest';
import useInterval from '../index';
interface ParamsObj {
@@ -11,46 +12,46 @@ const setUp = ({ fn, delay, options }: ParamsObj) =>
renderHook(() => useInterval(fn, delay, options));
describe('useInterval', () => {
- jest.useFakeTimers();
- jest.spyOn(global, 'clearInterval');
+ vi.useFakeTimers();
+ vi.spyOn(global, 'clearInterval');
- it('interval should work', () => {
- const callback = jest.fn();
+ test('interval should work', () => {
+ const callback = vi.fn();
setUp({ fn: callback, delay: 20 });
expect(callback).not.toBeCalled();
- jest.advanceTimersByTime(70);
+ vi.advanceTimersByTime(70);
expect(callback).toHaveBeenCalledTimes(3);
});
- it('interval should stop', () => {
- const callback = jest.fn();
+ test('interval should stop', () => {
+ const callback = vi.fn();
setUp({ fn: callback, delay: undefined });
- jest.advanceTimersByTime(50);
+ vi.advanceTimersByTime(50);
expect(callback).toHaveBeenCalledTimes(0);
setUp({ fn: callback, delay: -2 });
- jest.advanceTimersByTime(50);
+ vi.advanceTimersByTime(50);
expect(callback).toHaveBeenCalledTimes(0);
});
- it('immediate in options should work', () => {
- const callback = jest.fn();
+ test('immediate in options should work', () => {
+ const callback = vi.fn();
setUp({ fn: callback, delay: 20, options: { immediate: true } });
expect(callback).toBeCalled();
expect(callback).toHaveBeenCalledTimes(1);
- jest.advanceTimersByTime(50);
+ vi.advanceTimersByTime(50);
expect(callback).toHaveBeenCalledTimes(3);
});
- it('interval should be clear', () => {
- const callback = jest.fn();
+ test('interval should be clear', () => {
+ const callback = vi.fn();
const hook = setUp({ fn: callback, delay: 20 });
expect(callback).not.toBeCalled();
hook.result.current();
- jest.advanceTimersByTime(70);
+ vi.advanceTimersByTime(70);
// not to be called
expect(callback).toHaveBeenCalledTimes(0);
expect(clearInterval).toHaveBeenCalledTimes(1);
diff --git a/packages/hooks/src/useInterval/demo/demo1.tsx b/packages/hooks/src/useInterval/demo/demo1.tsx
index c680ba8524..d45a346772 100644
--- a/packages/hooks/src/useInterval/demo/demo1.tsx
+++ b/packages/hooks/src/useInterval/demo/demo1.tsx
@@ -6,7 +6,7 @@
* desc.zh-CN: 每1000ms,执行一次
*/
-import React, { useState } from 'react';
+import { useState } from 'react';
import { useInterval } from 'ahooks';
export default () => {
diff --git a/packages/hooks/src/useInterval/demo/demo2.tsx b/packages/hooks/src/useInterval/demo/demo2.tsx
index a311c1deb3..cb528341c9 100644
--- a/packages/hooks/src/useInterval/demo/demo2.tsx
+++ b/packages/hooks/src/useInterval/demo/demo2.tsx
@@ -6,7 +6,7 @@
* desc.zh-CN: 动态修改 delay 以实现定时器间隔变化与暂停。
*/
-import React, { useState } from 'react';
+import { useState } from 'react';
import { useInterval } from 'ahooks';
export default () => {
diff --git a/packages/hooks/src/useInterval/index.ts b/packages/hooks/src/useInterval/index.ts
index 3c96b9f183..2b3c0cc6f7 100644
--- a/packages/hooks/src/useInterval/index.ts
+++ b/packages/hooks/src/useInterval/index.ts
@@ -1,43 +1,29 @@
import { useCallback, useEffect, useRef } from 'react';
-import useLatest from '../useLatest';
+import useMemoizedFn from '../useMemoizedFn';
import { isNumber } from '../utils';
-function useInterval(
- fn: () => void,
- delay: number | undefined,
- options: {
- immediate?: boolean;
- } = {},
-) {
- const { immediate } = options;
+const useInterval = (fn: () => void, delay?: number, options: { immediate?: boolean } = {}) => {
+ const timerCallback = useMemoizedFn(fn);
+ const timerRef = useRef | null>(null);
- const fnRef = useLatest(fn);
- const timerRef = useRef(null);
+ const clear = useCallback(() => {
+ if (timerRef.current) {
+ clearInterval(timerRef.current);
+ }
+ }, []);
useEffect(() => {
if (!isNumber(delay) || delay < 0) {
return;
}
- if (immediate) {
- fnRef.current();
+ if (options.immediate) {
+ timerCallback();
}
- timerRef.current = setInterval(() => {
- fnRef.current();
- }, delay);
- return () => {
- if (timerRef.current) {
- clearInterval(timerRef.current);
- }
- };
- }, [delay]);
-
- const clear = useCallback(() => {
- if (timerRef.current) {
- clearInterval(timerRef.current);
- }
- }, []);
+ timerRef.current = setInterval(timerCallback, delay);
+ return clear;
+ }, [delay, options.immediate]);
return clear;
-}
+};
export default useInterval;
diff --git a/packages/hooks/src/useIsomorphicLayoutEffect/__tests__/index.spec.ts b/packages/hooks/src/useIsomorphicLayoutEffect/__tests__/index.spec.ts
new file mode 100644
index 0000000000..219e2e5408
--- /dev/null
+++ b/packages/hooks/src/useIsomorphicLayoutEffect/__tests__/index.spec.ts
@@ -0,0 +1,12 @@
+import { renderHook } from '@testing-library/react';
+import { describe, expect, test, vi } from 'vitest';
+import useIsomorphicLayoutEffect from '../index';
+
+describe('useIsomorphicLayoutEffect', () => {
+ const callback = vi.fn();
+ const { result } = renderHook(() => useIsomorphicLayoutEffect(callback));
+
+ test('cheak return value', () => {
+ expect(result.current).toBeUndefined();
+ });
+});
diff --git a/packages/hooks/src/useIsomorphicLayoutEffect/__tests__/index.test.ts b/packages/hooks/src/useIsomorphicLayoutEffect/__tests__/index.test.ts
deleted file mode 100644
index dc2ceffda5..0000000000
--- a/packages/hooks/src/useIsomorphicLayoutEffect/__tests__/index.test.ts
+++ /dev/null
@@ -1,11 +0,0 @@
-import { renderHook } from '@testing-library/react-hooks';
-import useIsomorphicLayoutEffect from '../index';
-
-describe('useIsomorphicLayoutEffect', () => {
- const callback = jest.fn();
- const { result } = renderHook(() => useIsomorphicLayoutEffect(callback));
-
- it('cheak return value', () => {
- expect(result.current).toBe(undefined);
- });
-});
diff --git a/packages/hooks/src/useIsomorphicLayoutEffect/index.en-US.md b/packages/hooks/src/useIsomorphicLayoutEffect/index.en-US.md
index caaafbc920..c3b0c83e1e 100644
--- a/packages/hooks/src/useIsomorphicLayoutEffect/index.en-US.md
+++ b/packages/hooks/src/useIsomorphicLayoutEffect/index.en-US.md
@@ -14,9 +14,9 @@ To avoid this warning, useIsomorphicLayoutEffect can be used instead of useLayou
The source code of useIsomorphicLayoutEffect is:
```javascript
-const useIsomorphicLayoutEffect = isBrowser ? useLayoutEffect : useEffect;
+const useIsomorphicLayoutEffect = isBrowser ? useLayoutEffect : noop;
```
-Return useLayoutEffect for browser environment and useEffect for other environments.
+Return useLayoutEffect for browser environment and a no-op in non-browser environments to avoid SSR warnings.
For more information, please refer to [useLayoutEffect and SSR](https://medium.com/@alexandereardon/uselayouteffect-and-ssr-192986cdcf7a)
diff --git a/packages/hooks/src/useIsomorphicLayoutEffect/index.ts b/packages/hooks/src/useIsomorphicLayoutEffect/index.ts
index ae83221c1f..183f9306f7 100644
--- a/packages/hooks/src/useIsomorphicLayoutEffect/index.ts
+++ b/packages/hooks/src/useIsomorphicLayoutEffect/index.ts
@@ -1,6 +1,7 @@
-import { useEffect, useLayoutEffect } from 'react';
+import { useLayoutEffect } from 'react';
import isBrowser from '../utils/isBrowser';
+import noop from '../utils/noop';
-const useIsomorphicLayoutEffect = isBrowser ? useLayoutEffect : useEffect;
+const useIsomorphicLayoutEffect = isBrowser ? useLayoutEffect : noop;
export default useIsomorphicLayoutEffect;
diff --git a/packages/hooks/src/useIsomorphicLayoutEffect/index.zh-CN.md b/packages/hooks/src/useIsomorphicLayoutEffect/index.zh-CN.md
index 784f597218..57a1eec536 100644
--- a/packages/hooks/src/useIsomorphicLayoutEffect/index.zh-CN.md
+++ b/packages/hooks/src/useIsomorphicLayoutEffect/index.zh-CN.md
@@ -14,9 +14,9 @@ nav:
useIsomorphicLayoutEffect 源码如下:
```js
-const useIsomorphicLayoutEffect = isBrowser ? useLayoutEffect : useEffect;
+const useIsomorphicLayoutEffect = isBrowser ? useLayoutEffect : noop;
```
-在非浏览器环境返回 useEffect,在浏览器环境返回 useLayoutEffect。
+在浏览器环境返回 useLayoutEffect,在非浏览器环境返回空函数以避免 SSR 警告。
更多信息可以参考 [useLayoutEffect and SSR](https://medium.com/@alexandereardon/uselayouteffect-and-ssr-192986cdcf7a)
diff --git a/packages/hooks/src/useKeyPress/__tests__/index.spec.tsx b/packages/hooks/src/useKeyPress/__tests__/index.spec.tsx
new file mode 100644
index 0000000000..4f2a300f20
--- /dev/null
+++ b/packages/hooks/src/useKeyPress/__tests__/index.spec.tsx
@@ -0,0 +1,179 @@
+import { fireEvent, renderHook } from '@testing-library/react';
+import { afterEach, describe, expect, test, vi } from 'vitest';
+import useKeyPress from '../index';
+
+const callback = vi.fn();
+
+afterEach(() => {
+ callback.mockClear();
+});
+
+describe('useKeyPress ', () => {
+ test('test single key', async () => {
+ const { unmount } = renderHook(() => useKeyPress(['c'], callback));
+ fireEvent.keyDown(document, { key: 'c', keyCode: 67 });
+ expect(callback.mock.calls.length).toBe(1);
+ unmount();
+ });
+
+ test('test standard key aliases', async () => {
+ const { unmount } = renderHook(() => useKeyPress(['arrowleft', 'escape'], callback));
+ fireEvent.keyDown(document, { key: 'ArrowLeft', keyCode: 37 });
+ fireEvent.keyDown(document, { key: 'Escape', keyCode: 27 });
+ expect(callback.mock.calls.length).toBe(2);
+ unmount();
+ });
+
+ test('test standard vs legacy key aliases', async () => {
+ const aliasCallback = vi.fn();
+ const { unmount } = renderHook(() =>
+ useKeyPress(
+ [
+ 'control',
+ 'ctrl',
+ 'escape',
+ 'esc',
+ 'arrowleft',
+ 'leftarrow',
+ 'spacebar',
+ 'space',
+ 'contextmenu',
+ 'selectkey',
+ 'pause',
+ 'pausebreak',
+ ],
+ aliasCallback,
+ ),
+ );
+
+ fireEvent.keyDown(document, { key: 'Control', keyCode: 17, ctrlKey: true });
+ fireEvent.keyDown(document, { key: 'Escape', keyCode: 27 });
+ fireEvent.keyDown(document, { key: 'ArrowLeft', keyCode: 37 });
+ fireEvent.keyDown(document, { key: ' ', keyCode: 32 });
+ fireEvent.keyDown(document, { key: 'ContextMenu', keyCode: 93 });
+ fireEvent.keyDown(document, { key: 'Pause', keyCode: 19 });
+
+ // each event should match once (first alias hit)
+ expect(aliasCallback.mock.calls.length).toBe(6);
+ unmount();
+ });
+
+ test('test modifier key', async () => {
+ const { unmount } = renderHook(() => useKeyPress(['ctrl'], callback));
+ fireEvent.keyDown(document, { key: 'ctrl', keyCode: 17, ctrlKey: true });
+ expect(callback.mock.calls.length).toBe(1);
+ unmount();
+ });
+
+ test('test combination keys', async () => {
+ const hook1 = renderHook(() => useKeyPress(['shift.c'], callback));
+ const hook2 = renderHook(() => useKeyPress(['shift'], callback));
+ const hook3 = renderHook(() => useKeyPress(['c'], callback));
+
+ fireEvent.keyDown(document, { key: 'c', shiftKey: true, keyCode: 67 });
+
+ expect(callback.mock.calls.length).toBe(3);
+ hook1.unmount();
+ hook2.unmount();
+ hook3.unmount();
+ });
+
+ test('test combination keys by exact match', async () => {
+ const callbackShift = vi.fn();
+ const callbackC = vi.fn();
+ const callbackMulti = vi.fn();
+ const hook1 = renderHook(() => useKeyPress(['shift.c'], callback, { exactMatch: true }));
+ const hook2 = renderHook(() => useKeyPress(['shift'], callbackShift, { exactMatch: true }));
+ const hook3 = renderHook(() => useKeyPress(['c'], callbackC, { exactMatch: true }));
+ const hook4 = renderHook(() => useKeyPress(['ctrl.shift.c'], callbackMulti));
+
+ fireEvent.keyDown(document, { key: 'c', shiftKey: true, keyCode: 67 });
+ /**
+ * 只有 shift.c 才会触发,shift 和 c 都不应该触发
+ */
+ expect(callback.mock.calls.length).toBe(1);
+ expect(callbackShift.mock.calls.length).toBe(0);
+ expect(callbackC.mock.calls.length).toBe(0);
+
+ callback.mockClear();
+ fireEvent.keyDown(document, { key: 'c', ctrlKey: true, shiftKey: true, keyCode: 67 });
+ expect(callbackMulti.mock.calls.length).toBe(1);
+ expect(callback.mock.calls.length).toBe(0);
+ expect(callbackC.mock.calls.length).toBe(0);
+
+ hook1.unmount();
+ hook2.unmount();
+ hook3.unmount();
+ hook4.unmount();
+ });
+
+ test('test multiple keys', async () => {
+ const { unmount } = renderHook(() => useKeyPress(['0', 65], callback));
+ fireEvent.keyDown(document, { key: '0', keyCode: 48 });
+ fireEvent.keyDown(document, { key: 'a', keyCode: 65 });
+ expect(callback.mock.calls.length).toBe(2);
+ unmount();
+ });
+
+ test('meta key should be work in keyup event', async () => {
+ renderHook(() =>
+ useKeyPress(['meta'], callback, {
+ events: ['keyup'],
+ }),
+ );
+
+ fireEvent.keyUp(document, { key: 'meta', keyCode: 91, metaKey: false });
+ expect(callback).toBeCalled();
+ });
+
+ test('test `keyFilter` function parameter', async () => {
+ const callback1 = vi.fn();
+ const callback2 = vi.fn();
+
+ // all keys can trigger callback
+ const hook1 = renderHook(() => useKeyPress(() => true, callback1));
+ fireEvent.keyDown(document, { key: '0', keyCode: 48 });
+ fireEvent.keyDown(document, { key: 'a', keyCode: 65 });
+ expect(callback1.mock.calls.length).toBe(2);
+
+ // only some keys can trigger callback
+ const hook2 = renderHook(() => useKeyPress((e) => ['0', 'meta'].includes(e.key), callback2));
+ fireEvent.keyDown(document, { key: '0', keyCode: 48 });
+ fireEvent.keyDown(document, { key: '1', keyCode: 49 });
+ fireEvent.keyDown(document, { key: 'ctrl', keyCode: 17, ctrlKey: true });
+ fireEvent.keyDown(document, { key: 'meta', keyCode: 91, metaKey: true });
+ expect(callback2.mock.calls.length).toBe(2);
+
+ hook1.unmount();
+ hook2.unmount();
+ });
+
+ test('test key in `eventHandler` parameter', async () => {
+ let pressedKey;
+ const KEYS = ['c', 'shift.c', 'shift.ctrl.c'];
+ const callbackKey = (e: any, key: any) => {
+ pressedKey = key;
+ };
+
+ // test `exactMatch: true` props
+ const hook1 = renderHook(() => useKeyPress(KEYS, callbackKey, { exactMatch: true }));
+ fireEvent.keyDown(document, { key: 'c', keyCode: 67 });
+ expect(pressedKey).toBe('c');
+ fireEvent.keyDown(document, { key: 'c', keyCode: 67, shiftKey: true });
+ expect(pressedKey).toBe('shift.c');
+ fireEvent.keyDown(document, { key: 'c', keyCode: 67, shiftKey: true, ctrlKey: true });
+ expect(pressedKey).toBe('shift.ctrl.c');
+
+ // test `exactMatch: false`(default) props
+ const hook2 = renderHook(() => useKeyPress(KEYS, callbackKey));
+ fireEvent.keyDown(document, { key: 'c', keyCode: 67 });
+ expect(pressedKey).toBe('c');
+ fireEvent.keyDown(document, { key: 'c', keyCode: 67, shiftKey: true });
+ expect(pressedKey).toBe('c');
+ fireEvent.keyDown(document, { key: 'c', keyCode: 67, shiftKey: true, ctrlKey: true });
+ expect(pressedKey).toBe('c');
+
+ hook2.unmount();
+ hook1.unmount();
+ });
+});
diff --git a/packages/hooks/src/useKeyPress/__tests__/index.test.tsx b/packages/hooks/src/useKeyPress/__tests__/index.test.tsx
deleted file mode 100644
index ca973c86a9..0000000000
--- a/packages/hooks/src/useKeyPress/__tests__/index.test.tsx
+++ /dev/null
@@ -1,75 +0,0 @@
-import { renderHook } from '@testing-library/react-hooks';
-import { fireEvent } from '@testing-library/react';
-import useKeyPress from '../index';
-
-const callback = jest.fn();
-
-afterEach(() => {
- callback.mockClear();
-});
-
-describe('useKeyPress ', () => {
- it('test single key', async () => {
- const { unmount } = renderHook(() => useKeyPress(['c'], callback));
- fireEvent.keyDown(document, { key: 'c', keyCode: 67 });
- expect(callback.mock.calls.length).toBe(1);
- unmount();
- });
-
- it('test modifier key', async () => {
- const { unmount } = renderHook(() => useKeyPress(['ctrl'], callback));
- fireEvent.keyDown(document, { key: 'ctrl', keyCode: 17, ctrlKey: true });
- expect(callback.mock.calls.length).toBe(1);
- unmount();
- });
-
- it('test combination keys', async () => {
- const hook1 = renderHook(() => useKeyPress(['shift.c'], callback));
- const hook2 = renderHook(() => useKeyPress(['shift'], callback));
- const hook3 = renderHook(() => useKeyPress(['c'], callback));
-
- fireEvent.keyDown(document, { key: 'c', shiftKey: true, keyCode: 67 });
-
- expect(callback.mock.calls.length).toBe(3);
- hook1.unmount();
- hook2.unmount();
- hook3.unmount();
- });
-
- it('test combination keys by exact match', async () => {
- const callbackShift = jest.fn();
- const callbackC = jest.fn();
- const callbackMulti = jest.fn();
- const hook1 = renderHook(() => useKeyPress(['shift.c'], callback, { exactMatch: true }));
- const hook2 = renderHook(() => useKeyPress(['shift'], callbackShift, { exactMatch: true }));
- const hook3 = renderHook(() => useKeyPress(['c'], callbackC, { exactMatch: true }));
- const hook4 = renderHook(() => useKeyPress(['ctrl.shift.c'], callbackMulti));
-
- fireEvent.keyDown(document, { key: 'c', shiftKey: true, keyCode: 67 });
- /**
- * 只有 shift.c 才会触发,shift 和 c 都不应该触发
- */
- expect(callback.mock.calls.length).toBe(1);
- expect(callbackShift.mock.calls.length).toBe(0);
- expect(callbackC.mock.calls.length).toBe(0);
-
- callback.mockClear();
- fireEvent.keyDown(document, { key: 'c', ctrlKey: true, shiftKey: true, keyCode: 67 });
- expect(callbackMulti.mock.calls.length).toBe(1);
- expect(callback.mock.calls.length).toBe(0);
- expect(callbackC.mock.calls.length).toBe(0);
-
- hook1.unmount();
- hook2.unmount();
- hook3.unmount();
- hook4.unmount();
- });
-
- it('test multiple keys', async () => {
- const { unmount } = renderHook(() => useKeyPress(['0', 65], callback));
- fireEvent.keyDown(document, { key: '0', keyCode: 48 });
- fireEvent.keyDown(document, { key: 'a', keyCode: 65 });
- expect(callback.mock.calls.length).toBe(2);
- unmount();
- });
-});
diff --git a/packages/hooks/src/useKeyPress/demo/demo1.tsx b/packages/hooks/src/useKeyPress/demo/demo1.tsx
index d727152f57..5b1496aff6 100644
--- a/packages/hooks/src/useKeyPress/demo/demo1.tsx
+++ b/packages/hooks/src/useKeyPress/demo/demo1.tsx
@@ -6,7 +6,7 @@
* desc.zh-CN: 支持键盘事件中的 keyCode 和别名,请按 ArrowUp 或 ArrowDown 键进行演示。
*/
-import React, { useState } from 'react';
+import { useState } from 'react';
import { useKeyPress } from 'ahooks';
export default () => {
diff --git a/packages/hooks/src/useKeyPress/demo/demo2.tsx b/packages/hooks/src/useKeyPress/demo/demo2.tsx
index c602c2dd6e..9f7db4bc07 100644
--- a/packages/hooks/src/useKeyPress/demo/demo2.tsx
+++ b/packages/hooks/src/useKeyPress/demo/demo2.tsx
@@ -6,7 +6,7 @@
* desc.zh-CN: 支持使用别名,更多内容请[查看备注](#remarks)。
*/
-import React, { useState } from 'react';
+import { useState } from 'react';
import { useKeyPress } from 'ahooks';
export default () => {
diff --git a/packages/hooks/src/useKeyPress/demo/demo3.tsx b/packages/hooks/src/useKeyPress/demo/demo3.tsx
index c5ee6a30c7..c05de05561 100644
--- a/packages/hooks/src/useKeyPress/demo/demo3.tsx
+++ b/packages/hooks/src/useKeyPress/demo/demo3.tsx
@@ -1,5 +1,5 @@
import { useKeyPress } from 'ahooks';
-import React, { useState } from 'react';
+import { useState } from 'react';
export default () => {
const [num, setNum] = useState();
diff --git a/packages/hooks/src/useKeyPress/demo/demo4.tsx b/packages/hooks/src/useKeyPress/demo/demo4.tsx
index e727afbc2c..b6bfc07315 100644
--- a/packages/hooks/src/useKeyPress/demo/demo4.tsx
+++ b/packages/hooks/src/useKeyPress/demo/demo4.tsx
@@ -6,7 +6,7 @@
* desc.zh-CN: 支持接收一个返回 boolean 的回调函数,自己处理逻辑。
*/
-import React, { useState } from 'react';
+import { useState } from 'react';
import { useKeyPress } from 'ahooks';
export default () => {
diff --git a/packages/hooks/src/useKeyPress/demo/demo5.tsx b/packages/hooks/src/useKeyPress/demo/demo5.tsx
index 6fc5536a75..2eafb9b6c9 100644
--- a/packages/hooks/src/useKeyPress/demo/demo5.tsx
+++ b/packages/hooks/src/useKeyPress/demo/demo5.tsx
@@ -9,7 +9,7 @@
* 如常见的监听输入框事件,支持多种 DOM 指定方式。
*/
-import React, { useState, useRef } from 'react';
+import { useState, useRef } from 'react';
import { useKeyPress } from 'ahooks';
export default () => {
diff --git a/packages/hooks/src/useKeyPress/demo/demo6.tsx b/packages/hooks/src/useKeyPress/demo/demo6.tsx
index 65a7dc2fb4..cb656d6206 100644
--- a/packages/hooks/src/useKeyPress/demo/demo6.tsx
+++ b/packages/hooks/src/useKeyPress/demo/demo6.tsx
@@ -1,6 +1,6 @@
import { CheckOutlined } from '@ant-design/icons';
import { useKeyPress } from 'ahooks';
-import React, { useState } from 'react';
+import { useState } from 'react';
export default () => {
const [state, setState] = useState();
diff --git a/packages/hooks/src/useKeyPress/demo/demo7.tsx b/packages/hooks/src/useKeyPress/demo/demo7.tsx
index d107a3203e..bdec1e853a 100644
--- a/packages/hooks/src/useKeyPress/demo/demo7.tsx
+++ b/packages/hooks/src/useKeyPress/demo/demo7.tsx
@@ -8,7 +8,7 @@
import { CheckOutlined } from '@ant-design/icons';
import { useKeyPress } from 'ahooks';
-import React, { useState } from 'react';
+import { useState } from 'react';
export default () => {
const [state, setState] = useState();
diff --git a/packages/hooks/src/useKeyPress/demo/demo8.tsx b/packages/hooks/src/useKeyPress/demo/demo8.tsx
new file mode 100644
index 0000000000..c1d04df71c
--- /dev/null
+++ b/packages/hooks/src/useKeyPress/demo/demo8.tsx
@@ -0,0 +1,42 @@
+/**
+ * title: Get the trigger key
+ * desc: Multiple shortcuts are registered by a hook, each corresponding to a different logic.
+ *
+ * title.zh-CN: 获取触发的按键
+ * desc.zh-CN: 单个 hook 注册多个快捷键,每个快捷键对应不同逻辑。
+ */
+
+import { useState } from 'react';
+import { useKeyPress } from 'ahooks';
+
+export default () => {
+ const [count, setCount] = useState(0);
+
+ const keyCallbackMap = {
+ w: () => {
+ setCount((prev) => prev + 1);
+ },
+ s: () => {
+ setCount((prev) => prev - 1);
+ },
+ 'shift.c': () => {
+ setCount(0);
+ },
+ };
+
+ useKeyPress(['w', 's', 'shift.c'], (e, key) => {
+ keyCallbackMap[key as keyof typeof keyCallbackMap]();
+ });
+
+ return (
+
+
Try pressing the following:
+
1. Press [w] to increase
+
2. Press [s] to decrease
+
3. Press [shift.c] to reset
+
+ counter: {count}
+
+
+ );
+};
diff --git a/packages/hooks/src/useKeyPress/index.en-US.md b/packages/hooks/src/useKeyPress/index.en-US.md
index ed9297c0dd..a3a7c6372b 100644
--- a/packages/hooks/src/useKeyPress/index.en-US.md
+++ b/packages/hooks/src/useKeyPress/index.en-US.md
@@ -25,6 +25,10 @@ Listen for the keyboard press, support key combinations, and support alias.
+### Get the trigger key
+
+
+
### Custom method
@@ -36,12 +40,12 @@ Listen for the keyboard press, support key combinations, and support alias.
## API
```typescript
-type keyType = number | string;
-type KeyFilter = keyType | keyType[] | ((event: KeyboardEvent) => boolean);
+type KeyType = number | string;
+type KeyFilter = KeyType | KeyType[] | ((event: KeyboardEvent) => boolean);
useKeyPress(
keyFilter: KeyFilter,
- eventHandler: EventHandler,
+ eventHandler: (event: KeyboardEvent, key: KeyType) => void,
options?: Options
);
```
@@ -50,23 +54,22 @@ useKeyPress(
| Property | Description | Type | Default |
| ------------ | ---------------------------------------------------------------- | --------------------------------------------------------------- | ------- |
-| keyFilter | Support keyCode、alias、combination keys、array、custom function | `keyType` \| `keyType[]` \| `(event: KeyboardEvent) => boolean` | - |
-| eventHandler | Callback function | `(event: KeyboardEvent) => void` | - |
-| options | advanced options | `Options` | - |
+| keyFilter | Support keyCode、alias、combination keys、array、custom function | `KeyType` \| `KeyType[]` \| `(event: KeyboardEvent) => boolean` | - |
+| eventHandler | Callback function | `(event: KeyboardEvent, key: KeyType) => void` | - |
+| options | Advanced options | `Options` | - |
### Options
-| Property | Description | Type | Default |
-| ---------- | --------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------- | ------------- |
-| events | Trigger Events | `('keydown' \| 'keyup')[]` | `['keydown']` |
-| target | DOM element or ref | `() => Element` \| `Element` \| `MutableRefObject` | - |
-| exactMatch | Exact match. If set `true`, the event will only be trigger when the keys match exactly. For example, pressing [shif + c] will not trigger [c] | `boolean` | `false` |
-| useCapture | to block events bubbling | `boolean` | `false` |
+| Property | Description | Type | Default |
+| ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------- | ------------- |
+| events | Trigger Events | `('keydown' \| 'keyup')[]` | `['keydown']` |
+| target | DOM element or ref | `() => Element` \| `Element` \| `MutableRefObject` | - |
+| exactMatch | Exact match. If set `true`, the event will only be trigger when the keys match exactly. For example, pressing [shift + c] will not trigger [c] | `boolean` | `false` |
+| useCapture | to block events bubbling | `boolean` | `false` |
## Remarks
-1. All key alias refer to [code](https://github.com/alibaba/hooks/blob/master/packages/hooks/src/useKeyPress/index.ts#L21)
-
+1. Supports part of standard browser key values. Reference: [MDN keyboard key values](https://developer.mozilla.org/en-US/docs/Web/API/UI_Events/Keyboard_event_key_values). Full alias list refers to [code](https://github.com/alibaba/hooks/blob/master/packages/hooks/src/useKeyPress/index.ts#L21)
2. Modifier keys
```text
diff --git a/packages/hooks/src/useKeyPress/index.ts b/packages/hooks/src/useKeyPress/index.ts
index 486546fb14..e0adca0faf 100644
--- a/packages/hooks/src/useKeyPress/index.ts
+++ b/packages/hooks/src/useKeyPress/index.ts
@@ -3,11 +3,11 @@ import { isFunction, isNumber, isString } from '../utils';
import type { BasicTarget } from '../utils/domTarget';
import { getTargetElement } from '../utils/domTarget';
import useDeepCompareEffectWithTarget from '../utils/useDeepCompareWithTarget';
+import isAppleDevice from '../utils/isAppleDevice';
-export type KeyPredicate = (event: KeyboardEvent) => boolean;
-export type keyType = number | string;
-export type KeyFilter = keyType | keyType[] | ((event: KeyboardEvent) => boolean);
-export type EventHandler = (event: KeyboardEvent) => void;
+export type KeyType = number | string;
+export type KeyPredicate = (event: KeyboardEvent) => KeyType | boolean | undefined;
+export type KeyFilter = KeyType | KeyType[] | ((event: KeyboardEvent) => boolean);
export type KeyEvent = 'keydown' | 'keyup';
export type Target = BasicTarget;
@@ -35,20 +35,33 @@ const aliasKeyCodeMap = {
tab: 9,
enter: 13,
shift: 16,
+ // Legacy alias kept for compatibility; standard name is "control".
ctrl: 17,
+ control: 17,
alt: 18,
+ // Legacy alias kept for compatibility; standard name is "pause".
pausebreak: 19,
+ pause: 19,
capslock: 20,
+ // Legacy alias kept for compatibility; standard name is "escape".
esc: 27,
+ escape: 27,
+ // Legacy alias kept for compatibility; standard name is "spacebar" (non-standard but widely used).
space: 32,
+ spacebar: 32,
pageup: 33,
pagedown: 34,
end: 35,
home: 36,
+ // Legacy aliases kept for compatibility; standard names are "arrowleft/arrowup/arrowright/arrowdown".
leftarrow: 37,
+ arrowleft: 37,
uparrow: 38,
+ arrowup: 38,
rightarrow: 39,
+ arrowright: 39,
downarrow: 40,
+ arrowdown: 40,
insert: 45,
delete: 46,
a: 65,
@@ -79,7 +92,10 @@ const aliasKeyCodeMap = {
z: 90,
leftwindowkey: 91,
rightwindowkey: 92,
+ meta: isAppleDevice ? [91, 93] : [91, 92],
+ // Legacy alias kept for compatibility; standard name is "contextmenu".
selectkey: 93,
+ contextmenu: 93,
numpad0: 96,
numpad1: 97,
numpad2: 98,
@@ -127,13 +143,23 @@ const modifierKey = {
ctrl: (event: KeyboardEvent) => event.ctrlKey,
shift: (event: KeyboardEvent) => event.shiftKey,
alt: (event: KeyboardEvent) => event.altKey,
- meta: (event: KeyboardEvent) => event.metaKey,
+ meta: (event: KeyboardEvent) => {
+ if (event.type === 'keyup') {
+ return aliasKeyCodeMap.meta.includes(event.keyCode);
+ }
+ return event.metaKey;
+ },
};
+// 判断合法的按键类型
+function isValidKeyType(value: unknown): value is string | number {
+ return isString(value) || isNumber(value);
+}
+
// 根据 event 计算激活键数量
function countKeyByEvent(event: KeyboardEvent) {
const countOfModifier = Object.keys(modifierKey).reduce((total, key) => {
- if (modifierKey[key](event)) {
+ if ((modifierKey as any)[key](event)) {
return total + 1;
}
@@ -148,9 +174,9 @@ function countKeyByEvent(event: KeyboardEvent) {
* 判断按键是否激活
* @param [event: KeyboardEvent]键盘事件
* @param [keyFilter: any] 当前键
- * @returns Boolean
+ * @returns string | number | boolean
*/
-function genFilterKey(event: KeyboardEvent, keyFilter: keyType, exactMatch: boolean) {
+function genFilterKey(event: KeyboardEvent, keyFilter: KeyType, exactMatch: boolean) {
// 浏览器自动补全 input 的时候,会触发 keyDown、keyUp 事件,但此时 event.key 等为空
if (!event.key) {
return false;
@@ -158,7 +184,7 @@ function genFilterKey(event: KeyboardEvent, keyFilter: keyType, exactMatch: bool
// 数字类型直接匹配事件的 keyCode
if (isNumber(keyFilter)) {
- return event.keyCode === keyFilter;
+ return event.keyCode === keyFilter ? keyFilter : false;
}
// 字符串依次判断是否有组合键
@@ -167,9 +193,9 @@ function genFilterKey(event: KeyboardEvent, keyFilter: keyType, exactMatch: bool
for (const key of genArr) {
// 组合键
- const genModifier = modifierKey[key];
+ const genModifier = (modifierKey as any)[key];
// keyCode 别名
- const aliasKeyCode = aliasKeyCodeMap[key.toLowerCase()];
+ const aliasKeyCode: number | number[] = (aliasKeyCodeMap as any)[key.toLowerCase()];
if ((genModifier && genModifier(event)) || (aliasKeyCode && aliasKeyCode === event.keyCode)) {
genLen++;
@@ -183,9 +209,9 @@ function genFilterKey(event: KeyboardEvent, keyFilter: keyType, exactMatch: bool
* 主要用来防止按组合键其子集也会触发的情况,例如监听 ctrl+a 会触发监听 ctrl 和 a 两个键的事件。
*/
if (exactMatch) {
- return genLen === genArr.length && countKeyByEvent(event) === genArr.length;
+ return genLen === genArr.length && countKeyByEvent(event) === genArr.length ? keyFilter : false;
}
- return genLen === genArr.length;
+ return genLen === genArr.length ? keyFilter : false;
}
/**
@@ -197,19 +223,23 @@ function genKeyFormatter(keyFilter: KeyFilter, exactMatch: boolean): KeyPredicat
if (isFunction(keyFilter)) {
return keyFilter;
}
- if (isString(keyFilter) || isNumber(keyFilter)) {
+ if (isValidKeyType(keyFilter)) {
return (event: KeyboardEvent) => genFilterKey(event, keyFilter, exactMatch);
}
if (Array.isArray(keyFilter)) {
return (event: KeyboardEvent) =>
- keyFilter.some((item) => genFilterKey(event, item, exactMatch));
+ keyFilter.find((item) => genFilterKey(event, item, exactMatch));
}
return () => Boolean(keyFilter);
}
const defaultEvents: KeyEvent[] = ['keydown'];
-function useKeyPress(keyFilter: KeyFilter, eventHandler: EventHandler, option?: Options) {
+function useKeyPress(
+ keyFilter: KeyFilter,
+ eventHandler: (event: KeyboardEvent, key: KeyType) => void,
+ option?: Options,
+) {
const { events = defaultEvents, target, exactMatch = false, useCapture = false } = option || {};
const eventHandlerRef = useLatest(eventHandler);
const keyFilterRef = useLatest(keyFilter);
@@ -221,10 +251,14 @@ function useKeyPress(keyFilter: KeyFilter, eventHandler: EventHandler, option?:
return;
}
- const callbackHandler = (event: KeyboardEvent) => {
- const genGuard: KeyPredicate = genKeyFormatter(keyFilterRef.current, exactMatch);
- if (genGuard(event)) {
- return eventHandlerRef.current?.(event);
+ const callbackHandler = (event: Event) => {
+ const keyEvent = event as KeyboardEvent;
+ const genGuard = genKeyFormatter(keyFilterRef.current, exactMatch);
+ const keyGuard = genGuard(keyEvent);
+ const firedKey = isValidKeyType(keyGuard) ? keyGuard : keyEvent.key;
+
+ if (keyGuard) {
+ return eventHandlerRef.current?.(keyEvent, firedKey);
}
};
diff --git a/packages/hooks/src/useKeyPress/index.zh-CN.md b/packages/hooks/src/useKeyPress/index.zh-CN.md
index c754ae8306..945104943b 100644
--- a/packages/hooks/src/useKeyPress/index.zh-CN.md
+++ b/packages/hooks/src/useKeyPress/index.zh-CN.md
@@ -19,12 +19,16 @@ nav:
### 精确匹配
-
+
### 监听多个按键
+### 获取触发的按键
+
+
+
### 自定义监听方式
@@ -36,12 +40,12 @@ nav:
## API
```typescript
-type keyType = number | string;
-type KeyFilter = keyType | keyType[] | ((event: KeyboardEvent) => boolean);
+type KeyType = number | string;
+type KeyFilter = KeyType | KeyType[] | ((event: KeyboardEvent) => boolean);
useKeyPress(
keyFilter: KeyFilter,
- eventHandler: EventHandler,
+ eventHandler: (event: KeyboardEvent, key: KeyType) => void,
options?: Options
);
```
@@ -50,23 +54,22 @@ useKeyPress(
| 参数 | 说明 | 类型 | 默认值 |
| ------------ | -------------------------------------------- | --------------------------------------------------------------- | ------ |
-| keyFilter | 支持 keyCode、别名、组合键、数组,自定义函数 | `keyType` \| `keyType[]` \| `(event: KeyboardEvent) => boolean` | - |
-| eventHandler | 回调函数 | `(event: KeyboardEvent) => void` | - |
+| keyFilter | 支持 keyCode、别名、组合键、数组、自定义函数 | `KeyType` \| `KeyType[]` \| `(event: KeyboardEvent) => boolean` | - |
+| eventHandler | 回调函数 | `(event: KeyboardEvent, key: KeyType) => void` | - |
| options | 可选配置项 | `Options` | - |
### Options
-| 参数 | 说明 | 类型 | 默认值 |
-| ---------- | ------------------------------------------------------------------------------------------ | ----------------------------------------------------------- | ------------- |
-| events | 触发事件 | `('keydown' \| 'keyup')[]` | `['keydown']` |
-| target | DOM 节点或者 ref | `() => Element` \| `Element` \| `MutableRefObject` | - |
-| exactMatch | 精确匹配。如果开启,则只有在按键完全匹配的情况下触发事件。比如按键 [shif + c] 不会触发 [c] | `boolean` | `false` |
-| useCapture | 是否阻止事件冒泡 | `boolean` | `false` |
+| 参数 | 说明 | 类型 | 默认值 |
+| ---------- | ------------------------------------------------------------------------------------------- | ----------------------------------------------------------- | ------------- |
+| events | 触发事件 | `('keydown' \| 'keyup')[]` | `['keydown']` |
+| target | DOM 节点或者 ref | `() => Element` \| `Element` \| `MutableRefObject` | - |
+| exactMatch | 精确匹配。如果开启,则只有在按键完全匹配的情况下触发事件。比如按键 [shift + c] 不会触发 [c] | `boolean` | `false` |
+| useCapture | 是否阻止事件冒泡 | `boolean` | `false` |
## Remarks
-1. 按键别名见 [代码](https://github.com/alibaba/hooks/blob/master/packages/hooks/src/useKeyPress/index.ts#L21)
-
+1. 支持部分浏览器标准按键值。参考文档:[MDN 键值表](https://developer.mozilla.org/en-US/docs/Web/API/UI_Events/Keyboard_event_key_values)。完整列表见 [代码](https://github.com/alibaba/hooks/blob/master/packages/hooks/src/useKeyPress/index.ts#L21)
2. 修饰键
```text
diff --git a/packages/hooks/src/useLatest/__tests__/index.spec.ts b/packages/hooks/src/useLatest/__tests__/index.spec.ts
new file mode 100644
index 0000000000..3ac331a901
--- /dev/null
+++ b/packages/hooks/src/useLatest/__tests__/index.spec.ts
@@ -0,0 +1,31 @@
+import { renderHook } from '@testing-library/react';
+import { describe, expect, test } from 'vitest';
+import useLatest from '../index';
+
+const setUp = (val: any) => renderHook((state) => useLatest(state), { initialProps: val });
+
+describe('useLatest', () => {
+ test('useLatest with basic variable should work', async () => {
+ const { result, rerender } = setUp(0);
+
+ rerender(1);
+ expect(result.current.current).toBe(1);
+
+ rerender(2);
+ expect(result.current.current).toBe(2);
+
+ rerender(3);
+ expect(result.current.current).toBe(3);
+ });
+
+ test('useLatest with reference variable should work', async () => {
+ const val1 = {};
+ const { result, rerender } = setUp(val1);
+
+ expect(result.current.current).toBe(val1);
+
+ const val2: any[] = [];
+ rerender(val2);
+ expect(result.current.current).toBe(val2);
+ });
+});
diff --git a/packages/hooks/src/useLatest/__tests__/index.test.ts b/packages/hooks/src/useLatest/__tests__/index.test.ts
deleted file mode 100644
index af2e1ba719..0000000000
--- a/packages/hooks/src/useLatest/__tests__/index.test.ts
+++ /dev/null
@@ -1,28 +0,0 @@
-import { renderHook } from '@testing-library/react-hooks';
-import useLatest from '../index';
-
-const setUp = (val) => renderHook((state) => useLatest(state), { initialProps: val });
-
-describe('useLatest', () => {
- it('useLatest with basic variable should work', async () => {
- const { result, rerender } = setUp(0);
-
- rerender(1);
- expect(result.current.current).toEqual(1);
-
- rerender(2);
- expect(result.current.current).toEqual(2);
-
- rerender(3);
- expect(result.current.current).toEqual(3);
- });
-
- it('useLatest with reference variable should work', async () => {
- const { result, rerender } = setUp({});
-
- expect(result.current.current).toEqual({});
-
- rerender([]);
- expect(result.current.current).toEqual([]);
- });
-});
diff --git a/packages/hooks/src/useLatest/demo/demo1.tsx b/packages/hooks/src/useLatest/demo/demo1.tsx
index 1d9f6ed2a3..81aecb79df 100644
--- a/packages/hooks/src/useLatest/demo/demo1.tsx
+++ b/packages/hooks/src/useLatest/demo/demo1.tsx
@@ -6,11 +6,12 @@
* desc.zh-CN: useLatest 返回的永远是最新值
*/
-import React, { useState, useEffect } from 'react';
+import { useState, useEffect } from 'react';
import { useLatest } from 'ahooks';
export default () => {
const [count, setCount] = useState(0);
+ const [count2, setCount2] = useState(0);
const latestCountRef = useLatest(count);
@@ -21,9 +22,17 @@ export default () => {
return () => clearInterval(interval);
}, []);
+ useEffect(() => {
+ const interval = setInterval(() => {
+ setCount2(count2 + 1);
+ }, 1000);
+ return () => clearInterval(interval);
+ }, []);
+
return (
<>
- count: {count}
+ count(useLatest): {count}
+ count(default): {count2}
>
);
};
diff --git a/packages/hooks/src/useLocalStorageState/__tests__/index.spec.ts b/packages/hooks/src/useLocalStorageState/__tests__/index.spec.ts
new file mode 100644
index 0000000000..9a0d86df1a
--- /dev/null
+++ b/packages/hooks/src/useLocalStorageState/__tests__/index.spec.ts
@@ -0,0 +1,165 @@
+import { act, renderHook } from '@testing-library/react';
+import { describe, expect, test } from 'vitest';
+import type { Options } from '../../createUseStorageState';
+import useLocalStorageState from '../index';
+
+describe('useLocalStorageState', () => {
+ const setUp = (key: string, value: T, options?: Options) =>
+ renderHook(() => {
+ const [state, setState] = useLocalStorageState(key, {
+ defaultValue: value,
+ ...options,
+ });
+ return {
+ state,
+ setState,
+ } as const;
+ });
+
+ test('getKey should work', () => {
+ const LOCAL_STORAGE_KEY = 'test-key';
+ const hook = setUp(LOCAL_STORAGE_KEY, 'A');
+ expect(hook.result.current.state).toBe('A');
+ act(() => {
+ hook.result.current.setState('B');
+ });
+ expect(hook.result.current.state).toBe('B');
+ const anotherHook = setUp(LOCAL_STORAGE_KEY, 'A');
+ expect(anotherHook.result.current.state).toBe('B');
+ act(() => {
+ anotherHook.result.current.setState('C');
+ });
+ expect(anotherHook.result.current.state).toBe('C');
+ expect(hook.result.current.state).toBe('B');
+ });
+
+ test('should support object', () => {
+ const LOCAL_STORAGE_KEY = 'test-object-key';
+ const hook = setUp<{ name: string }>(LOCAL_STORAGE_KEY, {
+ name: 'A',
+ });
+ expect(hook.result.current.state).toEqual({ name: 'A' });
+ act(() => {
+ hook.result.current.setState({ name: 'B' });
+ });
+ expect(hook.result.current.state).toEqual({ name: 'B' });
+ const anotherHook = setUp(LOCAL_STORAGE_KEY, {
+ name: 'C',
+ });
+ expect(anotherHook.result.current.state).toEqual({ name: 'B' });
+ act(() => {
+ anotherHook.result.current.setState({
+ name: 'C',
+ });
+ });
+ expect(anotherHook.result.current.state).toEqual({ name: 'C' });
+ expect(hook.result.current.state).toEqual({ name: 'B' });
+ });
+
+ test('should support number', () => {
+ const LOCAL_STORAGE_KEY = 'test-number-key';
+ const hook = setUp(LOCAL_STORAGE_KEY, 1);
+ expect(hook.result.current.state).toBe(1);
+ act(() => {
+ hook.result.current.setState(2);
+ });
+ expect(hook.result.current.state).toBe(2);
+ const anotherHook = setUp(LOCAL_STORAGE_KEY, 3);
+ expect(anotherHook.result.current.state).toBe(2);
+ act(() => {
+ anotherHook.result.current.setState(3);
+ });
+ expect(anotherHook.result.current.state).toBe(3);
+ expect(hook.result.current.state).toBe(2);
+ });
+
+ test('should support boolean', () => {
+ const LOCAL_STORAGE_KEY = 'test-boolean-key';
+ const hook = setUp(LOCAL_STORAGE_KEY, true);
+ expect(hook.result.current.state).toBe(true);
+ act(() => {
+ hook.result.current.setState(false);
+ });
+ expect(hook.result.current.state).toBe(false);
+ const anotherHook = setUp(LOCAL_STORAGE_KEY, true);
+ expect(anotherHook.result.current.state).toBe(false);
+ act(() => {
+ anotherHook.result.current.setState(true);
+ });
+ expect(anotherHook.result.current.state).toBe(true);
+ expect(hook.result.current.state).toBe(false);
+ });
+
+ test('should support null', () => {
+ const LOCAL_STORAGE_KEY = 'test-boolean-key-with-null';
+ const hook = setUp(LOCAL_STORAGE_KEY, false);
+ expect(hook.result.current.state).toBe(false);
+ act(() => {
+ hook.result.current.setState(null);
+ });
+ expect(hook.result.current.state).toBeNull();
+ const anotherHook = setUp(LOCAL_STORAGE_KEY, false);
+ expect(anotherHook.result.current.state).toBeNull();
+ });
+
+ test('should support function updater', () => {
+ const LOCAL_STORAGE_KEY = 'test-func-updater';
+ const hook = setUp(LOCAL_STORAGE_KEY, 'hello world');
+ expect(hook.result.current.state).toBe('hello world');
+ act(() => {
+ hook.result.current.setState((state) => `${state}, zhangsan`);
+ });
+ expect(hook.result.current.state).toBe('hello world, zhangsan');
+ });
+
+ test('should sync state when changes', async () => {
+ const LOCAL_STORAGE_KEY = 'test-sync-state';
+ const hook = setUp(LOCAL_STORAGE_KEY, 'foo', { listenStorageChange: true });
+ const anotherHook = setUp(LOCAL_STORAGE_KEY, 'bar', {
+ listenStorageChange: true,
+ });
+
+ expect(hook.result.current.state).toBe('foo');
+ expect(anotherHook.result.current.state).toBe('bar');
+
+ act(() => hook.result.current.setState('baz'));
+ expect(hook.result.current.state).toBe('baz');
+ expect(anotherHook.result.current.state).toBe('baz');
+
+ act(() => anotherHook.result.current.setState('qux'));
+ expect(hook.result.current.state).toBe('qux');
+ expect(anotherHook.result.current.state).toBe('qux');
+ });
+
+ test('should not rerender when setting the same reference', () => {
+ const LOCAL_STORAGE_KEY = 'test-same-reference';
+ const value = {
+ name: 'A',
+ };
+ let renderCount = 0;
+
+ const hook = renderHook(() => {
+ renderCount += 1;
+ const [state, setState] = useLocalStorageState(LOCAL_STORAGE_KEY, {
+ defaultValue: value,
+ listenStorageChange: true,
+ });
+
+ return {
+ state,
+ setState,
+ } as const;
+ });
+
+ expect(renderCount).toBe(1);
+ expect(hook.result.current.state).toBe(value);
+
+ act(() => {
+ hook.result.current.setState((prev) => prev!);
+ });
+
+ expect(renderCount).toBe(1);
+ expect(hook.result.current.state).toBe(value);
+ expect(localStorage.getItem(LOCAL_STORAGE_KEY)).toBeNull();
+ });
+});
diff --git a/packages/hooks/src/useLocalStorageState/__tests__/index.test.ts b/packages/hooks/src/useLocalStorageState/__tests__/index.test.ts
deleted file mode 100644
index 88871b9745..0000000000
--- a/packages/hooks/src/useLocalStorageState/__tests__/index.test.ts
+++ /dev/null
@@ -1,109 +0,0 @@
-import { renderHook, act } from '@testing-library/react-hooks';
-import useLocalStorageState from '../index';
-
-describe('useLocalStorageState', () => {
- const setUp = (key: string, value: T) =>
- renderHook(() => {
- const [state, setState] = useLocalStorageState(key, { defaultValue: value });
- return {
- state,
- setState,
- } as const;
- });
-
- it('getKey should work', () => {
- const LOCAL_STORAGE_KEY = 'test-key';
- const hook = setUp(LOCAL_STORAGE_KEY, 'A');
- expect(hook.result.current.state).toEqual('A');
- act(() => {
- hook.result.current.setState('B');
- });
- expect(hook.result.current.state).toEqual('B');
- const anotherHook = setUp(LOCAL_STORAGE_KEY, 'A');
- expect(anotherHook.result.current.state).toEqual('B');
- act(() => {
- anotherHook.result.current.setState('C');
- });
- expect(anotherHook.result.current.state).toEqual('C');
- expect(hook.result.current.state).toEqual('B');
- });
-
- it('should support object', () => {
- const LOCAL_STORAGE_KEY = 'test-object-key';
- const hook = setUp<{ name: string }>(LOCAL_STORAGE_KEY, {
- name: 'A',
- });
- expect(hook.result.current.state).toEqual({ name: 'A' });
- act(() => {
- hook.result.current.setState({ name: 'B' });
- });
- expect(hook.result.current.state).toEqual({ name: 'B' });
- const anotherHook = setUp(LOCAL_STORAGE_KEY, {
- name: 'C',
- });
- expect(anotherHook.result.current.state).toEqual({ name: 'B' });
- act(() => {
- anotherHook.result.current.setState({
- name: 'C',
- });
- });
- expect(anotherHook.result.current.state).toEqual({ name: 'C' });
- expect(hook.result.current.state).toEqual({ name: 'B' });
- });
-
- it('should support number', () => {
- const LOCAL_STORAGE_KEY = 'test-number-key';
- const hook = setUp(LOCAL_STORAGE_KEY, 1);
- expect(hook.result.current.state).toEqual(1);
- act(() => {
- hook.result.current.setState(2);
- });
- expect(hook.result.current.state).toEqual(2);
- const anotherHook = setUp(LOCAL_STORAGE_KEY, 3);
- expect(anotherHook.result.current.state).toEqual(2);
- act(() => {
- anotherHook.result.current.setState(3);
- });
- expect(anotherHook.result.current.state).toEqual(3);
- expect(hook.result.current.state).toEqual(2);
- });
-
- it('should support boolean', () => {
- const LOCAL_STORAGE_KEY = 'test-boolean-key';
- const hook = setUp(LOCAL_STORAGE_KEY, true);
- expect(hook.result.current.state).toEqual(true);
- act(() => {
- hook.result.current.setState(false);
- });
- expect(hook.result.current.state).toEqual(false);
- const anotherHook = setUp(LOCAL_STORAGE_KEY, true);
- expect(anotherHook.result.current.state).toEqual(false);
- act(() => {
- anotherHook.result.current.setState(true);
- });
- expect(anotherHook.result.current.state).toEqual(true);
- expect(hook.result.current.state).toEqual(false);
- });
-
- it('should support null', () => {
- const LOCAL_STORAGE_KEY = 'test-boolean-key-with-null';
- const hook = setUp(LOCAL_STORAGE_KEY, false);
- expect(hook.result.current.state).toEqual(false);
- act(() => {
- hook.result.current.setState(null);
- });
- expect(hook.result.current.state).toEqual(null);
- const anotherHook = setUp(LOCAL_STORAGE_KEY, false);
- expect(anotherHook.result.current.state).toEqual(null);
- });
-
- it('should support function updater', () => {
- const LOCAL_STORAGE_KEY = 'test-func-updater';
- const hook = setUp(LOCAL_STORAGE_KEY, 'hello world');
- expect(hook.result.current.state).toEqual('hello world');
- act(() => {
- hook.result.current.setState((state) => `${state}, zhangsan`);
- });
- expect(hook.result.current.state).toEqual('hello world, zhangsan');
- });
-});
diff --git a/packages/hooks/src/useLocalStorageState/demo/demo1.tsx b/packages/hooks/src/useLocalStorageState/demo/demo1.tsx
index 832650cfc6..91774b8c6b 100644
--- a/packages/hooks/src/useLocalStorageState/demo/demo1.tsx
+++ b/packages/hooks/src/useLocalStorageState/demo/demo1.tsx
@@ -6,7 +6,6 @@
* desc.zh-CN: 刷新页面后,可以看到输入框中的内容被从 localStorage 中恢复了。
*/
-import React from 'react';
import { useLocalStorageState } from 'ahooks';
export default function () {
diff --git a/packages/hooks/src/useLocalStorageState/demo/demo2.tsx b/packages/hooks/src/useLocalStorageState/demo/demo2.tsx
index b6842032ee..a529a4b809 100644
--- a/packages/hooks/src/useLocalStorageState/demo/demo2.tsx
+++ b/packages/hooks/src/useLocalStorageState/demo/demo2.tsx
@@ -6,7 +6,6 @@
* desc.zh-CN: useLocalStorageState 会自动处理序列化和反序列化的操作。
*/
-import React from 'react';
import { useLocalStorageState } from 'ahooks';
const defaultArray = ['a', 'e', 'i', 'o', 'u'];
diff --git a/packages/hooks/src/useLocalStorageState/demo/demo3.tsx b/packages/hooks/src/useLocalStorageState/demo/demo3.tsx
index c57e2c86ff..f9afa23a75 100644
--- a/packages/hooks/src/useLocalStorageState/demo/demo3.tsx
+++ b/packages/hooks/src/useLocalStorageState/demo/demo3.tsx
@@ -6,7 +6,6 @@
* desc.zh-CN: 对于普通的字符串,可能你不需要默认的 `JSON.stringify/JSON.parse` 来序列化。
*/
-import React from 'react';
import { useLocalStorageState } from 'ahooks';
export default function () {
diff --git a/packages/hooks/src/useLocalStorageState/demo/demo4.tsx b/packages/hooks/src/useLocalStorageState/demo/demo4.tsx
new file mode 100644
index 0000000000..5fd7a530c3
--- /dev/null
+++ b/packages/hooks/src/useLocalStorageState/demo/demo4.tsx
@@ -0,0 +1,34 @@
+/**
+ * title: Sync state with localStorage
+ * desc: When the stored value changes, all `useLocalStorageState` with the same `key` will synchronize their states, including different tabs of the same browser (try to open two tabs of this page, clicking a button on one page will automatically update the "count" on the other page).
+ *
+ * title.zh-CN: 将 state 与 localStorage 保持同步
+ * desc.zh-CN: 存储值变化时,所有 `key` 相同的 `useLocalStorageState` 会同步状态,包括同一浏览器不同 tab 之间(尝试打开两个此页面,点击其中一个页面的按钮,另一个页面的 count 会自动更新)
+ */
+
+import { useLocalStorageState } from 'ahooks';
+
+export default function () {
+ return (
+ <>
+
+
+ >
+ );
+}
+
+function Counter() {
+ const [count, setCount] = useLocalStorageState('use-local-storage-state-demo4', {
+ defaultValue: 0,
+ listenStorageChange: true,
+ });
+
+ return (
+
+ setCount(count! + 1)}>
+ count: {count}
+
+ setCount(0)}>Clear
+
+ );
+}
diff --git a/packages/hooks/src/useLocalStorageState/index.en-US.md b/packages/hooks/src/useLocalStorageState/index.en-US.md
index 15bd992e5c..3a02582cdc 100644
--- a/packages/hooks/src/useLocalStorageState/index.en-US.md
+++ b/packages/hooks/src/useLocalStorageState/index.en-US.md
@@ -21,30 +21,49 @@ A Hook that store state into localStorage.
+### Sync state with localStorage
+
+
+
## API
If you want to delete this record from localStorage, you can use `setState()` or `setState(undefined)`.
```typescript
+type SetState = S | ((prevState?: S) => S);
+
interface Options {
defaultValue?: T | (() => T);
+ getInitialValueInEffect?: boolean;
+ listenStorageChange?: boolean;
serializer?: (value: T) => string;
deserializer?: (value: string) => T;
+ onError?: (error: unknown) => void;
}
const [state, setState] = useLocalStorageState(
key: string,
options: Options
-): [T?, (value?: T | ((previousState: T) => T)) => void];
+): [T?, (value?: SetState) => void];
```
+### Result
+
+| Property | Description | Type |
+| -------- | --------------------------- | ------------------------------- |
+| state | Local `localStorage` value | `T` |
+| setState | Update `localStorage` value | `(value?: SetState) => void` |
+
### Options
-| Property | Description | Type | Default |
-| ------------ | ----------------------------- | ------------------------ | ---------------- |
-| defaultValue | Default value | `any \| (() => any)` | - |
-| serializer | Custom serialization method | `(value: any) => string` | `JSON.stringify` |
-| deserializer | Custom deserialization method | `(value: string) => any` | `JSON.parse` |
+| Property | Description | Type | Default |
+| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------- | ----------------------------- |
+| defaultValue | Default value | `any \| (() => any)` | - |
+| getInitialValueInEffect | Whether to read localStorage in an effect after mount, useful to avoid SSR hydration mismatch | `boolean` | `false` |
+| listenStorageChange | Whether to listen storage changes. If `true`, when the stored value changes, all `useLocalStorageState` with the same `key` will synchronize their states, including different tabs of the same browser | `boolean` | `false` |
+| serializer | Custom serialization method | `(value: any) => string` | `JSON.stringify` |
+| deserializer | Custom deserialization method | `(value: string) => any` | `JSON.parse` |
+| onError | On error callback | `(error: unknown) => void` | `(e) => { console.error(e) }` |
## Remark
diff --git a/packages/hooks/src/useLocalStorageState/index.zh-CN.md b/packages/hooks/src/useLocalStorageState/index.zh-CN.md
index a1159a0797..25d193c444 100644
--- a/packages/hooks/src/useLocalStorageState/index.zh-CN.md
+++ b/packages/hooks/src/useLocalStorageState/index.zh-CN.md
@@ -21,30 +21,49 @@ nav:
+### 将 state 与 localStorage 保持同步
+
+
+
## API
如果想从 localStorage 中删除这条数据,可以使用 `setState()` 或 `setState(undefined)` 。
```typescript
+type SetState = S | ((prevState?: S) => S);
+
interface Options {
defaultValue?: T | (() => T);
+ getInitialValueInEffect?: boolean;
+ listenStorageChange?: boolean;
serializer?: (value: T) => string;
deserializer?: (value: string) => T;
+ onError?: (error: unknown) => void;
}
const [state, setState] = useLocalStorageState(
key: string,
options: Options
-): [T?, (value?: T | ((previousState: T) => T)) => void];
+): [T?, (value?: SetState) => void];
```
+### Result
+
+| 参数 | 说明 | 类型 |
+| -------- | ---------------------- | ------------------------------- |
+| state | 本地 `localStorage` 值 | `T` |
+| setState | 设置 `localStorage` 值 | `(value?: SetState) => void` |
+
### Options
-| 参数 | 说明 | 类型 | 默认值 |
-| ------------ | ------------------ | ------------------------ | ---------------- |
-| defaultValue | 默认值 | `any \| (() => any)` | - |
-| serializer | 自定义序列化方法 | `(value: any) => string` | `JSON.stringify` |
-| deserializer | 自定义反序列化方法 | `(value: string) => any` | `JSON.parse` |
+| 参数 | 说明 | 类型 | 默认值 |
+| ------------------- | --------------------------------------------------------------------------------------------------------------------------------- | -------------------------- | ----------------------------- |
+| defaultValue | 默认值 | `any \| (() => any)` | - |
+| getInitialValueInEffect | 是否在挂载后通过 effect 读取 localStorage,可用于避免 SSR hydration 不一致 | `boolean` | `false` |
+| listenStorageChange | 是否监听存储变化。如果是 `true`,当存储值变化时,所有 `key` 相同的 `useLocalStorageState` 会同步状态,包括同一浏览器不同 tab 之间 | `boolean` | `false` |
+| serializer | 自定义序列化方法 | `(value: any) => string` | `JSON.stringify` |
+| deserializer | 自定义反序列化方法 | `(value: string) => any` | `JSON.parse` |
+| onError | 错误回调函数 | `(error: unknown) => void` | `(e) => { console.error(e) }` |
## 备注
diff --git a/packages/hooks/src/useLockFn/__tests__/index.test.ts b/packages/hooks/src/useLockFn/__tests__/index.spec.ts
similarity index 84%
rename from packages/hooks/src/useLockFn/__tests__/index.test.ts
rename to packages/hooks/src/useLockFn/__tests__/index.spec.ts
index 164020c1d2..2a8631ae1a 100644
--- a/packages/hooks/src/useLockFn/__tests__/index.test.ts
+++ b/packages/hooks/src/useLockFn/__tests__/index.spec.ts
@@ -1,7 +1,8 @@
-import { renderHook, act } from '@testing-library/react-hooks';
-import { useRef, useCallback, useState } from 'react';
-import useLockFn from '../index';
+import { act, renderHook } from '@testing-library/react';
+import { useCallback, useRef, useState } from 'react';
+import { describe, expect, test } from 'vitest';
import { sleep } from '../../utils/testingHelpers';
+import useLockFn from '../index';
describe('useLockFn', () => {
const setUp = (): any =>
@@ -24,7 +25,7 @@ describe('useLockFn', () => {
};
});
- it('should work', async () => {
+ test('should work', async () => {
const hook = setUp();
const { locked, countRef } = hook.result.current;
locked(1);
@@ -41,7 +42,7 @@ describe('useLockFn', () => {
expect(countRef.current).toBe(5);
});
- it('should same', () => {
+ test('should same', () => {
const hook = setUp();
const preLocked = hook.result.current.locked;
hook.rerender();
diff --git a/packages/hooks/src/useLockFn/demo/demo1.tsx b/packages/hooks/src/useLockFn/demo/demo1.tsx
index dd35e021f4..2d82e373f0 100644
--- a/packages/hooks/src/useLockFn/demo/demo1.tsx
+++ b/packages/hooks/src/useLockFn/demo/demo1.tsx
@@ -8,7 +8,7 @@
import { useLockFn } from 'ahooks';
import { message } from 'antd';
-import React, { useState } from 'react';
+import { useState } from 'react';
function mockApiRequest() {
return new Promise((resolve) => {
diff --git a/packages/hooks/src/useLockFn/index.en-US.md b/packages/hooks/src/useLockFn/index.en-US.md
index c570cc4d72..2275287de3 100644
--- a/packages/hooks/src/useLockFn/index.en-US.md
+++ b/packages/hooks/src/useLockFn/index.en-US.md
@@ -16,7 +16,7 @@ Add lock to an async function to prevent parallel executions.
## API
```typescript
-function useLockFn(
+function useLockFn
(
fn: (...args: P) => Promise
): fn: (...args: P) => Promise;
```
diff --git a/packages/hooks/src/useLockFn/index.ts b/packages/hooks/src/useLockFn/index.ts
index 5ce96fba40..07de338273 100644
--- a/packages/hooks/src/useLockFn/index.ts
+++ b/packages/hooks/src/useLockFn/index.ts
@@ -1,19 +1,21 @@
import { useRef, useCallback } from 'react';
-function useLockFn(fn: (...args: P) => Promise) {
+function useLockFn(fn: (...args: P) => Promise) {
const lockRef = useRef(false);
return useCallback(
async (...args: P) => {
- if (lockRef.current) return;
+ if (lockRef.current) {
+ return;
+ }
lockRef.current = true;
try {
const ret = await fn(...args);
- lockRef.current = false;
return ret;
} catch (e) {
- lockRef.current = false;
throw e;
+ } finally {
+ lockRef.current = false;
}
},
[fn],
diff --git a/packages/hooks/src/useLockFn/index.zh-CN.md b/packages/hooks/src/useLockFn/index.zh-CN.md
index 61ace21a64..60c48f2759 100644
--- a/packages/hooks/src/useLockFn/index.zh-CN.md
+++ b/packages/hooks/src/useLockFn/index.zh-CN.md
@@ -16,7 +16,7 @@ nav:
## API
```typescript
-function useLockFn(
+function useLockFn
(
fn: (...args: P) => Promise
): fn: (...args: P) => Promise;
```
diff --git a/packages/hooks/src/useLongPress/__tests__/index.test.ts b/packages/hooks/src/useLongPress/__tests__/index.spec.ts
similarity index 71%
rename from packages/hooks/src/useLongPress/__tests__/index.test.ts
rename to packages/hooks/src/useLongPress/__tests__/index.spec.ts
index 52d88e5aef..1232a051ec 100644
--- a/packages/hooks/src/useLongPress/__tests__/index.test.ts
+++ b/packages/hooks/src/useLongPress/__tests__/index.spec.ts
@@ -1,49 +1,52 @@
-import { renderHook } from '@testing-library/react-hooks';
-import useLongPress from '../index';
+import { renderHook } from '@testing-library/react';
+import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import type { Options } from '../index';
+import useLongPress from '../index';
-const mockCallback = jest.fn();
-const mockClickCallback = jest.fn();
-const mockLongPressEndCallback = jest.fn();
+const mockCallback = vi.fn();
+const mockClickCallback = vi.fn();
+const mockLongPressEndCallback = vi.fn();
-let events = {};
+let events: Record = {};
const mockTarget = {
- addEventListener: jest.fn((event, callback) => {
+ addEventListener: vi.fn((event, callback) => {
events[event] = callback;
}),
- removeEventListener: jest.fn((event) => {
+ removeEventListener: vi.fn((event) => {
Reflect.deleteProperty(events, event);
}),
};
-const setup = (onLongPress: any, target, options?: Options) =>
+const setup = (onLongPress: any, target: any, options?: Options) =>
renderHook(() => useLongPress(onLongPress, target, options));
describe('useLongPress', () => {
beforeEach(() => {
- jest.useFakeTimers();
+ vi.useFakeTimers();
+ vi.clearAllMocks();
});
afterEach(() => {
events = {};
- jest.useRealTimers();
+ vi.useRealTimers();
+ vi.clearAllMocks();
});
- it('longPress callback correct', () => {
+ test('longPress callback correct', () => {
setup(mockCallback, mockTarget, {
onClick: mockClickCallback,
onLongPressEnd: mockLongPressEndCallback,
});
expect(mockTarget.addEventListener).toBeCalled();
events['mousedown']();
- jest.advanceTimersByTime(350);
+ vi.advanceTimersByTime(350);
events['mouseleave']();
expect(mockCallback).toBeCalledTimes(1);
expect(mockLongPressEndCallback).toBeCalledTimes(1);
expect(mockClickCallback).toBeCalledTimes(0);
});
- it('click callback correct', () => {
+ test('click callback correct', () => {
setup(mockCallback, mockTarget, {
onClick: mockClickCallback,
onLongPressEnd: mockLongPressEndCallback,
@@ -58,14 +61,14 @@ describe('useLongPress', () => {
expect(mockClickCallback).toBeCalledTimes(2);
});
- it('longPress and click callback correct', () => {
+ test('longPress and click callback correct', () => {
setup(mockCallback, mockTarget, {
onClick: mockClickCallback,
onLongPressEnd: mockLongPressEndCallback,
});
expect(mockTarget.addEventListener).toBeCalled();
events['mousedown']();
- jest.advanceTimersByTime(350);
+ vi.advanceTimersByTime(350);
events['mouseup']();
events['mousedown']();
events['mouseup']();
@@ -74,7 +77,7 @@ describe('useLongPress', () => {
expect(mockClickCallback).toBeCalledTimes(1);
});
- it('onLongPress should not be called when over the threshold', () => {
+ test('onLongPress should not be called when over the threshold', () => {
const { unmount } = setup(mockCallback, mockTarget, {
moveThreshold: {
x: 30,
@@ -89,14 +92,14 @@ describe('useLongPress', () => {
clientY: 10,
}),
);
- jest.advanceTimersByTime(320);
+ vi.advanceTimersByTime(320);
expect(mockCallback).not.toBeCalled();
unmount();
expect(events['mousemove']).toBeUndefined();
});
- it(`should not work when target don't support addEventListener method`, () => {
+ test(`should not work when target don't support addEventListener method`, () => {
Object.defineProperty(mockTarget, 'addEventListener', {
get() {
return false;
diff --git a/packages/hooks/src/useLongPress/demo/demo1.tsx b/packages/hooks/src/useLongPress/demo/demo1.tsx
index 9a83291b4d..dacc982640 100644
--- a/packages/hooks/src/useLongPress/demo/demo1.tsx
+++ b/packages/hooks/src/useLongPress/demo/demo1.tsx
@@ -6,7 +6,7 @@
* desc.zh-CN: 请长按按钮查看效果。
*/
-import React, { useState, useRef } from 'react';
+import { useState, useRef } from 'react';
import { useLongPress } from 'ahooks';
export default () => {
diff --git a/packages/hooks/src/useLongPress/demo/demo2.tsx b/packages/hooks/src/useLongPress/demo/demo2.tsx
index dadee3a1e6..260d47898d 100644
--- a/packages/hooks/src/useLongPress/demo/demo2.tsx
+++ b/packages/hooks/src/useLongPress/demo/demo2.tsx
@@ -1,4 +1,4 @@
-import React, { useRef, useState } from 'react';
+import { useRef, useState } from 'react';
import { useLongPress } from 'ahooks';
export default () => {
diff --git a/packages/hooks/src/useLongPress/demo/demo3.tsx b/packages/hooks/src/useLongPress/demo/demo3.tsx
index 9a68bc37c4..6b155e7d53 100644
--- a/packages/hooks/src/useLongPress/demo/demo3.tsx
+++ b/packages/hooks/src/useLongPress/demo/demo3.tsx
@@ -5,7 +5,7 @@
* title.zh-CN: 超出移动阈值
* desc.zh-CN: 超出移动阈值之后,长按事件将不会触发
*/
-import React, { useRef, useState } from 'react';
+import { useRef, useState } from 'react';
import { useLongPress } from 'ahooks';
export default () => {
diff --git a/packages/hooks/src/useLongPress/index.en-US.md b/packages/hooks/src/useLongPress/index.en-US.md
index ea1163581f..fc7e6aa2a3 100644
--- a/packages/hooks/src/useLongPress/index.en-US.md
+++ b/packages/hooks/src/useLongPress/index.en-US.md
@@ -27,7 +27,7 @@ Listen for the long press event of the target element.
useLongPress(
onLongPress: (event: MouseEvent | TouchEvent) => void,
target: Target,
- options?: {
+ options: {
delay?: number;
moveThreshold?: { x?: number; y?: number };
onClick?: (event: MouseEvent | TouchEvent) => void;
@@ -42,7 +42,7 @@ useLongPress(
| ----------- | ---------------------------- | ----------------------------------------------------------- | ------- |
| onLongPress | Trigger function | `(event: MouseEvent \| TouchEvent) => void` | - |
| target | DOM node or Ref | `Element` \| `() => Element` \| `MutableRefObject` | - |
-| options | Optional configuration items | `Options` | - |
+| options | Optional configuration items | `Options` | `{}` |
### Options
diff --git a/packages/hooks/src/useLongPress/index.ts b/packages/hooks/src/useLongPress/index.ts
index df3f8be751..1edcda784c 100644
--- a/packages/hooks/src/useLongPress/index.ts
+++ b/packages/hooks/src/useLongPress/index.ts
@@ -2,7 +2,6 @@ import { useRef } from 'react';
import useLatest from '../useLatest';
import type { BasicTarget } from '../utils/domTarget';
import { getTargetElement } from '../utils/domTarget';
-import isBrowser from '../utils/isBrowser';
import useEffectWithTarget from '../utils/useEffectWithTarget';
type EventType = MouseEvent | TouchEvent;
@@ -13,11 +12,6 @@ export interface Options {
onLongPressEnd?: (event: EventType) => void;
}
-const touchSupported =
- isBrowser &&
- // @ts-ignore
- ('ontouchstart' in window || (window.DocumentTouch && document instanceof DocumentTouch));
-
function useLongPress(
onLongPress: (event: EventType) => void,
target: BasicTarget,
@@ -27,9 +21,12 @@ function useLongPress(
const onClickRef = useLatest(onClick);
const onLongPressEndRef = useLatest(onLongPressEnd);
- const timerRef = useRef>();
+ const timerRef = useRef>(undefined);
+
const isTriggeredRef = useRef(false);
const pervPositionRef = useRef({ x: 0, y: 0 });
+ const mousePressed = useRef(false);
+ const touchPressed = useRef(false);
const hasMoveThreshold = !!(
(moveThreshold?.x && moveThreshold.x > 0) ||
(moveThreshold?.y && moveThreshold.y > 0)
@@ -54,13 +51,12 @@ function useLongPress(
};
function getClientPosition(event: EventType) {
- if (event instanceof TouchEvent) {
+ if ('TouchEvent' in window && event instanceof TouchEvent) {
return {
clientX: event.touches[0].clientX,
clientY: event.touches[0].clientY,
};
}
-
if (event instanceof MouseEvent) {
return {
clientX: event.clientX,
@@ -68,69 +64,134 @@ function useLongPress(
};
}
- console.warn('Unsupported event type');
-
return { clientX: 0, clientY: 0 };
}
- const onStart = (event: EventType) => {
+ const createTimer = (event: EventType) => {
+ timerRef.current = setTimeout(() => {
+ onLongPressRef.current(event);
+ isTriggeredRef.current = true;
+ }, delay);
+ };
+
+ const onTouchStart = (event: TouchEvent) => {
+ if (touchPressed.current) {
+ return;
+ }
+ touchPressed.current = true;
+
if (hasMoveThreshold) {
const { clientX, clientY } = getClientPosition(event);
pervPositionRef.current.x = clientX;
pervPositionRef.current.y = clientY;
}
- timerRef.current = setTimeout(() => {
- onLongPressRef.current(event);
- isTriggeredRef.current = true;
- }, delay);
+ createTimer(event);
+ };
+
+ const onMouseDown = (event: MouseEvent) => {
+ if ((event as any)?.sourceCapabilities?.firesTouchEvents) {
+ return;
+ }
+
+ mousePressed.current = true;
+
+ if (hasMoveThreshold) {
+ pervPositionRef.current.x = event.clientX;
+ pervPositionRef.current.y = event.clientY;
+ }
+ createTimer(event);
};
- const onMove = (event: TouchEvent) => {
+ const onMove = (event: EventType) => {
if (timerRef.current && overThreshold(event)) {
- clearInterval(timerRef.current);
+ clearTimeout(timerRef.current);
timerRef.current = undefined;
}
};
- const onEnd = (event: EventType, shouldTriggerClick: boolean = false) => {
+ const onTouchEnd = (event: TouchEvent) => {
+ if (!touchPressed.current) {
+ return;
+ }
+ touchPressed.current = false;
+
if (timerRef.current) {
clearTimeout(timerRef.current);
+ timerRef.current = undefined;
}
+
if (isTriggeredRef.current) {
onLongPressEndRef.current?.(event);
+ } else if (onClickRef.current) {
+ onClickRef.current(event);
}
- if (shouldTriggerClick && !isTriggeredRef.current && onClickRef.current) {
+ isTriggeredRef.current = false;
+ };
+
+ const onMouseUp = (event: MouseEvent) => {
+ if ((event as any)?.sourceCapabilities?.firesTouchEvents) {
+ return;
+ }
+ if (!mousePressed.current) {
+ return;
+ }
+ mousePressed.current = false;
+
+ if (timerRef.current) {
+ clearTimeout(timerRef.current);
+ timerRef.current = undefined;
+ }
+
+ if (isTriggeredRef.current) {
+ onLongPressEndRef.current?.(event);
+ } else if (onClickRef.current) {
onClickRef.current(event);
}
isTriggeredRef.current = false;
};
- const onEndWithClick = (event: EventType) => onEnd(event, true);
-
- if (!touchSupported) {
- targetElement.addEventListener('mousedown', onStart);
- targetElement.addEventListener('mouseup', onEndWithClick);
- targetElement.addEventListener('mouseleave', onEnd);
- if (hasMoveThreshold) targetElement.addEventListener('mousemove', onMove);
- } else {
- targetElement.addEventListener('touchstart', onStart);
- targetElement.addEventListener('touchend', onEndWithClick);
- if (hasMoveThreshold) targetElement.addEventListener('touchmove', onMove);
+ const onMouseLeave = (event: MouseEvent) => {
+ if (!mousePressed.current) {
+ return;
+ }
+ mousePressed.current = false;
+
+ if (timerRef.current) {
+ clearTimeout(timerRef.current);
+ timerRef.current = undefined;
+ }
+ if (isTriggeredRef.current) {
+ onLongPressEndRef.current?.(event);
+ isTriggeredRef.current = false;
+ }
+ };
+
+ targetElement.addEventListener('mousedown', onMouseDown as EventListener);
+ targetElement.addEventListener('mouseup', onMouseUp as EventListener);
+ targetElement.addEventListener('mouseleave', onMouseLeave as EventListener);
+ targetElement.addEventListener('touchstart', onTouchStart as EventListener);
+ targetElement.addEventListener('touchend', onTouchEnd as EventListener);
+
+ if (hasMoveThreshold) {
+ targetElement.addEventListener('mousemove', onMove as EventListener);
+ targetElement.addEventListener('touchmove', onMove as EventListener);
}
+
return () => {
if (timerRef.current) {
clearTimeout(timerRef.current);
isTriggeredRef.current = false;
}
- if (!touchSupported) {
- targetElement.removeEventListener('mousedown', onStart);
- targetElement.removeEventListener('mouseup', onEndWithClick);
- targetElement.removeEventListener('mouseleave', onEnd);
- if (hasMoveThreshold) targetElement.removeEventListener('mousemove', onMove);
- } else {
- targetElement.removeEventListener('touchstart', onStart);
- targetElement.removeEventListener('touchend', onEndWithClick);
- if (hasMoveThreshold) targetElement.removeEventListener('touchmove', onMove);
+
+ targetElement.removeEventListener('mousedown', onMouseDown as EventListener);
+ targetElement.removeEventListener('mouseup', onMouseUp as EventListener);
+ targetElement.removeEventListener('mouseleave', onMouseLeave as EventListener);
+ targetElement.removeEventListener('touchstart', onTouchStart as EventListener);
+ targetElement.removeEventListener('touchend', onTouchEnd as EventListener);
+
+ if (hasMoveThreshold) {
+ targetElement.removeEventListener('mousemove', onMove as EventListener);
+ targetElement.removeEventListener('touchmove', onMove as EventListener);
}
};
},
diff --git a/packages/hooks/src/useLongPress/index.zh-CN.md b/packages/hooks/src/useLongPress/index.zh-CN.md
index d1cedd91c3..a3683ca2cf 100644
--- a/packages/hooks/src/useLongPress/index.zh-CN.md
+++ b/packages/hooks/src/useLongPress/index.zh-CN.md
@@ -27,7 +27,7 @@ nav:
useLongPress(
onLongPress: (event: MouseEvent | TouchEvent) => void,
target: Target,
- options?: {
+ options: {
delay?: number;
moveThreshold?: { x?: number; y?: number };
onClick?: (event: MouseEvent | TouchEvent) => void;
@@ -42,7 +42,7 @@ useLongPress(
| ----------- | ---------------- | ----------------------------------------------------------- | ------ |
| onLongPress | 触发函数 | `(event: MouseEvent \| TouchEvent) => void` | - |
| target | DOM 节点或者 Ref | `Element` \| `() => Element` \| `MutableRefObject` | - |
-| options | 可选配置项 | `Options` | - |
+| options | 可选配置项 | `Options` | `{}` |
### Options
diff --git a/packages/hooks/src/useMap/__tests__/index.test.ts b/packages/hooks/src/useMap/__tests__/index.spec.ts
similarity index 70%
rename from packages/hooks/src/useMap/__tests__/index.test.ts
rename to packages/hooks/src/useMap/__tests__/index.spec.ts
index 11a5a10d11..5996b7ac79 100644
--- a/packages/hooks/src/useMap/__tests__/index.test.ts
+++ b/packages/hooks/src/useMap/__tests__/index.spec.ts
@@ -1,10 +1,11 @@
-import { act, renderHook } from '@testing-library/react-hooks';
+import { act, renderHook } from '@testing-library/react';
+import { describe, expect, test } from 'vitest';
import useMap from '../index';
const setup = (initialMap?: Iterable<[any, any]>) => renderHook(() => useMap(initialMap));
describe('useMap', () => {
- it('should init map and utils', () => {
+ test('should init map and utils', () => {
const { result } = setup([
['foo', 'bar'],
['a', 1],
@@ -24,13 +25,15 @@ describe('useMap', () => {
});
});
- it('should init empty map if not initial object provided', () => {
+ test('should init empty map if not initial object provided', () => {
const { result } = setup();
-
expect([...result.current[0]]).toEqual([]);
+
+ const { result: result2 } = setup(undefined);
+ expect([...result2.current[0]]).toEqual([]);
});
- it('should get corresponding value for initial provided key', () => {
+ test('should get corresponding value for initial provided key', () => {
const { result } = setup([
['foo', 'bar'],
['a', 1],
@@ -45,7 +48,7 @@ describe('useMap', () => {
expect(value).toBe(1);
});
- it('should get corresponding value for existing provided key', () => {
+ test('should get corresponding value for existing provided key', () => {
const { result } = setup([
['foo', 'bar'],
['a', 1],
@@ -63,7 +66,7 @@ describe('useMap', () => {
expect(value).toBe(99);
});
- it('should get undefined for non-existing provided key', () => {
+ test('should get undefined for non-existing provided key', () => {
const { result } = setup([
['foo', 'bar'],
['a', 1],
@@ -78,7 +81,7 @@ describe('useMap', () => {
expect(value).toBeUndefined();
});
- it('should set new key-value pair', () => {
+ test('should set new key-value pair', () => {
const { result } = setup([
['foo', 'bar'],
['a', 1],
@@ -96,7 +99,7 @@ describe('useMap', () => {
]);
});
- it('should override current value if setting existing key', () => {
+ test('should override current value if setting existing key', () => {
const { result } = setup([
['foo', 'bar'],
['a', 1],
@@ -113,7 +116,7 @@ describe('useMap', () => {
]);
});
- it('should set new map', () => {
+ test('should set new map', () => {
const { result } = setup([
['foo', 'bar'],
['a', 1],
@@ -131,9 +134,15 @@ describe('useMap', () => {
['foo', 'foo'],
['a', 2],
]);
+
+ act(() => {
+ // @ts-ignore
+ utils.setAll();
+ });
+ expect([...result.current[0]]).toEqual([]);
});
- it('remove should be work', () => {
+ test('remove should be work', () => {
const { result } = setup([['msg', 'hello']]);
const { remove } = result.current[1];
expect(result.current[0].size).toBe(1);
@@ -141,9 +150,27 @@ describe('useMap', () => {
remove('msg');
});
expect(result.current[0].size).toBe(0);
+
+ const { result: result2 } = setup([
+ ['foo', 'bar'],
+ ['a', 1],
+ ['b', 2],
+ ['c', 3],
+ ]);
+ const [, utils] = result2.current;
+
+ act(() => {
+ utils.remove('a');
+ });
+
+ expect([...result2.current[0]]).toEqual([
+ ['foo', 'bar'],
+ ['b', 2],
+ ['c', 3],
+ ]);
});
- it('reset should be work', () => {
+ test('reset should be work', () => {
const { result } = setup([['msg', 'hello']]);
const { set, reset } = result.current[1];
act(() => {
diff --git a/packages/hooks/src/useMap/demo/demo1.tsx b/packages/hooks/src/useMap/demo/demo1.tsx
index 828e714e65..58cd02f812 100644
--- a/packages/hooks/src/useMap/demo/demo1.tsx
+++ b/packages/hooks/src/useMap/demo/demo1.tsx
@@ -1,4 +1,3 @@
-import React from 'react';
import { useMap } from 'ahooks';
export default () => {
diff --git a/packages/hooks/src/useMap/index.en-US.md b/packages/hooks/src/useMap/index.en-US.md
index 6bac03088e..2ee1a6b7e7 100644
--- a/packages/hooks/src/useMap/index.en-US.md
+++ b/packages/hooks/src/useMap/index.en-US.md
@@ -25,22 +25,22 @@ const [
reset,
get
}
-] = useMap(initialValue?: Iterable<[any, any]>);
+] = useMap(initialValue);
```
### Result
-| Property | Description | Type |
-| -------- | ---------------- | ---------------------------------------- |
-| map | Map object | `Map` |
-| set | Add item | `(key: any, value: any) => void` |
-| get | Get item | `(key: any) => MapItem` |
-| setAll | Set a new Map | `(newMap: Iterable<[any, any]>) => void` |
-| remove | Remove key | `(key: any) => void` |
-| reset | Reset to default | `() => void` |
+| Property | Description | Type |
+| -------- | ---------------- | ------------------------------------ |
+| map | Map object | `Map` |
+| set | Add item | `(key: K, value: V) => void` |
+| get | Get item | `(key: K) => V \| undefined` |
+| setAll | Set a new Map | `(newMap: Iterable<[K, V]>) => void` |
+| remove | Remove key | `(key: K) => void` |
+| reset | Reset to default | `() => void` |
### Params
-| Property | Description | Type | Default |
-| ------------ | --------------------------- | ---------------------- | ------- |
-| initialValue | Optional, set default value | `Iterable<[any, any]>` | - |
+| Property | Description | Type | Default |
+| ------------ | --------------------------- | ------------------ | ------- |
+| initialValue | Optional, set default value | `Iterable<[K, V]>` | - |
diff --git a/packages/hooks/src/useMap/index.ts b/packages/hooks/src/useMap/index.ts
index 464ff3e4e9..4f0407fb57 100644
--- a/packages/hooks/src/useMap/index.ts
+++ b/packages/hooks/src/useMap/index.ts
@@ -2,11 +2,8 @@ import { useState } from 'react';
import useMemoizedFn from '../useMemoizedFn';
function useMap(initialValue?: Iterable) {
- const getInitValue = () => {
- return initialValue === undefined ? new Map() : new Map(initialValue);
- };
-
- const [map, setMap] = useState>(() => getInitValue());
+ const getInitValue = () => new Map(initialValue);
+ const [map, setMap] = useState