Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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.
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 });
```
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