diff --git a/.changeset/callback-once.md b/.changeset/callback-once.md new file mode 100644 index 00000000..bae13d4d --- /dev/null +++ b/.changeset/callback-once.md @@ -0,0 +1,5 @@ +--- +'react-simplikit': minor +--- + +Add `useCallbackOnce`, which runs a callback only once until its `deps` change and has the same signature and behavior as `useCallbackOncePerRender`. `useCallbackOncePerRender` is now deprecated in favor of `useCallbackOnce` and still works as an alias of it. The second example now lists `userId` in the effect's dependencies, because the returned function keeps the same reference and an effect keyed only on it does not run again when `deps` change. diff --git a/.changeset/curly-melons-shop.md b/.changeset/curly-melons-shop.md new file mode 100644 index 00000000..16ee5643 --- /dev/null +++ b/.changeset/curly-melons-shop.md @@ -0,0 +1,7 @@ +--- +'react-simplikit': major +--- + +Rename the useDoubleClick options click, doubleClick, and delay to onClick, onDoubleClick, and delayMs, respectively. + +Rename the initial value parameter of useList, useMap, and useSet from initialState to initialValue. diff --git a/.changeset/deep-pandas-stare.md b/.changeset/deep-pandas-stare.md new file mode 100644 index 00000000..3988ddad --- /dev/null +++ b/.changeset/deep-pandas-stare.md @@ -0,0 +1,21 @@ +--- +'react-simplikit': major +--- + +Time-related options and parameters now include `Ms` in their names. +These option keys are renamed, so existing calls need to be updated: + +- `useDebouncedCallback`: `timeThreshold` → `debounceMs` +- `useThrottledCallback`: `timeThreshold` → `throttleMs` +- `useImpressionRef`, `ImpressionArea`: `timeThreshold` → `timeThresholdMs` +- `useLongPress`: `delay` → `delayMs` +- `useAvoidKeyboard`: `transitionDuration` → `transitionDurationMs` + +```ts +// before +useDebouncedCallback({ onChange: setQuery, timeThreshold: 300 }); +// after +useDebouncedCallback({ onChange: setQuery, debounceMs: 300 }); +``` + +The positional `wait` parameter of `useDebounce`, `useDebouncedValue`, `useThrottle` and `useThrottledValue` is now `debounceMs` / `throttleMs`. Calls like `useDebounce(fn, 300)` keep working. diff --git a/.changeset/equality-fn-options.md b/.changeset/equality-fn-options.md new file mode 100644 index 00000000..32bed967 --- /dev/null +++ b/.changeset/equality-fn-options.md @@ -0,0 +1,5 @@ +--- +'react-simplikit': patch +--- + +Rename the comparison parameter of `usePrevious` and `usePreservedReference` to `equalityFn`, matching `useControlledState`. `usePrevious` now compares with `Object.is` by default, so rendering `NaN` again no longer overwrites the previous value, and a change between `0` and `-0` is detected. diff --git a/.changeset/hip-pugs-joke.md b/.changeset/hip-pugs-joke.md new file mode 100644 index 00000000..1f35f625 --- /dev/null +++ b/.changeset/hip-pugs-joke.md @@ -0,0 +1,5 @@ +--- +'react-simplikit': minor +--- + +Add `useDebouncedState` and `useThrottledState`, which manage state and return debounced and throttled setters, respectively. Both hooks support `leading` and `trailing` options and cancel pending updates on unmount or when timing options change. diff --git a/.changeset/keyboard-height-immediate.md b/.changeset/keyboard-height-immediate.md new file mode 100644 index 00000000..881f9c98 --- /dev/null +++ b/.changeset/keyboard-height-immediate.md @@ -0,0 +1,16 @@ +--- +'react-simplikit': major +--- + +`subscribeKeyboardHeight` now defaults `immediate` to `true`, matching `useKeyboardHeight`. The callback runs once with the current keyboard height right after subscribing. Pass `immediate: false` to keep the previous behavior. `useKeyboardHeight` and `useAvoidKeyboard` are unaffected. + +```ts +// before: the callback waits for the first resize or scroll event +subscribeKeyboardHeight({ callback }); + +// after: the callback also runs once right after subscribing +subscribeKeyboardHeight({ callback }); + +// to keep the previous behavior +subscribeKeyboardHeight({ callback, immediate: false }); +``` diff --git a/.changeset/page-visibility-effect.md b/.changeset/page-visibility-effect.md new file mode 100644 index 00000000..c8094190 --- /dev/null +++ b/.changeset/page-visibility-effect.md @@ -0,0 +1,5 @@ +--- +'react-simplikit': minor +--- + +Add `usePageVisibilityEffect`, which runs a callback when the page visibility changes and pairs by name with `usePageVisibility`, which returns the same value as state. `useVisibilityEvent` is deprecated in favor of `usePageVisibilityEffect` and still works as before, since it is now the same function under its old name. This reverses the note from #435 that `useVisibilityEvent` is no longer deprecated. It is deprecated again because of the rename, not because it is going away: it keeps working throughout v1 and may be removed in a later major version. diff --git a/.changeset/petite-beans-fetch.md b/.changeset/petite-beans-fetch.md new file mode 100644 index 00000000..4c42c88d --- /dev/null +++ b/.changeset/petite-beans-fetch.md @@ -0,0 +1,5 @@ +--- +'react-simplikit': major +--- + +Replace the `edges` option in `useThrottle` and `useThrottledCallback` with optional `leading` and `trailing` booleans. Both default to `true`, preserving the behavior when options are omitted. diff --git a/.changeset/state-hook-returns.md b/.changeset/state-hook-returns.md new file mode 100644 index 00000000..3062b7c2 --- /dev/null +++ b/.changeset/state-hook-returns.md @@ -0,0 +1,19 @@ +--- +'react-simplikit': major +--- + +`useBooleanState`, `useStorageState` and `useCounter` now return `[value, actions]`, matching `useList`, `useMap` and `useSet`. The actions object keeps the same reference across renders. + +```ts +// before +const [isOpen, setTrue, setFalse, toggle] = useBooleanState(false); +const [token, setToken, refreshToken] = useStorageState('token'); +const { count, increment, decrement, reset, setCount } = useCounter(0); + +// after +const [isOpen, { setTrue, setFalse, toggle }] = useBooleanState(false); +const [token, { setValue, refresh }] = useStorageState('token'); +const [count, { increment, decrement, reset, setCount }] = useCounter(0); +``` + +`useToggle` keeps its `[value, toggle]` signature and now declares its return type, so React 18 consumers no longer see the React 19 `ActionDispatch` type. diff --git a/.changeset/timer-hook-options.md b/.changeset/timer-hook-options.md new file mode 100644 index 00000000..3105122d --- /dev/null +++ b/.changeset/timer-hook-options.md @@ -0,0 +1,17 @@ +--- +'react-simplikit': major +--- + +`useInterval` and `useTimeout` now take a single options object, with the callback as `onTick` and `onTimeout` and the delay as `delayMs`. `useInterval` no longer accepts a bare number as its delay. + +```ts +// before +useInterval(poll, 1000); +useInterval(poll, { delay: 1000, immediate: true }); +useTimeout(close, 3000); + +// after +useInterval({ onTick: poll, delayMs: 1000 }); +useInterval({ onTick: poll, delayMs: 1000, immediate: true }); +useTimeout({ onTimeout: close, delayMs: 3000 }); +``` diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index 82518e94..e27e85f0 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -10,6 +10,9 @@ on: - main - v1-base +env: + INTERNAL_HOOKS_PATH: 'packages/*/src/hooks/_internal/*' + jobs: quality: runs-on: ubuntu-latest @@ -227,6 +230,9 @@ jobs: - name: Check for JSDoc comments run: | for file in ${{ needs.check-changes.outputs.changed_files }}; do + case "$file" in + $INTERNAL_HOOKS_PATH) continue ;; + esac dir_name=$(basename $(dirname "$file")) base_name=$(basename $dir_name) implementation="$(dirname "$file")/$base_name.ts" @@ -279,6 +285,9 @@ jobs: - name: Check JSDoc structure run: | for file in ${{ needs.check-changes.outputs.changed_files }}; do + case "$file" in + $INTERNAL_HOOKS_PATH) continue ;; + esac directory=$(basename "$(dirname "$file")") filename=$(basename $file) filename="${filename%%.*}" @@ -357,6 +366,10 @@ jobs: continue fi + case "$impl_dir" in + $INTERNAL_HOOKS_PATH) continue ;; + esac + impl_name=$(basename "$impl_dir") impl_index="$impl_dir/index.ts" impl_file="" diff --git a/.scripts/commands/generateDocs/index.spec.ts b/.scripts/commands/generateDocs/index.spec.ts index 75cdcf4c..f0d2b5fc 100644 --- a/.scripts/commands/generateDocs/index.spec.ts +++ b/.scripts/commands/generateDocs/index.spec.ts @@ -677,4 +677,42 @@ export function useNoReturnValue() {}` expect(document).toContain('\nThis function does not return anything.'); }); + + it('refuses an export whose JSDoc is @deprecated, because it has no page', async () => { + await expect( + render( + 'useOld', + `/** + * @description + * \`useOld\` is the name \`useNew\` had before. + * + * @deprecated Use \`useNew\` instead. + */ +export const useOld = useNew;` + ) + ).rejects.toThrow('useOld is @deprecated, and a deprecated export has no documentation page'); + }); + + it('still renders a page when only an earlier comment, such as an options type, is @deprecated', async () => { + const document = await render( + 'useCurrent', + `type Options = { + /** @deprecated Use \`delay\` instead. */ + wait?: number; +}; + +/** + * @description + * \`useCurrent\` does something. + * + * @returns {void} + * + * @example + * useCurrent(); + */ +export function useCurrent(options: Options) {}` + ); + + expect(document.startsWith('# useCurrent\n\n`useCurrent` does something.\n')).toBe(true); + }); }); diff --git a/.scripts/commands/generateDocs/index.ts b/.scripts/commands/generateDocs/index.ts index 4d80822f..fd456000 100644 --- a/.scripts/commands/generateDocs/index.ts +++ b/.scripts/commands/generateDocs/index.ts @@ -1,4 +1,4 @@ -import { parse, Spec as OriginSpec, tokenizers } from 'comment-parser'; +import { parse, Spec as OriginSpec } from 'comment-parser'; import glob from 'fast-glob'; import * as fs from 'fs/promises'; import { Listr } from 'listr2'; @@ -6,6 +6,7 @@ import path from 'path'; import * as prettier from 'prettier'; import { getRootPath } from '../../utils/getRootPath.ts'; +import { parseOptions, readDeprecation, trimBlock } from '../../utils/jsdoc.ts'; import { generateSkill } from '../generateSkill/index.ts'; type Spec = Pick; @@ -23,10 +24,17 @@ const prettierConfig: prettier.Options = { * * `verifyDocs.ts` compares a committed page against this, so the formatting applied here has to be * the same formatting the page is written with — hence prettier runs inside, not at the call site. + * + * Throws for an export whose JSDoc is `@deprecated`: it has no page, and the page of the export + * that replaces it says what it was renamed from. */ export async function renderEnglishDoc(name: string, sourceFilePath: string): Promise { const documentPath = `${path.dirname(sourceFilePath)}/${name}.md`; - const docSource = await jsdocToMd(name, parseJSDoc(await fs.readFile(sourceFilePath, 'utf-8'))); + const source = await fs.readFile(sourceFilePath, 'utf-8'); + + assertHasPage(name, source); + + const docSource = await jsdocToMd(name, parseJSDoc(source)); return prettier.format(docSource, { ...(await prettier.resolveConfig(documentPath)), @@ -36,43 +44,50 @@ export async function renderEnglishDoc(name: string, sourceFilePath: string): Pr export async function generateDocs(names: string[]) { const tasks = new Listr([], { concurrent: 10 }); + const targets = names.map(name => [name, glob.sync(`**/${name}.ts*`, { cwd: getRootPath() })[0]]); + + // Checked before any task runs: the tasks below collect their errors instead of exiting, and a + // deprecated name has to fail the command without writing anything. + for (const [name, sourceFilePath] of targets) { + if (sourceFilePath !== undefined) { + assertHasPage(name, await fs.readFile(sourceFilePath, 'utf-8')); + } + } - names - .map(name => [name, glob.sync(`**/${name}.ts*`, { cwd: getRootPath() })[0]]) - .forEach(([name, sourceFilePath]) => { - const subCtx: { document?: string } = {}; - tasks.add([ - { - title: `Generate documents: ${sourceFilePath}`, - task: async (_, task) => - task.newListr<{ document?: string }>( - [ - { - title: `Convert JSDoc to markdown`, - task: async ctx => { - ctx.document = await renderEnglishDoc(name, sourceFilePath); - }, + targets.forEach(([name, sourceFilePath]) => { + const subCtx: { document?: string } = {}; + tasks.add([ + { + title: `Generate documents: ${sourceFilePath}`, + task: async (_, task) => + task.newListr<{ document?: string }>( + [ + { + title: `Convert JSDoc to markdown`, + task: async ctx => { + ctx.document = await renderEnglishDoc(name, sourceFilePath); }, - { - title: `Write English document`, - task: async ctx => { - const { document } = ctx; - - if (document != null) { - // Written already formatted: `.prettierignore`'s `src/hooks/**/*.md` is anchored to the - // repo root and never reaches packages/, so prettier (yarn fix, autofix.ci) reformats - // these pages later. `generateSkill()` below copies them, and an unformatted copy - // would drift from the page the moment prettier runs. - await fs.writeFile(`${path.dirname(sourceFilePath)}/${name}.md`, document); - } - }, + }, + { + title: `Write English document`, + task: async ctx => { + const { document } = ctx; + + if (document != null) { + // Written already formatted: `.prettierignore`'s `src/hooks/**/*.md` is anchored to the + // repo root and never reaches packages/, so prettier (yarn fix, autofix.ci) reformats + // these pages later. `generateSkill()` below copies them, and an unformatted copy + // would drift from the page the moment prettier runs. + await fs.writeFile(`${path.dirname(sourceFilePath)}/${name}.md`, document); + } }, - ], - { concurrent: false, ctx: subCtx, exitOnError: false } - ), - }, - ]); - }); + }, + ], + { concurrent: false, ctx: subCtx, exitOnError: false } + ), + }, + ]); + }); await tasks.run(); @@ -80,15 +95,13 @@ export async function generateDocs(names: string[]) { await generateSkill(); } -// These tags carry no name, but the stock name tokenizer still takes the description's first word -// as one ("An object…" became "object…"). Skipping it for them keeps the sentence whole. -const NAMELESS_TAGS = new Set(['returns', 'remarks', 'description']); -const skipNameForNamelessTags = (spec: OriginSpec) => (NAMELESS_TAGS.has(spec.tag) ? spec : tokenizers.name()(spec)); - -const parseOptions = (spacing: 'compact' | 'preserve') => ({ - spacing, - tokenizers: [tokenizers.tag(), tokenizers.type(spacing), skipNameForNamelessTags, tokenizers.description(spacing)], -}); +function assertHasPage(name: string, source: string) { + if (readDeprecation(source) != null) { + throw new Error( + `${name} is @deprecated, and a deprecated export has no documentation page — document the rename on its replacement's page instead` + ); + } +} function parseJSDoc(source: string) { const parsedComments = parse(source, parseOptions('compact')); @@ -284,18 +297,6 @@ ${remarks.length === 0 ? '' : `\n## Notes\n\n${remarks}\n`}`; const LIST_ITEM = /^[-*]\s/; -/** - * `@description` and `@remarks` are Markdown and go to the page as written: a line break inside a - * paragraph renders as a space, and fences, numbered and nested lists keep their meaning. - */ -function trimBlock(text: string) { - return text - .split('\n') - .map(line => line.trimEnd()) - .join('\n') - .trim(); -} - /** * The `@returns` intro lands in an HTML attribute where a line break becomes `
`, so the * source's wrapping is joined back into one line; a blank line still separates paragraphs. diff --git a/.scripts/commands/generateSkill/catalog.spec.ts b/.scripts/commands/generateSkill/catalog.spec.ts index af4cb54d..ac980a31 100644 --- a/.scripts/commands/generateSkill/catalog.spec.ts +++ b/.scripts/commands/generateSkill/catalog.spec.ts @@ -1,7 +1,7 @@ import assert from 'node:assert/strict'; import { describe, it } from 'vitest'; -import { extractDescription, getCategory, renderSkill } from './catalog.ts'; +import { extractDescription, getCategory, readCatalogNames, readDeprecatedNames, renderSkill } from './catalog.ts'; describe('getCategory', () => { it('derives the category from the export source path', () => { @@ -60,6 +60,7 @@ describe('renderSkill', () => { { name: 'isIOS', category: 'utils', description: 'Detects iOS.' }, { name: 'useToggle', category: 'hooks', description: 'Toggles a | boolean.' }, ], + deprecatedEntries: [], }); assert.equal( @@ -80,12 +81,85 @@ describe('renderSkill', () => { | --- | --- | | [\`isIOS\`](references/isIOS.md) | Detects iOS. | +## Learn more +` + ); + }); + + it('lists a deprecated entry under Deprecated as an unlinked row with its notice on one line', () => { + const rendered = renderSkill({ + template: '# Skill\n\n## Catalog\n\n\n\n## Learn more\n', + entries: [{ name: 'useNew', category: 'hooks', description: 'Does it.' }], + deprecatedEntries: [{ name: 'useOld', notice: 'Use `useNew` | not this.\nIt goes away\nlater.' }], + }); + + assert.equal( + rendered, + `# Skill + +## Catalog + +### hooks + +| Name | Description | +| --- | --- | +| [\`useNew\`](references/useNew.md) | Does it. | + +### Deprecated + +These still work but are kept only for backward compatibility. Do not use them in new code; each row names the replacement. + +| Name | Notice | +| --- | --- | +| \`useOld\` | Use \`useNew\` \\| not this. It goes away later. | + ## Learn more ` ); }); it('rejects a template without the placeholder', () => { - assert.throws(() => renderSkill({ template: '# Skill\n', entries: [] }), //); + assert.throws(() => renderSkill({ template: '# Skill\n', entries: [], deprecatedEntries: [] }), //); + }); +}); + +describe('readCatalogNames', () => { + it('reads the linked rows only, so a deprecated export is not counted as documented', () => { + const skill = renderSkill({ + template: '# Skill\n\n| Need | Use |\n| --- | --- |\n| Toggle | `useToggle` |\n\n\n', + entries: [ + { name: 'isIOS', category: 'utils', description: 'Detects iOS.' }, + { name: 'useNew', category: 'hooks', description: 'Does it.' }, + ], + deprecatedEntries: [{ name: 'useOld', notice: 'Use `useNew` instead.' }], + }); + + assert.deepEqual(readCatalogNames(skill), ['useNew', 'isIOS']); + }); +}); + +describe('readDeprecatedNames', () => { + it('reads only the names in the Deprecated table of the rendered catalog', () => { + const skill = renderSkill({ + template: + '# Skill\n\n| Need | Use |\n| --- | --- |\n| `useBefore` | Toggle |\n\n\n\n## Learn more\n\n| Name | Link |\n| --- | --- |\n| `useAfter` | Docs |\n', + entries: [ + { name: 'isIOS', category: 'utils', description: 'Detects iOS.' }, + { name: 'useNew', category: 'hooks', description: 'Does it.' }, + ], + deprecatedEntries: [{ name: 'useOld', notice: 'Use `useNew` instead.' }], + }); + + assert.deepEqual(readDeprecatedNames(skill), ['useOld']); + }); + + it('reports no deprecated names when the catalog has no Deprecated table', () => { + const skill = renderSkill({ + template: '\n', + entries: [{ name: 'useNew', category: 'hooks', description: 'Does it.' }], + deprecatedEntries: [], + }); + + assert.deepEqual(readDeprecatedNames(skill), []); }); }); diff --git a/.scripts/commands/generateSkill/catalog.ts b/.scripts/commands/generateSkill/catalog.ts index 16930698..07ef78db 100644 --- a/.scripts/commands/generateSkill/catalog.ts +++ b/.scripts/commands/generateSkill/catalog.ts @@ -8,13 +8,26 @@ export type CatalogEntry = { description: string; }; +/** A deprecated export: it has no documentation page, so it is listed by name with its `@deprecated` text. */ +export type DeprecatedEntry = { + name: string; + notice: string; +}; + type RenderSkillOptions = { template: string; entries: CatalogEntry[]; + deprecatedEntries: DeprecatedEntry[]; }; const CATALOG_PLACEHOLDER = ''; +const DEPRECATED_HEADING = '### Deprecated'; + +const CATALOG_ROW = /^\| \[`([^`]+)`\]\(references\/\1\.md\) \| .+ \|$/gm; + +const DEPRECATED_ROW = /^\| `([^`]+)` \| .+ \|$/gm; + /** Catalog heading for an export: the first segment of its source path, `./hooks/x/index.ts` → `hooks`. */ export function getCategory(sourcePath: string): Category { const category = sourcePath.replace(/^\.\//, '').split('/')[0]; @@ -66,10 +79,12 @@ export function extractDescription(markdown: string, name: string): string { } /** - * Fills the template's `` with one table per category. Categories keep the - * order of `CATEGORIES`; rows keep the order they are given (sorted by name upstream). + * Fills the template's `` with one table per category, then a Deprecated table + * when there are deprecated entries. Categories keep the order of `CATEGORIES`; rows keep the order + * they are given (sorted by name upstream). A Deprecated row links nowhere, because a deprecated + * export has no reference page. */ -export function renderSkill({ template, entries }: RenderSkillOptions): string { +export function renderSkill({ template, entries, deprecatedEntries }: RenderSkillOptions): string { if (!template.includes(CATALOG_PLACEHOLDER)) { throw new Error(`The skill template must contain ${CATALOG_PLACEHOLDER}`); } @@ -93,7 +108,46 @@ export function renderSkill({ template, entries }: RenderSkillOptions): string { ]; }); - return template.replace(CATALOG_PLACEHOLDER, sections.join('\n').trimEnd()); + const deprecatedSection = + deprecatedEntries.length === 0 + ? [] + : [ + DEPRECATED_HEADING, + '', + 'These still work but are kept only for backward compatibility. Do not use them in new code; each row names the replacement.', + '', + '| Name | Notice |', + '| --- | --- |', + // A wrapped `@deprecated` text is joined into one line so it fits the table cell. + ...deprecatedEntries.map( + ({ name, notice }) => `| \`${name}\` | ${escapeTableCell(notice.replace(/\s*\n\s*/g, ' '))} |` + ), + '', + ]; + + return template.replace(CATALOG_PLACEHOLDER, [...sections, ...deprecatedSection].join('\n').trimEnd()); +} + +/** Names of the catalog rows of a rendered `SKILL.md`: the rows that link `references/.md` from their first cell. */ +export function readCatalogNames(skill: string): string[] { + return [...skill.matchAll(CATALOG_ROW)].map(match => match[1]); +} + +/** + * Names in the Deprecated table of a rendered `SKILL.md`, which `renderSkill` writes last in the + * catalog; empty when there is none. The section ends at the next heading, so a table further + * down the template is not read as part of it. + */ +export function readDeprecatedNames(skill: string): string[] { + const deprecatedStart = skill.indexOf(`\n${DEPRECATED_HEADING}\n`); + + if (deprecatedStart === -1) { + return []; + } + + const [section] = skill.slice(deprecatedStart + `\n${DEPRECATED_HEADING}\n`.length).split(/^#+ /m); + + return [...section.matchAll(DEPRECATED_ROW)].map(match => match[1]); } function escapeTableCell(text: string): string { diff --git a/.scripts/commands/generateSkill/index.spec.ts b/.scripts/commands/generateSkill/index.spec.ts index bcd25135..20c8e1d5 100644 --- a/.scripts/commands/generateSkill/index.spec.ts +++ b/.scripts/commands/generateSkill/index.spec.ts @@ -43,6 +43,7 @@ export { useToggle } from './hooks/useToggle/index.ts'; true ); assert.equal(skill.includes(''), false); + assert.equal(skill.includes('### Deprecated'), false); assert.equal( await fs.readFile(path.join(outputDirectory, 'references', 'useToggle.md'), 'utf8'), '# useToggle\n\n`useToggle` flips a boolean. More text.\n\n## Interface\n' @@ -78,10 +79,71 @@ export { useToggle } from './hooks/useToggle/index.ts'; assert.equal((await fs.readFile(path.join(outputDirectory, 'SKILL.md'), 'utf8')).includes('isIOS'), false); }); + it('lists an export whose JSDoc is @deprecated under Deprecated, without a page or a reference', async () => { + const root = await writeFixtureRoot({ + index: ` +export { useNew, useOld } from './hooks/useNew/index.ts'; +`, + pages: { 'hooks/useNew/useNew.md': '# useNew\n\n`useNew` flips a boolean.\n' }, + sources: { + 'hooks/useNew/useNew.ts': 'export function useNew() {}\n', + 'hooks/useNew/useOld.ts': `/** + * @description + * \`useOld\` is the name \`useNew\` had before. + * + * @deprecated Use \`useNew\` instead. + */ +export const useOld = useNew; +`, + }, + }); + const outputDirectory = path.join(root, 'out'); + + await generateSkill({ root, outputDirectory }); + + const skill = await fs.readFile(path.join(outputDirectory, 'SKILL.md'), 'utf8'); + assert.equal( + skill.includes( + '### hooks\n\n| Name | Description |\n| --- | --- |\n| [`useNew`](references/useNew.md) | `useNew` flips a boolean. |\n\n### Deprecated\n' + ), + true + ); + assert.equal(skill.includes('| Name | Notice |\n| --- | --- |\n| `useOld` | Use `useNew` instead. |'), true); + assert.equal(skill.includes('references/useOld.md'), false); + assert.deepEqual(await fs.readdir(path.join(outputDirectory, 'references')), ['useNew.md']); + }); + + it('reads a component source from a .tsx file', async () => { + const root = await writeFixtureRoot({ + index: `export { Separated } from './components/Separated/index.ts';\n`, + pages: { 'components/Separated/Separated.md': '# Separated\n\n`Separated` joins children.\n' }, + sources: { 'components/Separated/Separated.tsx': 'export function Separated() {}\n' }, + }); + const outputDirectory = path.join(root, 'out'); + + await generateSkill({ root, outputDirectory }); + + assert.deepEqual(await fs.readdir(path.join(outputDirectory, 'references')), ['Separated.md']); + }); + + it('fails when an export has no source file next to its index', async () => { + const root = await writeFixtureRoot({ + index: `export { useToggle } from './hooks/useToggle/index.ts';\n`, + pages: { 'hooks/useToggle/useToggle.md': '# useToggle\n\n`useToggle` flips a boolean.\n' }, + sources: {}, + }); + + await assert.rejects( + generateSkill({ root, outputDirectory: path.join(root, 'out') }), + /useToggle has no source file/ + ); + }); + it('fails when an export has no documentation page', async () => { const root = await writeFixtureRoot({ index: `export { useToggle } from './hooks/useToggle/index.ts';\n`, pages: {}, + sources: { 'hooks/useToggle/useToggle.ts': 'export function useToggle() {}\n' }, }); await assert.rejects( @@ -91,18 +153,28 @@ export { useToggle } from './hooks/useToggle/index.ts'; }); }); -async function writeFixtureRoot({ index, pages }: { index: string; pages: Record }): Promise { +type FixtureRoot = { + index: string; + pages: Record; + /** Replaces the default: a `.ts` without JSDoc next to each page, which is how a current export looks. */ + sources?: Record; +}; + +async function writeFixtureRoot({ index, pages, sources }: FixtureRoot): Promise { const root = await fs.mkdtemp(path.join(os.tmpdir(), 'react-simplikit-skill-')); fixtureDirectories.push(root); const sourceDirectory = path.join(root, path.dirname(PACKAGE_INDEX_FILE)); + const pageSources = Object.fromEntries( + Object.keys(pages).map(pagePath => [pagePath.replace(/\.md$/, '.ts'), 'export {};\n']) + ); await fs.mkdir(sourceDirectory, { recursive: true }); await fs.writeFile(path.join(root, PACKAGE_INDEX_FILE), index); - for (const [relativePath, content] of Object.entries(pages)) { - const pagePath = path.join(sourceDirectory, relativePath); - await fs.mkdir(path.dirname(pagePath), { recursive: true }); - await fs.writeFile(pagePath, content); + for (const [relativePath, content] of Object.entries({ ...pages, ...(sources ?? pageSources) })) { + const filePath = path.join(sourceDirectory, relativePath); + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, content); } return root; diff --git a/.scripts/commands/generateSkill/index.ts b/.scripts/commands/generateSkill/index.ts index 222c8a82..8128fe58 100644 --- a/.scripts/commands/generateSkill/index.ts +++ b/.scripts/commands/generateSkill/index.ts @@ -3,8 +3,9 @@ import path from 'node:path'; import { collectPublicExportEntries } from '../../utils/collectPublicExports.ts'; import { getRootPath } from '../../utils/getRootPath.ts'; +import { readDeprecation } from '../../utils/jsdoc.ts'; -import { CatalogEntry, extractDescription, getCategory, renderSkill } from './catalog.ts'; +import { CatalogEntry, DeprecatedEntry, extractDescription, getCategory, renderSkill } from './catalog.ts'; export const SKILL_DIRECTORY = 'packages/plugin/skills/react-simplikit'; export const PACKAGE_INDEX_FILE = 'packages/react-simplikit/src/index.ts'; @@ -19,7 +20,11 @@ type GenerateSkillOptions = { /** * Writes the consumer-facing agent skill: `SKILL.md` (template + a catalog of every public export) * and `references/.md` (a verbatim copy of each export's English documentation page). - * The output depends only on `index.ts` and the pages, so re-running on unchanged sources changes nothing. + * The output depends only on `index.ts`, the pages and each export's `@deprecated` tag, so re-running + * on unchanged sources changes nothing. + * + * An export whose JSDoc is `@deprecated` has no page, so it is recognised from its source file. It + * gets no reference page and is listed in the Deprecated table instead of its category. */ export async function generateSkill({ root = getRootPath(), @@ -33,20 +38,41 @@ export async function generateSkill({ await fs.rm(referencesDirectory, { force: true, recursive: true }); await fs.mkdir(referencesDirectory, { recursive: true }); - const entries: CatalogEntry[] = await Promise.all( + const publicExports: Array<{ entry: CatalogEntry } | { deprecatedEntry: DeprecatedEntry }> = await Promise.all( (await collectPublicExportEntries(indexFilePath)).map(async ({ name, sourcePath }) => { - const documentPath = path.join(packageSourceDirectory, path.dirname(sourcePath), `${name}.md`); - const markdown = await readDocumentationPage(documentPath, name); + const exportDirectory = path.join(packageSourceDirectory, path.dirname(sourcePath)); + const notice = readDeprecation(await readExportSource(exportDirectory, name)); + + if (notice != null) { + return { deprecatedEntry: { name, notice } }; + } + + const markdown = await readDocumentationPage(path.join(exportDirectory, `${name}.md`), name); await fs.writeFile(path.join(referencesDirectory, `${name}.md`), markdown); - return { name, category: getCategory(sourcePath), description: extractDescription(markdown, name) }; + return { entry: { name, category: getCategory(sourcePath), description: extractDescription(markdown, name) } }; }) ); + const entries = publicExports.flatMap(item => ('entry' in item ? [item.entry] : [])); + const deprecatedEntries = publicExports.flatMap(item => ('deprecatedEntry' in item ? [item.deprecatedEntry] : [])); const template = await fs.readFile(TEMPLATE_FILE, 'utf8'); - await fs.writeFile(path.join(outputDirectory, 'SKILL.md'), renderSkill({ template, entries })); + await fs.writeFile(path.join(outputDirectory, 'SKILL.md'), renderSkill({ template, entries, deprecatedEntries })); +} + +/** The file an export is declared in sits next to the `index.ts` that re-exports it, named after the export. */ +async function readExportSource(exportDirectory: string, name: string): Promise { + for (const extension of ['ts', 'tsx']) { + try { + return await fs.readFile(path.join(exportDirectory, `${name}.${extension}`), 'utf8'); + } catch { + continue; + } + } + + throw new Error(`${name} has no source file at ${path.join(exportDirectory, name)}.ts(x)`); } async function readDocumentationPage(documentPath: string, name: string): Promise { diff --git a/.scripts/commands/generateSkill/template.md b/.scripts/commands/generateSkill/template.md index b4b1ad5e..1df730f9 100644 --- a/.scripts/commands/generateSkill/template.md +++ b/.scripts/commands/generateSkill/template.md @@ -43,7 +43,7 @@ Backticks in this table mark catalog entries only. | Long press / double click | `useLongPress` / `useDoubleClick` | | Element enters or leaves the viewport | `useIntersectionObserver` | | How long an element stayed visible (impression tracking) | `useImpressionRef` / `ImpressionArea` | -| React to tab / page visibility changes with a callback | `useVisibilityEvent` | +| React to tab / page visibility changes with a callback | `usePageVisibilityEffect` | | Read the current tab / page visibility as state | `usePageVisibility` | | Device location | `useGeolocation` | | Combine multiple refs into one | `mergeRefs` | diff --git a/.scripts/utils/assertLlmsOutput.ts b/.scripts/utils/assertLlmsOutput.ts index 3d3d6fb4..5e25a0a7 100644 --- a/.scripts/utils/assertLlmsOutput.ts +++ b/.scripts/utils/assertLlmsOutput.ts @@ -2,6 +2,7 @@ import assert from 'node:assert/strict'; import * as fs from 'node:fs/promises'; import path from 'node:path'; +import { collectDeprecatedExports } from './collectDeprecatedExports.ts'; import { collectPublicExports } from './collectPublicExports.ts'; type AssertLlmsOutputOptions = { @@ -18,8 +19,8 @@ const ALLOWED_LINK = /^https:\/\/react-simplikit\.slash\.page\/(?:(?:hooks|compo /** * Checks the llms outputs vitepress-plugin-llms wrote into a docs build: - * every public export has a page in llms.txt, localized copies are not listed, - * and the per-page Markdown exists. + * every public export that is not deprecated has a page in llms.txt, localized copies are not + * listed, and the per-page Markdown exists. */ export async function assertLlmsOutput({ buildOutputDirectory, root }: AssertLlmsOutputOptions): Promise { const llmsTxt = await fs.readFile(path.join(buildOutputDirectory, 'llms.txt'), 'utf8'); @@ -35,11 +36,18 @@ export async function assertLlmsOutput({ buildOutputDirectory, root }: AssertLlm ); } - for (const name of await collectPublicExports(path.join(root, PACKAGE_INDEX_FILE))) { + const publicExports = await collectPublicExports(path.join(root, PACKAGE_INDEX_FILE)); + const deprecatedExports = new Set(await collectDeprecatedExports(root, publicExports)); + + // A deprecated export has no page. Its old URL only serves a copy of its replacement's Markdown, + // which must stay out of the listing or an agent would read the same page under two names. + for (const name of publicExports) { assert.equal( links.some(link => link.endsWith(`/${name}.md`)), - true, - `llms.txt must link the documentation page of ${name}` + !deprecatedExports.has(name), + deprecatedExports.has(name) + ? `llms.txt must not link a page for ${name}, which is @deprecated` + : `llms.txt must link the documentation page of ${name}` ); } diff --git a/.scripts/utils/collectDeprecatedExports.spec.ts b/.scripts/utils/collectDeprecatedExports.spec.ts new file mode 100644 index 00000000..66f468e1 --- /dev/null +++ b/.scripts/utils/collectDeprecatedExports.spec.ts @@ -0,0 +1,46 @@ +import assert from 'node:assert/strict'; +import * as fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, it } from 'vitest'; + +import { collectDeprecatedExports } from './collectDeprecatedExports.ts'; + +const fixtureDirectories: string[] = []; + +afterEach(async () => { + await Promise.all(fixtureDirectories.splice(0).map(directory => fs.rm(directory, { force: true, recursive: true }))); +}); + +describe('collectDeprecatedExports', () => { + it('keeps the exports whose own JSDoc is @deprecated, wherever their source sits', async () => { + const root = await writeFixtureRoot({ + 'hooks/useNew/useNew.ts': '/**\n * @description Does it.\n */\nexport function useNew() {}\n', + 'hooks/useNew/useNew.test.ts': '/**\n * @deprecated Use `nothing` instead.\n */\n', + 'hooks/useNew/useOld.ts': + '/**\n * @description The old name.\n * @deprecated Use `useNew` instead.\n */\nexport const useOld = useNew;\n', + 'components/Old/Old.tsx': '/**\n * @deprecated Use `New` instead.\n */\nexport function Old() {}\n', + }); + + assert.deepEqual(await collectDeprecatedExports(root, ['Old', 'useNew', 'useOld']), ['Old', 'useOld']); + }); + + it('fails on an export that has no source file', async () => { + const root = await writeFixtureRoot({}); + + await assert.rejects(collectDeprecatedExports(root, ['useGone']), /useGone is exported but has no source file/); + }); +}); + +async function writeFixtureRoot(sources: Record): Promise { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'react-simplikit-deprecated-')); + fixtureDirectories.push(root); + + for (const [relativePath, content] of Object.entries(sources)) { + const filePath = path.join(root, 'packages/react-simplikit/src', relativePath); + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, content); + } + + return root; +} diff --git a/.scripts/utils/collectDeprecatedExports.ts b/.scripts/utils/collectDeprecatedExports.ts new file mode 100644 index 00000000..ad10c247 --- /dev/null +++ b/.scripts/utils/collectDeprecatedExports.ts @@ -0,0 +1,30 @@ +import glob from 'fast-glob'; +import assert from 'node:assert/strict'; +import * as fs from 'node:fs/promises'; + +import { readDeprecation } from './jsdoc.ts'; + +/** + * The given public exports whose own JSDoc is `@deprecated`, in the order given. Such an export has + * no documentation page, so every check that expects one page per export leaves these out. + * + * Each source is found by name anywhere under `src/`, not through the path `generateSkill` derives + * from `index.ts`, so the verify scripts do not repeat a mistake the generator makes. + */ +export async function collectDeprecatedExports(root: string, publicExports: string[]): Promise { + const deprecations = await Promise.all( + publicExports.map(async name => { + const [sourceFilePath] = await glob(`packages/react-simplikit/src/**/${name}.ts?(x)`, { + absolute: true, + cwd: root, + ignore: ['**/*.spec.*', '**/*.test.*'], + }); + + assert.ok(sourceFilePath, `${name} is exported but has no source file`); + + return readDeprecation(await fs.readFile(sourceFilePath, 'utf8')); + }) + ); + + return publicExports.filter((_, index) => deprecations[index] != null); +} diff --git a/.scripts/utils/jsdoc.spec.ts b/.scripts/utils/jsdoc.spec.ts new file mode 100644 index 00000000..bf2c76c4 --- /dev/null +++ b/.scripts/utils/jsdoc.spec.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from 'vitest'; + +import { readDeprecation } from './jsdoc.ts'; + +describe('readDeprecation', () => { + it('reads @deprecated from the last comment in the file, which documents the export', () => { + expect( + readDeprecation(`/** + * @description Does something. + * @deprecated Use \`useNew\` instead. + */ +export function useOld() {}`) + ).toBe('Use `useNew` instead.'); + }); + + it('ignores @deprecated on an earlier comment, such as an options type', () => { + expect( + readDeprecation(`type Options = { + /** @deprecated Use \`delay\` instead. */ + wait?: number; +}; + +/** + * @description Does something. + */ +export function useCurrent(options: Options) {}`) + ).toBeUndefined(); + }); + + it('throws on a @deprecated that does not say what to use instead', () => { + expect(() => + readDeprecation(`/** + * @description Does something. + * @deprecated + */ +export function useOld() {}`) + ).toThrow('@deprecated must name the replacement in backticks'); + }); + + it('throws on a @deprecated that names no replacement', () => { + expect(() => + readDeprecation(`/** + * @description Does something. + * @deprecated Will be removed in v2. + */ +export function useOld() {}`) + ).toThrow('@deprecated must name the replacement in backticks'); + }); + + it('throws on a @deprecated that opens with {@link}, which the parser reads as a type', () => { + expect(() => + readDeprecation(`/** + * @description Does something. + * @deprecated {@link useNew} instead. + */ +export function useOld() {}`) + ).toThrow('@deprecated must name the replacement in backticks'); + }); + + it('throws on a @deprecated that names the replacement with {@link}, which the skill catalog would show as written', () => { + expect(() => + readDeprecation(`/** + * @description Does something. + * @deprecated Use {@link useNew} instead. + */ +export function useOld() {}`) + ).toThrow('@deprecated must name the replacement in backticks'); + }); +}); diff --git a/.scripts/utils/jsdoc.ts b/.scripts/utils/jsdoc.ts new file mode 100644 index 00000000..afbc0454 --- /dev/null +++ b/.scripts/utils/jsdoc.ts @@ -0,0 +1,52 @@ +import { parse, Spec, tokenizers } from 'comment-parser'; + +// These tags carry no name, but the stock name tokenizer still takes the description's first word +// as one ("An object…" became "object…"). Skipping it for them keeps the sentence whole. +const NAMELESS_TAGS = new Set(['returns', 'remarks', 'description', 'deprecated']); +const skipNameForNamelessTags = (spec: Spec) => (NAMELESS_TAGS.has(spec.tag) ? spec : tokenizers.name()(spec)); + +export const parseOptions = (spacing: 'compact' | 'preserve') => ({ + spacing, + tokenizers: [tokenizers.tag(), tokenizers.type(spacing), skipNameForNamelessTags, tokenizers.description(spacing)], +}); + +/** + * `@description` and `@remarks` are Markdown and go to the page as written: a line break inside a + * paragraph renders as a space, and fences, numbered and nested lists keep their meaning. + */ +export function trimBlock(text: string) { + return text + .split('\n') + .map(line => line.trimEnd()) + .join('\n') + .trim(); +} + +/** + * The `@deprecated` text of an export's own comment (the last one in its source file), or + * `undefined` when there is none. A `@deprecated` on another comment in the file, such as an + * options type, does not mark the export itself. + * + * A deprecated export has no documentation page, so this is the only place the docs and skill + * tooling learn about it from. + */ +export function readDeprecation(source: string): string | undefined { + const tag = parse(source, parseOptions('preserve')) + .at(-1) + ?.tags.find(tag => tag.tag === 'deprecated'); + + if (tag == null) { + return undefined; + } + + const text = trimBlock(tag.description); + const namesReplacement = /`[^`]+`/.test(text); + + if (!namesReplacement) { + throw new Error( + '@deprecated must name the replacement in backticks, e.g. "@deprecated Use `newName` instead.", not with {@link newName}' + ); + } + + return text; +} diff --git a/.scripts/verifyDocs.ts b/.scripts/verifyDocs.ts index 2b09b736..96d229b3 100644 --- a/.scripts/verifyDocs.ts +++ b/.scripts/verifyDocs.ts @@ -5,14 +5,17 @@ import path from 'node:path'; import { renderEnglishDoc } from './commands/generateDocs/index.ts'; import { PACKAGE_INDEX_FILE } from './commands/generateSkill/index.ts'; +import { collectDeprecatedExports } from './utils/collectDeprecatedExports.ts'; import { collectPublicExports } from './utils/collectPublicExports.ts'; import { getRootPath } from './utils/getRootPath.ts'; const root = getRootPath(); const publicExports = await collectPublicExports(path.join(root, PACKAGE_INDEX_FILE)); +const deprecatedExports = new Set(await collectDeprecatedExports(root, publicExports)); // The English pages are generated from JSDoc, and `verifySkill.ts` checks the skill against those // pages — so a page that drifts from its source carries the drift into the skill unnoticed. +// A deprecated export is the exception: it has no page, and its replacement's page names it. for (const name of publicExports) { const [sourceFilePath] = await glob(`packages/react-simplikit/src/**/${name}.ts?(x)`, { absolute: true, @@ -24,6 +27,22 @@ for (const name of publicExports) { const documentPath = path.join(path.dirname(sourceFilePath), `${name}.md`); + if (deprecatedExports.has(name)) { + // The sidebar, the reference index and the legacy redirects list one entry per folder, so an + // alias in a folder of its own would be listed there with no page behind it. + assert.notEqual( + path.basename(path.dirname(sourceFilePath)), + name, + `${name} is @deprecated, so it cannot keep a folder of its own — move it into the folder of the export that replaces it` + ); + assert.equal( + await pathExists(documentPath), + false, + `${path.relative(root, documentPath)} must not exist: ${name} is @deprecated, and a deprecated export has no documentation page — delete it and its translations, and say what was renamed on the replacement's page` + ); + continue; + } + assert.equal( await fs.readFile(documentPath, 'utf8'), await renderEnglishDoc(name, sourceFilePath), @@ -57,3 +76,12 @@ for (const page of handWrittenPages) { } } } + +async function pathExists(target: string): Promise { + try { + await fs.access(target); + return true; + } catch { + return false; + } +} diff --git a/.scripts/verifyDocsI18n.ts b/.scripts/verifyDocsI18n.ts index 9e77b2e4..f86a91f6 100644 --- a/.scripts/verifyDocsI18n.ts +++ b/.scripts/verifyDocsI18n.ts @@ -17,8 +17,11 @@ import { } from '../.vitepress/locales.mts'; import { packageSourceRoot } from '../.vitepress/shared.mts'; +import { PACKAGE_INDEX_FILE } from './commands/generateSkill/index.ts'; import { assertLlmsOutput } from './utils/assertLlmsOutput.ts'; import { assertSeoOutput } from './utils/assertSeoOutput.ts'; +import { collectDeprecatedExports } from './utils/collectDeprecatedExports.ts'; +import { collectPublicExports } from './utils/collectPublicExports.ts'; import { execWithOutput } from './utils/execWithOutput.ts'; import { getRootPath } from './utils/getRootPath.ts'; @@ -134,16 +137,17 @@ try { async directory => ( await fs.readdir(path.join(root, 'packages/react-simplikit/src', directory), { withFileTypes: true }) - ).filter(entry => entry.isDirectory()).length + ).filter(entry => entry.isDirectory() && entry.name !== '_internal').length ) ) ).reduce((total, count) => total + count, 0); const guidePageCount = 11; + const renamedPageRedirectCount = 4; assert.equal( stubs.length, - (guidePageCount + referenceItemCount) * localeCount, - 'the legacy redirect set must cover every pre-flattening URL across all locales' + (guidePageCount + referenceItemCount + renamedPageRedirectCount) * localeCount, + 'the legacy redirect set must cover every pre-flattening and renamed URL across all locales' ); // Spot-check the shape itself: a renamed `from` would keep the count intact and @@ -155,10 +159,39 @@ try { 'mobile/roadmap.html', 'ko/core/utils/mergeRefs.html', 'ja/mobile/utils/isServer.html', + 'hooks/useCallbackOncePerRender.html', + 'ko/core/hooks/useCallbackOncePerRender.html', + 'hooks/useVisibilityEvent.html', + 'ko/core/hooks/useVisibilityEvent.html', ]) { assert.equal(stubPaths.has(expected), true, `the legacy URL ${expected} must keep a redirect`); } + // A deprecated export lost its page, so forgetting its redirect would turn the published URL into a 404. + const publicExports = await collectPublicExports(path.join(root, PACKAGE_INDEX_FILE)); + + for (const name of await collectDeprecatedExports(root, publicExports)) { + assert.equal( + ['hooks', 'components', 'utils'].some(category => stubPaths.has(`${category}/${name}.html`)), + true, + `${name} is @deprecated and has no page, so its old URL must redirect — add it to the renamed pages in legacyRedirects.mts` + ); + } + + // A renamed export has no page of its own, so its old URLs must land on the page of its new name. + for (const [oldName, newName] of [ + ['useCallbackOncePerRender', 'useCallbackOnce'], + ['useVisibilityEvent', 'usePageVisibilityEffect'], + ]) { + for (const from of [`hooks/${oldName}.html`, `ja/core/hooks/${oldName}.html`]) { + assert.equal( + stubs.find(stub => stub.from === from)?.to, + from.replace('core/', '').replace(oldName, newName), + `${from} must redirect to the ${newName} page` + ); + } + } + for (const { from, to } of stubs) { const stub = await fs.readFile(path.join(buildOutputDirectory, from), 'utf8'); assert.match(stub, /http-equiv="refresh"/, `${from} must redirect`); diff --git a/.scripts/verifySkill.ts b/.scripts/verifySkill.ts index 94c57411..9f40499a 100644 --- a/.scripts/verifySkill.ts +++ b/.scripts/verifySkill.ts @@ -4,7 +4,9 @@ import * as fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; +import { readCatalogNames, readDeprecatedNames } from './commands/generateSkill/catalog.ts'; import { generateSkill, PACKAGE_INDEX_FILE, SKILL_DIRECTORY } from './commands/generateSkill/index.ts'; +import { collectDeprecatedExports } from './utils/collectDeprecatedExports.ts'; import { collectPublicExports } from './utils/collectPublicExports.ts'; import { getRootPath } from './utils/getRootPath.ts'; @@ -64,11 +66,21 @@ for (const [label, contents] of [ } const publicExports = await collectPublicExports(path.join(root, PACKAGE_INDEX_FILE)); -const catalogNames = [...skill.matchAll(/^\| \[`([^`]+)`\]\(references\/\1\.md\) \| .+ \|$/gm)].map(match => match[1]); - -assert.deepEqual(catalogNames.toSorted(), publicExports, 'the catalog must list exactly the public exports'); - -for (const name of publicExports) { +const deprecatedExports = await collectDeprecatedExports(root, publicExports); +const documentedExports = publicExports.filter(name => !deprecatedExports.includes(name)); + +assert.deepEqual( + readCatalogNames(skill).toSorted(), + documentedExports, + 'the catalog must link exactly the public exports that are not @deprecated — run `yarn skill:gen`' +); +assert.deepEqual( + readDeprecatedNames(skill).toSorted(), + deprecatedExports, + 'the Deprecated section must list exactly the exports whose JSDoc is @deprecated — run `yarn skill:gen`' +); + +for (const name of documentedExports) { const page = await fs.readFile(path.join(skillDirectory, 'references', `${name}.md`), 'utf8'); // References are one level deep: a page that links to another local page would be read as a dead link. @@ -84,25 +96,16 @@ for (const name of publicExports) { ); } -// The skill has no "Deprecated" section by design; a deprecated export would be listed as if it were current. -const sourceFiles = await glob('packages/react-simplikit/src/**/*.{ts,tsx}', { - cwd: root, - ignore: ['**/*.spec.*', '**/*.test.*'], -}); - -for (const file of sourceFiles) { - assert.equal( - (await fs.readFile(path.join(root, file), 'utf8')).includes('@deprecated'), - false, - `${file} is @deprecated, but the skill catalog has no way to say so — un-deprecate it or add a Deprecated section to the template` - ); -} - // The hand-written table may only name catalog entries, or it drifts from the source. const commonNeeds = skill.slice(skill.indexOf('## Common needs'), skill.indexOf('## Workflow')); for (const [, token] of commonNeeds.matchAll(/`([^`]+)`/g)) { assert.equal(publicExports.includes(token), true, `"${token}" in the Common needs table is not a public export`); + assert.equal( + deprecatedExports.includes(token), + false, + `"${token}" in the Common needs table is @deprecated — name its replacement in template.md` + ); } async function readVerifiedSkill(directory: string, name: string): Promise { diff --git a/.vitepress/libs/legacyRedirects.mts b/.vitepress/libs/legacyRedirects.mts index 2cb4b4ac..f3b2a4bc 100644 --- a/.vitepress/libs/legacyRedirects.mts +++ b/.vitepress/libs/legacyRedirects.mts @@ -43,8 +43,17 @@ const RETIRED_MOBILE_PAGES = new Set([ 'subscribeKeyboardHeight', ]); +// A renamed export keeps no page under its old name, so its old URLs point at the new page: the +// current one and the pre-flattening one its folder used to produce. +const RENAMED_REFERENCE_REDIRECTS: RedirectPair[] = [ + { from: 'hooks/useCallbackOncePerRender.html', to: 'hooks/useCallbackOnce.html' }, + { from: 'core/hooks/useCallbackOncePerRender.html', to: 'hooks/useCallbackOnce.html' }, + { from: 'hooks/useVisibilityEvent.html', to: 'hooks/usePageVisibilityEffect.html' }, + { from: 'core/hooks/useVisibilityEvent.html', to: 'hooks/usePageVisibilityEffect.html' }, +]; + export function collectLegacyRedirects(): RedirectPair[] { - return [...GUIDE_REDIRECTS, ...collectReferenceRedirects()].flatMap(pair => [ + return [...GUIDE_REDIRECTS, ...collectReferenceRedirects(), ...RENAMED_REFERENCE_REDIRECTS].flatMap(pair => [ pair, ...localeDirectories.map(locale => ({ from: `${locale}/${pair.from}`, to: `${locale}/${pair.to}` })), ]); @@ -52,10 +61,12 @@ export function collectLegacyRedirects(): RedirectPair[] { function collectReferenceRedirects(): RedirectPair[] { return ['hooks', 'components', 'utils'].flatMap(category => - listDirectories(path.join(packageSourceRoot, category)).map(name => ({ - from: `${RETIRED_MOBILE_PAGES.has(name) ? 'mobile' : 'core'}/${category}/${name}.html`, - to: `${category}/${name}.html`, - })) + listDirectories(path.join(packageSourceRoot, category)) + .filter(name => name !== '_internal') + .map(name => ({ + from: `${RETIRED_MOBILE_PAGES.has(name) ? 'mobile' : 'core'}/${category}/${name}.html`, + to: `${category}/${name}.html`, + })) ); } diff --git a/.vitepress/shared.mts b/.vitepress/shared.mts index e369dac6..12c4e6c5 100644 --- a/.vitepress/shared.mts +++ b/.vitepress/shared.mts @@ -10,6 +10,6 @@ export const docsRoot = path.join(projectRoot, 'docs'); export function listDirectories(directory: string): string[] { return fs .readdirSync(directory, { withFileTypes: true }) - .filter(entry => entry.isDirectory()) + .filter(entry => entry.isDirectory() && entry.name !== '_internal') .map(entry => entry.name); } diff --git a/AGENTS.md b/AGENTS.md index 4e92374c..119d49b9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -116,9 +116,9 @@ Never initialize state with browser API calls (causes hydration mismatch). ### Hook Return Value Convention - **Single value**: `useDebounce(value, delay): T` -- **Tuple** (2 items): `useToggle(init): [boolean, () => void]` -- **Object** (3+ items): `usePagination(): { page, nextPage, prevPage }` -- **Object** also when the shape is expected to grow: `useKeyboardHeight(): { keyboardHeight }` +- **Tuple** (state + one action): `useToggle(init): [boolean, () => void]` +- **Tuple with an actions object** (state + two or more actions): `useBooleanState(init): [boolean, { setTrue, setFalse, toggle }]`, `useList`, `useCounter` +- **Object** for results that are not state + actions, and whenever the shape is expected to grow: browser measurements such as `useKeyboardHeight(): { keyboardHeight }` ## Testing diff --git a/CLAUDE.md b/CLAUDE.md index 2809c868..7332c8fd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -89,9 +89,9 @@ Never initialize state with browser API calls (causes hydration mismatch). ### Hook Return Value Convention - **Single value**: `useDebounce(value, delay): T` -- **Tuple** (state + action, 2 items): `useToggle(init): [boolean, () => void]` -- **Object** (3+ items): `usePagination(): { page, nextPage, prevPage }` -- **Object** also when the shape is expected to grow: browser measurements such as `useKeyboardHeight(): { keyboardHeight }` +- **Tuple** (state + one action): `useToggle(init): [boolean, () => void]` +- **Tuple with an actions object** (state + two or more actions): `useBooleanState(init): [boolean, { setTrue, setFalse, toggle }]`, `useList`, `useCounter` +- **Object** for results that are not state + actions, and whenever the shape is expected to grow: browser measurements such as `useKeyboardHeight(): { keyboardHeight }` ### Performance Patterns diff --git a/docs/es/use-cases.md b/docs/es/use-cases.md index e99627e7..76bebfc2 100644 --- a/docs/es/use-cases.md +++ b/docs/es/use-cases.md @@ -84,7 +84,7 @@ Usa [useDebouncedValue](/es/hooks/useDebouncedValue) cuando necesites un valor p - `useStorageState` usa `defaultValue` para la instantánea del servidor y la hidratación, y luego lee el almacenamiento del navegador en el cliente. Al desmontarse, elimina los listeners de almacenamiento. -- `useDebounce` cancela las llamadas pendientes cuando el componente se desmonta o cambian `wait`, `leading` o `trailing`. No cancela una solicitud de red que ya haya comenzado; tu aplicación debe gestionar la cancelación o las respuestas obsoletas. +- `useDebounce` cancela las llamadas pendientes cuando el componente se desmonta o cambian `debounceMs`, `leading` o `trailing`. No cancela una solicitud de red que ya haya comenzado; tu aplicación debe gestionar la cancelación o las respuestas obsoletas. - Las mediciones del navegador pueden cambiar después del montaje. Consulta los valores iniciales y las restricciones de plataforma en [Web móvil](/es/mobile-web) y en la referencia de cada API. diff --git a/docs/ja/use-cases.md b/docs/ja/use-cases.md index 76841681..dc88a871 100644 --- a/docs/ja/use-cases.md +++ b/docs/ja/use-cases.md @@ -84,7 +84,7 @@ export function FruitSearch() { - `useStorageState` はサーバースナップショットと hydration に `defaultValue` を使い、その後クライアントでブラウザストレージを読み取ります。アンマウント時にストレージのリスナーを解除します。 -- `useDebounce` はコンポーネントのアンマウント時、または `wait`、`leading`、`trailing` の変更時に保留中の呼び出しをキャンセルします。開始済みのネットワークリクエストはキャンセルしないため、リクエストの中止や古いレスポンスの処理はアプリケーション側で行う必要があります。 +- `useDebounce` はコンポーネントのアンマウント時、または `debounceMs`、`leading`、`trailing` の変更時に保留中の呼び出しをキャンセルします。開始済みのネットワークリクエストはキャンセルしないため、リクエストの中止や古いレスポンスの処理はアプリケーション側で行う必要があります。 - ブラウザの測定値はマウント後に変わることがあります。初期値とプラットフォームの制約は [モバイル Web](/ja/mobile-web) と各 API のリファレンスで確認してください。 diff --git a/docs/ko/use-cases.md b/docs/ko/use-cases.md index d3440f9c..a48c819b 100644 --- a/docs/ko/use-cases.md +++ b/docs/ko/use-cases.md @@ -84,7 +84,7 @@ export function FruitSearch() { - `useStorageState`는 서버 스냅샷과 hydration에 `defaultValue`를 사용한 뒤 클라이언트에서 브라우저 저장소를 읽어요. 언마운트 시 저장소 리스너를 제거해요. -- `useDebounce`는 컴포넌트가 언마운트되거나 `wait`, `leading`, `trailing`이 바뀌면 대기 중인 호출을 취소해요. 이미 시작한 네트워크 요청은 취소하지 않으므로 요청 취소나 오래된 응답 처리는 애플리케이션에서 담당해야 해요. +- `useDebounce`는 컴포넌트가 언마운트되거나 `debounceMs`, `leading`, `trailing`이 바뀌면 대기 중인 호출을 취소해요. 이미 시작한 네트워크 요청은 취소하지 않으므로 요청 취소나 오래된 응답 처리는 애플리케이션에서 담당해야 해요. - 브라우저 측정값은 마운트 후에 바뀔 수 있어요. 초기값과 플랫폼 제약은 [모바일 웹](/ko/mobile-web) 가이드와 개별 API 레퍼런스에서 확인하세요. diff --git a/docs/use-cases.md b/docs/use-cases.md index 8e69873b..c2bd686e 100644 --- a/docs/use-cases.md +++ b/docs/use-cases.md @@ -84,7 +84,7 @@ Use [useDebouncedValue](/hooks/useDebouncedValue) when you need a value for rend - `useStorageState` uses `defaultValue` for the server snapshot and hydration, then reads browser storage on the client. Its storage listeners are removed on unmount. -- `useDebounce` cancels pending calls when the component unmounts or when `wait`, `leading`, or `trailing` changes. It does not cancel a network request that has already started; the application must handle request cancellation or stale responses. +- `useDebounce` cancels pending calls when the component unmounts or when `debounceMs`, `leading`, or `trailing` changes. It does not cancel a network request that has already started; the application must handle request cancellation or stale responses. - Browser measurements can change after mount. Check the initial values and platform constraints in [Mobile Web](/mobile-web) and the individual API reference. diff --git a/docs/zh-Hans/use-cases.md b/docs/zh-Hans/use-cases.md index 08782da5..be2e3675 100644 --- a/docs/zh-Hans/use-cases.md +++ b/docs/zh-Hans/use-cases.md @@ -84,7 +84,7 @@ export function FruitSearch() { - `useStorageState` 使用 `defaultValue` 作为服务端快照和 hydration 的值,随后在客户端读取浏览器存储。卸载时会移除存储监听器。 -- `useDebounce` 在组件卸载或 `wait`、`leading`、`trailing` 发生变化时取消待执行的调用,但不会取消已经开始的网络请求。应用需要处理请求取消或过期响应。 +- `useDebounce` 在组件卸载或 `debounceMs`、`leading`、`trailing` 发生变化时取消待执行的调用,但不会取消已经开始的网络请求。应用需要处理请求取消或过期响应。 - 浏览器测量值可能在挂载后发生变化。请在[移动 Web](/zh-Hans/mobile-web) 指南和各 API 参考文档中查看初始值及平台限制。 diff --git a/packages/plugin/skills/react-simplikit/SKILL.md b/packages/plugin/skills/react-simplikit/SKILL.md index 9bdc2403..86e9039b 100644 --- a/packages/plugin/skills/react-simplikit/SKILL.md +++ b/packages/plugin/skills/react-simplikit/SKILL.md @@ -43,7 +43,7 @@ Backticks in this table mark catalog entries only. | Long press / double click | `useLongPress` / `useDoubleClick` | | Element enters or leaves the viewport | `useIntersectionObserver` | | How long an element stayed visible (impression tracking) | `useImpressionRef` / `ImpressionArea` | -| React to tab / page visibility changes with a callback | `useVisibilityEvent` | +| React to tab / page visibility changes with a callback | `usePageVisibilityEffect` | | Read the current tab / page visibility as state | `usePageVisibility` | | Device location | `useGeolocation` | | Combine multiple refs into one | `mergeRefs` | @@ -76,12 +76,13 @@ Backticks in this table mark catalog entries only. | [`useAvoidKeyboard`](references/useAvoidKeyboard.md) | `useAvoidKeyboard` is a React hook that helps fixed-bottom elements avoid the on-screen keyboard. | | [`useBodyScrollLock`](references/useBodyScrollLock.md) | `useBodyScrollLock` is a React hook that locks body scroll while the component is mounted. | | [`useBooleanState`](references/useBooleanState.md) | `useBooleanState` is a React hook that simplifies managing a boolean state. | -| [`useCallbackOncePerRender`](references/useCallbackOncePerRender.md) | `useCallbackOncePerRender` is a React hook that ensures a callback function is executed only once, regardless of how many times it's called. | +| [`useCallbackOnce`](references/useCallbackOnce.md) | `useCallbackOnce` is a React hook that runs a callback only once until `deps` change, no matter how many times the returned function is called. | | [`useConditionalEffect`](references/useConditionalEffect.md) | `useConditionalEffect` is a React hook that conditionally executes effects based on a predicate function. | | [`useControlledState`](references/useControlledState.md) | `useControlledState` is a React hook that allows you to control both controlled and uncontrolled states. | | [`useCounter`](references/useCounter.md) | `useCounter` is a React hook that manages a numeric counter state with increment, decrement, and reset capabilities. | | [`useDebounce`](references/useDebounce.md) | `useDebounce` is a React hook that returns a debounced version of the provided callback function. | | [`useDebouncedCallback`](references/useDebouncedCallback.md) | `useDebouncedCallback` is a React hook that returns a debounced version of the provided callback function. | +| [`useDebouncedState`](references/useDebouncedState.md) | `useDebouncedState` is a React hook that manages state and returns a debounced setter. | | [`useDebouncedValue`](references/useDebouncedValue.md) | `useDebouncedValue` is a React hook that returns a debounced copy of the given value. | | [`useDoubleClick`](references/useDoubleClick.md) | `useDoubleClick` is a React hook that differentiates between single and double click events. | | [`useGeolocation`](references/useGeolocation.md) | `useGeolocation` is a React hook that retrieves and tracks the user's geographical location. | @@ -99,6 +100,7 @@ Backticks in this table mark catalog entries only. | [`useNetworkStatus`](references/useNetworkStatus.md) | `useNetworkStatus` is a React hook that provides access to the Network Information API. | | [`useOutsideClickEffect`](references/useOutsideClickEffect.md) | `useOutsideClickEffect` is a React hook that triggers a callback when a click event occurs outside the specified container(s). | | [`usePageVisibility`](references/usePageVisibility.md) | `usePageVisibility` is a React hook that detects page visibility changes. | +| [`usePageVisibilityEffect`](references/usePageVisibilityEffect.md) | `usePageVisibilityEffect` is a React hook that listens to changes in the document's visibility state and triggers a callback. | | [`usePreservedCallback`](references/usePreservedCallback.md) | `usePreservedCallback` is a React hook that maintains a stable reference to a callback function while ensuring it always has access to the latest state or props. | | [`usePreservedReference`](references/usePreservedReference.md) | `usePreservedReference` is a React hook that helps maintain the reference of a value when it hasn't changed, while ensuring you can safely use the latest state. | | [`usePrevious`](references/usePrevious.md) | `usePrevious` is a React hook that returns the previous value of the input state. | @@ -106,13 +108,13 @@ Backticks in this table mark catalog entries only. | [`useSafeAreaInset`](references/useSafeAreaInset.md) | `useSafeAreaInset` is a React hook that tracks safe area inset changes. | | [`useScrollDirection`](references/useScrollDirection.md) | `useScrollDirection` is a React hook that detects scroll direction. | | [`useSet`](references/useSet.md) | A React hook that manages a Set as state. | -| [`useStorageState`](references/useStorageState.md) | `useStorageState` is a React that functions like `useState` but persists the state value in browser storage. | +| [`useStorageState`](references/useStorageState.md) | `useStorageState` is a React hook that functions like `useState` but persists the state value in browser storage. | | [`useThrottle`](references/useThrottle.md) | `useThrottle` is a React hook that creates a throttled version of a callback function. | | [`useThrottledCallback`](references/useThrottledCallback.md) | `useThrottledCallback` is a React hook that returns a throttled version of the provided callback function. | +| [`useThrottledState`](references/useThrottledState.md) | `useThrottledState` is a React hook that manages state and returns a throttled setter. | | [`useThrottledValue`](references/useThrottledValue.md) | `useThrottledValue` is a React hook that returns a throttled copy of the given value. | | [`useTimeout`](references/useTimeout.md) | `useTimeout` is a React hook that executes a callback function after a specified delay. | | [`useToggle`](references/useToggle.md) | `useToggle` is a React hook that simplifies managing a boolean state. | -| [`useVisibilityEvent`](references/useVisibilityEvent.md) | `useVisibilityEvent` is a React hook that listens to changes in the document's visibility state and triggers a callback. | | [`useVisualViewport`](references/useVisualViewport.md) | `useVisualViewport` is a React hook that tracks Visual Viewport changes. | ### components @@ -140,6 +142,15 @@ Backticks in this table mark catalog entries only. | [`mergeRefs`](references/mergeRefs.md) | This function takes multiple refs (RefObject or RefCallback) and returns a single ref that updates all provided refs. | | [`subscribeKeyboardHeight`](references/subscribeKeyboardHeight.md) | `subscribeKeyboardHeight` is a utility function that subscribes to changes in the on-screen keyboard height. | +### Deprecated + +These still work but are kept only for backward compatibility. Do not use them in new code; each row names the replacement. + +| Name | Notice | +| --- | --- | +| `useCallbackOncePerRender` | Use `useCallbackOnce` instead. | +| `useVisibilityEvent` | Use `usePageVisibilityEffect` instead. | + ## Learn more - Docs: https://react-simplikit.slash.page diff --git a/packages/plugin/skills/react-simplikit/references/ImpressionArea.md b/packages/plugin/skills/react-simplikit/references/ImpressionArea.md index ee15510f..d75ea05e 100644 --- a/packages/plugin/skills/react-simplikit/references/ImpressionArea.md +++ b/packages/plugin/skills/react-simplikit/references/ImpressionArea.md @@ -11,7 +11,7 @@ function ImpressionArea( as: T = 'div', rootMargin?: string, areaThreshold?: number, - timeThreshold?: number, + timeThresholdMs?: number, onImpressionStart?: () => void, onImpressionEnd?: () => void, ref?: Ref>, @@ -41,7 +41,7 @@ function ImpressionArea( /> @@ -92,7 +92,7 @@ function App() { console.log('Element entered view')} onImpressionEnd={() => console.log('Element exited view')} - timeThreshold={1000} + timeThresholdMs={1000} areaThreshold={0.5} >
Track me!
diff --git a/packages/plugin/skills/react-simplikit/references/subscribeKeyboardHeight.md b/packages/plugin/skills/react-simplikit/references/subscribeKeyboardHeight.md index 2d3c7b27..03352a4e 100644 --- a/packages/plugin/skills/react-simplikit/references/subscribeKeyboardHeight.md +++ b/packages/plugin/skills/react-simplikit/references/subscribeKeyboardHeight.md @@ -4,6 +4,7 @@ The provided callback is invoked whenever the keyboard height may change, including when the keyboard appears, disappears, or changes size. +By default, it is also invoked once with the current keyboard height when the subscription starts. Internally, this function listens to both `resize` and `scroll` events on the Visual Viewport: @@ -44,7 +45,7 @@ function subscribeKeyboardHeight( name: 'options.immediate', type: 'boolean', required: false, - defaultValue: 'false', + defaultValue: 'true', description: 'If true, the callback will be invoked immediately with the current keyboard height.', }, @@ -82,7 +83,6 @@ const { unsubscribe } = subscribeKeyboardHeight({ callback: height => { footer.style.paddingBottom = `${height}px`; }, - immediate: true, }); // Later, when cleanup is needed diff --git a/packages/plugin/skills/react-simplikit/references/useAvoidKeyboard.md b/packages/plugin/skills/react-simplikit/references/useAvoidKeyboard.md index 9f3cc571..a87b929b 100644 --- a/packages/plugin/skills/react-simplikit/references/useAvoidKeyboard.md +++ b/packages/plugin/skills/react-simplikit/references/useAvoidKeyboard.md @@ -28,7 +28,7 @@ function useAvoidKeyboard( 'Base bottom offset in pixels when the keyboard is hidden. Useful for accounting for the iPhone home indicator area.', }, { - name: 'options.transitionDuration', + name: 'options.transitionDurationMs', type: 'number', required: false, defaultValue: '200', diff --git a/packages/plugin/skills/react-simplikit/references/useBooleanState.md b/packages/plugin/skills/react-simplikit/references/useBooleanState.md index 3ef27c3e..50443689 100644 --- a/packages/plugin/skills/react-simplikit/references/useBooleanState.md +++ b/packages/plugin/skills/react-simplikit/references/useBooleanState.md @@ -8,12 +8,7 @@ It provides functions to set the state to `true`, set it to `false`, and toggle ```ts function useBooleanState( initialValue: boolean | (() => boolean) = false -): readonly [ - state: boolean, - setTrue: () => void, - setFalse: () => void, - toggle: () => void, -]; +): readonly [state: boolean, actions: BooleanStateActions]; ``` ### Parameters @@ -28,8 +23,8 @@ function useBooleanState( + + + +### Return Value + + + +## Example + +```tsx +import { useCallbackOnce } from 'react-simplikit'; + +function Component() { + const handleOneTimeEvent = useCallbackOnce(() => { + console.log('This will only run once'); + }, []); + + return ; +} +``` + +```tsx +// With dependencies +function TrackingComponent({ userId }: { userId: string }) { + const trackUserVisit = useCallbackOnce(() => { + analytics.trackVisit(userId); + }, [userId]); + + useEffect(() => { + trackUserVisit(); + }, [trackUserVisit, userId]); + + return
User page
; +} +``` diff --git a/packages/plugin/skills/react-simplikit/references/useCallbackOncePerRender.md b/packages/plugin/skills/react-simplikit/references/useCallbackOncePerRender.md deleted file mode 100644 index 00af226b..00000000 --- a/packages/plugin/skills/react-simplikit/references/useCallbackOncePerRender.md +++ /dev/null @@ -1,66 +0,0 @@ -# useCallbackOncePerRender - -`useCallbackOncePerRender` is a React hook that ensures a callback function is executed only once, regardless of how many times it's called. -This is useful for one-time operations that should not be repeated, even if the component re-renders. - -## Interface - -```ts -function useCallbackOncePerRender void>( - callback: () => void, - deps: DependencyList -): (...args: any[]) => void; -``` - -### Parameters - - - - - -### Return Value - - - -## Example - -```tsx -import { useCallbackOncePerRender } from 'react-simplikit'; - -function Component() { - const handleOneTimeEvent = useCallbackOncePerRender(() => { - console.log('This will only run once'); - }, []); - - return ; -} -``` - -```tsx -// With dependencies -function TrackingComponent({ userId }: { userId: string }) { - const trackUserVisit = useCallbackOncePerRender(() => { - analytics.trackVisit(userId); - }, [userId]); - - useEffect(() => { - trackUserVisit(); - }, [trackUserVisit]); - - return
User page
; -} -``` diff --git a/packages/plugin/skills/react-simplikit/references/useCounter.md b/packages/plugin/skills/react-simplikit/references/useCounter.md index fee36ceb..ec884f43 100644 --- a/packages/plugin/skills/react-simplikit/references/useCounter.md +++ b/packages/plugin/skills/react-simplikit/references/useCounter.md @@ -54,7 +54,7 @@ function useCounter( @@ -96,7 +96,7 @@ function useCounter( import { useCounter } from 'react-simplikit'; function ShoppingCart() { - const { count, increment, decrement, reset } = useCounter(1, { + const [count, { increment, decrement, reset }] = useCounter(1, { min: 1, max: 10, }); diff --git a/packages/plugin/skills/react-simplikit/references/useDebounce.md b/packages/plugin/skills/react-simplikit/references/useDebounce.md index 3ece3aab..375b2c0e 100644 --- a/packages/plugin/skills/react-simplikit/references/useDebounce.md +++ b/packages/plugin/skills/react-simplikit/references/useDebounce.md @@ -3,8 +3,8 @@ `useDebounce` is a React hook that returns a debounced version of the provided callback function. It helps optimize event handling by delaying function execution and grouping multiple calls into one. -With the default options, the last call runs after `wait` milliseconds without another call. -Pending calls are cancelled when the component unmounts or when `wait`, `leading`, or `trailing` changes. +With the default options, the last call runs after `debounceMs` milliseconds without another call. +Pending calls are cancelled when the component unmounts or when `debounceMs`, `leading`, or `trailing` changes. Calling `.cancel()` only cancels a pending callback, not an already-started network request. The example displays the query locally. To search a server, pass your application's search callback as the first argument to `useDebounce`. @@ -14,7 +14,7 @@ search callback as the first argument to `useDebounce`. ```ts function useDebounce unknown>( callback: F, - wait: number, + debounceMs: number, options?: DebounceOptions ): F & { cancel: () => void }; ``` @@ -30,7 +30,7 @@ function useDebounce unknown>( diff --git a/packages/plugin/skills/react-simplikit/references/useDebouncedCallback.md b/packages/plugin/skills/react-simplikit/references/useDebouncedCallback.md index 7c70c45e..2dcc8862 100644 --- a/packages/plugin/skills/react-simplikit/references/useDebouncedCallback.md +++ b/packages/plugin/skills/react-simplikit/references/useDebouncedCallback.md @@ -27,7 +27,7 @@ function useDebouncedCallback(options: Object): (nextValue: T) => void; 'The callback to debounce. A call with the same value as the last forwarded one is skipped.', }, { - name: 'options.timeThreshold', + name: 'options.debounceMs', type: 'number', required: true, description: @@ -70,7 +70,7 @@ function SearchInput() { const [query, setQuery] = useState(''); const setQueryDebounced = useDebouncedCallback({ onChange: setQuery, - timeThreshold: 300, + debounceMs: 300, }); return ( diff --git a/packages/plugin/skills/react-simplikit/references/useDebouncedState.md b/packages/plugin/skills/react-simplikit/references/useDebouncedState.md new file mode 100644 index 00000000..0b8f0500 --- /dev/null +++ b/packages/plugin/skills/react-simplikit/references/useDebouncedState.md @@ -0,0 +1,105 @@ +# useDebouncedState + +`useDebouncedState` is a React hook that manages state and returns a debounced setter. +With the default options, the state updates `debounceMs` milliseconds after the last setter call. +It is useful for applying only the last value from a series of rapid update requests, such as search input. + +The initial value is returned as is on the first render and on the server. No update is scheduled +on mount, so with `leading: true` the first setter call after mount is applied immediately. +If both `leading` and `trailing` are `false`, the state never updates. + +Pending updates are cancelled when the component unmounts or when `debounceMs`, `leading`, or +`trailing` changes. + +## Interface + +```ts +function useDebouncedState( + initialValue: T, + debounceMs: number, + options?: DebounceOptions +): [state: T, setState: Dispatch>]; +``` + +### Parameters + + + + + + + +### Return Value + + + +## Example + +```tsx +import { useDebouncedState } from 'react-simplikit'; + +function SearchInput() { + const [query, setQuery] = useDebouncedState('', 300); + + return ( + <> + + {query} + + ); +} +``` diff --git a/packages/plugin/skills/react-simplikit/references/useDebouncedValue.md b/packages/plugin/skills/react-simplikit/references/useDebouncedValue.md index 1d7cbc5c..0cd15500 100644 --- a/packages/plugin/skills/react-simplikit/references/useDebouncedValue.md +++ b/packages/plugin/skills/react-simplikit/references/useDebouncedValue.md @@ -2,7 +2,7 @@ `useDebouncedValue` is a React hook that returns a debounced copy of the given value. The caller keeps owning the state; the hook only delays how quickly the returned value follows it. -The returned value updates `wait` milliseconds after the last change, which is useful for +The returned value updates `debounceMs` milliseconds after the last change, which is useful for deriving a search query or a validation input from fast-changing state. Pending updates are cancelled when the component unmounts. The example displays the @@ -13,7 +13,7 @@ on mount, so with `leading: true` the first change after mount is applied immedi If both `leading` and `trailing` are `false`, the returned value never updates. The value is compared by reference. Passing a new object or array on every render keeps -the returned value updating every `wait` milliseconds; stabilize the reference first, for +the returned value updating every `debounceMs` milliseconds; stabilize the reference first, for example with `usePreservedReference`. ## Interface @@ -21,7 +21,7 @@ example with `usePreservedReference`. ```ts function useDebouncedValue( value: T, - wait: number, + debounceMs: number, options?: DebounceOptions ): T; ``` @@ -37,7 +37,7 @@ function useDebouncedValue( @@ -61,7 +61,7 @@ function useDebouncedValue( required: false, defaultValue: 'true', description: - 'If true, the last change is applied after wait milliseconds.', + 'If true, the last change is applied after debounceMs milliseconds.', }, ]" /> diff --git a/packages/plugin/skills/react-simplikit/references/useDoubleClick.md b/packages/plugin/skills/react-simplikit/references/useDoubleClick.md index 61b45ee6..6ea8acde 100644 --- a/packages/plugin/skills/react-simplikit/references/useDoubleClick.md +++ b/packages/plugin/skills/react-simplikit/references/useDoubleClick.md @@ -20,7 +20,7 @@ function useDoubleClick( description="Configuration options for click handling." :nested="[ { - name: 'props.delay', + name: 'props.delayMs', type: 'number', required: false, defaultValue: '250', @@ -28,13 +28,13 @@ function useDoubleClick( 'The number of milliseconds to wait before triggering the single click callback. Defaults to 250ms.', }, { - name: 'props.click', + name: 'props.onClick', type: '(event: MouseEvent) => void', required: false, description: 'The callback function to be executed on a single click.', }, { - name: 'props.doubleClick', + name: 'props.onDoubleClick', type: '(event: MouseEvent) => void', required: true, description: @@ -61,8 +61,8 @@ function GalleryCard() { const handleDoubleClick = () => alert('Zoom in!'); const handleEvent = useDoubleClick({ - click: handleClick, - doubleClick: handleDoubleClick, + onClick: handleClick, + onDoubleClick: handleDoubleClick, }); return ( diff --git a/packages/plugin/skills/react-simplikit/references/useImpressionRef.md b/packages/plugin/skills/react-simplikit/references/useImpressionRef.md index b3a1fcdc..cb12e5fe 100644 --- a/packages/plugin/skills/react-simplikit/references/useImpressionRef.md +++ b/packages/plugin/skills/react-simplikit/references/useImpressionRef.md @@ -33,7 +33,7 @@ function useImpressionRef( description: 'Callback function executed when the element exits the view', }, { - name: 'options.timeThreshold', + name: 'options.timeThresholdMs', type: 'number', required: false, defaultValue: '0', @@ -72,7 +72,7 @@ function Component() { const ref = useImpressionRef({ onImpressionStart: () => console.log('Element entered view'), onImpressionEnd: () => console.log('Element exited view'), - timeThreshold: 1000, + timeThresholdMs: 1000, areaThreshold: 0.5, }); diff --git a/packages/plugin/skills/react-simplikit/references/useInterval.md b/packages/plugin/skills/react-simplikit/references/useInterval.md index f008aeb9..381e7bf3 100644 --- a/packages/plugin/skills/react-simplikit/references/useInterval.md +++ b/packages/plugin/skills/react-simplikit/references/useInterval.md @@ -6,33 +6,28 @@ It is useful for timers, polling data, and other recurring tasks. ## Interface ```ts -function useInterval( - callback: () => void, - options: number | { delay: number; enabled?: boolean; immediate?: boolean } -): void; +function useInterval(options: Object): void; ``` ### Parameters - - + ### Return Value diff --git a/packages/plugin/skills/react-simplikit/references/useLongPress.md b/packages/plugin/skills/react-simplikit/references/useLongPress.md index 4990a515..b4bf1ae7 100644 --- a/packages/plugin/skills/react-simplikit/references/useLongPress.md +++ b/packages/plugin/skills/react-simplikit/references/useLongPress.md @@ -27,7 +27,7 @@ function useLongPress( description="Configuration options for the long press behavior." :nested="[ { - name: 'options.delay', + name: 'options.delayMs', type: 'number', required: false, defaultValue: '500', @@ -131,7 +131,7 @@ function ContextMenu() { const [menuVisible, setMenuVisible] = useState(false); const longPressHandlers = useLongPress(() => setMenuVisible(true), { - delay: 400, + delayMs: 400, onClick: () => console.log('Normal click'), onLongPressEnd: () => console.log('Long press completed'), }); diff --git a/packages/plugin/skills/react-simplikit/references/useMap.md b/packages/plugin/skills/react-simplikit/references/useMap.md index ab50cf3d..74af5ffc 100644 --- a/packages/plugin/skills/react-simplikit/references/useMap.md +++ b/packages/plugin/skills/react-simplikit/references/useMap.md @@ -6,16 +6,16 @@ Provides efficient state management and stable action functions. ## Interface ```ts -function useMap(initialState: MapOrEntries): UseMapReturn; +function useMap(initialValue: MapOrEntries): UseMapReturn; ``` ### Parameters ### Return Value diff --git a/packages/plugin/skills/react-simplikit/references/useVisibilityEvent.md b/packages/plugin/skills/react-simplikit/references/usePageVisibilityEffect.md similarity index 71% rename from packages/plugin/skills/react-simplikit/references/useVisibilityEvent.md rename to packages/plugin/skills/react-simplikit/references/usePageVisibilityEffect.md index 52115cc8..24882458 100644 --- a/packages/plugin/skills/react-simplikit/references/useVisibilityEvent.md +++ b/packages/plugin/skills/react-simplikit/references/usePageVisibilityEffect.md @@ -1,11 +1,12 @@ -# useVisibilityEvent +# usePageVisibilityEffect -`useVisibilityEvent` is a React hook that listens to changes in the document's visibility state and triggers a callback. +`usePageVisibilityEffect` is a React hook that listens to changes in the document's visibility state and triggers a callback. +In v0 this hook was named `useVisibilityEvent`. The old name still works but is deprecated. ## Interface ```ts -function useVisibilityEvent( +function usePageVisibilityEffect( callback: (visibilityState: 'visible' | 'hidden') => void, options?: object ): void; @@ -43,10 +44,10 @@ This function does not return anything. ## Example ```tsx -import { useVisibilityEvent } from 'react-simplikit'; +import { usePageVisibilityEffect } from 'react-simplikit'; function Component() { - useVisibilityEvent(visibilityState => { + usePageVisibilityEffect(visibilityState => { console.log(`Document is now ${visibilityState}`); }); diff --git a/packages/plugin/skills/react-simplikit/references/usePreservedReference.md b/packages/plugin/skills/react-simplikit/references/usePreservedReference.md index ebdca232..1195b1a9 100644 --- a/packages/plugin/skills/react-simplikit/references/usePreservedReference.md +++ b/packages/plugin/skills/react-simplikit/references/usePreservedReference.md @@ -9,7 +9,7 @@ It prevents unnecessary re-renders while always allowing access to the latest da ```ts function usePreservedReference( value: T, - areValuesEqual?: (a: T, b: T) => boolean + equalityFn?: (a: T, b: T) => boolean ): T; ``` @@ -23,7 +23,7 @@ function usePreservedReference( /> diff --git a/packages/plugin/skills/react-simplikit/references/usePrevious.md b/packages/plugin/skills/react-simplikit/references/usePrevious.md index d2d0d35e..f821ff6c 100644 --- a/packages/plugin/skills/react-simplikit/references/usePrevious.md +++ b/packages/plugin/skills/react-simplikit/references/usePrevious.md @@ -2,13 +2,16 @@ `usePrevious` is a React hook that returns the previous value of the input state. It preserves the previous value unchanged when re-renders occur without state changes. -If the state is an object or requires custom change detection, a `compare` function can be provided. -By default, state changes are detected using `prev === next`. +If the state is an object or requires custom change detection, an `equalityFn` can be provided. +By default, state changes are detected using `Object.is(prev, next)`. ## Interface ```ts -function usePrevious(state: T, compare?: (prev: T, next: T) => boolean): T; +function usePrevious( + state: T, + equalityFn?: (prev: T, next: T) => boolean +): T; ``` ### Parameters @@ -21,9 +24,9 @@ function usePrevious(state: T, compare?: (prev: T, next: T) => boolean): T; /> ### Return Value diff --git a/packages/plugin/skills/react-simplikit/references/useSet.md b/packages/plugin/skills/react-simplikit/references/useSet.md index 3701fbba..f9745fac 100644 --- a/packages/plugin/skills/react-simplikit/references/useSet.md +++ b/packages/plugin/skills/react-simplikit/references/useSet.md @@ -6,15 +6,15 @@ Provides efficient state management and stable action functions. ## Interface ```ts -function useSet(initialState: SetOrValues = new Set()): UseSetReturn; +function useSet(initialValue: SetOrValues = new Set()): UseSetReturn; ``` ### Parameters ### Return Value diff --git a/packages/plugin/skills/react-simplikit/references/useStorageState.md b/packages/plugin/skills/react-simplikit/references/useStorageState.md index 08ee4a2c..851d5d84 100644 --- a/packages/plugin/skills/react-simplikit/references/useStorageState.md +++ b/packages/plugin/skills/react-simplikit/references/useStorageState.md @@ -1,6 +1,6 @@ # useStorageState -`useStorageState` is a React that functions like `useState` but persists the state value in browser storage. +`useStorageState` is a React hook that functions like `useState` but persists the state value in browser storage. The value is retained across page reloads and can be shared between tabs when using `localStorage`. ## Interface @@ -11,8 +11,7 @@ function useStorageState( options?: Object ): readonly [ state: Serializable | undefined, - setState: (value: SetStateAction | undefined>) => void, - refreshState: () => void, + actions: StorageStateActions | undefined>, ]; ``` @@ -63,8 +62,8 @@ function useStorageState( @@ -62,7 +70,7 @@ const throttledScroll = useThrottle( console.log('Scroll event'); }, 200, - { edges: ['leading', 'trailing'] } + { leading: true, trailing: true } ); useEffect(() => { diff --git a/packages/plugin/skills/react-simplikit/references/useThrottledCallback.md b/packages/plugin/skills/react-simplikit/references/useThrottledCallback.md index de3fefc4..a73a19d6 100644 --- a/packages/plugin/skills/react-simplikit/references/useThrottledCallback.md +++ b/packages/plugin/skills/react-simplikit/references/useThrottledCallback.md @@ -25,18 +25,26 @@ function useThrottledCallback(options: Object): (nextValue: T) => void; 'The callback to throttle. A call with the same value as the last forwarded one is skipped.', }, { - name: 'options.timeThreshold', + name: 'options.throttleMs', type: 'number', required: true, description: 'The number of milliseconds to throttle invocations to.', }, { - name: 'options.edges', - type: 'Array<\'leading\' | \'trailing\'>', + name: 'options.leading', + type: 'boolean', required: false, - defaultValue: '[\'leading\', \'trailing\']', + defaultValue: 'true', description: - 'An optional array specifying whether the function should be invoked on the leading edge, trailing edge, or both.', + 'If true, allows an immediate call at the start of a throttle window.', + }, + { + name: 'options.trailing', + type: 'boolean', + required: false, + defaultValue: 'true', + description: + 'If true, allows a pending call to run after the delay with the latest value.', }, ]" /> @@ -59,7 +67,7 @@ function ScrollPosition() { const [scrollTop, setScrollTop] = useState(0); const setScrollTopThrottled = useThrottledCallback({ onChange: setScrollTop, - timeThreshold: 200, + throttleMs: 200, }); return ( diff --git a/packages/plugin/skills/react-simplikit/references/useThrottledState.md b/packages/plugin/skills/react-simplikit/references/useThrottledState.md new file mode 100644 index 00000000..4efffeb1 --- /dev/null +++ b/packages/plugin/skills/react-simplikit/references/useThrottledState.md @@ -0,0 +1,102 @@ +# useThrottledState + +`useThrottledState` is a React hook that manages state and returns a throttled setter. +With the default options, the first setter call is applied immediately, and subsequent calls +are throttled using `throttleMs`, with the last pending value applied on the trailing edge. +It is useful for limiting state updates from frequent events, such as scrolling. + +The initial value is returned as is on the first render and on the server. No update is scheduled +on mount, so with `leading: true` the first setter call after mount is applied immediately. +If both `leading` and `trailing` are `false`, the state never updates. + +Pending updates are cancelled when the component unmounts or when `throttleMs`, `leading`, or +`trailing` changes. + +## Interface + +```ts +function useThrottledState( + initialValue: T, + throttleMs: number, + options?: ThrottleOptions +): [state: T, setState: Dispatch>]; +``` + +### Parameters + + + + + + + +### Return Value + + + +## Example + +```tsx +import { useThrottledState } from 'react-simplikit'; + +function ScrollPosition() { + const [scrollTop, setScrollTop] = useThrottledState(0, 200); + + return ( +
setScrollTop(e.currentTarget.scrollTop)}> +

Scrolled {scrollTop}px

+
+ ); +} +``` diff --git a/packages/plugin/skills/react-simplikit/references/useThrottledValue.md b/packages/plugin/skills/react-simplikit/references/useThrottledValue.md index 87d11e99..0680e8e2 100644 --- a/packages/plugin/skills/react-simplikit/references/useThrottledValue.md +++ b/packages/plugin/skills/react-simplikit/references/useThrottledValue.md @@ -1,7 +1,7 @@ # useThrottledValue `useThrottledValue` is a React hook that returns a throttled copy of the given value. -The caller keeps owning the state; the returned value follows it at most once per `wait` milliseconds, +The caller keeps owning the state; the returned value follows it at most once per `throttleMs` milliseconds, which is useful for driving expensive renders from scroll position, pointer position, or an element's size on resize. @@ -10,7 +10,7 @@ on mount, so the first change after mount is applied immediately when `leading` If both `leading` and `trailing` are `false`, the returned value never updates. The value is compared by reference. Passing a new object or array on every render keeps -the returned value updating every `wait` milliseconds; stabilize the reference first, for +the returned value updating every `throttleMs` milliseconds; stabilize the reference first, for example with `usePreservedReference`. ## Interface @@ -18,7 +18,7 @@ example with `usePreservedReference`. ```ts function useThrottledValue( value: T, - wait: number, + throttleMs: number, options?: ThrottleOptions ): T; ``` @@ -34,7 +34,7 @@ function useThrottledValue( @@ -58,7 +58,7 @@ function useThrottledValue( required: false, defaultValue: 'true', description: - 'If true, the last change in a window is applied wait milliseconds after that change.', + 'If true, the last change in a window is applied throttleMs milliseconds after that change.', }, ]" /> diff --git a/packages/plugin/skills/react-simplikit/references/useTimeout.md b/packages/plugin/skills/react-simplikit/references/useTimeout.md index c2cd451d..a0f79491 100644 --- a/packages/plugin/skills/react-simplikit/references/useTimeout.md +++ b/packages/plugin/skills/react-simplikit/references/useTimeout.md @@ -6,22 +6,32 @@ It manages `setTimeout` in accordance with the React lifecycle, ensuring cleanup ## Interface ```ts -function useTimeout(callback: () => void, delay: number = 0): void; +function useTimeout(options: Object): void; ``` ### Parameters - - ### Return Value @@ -38,13 +48,15 @@ import { useState } from 'react'; function Example() { const [title, setTitle] = useState(''); - useTimeout(() => { - setTitle('Searching for products...'); - }, 2000); + useTimeout({ + onTimeout: () => setTitle('Searching for products...'), + delayMs: 2000, + }); - useTimeout(() => { - setTitle('Almost done...'); - }, 4000); + useTimeout({ + onTimeout: () => setTitle('Almost done...'), + delayMs: 4000, + }); return
{title}
; } diff --git a/packages/react-simplikit/src/components/ImpressionArea/ImpressionArea.md b/packages/react-simplikit/src/components/ImpressionArea/ImpressionArea.md index ee15510f..d75ea05e 100644 --- a/packages/react-simplikit/src/components/ImpressionArea/ImpressionArea.md +++ b/packages/react-simplikit/src/components/ImpressionArea/ImpressionArea.md @@ -11,7 +11,7 @@ function ImpressionArea( as: T = 'div', rootMargin?: string, areaThreshold?: number, - timeThreshold?: number, + timeThresholdMs?: number, onImpressionStart?: () => void, onImpressionEnd?: () => void, ref?: Ref>, @@ -41,7 +41,7 @@ function ImpressionArea( /> @@ -92,7 +92,7 @@ function App() { console.log('Element entered view')} onImpressionEnd={() => console.log('Element exited view')} - timeThreshold={1000} + timeThresholdMs={1000} areaThreshold={0.5} >
Track me!
diff --git a/packages/react-simplikit/src/components/ImpressionArea/ImpressionArea.tsx b/packages/react-simplikit/src/components/ImpressionArea/ImpressionArea.tsx index ec84b560..44c909ed 100644 --- a/packages/react-simplikit/src/components/ImpressionArea/ImpressionArea.tsx +++ b/packages/react-simplikit/src/components/ImpressionArea/ImpressionArea.tsx @@ -25,7 +25,7 @@ type Props = React.ComponentPropsWithoutRef & * @param {T} [as='div'] - The HTML tag to render. Defaults to `div`. * @param {string} [rootMargin] - Margin to adjust the detection area. * @param {number} [areaThreshold] - Minimum ratio of the element that must be visible (0 to 1). - * @param {number} [timeThreshold] - Minimum time the element must be visible (in milliseconds). + * @param {number} [timeThresholdMs] - Minimum time the element must be visible (in milliseconds). * @param {() => void} [onImpressionStart] - Callback function executed when the element enters the view. * @param {() => void} [onImpressionEnd] - Callback function executed when the element exits the view. * @param {Ref>} [ref] - Reference to the element. @@ -40,7 +40,7 @@ type Props = React.ComponentPropsWithoutRef & * console.log('Element entered view')} * onImpressionEnd={() => console.log('Element exited view')} - * timeThreshold={1000} + * timeThresholdMs={1000} * areaThreshold={0.5} * > *
Track me!
@@ -53,7 +53,7 @@ export const ImpressionArea = forwardRef(ImpressionAreaImpl) as React.ReactElement; function ImpressionAreaImpl( - { as, rootMargin, areaThreshold, timeThreshold, onImpressionStart, onImpressionEnd, ...props }: Props, + { as, rootMargin, areaThreshold, timeThresholdMs, onImpressionStart, onImpressionEnd, ...props }: Props, ref: Ref ) { const Component = as ?? 'div'; @@ -61,7 +61,7 @@ function ImpressionAreaImpl( onImpressionStart, onImpressionEnd, areaThreshold, - timeThreshold, + timeThresholdMs, rootMargin, }); diff --git a/packages/react-simplikit/src/components/ImpressionArea/es/ImpressionArea.md b/packages/react-simplikit/src/components/ImpressionArea/es/ImpressionArea.md index b94c9d0d..27bc468f 100644 --- a/packages/react-simplikit/src/components/ImpressionArea/es/ImpressionArea.md +++ b/packages/react-simplikit/src/components/ImpressionArea/es/ImpressionArea.md @@ -11,7 +11,7 @@ function ImpressionArea( as: T = 'div', rootMargin?: string, areaThreshold?: number, - timeThreshold?: number, + timeThresholdMs?: number, onImpressionStart?: () => void, onImpressionEnd?: () => void, ref?: Ref>, @@ -41,7 +41,7 @@ function ImpressionArea( /> @@ -94,7 +94,7 @@ function App() { console.log('El elemento entró en el área visible') } onImpressionEnd={() => console.log('El elemento salió del área visible')} - timeThreshold={1000} + timeThresholdMs={1000} areaThreshold={0.5} >
¡Sigue mi visibilidad!
diff --git a/packages/react-simplikit/src/components/ImpressionArea/ja/ImpressionArea.md b/packages/react-simplikit/src/components/ImpressionArea/ja/ImpressionArea.md index fcc1cab5..7f70ce9b 100644 --- a/packages/react-simplikit/src/components/ImpressionArea/ja/ImpressionArea.md +++ b/packages/react-simplikit/src/components/ImpressionArea/ja/ImpressionArea.md @@ -11,7 +11,7 @@ function ImpressionArea( as: T = 'div', rootMargin?: string, areaThreshold?: number, - timeThreshold?: number, + timeThresholdMs?: number, onImpressionStart?: () => void, onImpressionEnd?: () => void, ref?: Ref>, @@ -41,7 +41,7 @@ function ImpressionArea( /> @@ -92,7 +92,7 @@ function App() { console.log('要素が表示領域に入りました')} onImpressionEnd={() => console.log('要素が表示領域から出ました')} - timeThreshold={1000} + timeThresholdMs={1000} areaThreshold={0.5} >
この要素を追跡してください!
diff --git a/packages/react-simplikit/src/components/ImpressionArea/ko/ImpressionArea.md b/packages/react-simplikit/src/components/ImpressionArea/ko/ImpressionArea.md index 3948cf10..ad61e5e0 100644 --- a/packages/react-simplikit/src/components/ImpressionArea/ko/ImpressionArea.md +++ b/packages/react-simplikit/src/components/ImpressionArea/ko/ImpressionArea.md @@ -9,7 +9,7 @@ function ImpressionArea( as: T = 'div', rootMargin?: string, areaThreshold?: number, - timeThreshold?: number, + timeThresholdMs?: number, onImpressionStart?: () => void, onImpressionEnd?: () => void, ref?: Ref>, @@ -39,7 +39,7 @@ function ImpressionArea( /> @@ -90,7 +90,7 @@ function App() { console.log('요소가 보여요')} onImpressionEnd={() => console.log('요소가 사라졌어요')} - timeThreshold={1000} + timeThresholdMs={1000} areaThreshold={0.5} >
저를 추적해 보세요!
diff --git a/packages/react-simplikit/src/components/ImpressionArea/zh-Hans/ImpressionArea.md b/packages/react-simplikit/src/components/ImpressionArea/zh-Hans/ImpressionArea.md index 66fb08f0..cbb10343 100644 --- a/packages/react-simplikit/src/components/ImpressionArea/zh-Hans/ImpressionArea.md +++ b/packages/react-simplikit/src/components/ImpressionArea/zh-Hans/ImpressionArea.md @@ -9,7 +9,7 @@ function ImpressionArea( as: T = 'div', rootMargin?: string, areaThreshold?: number, - timeThreshold?: number, + timeThresholdMs?: number, onImpressionStart?: () => void, onImpressionEnd?: () => void, ref?: Ref>, @@ -39,7 +39,7 @@ function ImpressionArea( /> @@ -90,7 +90,7 @@ function App() { console.log('Element entered view')} onImpressionEnd={() => console.log('Element exited view')} - timeThreshold={1000} + timeThresholdMs={1000} areaThreshold={0.5} >
Track me!
diff --git a/packages/react-simplikit/src/hooks/_internal/useDebounce/debounce.ts b/packages/react-simplikit/src/hooks/_internal/useDebounce/debounce.ts new file mode 100644 index 00000000..a956065b --- /dev/null +++ b/packages/react-simplikit/src/hooks/_internal/useDebounce/debounce.ts @@ -0,0 +1,93 @@ +// Simplified version of https://github.com/toss/es-toolkit/blob/main/src/function/debounce.ts + +// eslint-disable-next-line @typescript-eslint/no-explicit-any -- generic function constraint requires `any[]` due to parameter contravariance +export type DebouncedFunction void> = { + (...args: Parameters): void; + cancel: () => void; +}; + +type DebounceOptions = { + /** + * An optional array specifying whether the function should be invoked on the leading edge, trailing edge, or both. + * If `edges` includes "leading", the function will be invoked at the start of the delay period. + * If `edges` includes "trailing", the function will be invoked at the end of the delay period. + * If both "leading" and "trailing" are included, the function will be invoked at both the start and end of the delay period. + * @default ["leading", "trailing"] + */ + edges?: Array<'leading' | 'trailing'>; +}; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any -- generic function constraint requires `any[]` due to parameter contravariance +export function debounce void>( + func: F, + debounceMs: number, + { edges = ['leading', 'trailing'] }: DebounceOptions = {} +): DebouncedFunction & { schedule: () => void } { + let pendingThis: ThisParameterType | undefined = undefined; + let pendingArgs: Parameters | null = null; + + const leading = edges != null && edges.includes('leading'); + const trailing = edges == null || edges.includes('trailing'); + + const invoke = () => { + if (pendingArgs !== null) { + func.apply(pendingThis, pendingArgs); + pendingThis = undefined; + pendingArgs = null; + } + }; + + const onTimerEnd = () => { + if (trailing) { + invoke(); + } + + cancel(); + }; + + let timeoutId: ReturnType | null = null; + + const schedule = () => { + if (timeoutId != null) { + clearTimeout(timeoutId); + } + + timeoutId = setTimeout(() => { + timeoutId = null; + + onTimerEnd(); + }, debounceMs); + }; + + const cancelTimer = () => { + if (timeoutId !== null) { + clearTimeout(timeoutId); + timeoutId = null; + } + }; + + const cancel = () => { + cancelTimer(); + pendingThis = undefined; + pendingArgs = null; + }; + + const debounced = function (this: ThisParameterType, ...args: Parameters) { + // eslint-disable-next-line @typescript-eslint/no-this-alias + pendingThis = this; + pendingArgs = args; + + const isFirstCall = timeoutId == null; + + schedule(); + + if (leading && isFirstCall) { + invoke(); + } + }; + + debounced.schedule = schedule; + debounced.cancel = cancel; + + return debounced; +} diff --git a/packages/react-simplikit/src/hooks/_internal/useDebounce/useDebounce.spec.ts b/packages/react-simplikit/src/hooks/_internal/useDebounce/useDebounce.spec.ts new file mode 100644 index 00000000..7e33c365 --- /dev/null +++ b/packages/react-simplikit/src/hooks/_internal/useDebounce/useDebounce.spec.ts @@ -0,0 +1,99 @@ +import { beforeEach, describe, expect, expectTypeOf, it, vi } from 'vitest'; + +import { renderHookSSR } from '../../../_internal/test-utils/renderHookSSR.tsx'; + +import { DebouncedFunction } from './debounce.ts'; +import { useDebounce } from './useDebounce.ts'; + +describe('useDebounce', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + it('is safe on server side rendering', () => { + const callback = vi.fn(); + renderHookSSR.serverOnly(() => useDebounce(callback, 100)); + + expect(callback).not.toHaveBeenCalled(); + }); + + it('should debounce the callback with default options', async () => { + const callback = vi.fn(); + const { result } = await renderHookSSR(() => useDebounce(callback, 100)); + + result.current(); + expect(callback).not.toBeCalled(); + + vi.advanceTimersByTime(50); + expect(callback).not.toBeCalled(); + + vi.advanceTimersByTime(50); + expect(callback).toBeCalledTimes(1); + }); + + it('should handle leading edge', async () => { + const callback = vi.fn(); + const { result } = await renderHookSSR(() => useDebounce(callback, 100, { leading: true })); + + result.current(); + expect(callback).toBeCalledTimes(1); + + result.current(); + vi.advanceTimersByTime(50); + expect(callback).toBeCalledTimes(1); + + vi.advanceTimersByTime(40); + expect(callback).toBeCalledTimes(1); + + vi.advanceTimersByTime(10); + expect(callback).toBeCalledTimes(2); + }); + + it('should handle trailing edge', async () => { + const callback = vi.fn(); + const { result } = await renderHookSSR(() => useDebounce(callback, 100, { trailing: true })); + + result.current(); + expect(callback).not.toBeCalled(); + + vi.advanceTimersByTime(50); + result.current(); + expect(callback).not.toBeCalled(); + + vi.advanceTimersByTime(100); + expect(callback).toBeCalledTimes(1); + }); + + it('should handle both leading and trailing edges', async () => { + const callback = vi.fn(); + const { result } = await renderHookSSR(() => useDebounce(callback, 100, { leading: true, trailing: true })); + + result.current(); + expect(callback).toBeCalledTimes(1); + + result.current(); + vi.advanceTimersByTime(150); + expect(callback).toBeCalledTimes(2); + }); + + it('should inference the callback type', async () => { + const callback = (value: string) => { + console.log('test::', value); + }; + + const { result } = renderHookSSR(() => useDebounce(callback, 100)); + + expectTypeOf(result.current).toEqualTypeOf>(); + }); + + it('should cleanup on unmount', async () => { + const callback = vi.fn(); + const { result, unmount } = await renderHookSSR(() => useDebounce(callback, 100)); + + result.current(); + unmount(); + vi.advanceTimersByTime(100); + + expect(callback).not.toBeCalled(); + }); +}); diff --git a/packages/react-simplikit/src/hooks/_internal/useDebounce/useDebounce.ts b/packages/react-simplikit/src/hooks/_internal/useDebounce/useDebounce.ts new file mode 100644 index 00000000..7015270c --- /dev/null +++ b/packages/react-simplikit/src/hooks/_internal/useDebounce/useDebounce.ts @@ -0,0 +1,60 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { useEffect } from 'react'; +import { useMemo } from 'react'; + +import { usePreservedCallback } from '../../usePreservedCallback/usePreservedCallback.ts'; + +import { debounce, DebouncedFunction } from './debounce.ts'; + +type DebounceOptions = { + leading?: boolean; + trailing?: boolean; +}; + +/** + * An internal React hook shared by debounce-related hooks. + * It debounces the provided callback and manages execution timing and cancellation of pending calls. + * + * With the default options, the last call runs after `debounceMs` milliseconds without another call. + * Pending calls are cancelled when the component unmounts or when `debounceMs`, `leading`, + * or `trailing` changes. + * + * `.cancel()` only cancels a pending callback; it does not stop work that has already started. + */ +export function useDebounce unknown>( + callback: F, + debounceMs: number, + options: DebounceOptions = {} +): DebouncedFunction { + const preservedCallback = usePreservedCallback(callback) as F; + + const { leading = false, trailing = true } = options; + + const edges = useMemo(() => { + const _edges: Array<'leading' | 'trailing'> = []; + if (leading) { + _edges.push('leading'); + } + + if (trailing) { + _edges.push('trailing'); + } + + return _edges; + }, [leading, trailing]); + + const debounced = useMemo(() => { + return debounce(preservedCallback, debounceMs, { edges }); + }, [preservedCallback, debounceMs, edges]); + + useEffect( + function cancelDebouncedOnUnmount() { + return () => { + debounced.cancel(); + }; + }, + [debounced] + ); + + return debounced; +} diff --git a/packages/react-simplikit/src/hooks/_internal/useThrottle/throttle.spec.ts b/packages/react-simplikit/src/hooks/_internal/useThrottle/throttle.spec.ts new file mode 100644 index 00000000..d844d563 --- /dev/null +++ b/packages/react-simplikit/src/hooks/_internal/useThrottle/throttle.spec.ts @@ -0,0 +1,248 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { throttle } from './throttle.ts'; + +describe('throttle', () => { + it('should throttle function calls', async () => { + const func = vi.fn(); + const throttledFunc = throttle(func, 100); + + throttledFunc(); + throttledFunc(); + throttledFunc(); + + expect(func).toHaveBeenCalledTimes(1); + }); + + it('should trigger a trailing call as soon as possible', async () => { + vi.useFakeTimers(); + + const func = vi.fn(); + const throttleMs = 50; + + const throttled = throttle(func, throttleMs); + + throttled(); + throttled(); + expect(func).toBeCalledTimes(1); + + await vi.advanceTimersByTimeAsync(throttleMs + 1); + expect(func).toBeCalledTimes(2); + }); + + it('should execute the function immediately if not called within the wait time', async () => { + vi.useFakeTimers(); + + const func = vi.fn(); + const throttleMs = 500; + const throttledFunc = throttle(func, throttleMs); + + throttledFunc(); // should be executed + expect(func).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(throttleMs / 2); + expect(func).toHaveBeenCalledTimes(1); + + throttledFunc(); // should be ignored + expect(func).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(throttleMs / 2 + 1); + expect(func).toHaveBeenCalledTimes(1); + + throttledFunc(); // should be executed + expect(func).toHaveBeenCalledTimes(2); + + await vi.advanceTimersByTimeAsync(throttleMs / 2 - 1); + expect(func).toHaveBeenCalledTimes(2); + + throttledFunc(); // should be ignored + expect(func).toHaveBeenCalledTimes(2); + + await vi.advanceTimersByTimeAsync(throttleMs / 2 + 1); + expect(func).toHaveBeenCalledTimes(2); + + throttledFunc(); // should be executed + expect(func).toHaveBeenCalledTimes(3); + }); + + it('should call the function with correct arguments', async () => { + const func = vi.fn(); + const throttleMs = 50; + const throttledFunc = throttle(func, throttleMs); + + throttledFunc('test', 123); + + expect(func).toHaveBeenCalledTimes(1); + expect(func).toHaveBeenCalledWith('test', 123); + }); + + it('should not trigger a trailing call when invoked once', async () => { + const func = vi.fn(); + const throttleMs = 50; + + const throttled = throttle(func, throttleMs); + + throttled(); + expect(func).toBeCalledTimes(1); + + await vi.advanceTimersByTimeAsync(throttleMs + 1); + expect(func).toBeCalledTimes(1); + }); + + it('should invoke once per window while calls keep coming with trailing edge only', () => { + vi.useFakeTimers(); + + const func = vi.fn(); + const throttled = throttle(func, 100, { edges: ['trailing'] }); + + for (let i = 1; i <= 10; i++) { + throttled(i); + vi.advanceTimersByTime(30); + } + expect(func.mock.calls.map(call => call[0])).toEqual([5, 9]); + + vi.advanceTimersByTime(100); + expect(func.mock.calls.map(call => call[0])).toEqual([5, 9, 10]); + + vi.useRealTimers(); + }); + + it('should defer a call after an idle period with trailing edge only', () => { + vi.useFakeTimers(); + + const func = vi.fn(); + const throttled = throttle(func, 100, { edges: ['trailing'] }); + + throttled('a'); + expect(func).toHaveBeenCalledTimes(0); + + vi.advanceTimersByTime(100); + expect(func).toHaveBeenCalledTimes(1); + + vi.advanceTimersByTime(400); + throttled('b'); + expect(func).toHaveBeenCalledTimes(1); + + vi.advanceTimersByTime(100); + expect(func).toHaveBeenCalledTimes(2); + expect(func).toHaveBeenLastCalledWith('b'); + + vi.useRealTimers(); + }); + + it('should invoke once per window while calls keep coming with leading edge only', () => { + vi.useFakeTimers(); + + const func = vi.fn(); + const throttled = throttle(func, 100, { edges: ['leading'] }); + + for (let i = 1; i <= 10; i++) { + throttled(i); + vi.advanceTimersByTime(30); + } + vi.advanceTimersByTime(200); + + expect(func.mock.calls.map(call => call[0])).toEqual([1, 5, 9]); + + vi.useRealTimers(); + }); + + it('should forget the pending call on cancel', () => { + vi.useFakeTimers(); + + const func = vi.fn(); + const throttled = throttle(func, 100, { edges: ['trailing'] }); + + throttled('a'); + throttled.cancel(); + vi.advanceTimersByTime(500); + + throttled('b'); + expect(func).toHaveBeenCalledTimes(0); + + vi.advanceTimersByTime(100); + expect(func).toHaveBeenCalledTimes(1); + expect(func).toHaveBeenLastCalledWith('b'); + + vi.useRealTimers(); + }); + + it('should never invoke when edges is empty', () => { + vi.useFakeTimers(); + + const func = vi.fn(); + const throttled = throttle(func, 100, { edges: [] }); + + throttled('a'); + vi.advanceTimersByTime(100); + throttled('b'); + vi.advanceTimersByTime(300); + + expect(func).not.toHaveBeenCalled(); + + vi.useRealTimers(); + }); + + it('should keep deferring calls after an idle period until the reopened window elapses', () => { + vi.useFakeTimers(); + + const func = vi.fn(); + const throttled = throttle(func, 100, { edges: ['trailing'] }); + + throttled('a'); + vi.advanceTimersByTime(100); + expect(func).toHaveBeenCalledTimes(1); + expect(func).toHaveBeenLastCalledWith('a'); + + vi.advanceTimersByTime(200); + throttled('b'); + vi.advanceTimersByTime(10); + throttled('c'); + expect(func).toHaveBeenCalledTimes(1); + + vi.advanceTimersByTime(100); + expect(func).toHaveBeenCalledTimes(2); + expect(func).toHaveBeenLastCalledWith('c'); + + vi.useRealTimers(); + }); + + it('should use the actual invocation time, not the call time, as the start of the next window', () => { + vi.useFakeTimers(); + + const func = vi.fn(); + const throttled = throttle(func, 100, { edges: ['trailing'] }); + + throttled('a'); + vi.advanceTimersByTime(100); // the debounce timer fires here, invoking with 'a' + expect(func).toHaveBeenCalledTimes(1); + + vi.advanceTimersByTime(50); // 50ms after the invocation above + throttled('b'); + + vi.advanceTimersByTime(60); // 110ms after the invocation, so the window has elapsed + throttled('c'); + expect(func).toHaveBeenCalledTimes(2); + expect(func).toHaveBeenLastCalledWith('c'); + + vi.useRealTimers(); + }); + + it('should treat a call exactly throttleMs after the window start as elapsed', () => { + vi.useFakeTimers(); + + const func = vi.fn(); + const throttled = throttle(func, 100, { edges: ['trailing'] }); + + throttled('a'); + vi.advanceTimersByTime(50); + throttled('b'); + vi.advanceTimersByTime(50); + throttled('c'); + + expect(func).toHaveBeenCalledTimes(1); + expect(func).toHaveBeenLastCalledWith('c'); + + vi.useRealTimers(); + }); +}); diff --git a/packages/react-simplikit/src/hooks/_internal/useThrottle/throttle.ts b/packages/react-simplikit/src/hooks/_internal/useThrottle/throttle.ts new file mode 100644 index 00000000..40315b16 --- /dev/null +++ b/packages/react-simplikit/src/hooks/_internal/useThrottle/throttle.ts @@ -0,0 +1,80 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +// Simplified version of https://github.com/toss/es-toolkit/blob/main/src/function/throttle.ts + +import { debounce } from '../useDebounce/debounce.ts'; + +type ThrottleOptions = { + /** + * An optional array specifying whether the function should be invoked on the leading edge, trailing edge, or both. + * If `edges` includes "leading", the function will be invoked at the start of the delay period. + * If `edges` includes "trailing", the function will be invoked at the end of the delay period. + * If both "leading" and "trailing" are included, the function will be invoked at both the start and end of the delay period. + * @default ["leading", "trailing"] + */ + edges?: Array<'leading' | 'trailing'>; +}; + +type ThrottledFunction void> = { + (...args: Parameters): void; + cancel: () => void; +}; + +export function throttle void>( + func: F, + throttleMs: number, + { edges = ['leading', 'trailing'] }: ThrottleOptions = {} +): ThrottledFunction { + const leading = edges.includes('leading'); + const trailing = edges.includes('trailing'); + + let pendingAt: number | null = null; + // `true` while a call is waiting on the debounce timer. A trailing-only throttle uses it to tell a + // continuous stream of calls (invoke at the window boundary) from a call after an idle period + // (open a fresh window and invoke at its end). es-toolkit invokes at the boundary in both cases, + // which makes a trailing-only throttle fire on the leading edge after it has been idle. + let isPending = false; + + const debounced = debounce( + function (this: ThisParameterType, ...args: Parameters) { + pendingAt = Date.now(); + isPending = false; + func.apply(this, args); + }, + throttleMs, + { edges } + ); + + const throttled = function (this: ThisParameterType, ...args: Parameters) { + if (pendingAt === null) { + pendingAt = Date.now(); + } + + const windowElapsed = Date.now() - pendingAt >= throttleMs; + + if (windowElapsed && (leading || (trailing && isPending))) { + pendingAt = Date.now(); + isPending = false; + func.apply(this, args); + + // Re-arm the timer without arguments so the next call in this window does not count as a + // leading call for the debounce. + debounced.cancel(); + debounced.schedule(); + return; + } + + if (windowElapsed) { + pendingAt = Date.now(); + } + + isPending = true; + debounced.apply(this, args); + }; + + throttled.cancel = () => { + isPending = false; + debounced.cancel(); + }; + + return throttled; +} diff --git a/packages/react-simplikit/src/hooks/_internal/useThrottle/useThrottle.spec.ts b/packages/react-simplikit/src/hooks/_internal/useThrottle/useThrottle.spec.ts new file mode 100644 index 00000000..71f3a146 --- /dev/null +++ b/packages/react-simplikit/src/hooks/_internal/useThrottle/useThrottle.spec.ts @@ -0,0 +1,155 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { renderHookSSR } from '../../../_internal/test-utils/renderHookSSR.tsx'; + +import { useThrottle } from './useThrottle.ts'; + +describe('useThrottle', () => { + it('is safe on server side rendering', () => { + const callback = vi.fn(); + renderHookSSR.serverOnly(() => useThrottle(callback, 50)); + + expect(callback).not.toHaveBeenCalled(); + }); + + it('should call throttle cancel when unmount', async () => { + vi.useFakeTimers(); + + const callback = vi.fn(); + const { result, unmount } = await renderHookSSR(() => useThrottle(callback, 50)); + + const cancel = vi.spyOn(result.current, 'cancel'); + + unmount(); + + expect(cancel).toHaveBeenCalled(); + + vi.restoreAllMocks(); + }); + + it('should throttle function calls', async () => { + const callback = vi.fn(); + const { result } = await renderHookSSR(() => useThrottle(callback, 100)); + + result.current(); + result.current(); + result.current(); + + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('should trigger a trailing call after throttleMs time', async () => { + vi.useFakeTimers(); + + const callback = vi.fn(); + const throttleMs = 50; + const { result } = await renderHookSSR(() => useThrottle(callback, throttleMs)); + + result.current(); + result.current(); + expect(callback).toBeCalledTimes(1); + + await vi.advanceTimersByTimeAsync(throttleMs + 1); + expect(callback).toBeCalledTimes(2); + + vi.useRealTimers(); + }); + + it('should call the function with correct arguments', async () => { + const callback = vi.fn(); + const throttleMs = 50; + const { result } = await renderHookSSR(() => useThrottle(callback, throttleMs)); + + result.current('test', 123); + + expect(callback).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenCalledWith('test', 123); + }); + + it('should execute immediately if not called within throttleMs time', async () => { + vi.useFakeTimers(); + + const callback = vi.fn(); + const throttleMs = 500; + const { result } = await renderHookSSR(() => useThrottle(callback, throttleMs)); + + result.current(); // should execute + expect(callback).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(throttleMs + 1); + + result.current(); // should execute again + expect(callback).toHaveBeenCalledTimes(2); + + vi.useRealTimers(); + }); + + it('should execute on leading and trailing when called multiple times with leading and trailing', async () => { + vi.useFakeTimers(); + + const callback = vi.fn(); + const throttleMs = 50; + const { result } = await renderHookSSR(() => useThrottle(callback, throttleMs, { leading: true, trailing: true })); + + result.current(); + + await vi.advanceTimersByTimeAsync(throttleMs + 1); + + expect(callback).toHaveBeenCalledTimes(1); + + result.current(); + + await vi.advanceTimersByTimeAsync(throttleMs); + + expect(callback).toHaveBeenCalledTimes(2); + + result.current(); + result.current(); + + expect(callback).toHaveBeenCalledTimes(3); + + await vi.advanceTimersByTimeAsync(throttleMs); + + expect(callback).toHaveBeenCalledTimes(4); + + vi.useRealTimers(); + }); + + it('should skip the leading edge when leading is false', async () => { + vi.useFakeTimers(); + + const callback = vi.fn(); + const throttleMs = 50; + const { result } = await renderHookSSR(() => useThrottle(callback, throttleMs, { leading: false })); + + result.current(); + + expect(callback).toHaveBeenCalledTimes(0); + + await vi.advanceTimersByTimeAsync(throttleMs); + + expect(callback).toHaveBeenCalledTimes(1); + + vi.useRealTimers(); + }); + + it('should skip the trailing edge when trailing is false', async () => { + vi.useFakeTimers(); + + const callback = vi.fn(); + const throttleMs = 50; + const { result } = await renderHookSSR(() => useThrottle(callback, throttleMs, { trailing: false })); + + result.current(); + + expect(callback).toHaveBeenCalledTimes(1); + + result.current(); + + await vi.advanceTimersByTimeAsync(throttleMs); + + expect(callback).toHaveBeenCalledTimes(1); + + vi.useRealTimers(); + }); +}); diff --git a/packages/react-simplikit/src/hooks/_internal/useThrottle/useThrottle.ts b/packages/react-simplikit/src/hooks/_internal/useThrottle/useThrottle.ts new file mode 100644 index 00000000..11cbb8b1 --- /dev/null +++ b/packages/react-simplikit/src/hooks/_internal/useThrottle/useThrottle.ts @@ -0,0 +1,55 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { useEffect, useMemo } from 'react'; + +import { usePreservedCallback } from '../../usePreservedCallback/usePreservedCallback.ts'; + +import { throttle } from './throttle.ts'; + +type ThrottleOptions = { + leading?: boolean; + trailing?: boolean; +}; + +/** + * An internal React hook shared by throttle-related hooks. + * It throttles the provided callback and manages execution timing and cancellation of pending calls. + * + * It limits how frequently consecutive calls execute based on the `throttleMs` interval. + * Pending calls are cancelled when the component unmounts or when `throttleMs`, `leading`, + * or `trailing` changes. + * + * `.cancel()` only cancels a pending callback; it does not stop work that has already started. + */ +export function useThrottle any>( + callback: F, + throttleMs: number, + { leading = true, trailing = true }: ThrottleOptions = {} +) { + const preservedCallback = usePreservedCallback(callback); + + const edges = useMemo(() => { + const _edges: Array<'leading' | 'trailing'> = []; + if (leading) { + _edges.push('leading'); + } + + if (trailing) { + _edges.push('trailing'); + } + + return _edges; + }, [leading, trailing]); + + const throttledCallback = useMemo( + () => throttle(preservedCallback, throttleMs, { edges }), + [preservedCallback, throttleMs, edges] + ); + + useEffect(() => { + return () => { + throttledCallback.cancel(); + }; + }, [throttledCallback]); + + return throttledCallback; +} diff --git a/packages/react-simplikit/src/hooks/useAvoidKeyboard/es/useAvoidKeyboard.md b/packages/react-simplikit/src/hooks/useAvoidKeyboard/es/useAvoidKeyboard.md index 86b14d1c..2985c9bb 100644 --- a/packages/react-simplikit/src/hooks/useAvoidKeyboard/es/useAvoidKeyboard.md +++ b/packages/react-simplikit/src/hooks/useAvoidKeyboard/es/useAvoidKeyboard.md @@ -28,7 +28,7 @@ function useAvoidKeyboard( 'Desplazamiento inferior base en píxeles cuando el teclado está oculto. Útil para tener en cuenta el área del indicador de inicio del iPhone.', }, { - name: 'options.transitionDuration', + name: 'options.transitionDurationMs', type: 'number', required: false, defaultValue: '200', diff --git a/packages/react-simplikit/src/hooks/useAvoidKeyboard/ja/useAvoidKeyboard.md b/packages/react-simplikit/src/hooks/useAvoidKeyboard/ja/useAvoidKeyboard.md index 2da266b3..df0c4e96 100644 --- a/packages/react-simplikit/src/hooks/useAvoidKeyboard/ja/useAvoidKeyboard.md +++ b/packages/react-simplikit/src/hooks/useAvoidKeyboard/ja/useAvoidKeyboard.md @@ -28,7 +28,7 @@ function useAvoidKeyboard( 'キーボードが非表示のときの、下端からの基本オフセット(ピクセル単位)です。iPhone のホームインジケーター領域を考慮する際に役立ちます。', }, { - name: 'options.transitionDuration', + name: 'options.transitionDurationMs', type: 'number', required: false, defaultValue: '200', diff --git a/packages/react-simplikit/src/hooks/useAvoidKeyboard/ko/useAvoidKeyboard.md b/packages/react-simplikit/src/hooks/useAvoidKeyboard/ko/useAvoidKeyboard.md index 70807a75..30e2b5fe 100644 --- a/packages/react-simplikit/src/hooks/useAvoidKeyboard/ko/useAvoidKeyboard.md +++ b/packages/react-simplikit/src/hooks/useAvoidKeyboard/ko/useAvoidKeyboard.md @@ -26,7 +26,7 @@ function useAvoidKeyboard( '키보드가 숨겨져 있을 때 기본 하단 오프셋(px)이에요. iPhone의 홈 인디케이터 영역을 고려할 때 유용해요.', }, { - name: 'options.transitionDuration', + name: 'options.transitionDurationMs', type: 'number', required: false, defaultValue: '200', diff --git a/packages/react-simplikit/src/hooks/useAvoidKeyboard/useAvoidKeyboard.md b/packages/react-simplikit/src/hooks/useAvoidKeyboard/useAvoidKeyboard.md index 9f3cc571..a87b929b 100644 --- a/packages/react-simplikit/src/hooks/useAvoidKeyboard/useAvoidKeyboard.md +++ b/packages/react-simplikit/src/hooks/useAvoidKeyboard/useAvoidKeyboard.md @@ -28,7 +28,7 @@ function useAvoidKeyboard( 'Base bottom offset in pixels when the keyboard is hidden. Useful for accounting for the iPhone home indicator area.', }, { - name: 'options.transitionDuration', + name: 'options.transitionDurationMs', type: 'number', required: false, defaultValue: '200', diff --git a/packages/react-simplikit/src/hooks/useAvoidKeyboard/useAvoidKeyboard.test.ts b/packages/react-simplikit/src/hooks/useAvoidKeyboard/useAvoidKeyboard.test.ts index 7be61af9..e4770535 100644 --- a/packages/react-simplikit/src/hooks/useAvoidKeyboard/useAvoidKeyboard.test.ts +++ b/packages/react-simplikit/src/hooks/useAvoidKeyboard/useAvoidKeyboard.test.ts @@ -56,7 +56,7 @@ describe('useAvoidKeyboard', () => { }); it('should apply custom transition duration', () => { - const { result } = renderHook(() => useAvoidKeyboard({ transitionDuration: 300 })); + const { result } = renderHook(() => useAvoidKeyboard({ transitionDurationMs: 300 })); expect(result.current.style.transition).toBe('transform 300ms ease-out'); }); @@ -75,7 +75,7 @@ describe('useAvoidKeyboard', () => { const { result } = renderHook(() => useAvoidKeyboard({ safeAreaBottom: 30, - transitionDuration: 150, + transitionDurationMs: 150, transitionTimingFunction: 'linear', }) ); diff --git a/packages/react-simplikit/src/hooks/useAvoidKeyboard/useAvoidKeyboard.ts b/packages/react-simplikit/src/hooks/useAvoidKeyboard/useAvoidKeyboard.ts index bb9d8736..7fc5fc07 100644 --- a/packages/react-simplikit/src/hooks/useAvoidKeyboard/useAvoidKeyboard.ts +++ b/packages/react-simplikit/src/hooks/useAvoidKeyboard/useAvoidKeyboard.ts @@ -13,7 +13,7 @@ type UseAvoidKeyboardOptions = { * Transition duration in milliseconds for smooth animation. * @default 200 */ - transitionDuration?: number; + transitionDurationMs?: number; /** * Transition timing function for the animation. * @default 'ease-out' @@ -42,7 +42,7 @@ type UseAvoidKeyboardResult = { * * @param {UseAvoidKeyboardOptions} [options] - Configuration options. * @param {number} [options.safeAreaBottom=0] - Base bottom offset in pixels when the keyboard is hidden. Useful for accounting for the iPhone home indicator area. - * @param {number} [options.transitionDuration=200] - Transition duration in milliseconds for smooth animation. + * @param {number} [options.transitionDurationMs=200] - Transition duration in milliseconds for smooth animation. * @param {CSSProperties['transitionTimingFunction']} [options.transitionTimingFunction='ease-out'] - Transition timing function for the animation. * @param {boolean} [options.immediate=true] - If true, gets the initial keyboard height on mount. * @@ -91,7 +91,7 @@ type UseAvoidKeyboardResult = { export function useAvoidKeyboard(options: UseAvoidKeyboardOptions = {}): UseAvoidKeyboardResult { const { safeAreaBottom = 0, - transitionDuration = 200, + transitionDurationMs = 200, transitionTimingFunction = 'ease-out', immediate = true, } = options; @@ -103,9 +103,9 @@ export function useAvoidKeyboard(options: UseAvoidKeyboardOptions = {}): UseAvoi return { transform: `translateY(${translateY}px)`, - transition: `transform ${transitionDuration}ms ${transitionTimingFunction}`, + transition: `transform ${transitionDurationMs}ms ${transitionTimingFunction}`, }; - }, [keyboardHeight, safeAreaBottom, transitionDuration, transitionTimingFunction]); + }, [keyboardHeight, safeAreaBottom, transitionDurationMs, transitionTimingFunction]); return { style }; } diff --git a/packages/react-simplikit/src/hooks/useAvoidKeyboard/zh-Hans/useAvoidKeyboard.md b/packages/react-simplikit/src/hooks/useAvoidKeyboard/zh-Hans/useAvoidKeyboard.md index 86bb71d1..90b2eca0 100644 --- a/packages/react-simplikit/src/hooks/useAvoidKeyboard/zh-Hans/useAvoidKeyboard.md +++ b/packages/react-simplikit/src/hooks/useAvoidKeyboard/zh-Hans/useAvoidKeyboard.md @@ -26,7 +26,7 @@ function useAvoidKeyboard( '键盘隐藏时的基础底部偏移量(像素)。用于考虑 iPhone 主页指示器区域。', }, { - name: 'options.transitionDuration', + name: 'options.transitionDurationMs', type: 'number', required: false, defaultValue: '200', diff --git a/packages/react-simplikit/src/hooks/useBooleanState/es/useBooleanState.md b/packages/react-simplikit/src/hooks/useBooleanState/es/useBooleanState.md index 4cd9664c..2f945add 100644 --- a/packages/react-simplikit/src/hooks/useBooleanState/es/useBooleanState.md +++ b/packages/react-simplikit/src/hooks/useBooleanState/es/useBooleanState.md @@ -8,12 +8,7 @@ Proporciona funciones para establecer el estado en `true`, establecerlo en `fals ```ts function useBooleanState( initialValue: boolean | (() => boolean) = false -): readonly [ - state: boolean, - setTrue: () => void, - setFalse: () => void, - toggle: () => void, -]; +): readonly [state: boolean, actions: BooleanStateActions]; ``` ### Parámetros @@ -28,8 +23,8 @@ function useBooleanState( + + + +### Valor de retorno + + + +## Ejemplo + +```tsx +import { useCallbackOnce } from 'react-simplikit'; + +function Component() { + const handleOneTimeEvent = useCallbackOnce(() => { + console.log('Esto solo se ejecutará una vez'); + }, []); + + return ; +} +``` + +```tsx +// Con dependencias +function TrackingComponent({ userId }: { userId: string }) { + const trackUserVisit = useCallbackOnce(() => { + analytics.trackVisit(userId); + }, [userId]); + + useEffect(() => { + trackUserVisit(); + }, [trackUserVisit, userId]); + + return
Página del usuario
; +} +``` diff --git a/packages/react-simplikit/src/hooks/useCallbackOncePerRender/index.ts b/packages/react-simplikit/src/hooks/useCallbackOnce/index.ts similarity index 56% rename from packages/react-simplikit/src/hooks/useCallbackOncePerRender/index.ts rename to packages/react-simplikit/src/hooks/useCallbackOnce/index.ts index c4f884e5..892bf361 100644 --- a/packages/react-simplikit/src/hooks/useCallbackOncePerRender/index.ts +++ b/packages/react-simplikit/src/hooks/useCallbackOnce/index.ts @@ -1 +1,2 @@ +export { useCallbackOnce } from './useCallbackOnce.ts'; export { useCallbackOncePerRender } from './useCallbackOncePerRender.ts'; diff --git a/packages/react-simplikit/src/hooks/useCallbackOncePerRender/ja/useCallbackOncePerRender.md b/packages/react-simplikit/src/hooks/useCallbackOnce/ja/useCallbackOnce.md similarity index 50% rename from packages/react-simplikit/src/hooks/useCallbackOncePerRender/ja/useCallbackOncePerRender.md rename to packages/react-simplikit/src/hooks/useCallbackOnce/ja/useCallbackOnce.md index ef860a4c..f2dc619c 100644 --- a/packages/react-simplikit/src/hooks/useCallbackOncePerRender/ja/useCallbackOncePerRender.md +++ b/packages/react-simplikit/src/hooks/useCallbackOnce/ja/useCallbackOnce.md @@ -1,15 +1,16 @@ -# useCallbackOncePerRender +# useCallbackOnce -`useCallbackOncePerRender` は、何度呼び出されてもコールバック関数を一度だけ実行する React フックです。 +`useCallbackOnce` は、返された関数が何度呼び出されても、`deps` が変わるまではコールバック関数を一度だけ実行する React フックです。 コンポーネントが再レンダリングされても繰り返すべきでない、一度限りの処理に便利です。 +v0 では、このフックは `useCallbackOncePerRender` という名前でした。以前の名前も引き続き動作しますが、非推奨です。 ## インターフェース ```ts -function useCallbackOncePerRender void>( - callback: () => void, +function useCallbackOnce void>( + callback: F, deps: DependencyList -): (...args: any[]) => void; +): (...args: Parameters) => void; ``` ### パラメータ @@ -17,8 +18,8 @@ function useCallbackOncePerRender void>( void>( ## 使用例 ```tsx -import { useCallbackOncePerRender } from 'react-simplikit'; +import { useCallbackOnce } from 'react-simplikit'; function Component() { - const handleOneTimeEvent = useCallbackOncePerRender(() => { + const handleOneTimeEvent = useCallbackOnce(() => { console.log('This will only run once'); }, []); @@ -53,13 +54,13 @@ function Component() { ```tsx // 依存関係を指定する場合 function TrackingComponent({ userId }: { userId: string }) { - const trackUserVisit = useCallbackOncePerRender(() => { + const trackUserVisit = useCallbackOnce(() => { analytics.trackVisit(userId); }, [userId]); useEffect(() => { trackUserVisit(); - }, [trackUserVisit]); + }, [trackUserVisit, userId]); return
User page
; } diff --git a/packages/react-simplikit/src/hooks/useCallbackOnce/ko/useCallbackOnce.md b/packages/react-simplikit/src/hooks/useCallbackOnce/ko/useCallbackOnce.md new file mode 100644 index 00000000..1ba0bdc2 --- /dev/null +++ b/packages/react-simplikit/src/hooks/useCallbackOnce/ko/useCallbackOnce.md @@ -0,0 +1,65 @@ +# useCallbackOnce + +`deps`가 바뀌기 전까지는 반환된 함수를 몇 번 호출하든 콜백을 한 번만 실행하는 리액트 훅이에요. 이는 컴포넌트가 다시 렌더링되더라도 반복되지 말아야 하는 일회성 작업에 유용해요. v0에서는 이 훅의 이름이 `useCallbackOncePerRender`였어요. 이전 이름도 계속 동작하지만 deprecated 상태예요. + +## 인터페이스 + +```ts +function useCallbackOnce void>( + callback: F, + deps: DependencyList +): (...args: Parameters) => void; +``` + +### 파라미터 + + + + + +### 반환 값 + + + +## 예시 + +```tsx +import { useCallbackOnce } from 'react-simplikit'; + +function Component() { + const handleOneTimeEvent = useCallbackOnce(() => { + console.log('이것은 한 번만 실행될 거예요'); + }, []); + + return ; +} +``` + +```tsx +// 의존성과 함께 사용하는 경우 +function TrackingComponent({ userId }: { userId: string }) { + const trackUserVisit = useCallbackOnce(() => { + analytics.trackVisit(userId); + }, [userId]); + + useEffect(() => { + trackUserVisit(); + }, [trackUserVisit, userId]); + + return
사용자 페이지
; +} +``` diff --git a/packages/react-simplikit/src/hooks/useCallbackOnce/useCallbackOnce.md b/packages/react-simplikit/src/hooks/useCallbackOnce/useCallbackOnce.md new file mode 100644 index 00000000..b4c5b0d9 --- /dev/null +++ b/packages/react-simplikit/src/hooks/useCallbackOnce/useCallbackOnce.md @@ -0,0 +1,67 @@ +# useCallbackOnce + +`useCallbackOnce` is a React hook that runs a callback only once until `deps` change, no matter how many times the returned function is called. +This is useful for one-time operations that should not be repeated, even if the component re-renders. +In v0 this hook was named `useCallbackOncePerRender`. The old name still works but is deprecated. + +## Interface + +```ts +function useCallbackOnce void>( + callback: F, + deps: DependencyList +): (...args: Parameters) => void; +``` + +### Parameters + + + + + +### Return Value + + + +## Example + +```tsx +import { useCallbackOnce } from 'react-simplikit'; + +function Component() { + const handleOneTimeEvent = useCallbackOnce(() => { + console.log('This will only run once'); + }, []); + + return ; +} +``` + +```tsx +// With dependencies +function TrackingComponent({ userId }: { userId: string }) { + const trackUserVisit = useCallbackOnce(() => { + analytics.trackVisit(userId); + }, [userId]); + + useEffect(() => { + trackUserVisit(); + }, [trackUserVisit, userId]); + + return
User page
; +} +``` diff --git a/packages/react-simplikit/src/hooks/useCallbackOncePerRender/useCallbackOncePerRender.spec.ts b/packages/react-simplikit/src/hooks/useCallbackOnce/useCallbackOnce.test.ts similarity index 73% rename from packages/react-simplikit/src/hooks/useCallbackOncePerRender/useCallbackOncePerRender.spec.ts rename to packages/react-simplikit/src/hooks/useCallbackOnce/useCallbackOnce.test.ts index 102feb6c..e3cf3507 100644 --- a/packages/react-simplikit/src/hooks/useCallbackOncePerRender/useCallbackOncePerRender.spec.ts +++ b/packages/react-simplikit/src/hooks/useCallbackOnce/useCallbackOnce.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it, vi } from 'vitest'; import { renderHookSSR } from '../../_internal/test-utils/renderHookSSR.tsx'; +import { useCallbackOnce } from './useCallbackOnce.ts'; import { useCallbackOncePerRender } from './useCallbackOncePerRender.ts'; function useCaller(callback: (...args: any) => any, deps: DependencyList) { @@ -13,20 +14,17 @@ function useCaller(callback: (...args: any) => any, deps: DependencyList) { }, deps); } -describe('useCallbackOncePerRender', () => { +describe('useCallbackOnce', () => { it('is safe on server side rendering', () => { const mockFn = vi.fn(); - renderHookSSR.serverOnly(() => useCallbackOncePerRender(mockFn, [])); + renderHookSSR.serverOnly(() => useCallbackOnce(mockFn, [])); }); it('should execute callback only once', async () => { const mockFn = vi.fn(); - const { rerender } = await renderHookSSR( - ({ effect }) => useCaller(useCallbackOncePerRender(mockFn, []), [effect]), - { - initialProps: { effect: 0 }, - } - ); + const { rerender } = await renderHookSSR(({ effect }) => useCaller(useCallbackOnce(mockFn, []), [effect]), { + initialProps: { effect: 0 }, + }); rerender({ effect: 1 }); rerender({ effect: 2 }); @@ -38,7 +36,7 @@ describe('useCallbackOncePerRender', () => { it('should reset and execute again when dependencies change', async () => { const mockFn = vi.fn(); const { rerender } = await renderHookSSR( - ({ effect, call }) => useCaller(useCallbackOncePerRender(mockFn, [call]), [effect, call]), + ({ effect, call }) => useCaller(useCallbackOnce(mockFn, [call]), [effect, call]), { initialProps: { effect: 0, call: 0 }, } @@ -55,8 +53,12 @@ describe('useCallbackOncePerRender', () => { it('should pass arguments to callback', async () => { const mockFn = vi.fn(); - const { result } = await renderHookSSR(() => useCallbackOncePerRender(mockFn, [])); + const { result } = await renderHookSSR(() => useCallbackOnce(mockFn, [])); result.current('test', 123); expect(mockFn).toHaveBeenCalledWith('test', 123); }); + + it('is also exported under the deprecated name useCallbackOncePerRender', () => { + expect(useCallbackOncePerRender).toBe(useCallbackOnce); + }); }); diff --git a/packages/react-simplikit/src/hooks/useCallbackOncePerRender/useCallbackOncePerRender.ts b/packages/react-simplikit/src/hooks/useCallbackOnce/useCallbackOnce.ts similarity index 60% rename from packages/react-simplikit/src/hooks/useCallbackOncePerRender/useCallbackOncePerRender.ts rename to packages/react-simplikit/src/hooks/useCallbackOnce/useCallbackOnce.ts index ded97fc9..2fc02519 100644 --- a/packages/react-simplikit/src/hooks/useCallbackOncePerRender/useCallbackOncePerRender.ts +++ b/packages/react-simplikit/src/hooks/useCallbackOnce/useCallbackOnce.ts @@ -5,20 +5,21 @@ import { usePreservedCallback } from '../usePreservedCallback/index.ts'; /** * @description - * `useCallbackOncePerRender` is a React hook that ensures a callback function is executed only once, regardless of how many times it's called. + * `useCallbackOnce` is a React hook that runs a callback only once until `deps` change, no matter how many times the returned function is called. * This is useful for one-time operations that should not be repeated, even if the component re-renders. + * In v0 this hook was named `useCallbackOncePerRender`. The old name still works but is deprecated. * * @template {(...args: any[]) => void} F - The type of the callback function. - * @param {() => void} callback - The callback function to be executed once. - * @param {DependencyList} deps - Dependencies array that will trigger a new one-time execution when changed. + * @param {F} callback - The callback function to be executed once. It receives the arguments passed to the returned function. + * @param {DependencyList} deps - Dependencies array. When it changes, the returned function can run the callback once more. * - * @returns {(...args: any[]) => void} A memoized function that will only execute once until dependencies change. + * @returns {(...args: Parameters) => void} A function whose reference never changes. It runs the callback only once until `deps` change. * * @example - * import { useCallbackOncePerRender } from 'react-simplikit'; + * import { useCallbackOnce } from 'react-simplikit'; * * function Component() { - * const handleOneTimeEvent = useCallbackOncePerRender(() => { + * const handleOneTimeEvent = useCallbackOnce(() => { * console.log('This will only run once'); * }, []); * @@ -28,18 +29,18 @@ import { usePreservedCallback } from '../usePreservedCallback/index.ts'; * @example * // With dependencies * function TrackingComponent({ userId }: { userId: string }) { - * const trackUserVisit = useCallbackOncePerRender(() => { + * const trackUserVisit = useCallbackOnce(() => { * analytics.trackVisit(userId); * }, [userId]); * * useEffect(() => { * trackUserVisit(); - * }, [trackUserVisit]); + * }, [trackUserVisit, userId]); * * return
User page
; * } */ -export function useCallbackOncePerRender void>(callback: F, deps: DependencyList) { +export function useCallbackOnce void>(callback: F, deps: DependencyList) { // Same reason as `useAsyncEffect`: the body itself is compiler-clean, but React Compiler // bails on any function carrying a React ESLint suppression, and the // `react-hooks/exhaustive-deps` suppression below is unavoidable for a caller-supplied `deps`. diff --git a/packages/react-simplikit/src/hooks/useCallbackOnce/useCallbackOncePerRender.ts b/packages/react-simplikit/src/hooks/useCallbackOnce/useCallbackOncePerRender.ts new file mode 100644 index 00000000..ae6593b1 --- /dev/null +++ b/packages/react-simplikit/src/hooks/useCallbackOnce/useCallbackOncePerRender.ts @@ -0,0 +1,9 @@ +import { useCallbackOnce } from './useCallbackOnce.ts'; + +/** + * @description + * `useCallbackOncePerRender` is the name `useCallbackOnce` had in v0. + * + * @deprecated Use `useCallbackOnce` instead. + */ +export const useCallbackOncePerRender = useCallbackOnce; diff --git a/packages/react-simplikit/src/hooks/useCallbackOnce/zh-Hans/useCallbackOnce.md b/packages/react-simplikit/src/hooks/useCallbackOnce/zh-Hans/useCallbackOnce.md new file mode 100644 index 00000000..1cd584dd --- /dev/null +++ b/packages/react-simplikit/src/hooks/useCallbackOnce/zh-Hans/useCallbackOnce.md @@ -0,0 +1,65 @@ +# useCallbackOnce + +`useCallbackOnce` 是一个 React Hook,在 `deps` 变化之前,无论返回的函数被调用多少次,它都只会执行一次回调函数。它适用于那些即使组件重复渲染也不应重复执行的一次性操作。在 v0 中,这个 Hook 名为 `useCallbackOncePerRender`。旧名称仍然可用,但已弃用。 + +## 接口 + +```ts +function useCallbackOnce void>( + callback: F, + deps: DependencyList +): (...args: Parameters) => void; +``` + +### 参数 + + + + + +### 返回值 + + + +## 示例 + +```tsx +import { useCallbackOnce } from 'react-simplikit'; + +function Component() { + const handleOneTimeEvent = useCallbackOnce(() => { + console.log('This will only run once'); + }, []); + + return ; +} +``` + +```tsx +// With dependencies +function TrackingComponent({ userId }: { userId: string }) { + const trackUserVisit = useCallbackOnce(() => { + analytics.trackVisit(userId); + }, [userId]); + + useEffect(() => { + trackUserVisit(); + }, [trackUserVisit, userId]); + + return
User page
; +} +``` diff --git a/packages/react-simplikit/src/hooks/useCallbackOncePerRender/es/useCallbackOncePerRender.md b/packages/react-simplikit/src/hooks/useCallbackOncePerRender/es/useCallbackOncePerRender.md deleted file mode 100644 index 21699e92..00000000 --- a/packages/react-simplikit/src/hooks/useCallbackOncePerRender/es/useCallbackOncePerRender.md +++ /dev/null @@ -1,66 +0,0 @@ -# useCallbackOncePerRender - -`useCallbackOncePerRender` es un Hook de React que garantiza que un callback se ejecute una sola vez, sin importar cuántas veces lo llames. -Te resulta útil para operaciones que solo deben ejecutarse una vez, incluso si el componente vuelve a renderizarse. - -## Interfaz - -```ts -function useCallbackOncePerRender void>( - callback: () => void, - deps: DependencyList -): (...args: any[]) => void; -``` - -### Parámetros - - - - - -### Valor de retorno - - - -## Ejemplo - -```tsx -import { useCallbackOncePerRender } from 'react-simplikit'; - -function Component() { - const handleOneTimeEvent = useCallbackOncePerRender(() => { - console.log('Esto solo se ejecutará una vez'); - }, []); - - return ; -} -``` - -```tsx -// Con dependencias -function TrackingComponent({ userId }: { userId: string }) { - const trackUserVisit = useCallbackOncePerRender(() => { - analytics.trackVisit(userId); - }, [userId]); - - useEffect(() => { - trackUserVisit(); - }, [trackUserVisit]); - - return
Página del usuario
; -} -``` diff --git a/packages/react-simplikit/src/hooks/useCallbackOncePerRender/ko/useCallbackOncePerRender.md b/packages/react-simplikit/src/hooks/useCallbackOncePerRender/ko/useCallbackOncePerRender.md deleted file mode 100644 index 57eaec16..00000000 --- a/packages/react-simplikit/src/hooks/useCallbackOncePerRender/ko/useCallbackOncePerRender.md +++ /dev/null @@ -1,65 +0,0 @@ -# useCallbackOncePerRender - -리액트 훅으로, 콜백 함수가 한 번만 실행되도록 보장해요. 몇 번 호출되든 간에 단 한 번만 실행돼요. 이는 컴포넌트가 다시 렌더링되더라도 반복되지 말아야 하는 일회성 작업에 유용해요. - -## 인터페이스 - -```ts -function useCallbackOncePerRender void>( - callback: () => void, - deps: DependencyList -): (...args: any[]) => void; -``` - -### 파라미터 - - - - - -### 반환 값 - - - -## 예시 - -```tsx -import { useCallbackOncePerRender } from 'react-simplikit'; - -function Component() { - const handleOneTimeEvent = useCallbackOncePerRender(() => { - console.log('이것은 한 번만 실행될 거예요'); - }, []); - - return ; -} -``` - -```tsx -// 의존성과 함께 사용하는 경우 -function TrackingComponent({ userId }: { userId: string }) { - const trackUserVisit = useCallbackOncePerRender(() => { - analytics.trackVisit(userId); - }, [userId]); - - useEffect(() => { - trackUserVisit(); - }, [trackUserVisit]); - - return
사용자 페이지
; -} -``` diff --git a/packages/react-simplikit/src/hooks/useCallbackOncePerRender/useCallbackOncePerRender.md b/packages/react-simplikit/src/hooks/useCallbackOncePerRender/useCallbackOncePerRender.md deleted file mode 100644 index 00af226b..00000000 --- a/packages/react-simplikit/src/hooks/useCallbackOncePerRender/useCallbackOncePerRender.md +++ /dev/null @@ -1,66 +0,0 @@ -# useCallbackOncePerRender - -`useCallbackOncePerRender` is a React hook that ensures a callback function is executed only once, regardless of how many times it's called. -This is useful for one-time operations that should not be repeated, even if the component re-renders. - -## Interface - -```ts -function useCallbackOncePerRender void>( - callback: () => void, - deps: DependencyList -): (...args: any[]) => void; -``` - -### Parameters - - - - - -### Return Value - - - -## Example - -```tsx -import { useCallbackOncePerRender } from 'react-simplikit'; - -function Component() { - const handleOneTimeEvent = useCallbackOncePerRender(() => { - console.log('This will only run once'); - }, []); - - return ; -} -``` - -```tsx -// With dependencies -function TrackingComponent({ userId }: { userId: string }) { - const trackUserVisit = useCallbackOncePerRender(() => { - analytics.trackVisit(userId); - }, [userId]); - - useEffect(() => { - trackUserVisit(); - }, [trackUserVisit]); - - return
User page
; -} -``` diff --git a/packages/react-simplikit/src/hooks/useCallbackOncePerRender/zh-Hans/useCallbackOncePerRender.md b/packages/react-simplikit/src/hooks/useCallbackOncePerRender/zh-Hans/useCallbackOncePerRender.md deleted file mode 100644 index 9324d643..00000000 --- a/packages/react-simplikit/src/hooks/useCallbackOncePerRender/zh-Hans/useCallbackOncePerRender.md +++ /dev/null @@ -1,65 +0,0 @@ -# useCallbackOncePerRender - -`useCallbackOncePerRender` 是一个确保回调函数无论被调用多少次都只执行一次的 React Hook。它适用于那些即使组件重复渲染也不应重复执行的一次性操作。 - -## 接口 - -```ts -function useCallbackOncePerRender void>( - callback: () => void, - deps: DependencyList -): (...args: any[]) => void; -``` - -### 参数 - - - - - -### 返回值 - - - -## 示例 - -```tsx -import { useCallbackOncePerRender } from 'react-simplikit'; - -function Component() { - const handleOneTimeEvent = useCallbackOncePerRender(() => { - console.log('This will only run once'); - }, []); - - return ; -} -``` - -```tsx -// With dependencies -function TrackingComponent({ userId }: { userId: string }) { - const trackUserVisit = useCallbackOncePerRender(() => { - analytics.trackVisit(userId); - }, [userId]); - - useEffect(() => { - trackUserVisit(); - }, [trackUserVisit]); - - return
User page
; -} -``` diff --git a/packages/react-simplikit/src/hooks/useCounter/es/useCounter.md b/packages/react-simplikit/src/hooks/useCounter/es/useCounter.md index 0d337be0..4ff4635a 100644 --- a/packages/react-simplikit/src/hooks/useCounter/es/useCounter.md +++ b/packages/react-simplikit/src/hooks/useCounter/es/useCounter.md @@ -54,7 +54,7 @@ function useCounter( @@ -96,7 +96,7 @@ function useCounter( import { useCounter } from 'react-simplikit'; function ShoppingCart() { - const { count, increment, decrement, reset } = useCounter(1, { + const [count, { increment, decrement, reset }] = useCounter(1, { min: 1, max: 10, }); diff --git a/packages/react-simplikit/src/hooks/useCounter/ja/useCounter.md b/packages/react-simplikit/src/hooks/useCounter/ja/useCounter.md index b5ea07fe..c112f799 100644 --- a/packages/react-simplikit/src/hooks/useCounter/ja/useCounter.md +++ b/packages/react-simplikit/src/hooks/useCounter/ja/useCounter.md @@ -54,7 +54,7 @@ function useCounter( @@ -96,7 +96,7 @@ function useCounter( import { useCounter } from 'react-simplikit'; function ShoppingCart() { - const { count, increment, decrement, reset } = useCounter(1, { + const [count, { increment, decrement, reset }] = useCounter(1, { min: 1, max: 10, }); diff --git a/packages/react-simplikit/src/hooks/useCounter/ko/useCounter.md b/packages/react-simplikit/src/hooks/useCounter/ko/useCounter.md index baff591b..28ff3849 100644 --- a/packages/react-simplikit/src/hooks/useCounter/ko/useCounter.md +++ b/packages/react-simplikit/src/hooks/useCounter/ko/useCounter.md @@ -53,7 +53,7 @@ function useCounter( @@ -95,7 +95,7 @@ function useCounter( import { useCounter } from 'react-simplikit'; function ShoppingCart() { - const { count, increment, decrement, reset } = useCounter(1, { + const [count, { increment, decrement, reset }] = useCounter(1, { min: 1, max: 10, }); diff --git a/packages/react-simplikit/src/hooks/useCounter/useCounter.md b/packages/react-simplikit/src/hooks/useCounter/useCounter.md index fee36ceb..ec884f43 100644 --- a/packages/react-simplikit/src/hooks/useCounter/useCounter.md +++ b/packages/react-simplikit/src/hooks/useCounter/useCounter.md @@ -54,7 +54,7 @@ function useCounter( @@ -96,7 +96,7 @@ function useCounter( import { useCounter } from 'react-simplikit'; function ShoppingCart() { - const { count, increment, decrement, reset } = useCounter(1, { + const [count, { increment, decrement, reset }] = useCounter(1, { min: 1, max: 10, }); diff --git a/packages/react-simplikit/src/hooks/useCounter/useCounter.spec.ts b/packages/react-simplikit/src/hooks/useCounter/useCounter.spec.ts index 92784749..950033b0 100644 --- a/packages/react-simplikit/src/hooks/useCounter/useCounter.spec.ts +++ b/packages/react-simplikit/src/hooks/useCounter/useCounter.spec.ts @@ -7,50 +7,50 @@ describe('useCounter', () => { it('should initialize with default value', () => { const { result } = renderHook(() => useCounter()); - expect(result.current.count).toBe(0); + expect(result.current[0]).toBe(0); }); it('should initialize with provided initial value', () => { const { result } = renderHook(() => useCounter(10)); - expect(result.current.count).toBe(10); + expect(result.current[0]).toBe(10); }); it('should increment the counter', async () => { const { result } = renderHook(() => useCounter(5)); await act(async () => { - result.current.increment(); + result.current[1].increment(); }); - expect(result.current.count).toBe(6); + expect(result.current[0]).toBe(6); }); it('should decrement the counter', async () => { const { result } = renderHook(() => useCounter(5)); await act(async () => { - result.current.decrement(); + result.current[1].decrement(); }); - expect(result.current.count).toBe(4); + expect(result.current[0]).toBe(4); }); it('should reset the counter to initial value', async () => { const { result } = renderHook(() => useCounter(5)); await act(async () => { - result.current.increment(); - result.current.increment(); + result.current[1].increment(); + result.current[1].increment(); }); - expect(result.current.count).toBe(7); + expect(result.current[0]).toBe(7); await act(async () => { - result.current.reset(); + result.current[1].reset(); }); - expect(result.current.count).toBe(5); + expect(result.current[0]).toBe(5); }); it('should not go below minimum value', async () => { @@ -61,12 +61,12 @@ describe('useCounter', () => { ); await act(async () => { - result.current.decrement(); - result.current.decrement(); - result.current.decrement(); + result.current[1].decrement(); + result.current[1].decrement(); + result.current[1].decrement(); }); - expect(result.current.count).toBe(3); + expect(result.current[0]).toBe(3); }); it('should not go above maximum value', async () => { @@ -77,12 +77,12 @@ describe('useCounter', () => { ); await act(async () => { - result.current.increment(); - result.current.increment(); - result.current.increment(); + result.current[1].increment(); + result.current[1].increment(); + result.current[1].increment(); }); - expect(result.current.count).toBe(7); + expect(result.current[0]).toBe(7); }); it('should use the provided step value for increment and decrement', async () => { @@ -93,16 +93,16 @@ describe('useCounter', () => { ); await act(async () => { - result.current.increment(); + result.current[1].increment(); }); - expect(result.current.count).toBe(7); + expect(result.current[0]).toBe(7); await act(async () => { - result.current.decrement(); + result.current[1].decrement(); }); - expect(result.current.count).toBe(5); + expect(result.current[0]).toBe(5); }); it('should adjust initial value to match constraints', () => { @@ -112,7 +112,7 @@ describe('useCounter', () => { }) ); - expect(result.current.count).toBe(3); + expect(result.current[0]).toBe(3); const { result: result2 } = renderHook(() => useCounter(10, { @@ -120,7 +120,7 @@ describe('useCounter', () => { }) ); - expect(result2.current.count).toBe(8); + expect(result2.current[0]).toBe(8); }); it('should allow setting arbitrary value within constraints', async () => { @@ -132,31 +132,43 @@ describe('useCounter', () => { ); await act(async () => { - result.current.setCount(6); + result.current[1].setCount(6); }); - expect(result.current.count).toBe(6); + expect(result.current[0]).toBe(6); await act(async () => { - result.current.setCount(1); + result.current[1].setCount(1); }); - expect(result.current.count).toBe(3); + expect(result.current[0]).toBe(3); await act(async () => { - result.current.setCount(10); + result.current[1].setCount(10); }); - expect(result.current.count).toBe(8); + expect(result.current[0]).toBe(8); }); it('should work with updater function for setCount', async () => { const { result } = renderHook(() => useCounter(5)); await act(async () => { - result.current.setCount(prev => prev + 3); + result.current[1].setCount(prev => prev + 3); }); - expect(result.current.count).toBe(8); + expect(result.current[0]).toBe(8); + }); + + it('should keep the actions object stable across renders', () => { + const { result } = renderHook(() => useCounter(0)); + const [, actions] = result.current; + + act(() => { + actions.increment(); + }); + + expect(result.current[0]).toBe(1); + expect(result.current[1]).toBe(actions); }); }); diff --git a/packages/react-simplikit/src/hooks/useCounter/useCounter.ts b/packages/react-simplikit/src/hooks/useCounter/useCounter.ts index ef8612c5..7dbab755 100644 --- a/packages/react-simplikit/src/hooks/useCounter/useCounter.ts +++ b/packages/react-simplikit/src/hooks/useCounter/useCounter.ts @@ -1,4 +1,4 @@ -import { useCallback, useState } from 'react'; +import { useCallback, useMemo, useState } from 'react'; type UseCounterOptions = { min?: number; @@ -6,14 +6,15 @@ type UseCounterOptions = { step?: number; }; -type UseCounterReturn = { - count: number; +type CounterActions = { increment: () => void; decrement: () => void; reset: () => void; setCount: (value: number | ((prev: number) => number)) => void; }; +type UseCounterReturn = readonly [number, CounterActions]; + const validateValue = (value: number, { min, max }: Omit): number => { if (min !== undefined && value < min) { return min; @@ -37,18 +38,18 @@ const validateValue = (value: number, { min, max }: Omit void` - A function to increment the count; - * - decrement `() => void` - A function to decrement the count; - * - reset `() => void` - A function to reset the count to the initial value; - * - setCount `(value: number | ((prev: number) => number)) => void` - A function to set the count to a specific value or a function that returns a new value; + * - actions.increment `() => void` - A function to increment the count; + * - actions.decrement `() => void` - A function to decrement the count; + * - actions.reset `() => void` - A function to reset the count to the initial value; + * - actions.setCount `(value: number | ((prev: number) => number)) => void` - A function to set the count, either to a value or with an updater function that receives the previous count and returns the next one; * * @example * import { useCounter } from 'react-simplikit'; * * function ShoppingCart() { - * const { count, increment, decrement, reset } = useCounter(1, { + * const [count, { increment, decrement, reset }] = useCounter(1, { * min: 1, * max: 10, * }); @@ -89,11 +90,10 @@ export function useCounter(initialValue = 0, { min, max, step = 1 }: UseCounterO setCount(initialValue); }, [setCount, initialValue]); - return { - count, - increment, - decrement, - reset, - setCount, - }; + const actions = useMemo( + () => ({ increment, decrement, reset, setCount }), + [increment, decrement, reset, setCount] + ); + + return [count, actions] as const; } diff --git a/packages/react-simplikit/src/hooks/useCounter/zh-Hans/useCounter.md b/packages/react-simplikit/src/hooks/useCounter/zh-Hans/useCounter.md index 32ff22ee..1dd97a4f 100644 --- a/packages/react-simplikit/src/hooks/useCounter/zh-Hans/useCounter.md +++ b/packages/react-simplikit/src/hooks/useCounter/zh-Hans/useCounter.md @@ -53,7 +53,7 @@ function useCounter( @@ -95,7 +95,7 @@ function useCounter( import { useCounter } from 'react-simplikit'; function ShoppingCart() { - const { count, increment, decrement, reset } = useCounter(1, { + const [count, { increment, decrement, reset }] = useCounter(1, { min: 1, max: 10, }); diff --git a/packages/react-simplikit/src/hooks/useDebounce/es/useDebounce.md b/packages/react-simplikit/src/hooks/useDebounce/es/useDebounce.md index df9491e0..02670516 100644 --- a/packages/react-simplikit/src/hooks/useDebounce/es/useDebounce.md +++ b/packages/react-simplikit/src/hooks/useDebounce/es/useDebounce.md @@ -3,8 +3,8 @@ `useDebounce` es un Hook de React que devuelve una versión con debounce del callback que proporcionas. Te ayuda a optimizar la gestión de eventos al retrasar la ejecución de la función y agrupar varias llamadas en una sola. -Con las opciones predeterminadas, la última llamada se ejecuta después de que transcurran `wait` milisegundos sin otra llamada. -Las llamadas pendientes se cancelan cuando el componente se desmonta o cuando cambia `wait`, `leading` o `trailing`. +Con las opciones predeterminadas, la última llamada se ejecuta después de que transcurran `debounceMs` milisegundos sin otra llamada. +Las llamadas pendientes se cancelan cuando el componente se desmonta o cuando cambia `debounceMs`, `leading` o `trailing`. Cuando llamas a `.cancel()`, solo cancelas un callback pendiente, no una solicitud de red que ya haya comenzado. El ejemplo muestra la consulta de forma local. Para buscar en un servidor, pasa el callback de búsqueda de tu aplicación como primer argumento a `useDebounce`. @@ -14,7 +14,7 @@ de búsqueda de tu aplicación como primer argumento a `useDebounce`. ```ts function useDebounce unknown>( callback: F, - wait: number, + debounceMs: number, options?: DebounceOptions ): F & { cancel: () => void }; ``` @@ -30,7 +30,7 @@ function useDebounce unknown>( diff --git a/packages/react-simplikit/src/hooks/useDebounce/ja/useDebounce.md b/packages/react-simplikit/src/hooks/useDebounce/ja/useDebounce.md index fbdbbc8f..5da1b45a 100644 --- a/packages/react-simplikit/src/hooks/useDebounce/ja/useDebounce.md +++ b/packages/react-simplikit/src/hooks/useDebounce/ja/useDebounce.md @@ -3,8 +3,8 @@ `useDebounce` は、渡されたコールバック関数をデバウンスする React フックです。 関数の実行を遅らせ、複数の呼び出しを一度にまとめることで、イベント処理を最適化します。 -デフォルトのオプションでは、最後の呼び出しから `wait` ミリ秒の間に次の呼び出しがなければ、その最後の呼び出しが実行されます。 -コンポーネントのアンマウント時、または `wait`、`leading`、`trailing` の変更時には、実行待ちの呼び出しをキャンセルします。 +デフォルトのオプションでは、最後の呼び出しから `debounceMs` ミリ秒の間に次の呼び出しがなければ、その最後の呼び出しが実行されます。 +コンポーネントのアンマウント時、または `debounceMs`、`leading`、`trailing` の変更時には、実行待ちの呼び出しをキャンセルします。 `.cancel()` がキャンセルするのは実行待ちのコールバックだけで、すでに開始されたネットワークリクエストはキャンセルしません。 使用例では、クエリをローカルに表示しています。サーバーで検索するには、アプリケーションの 検索用コールバックを `useDebounce` の第 1 引数に渡してください。 @@ -14,7 +14,7 @@ ```ts function useDebounce unknown>( callback: F, - wait: number, + debounceMs: number, options?: DebounceOptions ): F & { cancel: () => void }; ``` @@ -30,7 +30,7 @@ function useDebounce unknown>( diff --git a/packages/react-simplikit/src/hooks/useDebounce/ko/useDebounce.md b/packages/react-simplikit/src/hooks/useDebounce/ko/useDebounce.md index 6f9869c9..ed41ea6c 100644 --- a/packages/react-simplikit/src/hooks/useDebounce/ko/useDebounce.md +++ b/packages/react-simplikit/src/hooks/useDebounce/ko/useDebounce.md @@ -2,14 +2,14 @@ `useDebounce`는 제공된 콜백 함수의 디바운스 버전을 반환하는 리액트 훅이에요. 함수 실행을 지연시키고 여러 호출을 하나로 그룹화하여 이벤트 처리를 최적화하는 데 도움을 줘요. -기본 옵션에서는 추가 호출 없이 `wait` 밀리초가 지나면 마지막 호출을 실행해요. 컴포넌트가 언마운트되거나 `wait`, `leading`, `trailing`이 바뀌면 대기 중인 호출을 취소해요. `.cancel()`은 대기 중인 콜백만 취소하고 이미 시작한 네트워크 요청은 취소하지 않아요. 예제는 검색어를 로컬에 표시해요. 서버에서 검색하려면 `useDebounce`의 첫 번째 인자로 애플리케이션의 검색 콜백을 전달하세요. +기본 옵션에서는 추가 호출 없이 `debounceMs` 밀리초가 지나면 마지막 호출을 실행해요. 컴포넌트가 언마운트되거나 `debounceMs`, `leading`, `trailing`이 바뀌면 대기 중인 호출을 취소해요. `.cancel()`은 대기 중인 콜백만 취소하고 이미 시작한 네트워크 요청은 취소하지 않아요. 예제는 검색어를 로컬에 표시해요. 서버에서 검색하려면 `useDebounce`의 첫 번째 인자로 애플리케이션의 검색 콜백을 전달하세요. ## 인터페이스 ```ts function useDebounce unknown>( callback: F, - wait: number, + debounceMs: number, options?: DebounceOptions ): F & { cancel: () => void }; ``` @@ -25,7 +25,7 @@ function useDebounce unknown>( diff --git a/packages/react-simplikit/src/hooks/useDebounce/useDebounce.md b/packages/react-simplikit/src/hooks/useDebounce/useDebounce.md index 3ece3aab..375b2c0e 100644 --- a/packages/react-simplikit/src/hooks/useDebounce/useDebounce.md +++ b/packages/react-simplikit/src/hooks/useDebounce/useDebounce.md @@ -3,8 +3,8 @@ `useDebounce` is a React hook that returns a debounced version of the provided callback function. It helps optimize event handling by delaying function execution and grouping multiple calls into one. -With the default options, the last call runs after `wait` milliseconds without another call. -Pending calls are cancelled when the component unmounts or when `wait`, `leading`, or `trailing` changes. +With the default options, the last call runs after `debounceMs` milliseconds without another call. +Pending calls are cancelled when the component unmounts or when `debounceMs`, `leading`, or `trailing` changes. Calling `.cancel()` only cancels a pending callback, not an already-started network request. The example displays the query locally. To search a server, pass your application's search callback as the first argument to `useDebounce`. @@ -14,7 +14,7 @@ search callback as the first argument to `useDebounce`. ```ts function useDebounce unknown>( callback: F, - wait: number, + debounceMs: number, options?: DebounceOptions ): F & { cancel: () => void }; ``` @@ -30,7 +30,7 @@ function useDebounce unknown>( diff --git a/packages/react-simplikit/src/hooks/useDebounce/useDebounce.ts b/packages/react-simplikit/src/hooks/useDebounce/useDebounce.ts index 7e535687..db29d3e0 100644 --- a/packages/react-simplikit/src/hooks/useDebounce/useDebounce.ts +++ b/packages/react-simplikit/src/hooks/useDebounce/useDebounce.ts @@ -16,15 +16,15 @@ type DebounceOptions = { * `useDebounce` is a React hook that returns a debounced version of the provided callback function. * It helps optimize event handling by delaying function execution and grouping multiple calls into one. * - * With the default options, the last call runs after `wait` milliseconds without another call. - * Pending calls are cancelled when the component unmounts or when `wait`, `leading`, or `trailing` changes. + * With the default options, the last call runs after `debounceMs` milliseconds without another call. + * Pending calls are cancelled when the component unmounts or when `debounceMs`, `leading`, or `trailing` changes. * Calling `.cancel()` only cancels a pending callback, not an already-started network request. * The example displays the query locally. To search a server, pass your application's * search callback as the first argument to `useDebounce`. * * @template {(...args: any[]) => unknown} F - The type of the callback function. * @param {F} callback - The function to debounce. - * @param {number} wait - The number of milliseconds to delay the function execution. + * @param {number} debounceMs - The number of milliseconds to delay the function execution. * @param {DebounceOptions} [options] - Configuration options for debounce behavior. * @param {boolean} [options.leading=false] - If `true`, the function is called at the start of the sequence. * @param {boolean} [options.trailing=true] - If `true`, the function is called at the end of the sequence. @@ -63,7 +63,7 @@ type DebounceOptions = { */ export function useDebounce unknown>( callback: F, - wait: number, + debounceMs: number, options: DebounceOptions = {} ): DebouncedFunction { const preservedCallback = usePreservedCallback(callback) as F; @@ -84,8 +84,8 @@ export function useDebounce unknown>( }, [leading, trailing]); const debounced = useMemo(() => { - return debounce(preservedCallback, wait, { edges }); - }, [preservedCallback, wait, edges]); + return debounce(preservedCallback, debounceMs, { edges }); + }, [preservedCallback, debounceMs, edges]); useEffect( function cancelDebouncedOnUnmount() { diff --git a/packages/react-simplikit/src/hooks/useDebounce/zh-Hans/useDebounce.md b/packages/react-simplikit/src/hooks/useDebounce/zh-Hans/useDebounce.md index 22481e46..b00c6990 100644 --- a/packages/react-simplikit/src/hooks/useDebounce/zh-Hans/useDebounce.md +++ b/packages/react-simplikit/src/hooks/useDebounce/zh-Hans/useDebounce.md @@ -2,14 +2,14 @@ `useDebounce` 是一个返回所提供回调函数防抖版本的 React Hook。它通过延迟函数执行并将多次调用合并为一次,帮助优化事件处理。 -使用默认选项时,最后一次调用会在没有再次调用的 `wait` 毫秒后执行。当组件卸载,或 `wait`、`leading`、`trailing` 发生变化时,待执行的调用会被取消。调用 `.cancel()` 只会取消一个待执行的回调,而不会取消一个已经开始的网络请求。该示例在本地显示查询。要搜索服务器,请将应用程序的搜索回调作为第一个参数传给 `useDebounce`。 +使用默认选项时,最后一次调用会在没有再次调用的 `debounceMs` 毫秒后执行。当组件卸载,或 `debounceMs`、`leading`、`trailing` 发生变化时,待执行的调用会被取消。调用 `.cancel()` 只会取消一个待执行的回调,而不会取消一个已经开始的网络请求。该示例在本地显示查询。要搜索服务器,请将应用程序的搜索回调作为第一个参数传给 `useDebounce`。 ## 接口 ```ts function useDebounce unknown>( callback: F, - wait: number, + debounceMs: number, options?: DebounceOptions ): F & { cancel: () => void }; ``` @@ -25,7 +25,7 @@ function useDebounce unknown>( diff --git a/packages/react-simplikit/src/hooks/useDebouncedCallback/es/useDebouncedCallback.md b/packages/react-simplikit/src/hooks/useDebouncedCallback/es/useDebouncedCallback.md index e0299431..036d889c 100644 --- a/packages/react-simplikit/src/hooks/useDebouncedCallback/es/useDebouncedCallback.md +++ b/packages/react-simplikit/src/hooks/useDebouncedCallback/es/useDebouncedCallback.md @@ -27,7 +27,7 @@ function useDebouncedCallback(options: Object): (nextValue: T) => void; 'El callback al que quieres aplicar debounce. Las llamadas con el mismo valor que el último enviado se omiten.', }, { - name: 'options.timeThreshold', + name: 'options.debounceMs', type: 'number', required: true, description: @@ -70,7 +70,7 @@ function SearchInput() { const [query, setQuery] = useState(''); const setQueryDebounced = useDebouncedCallback({ onChange: setQuery, - timeThreshold: 300, + debounceMs: 300, }); return ( diff --git a/packages/react-simplikit/src/hooks/useDebouncedCallback/ja/useDebouncedCallback.md b/packages/react-simplikit/src/hooks/useDebouncedCallback/ja/useDebouncedCallback.md index cbe95f47..ce7b1948 100644 --- a/packages/react-simplikit/src/hooks/useDebouncedCallback/ja/useDebouncedCallback.md +++ b/packages/react-simplikit/src/hooks/useDebouncedCallback/ja/useDebouncedCallback.md @@ -27,7 +27,7 @@ function useDebouncedCallback(options: Object): (nextValue: T) => void; 'デバウンスするコールバックです。最後に渡した値と同じ値での呼び出しはスキップします。', }, { - name: 'options.timeThreshold', + name: 'options.debounceMs', type: 'number', required: true, description: @@ -70,7 +70,7 @@ function SearchInput() { const [query, setQuery] = useState(''); const setQueryDebounced = useDebouncedCallback({ onChange: setQuery, - timeThreshold: 300, + debounceMs: 300, }); return ( diff --git a/packages/react-simplikit/src/hooks/useDebouncedCallback/ko/useDebouncedCallback.md b/packages/react-simplikit/src/hooks/useDebouncedCallback/ko/useDebouncedCallback.md index 16149f59..9f05423b 100644 --- a/packages/react-simplikit/src/hooks/useDebouncedCallback/ko/useDebouncedCallback.md +++ b/packages/react-simplikit/src/hooks/useDebouncedCallback/ko/useDebouncedCallback.md @@ -24,7 +24,7 @@ function useDebouncedCallback(options: Object): (nextValue: T) => void; '디바운스할 콜백이에요. 마지막으로 전달된 값과 같은 값으로 호출하면 건너뛰어요.', }, { - name: 'options.timeThreshold', + name: 'options.debounceMs', type: 'number', required: true, description: @@ -67,7 +67,7 @@ function SearchInput() { const [query, setQuery] = useState(''); const setQueryDebounced = useDebouncedCallback({ onChange: setQuery, - timeThreshold: 300, + debounceMs: 300, }); return ( diff --git a/packages/react-simplikit/src/hooks/useDebouncedCallback/useDebouncedCallback.md b/packages/react-simplikit/src/hooks/useDebouncedCallback/useDebouncedCallback.md index 7c70c45e..2dcc8862 100644 --- a/packages/react-simplikit/src/hooks/useDebouncedCallback/useDebouncedCallback.md +++ b/packages/react-simplikit/src/hooks/useDebouncedCallback/useDebouncedCallback.md @@ -27,7 +27,7 @@ function useDebouncedCallback(options: Object): (nextValue: T) => void; 'The callback to debounce. A call with the same value as the last forwarded one is skipped.', }, { - name: 'options.timeThreshold', + name: 'options.debounceMs', type: 'number', required: true, description: @@ -70,7 +70,7 @@ function SearchInput() { const [query, setQuery] = useState(''); const setQueryDebounced = useDebouncedCallback({ onChange: setQuery, - timeThreshold: 300, + debounceMs: 300, }); return ( diff --git a/packages/react-simplikit/src/hooks/useDebouncedCallback/useDebouncedCallback.spec.ts b/packages/react-simplikit/src/hooks/useDebouncedCallback/useDebouncedCallback.spec.ts index e6c39e91..3b9113ba 100644 --- a/packages/react-simplikit/src/hooks/useDebouncedCallback/useDebouncedCallback.spec.ts +++ b/packages/react-simplikit/src/hooks/useDebouncedCallback/useDebouncedCallback.spec.ts @@ -11,14 +11,14 @@ describe('useDebouncedCallback', () => { it('is safe on server side rendering', () => { const onChange = vi.fn(); - renderHookSSR.serverOnly(() => useDebouncedCallback({ onChange, timeThreshold: 100 })); + renderHookSSR.serverOnly(() => useDebouncedCallback({ onChange, debounceMs: 100 })); expect(onChange).not.toHaveBeenCalled(); }); it('should debounce the callback with the specified time threshold', () => { const onChange = vi.fn(); - const { result } = renderHookSSR(() => useDebouncedCallback({ onChange, timeThreshold: 100 })); + const { result } = renderHookSSR(() => useDebouncedCallback({ onChange, debounceMs: 100 })); result.current(true); expect(onChange).not.toBeCalled(); @@ -44,7 +44,7 @@ describe('useDebouncedCallback', () => { it('should handle leading edge', () => { const onChange = vi.fn(); - const { result } = renderHookSSR(() => useDebouncedCallback({ onChange, timeThreshold: 100, leading: true })); + const { result } = renderHookSSR(() => useDebouncedCallback({ onChange, debounceMs: 100, leading: true })); result.current(true); expect(onChange).toBeCalledTimes(1); @@ -64,7 +64,7 @@ describe('useDebouncedCallback', () => { it('should not trigger callback if value has not changed', () => { const onChange = vi.fn(); - const { result } = renderHookSSR(() => useDebouncedCallback({ onChange, timeThreshold: 100 })); + const { result } = renderHookSSR(() => useDebouncedCallback({ onChange, debounceMs: 100 })); result.current(true); vi.advanceTimersByTime(100); @@ -77,7 +77,7 @@ describe('useDebouncedCallback', () => { it('forwards a string value to onChange', () => { const onChange = vi.fn<(value: string) => void>(); - const { result } = renderHookSSR(() => useDebouncedCallback({ onChange, timeThreshold: 100 })); + const { result } = renderHookSSR(() => useDebouncedCallback({ onChange, debounceMs: 100 })); result.current('react'); vi.advanceTimersByTime(100); @@ -87,7 +87,7 @@ describe('useDebouncedCallback', () => { it('invokes the callback when the first value is false', () => { const onChange = vi.fn(); - const { result } = renderHookSSR(() => useDebouncedCallback({ onChange, timeThreshold: 100 })); + const { result } = renderHookSSR(() => useDebouncedCallback({ onChange, debounceMs: 100 })); result.current(false); vi.advanceTimersByTime(100); @@ -98,7 +98,7 @@ describe('useDebouncedCallback', () => { it('discards a pending value when the caller returns to the last forwarded one', () => { const onChange = vi.fn(); - const { result } = renderHookSSR(() => useDebouncedCallback({ onChange, timeThreshold: 100 })); + const { result } = renderHookSSR(() => useDebouncedCallback({ onChange, debounceMs: 100 })); result.current('seoul'); vi.advanceTimersByTime(100); @@ -114,7 +114,7 @@ describe('useDebouncedCallback', () => { it('should cleanup on unmount', async () => { const onChange = vi.fn(); - const { result, unmount } = await renderHookSSR(() => useDebouncedCallback({ onChange, timeThreshold: 100 })); + const { result, unmount } = await renderHookSSR(() => useDebouncedCallback({ onChange, debounceMs: 100 })); result.current(true); unmount(); @@ -125,7 +125,7 @@ describe('useDebouncedCallback', () => { it('debounces a stream of distinct values when leading is true', () => { const onChange = vi.fn(); - const { result } = renderHookSSR(() => useDebouncedCallback({ onChange, timeThreshold: 100, leading: true })); + const { result } = renderHookSSR(() => useDebouncedCallback({ onChange, debounceMs: 100, leading: true })); for (const value of ['a', 'b', 'c']) { result.current(value); @@ -140,7 +140,7 @@ describe('useDebouncedCallback', () => { it('forwards only the first value of a burst when trailing is false', () => { const onChange = vi.fn(); const { result } = renderHookSSR(() => - useDebouncedCallback({ onChange, timeThreshold: 100, leading: true, trailing: false }) + useDebouncedCallback({ onChange, debounceMs: 100, leading: true, trailing: false }) ); for (const value of ['a', 'b', 'c']) { diff --git a/packages/react-simplikit/src/hooks/useDebouncedCallback/useDebouncedCallback.ts b/packages/react-simplikit/src/hooks/useDebouncedCallback/useDebouncedCallback.ts index d1250e18..017a8c4b 100644 --- a/packages/react-simplikit/src/hooks/useDebouncedCallback/useDebouncedCallback.ts +++ b/packages/react-simplikit/src/hooks/useDebouncedCallback/useDebouncedCallback.ts @@ -1,6 +1,6 @@ import { useRef } from 'react'; -import { useDebounce } from '../useDebounce/index.ts'; +import { useDebounce } from '../_internal/useDebounce/useDebounce.ts'; type DebounceOptions = { leading?: boolean; @@ -26,7 +26,7 @@ const NOT_INVOKED = Symbol('NOT_INVOKED'); * @template T - The type of the value passed to `onChange`. * @param {Object} options - The options object. * @param {(newValue: T) => void} options.onChange - The callback to debounce. A call with the same value as the last forwarded one is skipped. - * @param {number} options.timeThreshold - The number of milliseconds to delay the function execution. + * @param {number} options.debounceMs - The number of milliseconds to delay the function execution. * @param {boolean} [options.leading=false] - If `true`, the function is called at the start of the sequence. * @param {boolean} [options.trailing=true] - If `true`, the function is called at the end of the sequence. * @@ -38,7 +38,7 @@ const NOT_INVOKED = Symbol('NOT_INVOKED'); * * function SearchInput() { * const [query, setQuery] = useState(''); - * const setQueryDebounced = useDebouncedCallback({ onChange: setQuery, timeThreshold: 300 }); + * const setQueryDebounced = useDebouncedCallback({ onChange: setQuery, debounceMs: 300 }); * * return ( * <> @@ -50,12 +50,12 @@ const NOT_INVOKED = Symbol('NOT_INVOKED'); */ export function useDebouncedCallback({ onChange, - timeThreshold, + debounceMs, leading = false, trailing = true, }: DebounceOptions & { onChange: (newValue: T) => void; - timeThreshold: number; + debounceMs: number; }): (nextValue: T) => void { const lastForwardedRef = useRef(NOT_INVOKED); @@ -69,7 +69,7 @@ export function useDebouncedCallback({ lastForwardedRef.current = nextValue; }, - timeThreshold, + debounceMs, { leading, trailing } ); } diff --git a/packages/react-simplikit/src/hooks/useDebouncedCallback/zh-Hans/useDebouncedCallback.md b/packages/react-simplikit/src/hooks/useDebouncedCallback/zh-Hans/useDebouncedCallback.md index 0bf0c5fe..1974015e 100644 --- a/packages/react-simplikit/src/hooks/useDebouncedCallback/zh-Hans/useDebouncedCallback.md +++ b/packages/react-simplikit/src/hooks/useDebouncedCallback/zh-Hans/useDebouncedCallback.md @@ -26,7 +26,7 @@ function useDebouncedCallback(options: Object): (nextValue: T) => void; '要被防抖的回调。携带与上一次转发的值相同的调用会被跳过。', }, { - name: 'options.timeThreshold', + name: 'options.debounceMs', type: 'number', required: true, description: @@ -69,7 +69,7 @@ function SearchInput() { const [query, setQuery] = useState(''); const setQueryDebounced = useDebouncedCallback({ onChange: setQuery, - timeThreshold: 300, + debounceMs: 300, }); return ( diff --git a/packages/react-simplikit/src/hooks/useDebouncedState/es/useDebouncedState.md b/packages/react-simplikit/src/hooks/useDebouncedState/es/useDebouncedState.md new file mode 100644 index 00000000..3a6cb7ff --- /dev/null +++ b/packages/react-simplikit/src/hooks/useDebouncedState/es/useDebouncedState.md @@ -0,0 +1,99 @@ +# useDebouncedState + +`useDebouncedState` es un Hook de React que gestiona el estado y devuelve una función de actualización con debounce. Con las opciones predeterminadas, el estado se actualiza `debounceMs` milisegundos después de tu última llamada a la función de actualización. Es útil para aplicar solo el último valor de una serie de solicitudes de actualización rápidas, como las de un campo de búsqueda. + +El valor inicial se devuelve sin cambios en el primer renderizado y en el servidor. No se programa ninguna actualización al montar el componente, por lo que, con `leading: true`, tu primera llamada a la función de actualización después del montaje se aplica de inmediato. Si tanto `leading` como `trailing` son `false`, el estado nunca se actualiza. + +Las actualizaciones pendientes se cancelan cuando el componente se desmonta o cuando cambian `debounceMs`, `leading` o `trailing`. + +## Interfaz + +```ts +function useDebouncedState( + initialValue: T, + debounceMs: number, + options?: DebounceOptions +): [state: T, setState: Dispatch>]; +``` + +### Parámetros + + + + + + + +### Valor de retorno + + + +## Ejemplo + +```tsx +import { useDebouncedState } from 'react-simplikit'; + +function SearchInput() { + const [query, setQuery] = useDebouncedState('', 300); + + return ( + <> + + {query} + + ); +} +``` diff --git a/packages/react-simplikit/src/hooks/useDebouncedState/index.ts b/packages/react-simplikit/src/hooks/useDebouncedState/index.ts new file mode 100644 index 00000000..dc79e5b9 --- /dev/null +++ b/packages/react-simplikit/src/hooks/useDebouncedState/index.ts @@ -0,0 +1 @@ +export { useDebouncedState } from './useDebouncedState.ts'; diff --git a/packages/react-simplikit/src/hooks/useDebouncedState/ja/useDebouncedState.md b/packages/react-simplikit/src/hooks/useDebouncedState/ja/useDebouncedState.md new file mode 100644 index 00000000..a525e01f --- /dev/null +++ b/packages/react-simplikit/src/hooks/useDebouncedState/ja/useDebouncedState.md @@ -0,0 +1,99 @@ +# useDebouncedState + +`useDebouncedState` は、状態を管理し、デバウンスされたセッターを返す React フックです。デフォルトの設定では、最後にセッターを呼び出してから `debounceMs` ミリ秒後に状態が更新されます。検索入力のように更新リクエストが連続して発生する場合に、最後の値だけを反映するのに便利です。 + +初回レンダリング時とサーバー上では、初期値をそのまま返します。マウント時には更新を予約しないため、`leading: true` の場合、マウント後の最初のセッター呼び出しが即座に反映されます。`leading` と `trailing` が両方とも `false` の場合、状態は更新されません。 + +コンポーネントがアンマウントされたとき、または `debounceMs`、`leading`、`trailing` が変更されたときは、保留中の更新がキャンセルされます。 + +## インターフェース + +```ts +function useDebouncedState( + initialValue: T, + debounceMs: number, + options?: DebounceOptions +): [state: T, setState: Dispatch>]; +``` + +### パラメータ + + + + + + + +### 戻り値 + + + +## 使用例 + +```tsx +import { useDebouncedState } from 'react-simplikit'; + +function SearchInput() { + const [query, setQuery] = useDebouncedState('', 300); + + return ( + <> + + {query} + + ); +} +``` diff --git a/packages/react-simplikit/src/hooks/useDebouncedState/ko/useDebouncedState.md b/packages/react-simplikit/src/hooks/useDebouncedState/ko/useDebouncedState.md new file mode 100644 index 00000000..d4573f57 --- /dev/null +++ b/packages/react-simplikit/src/hooks/useDebouncedState/ko/useDebouncedState.md @@ -0,0 +1,99 @@ +# useDebouncedState + +`useDebouncedState`는 상태를 직접 관리하고, 디바운스가 적용된 setter를 반환하는 리액트 훅이에요. 기본 설정에서는 마지막 setter 호출 뒤 `debounceMs` 밀리초가 지나면 상태가 갱신돼요. 검색 입력처럼 연속으로 발생하는 변경 요청을 마지막 값으로 모아 반영할 때 유용해요. + +첫 렌더와 서버 렌더에서는 초기값을 그대로 반환해요. 마운트 시점에는 갱신을 예약하지 않으므로, `leading: true`이면 마운트 뒤 첫 setter 호출이 즉시 반영돼요. `leading`과 `trailing`이 모두 `false`이면 상태가 갱신되지 않아요. + +컴포넌트가 언마운트되거나 `debounceMs`, `leading`, `trailing`이 변경되면 대기 중인 갱신을 취소해요. + +## 인터페이스 + +```ts +function useDebouncedState( + initialValue: T, + debounceMs: number, + options?: DebounceOptions +): [state: T, setState: Dispatch>]; +``` + +### 파라미터 + + + + + + + +### 반환 값 + + + +## 예시 + +```tsx +import { useDebouncedState } from 'react-simplikit'; + +function SearchInput() { + const [query, setQuery] = useDebouncedState('', 300); + + return ( + <> + + {query} + + ); +} +``` diff --git a/packages/react-simplikit/src/hooks/useDebouncedState/useDebouncedState.md b/packages/react-simplikit/src/hooks/useDebouncedState/useDebouncedState.md new file mode 100644 index 00000000..0b8f0500 --- /dev/null +++ b/packages/react-simplikit/src/hooks/useDebouncedState/useDebouncedState.md @@ -0,0 +1,105 @@ +# useDebouncedState + +`useDebouncedState` is a React hook that manages state and returns a debounced setter. +With the default options, the state updates `debounceMs` milliseconds after the last setter call. +It is useful for applying only the last value from a series of rapid update requests, such as search input. + +The initial value is returned as is on the first render and on the server. No update is scheduled +on mount, so with `leading: true` the first setter call after mount is applied immediately. +If both `leading` and `trailing` are `false`, the state never updates. + +Pending updates are cancelled when the component unmounts or when `debounceMs`, `leading`, or +`trailing` changes. + +## Interface + +```ts +function useDebouncedState( + initialValue: T, + debounceMs: number, + options?: DebounceOptions +): [state: T, setState: Dispatch>]; +``` + +### Parameters + + + + + + + +### Return Value + + + +## Example + +```tsx +import { useDebouncedState } from 'react-simplikit'; + +function SearchInput() { + const [query, setQuery] = useDebouncedState('', 300); + + return ( + <> + + {query} + + ); +} +``` diff --git a/packages/react-simplikit/src/hooks/useDebouncedState/useDebouncedState.spec.ts b/packages/react-simplikit/src/hooks/useDebouncedState/useDebouncedState.spec.ts new file mode 100644 index 00000000..54995abe --- /dev/null +++ b/packages/react-simplikit/src/hooks/useDebouncedState/useDebouncedState.spec.ts @@ -0,0 +1,279 @@ +import { Dispatch, SetStateAction } from 'react'; +import { act } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { renderHookSSR } from '../../_internal/test-utils/renderHookSSR.tsx'; + +import { useDebouncedState } from './useDebouncedState.ts'; + +function actions(result: { current: [T, Dispatch>] }) { + function getValue() { + return result.current[0]; + } + + function setValue(value: SetStateAction) { + act(() => { + result.current[1](value); + }); + } + + return { getValue, setValue }; +} + +describe('useDebouncedState', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + it('is safe on server side rendering', async () => { + const result = renderHookSSR.serverOnly(() => useDebouncedState('a', 100)); + const { getValue } = actions(result); + expect(getValue()).toBe('a'); + }); + + it('should return the initial value', () => { + const { result } = renderHookSSR(() => useDebouncedState('a', 100)); + const { getValue } = actions(result); + expect(getValue()).toBe('a'); + }); + + it('should update only on the trailing edge by default debounce options', () => { + const { result } = renderHookSSR(() => useDebouncedState('a', 100)); + const { getValue, setValue } = actions(result); + + setValue('b'); + expect(getValue()).toBe('a'); + + act(() => { + vi.advanceTimersByTime(99); + }); + expect(getValue()).toBe('a'); + + act(() => { + vi.advanceTimersByTime(1); + }); + expect(getValue()).toBe('b'); + }); + + it('should debounce continuous state updates', () => { + const { result } = renderHookSSR(() => useDebouncedState('a', 100)); + const { getValue, setValue } = actions(result); + + setValue('b'); + + act(() => { + vi.advanceTimersByTime(50); + }); + + setValue('c'); + expect(getValue()).toBe('a'); + + act(() => { + vi.advanceTimersByTime(50); + }); + expect(getValue()).toBe('a'); + + act(() => { + vi.advanceTimersByTime(59); + }); + expect(getValue()).toBe('c'); + }); + + it('should update on leading and trailing edges when both are enabled', () => { + const { result } = renderHookSSR(() => useDebouncedState('a', 100, { leading: true, trailing: true })); + const { getValue, setValue } = actions(result); + + setValue('b'); + expect(getValue()).toBe('b'); + + act(() => { + vi.advanceTimersByTime(50); + }); + setValue('c'); + + act(() => { + vi.advanceTimersByTime(99); + }); + expect(getValue()).toBe('b'); + + act(() => { + vi.advanceTimersByTime(1); + }); + expect(getValue()).toBe('c'); + }); + + it('should update only on the leading edge when trailing is false', () => { + const { result } = renderHookSSR(() => useDebouncedState('a', 100, { leading: true, trailing: false })); + const { getValue, setValue } = actions(result); + + setValue('b'); + expect(getValue()).toBe('b'); + + act(() => { + vi.advanceTimersByTime(50); + }); + setValue('c'); + expect(getValue()).toBe('b'); + + act(() => { + vi.advanceTimersByTime(150); + }); + expect(getValue()).toBe('b'); + + setValue('d'); + expect(getValue()).toBe('d'); + }); + + it('should not update when both edges are disabled', () => { + const { result } = renderHookSSR(() => useDebouncedState('a', 100, { leading: false, trailing: false })); + const { getValue, setValue } = actions(result); + + setValue('b'); + expect(getValue()).toBe('a'); + + act(() => { + vi.advanceTimersByTime(100); + }); + expect(getValue()).toBe('a'); + }); + + it('should discard a pending value when returning to the current state', () => { + const valueLog: string[] = []; + const { result } = renderHookSSR(() => { + const state = useDebouncedState('a', 100); + valueLog.push(state[0]); + return state; + }); + const { getValue, setValue } = actions(result); + + setValue('b'); + act(() => { + vi.advanceTimersByTime(50); + }); + setValue('a'); + + act(() => { + vi.advanceTimersByTime(50); + }); + act(() => { + vi.advanceTimersByTime(50); + }); + + expect(getValue()).toBe('a'); + expect(valueLog).not.toContain('b'); + }); + + it('should support updater functions followed by direct value updates', () => { + const { result } = renderHookSSR(() => useDebouncedState('a', 100)); + const { getValue, setValue } = actions(result); + + setValue(prev => prev + 'b'); + act(() => { + vi.advanceTimersByTime(100); + }); + expect(getValue()).toBe('ab'); + + setValue('c'); + act(() => { + vi.advanceTimersByTime(100); + }); + expect(getValue()).toBe('c'); + }); + + it('should apply only the last updater function during continuous updates', () => { + const { result } = renderHookSSR(() => useDebouncedState('z', 100)); + const { getValue, setValue } = actions(result); + + setValue(prev => prev + 'b'); + act(() => { + vi.advanceTimersByTime(99); + }); + + setValue(prev => prev + 'c'); + act(() => { + vi.advanceTimersByTime(100); + }); + expect(getValue()).toBe('zc'); + }); + + it('should apply a direct value after an updater without a render in between', () => { + const { result } = renderHookSSR(() => useDebouncedState(0, 100, { leading: true })); + const { getValue, setValue } = actions(result); + + act(() => { + setValue(prev => prev + 1); + setValue(0); + vi.advanceTimersByTime(100); + }); + + expect(getValue()).toBe(0); + }); + + it('should store a function initial value without calling it', () => { + const fn = vi.fn(() => 'result'); + const { result } = renderHookSSR(() => useDebouncedState(fn, 100)); + + expect(result.current[0]).toBe(fn); + expect(fn).not.toHaveBeenCalled(); + }); + + it('should store a function update without calling it', () => { + const initialFn = () => 'initial'; + const nextFn = vi.fn(() => 'next'); + const { result } = renderHookSSR(() => useDebouncedState(initialFn, 100)); + + const { getValue, setValue } = actions(result); + + setValue(() => nextFn); + + act(() => { + vi.advanceTimersByTime(100); + }); + + expect(getValue()).toBe(nextFn); + expect(nextFn).not.toHaveBeenCalled(); + }); + + it('should reset debouncing when debounceMs changes', () => { + const { result, rerender } = renderHookSSR(({ debounceMs }) => useDebouncedState('a', debounceMs), { + initialProps: { debounceMs: 100 }, + }); + const { getValue, setValue } = actions(result); + + setValue('b'); + rerender({ debounceMs: 200 }); + act(() => vi.advanceTimersByTime(200)); + expect(getValue()).toBe('a'); + + setValue('c'); + act(() => vi.advanceTimersByTime(199)); + expect(getValue()).toBe('a'); + act(() => vi.advanceTimersByTime(1)); + expect(getValue()).toBe('c'); + }); + + it('should reset debouncing when debounce options changes', () => { + const { result, rerender } = renderHookSSR(({ leading }) => useDebouncedState('a', 100, { leading }), { + initialProps: { leading: false }, + }); + const { getValue, setValue } = actions(result); + + setValue('b'); + rerender({ leading: true }); + act(() => vi.advanceTimersByTime(100)); + expect(getValue()).toBe('a'); + + setValue('c'); + expect(getValue()).toBe('c'); + }); + + it('should cancel pending updates on unmount', () => { + const { result, unmount } = renderHookSSR(() => useDebouncedState('a', 100)); + const { setValue } = actions(result); + + setValue('b'); + expect(vi.getTimerCount()).toBe(1); + unmount(); + expect(vi.getTimerCount()).toBe(0); + }); +}); diff --git a/packages/react-simplikit/src/hooks/useDebouncedState/useDebouncedState.ts b/packages/react-simplikit/src/hooks/useDebouncedState/useDebouncedState.ts new file mode 100644 index 00000000..2ad9c3d1 --- /dev/null +++ b/packages/react-simplikit/src/hooks/useDebouncedState/useDebouncedState.ts @@ -0,0 +1,84 @@ +import { Dispatch, SetStateAction, useRef, useState } from 'react'; + +import { useDebounce } from '../_internal/useDebounce/useDebounce.ts'; + +type DebounceOptions = { + leading?: boolean; + trailing?: boolean; +}; + +const UPDATER_FORWARDED = Symbol('UPDATER_FORWARDED'); + +/** + * @description + * `useDebouncedState` is a React hook that manages state and returns a debounced setter. + * With the default options, the state updates `debounceMs` milliseconds after the last setter call. + * It is useful for applying only the last value from a series of rapid update requests, such as search input. + * + * The initial value is returned as is on the first render and on the server. No update is scheduled + * on mount, so with `leading: true` the first setter call after mount is applied immediately. + * If both `leading` and `trailing` are `false`, the state never updates. + * + * Pending updates are cancelled when the component unmounts or when `debounceMs`, `leading`, or + * `trailing` changes. + * + * @template T - The type of the state value. + * @param {T} initialValue - The initial state value, used only on the first render. + * @param {number} debounceMs - The number of milliseconds to wait after the last setter call before updating. + * @param {DebounceOptions} [options] - Configuration options for debounce behavior. + * @param {boolean} [options.leading=false] - If `true`, the first setter call after an idle period is applied immediately. + * @param {boolean} [options.trailing=true] - If `true`, the last value is applied after `debounceMs` milliseconds. + * + * @returns {[state: T, setState: Dispatch>]} A tuple: + * - state `T` - The current state value; + * - setState `Dispatch>` - A debounced setter that accepts the next state value or an updater function; + * + * @example + * import { useDebouncedState } from 'react-simplikit'; + * + * function SearchInput() { + * const [query, setQuery] = useDebouncedState('', 300); + * + * return ( + * <> + * + * {query} + * + * ); + * } + */ +export function useDebouncedState( + initialValue: T, + debounceMs: number, + { leading = false, trailing = true }: DebounceOptions = {} +): [T, Dispatch>] { + const [debouncedValue, setDebouncedValue] = useState(() => initialValue); + const lastForwardedRef = useRef(initialValue); + + function forwardChangedValue(value: SetStateAction) { + if (typeof value === 'function') { + lastForwardedRef.current = UPDATER_FORWARDED; + setDebouncedValue(value); + return; + } + + const unchanged = Object.is(lastForwardedRef.current, value); + const settled = Object.is(value, debouncedValue); + if (unchanged && settled) { + return; + } + + lastForwardedRef.current = value; + setDebouncedValue(() => value); + } + + const debounced = useDebounce((next: SetStateAction) => forwardChangedValue(next), debounceMs, { + leading, + trailing, + }); + + return [debouncedValue, debounced]; +} diff --git a/packages/react-simplikit/src/hooks/useDebouncedState/zh-Hans/useDebouncedState.md b/packages/react-simplikit/src/hooks/useDebouncedState/zh-Hans/useDebouncedState.md new file mode 100644 index 00000000..d8fb727d --- /dev/null +++ b/packages/react-simplikit/src/hooks/useDebouncedState/zh-Hans/useDebouncedState.md @@ -0,0 +1,99 @@ +# useDebouncedState + +`useDebouncedState` 是一个管理状态并返回防抖状态设置函数的 React Hook。使用默认选项时,状态会在最后一次调用状态设置函数的 `debounceMs` 毫秒后更新。它适用于搜索输入等场景,可以将一连串快速的更新请求合并为最后一个值进行更新。 + +首次渲染和服务端渲染时,初始值会原样返回。挂载时不会安排更新,因此设置 `leading: true` 后,挂载后的第一次状态设置函数调用会立即生效。如果 `leading` 和 `trailing` 均为 `false`,状态将不会更新。 + +组件卸载或 `debounceMs`、`leading`、`trailing` 发生变化时,待处理的更新会被取消。 + +## 接口 + +```ts +function useDebouncedState( + initialValue: T, + debounceMs: number, + options?: DebounceOptions +): [state: T, setState: Dispatch>]; +``` + +### 参数 + + + + + + + +### 返回值 + + + +## 示例 + +```tsx +import { useDebouncedState } from 'react-simplikit'; + +function SearchInput() { + const [query, setQuery] = useDebouncedState('', 300); + + return ( + <> + + {query} + + ); +} +``` diff --git a/packages/react-simplikit/src/hooks/useDebouncedValue/es/useDebouncedValue.md b/packages/react-simplikit/src/hooks/useDebouncedValue/es/useDebouncedValue.md index 4b49d1e5..90231636 100644 --- a/packages/react-simplikit/src/hooks/useDebouncedValue/es/useDebouncedValue.md +++ b/packages/react-simplikit/src/hooks/useDebouncedValue/es/useDebouncedValue.md @@ -2,7 +2,7 @@ `useDebouncedValue` es un Hook de React que devuelve una copia con debounce del valor que proporcionas. Mantienes el control del estado; el Hook solo retrasa la actualización del valor devuelto para que refleje ese estado. -El valor devuelto se actualiza `wait` milisegundos después del último cambio, lo que te resulta útil +El valor devuelto se actualiza `debounceMs` milisegundos después del último cambio, lo que te resulta útil para obtener una consulta de búsqueda o un valor para validar a partir de un estado que cambia rápidamente. Las actualizaciones pendientes se cancelan cuando el componente se desmonta. El ejemplo muestra la @@ -13,7 +13,7 @@ al montar el componente, por lo que, con `leading: true`, el primer cambio poste Si tanto `leading` como `trailing` son `false`, el valor devuelto nunca se actualiza. El valor se compara por referencia. Si pasas un objeto o arreglo nuevo en cada renderizado, -el valor devuelto seguirá actualizándose cada `wait` milisegundos; estabiliza primero la referencia, +el valor devuelto seguirá actualizándose cada `debounceMs` milisegundos; estabiliza primero la referencia, por ejemplo, con `usePreservedReference`. ## Interfaz @@ -21,7 +21,7 @@ por ejemplo, con `usePreservedReference`. ```ts function useDebouncedValue( value: T, - wait: number, + debounceMs: number, options?: DebounceOptions ): T; ``` @@ -37,7 +37,7 @@ function useDebouncedValue( @@ -61,7 +61,7 @@ function useDebouncedValue( required: false, defaultValue: 'true', description: - 'Si es true, el último cambio se aplica después de wait milisegundos.', + 'Si es true, el último cambio se aplica después de debounceMs milisegundos.', }, ]" /> diff --git a/packages/react-simplikit/src/hooks/useDebouncedValue/ja/useDebouncedValue.md b/packages/react-simplikit/src/hooks/useDebouncedValue/ja/useDebouncedValue.md index 5dea7696..b8d90cf8 100644 --- a/packages/react-simplikit/src/hooks/useDebouncedValue/ja/useDebouncedValue.md +++ b/packages/react-simplikit/src/hooks/useDebouncedValue/ja/useDebouncedValue.md @@ -2,7 +2,7 @@ `useDebouncedValue` は、渡された値をデバウンスした値として返す React フックです。 状態は引き続き呼び出し側で管理し、このフックは戻り値がその状態に追従するタイミングだけを遅らせます。 -戻り値は最後の変更から `wait` ミリ秒後に更新されるため、頻繁に変わる状態から +戻り値は最後の変更から `debounceMs` ミリ秒後に更新されるため、頻繁に変わる状態から 検索クエリやバリデーション用の入力値を得る場合に便利です。 コンポーネントのアンマウント時に、保留中の更新をキャンセルします。使用例では、 @@ -13,7 +13,7 @@ `leading` と `trailing` が両方とも `false` の場合、戻り値は更新されません。 値は参照で比較します。レンダリングのたびに新しいオブジェクトや配列を渡すと、 -戻り値が `wait` ミリ秒ごとに更新され続けます。まず `usePreservedReference` などを使って、 +戻り値が `debounceMs` ミリ秒ごとに更新され続けます。まず `usePreservedReference` などを使って、 参照を安定させてください。 ## インターフェース @@ -21,7 +21,7 @@ ```ts function useDebouncedValue( value: T, - wait: number, + debounceMs: number, options?: DebounceOptions ): T; ``` @@ -37,7 +37,7 @@ function useDebouncedValue( @@ -61,7 +61,7 @@ function useDebouncedValue( required: false, defaultValue: 'true', description: - 'true の場合、wait ミリ秒後に最後の変更を反映します。', + 'true の場合、debounceMs ミリ秒後に最後の変更を反映します。', }, ]" /> diff --git a/packages/react-simplikit/src/hooks/useDebouncedValue/ko/useDebouncedValue.md b/packages/react-simplikit/src/hooks/useDebouncedValue/ko/useDebouncedValue.md index 1d71282d..5d4d7fc0 100644 --- a/packages/react-simplikit/src/hooks/useDebouncedValue/ko/useDebouncedValue.md +++ b/packages/react-simplikit/src/hooks/useDebouncedValue/ko/useDebouncedValue.md @@ -1,19 +1,19 @@ # useDebouncedValue -`useDebouncedValue`는 전달받은 값의 디바운스된 사본을 반환하는 리액트 훅이에요. 상태는 호출자가 그대로 소유하고, 훅은 반환값이 그 상태를 얼마나 늦게 따라갈지만 조절해요. 반환값은 마지막 변경 뒤 `wait` 밀리초가 지나면 갱신되므로, 빠르게 바뀌는 상태에서 검색어나 검증 대상을 파생할 때 유용해요. +`useDebouncedValue`는 전달받은 값의 디바운스된 사본을 반환하는 리액트 훅이에요. 상태는 호출자가 그대로 소유하고, 훅은 반환값이 그 상태를 얼마나 늦게 따라갈지만 조절해요. 반환값은 마지막 변경 뒤 `debounceMs` 밀리초가 지나면 갱신되므로, 빠르게 바뀌는 상태에서 검색어나 검증 대상을 파생할 때 유용해요. 컴포넌트가 언마운트되면 대기 중인 갱신을 취소해요. 예제는 별도의 검색 서비스나 추가 컴포넌트 없이 지연된 검색어를 표시해요. 첫 렌더와 서버 렌더에서는 값을 그대로 돌려줘요. 마운트 시점에는 갱신을 예약하지 않으므로 `leading: true`이면 마운트 뒤 첫 변경이 즉시 반영돼요. `leading`과 `trailing`이 모두 `false`이면 반환값은 갱신되지 않아요. -값은 참조로 비교해요. 렌더마다 새 객체나 배열을 전달하면 반환값이 `wait` 밀리초마다 계속 갱신되니, `usePreservedReference` 같은 방법으로 참조를 먼저 안정화하세요. +값은 참조로 비교해요. 렌더마다 새 객체나 배열을 전달하면 반환값이 `debounceMs` 밀리초마다 계속 갱신되니, `usePreservedReference` 같은 방법으로 참조를 먼저 안정화하세요. ## 인터페이스 ```ts function useDebouncedValue( value: T, - wait: number, + debounceMs: number, options?: DebounceOptions ): T; ``` @@ -29,7 +29,7 @@ function useDebouncedValue( @@ -53,7 +53,7 @@ function useDebouncedValue( required: false, defaultValue: 'true', description: - 'true이면 마지막 변경을 wait 밀리초 뒤에 반영해요.', + 'true이면 마지막 변경을 debounceMs 밀리초 뒤에 반영해요.', }, ]" /> diff --git a/packages/react-simplikit/src/hooks/useDebouncedValue/useDebouncedValue.md b/packages/react-simplikit/src/hooks/useDebouncedValue/useDebouncedValue.md index 1d7cbc5c..0cd15500 100644 --- a/packages/react-simplikit/src/hooks/useDebouncedValue/useDebouncedValue.md +++ b/packages/react-simplikit/src/hooks/useDebouncedValue/useDebouncedValue.md @@ -2,7 +2,7 @@ `useDebouncedValue` is a React hook that returns a debounced copy of the given value. The caller keeps owning the state; the hook only delays how quickly the returned value follows it. -The returned value updates `wait` milliseconds after the last change, which is useful for +The returned value updates `debounceMs` milliseconds after the last change, which is useful for deriving a search query or a validation input from fast-changing state. Pending updates are cancelled when the component unmounts. The example displays the @@ -13,7 +13,7 @@ on mount, so with `leading: true` the first change after mount is applied immedi If both `leading` and `trailing` are `false`, the returned value never updates. The value is compared by reference. Passing a new object or array on every render keeps -the returned value updating every `wait` milliseconds; stabilize the reference first, for +the returned value updating every `debounceMs` milliseconds; stabilize the reference first, for example with `usePreservedReference`. ## Interface @@ -21,7 +21,7 @@ example with `usePreservedReference`. ```ts function useDebouncedValue( value: T, - wait: number, + debounceMs: number, options?: DebounceOptions ): T; ``` @@ -37,7 +37,7 @@ function useDebouncedValue( @@ -61,7 +61,7 @@ function useDebouncedValue( required: false, defaultValue: 'true', description: - 'If true, the last change is applied after wait milliseconds.', + 'If true, the last change is applied after debounceMs milliseconds.', }, ]" /> diff --git a/packages/react-simplikit/src/hooks/useDebouncedValue/useDebouncedValue.ts b/packages/react-simplikit/src/hooks/useDebouncedValue/useDebouncedValue.ts index 52a69a49..8d7c4fcd 100644 --- a/packages/react-simplikit/src/hooks/useDebouncedValue/useDebouncedValue.ts +++ b/packages/react-simplikit/src/hooks/useDebouncedValue/useDebouncedValue.ts @@ -1,6 +1,6 @@ import { useEffect, useRef, useState } from 'react'; -import { useDebounce } from '../useDebounce/index.ts'; +import { useDebounce } from '../_internal/useDebounce/useDebounce.ts'; type DebounceOptions = { leading?: boolean; @@ -11,7 +11,7 @@ type DebounceOptions = { * @description * `useDebouncedValue` is a React hook that returns a debounced copy of the given value. * The caller keeps owning the state; the hook only delays how quickly the returned value follows it. - * The returned value updates `wait` milliseconds after the last change, which is useful for + * The returned value updates `debounceMs` milliseconds after the last change, which is useful for * deriving a search query or a validation input from fast-changing state. * * Pending updates are cancelled when the component unmounts. The example displays the @@ -22,15 +22,15 @@ type DebounceOptions = { * If both `leading` and `trailing` are `false`, the returned value never updates. * * The value is compared by reference. Passing a new object or array on every render keeps - * the returned value updating every `wait` milliseconds; stabilize the reference first, for + * the returned value updating every `debounceMs` milliseconds; stabilize the reference first, for * example with `usePreservedReference`. * * @template T - The type of the value. * @param {T} value - The value to debounce. - * @param {number} wait - The number of milliseconds to wait after the last change before updating. + * @param {number} debounceMs - The number of milliseconds to wait after the last change before updating. * @param {DebounceOptions} [options] - Configuration options for debounce behavior. * @param {boolean} [options.leading=false] - If `true`, the first change after an idle period is applied immediately. - * @param {boolean} [options.trailing=true] - If `true`, the last change is applied after `wait` milliseconds. + * @param {boolean} [options.trailing=true] - If `true`, the last change is applied after `debounceMs` milliseconds. * * @returns {T} The debounced value. * @@ -55,11 +55,11 @@ type DebounceOptions = { */ export function useDebouncedValue( value: T, - wait: number, + debounceMs: number, { leading = false, trailing = true }: DebounceOptions = {} ): T { const [debouncedValue, setDebouncedValue] = useState(() => value); - const debounced = useDebounce((next: T) => setDebouncedValue(() => next), wait, { leading, trailing }); + const debounced = useDebounce((next: T) => setDebouncedValue(() => next), debounceMs, { leading, trailing }); const lastForwardedRef = useRef(value); useEffect( diff --git a/packages/react-simplikit/src/hooks/useDebouncedValue/zh-Hans/useDebouncedValue.md b/packages/react-simplikit/src/hooks/useDebouncedValue/zh-Hans/useDebouncedValue.md index 9d785f4b..d320c989 100644 --- a/packages/react-simplikit/src/hooks/useDebouncedValue/zh-Hans/useDebouncedValue.md +++ b/packages/react-simplikit/src/hooks/useDebouncedValue/zh-Hans/useDebouncedValue.md @@ -1,19 +1,19 @@ # useDebouncedValue -`useDebouncedValue` 是一个返回所给值防抖副本的 React Hook。调用方始终拥有该状态;Hook 只延迟返回值的跟随速度。返回值会在最后一次变化后 `wait` 毫秒更新,这适用于从快速变化的状态中推导搜索查询或校验输入。 +`useDebouncedValue` 是一个返回所给值防抖副本的 React Hook。调用方始终拥有该状态;Hook 只延迟返回值的跟随速度。返回值会在最后一次变化后 `debounceMs` 毫秒更新,这适用于从快速变化的状态中推导搜索查询或校验输入。 当组件卸载时,待执行的更新会被取消。该示例展示了延迟后的查询,无需搜索服务或额外的组件。 在首次渲染时和服务端,该值会原样返回。挂载时永远不会安排变化,因此使用 `leading: true` 时,挂载后的第一次变化会立即应用。如果 `leading` 和 `trailing` 都为 `false`,返回值永远不会更新。 -该值通过引用进行比较。如果每次渲染都传入新的对象或数组,返回值会每 `wait` 毫秒更新一次;请先稳定引用,例如使用 `usePreservedReference`。 +该值通过引用进行比较。如果每次渲染都传入新的对象或数组,返回值会每 `debounceMs` 毫秒更新一次;请先稳定引用,例如使用 `usePreservedReference`。 ## 接口 ```ts function useDebouncedValue( value: T, - wait: number, + debounceMs: number, options?: DebounceOptions ): T; ``` @@ -29,7 +29,7 @@ function useDebouncedValue( @@ -53,7 +53,7 @@ function useDebouncedValue( required: false, defaultValue: 'true', description: - '如果为 true,最后一次变化会在 wait 毫秒后应用。', + '如果为 true,最后一次变化会在 debounceMs 毫秒后应用。', }, ]" /> diff --git a/packages/react-simplikit/src/hooks/useDoubleClick/es/useDoubleClick.md b/packages/react-simplikit/src/hooks/useDoubleClick/es/useDoubleClick.md index 135d05a2..b7bb1f2f 100644 --- a/packages/react-simplikit/src/hooks/useDoubleClick/es/useDoubleClick.md +++ b/packages/react-simplikit/src/hooks/useDoubleClick/es/useDoubleClick.md @@ -20,7 +20,7 @@ function useDoubleClick( description="Opciones de configuración para gestionar los clics." :nested="[ { - name: 'props.delay', + name: 'props.delayMs', type: 'number', required: false, defaultValue: '250', @@ -28,13 +28,13 @@ function useDoubleClick( 'El número de milisegundos de espera antes de ejecutar el callback de un solo clic. El valor predeterminado es 250 ms.', }, { - name: 'props.click', + name: 'props.onClick', type: '(event: MouseEvent) => void', required: false, description: 'El callback que debe ejecutarse al hacer un solo clic.', }, { - name: 'props.doubleClick', + name: 'props.onDoubleClick', type: '(event: MouseEvent) => void', required: true, description: @@ -61,8 +61,8 @@ function GalleryCard() { const handleDoubleClick = () => alert('¡Ampliar!'); const handleEvent = useDoubleClick({ - click: handleClick, - doubleClick: handleDoubleClick, + onClick: handleClick, + onDoubleClick: handleDoubleClick, }); return ( diff --git a/packages/react-simplikit/src/hooks/useDoubleClick/ja/useDoubleClick.md b/packages/react-simplikit/src/hooks/useDoubleClick/ja/useDoubleClick.md index 62e6a21a..cc276d75 100644 --- a/packages/react-simplikit/src/hooks/useDoubleClick/ja/useDoubleClick.md +++ b/packages/react-simplikit/src/hooks/useDoubleClick/ja/useDoubleClick.md @@ -20,7 +20,7 @@ function useDoubleClick( description="クリック処理の設定オプション。" :nested="[ { - name: 'props.delay', + name: 'props.delayMs', type: 'number', required: false, defaultValue: '250', @@ -28,13 +28,13 @@ function useDoubleClick( 'シングルクリックのコールバックを実行するまでの待機時間(ミリ秒単位)です。デフォルトは 250 ミリ秒です。', }, { - name: 'props.click', + name: 'props.onClick', type: '(event: MouseEvent) => void', required: false, description: 'シングルクリック時に実行するコールバック関数。', }, { - name: 'props.doubleClick', + name: 'props.onDoubleClick', type: '(event: MouseEvent) => void', required: true, description: @@ -61,8 +61,8 @@ function GalleryCard() { const handleDoubleClick = () => alert('Zoom in!'); const handleEvent = useDoubleClick({ - click: handleClick, - doubleClick: handleDoubleClick, + onClick: handleClick, + onDoubleClick: handleDoubleClick, }); return ( diff --git a/packages/react-simplikit/src/hooks/useDoubleClick/ko/useDoubleClick.md b/packages/react-simplikit/src/hooks/useDoubleClick/ko/useDoubleClick.md index 9461f253..4dcf9000 100644 --- a/packages/react-simplikit/src/hooks/useDoubleClick/ko/useDoubleClick.md +++ b/packages/react-simplikit/src/hooks/useDoubleClick/ko/useDoubleClick.md @@ -19,7 +19,7 @@ function useDoubleClick( description="클릭 처리를 위한 설정 옵션이에요." :nested="[ { - name: 'props.delay', + name: 'props.delayMs', type: 'number', required: false, defaultValue: '250', @@ -27,13 +27,13 @@ function useDoubleClick( '단일 클릭 콜백을 실행하기 전에 기다리는 밀리초 단위 수로, 기본값은 250ms예요.', }, { - name: 'props.click', + name: 'props.onClick', type: '(event: MouseEvent) => void', required: false, description: '단일 클릭 시 실행되는 콜백 함수예요.', }, { - name: 'props.doubleClick', + name: 'props.onDoubleClick', type: '(event: MouseEvent) => void', required: true, description: @@ -60,8 +60,8 @@ function GalleryCard() { const handleDoubleClick = () => alert('확대!'); const handleEvent = useDoubleClick({ - click: handleClick, - doubleClick: handleDoubleClick, + onClick: handleClick, + onDoubleClick: handleDoubleClick, }); return
{selected ? '선택됨' : '선택 안됨'}
; diff --git a/packages/react-simplikit/src/hooks/useDoubleClick/useDoubleClick.md b/packages/react-simplikit/src/hooks/useDoubleClick/useDoubleClick.md index 61b45ee6..6ea8acde 100644 --- a/packages/react-simplikit/src/hooks/useDoubleClick/useDoubleClick.md +++ b/packages/react-simplikit/src/hooks/useDoubleClick/useDoubleClick.md @@ -20,7 +20,7 @@ function useDoubleClick( description="Configuration options for click handling." :nested="[ { - name: 'props.delay', + name: 'props.delayMs', type: 'number', required: false, defaultValue: '250', @@ -28,13 +28,13 @@ function useDoubleClick( 'The number of milliseconds to wait before triggering the single click callback. Defaults to 250ms.', }, { - name: 'props.click', + name: 'props.onClick', type: '(event: MouseEvent) => void', required: false, description: 'The callback function to be executed on a single click.', }, { - name: 'props.doubleClick', + name: 'props.onDoubleClick', type: '(event: MouseEvent) => void', required: true, description: @@ -61,8 +61,8 @@ function GalleryCard() { const handleDoubleClick = () => alert('Zoom in!'); const handleEvent = useDoubleClick({ - click: handleClick, - doubleClick: handleDoubleClick, + onClick: handleClick, + onDoubleClick: handleDoubleClick, }); return ( diff --git a/packages/react-simplikit/src/hooks/useDoubleClick/useDoubleClick.spec.tsx b/packages/react-simplikit/src/hooks/useDoubleClick/useDoubleClick.spec.tsx index cf481b19..9e4c2de2 100644 --- a/packages/react-simplikit/src/hooks/useDoubleClick/useDoubleClick.spec.tsx +++ b/packages/react-simplikit/src/hooks/useDoubleClick/useDoubleClick.spec.tsx @@ -5,18 +5,18 @@ import { afterEach, beforeEach, describe, expect, it, Mock, vi } from 'vitest'; import { useDoubleClick } from './useDoubleClick.ts'; function TestComponent({ - delay = 250, + delayMs = 250, onClick, onDoubleClick, }: { - delay?: number; + delayMs?: number; onClick?: (event: MouseEvent) => void; onDoubleClick: (event: MouseEvent) => void; }) { const handleEvent = useDoubleClick({ - delay, - click: onClick, - doubleClick: onDoubleClick, + delayMs, + onClick, + onDoubleClick, }); return ; @@ -64,7 +64,7 @@ describe('useDoubleClick', () => { expect(doubleClickSpy).toHaveBeenCalledTimes(1); }); - it('does not throw if click is not provided', () => { + it('does not throw if onClick is not provided', () => { const { getByText } = render(); const button = getByText('Test Button'); @@ -102,7 +102,7 @@ describe('useDoubleClick', () => { }); it('respects custom delay time', () => { - const { getByText } = render(); + const { getByText } = render(); const button = getByText('Test Button'); fireEvent.click(button, { detail: 1 }); @@ -116,8 +116,8 @@ describe('useDoubleClick', () => { expect(clickSpy).toHaveBeenCalledTimes(1); }); - it('does not call click if doubleClick is triggered before delay', () => { - const { getByText } = render(); + it('does not call onClick if onDoubleClick is triggered before delayMs', () => { + const { getByText } = render(); const button = getByText('Test Button'); fireEvent.click(button, { detail: 1 }); diff --git a/packages/react-simplikit/src/hooks/useDoubleClick/useDoubleClick.ts b/packages/react-simplikit/src/hooks/useDoubleClick/useDoubleClick.ts index 4614a9ab..765ab629 100644 --- a/packages/react-simplikit/src/hooks/useDoubleClick/useDoubleClick.ts +++ b/packages/react-simplikit/src/hooks/useDoubleClick/useDoubleClick.ts @@ -3,9 +3,9 @@ import { MouseEvent, useCallback, useEffect, useRef } from 'react'; import { usePreservedCallback } from '../usePreservedCallback/index.ts'; type UseDoubleClickProps = { - delay?: number; - click?: (event: MouseEvent) => void; - doubleClick: (event: MouseEvent) => void; + delayMs?: number; + onClick?: (event: MouseEvent) => void; + onDoubleClick: (event: MouseEvent) => void; }; /** @@ -15,9 +15,9 @@ type UseDoubleClickProps = { * * @template {HTMLElement} E - The specific type of HTMLElement to be used with this hook (e.g., HTMLButtonElement, HTMLDivElement). * @param {Object} props - Configuration options for click handling. - * @param {number} [props.delay=250] - The number of milliseconds to wait before triggering the single click callback. Defaults to 250ms. - * @param {(event: MouseEvent) => void} [props.click] - The callback function to be executed on a single click. - * @param {(event: MouseEvent) => void} props.doubleClick - The callback function to be executed on a double click. Required. + * @param {number} [props.delayMs=250] - The number of milliseconds to wait before triggering the single click callback. Defaults to 250ms. + * @param {(event: MouseEvent) => void} [props.onClick] - The callback function to be executed on a single click. + * @param {(event: MouseEvent) => void} props.onDoubleClick - The callback function to be executed on a double click. Required. * * @returns {(event: MouseEvent) => void} A click handler function to attach to an element's `onClick` event. * @@ -29,8 +29,8 @@ type UseDoubleClickProps = { * const handleDoubleClick = () => alert('Zoom in!'); * * const handleEvent = useDoubleClick({ - * click: handleClick, - * doubleClick: handleDoubleClick, + * onClick: handleClick, + * onDoubleClick: handleDoubleClick, * }); * * return ( @@ -41,9 +41,9 @@ type UseDoubleClickProps = { * } */ export function useDoubleClick({ - delay = 250, - click, - doubleClick, + delayMs = 250, + onClick, + onDoubleClick, }: UseDoubleClickProps) { const clickTimeout = useRef(null); @@ -60,17 +60,17 @@ export function useDoubleClick({ (event: MouseEvent) => { clearClickTimeout(); - if (click && event.detail === 1) { + if (onClick && event.detail === 1) { clickTimeout.current = window.setTimeout(() => { - click(event); - }, delay); + onClick(event); + }, delayMs); } if (event.detail === 2) { - doubleClick(event); + onDoubleClick(event); } }, - [click, doubleClick, delay, clearClickTimeout] + [onClick, onDoubleClick, delayMs, clearClickTimeout] ); return handleEvent; diff --git a/packages/react-simplikit/src/hooks/useDoubleClick/zh-Hans/useDoubleClick.md b/packages/react-simplikit/src/hooks/useDoubleClick/zh-Hans/useDoubleClick.md index 5324ec15..82905dd9 100644 --- a/packages/react-simplikit/src/hooks/useDoubleClick/zh-Hans/useDoubleClick.md +++ b/packages/react-simplikit/src/hooks/useDoubleClick/zh-Hans/useDoubleClick.md @@ -19,7 +19,7 @@ function useDoubleClick( description="用于点击处理的配置选项。" :nested="[ { - name: 'props.delay', + name: 'props.delayMs', type: 'number', required: false, defaultValue: '250', @@ -27,13 +27,13 @@ function useDoubleClick( '触发单击回调之前等待的毫秒数。默认为 250 毫秒。', }, { - name: 'props.click', + name: 'props.onClick', type: '(event: MouseEvent) => void', required: false, description: '在单击时执行的回调函数。', }, { - name: 'props.doubleClick', + name: 'props.onDoubleClick', type: '(event: MouseEvent) => void', required: true, description: @@ -60,8 +60,8 @@ function GalleryCard() { const handleDoubleClick = () => alert('Zoom in!'); const handleEvent = useDoubleClick({ - click: handleClick, - doubleClick: handleDoubleClick, + onClick: handleClick, + onDoubleClick: handleDoubleClick, }); return ( diff --git a/packages/react-simplikit/src/hooks/useImpressionRef/es/useImpressionRef.md b/packages/react-simplikit/src/hooks/useImpressionRef/es/useImpressionRef.md index ac03fbf6..93dbeac2 100644 --- a/packages/react-simplikit/src/hooks/useImpressionRef/es/useImpressionRef.md +++ b/packages/react-simplikit/src/hooks/useImpressionRef/es/useImpressionRef.md @@ -33,7 +33,7 @@ function useImpressionRef( description: 'Callback que se ejecuta cuando el elemento sale del área visible', }, { - name: 'options.timeThreshold', + name: 'options.timeThresholdMs', type: 'number', required: false, defaultValue: '0', @@ -73,7 +73,7 @@ function Component() { onImpressionStart: () => console.log('El elemento entró en el área visible'), onImpressionEnd: () => console.log('El elemento salió del área visible'), - timeThreshold: 1000, + timeThresholdMs: 1000, areaThreshold: 0.5, }); diff --git a/packages/react-simplikit/src/hooks/useImpressionRef/ja/useImpressionRef.md b/packages/react-simplikit/src/hooks/useImpressionRef/ja/useImpressionRef.md index 29e2e560..f109c14b 100644 --- a/packages/react-simplikit/src/hooks/useImpressionRef/ja/useImpressionRef.md +++ b/packages/react-simplikit/src/hooks/useImpressionRef/ja/useImpressionRef.md @@ -33,7 +33,7 @@ function useImpressionRef( description: '要素が表示領域から出たときに実行するコールバック関数。', }, { - name: 'options.timeThreshold', + name: 'options.timeThresholdMs', type: 'number', required: false, defaultValue: '0', @@ -72,7 +72,7 @@ function Component() { const ref = useImpressionRef({ onImpressionStart: () => console.log('Element entered view'), onImpressionEnd: () => console.log('Element exited view'), - timeThreshold: 1000, + timeThresholdMs: 1000, areaThreshold: 0.5, }); diff --git a/packages/react-simplikit/src/hooks/useImpressionRef/ko/useImpressionRef.md b/packages/react-simplikit/src/hooks/useImpressionRef/ko/useImpressionRef.md index 9c3f0469..c69bb870 100644 --- a/packages/react-simplikit/src/hooks/useImpressionRef/ko/useImpressionRef.md +++ b/packages/react-simplikit/src/hooks/useImpressionRef/ko/useImpressionRef.md @@ -32,7 +32,7 @@ function useImpressionRef( description: '요소가 뷰에서 나갈 때 실행되는 콜백 함수예요', }, { - name: 'options.timeThreshold', + name: 'options.timeThresholdMs', type: 'number', required: false, defaultValue: '0', @@ -71,7 +71,7 @@ function Component() { const ref = useImpressionRef({ onImpressionStart: () => console.log('Element entered view'), onImpressionEnd: () => console.log('Element exited view'), - timeThreshold: 1000, + timeThresholdMs: 1000, areaThreshold: 0.5, }); diff --git a/packages/react-simplikit/src/hooks/useImpressionRef/useImpressionRef.md b/packages/react-simplikit/src/hooks/useImpressionRef/useImpressionRef.md index b3a1fcdc..cb12e5fe 100644 --- a/packages/react-simplikit/src/hooks/useImpressionRef/useImpressionRef.md +++ b/packages/react-simplikit/src/hooks/useImpressionRef/useImpressionRef.md @@ -33,7 +33,7 @@ function useImpressionRef( description: 'Callback function executed when the element exits the view', }, { - name: 'options.timeThreshold', + name: 'options.timeThresholdMs', type: 'number', required: false, defaultValue: '0', @@ -72,7 +72,7 @@ function Component() { const ref = useImpressionRef({ onImpressionStart: () => console.log('Element entered view'), onImpressionEnd: () => console.log('Element exited view'), - timeThreshold: 1000, + timeThresholdMs: 1000, areaThreshold: 0.5, }); diff --git a/packages/react-simplikit/src/hooks/useImpressionRef/useImpressionRef.spec.ts b/packages/react-simplikit/src/hooks/useImpressionRef/useImpressionRef.spec.ts index de3d2c93..d0ccd32e 100644 --- a/packages/react-simplikit/src/hooks/useImpressionRef/useImpressionRef.spec.ts +++ b/packages/react-simplikit/src/hooks/useImpressionRef/useImpressionRef.spec.ts @@ -30,7 +30,7 @@ describe('useImpressionRef', () => { const defaultOptions: UseImpressionRefOptions = { onImpressionStart: mockOnImpressionStart, onImpressionEnd: mockOnImpressionEnd, - timeThreshold: 100, + timeThresholdMs: 100, rootMargin: '0px', areaThreshold: 0.5, }; diff --git a/packages/react-simplikit/src/hooks/useImpressionRef/useImpressionRef.ts b/packages/react-simplikit/src/hooks/useImpressionRef/useImpressionRef.ts index 245f3401..095e6df0 100644 --- a/packages/react-simplikit/src/hooks/useImpressionRef/useImpressionRef.ts +++ b/packages/react-simplikit/src/hooks/useImpressionRef/useImpressionRef.ts @@ -2,15 +2,15 @@ import { useEffect, useRef } from 'react'; import { useDebouncedCallback } from '../useDebouncedCallback/useDebouncedCallback.ts'; import { useIntersectionObserver } from '../useIntersectionObserver/index.ts'; +import { usePageVisibilityEffect } from '../usePageVisibilityEffect/index.ts'; import { usePreservedCallback } from '../usePreservedCallback/index.ts'; -import { useVisibilityEvent } from '../useVisibilityEvent/index.ts'; export type UseImpressionRefOptions = Partial<{ onImpressionStart: () => void; onImpressionEnd: () => void; rootMargin: string; areaThreshold: number; - timeThreshold: number; + timeThresholdMs: number; }>; /** @@ -22,7 +22,7 @@ export type UseImpressionRefOptions = Partial<{ * @param {UseImpressionRefOptions} options - Options for tracking the element's visibility. * @param {() => void} [options.onImpressionStart] - Callback function executed when the element enters the view * @param {() => void} [options.onImpressionEnd] - Callback function executed when the element exits the view - * @param {number} [options.timeThreshold=0] - Minimum time the element must be visible (in milliseconds) + * @param {number} [options.timeThresholdMs=0] - Minimum time the element must be visible (in milliseconds) * @param {number} [options.areaThreshold=0] - Minimum ratio of the element that must be visible (0 to 1) * @param {string} [options.rootMargin] - Margin to adjust the detection area * @@ -35,7 +35,7 @@ export type UseImpressionRefOptions = Partial<{ * const ref = useImpressionRef({ * onImpressionStart: () => console.log('Element entered view'), * onImpressionEnd: () => console.log('Element exited view'), - * timeThreshold: 1000, + * timeThresholdMs: 1000, * areaThreshold: 0.5, * }); * @@ -47,7 +47,7 @@ export function useImpressionRef({ onImpressionEnd = () => {}, rootMargin, areaThreshold = 0, - timeThreshold = 0, + timeThresholdMs = 0, }: UseImpressionRefOptions) { const impressionStartHandler = usePreservedCallback(onImpressionStart); const impressionEndHandler = usePreservedCallback(onImpressionEnd); @@ -60,7 +60,7 @@ export function useImpressionRef({ // or the tab is hidden before it ever intersects); an end without a start must not be emitted. const hasImpressionStartedRef = useRef(false); const impressionEventHandler = useDebouncedCallback({ - timeThreshold, + debounceMs: timeThresholdMs, onChange: (impressed: boolean) => { if (impressed) { hasImpressionStartedRef.current = true; @@ -96,7 +96,7 @@ export function useImpressionRef({ [impressionEndHandler] ); - useVisibilityEvent(documentVisible => { + usePageVisibilityEffect(documentVisible => { if (!isIntersectingRef.current) { return; } diff --git a/packages/react-simplikit/src/hooks/useImpressionRef/zh-Hans/useImpressionRef.md b/packages/react-simplikit/src/hooks/useImpressionRef/zh-Hans/useImpressionRef.md index 0ba3bbae..e8781da7 100644 --- a/packages/react-simplikit/src/hooks/useImpressionRef/zh-Hans/useImpressionRef.md +++ b/packages/react-simplikit/src/hooks/useImpressionRef/zh-Hans/useImpressionRef.md @@ -32,7 +32,7 @@ function useImpressionRef( description: '元素退出视图时执行的回调函数', }, { - name: 'options.timeThreshold', + name: 'options.timeThresholdMs', type: 'number', required: false, defaultValue: '0', @@ -71,7 +71,7 @@ function Component() { const ref = useImpressionRef({ onImpressionStart: () => console.log('Element entered view'), onImpressionEnd: () => console.log('Element exited view'), - timeThreshold: 1000, + timeThresholdMs: 1000, areaThreshold: 0.5, }); diff --git a/packages/react-simplikit/src/hooks/useInterval/es/useInterval.md b/packages/react-simplikit/src/hooks/useInterval/es/useInterval.md index 00ecbc3c..b1c30d2d 100644 --- a/packages/react-simplikit/src/hooks/useInterval/es/useInterval.md +++ b/packages/react-simplikit/src/hooks/useInterval/es/useInterval.md @@ -6,33 +6,28 @@ Es útil para temporizadores, consultas periódicas de datos y otras tareas recu ## Interfaz ```ts -function useInterval( - callback: () => void, - options: number | { delay: number; enabled?: boolean; immediate?: boolean } -): void; +function useInterval(options: Object): void; ``` ### Parámetros - - - - - - + ### Valor de retorno diff --git a/packages/react-simplikit/src/hooks/useList/ja/useList.md b/packages/react-simplikit/src/hooks/useList/ja/useList.md index 1928641e..0dde8698 100644 --- a/packages/react-simplikit/src/hooks/useList/ja/useList.md +++ b/packages/react-simplikit/src/hooks/useList/ja/useList.md @@ -6,12 +6,12 @@ ## インターフェース ```ts -function useList(initialState: T[] = []): UseListReturn; +function useList(initialValue: T[] = []): UseListReturn; ``` ### パラメータ - + ### 戻り値 diff --git a/packages/react-simplikit/src/hooks/useList/ko/useList.md b/packages/react-simplikit/src/hooks/useList/ko/useList.md index a9de68cd..366d1d89 100644 --- a/packages/react-simplikit/src/hooks/useList/ko/useList.md +++ b/packages/react-simplikit/src/hooks/useList/ko/useList.md @@ -5,12 +5,12 @@ ## 인터페이스 ```ts -function useList(initialState: T[] = []): UseListReturn; +function useList(initialValue: T[] = []): UseListReturn; ``` ### 파라미터 - + ### 반환 값 diff --git a/packages/react-simplikit/src/hooks/useList/useList.md b/packages/react-simplikit/src/hooks/useList/useList.md index ebb419a4..dd859ef6 100644 --- a/packages/react-simplikit/src/hooks/useList/useList.md +++ b/packages/react-simplikit/src/hooks/useList/useList.md @@ -6,12 +6,12 @@ Provides efficient state management and stable action functions. ## Interface ```ts -function useList(initialState: T[] = []): UseListReturn; +function useList(initialValue: T[] = []): UseListReturn; ``` ### Parameters - + ### Return Value diff --git a/packages/react-simplikit/src/hooks/useList/useList.ts b/packages/react-simplikit/src/hooks/useList/useList.ts index 1b3be4ee..8af6a3fc 100644 --- a/packages/react-simplikit/src/hooks/useList/useList.ts +++ b/packages/react-simplikit/src/hooks/useList/useList.ts @@ -21,7 +21,7 @@ type UseListReturn = [ReadonlyArray, ListActions]; * * @template T - The type of the values held in the list. * - * @param {T[]} [initialState=[]] - Initial array state. + * @param {T[]} [initialValue=[]] - Initial array value. * * @returns {UseListReturn} A tuple containing the array state and actions to manipulate it. * - list `ReadonlyArray` - The current array state; @@ -55,10 +55,10 @@ type UseListReturn = [ReadonlyArray, ListActions]; * actions.reset(); * ``` */ -export function useList(initialState: T[] = []): UseListReturn { - const [list, setList] = useState(initialState); +export function useList(initialValue: T[] = []): UseListReturn { + const [list, setList] = useState(initialValue); - const preservedInitialState = usePreservedReference(initialState); + const preservedInitialValue = usePreservedReference(initialValue); const push = usePreservedCallback((value: T) => { setList(prev => [...prev, value]); @@ -93,7 +93,7 @@ export function useList(initialState: T[] = []): UseListReturn { }); const reset = usePreservedCallback(() => { - setList(preservedInitialState); + setList(preservedInitialValue); }); const actions = useMemo>( diff --git a/packages/react-simplikit/src/hooks/useList/zh-Hans/useList.md b/packages/react-simplikit/src/hooks/useList/zh-Hans/useList.md index 9e9ab241..8454bb02 100644 --- a/packages/react-simplikit/src/hooks/useList/zh-Hans/useList.md +++ b/packages/react-simplikit/src/hooks/useList/zh-Hans/useList.md @@ -5,12 +5,12 @@ ## 接口 ```ts -function useList(initialState: T[] = []): UseListReturn; +function useList(initialValue: T[] = []): UseListReturn; ``` ### 参数 - + ### 返回值 diff --git a/packages/react-simplikit/src/hooks/useLongPress/es/useLongPress.md b/packages/react-simplikit/src/hooks/useLongPress/es/useLongPress.md index 28fc1c54..18b2bfba 100644 --- a/packages/react-simplikit/src/hooks/useLongPress/es/useLongPress.md +++ b/packages/react-simplikit/src/hooks/useLongPress/es/useLongPress.md @@ -27,7 +27,7 @@ function useLongPress( description="Opciones de configuración del comportamiento de la pulsación prolongada." :nested="[ { - name: 'options.delay', + name: 'options.delayMs', type: 'number', required: false, defaultValue: '500', @@ -131,7 +131,7 @@ function ContextMenu() { const [menuVisible, setMenuVisible] = useState(false); const longPressHandlers = useLongPress(() => setMenuVisible(true), { - delay: 400, + delayMs: 400, onClick: () => console.log('Clic normal'), onLongPressEnd: () => console.log('Pulsación prolongada completada'), }); diff --git a/packages/react-simplikit/src/hooks/useLongPress/ja/useLongPress.md b/packages/react-simplikit/src/hooks/useLongPress/ja/useLongPress.md index aa8eb966..69c6f22f 100644 --- a/packages/react-simplikit/src/hooks/useLongPress/ja/useLongPress.md +++ b/packages/react-simplikit/src/hooks/useLongPress/ja/useLongPress.md @@ -27,7 +27,7 @@ function useLongPress( description="長押しの動作を設定するオプションです。" :nested="[ { - name: 'options.delay', + name: 'options.delayMs', type: 'number', required: false, defaultValue: '500', @@ -57,7 +57,7 @@ function useLongPress( type: '(event) => void', required: false, description: - '通常のクリック(押してから delay が経過する前に離す操作)で実行する関数です。省略できます。', + '通常のクリック(押してから delayMs が経過する前に離す操作)で実行する関数です。省略できます。', }, { name: 'options.onLongPressEnd', @@ -131,7 +131,7 @@ function ContextMenu() { const [menuVisible, setMenuVisible] = useState(false); const longPressHandlers = useLongPress(() => setMenuVisible(true), { - delay: 400, + delayMs: 400, onClick: () => console.log('Normal click'), onLongPressEnd: () => console.log('Long press completed'), }); diff --git a/packages/react-simplikit/src/hooks/useLongPress/ko/useLongPress.md b/packages/react-simplikit/src/hooks/useLongPress/ko/useLongPress.md index 14e5392b..02a97ffb 100644 --- a/packages/react-simplikit/src/hooks/useLongPress/ko/useLongPress.md +++ b/packages/react-simplikit/src/hooks/useLongPress/ko/useLongPress.md @@ -26,7 +26,7 @@ function useLongPress( description="길게 누름 동작을 위한 설정 옵션이에요." :nested="[ { - name: 'options.delay', + name: 'options.delayMs', type: 'number', required: false, defaultValue: '500', @@ -130,7 +130,7 @@ function ContextMenu() { const [menuVisible, setMenuVisible] = useState(false); const longPressHandlers = useLongPress(() => setMenuVisible(true), { - delay: 400, + delayMs: 400, onClick: () => console.log('일반 클릭'), onLongPressEnd: () => console.log('길게 누름 완료'), }); diff --git a/packages/react-simplikit/src/hooks/useLongPress/useLongPress.md b/packages/react-simplikit/src/hooks/useLongPress/useLongPress.md index 4990a515..b4bf1ae7 100644 --- a/packages/react-simplikit/src/hooks/useLongPress/useLongPress.md +++ b/packages/react-simplikit/src/hooks/useLongPress/useLongPress.md @@ -27,7 +27,7 @@ function useLongPress( description="Configuration options for the long press behavior." :nested="[ { - name: 'options.delay', + name: 'options.delayMs', type: 'number', required: false, defaultValue: '500', @@ -131,7 +131,7 @@ function ContextMenu() { const [menuVisible, setMenuVisible] = useState(false); const longPressHandlers = useLongPress(() => setMenuVisible(true), { - delay: 400, + delayMs: 400, onClick: () => console.log('Normal click'), onLongPressEnd: () => console.log('Long press completed'), }); diff --git a/packages/react-simplikit/src/hooks/useLongPress/useLongPress.spec.tsx b/packages/react-simplikit/src/hooks/useLongPress/useLongPress.spec.tsx index 70968e52..01ed8936 100644 --- a/packages/react-simplikit/src/hooks/useLongPress/useLongPress.spec.tsx +++ b/packages/react-simplikit/src/hooks/useLongPress/useLongPress.spec.tsx @@ -12,7 +12,7 @@ type PressTargetProps = { }; function PressTarget({ onLongPress, onClick, onLongPressEnd }: PressTargetProps) { - const handlers = useLongPress(onLongPress, { delay: 500, onClick, onLongPressEnd }); + const handlers = useLongPress(onLongPress, { delayMs: 500, onClick, onLongPressEnd }); return ; } @@ -97,7 +97,7 @@ describe('useLongPress', () => { it('should respect custom delay timing', async () => { const onLongPress = vi.fn(); const TestComponent = () => { - const longPressHandlers = useLongPress(onLongPress, { delay: 1000 }); + const longPressHandlers = useLongPress(onLongPress, { delayMs: 1000 }); return (