Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
4ee9b53
refactor(hooks): standardize option and parameter names (#549)
Antoliny0919 Oct 1, 2026
62fc4b4
Merge branch 'main' into v1-base
hyesungoh Oct 1, 2026
917b09b
refactor: rename equality options to equalityFn (#544)
hyesungoh Oct 1, 2026
95252f4
Merge branch 'main' into v1-base
hyesungoh Oct 6, 2026
15aea8a
refactor(useThrottle, useThrottledCallback): replace edges with leadi…
Antoliny0919 Oct 6, 2026
9d16479
feat(useCallbackOnce): rename useCallbackOncePerRender to useCallback…
hyesungoh Oct 7, 2026
83ae1a3
feat(usePageVisibilityEffect): add usePageVisibilityEffect and deprec…
hyesungoh Oct 7, 2026
78788e2
refactor(useBooleanState, useStorageState, useCounter): return the va…
mnxmnz Oct 8, 2026
c6511ef
refactor(useInterval, useTimeout): take a single options object (#530)
mnxmnz Oct 8, 2026
d5a46be
refactor: make millisecond units explicit in time parameter names (#552)
Antoliny0919 Oct 8, 2026
88046cf
Merge branch 'main' into v1-base
hyesungoh Oct 8, 2026
cf2626b
refactor(subscribeKeyboardHeight): default immediate to true (#561)
hyesungoh Oct 14, 2026
71c1a07
feat(useDebouncedState, useThrottledState): add new hook useDebounced…
Antoliny0919 Oct 14, 2026
1a85542
refactor: separate internal debounce and throttle hooks (#565)
Antoliny0919 Oct 20, 2026
eee2c61
Merge branch 'main' into v1-base
hyesungoh Oct 20, 2026
1a0a491
refactor(useStorageState): type the state as T and check JSON values …
hyesungoh Oct 28, 2026
e2057e9
Merge branch 'main' into v1-base
hyesungoh Oct 28, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/callback-once.md
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 7 additions & 0 deletions .changeset/curly-melons-shop.md
Original file line number Diff line number Diff line change
@@ -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.
21 changes: 21 additions & 0 deletions .changeset/deep-pandas-stare.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/equality-fn-options.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/hip-pugs-joke.md
Original file line number Diff line number Diff line change
@@ -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.
16 changes: 16 additions & 0 deletions .changeset/keyboard-height-immediate.md
Original file line number Diff line number Diff line change
@@ -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 });
```
5 changes: 5 additions & 0 deletions .changeset/page-visibility-effect.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/petite-beans-fetch.md
Original file line number Diff line number Diff line change
@@ -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.
19 changes: 19 additions & 0 deletions .changeset/state-hook-returns.md
Original file line number Diff line number Diff line change
@@ -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<string>('token');
const { count, increment, decrement, reset, setCount } = useCounter(0);

// after
const [isOpen, { setTrue, setFalse, toggle }] = useBooleanState(false);
const [token, { setValue, refresh }] = useStorageState<string>('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.
29 changes: 29 additions & 0 deletions .changeset/storage-state-json.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
---
'react-simplikit': major
---

`useStorageState` now types its state as `T` instead of `Serializable<T>`, so `null` in a union and `interface` types are kept. With `<string | null>`, 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<string | null>('token', { defaultValue: null });

// after: the state is typed `string | null`
const [token] = useStorageState<string | null>('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),
});
```
17 changes: 17 additions & 0 deletions .changeset/timer-hook-options.md
Original file line number Diff line number Diff line change
@@ -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 });
```
13 changes: 13 additions & 0 deletions .github/workflows/integration.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ on:
- main
- v1-base

env:
INTERNAL_HOOKS_PATH: 'packages/*/src/hooks/_internal/*'

jobs:
quality:
runs-on: ubuntu-latest
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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%%.*}"
Expand Down Expand Up @@ -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=""
Expand Down
38 changes: 38 additions & 0 deletions .scripts/commands/generateDocs/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
115 changes: 58 additions & 57 deletions .scripts/commands/generateDocs/index.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
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';
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<OriginSpec, 'type' | 'name' | 'description' | 'optional' | 'default'>;
Expand All @@ -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<string> {
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)),
Expand All @@ -36,59 +44,64 @@ 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();

// The skill catalog is rendered from the English pages written above, so it is refreshed in the same run.
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'));
Expand Down Expand Up @@ -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 `<br />`, so the
* source's wrapping is joined back into one line; a blank line still separates paragraphs.
Expand Down
Loading
Loading