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/storage-state-json.md b/.changeset/storage-state-json.md new file mode 100644 index 00000000..971012b1 --- /dev/null +++ b/.changeset/storage-state-json.md @@ -0,0 +1,29 @@ +--- +'react-simplikit': major +--- + +`useStorageState` now types its state as `T` instead of `Serializable`, so `null` in a union and `interface` types are kept. With ``, the state was typed `string` and is now `string | null`. + +Without `serializer` and `deserializer`, the value is stored as JSON and is now checked all the way down. Setting a value with a `Date`, `Map`, class instance, function, `NaN` or `Infinity` anywhere in it throws with its path. Before, such a nested value was silently changed or dropped: a `Date` became a string, a `Map` became `{}`, `NaN` became `null`, and a function disappeared. `defaultValue` is checked on mount, even when storage already holds a value. + +`serializer` and `deserializer` must be passed together. A lone one is a type error and is ignored at runtime. When both are passed, the value is not checked, so a `Date` default works, and the callback parameters are typed from `defaultValue`. + +`refresh` now reads storage again without writing to it. Before, it wrote the current value back: an empty key got `defaultValue`, an unreadable value was replaced with `defaultValue` or removed, and a function stored with `serializer` and `deserializer` was run as an updater. Call `refresh` when `deserializer` changes what it returns for the same stored string. + +```ts +// before: the state was typed `string`, though it can be `null` +const [token] = useStorageState('token', { defaultValue: null }); + +// after: the state is typed `string | null` +const [token] = useStorageState('token', { defaultValue: null }); + +// before: stored as `{"at":"2026-01-01T00:00:00.000Z"}` and read back as a string +setValue({ at: new Date() }); + +// after: throws "value.at is not JSON-serializable", so store a Date with both functions +useStorageState('at', { + defaultValue: new Date(), + serializer: date => date.toISOString(), + deserializer: text => new Date(text), +}); +``` 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 a4323009..ee543f2b 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 6d150066..ba44fae3 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..51b5569f 100644 --- a/packages/plugin/skills/react-simplikit/references/useStorageState.md +++ b/packages/plugin/skills/react-simplikit/references/useStorageState.md @@ -1,19 +1,20 @@ # 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`. +Without `serializer` and `deserializer`, the value is stored as JSON and must be JSON-serializable all the way down: +a `Date`, `Map`, class instance or function anywhere in it throws when it is set, and `defaultValue` is checked on mount. +Pass both functions to store any other type. The stored string is not validated when it is read back. +The value read back is reused until the stored string changes, so call `refresh` when `deserializer` changes what it returns. + ## Interface ```ts function useStorageState( key: string, options?: Object -): readonly [ - state: Serializable | undefined, - setState: (value: SetStateAction | undefined>) => void, - refreshState: () => void, -]; +): readonly [state: T | undefined, actions: StorageStateActions]; ``` ### Parameters @@ -46,15 +47,17 @@ function useStorageState( }, { name: 'options.serializer', - type: 'Function', + type: '(value: T) => string', required: false, - description: 'A function to serialize the state value to a string.', + description: + 'Converts the value to a string. Pass it together with deserializer.', }, { name: 'options.deserializer', - type: 'Function', + type: '(value: string) => T', required: false, - description: 'A function to deserialize the state value from a string.', + description: + 'Converts the stored string back to a value. Pass it together with serializer.', }, ]" /> @@ -63,26 +66,27 @@ function useStorageState( @@ -94,12 +98,12 @@ function useStorageState( import { useStorageState } from 'react-simplikit'; function Counter() { - const [count, setCount] = useStorageState('counter', { + const [count, { setValue }] = useStorageState('counter', { defaultValue: 0, }); return ( - + ); } ``` diff --git a/packages/plugin/skills/react-simplikit/references/useThrottle.md b/packages/plugin/skills/react-simplikit/references/useThrottle.md index 1995c67d..7e966063 100644 --- a/packages/plugin/skills/react-simplikit/references/useThrottle.md +++ b/packages/plugin/skills/react-simplikit/references/useThrottle.md @@ -9,8 +9,8 @@ such as when handling scroll or resize events. ```ts function useThrottle unknown>( callback: F, - wait: number, - options?: { edges?: Array<'leading' | 'trailing'> } + throttleMs: number, + options?: ThrottleOptions ): F & { cancel: () => void }; ``` @@ -25,23 +25,31 @@ function useThrottle unknown>( @@ -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..6fe81a85 --- /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 + +import type { CallerTypedArgs } from '../../../_internal/types.ts'; + +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'>; +}; + +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..fc41b1e9 --- /dev/null +++ b/packages/react-simplikit/src/hooks/_internal/useDebounce/useDebounce.ts @@ -0,0 +1,60 @@ +import { useEffect } from 'react'; +import { useMemo } from 'react'; + +import type { CallerTypedArgs } from '../../../_internal/types.ts'; +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..e6618dc0 --- /dev/null +++ b/packages/react-simplikit/src/hooks/_internal/useThrottle/throttle.ts @@ -0,0 +1,80 @@ +// Simplified version of https://github.com/toss/es-toolkit/blob/main/src/function/throttle.ts + +import type { CallerTypedArgs } from '../../../_internal/types.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..b50f2650 --- /dev/null +++ b/packages/react-simplikit/src/hooks/_internal/useThrottle/useThrottle.ts @@ -0,0 +1,55 @@ +import { useEffect, useMemo } from 'react'; + +import type { CallerTypedArgs } from '../../../_internal/types.ts'; +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 unknown>( + 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 5e3215a1..89564256 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,21 +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 bd910a61..93125717 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 = {} // Written out because `DebouncedFunction` is not exported from the package entry, so consumers emitting declarations cannot name it (TS2742) ): { (...args: Parameters): void; cancel: () => void } { @@ -85,8 +85,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 11bc74dd..071e966a 100644 --- a/packages/react-simplikit/src/hooks/useList/ja/useList.md +++ b/packages/react-simplikit/src/hooks/useList/ja/useList.md @@ -2,17 +2,17 @@ 配列を状態として管理する React フックです。 効率的な状態管理と、参照が安定したアクション関数を提供します。 -初期状態と `setAll` には、変更可能な配列と読み取り専用の配列の両方を渡せます。 +初期値と `setAll` には、変更可能な配列と読み取り専用の配列の両方を渡せます。 ## インターフェース ```ts -function useList(initialState: readonly T[] = []): UseListReturn; +function useList(initialValue: readonly 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 c2370eb7..dcb86595 100644 --- a/packages/react-simplikit/src/hooks/useList/ko/useList.md +++ b/packages/react-simplikit/src/hooks/useList/ko/useList.md @@ -6,12 +6,12 @@ ## 인터페이스 ```ts -function useList(initialState: readonly T[] = []): UseListReturn; +function useList(initialValue: readonly T[] = []): UseListReturn; ``` ### 파라미터 - + ### 반환 값 diff --git a/packages/react-simplikit/src/hooks/useList/useList.md b/packages/react-simplikit/src/hooks/useList/useList.md index 0fe5ce12..1c83ef0c 100644 --- a/packages/react-simplikit/src/hooks/useList/useList.md +++ b/packages/react-simplikit/src/hooks/useList/useList.md @@ -2,20 +2,20 @@ A React hook that manages an array as state. Provides efficient state management and stable action functions. -Accepts mutable and readonly arrays for the initial state and `setAll`. +Accepts mutable and readonly arrays for the initial value and `setAll`. ## Interface ```ts -function useList(initialState: readonly T[] = []): UseListReturn; +function useList(initialValue: readonly 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 2a8a887c..87045e6d 100644 --- a/packages/react-simplikit/src/hooks/useList/useList.ts +++ b/packages/react-simplikit/src/hooks/useList/useList.ts @@ -18,11 +18,11 @@ type UseListReturn = [ReadonlyArray, ListActions]; * @description * A React hook that manages an array as state. * Provides efficient state management and stable action functions. - * Accepts mutable and readonly arrays for the initial state and `setAll`. + * Accepts mutable and readonly arrays for the initial value and `setAll`. * * @template T - The type of the values held in the list. * - * @param {readonly T[]} [initialState=[]] - Initial array state. + * @param {readonly T[]} [initialValue=[]] - Initial array value. * * @returns {UseListReturn} A tuple containing the array state and actions to manipulate it. * - list `ReadonlyArray` - The current array state; @@ -56,10 +56,10 @@ type UseListReturn = [ReadonlyArray, ListActions]; * actions.reset(); * ``` */ -export function useList(initialState: readonly T[] = []): UseListReturn { - const [list, setList] = useState(initialState); +export function useList(initialValue: readonly T[] = []): UseListReturn { + const [list, setList] = useState(initialValue); - const preservedInitialState = usePreservedReference(initialState); + const preservedInitialValue = usePreservedReference(initialValue); const push = usePreservedCallback((value: T) => { setList(prev => [...prev, value]); @@ -94,7 +94,7 @@ export function useList(initialState: readonly 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 26da7c88..ebe0ca4c 100644 --- a/packages/react-simplikit/src/hooks/useList/zh-Hans/useList.md +++ b/packages/react-simplikit/src/hooks/useList/zh-Hans/useList.md @@ -1,17 +1,17 @@ # useList 一个将数组作为状态进行管理的 React Hook。它提供高效的状态管理和稳定的操作函数。 -初始状态和 `setAll` 均接受可变数组和只读数组。 +初始值和 `setAll` 均接受可变数组和只读数组。 ## 接口 ```ts -function useList(initialState: readonly T[] = []): UseListReturn; +function useList(initialValue: readonly 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 ede2e7a6..a7afaeb5 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 ( + ); } ``` diff --git a/packages/react-simplikit/src/hooks/useStorageState/index.ts b/packages/react-simplikit/src/hooks/useStorageState/index.ts index f045c6c6..415397cf 100644 --- a/packages/react-simplikit/src/hooks/useStorageState/index.ts +++ b/packages/react-simplikit/src/hooks/useStorageState/index.ts @@ -1 +1 @@ -export { type Serializable, useStorageState } from './useStorageState.ts'; +export { useStorageState } from './useStorageState.ts'; diff --git a/packages/react-simplikit/src/hooks/useStorageState/ja/useStorageState.md b/packages/react-simplikit/src/hooks/useStorageState/ja/useStorageState.md index 2d586814..ab58d5b4 100644 --- a/packages/react-simplikit/src/hooks/useStorageState/ja/useStorageState.md +++ b/packages/react-simplikit/src/hooks/useStorageState/ja/useStorageState.md @@ -3,17 +3,15 @@ `useStorageState` は、`useState` と同様に動作し、状態の値をブラウザーのストレージに永続化する React フックです。 ページを再読み込みしても値が保持され、`localStorage` を使う場合はタブ間で共有できます。 +`serializer` と `deserializer` を渡さない場合、値は JSON として保存されるため、内側まですべて JSON にシリアライズできる必要があります。値のどこかに `Date`、`Map`、クラスのインスタンス、関数があると、値を設定したときにエラーになります。`defaultValue` はマウント時に検査されます。ほかの型を保存するには、両方の関数を渡してください。ストレージから読み戻した文字列は検証しません。読み戻した値は保存された文字列が変わるまで再利用されるため、`deserializer` が返す値を変えた場合は `refresh` を呼び出してください。 + ## インターフェース ```ts function useStorageState( key: string, options?: Object -): readonly [ - state: Serializable | undefined, - setState: (value: SetStateAction | undefined>) => void, - refreshState: () => void, -]; +): readonly [state: T | undefined, actions: StorageStateActions]; ``` ### パラメータ @@ -46,15 +44,15 @@ function useStorageState( }, { name: 'options.serializer', - type: 'Function', + type: '(value: T) => string', required: false, - description: '状態の値を文字列にシリアライズする関数。', + description: '値を文字列に変換する関数。deserializer と一緒に渡します。', }, { name: 'options.deserializer', - type: 'Function', + type: '(value: string) => T', required: false, - description: '文字列から状態の値をデシリアライズする関数。', + description: '保存された文字列を値に戻す関数。serializer と一緒に渡します。', }, ]" /> @@ -63,26 +61,26 @@ function useStorageState( @@ -94,12 +92,12 @@ function useStorageState( import { useStorageState } from 'react-simplikit'; function Counter() { - const [count, setCount] = useStorageState('counter', { + const [count, { setValue }] = useStorageState('counter', { defaultValue: 0, }); return ( - + ); } ``` diff --git a/packages/react-simplikit/src/hooks/useStorageState/ko/useStorageState.md b/packages/react-simplikit/src/hooks/useStorageState/ko/useStorageState.md index ecf1be84..dc1196fc 100644 --- a/packages/react-simplikit/src/hooks/useStorageState/ko/useStorageState.md +++ b/packages/react-simplikit/src/hooks/useStorageState/ko/useStorageState.md @@ -1,6 +1,8 @@ # useStorageState -리액트 훅으로, `useState`처럼 작동하지만 상태 값을 브라우저 저장소에 저장해요. 이 값은 페이지를 새로고침해도 유지되며, `localStorage`를 사용할 경우 탭 간에 공유될 수 있어요. +`useStorageState`는 `useState`처럼 작동하지만 상태 값을 브라우저 저장소에 저장하는 리액트 훅이에요. 이 값은 페이지를 새로고침해도 유지되며, `localStorage`를 사용할 경우 탭 간에 공유될 수 있어요. + +`serializer`와 `deserializer`를 전달하지 않으면 값을 JSON으로 저장하므로, 값의 안쪽까지 모두 JSON으로 직렬화할 수 있어야 해요. 값 어딘가에 `Date`, `Map`, 클래스 인스턴스나 함수가 있으면 값을 설정할 때 에러가 발생하고, `defaultValue`는 마운트 시 검사해요. 다른 타입을 저장하려면 두 함수를 함께 전달하세요. 저장소에서 읽어 온 문자열은 따로 검증하지 않아요. 읽어 온 값은 저장된 문자열이 바뀔 때까지 재사용하므로, `deserializer`가 반환하는 값이 달라지면 `refresh`를 호출하세요. ## 인터페이스 @@ -8,11 +10,7 @@ function useStorageState( key: string, options?: Object -): readonly [ - state: Serializable | undefined, - setState: (value: SetStateAction | undefined>) => void, - refreshState: () => void, -]; +): readonly [state: T | undefined, actions: StorageStateActions]; ``` ### 파라미터 @@ -45,15 +43,15 @@ function useStorageState( }, { name: 'options.serializer', - type: 'Function', + type: '(value: T) => string', required: false, - description: '상태 값을 문자열로 직렬화하는 함수예요.', + description: '값을 문자열로 바꾸는 함수예요. deserializer와 함께 전달하세요.', }, { name: 'options.deserializer', - type: 'Function', + type: '(value: string) => T', required: false, - description: '상태 값을 문자열에서 역직렬화하는 함수예요.', + description: '저장된 문자열을 값으로 되돌리는 함수예요. serializer와 함께 전달하세요.', }, ]" /> @@ -62,26 +60,26 @@ function useStorageState( @@ -93,12 +91,12 @@ function useStorageState( import { useStorageState } from 'react-simplikit'; function Counter() { - const [count, setCount] = useStorageState('counter', { + const [count, { setValue }] = useStorageState('counter', { defaultValue: 0, }); return ( - + ); } ``` diff --git a/packages/react-simplikit/src/hooks/useStorageState/useStorageState.md b/packages/react-simplikit/src/hooks/useStorageState/useStorageState.md index 08ee4a2c..51b5569f 100644 --- a/packages/react-simplikit/src/hooks/useStorageState/useStorageState.md +++ b/packages/react-simplikit/src/hooks/useStorageState/useStorageState.md @@ -1,19 +1,20 @@ # 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`. +Without `serializer` and `deserializer`, the value is stored as JSON and must be JSON-serializable all the way down: +a `Date`, `Map`, class instance or function anywhere in it throws when it is set, and `defaultValue` is checked on mount. +Pass both functions to store any other type. The stored string is not validated when it is read back. +The value read back is reused until the stored string changes, so call `refresh` when `deserializer` changes what it returns. + ## Interface ```ts function useStorageState( key: string, options?: Object -): readonly [ - state: Serializable | undefined, - setState: (value: SetStateAction | undefined>) => void, - refreshState: () => void, -]; +): readonly [state: T | undefined, actions: StorageStateActions]; ``` ### Parameters @@ -46,15 +47,17 @@ function useStorageState( }, { name: 'options.serializer', - type: 'Function', + type: '(value: T) => string', required: false, - description: 'A function to serialize the state value to a string.', + description: + 'Converts the value to a string. Pass it together with deserializer.', }, { name: 'options.deserializer', - type: 'Function', + type: '(value: string) => T', required: false, - description: 'A function to deserialize the state value from a string.', + description: + 'Converts the stored string back to a value. Pass it together with serializer.', }, ]" /> @@ -63,26 +66,27 @@ function useStorageState( @@ -94,12 +98,12 @@ function useStorageState( import { useStorageState } from 'react-simplikit'; function Counter() { - const [count, setCount] = useStorageState('counter', { + const [count, { setValue }] = useStorageState('counter', { defaultValue: 0, }); return ( - + ); } ``` diff --git a/packages/react-simplikit/src/hooks/useStorageState/useStorageState.spec.ts b/packages/react-simplikit/src/hooks/useStorageState/useStorageState.spec.ts index 5bccd602..8252ee1a 100644 --- a/packages/react-simplikit/src/hooks/useStorageState/useStorageState.spec.ts +++ b/packages/react-simplikit/src/hooks/useStorageState/useStorageState.spec.ts @@ -1,5 +1,6 @@ -import { act } from '@testing-library/react'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { createElement, useEffect } from 'react'; +import { act, render } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, expectTypeOf, it, vi } from 'vitest'; import { renderHookSSR } from '../../_internal/test-utils/renderHookSSR.tsx'; @@ -167,7 +168,7 @@ describe('useStorageState', () => { it('should set and get value', async () => { const { result } = await renderHookSSR(() => useStorageState('test-key', { storage })); await act(async () => { - result.current[1]('new value'); + result.current[1].setValue('new value'); }); expect(result.current[0]).toBe('new value'); }); @@ -175,17 +176,27 @@ describe('useStorageState', () => { it('should update value using function', async () => { const { result } = await renderHookSSR(() => useStorageState('test-key', { defaultValue: 0, storage })); await act(async () => { - result.current[1](prev => prev + 1); + result.current[1].setValue(prev => prev + 1); }); expect(result.current[0]).toBe(1); }); + it('should keep the actions object stable across renders', async () => { + const { result } = await renderHookSSR(() => useStorageState('test-key', { storage })); + const [, actions] = result.current; + await act(async () => { + actions.setValue('new value'); + }); + expect(result.current[0]).toBe('new value'); + expect(result.current[1]).toBe(actions); + }); + it('should sync between multiple hooks with same key', async () => { const { result: result1 } = await renderHookSSR(() => useStorageState('test-key', { storage })); const { result: result2 } = await renderHookSSR(() => useStorageState('test-key', { storage })); await act(async () => { - result1.current[1]('updated value'); + result1.current[1].setValue('updated value'); }); expect(result2.current[0]).toBe('updated value'); @@ -197,7 +208,7 @@ describe('useStorageState', () => { storage.set('test-key', JSON.stringify({ hello: 'world' })); await act(async () => { - result.current[2](); + result.current[1].refresh(); }); expect(result.current[0]).toEqual({ hello: 'world' }); @@ -212,7 +223,7 @@ describe('useStorageState', () => { const { result } = await renderHookSSR(() => useStorageState('test-key', { storage, serializer, deserializer })); await act(async () => { - result.current[1]('hello'); + result.current[1].setValue('hello'); }); expect(result.current[0]).toEqual('hello'); @@ -221,12 +232,12 @@ describe('useStorageState', () => { it('should throw error when value is not serializable', async () => { await expect( async () => await renderHookSSR(() => useStorageState('test-key', { storage, defaultValue: () => 'world' })) - ).rejects.toThrow('Received a non-serializable value'); + ).rejects.toThrow('defaultValue is not JSON-serializable'); await expect( async () => await renderHookSSR(() => useStorageState('test-key', { storage, defaultValue: new (class Cls {})() })) - ).rejects.toThrow('Received a non-serializable value'); + ).rejects.toThrow('defaultValue is not JSON-serializable'); }); }; @@ -244,7 +255,7 @@ describe('useStorageState', () => { const { result, rerender } = await renderHookSSR(() => useStorageState('test-key', { storage })); await act(async () => { - result.current[1]('memo value'); + result.current[1].setValue('memo value'); }); rerender(); @@ -266,7 +277,7 @@ describe('useStorageState', () => { ); await act(async () => { - result.current[1]('session value'); + result.current[1].setValue('session value'); }); rerender(); @@ -304,7 +315,7 @@ describe('useStorageState', () => { ); await act(async () => { - result.current[1]('local value'); + result.current[1].setValue('local value'); }); rerender(); @@ -404,16 +415,375 @@ describe('useStorageState', () => { const { result } = await renderHookSSR(() => useStorageState('test-key', { storage: safeLocalStorage })); await act(async () => { - result.current[1]('value'); + result.current[1].setValue('value'); }); expect(result.current[0]).toBe('value'); await act(async () => { - result.current[1](undefined); + result.current[1].setValue(undefined); }); expect(result.current[0]).toBeUndefined(); }); }); + + describe('when the value is stored as JSON', () => { + const storage = new MemoStorage(); + + afterEach(() => { + storage.clear(); + }); + + it('should throw with the path when a nested Date is set', async () => { + const { result } = await renderHookSSR(() => + useStorageState<{ list: Array<{ at: Date | string }> }>('test-key', { + storage, + defaultValue: { list: [{ at: 'now' }] }, + }) + ); + + expect(() => act(() => result.current[1].setValue({ list: [{ at: 'now' }, { at: new Date() }] }))).toThrow( + 'value.list[1].at is not JSON-serializable' + ); + expect(storage.get('test-key')).toBeNull(); + }); + + it('should throw when a nested function is set through an updater', async () => { + const { result } = await renderHookSSR(() => + useStorageState>('test-key', { storage, defaultValue: {} }) + ); + + expect(() => act(() => result.current[1].setValue(prev => ({ ...prev, onClick: () => {} })))).toThrow( + 'value.onClick is not JSON-serializable' + ); + }); + + it('should throw for NaN and for undefined inside an array', async () => { + const { result } = await renderHookSSR(() => useStorageState('test-key', { storage })); + + expect(() => act(() => result.current[1].setValue({ ratio: NaN }))).toThrow( + 'value.ratio is not JSON-serializable' + ); + expect(() => act(() => result.current[1].setValue([1, undefined]))).toThrow('value[1] is not JSON-serializable'); + }); + + it('should drop an undefined property like JSON.stringify does', async () => { + const { result } = await renderHookSSR(() => + useStorageState<{ nickname: string | undefined; age: number }>('test-key', { + storage, + defaultValue: { nickname: undefined, age: 1 }, + }) + ); + + await act(async () => { + result.current[1].setValue({ nickname: undefined, age: 2 }); + }); + + expect(storage.get('test-key')).toBe('{"age":2}'); + }); + + it('should check defaultValue on mount even when storage already holds a value', async () => { + storage.set('test-key', JSON.stringify({ at: '2026-01-01' })); + + await expect( + async () => + await renderHookSSR(() => useStorageState('test-key', { storage, defaultValue: { at: new Date(0) } })) + ).rejects.toThrow('defaultValue.at is not JSON-serializable'); + }); + + it('should not write defaultValue to storage on refresh', async () => { + const { result } = await renderHookSSR(() => useStorageState('test-key', { storage, defaultValue: { a: 1 } })); + + await act(async () => { + result.current[1].refresh(); + }); + + expect(storage.get('test-key')).toBeNull(); + expect(result.current[0]).toEqual({ a: 1 }); + }); + }); + + describe('when serializer and deserializer are passed', () => { + const storage = new MemoStorage(); + + afterEach(() => { + storage.clear(); + }); + + it('should store a Date default and updates without the JSON check', async () => { + const { result } = await renderHookSSR(() => + useStorageState('test-key', { + storage, + defaultValue: new Date(0), + serializer: date => date.toISOString(), + deserializer: text => new Date(text), + }) + ); + + expect(result.current[0]).toEqual(new Date(0)); + + await act(async () => { + result.current[1].setValue(new Date(1000)); + }); + + expect(storage.get('test-key')).toBe('1970-01-01T00:00:01.000Z'); + expect(result.current[0]).toEqual(new Date(1000)); + }); + + it('should ignore a deserializer passed without a serializer', async () => { + storage.set('test-key', JSON.stringify('stored')); + const options = { storage, deserializer: () => 'custom' }; + + // @ts-expect-error A lone deserializer would read a format the value was never written in. + const { result } = await renderHookSSR(() => useStorageState('test-key', options)); + + expect(result.current[0]).toBe('stored'); + }); + + it('should write with the serializer from the latest render', async () => { + const { result, rerender } = await renderHookSSR( + ({ prefix }: { prefix: string }) => + useStorageState('test-key', { + storage, + defaultValue: 'a', + serializer: value => `${prefix}:${value}`, + deserializer: text => text.slice(text.indexOf(':') + 1), + }), + { initialProps: { prefix: 'v1' } } + ); + + rerender({ prefix: 'v2' }); + await act(async () => { + result.current[1].setValue('b'); + }); + + expect(storage.get('test-key')).toBe('v2:b'); + }); + + it('should read with the deserializer from the latest render', async () => { + const { result, rerender } = await renderHookSSR( + ({ suffix }: { suffix: string }) => + useStorageState('test-key', { + storage, + defaultValue: 'a', + serializer: value => value, + deserializer: text => `${text}${suffix}`, + }), + { initialProps: { suffix: '' } } + ); + + rerender({ suffix: '!' }); + await act(async () => { + result.current[1].setValue('b'); + }); + + expect(result.current[0]).toBe('b!'); + }); + + it('should read with the new deserializer when the key and deserializer change together', async () => { + storage.set('a', 'A'); + storage.set('b', 'B'); + const { result, rerender } = await renderHookSSR( + ({ key, tag }: { key: string; tag: string }) => + useStorageState(key, { + storage, + defaultValue: '', + serializer: value => value, + deserializer: text => `${tag}:${text}`, + }), + { initialProps: { key: 'a', tag: 'old' } } + ); + + rerender({ key: 'b', tag: 'new' }); + + expect(result.current[0]).toBe('new:B'); + }); + + it('should keep the actions object stable when the functions are passed inline', async () => { + const { result, rerender } = await renderHookSSR(() => + useStorageState('test-key', { + storage, + defaultValue: 0, + serializer: value => String(value), + deserializer: text => Number(text), + }) + ); + const [, actions] = result.current; + + rerender(); + + expect(result.current[1]).toBe(actions); + }); + + it('should not run a stored function as an updater on refresh', async () => { + const greet = vi.fn(() => 'hello'); + const { result } = await renderHookSSR(() => + useStorageState('test-key', { + storage, + defaultValue: greet, + serializer: () => 'greet', + deserializer: () => greet, + }) + ); + + await act(async () => { + result.current[1].setValue(() => greet); + }); + await act(async () => { + result.current[1].refresh(); + }); + + expect(greet).not.toHaveBeenCalled(); + expect(result.current[0]).toBe(greet); + expect(storage.get('test-key')).toBe('greet'); + }); + + it('should read with the new deserializer on refresh without writing the value back', async () => { + storage.set('test-key', 'same'); + const { result, rerender } = await renderHookSSR( + ({ tag }: { tag: string }) => + useStorageState('test-key', { + storage, + defaultValue: '', + serializer: value => value, + deserializer: text => `${tag}:${text}`, + }), + { initialProps: { tag: 'old' } } + ); + + rerender({ tag: 'new' }); + await act(async () => { + result.current[1].refresh(); + }); + + expect(result.current[0]).toBe('new:same'); + expect(storage.get('test-key')).toBe('same'); + }); + }); + + describe('when a child effect calls setValue in the commit that changes the options', () => { + const storage = new MemoStorage(); + + afterEach(() => { + storage.clear(); + }); + + function Child({ value, setValue }: { value: string; setValue: (value: string) => void }) { + useEffect( + function forwardValue() { + setValue(value); + }, + [value, setValue] + ); + return null; + } + + it('should write to the new key', async () => { + function Parent({ id }: { id: string }) { + const [, { setValue }] = useStorageState(`draft:${id}`, { storage }); + return createElement(Child, { value: id, setValue }); + } + + const { rerender } = render(createElement(Parent, { id: 'a' })); + rerender(createElement(Parent, { id: 'b' })); + + expect(storage.get('draft:a')).toBe('"a"'); + expect(storage.get('draft:b')).toBe('"b"'); + }); + + it('should write with the new serializer', async () => { + function Parent({ version }: { version: string }) { + const [, { setValue }] = useStorageState('test-key', { + storage, + defaultValue: '', + serializer: value => `${version}:${value}`, + deserializer: text => text.slice(text.indexOf(':') + 1), + }); + return createElement(Child, { value: version, setValue }); + } + + const { rerender } = render(createElement(Parent, { version: 'v1' })); + rerender(createElement(Parent, { version: 'v2' })); + + expect(storage.get('test-key')).toBe('v2:v2'); + }); + }); + + describe('types', () => { + type User = { name: string }; + // eslint-disable-next-line @typescript-eslint/consistent-type-definitions -- users declare stored shapes as interfaces too, and an interface has no implicit index signature + interface Settings { + theme: string; + } + + it('should infer the state type from defaultValue and the generic', () => { + function useTypeCheck() { + const [count, { setValue: setCount }] = useStorageState('count', { defaultValue: 0 }); + expectTypeOf(count).toEqualTypeOf(); + setCount(count + 1); + setCount(prev => prev + 1); + + const [theme] = useStorageState('theme', { defaultValue: 'light' }); + expectTypeOf(theme).toEqualTypeOf(); + + const [mode, { setValue: setMode }] = useStorageState<'light' | 'dark'>('mode', { defaultValue: 'light' }); + expectTypeOf(mode).toEqualTypeOf<'light' | 'dark'>(); + // @ts-expect-error 'blue' is not one of the declared modes. + setMode('blue'); + + const [token] = useStorageState('token'); + expectTypeOf(token).toEqualTypeOf(); + } + + expectTypeOf(useTypeCheck).toBeFunction(); + }); + + it('should keep null, interfaces and records in the state type', () => { + function useTypeCheck() { + const [user] = useStorageState('user', { defaultValue: null }); + expectTypeOf(user).toEqualTypeOf(); + + const [settings] = useStorageState('settings', { defaultValue: { theme: 'light' } }); + expectTypeOf(settings).toEqualTypeOf(); + + const [record] = useStorageState>('record', { defaultValue: { a: 1 } }); + expectTypeOf(record).toEqualTypeOf>(); + } + + expectTypeOf(useTypeCheck).toBeFunction(); + }); + + it('should type serializer and deserializer from defaultValue', () => { + function useTypeCheck() { + const [date] = useStorageState('date', { + defaultValue: new Date(0), + serializer: value => value.toISOString(), + deserializer: text => new Date(text), + }); + expectTypeOf(date).toEqualTypeOf(); + + const [maybeDate] = useStorageState('maybe-date', { + serializer: value => value.toISOString(), + deserializer: text => new Date(text), + }); + expectTypeOf(maybeDate).toEqualTypeOf(); + + // @ts-expect-error A serializer needs a matching deserializer. + useStorageState('half', { defaultValue: 0, serializer: value => String(value) }); + } + + expectTypeOf(useTypeCheck).toBeFunction(); + }); + + it('should accept a generic value from a wrapping hook', () => { + function useWrapped(key: string, defaultValue: T) { + const [value, { setValue }] = useStorageState(key, { defaultValue }); + setValue(value); + return value; + } + + expectTypeOf(useWrapped).returns.toEqualTypeOf(); + }); + }); }); diff --git a/packages/react-simplikit/src/hooks/useStorageState/useStorageState.ts b/packages/react-simplikit/src/hooks/useStorageState/useStorageState.ts index cb4be452..8acafb68 100644 --- a/packages/react-simplikit/src/hooks/useStorageState/useStorageState.ts +++ b/packages/react-simplikit/src/hooks/useStorageState/useStorageState.ts @@ -1,31 +1,29 @@ -/* eslint-disable react-hooks/exhaustive-deps */ -import { SetStateAction, useCallback, useRef, useSyncExternalStore } from 'react'; +import { SetStateAction, useInsertionEffect, useMemo, useRef, useSyncExternalStore } from 'react'; import { safeLocalStorage, Storage } from './storage.ts'; -type ToObject = T extends unknown[] | Record ? T : never; - -export type Serializable = T extends string | number | boolean ? T : ToObject; - type StorageStateOptions = { storage?: Storage; defaultValue?: T; }; -type StorageStateOptionsWithDefaultValue = StorageStateOptions & { - defaultValue: T; +type StorageStateCodec = { + serializer: (value: T) => string; + deserializer: (value: string) => T; }; -type StorageStateOptionsWithSerializer = StorageStateOptions & { - serializer: (value: Serializable) => string; - deserializer: (value: string) => Serializable; +/** Rejects a lone `serializer` or `deserializer`, which would write and read in different formats. */ +type WithoutCodec = { + serializer?: never; + deserializer?: never; }; -type SerializableGuard = T[0] extends unknown - ? T - : T[0] extends never - ? 'Received a non-serializable value' - : T; +type StorageStateActions = { + setValue: (value: SetStateAction) => void; + refresh: () => void; +}; + +type StorageStateResult = readonly [V, StorageStateActions]; const listeners = new Set<() => void>(); @@ -33,126 +31,160 @@ const emitListeners = () => { listeners.forEach(listener => listener()); }; -function isPlainObject(value: unknown): value is Record { - if (typeof value !== 'object') { - return false; +function isUpdater(value: SetStateAction): value is (prevState: V) => V { + return typeof value === 'function'; +} + +function isPlainObject(value: object): boolean { + const proto = Object.getPrototypeOf(value); + return proto === Object.prototype || proto === null; +} + +/** + * Returns the path to the first value that would not survive `JSON.stringify` and `JSON.parse` unchanged, or `null`. + * `undefined` is allowed only as an object property, which JSON drops just like a missing key. + * The path is built only on failure, so a valid value costs one walk with no string work. + */ +function findNonJsonPath(value: unknown): Array | null { + if (value === null || typeof value === 'string' || typeof value === 'boolean') { + return null; } - const proto = Object.getPrototypeOf(value) as typeof Object.prototype | null; + if (typeof value === 'number') { + return Number.isFinite(value) ? null : []; + } - const hasObjectPrototype = proto === Object.prototype; + if (Array.isArray(value)) { + for (let index = 0; index < value.length; index++) { + const path = findNonJsonPath(value[index]); + if (path !== null) { + return [index, ...path]; + } + } + return null; + } - if (!hasObjectPrototype) { - return false; + if (typeof value === 'object' && isPlainObject(value)) { + for (const [key, item] of Object.entries(value)) { + const path = item === undefined ? null : findNonJsonPath(item); + if (path !== null) { + return [key, ...path]; + } + } + return null; } - return Object.prototype.toString.call(value) === '[object Object]'; + return []; } -const ensureSerializable = (value: T): SerializableGuard => { - if ( - value[0] != null && - !['string', 'number', 'boolean'].includes(typeof value[0]) && - !(isPlainObject(value[0]) || Array.isArray(value[0])) - ) { - throw new Error('Received a non-serializable value'); - } +function assertJson(value: unknown, name: string): void { + const path = findNonJsonPath(value); - return value as SerializableGuard; -}; + if (path !== null) { + const location = path.map(part => (typeof part === 'number' ? `[${part}]` : `.${part}`)).join(''); + throw new Error(`${name}${location} is not JSON-serializable. Pass serializer and deserializer.`); + } +} /** * @description - * `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`. * + * Without `serializer` and `deserializer`, the value is stored as JSON and must be JSON-serializable all the way down: + * a `Date`, `Map`, class instance or function anywhere in it throws when it is set, and `defaultValue` is checked on mount. + * Pass both functions to store any other type. The stored string is not validated when it is read back. + * The value read back is reused until the stored string changes, so call `refresh` when `deserializer` changes what it returns. + * * @template T - The type of the stored value. * @param {string} key - The key used to store the value in storage. * @param {Object} [options] - Configuration options for storage behavior. * @param {Storage} [options.storage=localStorage] - The storage type (`localStorage` or `sessionStorage`). Defaults to `localStorage`. * @param {T} [options.defaultValue] - The initial value if no existing value is found. - * @param {Function} [options.serializer] - A function to serialize the state value to a string. - * @param {Function} [options.deserializer] - A function to deserialize the state value from a string. + * @param {(value: T) => string} [options.serializer] - Converts the value to a string. Pass it together with `deserializer`. + * @param {(value: string) => T} [options.deserializer] - Converts the stored string back to a value. Pass it together with `serializer`. * - * @returns {readonly [state: Serializable | undefined, setState: (value: SetStateAction | undefined>) => void, refreshState: () => void]} A tuple: - * - state `Serializable | undefined` - The current state value retrieved from storage; - * - setState `(value: SetStateAction | undefined>) => void` - A function to update and persist the state; - * - refreshState `() => void` - A function to refresh the state from storage; + * @returns {readonly [state: T | undefined, actions: StorageStateActions]} A tuple containing the state and actions to change it. + * - state `T | undefined` - The current state value retrieved from storage. It is `T` when `defaultValue` is given; + * - actions.setValue `(value: SetStateAction) => void` - A function to update and persist the state; + * - actions.refresh `() => void` - Reads the value from storage again without writing to it; * @example * // Counter with persistent state * import { useStorageState } from 'react-simplikit'; * * function Counter() { - * const [count, setCount] = useStorageState('counter', { + * const [count, { setValue }] = useStorageState('counter', { * defaultValue: 0, * }); * - * return ; + * return ; * } */ +export function useStorageState(key: string): StorageStateResult; export function useStorageState( - key: string -): SerializableGuard< - readonly [Serializable | undefined, (value: SetStateAction | undefined>) => void, () => void] ->; + key: string, + options: StorageStateOptions & StorageStateCodec & { defaultValue: T } +): StorageStateResult; export function useStorageState( key: string, - options: StorageStateOptionsWithDefaultValue -): SerializableGuard, (value: SetStateAction>) => void, () => void]>; + options: StorageStateOptions & StorageStateCodec +): StorageStateResult; export function useStorageState( key: string, - options: StorageStateOptions -): SerializableGuard< - readonly [Serializable | undefined, (value: SetStateAction | undefined>) => void, () => void] ->; + options: StorageStateOptions & WithoutCodec & { defaultValue: T } +): StorageStateResult; export function useStorageState( key: string, - options: StorageStateOptionsWithSerializer -): SerializableGuard< - readonly [Serializable | undefined, (value: SetStateAction | undefined>) => void, () => void] ->; + options: StorageStateOptions & WithoutCodec +): StorageStateResult; export function useStorageState( key: string, - { - storage = safeLocalStorage, - defaultValue, - ...options - }: StorageStateOptions | StorageStateOptionsWithSerializer = {} -): SerializableGuard< - readonly [Serializable | undefined, (value: SetStateAction | undefined>) => void, () => void] -> { + options: StorageStateOptions & Partial> = {} +): StorageStateResult { // Without `'use no memo'`, React Compiler throws when `panicThreshold` is not `'none'` // because `cache.current` is read from `getSnapshot`, which `useSyncExternalStore` calls // during render. Belongs on this implementation signature — the overload declarations // above have no body to carry a directive. 'use no memo'; - const serializedDefaultValue = defaultValue as Serializable; + const { storage = safeLocalStorage, defaultValue, serializer, deserializer } = options; + // Both functions are picked by one condition so a value is never written in one format and read in another. + const codec = serializer !== undefined && deserializer !== undefined ? { serializer, deserializer } : null; + + // `data` is `undefined` on mount and after `refresh`, a value storage never returns, so the next + // snapshot always runs the branch below and checks `defaultValue`, even when storage holds a value. const cache = useRef<{ - data: string | null; - parsed: Serializable | undefined; + data: string | null | undefined; + parsed: T | undefined; }>({ - data: null, - parsed: serializedDefaultValue, + data: undefined, + parsed: defaultValue, }); - const getSnapshot = useCallback(() => { - const deserializer = 'deserializer' in options ? options.deserializer : JSON.parse; + // Runs during render, so it reads this render's options instead of a ref that effects update later. + const getSnapshot = () => { const data = storage.get(key); if (data !== cache.current.data) { + const isFirstRead = cache.current.data === undefined; + const fallsBackToDefault = data == null; + + if (codec === null && defaultValue !== undefined && (isFirstRead || fallsBackToDefault)) { + assertJson(defaultValue, 'defaultValue'); + } + try { - cache.current.parsed = data != null ? deserializer(data) : defaultValue; + cache.current.parsed = data != null ? deserialize(data) : defaultValue; } catch { - cache.current.parsed = serializedDefaultValue; + cache.current.parsed = defaultValue; } cache.current.data = data; } return cache.current.parsed; - }, [defaultValue, key, storage]); + }; - const storageState = useSyncExternalStore | undefined>( + const storageState = useSyncExternalStore( onStoreChange => { listeners.add(onStoreChange); @@ -169,33 +201,54 @@ export function useStorageState( window.removeEventListener('storage', handler); }; }, - () => getSnapshot(), - () => serializedDefaultValue + getSnapshot, + () => defaultValue ); - const setStorageState = useCallback( - (value: SetStateAction | undefined>) => { - const serializer = 'serializer' in options ? options.serializer : JSON.stringify; - - const nextValue = typeof value === 'function' ? value(getSnapshot()) : value; + // Insertion effects run before every layout and passive effect, so a child effect that calls + // `setValue` in the commit that changed `key` or the serializer already sees this render's values. + // `usePreservedCallback` syncs in a passive effect, which runs after the child's. + const latest = useRef({ writeValue }); + useInsertionEffect(function syncLatestRender() { + latest.current = { writeValue }; + }); - if (nextValue == null) { - storage.remove(key); - } else { - storage.set(key, serializer(nextValue)); - } - emitListeners(); - }, - [getSnapshot, key, storage] + // Reads through `latest`, so `actions` stays stable even when the options are passed inline. + const actions = useMemo>( + () => ({ + setValue: value => latest.current.writeValue(value), + // Reads storage again without writing to it, so a stored function is never run as an updater + // and a value read with a previous deserializer is never written back. + refresh: () => { + cache.current.data = undefined; + emitListeners(); + }, + }), + [] ); - const refreshStorageState = useCallback(() => { - setStorageState(getSnapshot()); - }, [storage, getSnapshot, setStorageState]); + return [storageState, actions] as const; + + function writeValue(value: SetStateAction) { + const nextValue = isUpdater(value) ? value(getSnapshot()) : value; + + if (nextValue == null) { + storage.remove(key); + } else { + storage.set(key, serialize(nextValue)); + } + emitListeners(); + } - /* eslint-disable-next-line react-hooks/refs -- the two callbacks close over `cache` through - `getSnapshot`, so the rule treats handing them to any function as a possible ref read - during render. `ensureSerializable` only inspects element 0 — `storageState`, a plain - value — and never calls them; passing either callback alone reproduces the report. */ - return ensureSerializable([storageState, setStorageState, refreshStorageState] as const); + function serialize(value: T): string { + if (codec !== null) { + return codec.serializer(value); + } + assertJson(value, 'value'); + return JSON.stringify(value); + } + + function deserialize(data: string): T { + return codec !== null ? codec.deserializer(data) : JSON.parse(data); + } } diff --git a/packages/react-simplikit/src/hooks/useStorageState/zh-Hans/useStorageState.md b/packages/react-simplikit/src/hooks/useStorageState/zh-Hans/useStorageState.md index 36ded34f..1ef737e1 100644 --- a/packages/react-simplikit/src/hooks/useStorageState/zh-Hans/useStorageState.md +++ b/packages/react-simplikit/src/hooks/useStorageState/zh-Hans/useStorageState.md @@ -2,17 +2,15 @@ `useStorageState` 是一个功能类似于 `useState`、但会将状态值持久化到浏览器存储中的 React Hook。该值会在页面刷新后保留,并且在配合 `localStorage` 使用时可以在标签页之间共享。 +如果不传入 `serializer` 和 `deserializer`,值会以 JSON 形式存储,因此值的每一层都必须能够序列化为 JSON:值中任何位置出现 `Date`、`Map`、类实例或函数时,设置该值都会抛出错误,`defaultValue` 则会在挂载时检查。要存储其他类型,请同时传入这两个函数。从存储中读回的字符串不会经过校验。读回的值会一直复用,直到存储的字符串发生变化,因此当 `deserializer` 的返回结果改变时,请调用 `refresh`。 + ## 接口 ```ts function useStorageState( key: string, options?: Object -): readonly [ - state: Serializable | undefined, - setState: (value: SetStateAction | undefined>) => void, - refreshState: () => void, -]; +): readonly [state: T | undefined, actions: StorageStateActions]; ``` ### 参数 @@ -45,15 +43,15 @@ function useStorageState( }, { name: 'options.serializer', - type: 'Function', + type: '(value: T) => string', required: false, - description: '将状态值序列化为字符串的函数。', + description: '将值转换为字符串的函数。需要与 deserializer 一起传入。', }, { name: 'options.deserializer', - type: 'Function', + type: '(value: string) => T', required: false, - description: '将状态值从字符串反序列化的函数。', + description: '将存储的字符串还原为值的函数。需要与 serializer 一起传入。', }, ]" /> @@ -62,26 +60,26 @@ function useStorageState( @@ -89,16 +87,16 @@ function useStorageState( ## 示例 ```tsx -// Counter with persistent state +// 具有持久化状态的计数器 import { useStorageState } from 'react-simplikit'; function Counter() { - const [count, setCount] = useStorageState('counter', { + const [count, { setValue }] = useStorageState('counter', { defaultValue: 0, }); return ( - + ); } ``` diff --git a/packages/react-simplikit/src/hooks/useThrottle/es/useThrottle.md b/packages/react-simplikit/src/hooks/useThrottle/es/useThrottle.md index caab8812..29972915 100644 --- a/packages/react-simplikit/src/hooks/useThrottle/es/useThrottle.md +++ b/packages/react-simplikit/src/hooks/useThrottle/es/useThrottle.md @@ -9,8 +9,8 @@ por ejemplo, al manejar eventos de desplazamiento o de cambio de tamaño. ```ts function useThrottle unknown>( callback: F, - wait: number, - options?: { edges?: Array<'leading' | 'trailing'> } + throttleMs: number, + options?: ThrottleOptions ): F & { cancel: () => void }; ``` @@ -25,23 +25,31 @@ function useThrottle unknown>( @@ -62,7 +70,7 @@ const throttledScroll = useThrottle( console.log('Evento de desplazamiento'); }, 200, - { edges: ['leading', 'trailing'] } + { leading: true, trailing: true } ); useEffect(() => { diff --git a/packages/react-simplikit/src/hooks/useThrottle/ja/useThrottle.md b/packages/react-simplikit/src/hooks/useThrottle/ja/useThrottle.md index b4f0fe33..439ca168 100644 --- a/packages/react-simplikit/src/hooks/useThrottle/ja/useThrottle.md +++ b/packages/react-simplikit/src/hooks/useThrottle/ja/useThrottle.md @@ -8,8 +8,8 @@ ```ts function useThrottle unknown>( callback: F, - wait: number, - options?: { edges?: Array<'leading' | 'trailing'> } + throttleMs: number, + options?: ThrottleOptions ): F & { cancel: () => void }; ``` @@ -24,23 +24,31 @@ function useThrottle unknown>( @@ -61,7 +69,7 @@ const throttledScroll = useThrottle( console.log('Scroll event'); }, 200, - { edges: ['leading', 'trailing'] } + { leading: true, trailing: true } ); useEffect(() => { diff --git a/packages/react-simplikit/src/hooks/useThrottle/ko/useThrottle.md b/packages/react-simplikit/src/hooks/useThrottle/ko/useThrottle.md index 0e85f5ac..280507af 100644 --- a/packages/react-simplikit/src/hooks/useThrottle/ko/useThrottle.md +++ b/packages/react-simplikit/src/hooks/useThrottle/ko/useThrottle.md @@ -7,8 +7,8 @@ ```ts function useThrottle unknown>( callback: F, - wait: number, - options?: { edges?: Array<'leading' | 'trailing'> } + throttleMs: number, + options?: ThrottleOptions ): F & { cancel: () => void }; ``` @@ -23,23 +23,31 @@ function useThrottle unknown>( @@ -60,7 +68,7 @@ const throttledScroll = useThrottle( console.log('Scroll event'); }, 200, - { edges: ['leading', 'trailing'] } + { leading: true, trailing: true } ); useEffect(() => { diff --git a/packages/react-simplikit/src/hooks/useThrottle/useThrottle.md b/packages/react-simplikit/src/hooks/useThrottle/useThrottle.md index 1995c67d..7e966063 100644 --- a/packages/react-simplikit/src/hooks/useThrottle/useThrottle.md +++ b/packages/react-simplikit/src/hooks/useThrottle/useThrottle.md @@ -9,8 +9,8 @@ such as when handling scroll or resize events. ```ts function useThrottle unknown>( callback: F, - wait: number, - options?: { edges?: Array<'leading' | 'trailing'> } + throttleMs: number, + options?: ThrottleOptions ): F & { cancel: () => void }; ``` @@ -25,23 +25,31 @@ function useThrottle unknown>( @@ -62,7 +70,7 @@ const throttledScroll = useThrottle( console.log('Scroll event'); }, 200, - { edges: ['leading', 'trailing'] } + { leading: true, trailing: true } ); useEffect(() => { diff --git a/packages/react-simplikit/src/hooks/useThrottle/useThrottle.spec.ts b/packages/react-simplikit/src/hooks/useThrottle/useThrottle.spec.ts index bd95ac11..b3a46c06 100644 --- a/packages/react-simplikit/src/hooks/useThrottle/useThrottle.spec.ts +++ b/packages/react-simplikit/src/hooks/useThrottle/useThrottle.spec.ts @@ -38,7 +38,7 @@ describe('useThrottle', () => { expect(callback).toHaveBeenCalledTimes(1); }); - it('should trigger a trailing call after wait time', async () => { + it('should trigger a trailing call after throttleMs time', async () => { vi.useFakeTimers(); const callback = vi.fn(); @@ -66,7 +66,7 @@ describe('useThrottle', () => { expect(callback).toHaveBeenCalledWith('test', 123); }); - it('should execute immediately if not called within wait time', async () => { + it('should execute immediately if not called within throttleMs time', async () => { vi.useFakeTimers(); const callback = vi.fn(); @@ -89,7 +89,7 @@ describe('useThrottle', () => { const callback = vi.fn(); const throttleMs = 50; - const { result } = await renderHookSSR(() => useThrottle(callback, throttleMs, { edges: ['leading', 'trailing'] })); + const { result } = await renderHookSSR(() => useThrottle(callback, throttleMs, { leading: true, trailing: true })); result.current(); @@ -114,4 +114,42 @@ describe('useThrottle', () => { 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/useThrottle/useThrottle.ts b/packages/react-simplikit/src/hooks/useThrottle/useThrottle.ts index c195f5d7..fed7e84f 100644 --- a/packages/react-simplikit/src/hooks/useThrottle/useThrottle.ts +++ b/packages/react-simplikit/src/hooks/useThrottle/useThrottle.ts @@ -2,10 +2,14 @@ import { useEffect, useMemo } from 'react'; import type { CallerTypedArgs } from '../../_internal/types.ts'; import { usePreservedCallback } from '../usePreservedCallback/index.ts'; -import { usePreservedReference } from '../usePreservedReference/index.ts'; import { throttle } from './throttle.ts'; +type ThrottleOptions = { + leading?: boolean; + trailing?: boolean; +}; + /** * @description * `useThrottle` is a React hook that creates a throttled version of a callback function. @@ -14,15 +18,16 @@ import { throttle } from './throttle.ts'; * * @template {(...args: any[]) => unknown} F - The type of the callback function. * @param {F} callback - The function to be throttled. - * @param {number} wait - The number of milliseconds to throttle invocations to. - * @param {{ edges?: Array<'leading' | 'trailing'> }} [options] - Options to control the behavior of the throttle. - * @param {Array<'leading' | 'trailing'>} [options.edges=['leading', 'trailing']] - An optional array specifying whether the function should be invoked on the leading edge, trailing edge, or both. + * @param {number} throttleMs - The number of milliseconds to throttle invocations to. + * @param {ThrottleOptions} [options] - Options to control the behavior of the throttle. + * @param {boolean} [options.leading=true] - If `true`, allows an immediate call at the start of a throttle window. + * @param {boolean} [options.trailing=true] - If `true`, allows a pending call to run after the delay with the latest arguments. * @returns {F & { cancel: () => void }} - Returns the throttled function with a `cancel` method to cancel pending executions. * * @example * const throttledScroll = useThrottle(() => { * console.log('Scroll event'); - * }, 200, { edges: ['leading', 'trailing'] }); + * }, 200, { leading: true, trailing: true }); * * useEffect(() => { * window.addEventListener('scroll', throttledScroll); @@ -34,16 +39,28 @@ import { throttle } from './throttle.ts'; */ export function useThrottle unknown>( callback: F, - wait: number, - options?: Parameters[2] + throttleMs: number, + { leading = true, trailing = true }: ThrottleOptions = {} // Written out because the inferred type names `CallerTypedArgs`, which consumers emitting declarations cannot import (TS2742) ): { (...args: Parameters): void; cancel: () => void } { const preservedCallback = usePreservedCallback(callback); - const preservedOptions = usePreservedReference(options ?? {}); + + 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, wait, preservedOptions), - [preservedOptions, preservedCallback, wait] + () => throttle(preservedCallback, throttleMs, { edges }), + [preservedCallback, throttleMs, edges] ); useEffect(() => { diff --git a/packages/react-simplikit/src/hooks/useThrottle/zh-Hans/useThrottle.md b/packages/react-simplikit/src/hooks/useThrottle/zh-Hans/useThrottle.md index bb17ea98..95eb6558 100644 --- a/packages/react-simplikit/src/hooks/useThrottle/zh-Hans/useThrottle.md +++ b/packages/react-simplikit/src/hooks/useThrottle/zh-Hans/useThrottle.md @@ -7,8 +7,8 @@ ```ts function useThrottle unknown>( callback: F, - wait: number, - options?: { edges?: Array<'leading' | 'trailing'> } + throttleMs: number, + options?: ThrottleOptions ): F & { cancel: () => void }; ``` @@ -23,23 +23,31 @@ function useThrottle unknown>( @@ -60,7 +68,7 @@ const throttledScroll = useThrottle( console.log('Scroll event'); }, 200, - { edges: ['leading', 'trailing'] } + { leading: true, trailing: true } ); useEffect(() => { diff --git a/packages/react-simplikit/src/hooks/useThrottledCallback/es/useThrottledCallback.md b/packages/react-simplikit/src/hooks/useThrottledCallback/es/useThrottledCallback.md index 654faea1..63dc9208 100644 --- a/packages/react-simplikit/src/hooks/useThrottledCallback/es/useThrottledCallback.md +++ b/packages/react-simplikit/src/hooks/useThrottledCallback/es/useThrottledCallback.md @@ -25,18 +25,26 @@ function useThrottledCallback(options: Object): (nextValue: T) => void; 'El callback cuya frecuencia de ejecución quieres limitar. El Hook omite las llamadas con el mismo valor que el último que transmitió.', }, { - name: 'options.timeThreshold', + name: 'options.throttleMs', type: 'number', required: true, description: 'El intervalo en milisegundos que limita la frecuencia de las llamadas.', }, { - name: 'options.edges', - type: 'Array<\'leading\' | \'trailing\'>', + name: 'options.leading', + type: 'boolean', required: false, - defaultValue: '[\'leading\', \'trailing\']', + defaultValue: 'true', description: - 'Un arreglo opcional que especifica si la función debe ejecutarse al inicio del intervalo, al final o en ambos momentos.', + 'Si es true, permite una llamada inmediata al inicio del intervalo de limitación de frecuencia.', + }, + { + name: 'options.trailing', + type: 'boolean', + required: false, + defaultValue: 'true', + description: + 'Si es true, permite ejecutar una llamada pendiente después de la espera con el valor más reciente.', }, ]" /> @@ -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/react-simplikit/src/hooks/useThrottledCallback/ja/useThrottledCallback.md b/packages/react-simplikit/src/hooks/useThrottledCallback/ja/useThrottledCallback.md index d2a7433b..a9965928 100644 --- a/packages/react-simplikit/src/hooks/useThrottledCallback/ja/useThrottledCallback.md +++ b/packages/react-simplikit/src/hooks/useThrottledCallback/ja/useThrottledCallback.md @@ -25,18 +25,26 @@ function useThrottledCallback(options: Object): (nextValue: T) => void; 'スロットリングするコールバック。最後に渡された値と同じ値での呼び出しはスキップされます。', }, { - name: 'options.timeThreshold', + name: 'options.throttleMs', type: 'number', required: true, description: '呼び出しを制限する間隔(ミリ秒単位)。', }, { - name: 'options.edges', - type: 'Array<\'leading\' | \'trailing\'>', + name: 'options.leading', + type: 'boolean', required: false, - defaultValue: '[\'leading\', \'trailing\']', + defaultValue: 'true', description: - '関数を区間の開始時、終了時、またはその両方で呼び出すかを指定する省略可能な配列。', + 'true の場合、スロットリング区間の開始時に即座に呼び出せます。', + }, + { + name: 'options.trailing', + type: 'boolean', + required: false, + defaultValue: 'true', + description: + 'true の場合、遅延時間の経過後に、待機中の呼び出しを最新の値で実行できます。', }, ]" /> @@ -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/react-simplikit/src/hooks/useThrottledCallback/ko/useThrottledCallback.md b/packages/react-simplikit/src/hooks/useThrottledCallback/ko/useThrottledCallback.md index c9c5c1ce..26916caf 100644 --- a/packages/react-simplikit/src/hooks/useThrottledCallback/ko/useThrottledCallback.md +++ b/packages/react-simplikit/src/hooks/useThrottledCallback/ko/useThrottledCallback.md @@ -24,18 +24,26 @@ function useThrottledCallback(options: Object): (nextValue: T) => void; '스로틀링할 콜백이에요. 마지막으로 전달된 값과 같은 값으로 호출하면 건너뛰어요.', }, { - name: 'options.timeThreshold', + name: 'options.throttleMs', type: 'number', required: true, description: '호출을 스로틀링할 밀리초(ms)이에요.', }, { - name: 'options.edges', - type: 'Array<\'leading\' | \'trailing\'>', + name: 'options.leading', + type: 'boolean', required: false, - defaultValue: '[\'leading\', \'trailing\']', + defaultValue: 'true', description: - '함수가 시작점, 끝점 또는 둘 다에서 호출될지 여부를 지정하는 선택적 배열이에요.', + 'true이면 스로틀 구간이 시작될 때 즉시 호출할 수 있어요.', + }, + { + name: 'options.trailing', + type: 'boolean', + required: false, + defaultValue: 'true', + description: + 'true이면 대기 중인 호출을 지연 시간이 지난 뒤 최신 값으로 실행할 수 있어요.', }, ]" /> @@ -58,7 +66,7 @@ function ScrollPosition() { const [scrollTop, setScrollTop] = useState(0); const setScrollTopThrottled = useThrottledCallback({ onChange: setScrollTop, - timeThreshold: 200, + throttleMs: 200, }); return ( diff --git a/packages/react-simplikit/src/hooks/useThrottledCallback/useThrottledCallback.md b/packages/react-simplikit/src/hooks/useThrottledCallback/useThrottledCallback.md index de3fefc4..a73a19d6 100644 --- a/packages/react-simplikit/src/hooks/useThrottledCallback/useThrottledCallback.md +++ b/packages/react-simplikit/src/hooks/useThrottledCallback/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/react-simplikit/src/hooks/useThrottledCallback/useThrottledCallback.spec.ts b/packages/react-simplikit/src/hooks/useThrottledCallback/useThrottledCallback.spec.ts index 509ea87e..2ea87f19 100644 --- a/packages/react-simplikit/src/hooks/useThrottledCallback/useThrottledCallback.spec.ts +++ b/packages/react-simplikit/src/hooks/useThrottledCallback/useThrottledCallback.spec.ts @@ -15,14 +15,14 @@ describe('useThrottledCallback', () => { it('is safe on server side rendering', () => { const onChange = vi.fn(); - renderHookSSR.serverOnly(() => useThrottledCallback({ onChange, timeThreshold: 100 })); + renderHookSSR.serverOnly(() => useThrottledCallback({ onChange, throttleMs: 100 })); expect(onChange).not.toHaveBeenCalled(); }); it('should throttle the callback with the specified time threshold', () => { const onChange = vi.fn(); - const { result } = renderHookSSR(() => useThrottledCallback({ onChange, timeThreshold: 100 })); + const { result } = renderHookSSR(() => useThrottledCallback({ onChange, throttleMs: 100 })); result.current(true); expect(onChange).toBeCalledTimes(1); @@ -38,7 +38,7 @@ describe('useThrottledCallback', () => { it('should call on leading edge by default', () => { const onChange = vi.fn(); - const { result } = renderHookSSR(() => useThrottledCallback({ onChange, timeThreshold: 100 })); + const { result } = renderHookSSR(() => useThrottledCallback({ onChange, throttleMs: 100 })); result.current(true); expect(onChange).toBeCalledTimes(1); @@ -47,7 +47,9 @@ describe('useThrottledCallback', () => { it('should handle trailing edge', () => { const onChange = vi.fn(); - const { result } = renderHookSSR(() => useThrottledCallback({ onChange, timeThreshold: 100, edges: ['trailing'] })); + const { result } = renderHookSSR(() => + useThrottledCallback({ onChange, throttleMs: 100, leading: false, trailing: true }) + ); result.current(true); expect(onChange).not.toBeCalled(); @@ -59,7 +61,7 @@ describe('useThrottledCallback', () => { it('should not trigger callback if value has not changed', () => { const onChange = vi.fn(); - const { result } = renderHookSSR(() => useThrottledCallback({ onChange, timeThreshold: 100 })); + const { result } = renderHookSSR(() => useThrottledCallback({ onChange, throttleMs: 100 })); result.current(true); vi.advanceTimersByTime(100); @@ -73,7 +75,7 @@ describe('useThrottledCallback', () => { it('should cleanup on unmount', async () => { const onChange = vi.fn(); const { result, unmount } = await renderHookSSR(() => - useThrottledCallback({ onChange, timeThreshold: 100, edges: ['trailing'] }) + useThrottledCallback({ onChange, throttleMs: 100, leading: false, trailing: true }) ); result.current(true); @@ -86,7 +88,7 @@ describe('useThrottledCallback', () => { it('should handle leading and trailing edges together', () => { const onChange = vi.fn(); const { result } = renderHookSSR(() => - useThrottledCallback({ onChange, timeThreshold: 100, edges: ['leading', 'trailing'] }) + useThrottledCallback({ onChange, throttleMs: 100, leading: true, trailing: true }) ); result.current(true); @@ -99,7 +101,7 @@ describe('useThrottledCallback', () => { it('forwards a number value to onChange', () => { const onChange = vi.fn<(value: number) => void>(); - const { result } = renderHookSSR(() => useThrottledCallback({ onChange, timeThreshold: 100 })); + const { result } = renderHookSSR(() => useThrottledCallback({ onChange, throttleMs: 100 })); result.current(120); @@ -109,7 +111,7 @@ describe('useThrottledCallback', () => { it('should invoke the callback when the first value is false', () => { const onChange = vi.fn(); - const { result } = renderHookSSR(() => useThrottledCallback({ onChange, timeThreshold: 100 })); + const { result } = renderHookSSR(() => useThrottledCallback({ onChange, throttleMs: 100 })); result.current(false); @@ -119,7 +121,7 @@ describe('useThrottledCallback', () => { it('should still skip a repeated false after the first one', () => { const onChange = vi.fn(); - const { result } = renderHookSSR(() => useThrottledCallback({ onChange, timeThreshold: 100 })); + const { result } = renderHookSSR(() => useThrottledCallback({ onChange, throttleMs: 100 })); result.current(false); vi.advanceTimersByTime(100); @@ -132,7 +134,9 @@ describe('useThrottledCallback', () => { it('discards a pending trailing value when the caller returns to the last forwarded one', () => { const onChange = vi.fn(); - const { result } = renderHookSSR(() => useThrottledCallback({ onChange, timeThreshold: 100, edges: ['trailing'] })); + const { result } = renderHookSSR(() => + useThrottledCallback({ onChange, throttleMs: 100, leading: false, trailing: true }) + ); result.current('seoul'); vi.advanceTimersByTime(100); @@ -148,7 +152,7 @@ describe('useThrottledCallback', () => { it('should handle value toggling', () => { const onChange = vi.fn(); - const { result } = renderHookSSR(() => useThrottledCallback({ onChange, timeThreshold: 100 })); + const { result } = renderHookSSR(() => useThrottledCallback({ onChange, throttleMs: 100 })); result.current(true); expect(onChange).toBeCalledTimes(1); @@ -163,7 +167,7 @@ describe('useThrottledCallback', () => { it('forwards a stream of distinct values at most once per interval', () => { const onChange = vi.fn(); - const { result } = renderHookSSR(() => useThrottledCallback({ onChange, timeThreshold: 100 })); + const { result } = renderHookSSR(() => useThrottledCallback({ onChange, throttleMs: 100 })); for (const value of ['a', 'b', 'c', 'd', 'e']) { result.current(value); @@ -177,7 +181,9 @@ describe('useThrottledCallback', () => { it('keeps forwarding once per interval while values keep changing with trailing edge only', () => { const onChange = vi.fn(); - const { result } = renderHookSSR(() => useThrottledCallback({ onChange, timeThreshold: 100, edges: ['trailing'] })); + const { result } = renderHookSSR(() => + useThrottledCallback({ onChange, throttleMs: 100, leading: false, trailing: true }) + ); for (let i = 1; i <= 10; i++) { result.current(i); @@ -191,7 +197,7 @@ describe('useThrottledCallback', () => { it('lets a value repeated after the window still occupy the next one', () => { const onChange = vi.fn(); - const { result } = renderHookSSR(() => useThrottledCallback({ onChange, timeThreshold: 100 })); + const { result } = renderHookSSR(() => useThrottledCallback({ onChange, throttleMs: 100 })); result.current(true); expect(onChange.mock.calls.map(call => call[0])).toEqual([true]); diff --git a/packages/react-simplikit/src/hooks/useThrottledCallback/useThrottledCallback.ts b/packages/react-simplikit/src/hooks/useThrottledCallback/useThrottledCallback.ts index 1184fb12..fdbe8893 100644 --- a/packages/react-simplikit/src/hooks/useThrottledCallback/useThrottledCallback.ts +++ b/packages/react-simplikit/src/hooks/useThrottledCallback/useThrottledCallback.ts @@ -1,9 +1,10 @@ import { useRef } from 'react'; -import { useThrottle } from '../useThrottle/index.ts'; +import { useThrottle } from '../_internal/useThrottle/useThrottle.ts'; type ThrottleOptions = { - edges?: Array<'leading' | 'trailing'>; + leading?: boolean; + trailing?: boolean; }; /** @@ -23,8 +24,9 @@ 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 throttle. A call with the same value as the last forwarded one is skipped. - * @param {number} options.timeThreshold - The number of milliseconds to throttle invocations to. - * @param {Array<'leading' | 'trailing'>} [options.edges=['leading', 'trailing']] - An optional array specifying whether the function should be invoked on the leading edge, trailing edge, or both. + * @param {number} options.throttleMs - The number of milliseconds to throttle invocations to. + * @param {boolean} [options.leading=true] - If `true`, allows an immediate call at the start of a throttle window. + * @param {boolean} [options.trailing=true] - If `true`, allows a pending call to run after the delay with the latest value. * * @returns {(nextValue: T) => void} A throttled function that forwards the value to `onChange` at most once per interval. * @@ -34,7 +36,7 @@ const NOT_INVOKED = Symbol('NOT_INVOKED'); * * function ScrollPosition() { * const [scrollTop, setScrollTop] = useState(0); - * const setScrollTopThrottled = useThrottledCallback({ onChange: setScrollTop, timeThreshold: 200 }); + * const setScrollTopThrottled = useThrottledCallback({ onChange: setScrollTop, throttleMs: 200 }); * * return ( *
setScrollTopThrottled(e.currentTarget.scrollTop)}> @@ -45,11 +47,12 @@ const NOT_INVOKED = Symbol('NOT_INVOKED'); */ export function useThrottledCallback({ onChange, - timeThreshold, - edges = ['leading', 'trailing'], + throttleMs, + leading = true, + trailing = true, }: ThrottleOptions & { onChange: (newValue: T) => void; - timeThreshold: number; + throttleMs: number; }): (nextValue: T) => void { const lastForwardedRef = useRef(NOT_INVOKED); @@ -63,7 +66,7 @@ export function useThrottledCallback({ lastForwardedRef.current = nextValue; }, - timeThreshold, - { edges } + throttleMs, + { leading, trailing } ); } diff --git a/packages/react-simplikit/src/hooks/useThrottledCallback/zh-Hans/useThrottledCallback.md b/packages/react-simplikit/src/hooks/useThrottledCallback/zh-Hans/useThrottledCallback.md index af3800b8..fb0b624c 100644 --- a/packages/react-simplikit/src/hooks/useThrottledCallback/zh-Hans/useThrottledCallback.md +++ b/packages/react-simplikit/src/hooks/useThrottledCallback/zh-Hans/useThrottledCallback.md @@ -24,18 +24,26 @@ function useThrottledCallback(options: Object): (nextValue: T) => void; '要被节流的回调。携带与上一次转发的值相同的调用会被跳过。', }, { - name: 'options.timeThreshold', + name: 'options.throttleMs', type: 'number', required: true, description: '将调用节流到的毫秒数。', }, { - name: 'options.edges', - type: 'Array<\'leading\' | \'trailing\'>', + name: 'options.leading', + type: 'boolean', required: false, - defaultValue: '[\'leading\', \'trailing\']', + defaultValue: 'true', description: - '一个可选的数组,用于指定应在领先边缘、落后边缘或两者处调用该函数。', + '如果为 true,则允许在节流时间窗口开始时立即调用。', + }, + { + name: 'options.trailing', + type: 'boolean', + required: false, + defaultValue: 'true', + description: + '如果为 true,则允许待执行的调用在延迟结束后以最新值运行。', }, ]" /> @@ -58,7 +66,7 @@ function ScrollPosition() { const [scrollTop, setScrollTop] = useState(0); const setScrollTopThrottled = useThrottledCallback({ onChange: setScrollTop, - timeThreshold: 200, + throttleMs: 200, }); return ( diff --git a/packages/react-simplikit/src/hooks/useThrottledState/es/useThrottledState.md b/packages/react-simplikit/src/hooks/useThrottledState/es/useThrottledState.md new file mode 100644 index 00000000..72194648 --- /dev/null +++ b/packages/react-simplikit/src/hooks/useThrottledState/es/useThrottledState.md @@ -0,0 +1,100 @@ +# useThrottledState + +`useThrottledState` es un Hook de React que gestiona el estado y devuelve un setter con limitación de frecuencia. +Con las opciones predeterminadas, la primera llamada al setter se aplica inmediatamente y las llamadas posteriores +se limitan en frecuencia según `throttleMs`, con el último valor pendiente aplicado en el extremo final (trailing). +Es útil para limitar las actualizaciones de estado provocadas por eventos frecuentes, como el desplazamiento. + +El valor inicial se devuelve tal cual en el primer renderizado y en el servidor. No se programa ninguna actualización +al montar el componente, por lo que, con `leading: true`, la primera llamada al setter después del montaje se aplica inmediatamente. +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 `throttleMs`, `leading` o `trailing`. + +## Interfaz + +```ts +function useThrottledState( + initialValue: T, + throttleMs: number, + options?: ThrottleOptions +): [state: T, setState: Dispatch>]; +``` + +### Parámetros + + + + + + + +### Valor de retorno + + + +## Ejemplo + +```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/react-simplikit/src/hooks/useThrottledState/index.ts b/packages/react-simplikit/src/hooks/useThrottledState/index.ts new file mode 100644 index 00000000..5fd4a4ef --- /dev/null +++ b/packages/react-simplikit/src/hooks/useThrottledState/index.ts @@ -0,0 +1 @@ +export { useThrottledState } from './useThrottledState.ts'; diff --git a/packages/react-simplikit/src/hooks/useThrottledState/ja/useThrottledState.md b/packages/react-simplikit/src/hooks/useThrottledState/ja/useThrottledState.md new file mode 100644 index 00000000..44c115d5 --- /dev/null +++ b/packages/react-simplikit/src/hooks/useThrottledState/ja/useThrottledState.md @@ -0,0 +1,101 @@ +# useThrottledState + +`useThrottledState` は、状態を管理し、スロットリングされた setter を返す React フックです。 +デフォルトのオプションでは、最初の setter 呼び出しをすぐに反映し、それ以降の呼び出しには +`throttleMs` に基づくスロットリングを適用します。保留中の最後の値は trailing のタイミングで反映します。 +スクロールなど、頻繁に発生するイベントによる状態の更新頻度を制限する場合に便利です。 + +初回レンダリング時とサーバー上では、初期値をそのまま返します。マウント時には更新を予約しないため、 +`leading: true` の場合、マウント後の最初の setter 呼び出しがすぐに反映されます。 +`leading` と `trailing` が両方とも `false` の場合、状態は更新されません。 + +コンポーネントがアンマウントされるか、`throttleMs`、`leading`、`trailing` が変更されると、 +保留中の更新はキャンセルされます。 + +## インターフェース + +```ts +function useThrottledState( + initialValue: T, + throttleMs: number, + options?: ThrottleOptions +): [state: T, setState: Dispatch>]; +``` + +### パラメータ + + + + + + + +### 戻り値 + + + +## 使用例 + +```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/react-simplikit/src/hooks/useThrottledState/ko/useThrottledState.md b/packages/react-simplikit/src/hooks/useThrottledState/ko/useThrottledState.md new file mode 100644 index 00000000..ff7a23ff --- /dev/null +++ b/packages/react-simplikit/src/hooks/useThrottledState/ko/useThrottledState.md @@ -0,0 +1,100 @@ +# useThrottledState + +`useThrottledState`는 상태를 직접 관리하고, 스로틀이 적용된 setter를 반환하는 리액트 훅이에요. +기본 설정에서는 첫 setter 호출을 즉시 반영하고, 이후 호출에는 `throttleMs`를 기준으로 스로틀을 적용하며, +대기 중인 마지막 값은 trailing 시점에 반영해요. +스크롤처럼 자주 발생하는 이벤트의 상태 갱신 빈도를 제한할 때 유용해요. + +첫 렌더와 서버 렌더에서는 초기값을 그대로 반환해요. 마운트 시점에는 갱신을 예약하지 않으므로, +`leading: true`이면 마운트 뒤 첫 setter 호출이 즉시 반영돼요. +`leading`과 `trailing`이 모두 `false`이면 상태가 갱신되지 않아요. + +컴포넌트가 언마운트되거나 `throttleMs`, `leading`, `trailing`이 변경되면 대기 중인 갱신을 취소해요. + +## 인터페이스 + +```ts +function useThrottledState( + initialValue: T, + throttleMs: number, + options?: ThrottleOptions +): [state: T, setState: Dispatch>]; +``` + +### 파라미터 + + + + + + + +### 반환 값 + + + +## 예시 + +```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/react-simplikit/src/hooks/useThrottledState/useThrottledState.md b/packages/react-simplikit/src/hooks/useThrottledState/useThrottledState.md new file mode 100644 index 00000000..4efffeb1 --- /dev/null +++ b/packages/react-simplikit/src/hooks/useThrottledState/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/react-simplikit/src/hooks/useThrottledState/useThrottledState.spec.ts b/packages/react-simplikit/src/hooks/useThrottledState/useThrottledState.spec.ts new file mode 100644 index 00000000..cc4067fa --- /dev/null +++ b/packages/react-simplikit/src/hooks/useThrottledState/useThrottledState.spec.ts @@ -0,0 +1,278 @@ +import { Dispatch, SetStateAction } from 'react'; +import { act, cleanup } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { renderHookSSR } from '../../_internal/test-utils/renderHookSSR.tsx'; + +import { useThrottledState } from './useThrottledState.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('useThrottledState', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + cleanup(); + vi.useRealTimers(); + }); + + it('is safe on server side rendering', () => { + const result = renderHookSSR.serverOnly(() => useThrottledState('a', 100)); + + expect(result.current[0]).toBe('a'); + }); + + it('should return the initial value', () => { + const { result } = renderHookSSR(() => useThrottledState('a', 100)); + const { getValue } = actions(result); + expect(getValue()).toBe('a'); + }); + + it('should skip the leading edge when leading is false', () => { + const { result } = renderHookSSR(() => useThrottledState('a', 100, { leading: false })); + 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 throttle continuous state updates', () => { + const { result } = renderHookSSR(() => useThrottledState('a', 100)); + const { getValue, setValue } = actions(result); + + setValue('b'); + expect(getValue()).toBe('b'); + + act(() => vi.advanceTimersByTime(40)); + setValue('c'); + expect(getValue()).toBe('b'); + + act(() => vi.advanceTimersByTime(40)); + setValue('d'); + expect(getValue()).toBe('b'); + + act(() => vi.advanceTimersByTime(40)); + setValue('e'); + expect(getValue()).toBe('e'); + }); + + it('should update on leading and trailing edges by default throttle options', () => { + const { result } = renderHookSSR(() => useThrottledState('a', 100)); + 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 skip the trailing edge when trailing is false', () => { + const { result } = renderHookSSR(() => useThrottledState('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(() => useThrottledState('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 = useThrottledState('a', 100, { leading: false }); + 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(() => useThrottledState('a', 100)); + const { getValue, setValue } = actions(result); + + setValue(prev => prev + 'b'); + expect(getValue()).toBe('ab'); + + act(() => vi.advanceTimersByTime(50)); + setValue('c'); + expect(getValue()).toBe('ab'); + + act(() => vi.advanceTimersByTime(100)); + expect(getValue()).toBe('c'); + }); + + it('should apply only the last updater function during continuous updates when leading is false', () => { + const { result } = renderHookSSR(() => useThrottledState('z', 100, { leading: false })); + const { getValue, setValue } = actions(result); + + setValue(prev => prev + 'b'); + act(() => vi.advanceTimersByTime(99)); + expect(getValue()).toBe('z'); + + 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(() => useThrottledState(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(() => useThrottledState(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(() => useThrottledState(initialFn, 100)); + + const { getValue, setValue } = actions(result); + + setValue(() => nextFn); + + act(() => { + vi.advanceTimersByTime(100); + }); + + expect(getValue()).toBe(nextFn); + expect(nextFn).not.toHaveBeenCalled(); + }); + + it('should reset throttling when throttleMs changes', () => { + const { result, rerender } = renderHookSSR( + ({ throttleMs }) => useThrottledState('a', throttleMs, { leading: false }), + { + initialProps: { throttleMs: 100 }, + } + ); + const { getValue, setValue } = actions(result); + + setValue('b'); + rerender({ throttleMs: 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 throttling when throttle options changes', () => { + const { result, rerender } = renderHookSSR(({ leading }) => useThrottledState('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(() => useThrottledState('a', 100, { leading: false })); + 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/useThrottledState/useThrottledState.ts b/packages/react-simplikit/src/hooks/useThrottledState/useThrottledState.ts new file mode 100644 index 00000000..006e3457 --- /dev/null +++ b/packages/react-simplikit/src/hooks/useThrottledState/useThrottledState.ts @@ -0,0 +1,81 @@ +import { Dispatch, SetStateAction, useRef, useState } from 'react'; + +import { useThrottle } from '../_internal/useThrottle/useThrottle.ts'; + +type ThrottleOptions = { + leading?: boolean; + trailing?: boolean; +}; + +const UPDATER_FORWARDED = Symbol('UPDATER_FORWARDED'); + +/** + * @description + * `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. + * + * @template T - The type of the state value. + * @param {T} initialValue - The initial state value, used only on the first render. + * @param {number} throttleMs - The length of the throttle window in milliseconds. + * @param {ThrottleOptions} [options] - Configuration options for throttle behavior. + * @param {boolean} [options.leading=true] - If `true`, allows an immediate update at the start of a throttle window. + * @param {boolean} [options.trailing=true] - If `true`, allows a pending update to run after the delay with the latest value. + * + * @returns {[state: T, setState: Dispatch>]} A tuple: + * - state `T` - The current state value; + * - setState `Dispatch>` - A throttled setter that accepts the next state value or an updater function; + * + * @example + * import { useThrottledState } from 'react-simplikit'; + * + * function ScrollPosition() { + * const [scrollTop, setScrollTop] = useThrottledState(0, 200); + * + * return ( + *
setScrollTop(e.currentTarget.scrollTop)}> + *

Scrolled {scrollTop}px

+ *
+ * ); + * } + */ +export function useThrottledState( + initialValue: T, + throttleMs: number, + { leading = true, trailing = true }: ThrottleOptions = {} +): [T, Dispatch>] { + const [throttledValue, setThrottledValue] = useState(() => initialValue); + const lastForwardedRef = useRef(initialValue); + + function forwardChangedValue(value: SetStateAction) { + if (typeof value === 'function') { + lastForwardedRef.current = UPDATER_FORWARDED; + setThrottledValue(value); + return; + } + + const unchanged = Object.is(lastForwardedRef.current, value); + const settled = Object.is(value, throttledValue); + if (unchanged && settled) { + return; + } + + lastForwardedRef.current = value; + setThrottledValue(() => value); + } + + const throttled = useThrottle((next: SetStateAction) => forwardChangedValue(next), throttleMs, { + leading, + trailing, + }); + + return [throttledValue, throttled]; +} diff --git a/packages/react-simplikit/src/hooks/useThrottledState/zh-Hans/useThrottledState.md b/packages/react-simplikit/src/hooks/useThrottledState/zh-Hans/useThrottledState.md new file mode 100644 index 00000000..0a324143 --- /dev/null +++ b/packages/react-simplikit/src/hooks/useThrottledState/zh-Hans/useThrottledState.md @@ -0,0 +1,100 @@ +# useThrottledState + +`useThrottledState` 是一个管理状态并返回节流 setter 的 React Hook。 +使用默认选项时,首次调用 setter 会立即生效,后续调用会根据 `throttleMs` 进行节流, +并在 trailing 时机应用最后一个待更新的值。 +它适用于限制滚动等高频事件引起的状态更新频率。 + +首次渲染时和服务端渲染时都会原样返回初始值。挂载时不会安排更新, +因此,当 `leading: true` 时,挂载后第一次调用 setter 会立即生效。 +如果 `leading` 和 `trailing` 都为 `false`,状态不会更新。 + +组件卸载或 `throttleMs`、`leading`、`trailing` 发生变化时,会取消待执行的更新。 + +## 接口 + +```ts +function useThrottledState( + initialValue: T, + throttleMs: number, + options?: ThrottleOptions +): [state: T, setState: Dispatch>]; +``` + +### 参数 + + + + + + + +### 返回值 + + + +## 示例 + +```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/react-simplikit/src/hooks/useThrottledValue/es/useThrottledValue.md b/packages/react-simplikit/src/hooks/useThrottledValue/es/useThrottledValue.md index 09bfd9e2..f2ba480b 100644 --- a/packages/react-simplikit/src/hooks/useThrottledValue/es/useThrottledValue.md +++ b/packages/react-simplikit/src/hooks/useThrottledValue/es/useThrottledValue.md @@ -1,7 +1,7 @@ # useThrottledValue `useThrottledValue` es un Hook de React que devuelve una copia del valor dado con una frecuencia de actualización limitada. -Tú mantienes el control del estado; el valor devuelto refleja sus cambios como máximo una vez cada `wait` milisegundos. +Tú mantienes el control del estado; el valor devuelto refleja sus cambios como máximo una vez cada `throttleMs` milisegundos. Esto te permite limitar los renderizados costosos que dependen de la posición de desplazamiento, la posición del puntero o el tamaño de un elemento cuando cambia. @@ -10,7 +10,7 @@ al montar el componente, por lo que aplica inmediatamente el primer cambio poste Si tanto `leading` como `trailing` son `false`, el valor devuelto nunca se actualiza. El Hook compara el valor por referencia. Si pasas un objeto o arreglo nuevo en cada renderizado, -el valor devuelto sigue actualizándose cada `wait` milisegundos; primero estabiliza la referencia, +el valor devuelto sigue actualizándose cada `throttleMs` milisegundos; primero estabiliza la referencia, por ejemplo, con `usePreservedReference`. ## Interfaz @@ -18,7 +18,7 @@ por ejemplo, con `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: - 'Si es true, el Hook aplica el último cambio de un intervalo wait milisegundos después de ese cambio.', + 'Si es true, el Hook aplica el último cambio de un intervalo throttleMs milisegundos después de ese cambio.', }, ]" /> diff --git a/packages/react-simplikit/src/hooks/useThrottledValue/ja/useThrottledValue.md b/packages/react-simplikit/src/hooks/useThrottledValue/ja/useThrottledValue.md index 38292c0e..79ec72a8 100644 --- a/packages/react-simplikit/src/hooks/useThrottledValue/ja/useThrottledValue.md +++ b/packages/react-simplikit/src/hooks/useThrottledValue/ja/useThrottledValue.md @@ -1,7 +1,7 @@ # useThrottledValue `useThrottledValue` は、渡された値をスロットリングして返す React フックです。 -状態は引き続き呼び出し元が管理し、戻り値は `wait` ミリ秒につき最大 1 回、その状態に追従します。 +状態は引き続き呼び出し元が管理し、戻り値は `throttleMs` ミリ秒につき最大 1 回、その状態に追従します。 スクロール位置、ポインターの位置、リサイズ時の要素サイズに応じて、負荷の高いレンダリングを行う場合に便利です。 初回レンダリング時とサーバー上では、値をそのまま返します。マウント時には変更を予約しないため、 @@ -9,14 +9,14 @@ `leading` と `trailing` が両方とも `false` の場合、戻り値は更新されません。 値は参照で比較します。レンダリングのたびに新しいオブジェクトや配列を渡すと、 -戻り値が `wait` ミリ秒ごとに更新され続けます。まず `usePreservedReference` などを使って参照を安定させてください。 +戻り値が `throttleMs` ミリ秒ごとに更新され続けます。まず `usePreservedReference` などを使って参照を安定させてください。 ## インターフェース ```ts function useThrottledValue( value: T, - wait: number, + throttleMs: number, options?: ThrottleOptions ): T; ``` @@ -32,7 +32,7 @@ function useThrottledValue( @@ -56,7 +56,7 @@ function useThrottledValue( required: false, defaultValue: 'true', description: - 'true の場合、区間内の最後の変更を、その変更から wait ミリ秒後に反映します。', + 'true の場合、区間内の最後の変更を、その変更から throttleMs ミリ秒後に反映します。', }, ]" /> diff --git a/packages/react-simplikit/src/hooks/useThrottledValue/ko/useThrottledValue.md b/packages/react-simplikit/src/hooks/useThrottledValue/ko/useThrottledValue.md index cc0b200a..79c08144 100644 --- a/packages/react-simplikit/src/hooks/useThrottledValue/ko/useThrottledValue.md +++ b/packages/react-simplikit/src/hooks/useThrottledValue/ko/useThrottledValue.md @@ -1,17 +1,17 @@ # useThrottledValue -`useThrottledValue`는 전달받은 값의 스로틀링된 사본을 반환하는 리액트 훅이에요. 상태는 호출자가 그대로 소유하고, 반환값은 `wait` 밀리초마다 최대 한 번만 그 상태를 따라가요. 스크롤 위치, 포인터 위치, 리사이즈 시 엘리먼트 크기처럼 자주 바뀌는 값으로 무거운 렌더링을 구동할 때 유용해요. +`useThrottledValue`는 전달받은 값의 스로틀링된 사본을 반환하는 리액트 훅이에요. 상태는 호출자가 그대로 소유하고, 반환값은 `throttleMs` 밀리초마다 최대 한 번만 그 상태를 따라가요. 스크롤 위치, 포인터 위치, 리사이즈 시 엘리먼트 크기처럼 자주 바뀌는 값으로 무거운 렌더링을 구동할 때 유용해요. 첫 렌더와 서버 렌더에서는 값을 그대로 돌려줘요. 마운트 시점에는 갱신을 예약하지 않으므로 `leading`이 `true`이면 마운트 뒤 첫 변경이 즉시 반영돼요. `leading`과 `trailing`이 모두 `false`이면 반환값은 갱신되지 않아요. -값은 참조로 비교해요. 렌더마다 새 객체나 배열을 전달하면 반환값이 `wait` 밀리초마다 계속 갱신되니, `usePreservedReference` 같은 방법으로 참조를 먼저 안정화하세요. +값은 참조로 비교해요. 렌더마다 새 객체나 배열을 전달하면 반환값이 `throttleMs` 밀리초마다 계속 갱신되니, `usePreservedReference` 같은 방법으로 참조를 먼저 안정화하세요. ## 인터페이스 ```ts function useThrottledValue( value: T, - wait: number, + throttleMs: number, options?: ThrottleOptions ): T; ``` @@ -27,7 +27,7 @@ function useThrottledValue( @@ -51,7 +51,7 @@ function useThrottledValue( required: false, defaultValue: 'true', description: - 'true이면 창 안의 마지막 변경을 그 변경 뒤 wait 밀리초가 지나서 반영해요.', + 'true이면 창 안의 마지막 변경을 그 변경 뒤 throttleMs 밀리초가 지나서 반영해요.', }, ]" /> diff --git a/packages/react-simplikit/src/hooks/useThrottledValue/useThrottledValue.md b/packages/react-simplikit/src/hooks/useThrottledValue/useThrottledValue.md index 87d11e99..0680e8e2 100644 --- a/packages/react-simplikit/src/hooks/useThrottledValue/useThrottledValue.md +++ b/packages/react-simplikit/src/hooks/useThrottledValue/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/react-simplikit/src/hooks/useThrottledValue/useThrottledValue.ts b/packages/react-simplikit/src/hooks/useThrottledValue/useThrottledValue.ts index 4800f34b..621df53b 100644 --- a/packages/react-simplikit/src/hooks/useThrottledValue/useThrottledValue.ts +++ b/packages/react-simplikit/src/hooks/useThrottledValue/useThrottledValue.ts @@ -1,18 +1,16 @@ import { useEffect, useRef, useState } from 'react'; -import { useThrottle } from '../useThrottle/index.ts'; +import { useThrottle } from '../_internal/useThrottle/useThrottle.ts'; type ThrottleOptions = { leading?: boolean; trailing?: boolean; }; -type Edge = 'leading' | 'trailing'; - /** * @description * `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. * @@ -21,15 +19,15 @@ type Edge = 'leading' | 'trailing'; * 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`. * * @template T - The type of the value. * @param {T} value - The value to throttle. - * @param {number} wait - The length of the throttle window in milliseconds. + * @param {number} throttleMs - The length of the throttle window in milliseconds. * @param {ThrottleOptions} [options] - Configuration options for throttle behavior. * @param {boolean} [options.leading=true] - If `true`, the first change in a window is applied immediately. - * @param {boolean} [options.trailing=true] - If `true`, the last change in a window is applied `wait` milliseconds after that change. + * @param {boolean} [options.trailing=true] - If `true`, the last change in a window is applied `throttleMs` milliseconds after that change. * * @returns {T} The throttled value. * @@ -50,12 +48,13 @@ type Edge = 'leading' | 'trailing'; */ export function useThrottledValue( value: T, - wait: number, + throttleMs: number, { leading = true, trailing = true }: ThrottleOptions = {} ): T { const [throttledValue, setThrottledValue] = useState(() => value); - const throttled = useThrottle((next: T) => setThrottledValue(() => next), wait, { - edges: toEdges(leading, trailing), + const throttled = useThrottle((next: T) => setThrottledValue(() => next), throttleMs, { + leading, + trailing, }); const lastForwardedRef = useRef(value); @@ -75,16 +74,3 @@ export function useThrottledValue( return throttledValue; } - -// Adapts this hook's `leading` / `trailing` booleans to the `edges` array `useThrottle` still takes. -// Delete once `useThrottle` accepts the booleans, and pass the options straight through. -function toEdges(leading: boolean, trailing: boolean): Edge[] { - const edges: Edge[] = []; - if (leading) { - edges.push('leading'); - } - if (trailing) { - edges.push('trailing'); - } - return edges; -} diff --git a/packages/react-simplikit/src/hooks/useThrottledValue/zh-Hans/useThrottledValue.md b/packages/react-simplikit/src/hooks/useThrottledValue/zh-Hans/useThrottledValue.md index b0256e1a..77cb4a82 100644 --- a/packages/react-simplikit/src/hooks/useThrottledValue/zh-Hans/useThrottledValue.md +++ b/packages/react-simplikit/src/hooks/useThrottledValue/zh-Hans/useThrottledValue.md @@ -1,17 +1,17 @@ # useThrottledValue -`useThrottledValue` 是一个返回所给值节流副本的 React Hook。调用方始终拥有该状态;返回值每 `wait` 毫秒至多跟随一次,这适用于用滚动位置、指针位置或元素在调整大小时的尺寸来驱动昂贵的渲染。 +`useThrottledValue` 是一个返回所给值节流副本的 React Hook。调用方始终拥有该状态;返回值每 `throttleMs` 毫秒至多跟随一次,这适用于用滚动位置、指针位置或元素在调整大小时的尺寸来驱动昂贵的渲染。 在首次渲染时和服务端,该值会原样返回。挂载时永远不会安排变化,因此当 `leading` 为 `true` 时,挂载后的第一次变化会被立即应用。如果 `leading` 和 `trailing` 都为 `false`,返回值永远不会更新。 -该值通过引用进行比较。如果每次渲染都传入新的对象或数组,返回值会每 `wait` 毫秒更新一次;请先稳定引用,例如使用 `usePreservedReference`。 +该值通过引用进行比较。如果每次渲染都传入新的对象或数组,返回值会每 `throttleMs` 毫秒更新一次;请先稳定引用,例如使用 `usePreservedReference`。 ## 接口 ```ts function useThrottledValue( value: T, - wait: number, + throttleMs: number, options?: ThrottleOptions ): T; ``` @@ -27,7 +27,7 @@ function useThrottledValue( @@ -51,7 +51,7 @@ function useThrottledValue( required: false, defaultValue: 'true', description: - '如果为 true,窗口内的最后一次变化会在变化后 wait 毫秒应用。', + '如果为 true,窗口内的最后一次变化会在变化后 throttleMs 毫秒应用。', }, ]" /> diff --git a/packages/react-simplikit/src/hooks/useTimeout/es/useTimeout.md b/packages/react-simplikit/src/hooks/useTimeout/es/useTimeout.md index 3916d8c8..51da9a7f 100644 --- a/packages/react-simplikit/src/hooks/useTimeout/es/useTimeout.md +++ b/packages/react-simplikit/src/hooks/useTimeout/es/useTimeout.md @@ -6,22 +6,31 @@ Gestiona `setTimeout` de acuerdo con el ciclo de vida de React y garantiza la li ## Interfaz ```ts -function useTimeout(callback: () => void, delay: number = 0): void; +function useTimeout(options: Object): void; ``` ### Parámetros - - ### Valor de retorno @@ -38,13 +47,15 @@ import { useState } from 'react'; function Example() { const [title, setTitle] = useState(''); - useTimeout(() => { - setTitle('Buscando productos...'); - }, 2000); + useTimeout({ + onTimeout: () => setTitle('Buscando productos...'), + delayMs: 2000, + }); - useTimeout(() => { - setTitle('Casi listo...'); - }, 4000); + useTimeout({ + onTimeout: () => setTitle('Casi listo...'), + delayMs: 4000, + }); return
{title}
; } diff --git a/packages/react-simplikit/src/hooks/useTimeout/ja/useTimeout.md b/packages/react-simplikit/src/hooks/useTimeout/ja/useTimeout.md index 8b065c34..2fdfe65c 100644 --- a/packages/react-simplikit/src/hooks/useTimeout/ja/useTimeout.md +++ b/packages/react-simplikit/src/hooks/useTimeout/ja/useTimeout.md @@ -6,22 +6,31 @@ React のライフサイクルに合わせて `setTimeout` を管理し、アン ## インターフェース ```ts -function useTimeout(callback: () => void, delay: number = 0): void; +function useTimeout(options: Object): void; ``` ### パラメータ - - ### 戻り値 @@ -38,13 +47,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/hooks/useTimeout/ko/useTimeout.md b/packages/react-simplikit/src/hooks/useTimeout/ko/useTimeout.md index 89c660e2..38db96f2 100644 --- a/packages/react-simplikit/src/hooks/useTimeout/ko/useTimeout.md +++ b/packages/react-simplikit/src/hooks/useTimeout/ko/useTimeout.md @@ -5,22 +5,31 @@ ## 인터페이스 ```ts -function useTimeout(callback: () => void, delay: number = 0): void; +function useTimeout(options: Object): void; ``` ### 파라미터 - - ### 반환 값 @@ -37,13 +46,15 @@ import { useState } from 'react'; function Example() { const [title, setTitle] = useState(''); - useTimeout(() => { - setTitle('제품을 검색 중이에요...'); - }, 2000); + useTimeout({ + onTimeout: () => setTitle('제품을 검색 중이에요...'), + delayMs: 2000, + }); - useTimeout(() => { - setTitle('거의 완료됐어요...'); - }, 4000); + useTimeout({ + onTimeout: () => setTitle('거의 완료됐어요...'), + delayMs: 4000, + }); return
{title}
; } diff --git a/packages/react-simplikit/src/hooks/useTimeout/useTimeout.md b/packages/react-simplikit/src/hooks/useTimeout/useTimeout.md index c2cd451d..a0f79491 100644 --- a/packages/react-simplikit/src/hooks/useTimeout/useTimeout.md +++ b/packages/react-simplikit/src/hooks/useTimeout/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/hooks/useTimeout/useTimeout.spec.ts b/packages/react-simplikit/src/hooks/useTimeout/useTimeout.spec.ts index e51a486e..f7f7a362 100644 --- a/packages/react-simplikit/src/hooks/useTimeout/useTimeout.spec.ts +++ b/packages/react-simplikit/src/hooks/useTimeout/useTimeout.spec.ts @@ -15,14 +15,14 @@ describe('useTimeout', () => { it('is safe on server side rendering', () => { const callback = vi.fn(); - renderHookSSR.serverOnly(() => useTimeout(callback, 1000)); + renderHookSSR.serverOnly(() => useTimeout({ onTimeout: callback, delayMs: 1000 })); expect(callback).not.toHaveBeenCalled(); }); - it('should call callback after specified delay', async () => { + it('should call callback after specified delayMs', async () => { const callback = vi.fn(); - await renderHookSSR(() => useTimeout(callback, 1000)); + await renderHookSSR(() => useTimeout({ onTimeout: callback, delayMs: 1000 })); expect(callback).not.toHaveBeenCalled(); @@ -30,9 +30,9 @@ describe('useTimeout', () => { expect(callback).toHaveBeenCalledTimes(1); }); - it('should use 0ms as default delay', async () => { + it('should use 0ms as default delayMs', async () => { const callback = vi.fn(); - await renderHookSSR(() => useTimeout(callback)); + await renderHookSSR(() => useTimeout({ onTimeout: callback })); expect(callback).not.toHaveBeenCalled(); @@ -42,7 +42,7 @@ describe('useTimeout', () => { it('should clear timeout on unmount', async () => { const callback = vi.fn(); - const { unmount } = await renderHookSSR(() => useTimeout(callback, 1000)); + const { unmount } = await renderHookSSR(() => useTimeout({ onTimeout: callback, delayMs: 1000 })); unmount(); vi.advanceTimersByTime(1000); @@ -52,7 +52,7 @@ describe('useTimeout', () => { it('should keep the timeout when rerendering happens', async () => { const callback = vi.fn(); - const { rerender } = await renderHookSSR(() => useTimeout(callback, 3000)); + const { rerender } = await renderHookSSR(() => useTimeout({ onTimeout: callback, delayMs: 3000 })); vi.advanceTimersByTime(1500); rerender(); @@ -62,9 +62,9 @@ describe('useTimeout', () => { expect(callback).toHaveBeenCalledTimes(1); }); - it('should reset timeout when delay changes', async () => { + it('should reset timeout when delayMs changes', async () => { const callback = vi.fn(); - const { rerender } = await renderHookSSR(({ delay }) => useTimeout(callback, delay), { + const { rerender } = await renderHookSSR(({ delay }) => useTimeout({ onTimeout: callback, delayMs: delay }), { initialProps: { delay: 1000 }, }); @@ -84,8 +84,8 @@ describe('useTimeout', () => { const callback2 = vi.fn(); await renderHookSSR(() => { - useTimeout(callback1, 1000); - useTimeout(callback2, 2000); + useTimeout({ onTimeout: callback1, delayMs: 1000 }); + useTimeout({ onTimeout: callback2, delayMs: 2000 }); }); vi.advanceTimersByTime(1000); @@ -96,18 +96,18 @@ describe('useTimeout', () => { expect(callback2).toHaveBeenCalledTimes(1); }); - it('should treat negative delay as 0ms', async () => { + it('should treat negative delayMs as 0ms', async () => { const callback = vi.fn(); - await renderHookSSR(() => useTimeout(callback, -1000)); + await renderHookSSR(() => useTimeout({ onTimeout: callback, delayMs: -1000 })); expect(callback).not.toHaveBeenCalled(); vi.advanceTimersByTime(0); expect(callback).toHaveBeenCalledTimes(1); }); - it('should treat undefined delay as 0ms', async () => { + it('should treat undefined delayMs as 0ms', async () => { const callback = vi.fn(); - await renderHookSSR(() => useTimeout(callback, undefined)); + await renderHookSSR(() => useTimeout({ onTimeout: callback, delayMs: undefined })); vi.advanceTimersByTime(0); expect(callback).toHaveBeenCalledTimes(1); diff --git a/packages/react-simplikit/src/hooks/useTimeout/useTimeout.ts b/packages/react-simplikit/src/hooks/useTimeout/useTimeout.ts index ee06d93a..90b7d944 100644 --- a/packages/react-simplikit/src/hooks/useTimeout/useTimeout.ts +++ b/packages/react-simplikit/src/hooks/useTimeout/useTimeout.ts @@ -2,13 +2,19 @@ import { useEffect } from 'react'; import { usePreservedCallback } from '../usePreservedCallback/index.ts'; +type UseTimeoutOptions = { + onTimeout: () => void; + delayMs?: number; +}; + /** * @description * `useTimeout` is a React hook that executes a callback function after a specified delay. * It manages `setTimeout` in accordance with the React lifecycle, ensuring cleanup on unmount or when dependencies change. * - * @param {() => void} callback - The function to be executed after the delay. - * @param {number} [delay=0] - The time in milliseconds to wait before executing the callback. + * @param {Object} options - Configures the timeout behavior. + * @param {() => void} options.onTimeout - The function to be executed after the delay. + * @param {number} [options.delayMs=0] - The time in milliseconds to wait before executing `onTimeout`. * * @example * // Updating a title after a delay @@ -18,23 +24,27 @@ import { usePreservedCallback } from '../usePreservedCallback/index.ts'; * 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}
; * } */ +export function useTimeout({ onTimeout, delayMs = 0 }: UseTimeoutOptions) { + const preservedCallback = usePreservedCallback(onTimeout); -export function useTimeout(callback: () => void, delay = 0) { - const preservedCallback = usePreservedCallback(callback); - - useEffect(() => { - const timeoutId = setTimeout(preservedCallback, delay); - return () => clearTimeout(timeoutId); - }, [delay, preservedCallback]); + useEffect( + function startTimeout() { + const timeoutId = setTimeout(preservedCallback, delayMs); + return () => clearTimeout(timeoutId); + }, + [delayMs, preservedCallback] + ); } diff --git a/packages/react-simplikit/src/hooks/useTimeout/zh-Hans/useTimeout.md b/packages/react-simplikit/src/hooks/useTimeout/zh-Hans/useTimeout.md index acd29093..b2d12741 100644 --- a/packages/react-simplikit/src/hooks/useTimeout/zh-Hans/useTimeout.md +++ b/packages/react-simplikit/src/hooks/useTimeout/zh-Hans/useTimeout.md @@ -5,22 +5,31 @@ ## 接口 ```ts -function useTimeout(callback: () => void, delay: number = 0): void; +function useTimeout(options: Object): void; ``` ### 参数 - - ### 返回值 @@ -30,20 +39,22 @@ function useTimeout(callback: () => void, delay: number = 0): void; ## 示例 ```tsx -// Updating a title after a delay +// 延迟后更新标题 import { useTimeout } from 'react-simplikit'; 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/hooks/useToggle/useToggle.ts b/packages/react-simplikit/src/hooks/useToggle/useToggle.ts index a83de479..b117c87a 100644 --- a/packages/react-simplikit/src/hooks/useToggle/useToggle.ts +++ b/packages/react-simplikit/src/hooks/useToggle/useToggle.ts @@ -25,7 +25,7 @@ import { useReducer } from 'react'; * ); * } */ -export function useToggle(initialValue: boolean = false) { +export function useToggle(initialValue: boolean = false): [boolean, () => void] { return useReducer(toggle, initialValue); } diff --git a/packages/react-simplikit/src/hooks/useVisibilityEvent/index.ts b/packages/react-simplikit/src/hooks/useVisibilityEvent/index.ts deleted file mode 100644 index 3120d82d..00000000 --- a/packages/react-simplikit/src/hooks/useVisibilityEvent/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { useVisibilityEvent } from './useVisibilityEvent.ts'; diff --git a/packages/react-simplikit/src/index.ts b/packages/react-simplikit/src/index.ts index b0fc24fd..e1c00d3b 100644 --- a/packages/react-simplikit/src/index.ts +++ b/packages/react-simplikit/src/index.ts @@ -5,12 +5,13 @@ export { useAsyncEffect } from './hooks/useAsyncEffect/index.ts'; export { useAvoidKeyboard } from './hooks/useAvoidKeyboard/index.ts'; export { useBodyScrollLock } from './hooks/useBodyScrollLock/index.ts'; export { useBooleanState } from './hooks/useBooleanState/index.ts'; -export { useCallbackOncePerRender } from './hooks/useCallbackOncePerRender/index.ts'; +export { useCallbackOnce, useCallbackOncePerRender } from './hooks/useCallbackOnce/index.ts'; export { useConditionalEffect } from './hooks/useConditionalEffect/index.ts'; export { useControlledState } from './hooks/useControlledState/index.ts'; export { useCounter } from './hooks/useCounter/index.ts'; export { useDebounce } from './hooks/useDebounce/index.ts'; export { useDebouncedCallback } from './hooks/useDebouncedCallback/index.ts'; +export { useDebouncedState } from './hooks/useDebouncedState/index.ts'; export { useDebouncedValue } from './hooks/useDebouncedValue/index.ts'; export { useDoubleClick } from './hooks/useDoubleClick/index.ts'; export { useGeolocation } from './hooks/useGeolocation/index.ts'; @@ -33,6 +34,7 @@ export { } from './hooks/useNetworkStatus/index.ts'; export { useOutsideClickEffect } from './hooks/useOutsideClickEffect/index.ts'; export { type PageVisibility, usePageVisibility, type VisibilityState } from './hooks/usePageVisibility/index.ts'; +export { usePageVisibilityEffect, useVisibilityEvent } from './hooks/usePageVisibilityEffect/index.ts'; export { usePreservedCallback } from './hooks/usePreservedCallback/index.ts'; export { usePreservedReference } from './hooks/usePreservedReference/index.ts'; export { usePrevious } from './hooks/usePrevious/index.ts'; @@ -43,10 +45,10 @@ export { useSet } from './hooks/useSet/index.ts'; export { useStorageState } from './hooks/useStorageState/index.ts'; export { useThrottle } from './hooks/useThrottle/index.ts'; export { useThrottledCallback } from './hooks/useThrottledCallback/index.ts'; +export { useThrottledState } from './hooks/useThrottledState/index.ts'; export { useThrottledValue } from './hooks/useThrottledValue/index.ts'; export { useTimeout } from './hooks/useTimeout/index.ts'; export { useToggle } from './hooks/useToggle/index.ts'; -export { useVisibilityEvent } from './hooks/useVisibilityEvent/index.ts'; export { useVisualViewport } from './hooks/useVisualViewport/index.ts'; export { buildContext } from './utils/buildContext/index.ts'; export { disableBodyScrollLock } from './utils/disableBodyScrollLock/index.ts'; diff --git a/packages/react-simplikit/src/utils/subscribeKeyboardHeight/es/subscribeKeyboardHeight.md b/packages/react-simplikit/src/utils/subscribeKeyboardHeight/es/subscribeKeyboardHeight.md index 52a5dd22..e324e2b4 100644 --- a/packages/react-simplikit/src/utils/subscribeKeyboardHeight/es/subscribeKeyboardHeight.md +++ b/packages/react-simplikit/src/utils/subscribeKeyboardHeight/es/subscribeKeyboardHeight.md @@ -4,6 +4,7 @@ La función invoca el callback proporcionado siempre que la altura del teclado pueda cambiar, incluso cuando el teclado aparece, desaparece o cambia de tamaño. +De forma predeterminada, también lo invoca una vez con la altura actual del teclado al iniciar la suscripción. Internamente, esta función escucha los eventos `resize` y `scroll` del área visible (Visual Viewport): @@ -44,7 +45,7 @@ function subscribeKeyboardHeight( name: 'options.immediate', type: 'boolean', required: false, - defaultValue: 'false', + defaultValue: 'true', description: 'Si es true, la función invoca el callback inmediatamente con la altura actual del teclado.', }, @@ -82,7 +83,6 @@ const { unsubscribe } = subscribeKeyboardHeight({ callback: height => { footer.style.paddingBottom = `${height}px`; }, - immediate: true, }); // Más adelante, cuando necesites realizar la limpieza diff --git a/packages/react-simplikit/src/utils/subscribeKeyboardHeight/ja/subscribeKeyboardHeight.md b/packages/react-simplikit/src/utils/subscribeKeyboardHeight/ja/subscribeKeyboardHeight.md index 96b460d2..9efbe57e 100644 --- a/packages/react-simplikit/src/utils/subscribeKeyboardHeight/ja/subscribeKeyboardHeight.md +++ b/packages/react-simplikit/src/utils/subscribeKeyboardHeight/ja/subscribeKeyboardHeight.md @@ -4,6 +4,7 @@ 指定したコールバックは、キーボードの表示、非表示、サイズ変更など、 キーボードの高さが変わる可能性があるたびに呼び出されます。 +デフォルトでは、購読を開始したときにも現在のキーボードの高さを引数として一度呼び出されます。 内部では、Visual Viewport の `resize` イベントと `scroll` イベントの 両方を監視します。 @@ -44,7 +45,7 @@ function subscribeKeyboardHeight( name: 'options.immediate', type: 'boolean', required: false, - defaultValue: 'false', + defaultValue: 'true', description: 'true の場合、現在のキーボードの高さを引数としてコールバックを即座に呼び出します。', }, @@ -82,7 +83,6 @@ const { unsubscribe } = subscribeKeyboardHeight({ callback: height => { footer.style.paddingBottom = `${height}px`; }, - immediate: true, }); // 後でクリーンアップが必要になったときに実行します diff --git a/packages/react-simplikit/src/utils/subscribeKeyboardHeight/ko/subscribeKeyboardHeight.md b/packages/react-simplikit/src/utils/subscribeKeyboardHeight/ko/subscribeKeyboardHeight.md index 2b8a7f34..f14fbc63 100644 --- a/packages/react-simplikit/src/utils/subscribeKeyboardHeight/ko/subscribeKeyboardHeight.md +++ b/packages/react-simplikit/src/utils/subscribeKeyboardHeight/ko/subscribeKeyboardHeight.md @@ -1,6 +1,6 @@ # subscribeKeyboardHeight -`subscribeKeyboardHeight`는 화면 키보드 높이의 변경 사항을 구독하는 유틸리티 함수에요. 제공된 콜백은 키보드 높이가 변경될 때마다 호출돼요. 키보드가 나타나거나 사라지거나 크기가 변경될 때 등이 포함돼요. 내부적으로 이 함수는 Visual Viewport에서 `resize` 및 `scroll` 이벤트를 모두 듣습니다. +`subscribeKeyboardHeight`는 화면 키보드 높이의 변경 사항을 구독하는 유틸리티 함수에요. 제공된 콜백은 키보드 높이가 변경될 때마다 호출돼요. 키보드가 나타나거나 사라지거나 크기가 변경될 때 등이 포함돼요. 기본적으로 구독을 시작할 때에도 현재 키보드 높이로 한 번 호출돼요. 내부적으로 이 함수는 Visual Viewport에서 `resize` 및 `scroll` 이벤트를 모두 듣습니다. - `resize`: 시각적 뷰포트 높이가 변경될 때 트리거됨 - `scroll`: 시각적 뷰포트 오프셋이 변경될 때 트리거됨(iOS의 경우 뷰포트가 리사이징 없이 이동할 수 있어 중요함) @@ -36,8 +36,8 @@ function subscribeKeyboardHeight( name: 'options.immediate', type: 'boolean', required: false, - defaultValue: 'false', - description: '참일 경우 현재 키보드 높이로 즉시 호출될 콜백.', + defaultValue: 'true', + description: 'true이면 현재 키보드 높이로 콜백을 즉시 호출해요.', }, { name: 'options.throttleMs', @@ -72,7 +72,6 @@ const { unsubscribe } = subscribeKeyboardHeight({ callback: height => { footer.style.paddingBottom = `${height}px`; }, - immediate: true, }); // 나중에 정리가 필요할 때 diff --git a/packages/react-simplikit/src/utils/subscribeKeyboardHeight/subscribeKeyboardHeight.md b/packages/react-simplikit/src/utils/subscribeKeyboardHeight/subscribeKeyboardHeight.md index 2d3c7b27..03352a4e 100644 --- a/packages/react-simplikit/src/utils/subscribeKeyboardHeight/subscribeKeyboardHeight.md +++ b/packages/react-simplikit/src/utils/subscribeKeyboardHeight/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/react-simplikit/src/utils/subscribeKeyboardHeight/subscribeKeyboardHeight.test.ts b/packages/react-simplikit/src/utils/subscribeKeyboardHeight/subscribeKeyboardHeight.test.ts index 98d0b010..1b8c9d72 100644 --- a/packages/react-simplikit/src/utils/subscribeKeyboardHeight/subscribeKeyboardHeight.test.ts +++ b/packages/react-simplikit/src/utils/subscribeKeyboardHeight/subscribeKeyboardHeight.test.ts @@ -46,6 +46,7 @@ describe('subscribeKeyboardHeight', () => { expect(typeof unsubscribe).toBe('function'); unsubscribe(); // should not throw + expect(callback).not.toHaveBeenCalled(); }); it('should add resize and scroll event listeners', () => { @@ -61,7 +62,7 @@ describe('subscribeKeyboardHeight', () => { it('should call callback with keyboard height on resize event', () => { const callback = vi.fn(); - subscribeKeyboardHeight({ callback, throttleMs: 0 }); + subscribeKeyboardHeight({ callback, immediate: false, throttleMs: 0 }); const resizeHandler = mockVisualViewport.addEventListener.mock.calls.find(call => call[0] === 'resize')?.[1]; @@ -73,8 +74,8 @@ describe('subscribeKeyboardHeight', () => { it('should call callback with keyboard height on scroll event', () => { const callback = vi.fn(); - // Use immediate to set lastHeight, then change height for scroll test - subscribeKeyboardHeight({ callback, immediate: true, throttleMs: 0 }); + // The immediate call sets lastHeight, then change height for scroll test + subscribeKeyboardHeight({ callback, throttleMs: 0 }); vi.runAllTimers(); // Reset callback tracking @@ -90,21 +91,47 @@ describe('subscribeKeyboardHeight', () => { expect(callback).toHaveBeenCalledWith(400); }); - it('should not call callback immediately by default', () => { + it('should call callback immediately by default', () => { const callback = vi.fn(); subscribeKeyboardHeight({ callback }); + expect(callback).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenCalledWith(300); + }); + + it('should not call callback immediately when immediate option is false', () => { + const callback = vi.fn(); + + subscribeKeyboardHeight({ callback, immediate: false }); + expect(callback).not.toHaveBeenCalled(); }); - it('should call callback immediately when immediate option is true', () => { + it('should skip an event that reports the same height as the immediate call', () => { const callback = vi.fn(); - subscribeKeyboardHeight({ callback, immediate: true }); + subscribeKeyboardHeight({ callback, throttleMs: 0 }); + + const resizeHandler = mockVisualViewport.addEventListener.mock.calls.find(call => call[0] === 'resize')?.[1]; + + resizeHandler?.(); expect(callback).toHaveBeenCalledTimes(1); - expect(callback).toHaveBeenCalledWith(300); + }); + + it('should not throttle the first event after the immediate call', () => { + const callback = vi.fn(); + + subscribeKeyboardHeight({ callback }); + + const resizeHandler = mockVisualViewport.addEventListener.mock.calls.find(call => call[0] === 'resize')?.[1]; + + mockVisualViewport.height = 400; + resizeHandler?.(); + + expect(callback).toHaveBeenCalledTimes(2); + expect(callback).toHaveBeenLastCalledWith(400); }); it('should remove event listeners when unsubscribe is called', () => { @@ -136,7 +163,7 @@ describe('subscribeKeyboardHeight', () => { it('should throttle events by default (16ms)', () => { const callback = vi.fn(); - subscribeKeyboardHeight({ callback }); + subscribeKeyboardHeight({ callback, immediate: false }); const resizeHandler = mockVisualViewport.addEventListener.mock.calls.find(call => call[0] === 'resize')?.[1]; @@ -162,7 +189,7 @@ describe('subscribeKeyboardHeight', () => { it('should respect custom throttle time', () => { const callback = vi.fn(); - subscribeKeyboardHeight({ callback, throttleMs: 100 }); + subscribeKeyboardHeight({ callback, immediate: false, throttleMs: 100 }); const resizeHandler = mockVisualViewport.addEventListener.mock.calls.find(call => call[0] === 'resize')?.[1]; @@ -187,7 +214,7 @@ describe('subscribeKeyboardHeight', () => { it('should handle throttleMs of 0 (no throttling)', () => { const callback = vi.fn(); - subscribeKeyboardHeight({ callback, throttleMs: 0 }); + subscribeKeyboardHeight({ callback, immediate: false, throttleMs: 0 }); const resizeHandler = mockVisualViewport.addEventListener.mock.calls.find(call => call[0] === 'resize')?.[1]; @@ -207,7 +234,7 @@ describe('subscribeKeyboardHeight', () => { it('should skip callback when height has not changed', () => { const callback = vi.fn(); - subscribeKeyboardHeight({ callback, throttleMs: 0 }); + subscribeKeyboardHeight({ callback, immediate: false, throttleMs: 0 }); const resizeHandler = mockVisualViewport.addEventListener.mock.calls.find(call => call[0] === 'resize')?.[1]; const scrollHandler = mockVisualViewport.addEventListener.mock.calls.find(call => call[0] === 'scroll')?.[1]; @@ -229,7 +256,7 @@ describe('subscribeKeyboardHeight', () => { it('should call callback when height changes after deduplication', () => { const callback = vi.fn(); - subscribeKeyboardHeight({ callback, throttleMs: 0 }); + subscribeKeyboardHeight({ callback, immediate: false, throttleMs: 0 }); const resizeHandler = mockVisualViewport.addEventListener.mock.calls.find(call => call[0] === 'resize')?.[1]; @@ -254,7 +281,7 @@ describe('subscribeKeyboardHeight', () => { it('should handle resize and scroll firing simultaneously with same value', () => { const callback = vi.fn(); - subscribeKeyboardHeight({ callback, throttleMs: 0 }); + subscribeKeyboardHeight({ callback, immediate: false, throttleMs: 0 }); const resizeHandler = mockVisualViewport.addEventListener.mock.calls.find(call => call[0] === 'resize')?.[1]; const scrollHandler = mockVisualViewport.addEventListener.mock.calls.find(call => call[0] === 'scroll')?.[1]; @@ -273,7 +300,7 @@ describe('subscribeKeyboardHeight', () => { it('should handle rapid events correctly with throttling', () => { const callback = vi.fn(); - subscribeKeyboardHeight({ callback, throttleMs: 16 }); + subscribeKeyboardHeight({ callback, immediate: false, throttleMs: 16 }); const resizeHandler = mockVisualViewport.addEventListener.mock.calls.find(call => call[0] === 'resize')?.[1]; @@ -289,7 +316,7 @@ describe('subscribeKeyboardHeight', () => { it('should limit callbacks during rapid height changes', () => { const callback = vi.fn(); - subscribeKeyboardHeight({ callback, throttleMs: 50 }); + subscribeKeyboardHeight({ callback, immediate: false, throttleMs: 50 }); const resizeHandler = mockVisualViewport.addEventListener.mock.calls.find(call => call[0] === 'resize')?.[1]; @@ -320,7 +347,7 @@ describe('subscribeKeyboardHeight', () => { const callback = vi.fn(); // 50ms throttle - subscribeKeyboardHeight({ callback, throttleMs: 50 }); + subscribeKeyboardHeight({ callback, immediate: false, throttleMs: 50 }); const resizeHandler = mockVisualViewport.addEventListener.mock.calls.find(call => call[0] === 'resize')?.[1]; @@ -343,7 +370,7 @@ describe('subscribeKeyboardHeight', () => { const clearTimeoutSpy = vi.spyOn(global, 'clearTimeout'); const callback = vi.fn(); - const { unsubscribe } = subscribeKeyboardHeight({ callback, throttleMs: 100 }); + const { unsubscribe } = subscribeKeyboardHeight({ callback, immediate: false, throttleMs: 100 }); const resizeHandler = mockVisualViewport.addEventListener.mock.calls.find(call => call[0] === 'resize')?.[1]; diff --git a/packages/react-simplikit/src/utils/subscribeKeyboardHeight/subscribeKeyboardHeight.ts b/packages/react-simplikit/src/utils/subscribeKeyboardHeight/subscribeKeyboardHeight.ts index 09d03083..3499b1cc 100644 --- a/packages/react-simplikit/src/utils/subscribeKeyboardHeight/subscribeKeyboardHeight.ts +++ b/packages/react-simplikit/src/utils/subscribeKeyboardHeight/subscribeKeyboardHeight.ts @@ -8,7 +8,7 @@ type SubscribeKeyboardHeightOptions = { callback: (height: number) => void; /** * If true, the callback will be invoked immediately with the current keyboard height. - * @default false + * @default true */ immediate?: boolean; /** @@ -32,6 +32,7 @@ type SubscribeKeyboardHeightResult = { * * 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: @@ -45,7 +46,7 @@ type SubscribeKeyboardHeightResult = { * * @param {SubscribeKeyboardHeightOptions} options - Configuration options * @param {(height: number) => void} options.callback - A function that will be called with the updated keyboard height in pixels. - * @param {boolean} [options.immediate=false] - If true, the callback will be invoked immediately with the current keyboard height. + * @param {boolean} [options.immediate=true] - If true, the callback will be invoked immediately with the current keyboard height. * @param {number} [options.throttleMs=16] - Throttle interval in milliseconds. * * @returns {SubscribeKeyboardHeightResult} An object containing the unsubscribe function. @@ -56,7 +57,6 @@ type SubscribeKeyboardHeightResult = { * callback: (height) => { * footer.style.paddingBottom = `${height}px`; * }, - * immediate: true, * }); * * // Later, when cleanup is needed @@ -64,7 +64,7 @@ type SubscribeKeyboardHeightResult = { */ export function subscribeKeyboardHeight({ callback, - immediate = false, + immediate = true, throttleMs = 16, }: SubscribeKeyboardHeightOptions): SubscribeKeyboardHeightResult { if (isServer()) { diff --git a/packages/react-simplikit/src/utils/subscribeKeyboardHeight/zh-Hans/subscribeKeyboardHeight.md b/packages/react-simplikit/src/utils/subscribeKeyboardHeight/zh-Hans/subscribeKeyboardHeight.md index be645745..9066271e 100644 --- a/packages/react-simplikit/src/utils/subscribeKeyboardHeight/zh-Hans/subscribeKeyboardHeight.md +++ b/packages/react-simplikit/src/utils/subscribeKeyboardHeight/zh-Hans/subscribeKeyboardHeight.md @@ -2,7 +2,7 @@ `subscribeKeyboardHeight` 是一个订阅屏幕键盘高度变化的工具函数。 -每当键盘高度可能变化时(包括键盘出现、消失或尺寸变化),传入的回调都会被调用。 +每当键盘高度可能变化时(包括键盘出现、消失或尺寸变化),传入的回调都会被调用。默认情况下,订阅开始时也会以当前键盘高度调用一次回调。 在内部,该函数同时监听 Visual Viewport 的 `resize` 和 `scroll` 事件: @@ -41,7 +41,7 @@ function subscribeKeyboardHeight( name: 'options.immediate', type: 'boolean', required: false, - defaultValue: 'false', + defaultValue: 'true', description: '若为 true,则注册时会立即以当前键盘高度调用一次回调。', }, @@ -79,7 +79,6 @@ const { unsubscribe } = subscribeKeyboardHeight({ callback: height => { footer.style.paddingBottom = `${height}px`; }, - immediate: true, }); // Later, when cleanup is needed