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/.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/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..2c47c738 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'; @@ -139,11 +142,12 @@ try { ) ).reduce((total, count) => total + count, 0); const guidePageCount = 11; + const renamedPageRedirectCount = 2; 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,32 @@ try { 'mobile/roadmap.html', 'ko/core/utils/mergeRefs.html', 'ja/mobile/utils/isServer.html', + 'hooks/useCallbackOncePerRender.html', + 'ko/core/hooks/useCallbackOncePerRender.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 from of ['hooks/useCallbackOncePerRender.html', 'ja/core/hooks/useCallbackOncePerRender.html']) { + assert.equal( + stubs.find(stub => stub.from === from)?.to, + from.replace('core/', '').replace('useCallbackOncePerRender', 'useCallbackOnce'), + `${from} must redirect to the useCallbackOnce 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..122faac7 100644 --- a/.vitepress/libs/legacyRedirects.mts +++ b/.vitepress/libs/legacyRedirects.mts @@ -43,8 +43,15 @@ 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' }, +]; + 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}` })), ]); diff --git a/packages/plugin/skills/react-simplikit/SKILL.md b/packages/plugin/skills/react-simplikit/SKILL.md index 0004a1fd..a4d6afd5 100644 --- a/packages/plugin/skills/react-simplikit/SKILL.md +++ b/packages/plugin/skills/react-simplikit/SKILL.md @@ -76,7 +76,7 @@ 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. | @@ -140,6 +140,14 @@ 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. | + ## Learn more - Docs: https://react-simplikit.slash.page diff --git a/packages/plugin/skills/react-simplikit/references/useCallbackOnce.md b/packages/plugin/skills/react-simplikit/references/useCallbackOnce.md new file mode 100644 index 00000000..b4c5b0d9 --- /dev/null +++ b/packages/plugin/skills/react-simplikit/references/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/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/react-simplikit/src/hooks/useCallbackOnce/es/useCallbackOnce.md b/packages/react-simplikit/src/hooks/useCallbackOnce/es/useCallbackOnce.md new file mode 100644 index 00000000..100d9d2d --- /dev/null +++ b/packages/react-simplikit/src/hooks/useCallbackOnce/es/useCallbackOnce.md @@ -0,0 +1,67 @@ +# useCallbackOnce + +`useCallbackOnce` es un Hook de React que ejecuta un callback una sola vez hasta que cambie `deps`, sin importar cuántas veces llames a la función que devuelve. +Te resulta útil para operaciones que solo deben ejecutarse una vez, incluso si el componente vuelve a renderizarse. +En v0 este Hook se llamaba `useCallbackOncePerRender`. El nombre anterior sigue funcionando, pero está obsoleto. + +## Interfaz + +```ts +function useCallbackOnce void>( + callback: F, + deps: DependencyList +): (...args: Parameters) => void; +``` + +### Parámetros + + + + + +### Valor de retorno + + + +## Ejemplo + +```tsx +import { useCallbackOnce } from 'react-simplikit'; + +function Component() { + const handleOneTimeEvent = useCallbackOnce(() => { + console.log('Esto solo se ejecutará una vez'); + }, []); + + return ; +} +``` + +```tsx +// Con dependencias +function TrackingComponent({ userId }: { userId: string }) { + const trackUserVisit = useCallbackOnce(() => { + analytics.trackVisit(userId); + }, [userId]); + + useEffect(() => { + trackUserVisit(); + }, [trackUserVisit, userId]); + + return
Página del usuario
; +} +``` diff --git a/packages/react-simplikit/src/hooks/useCallbackOncePerRender/index.ts b/packages/react-simplikit/src/hooks/useCallbackOnce/index.ts similarity index 56% rename from packages/react-simplikit/src/hooks/useCallbackOncePerRender/index.ts rename to packages/react-simplikit/src/hooks/useCallbackOnce/index.ts index c4f884e5..892bf361 100644 --- a/packages/react-simplikit/src/hooks/useCallbackOncePerRender/index.ts +++ b/packages/react-simplikit/src/hooks/useCallbackOnce/index.ts @@ -1 +1,2 @@ +export { useCallbackOnce } from './useCallbackOnce.ts'; export { useCallbackOncePerRender } from './useCallbackOncePerRender.ts'; diff --git a/packages/react-simplikit/src/hooks/useCallbackOncePerRender/ja/useCallbackOncePerRender.md b/packages/react-simplikit/src/hooks/useCallbackOnce/ja/useCallbackOnce.md similarity index 50% rename from packages/react-simplikit/src/hooks/useCallbackOncePerRender/ja/useCallbackOncePerRender.md rename to packages/react-simplikit/src/hooks/useCallbackOnce/ja/useCallbackOnce.md index ef860a4c..f2dc619c 100644 --- a/packages/react-simplikit/src/hooks/useCallbackOncePerRender/ja/useCallbackOncePerRender.md +++ b/packages/react-simplikit/src/hooks/useCallbackOnce/ja/useCallbackOnce.md @@ -1,15 +1,16 @@ -# useCallbackOncePerRender +# useCallbackOnce -`useCallbackOncePerRender` は、何度呼び出されてもコールバック関数を一度だけ実行する React フックです。 +`useCallbackOnce` は、返された関数が何度呼び出されても、`deps` が変わるまではコールバック関数を一度だけ実行する React フックです。 コンポーネントが再レンダリングされても繰り返すべきでない、一度限りの処理に便利です。 +v0 では、このフックは `useCallbackOncePerRender` という名前でした。以前の名前も引き続き動作しますが、非推奨です。 ## インターフェース ```ts -function useCallbackOncePerRender void>( - callback: () => void, +function useCallbackOnce void>( + callback: F, deps: DependencyList -): (...args: any[]) => void; +): (...args: Parameters) => void; ``` ### パラメータ @@ -17,8 +18,8 @@ function useCallbackOncePerRender void>( void>( ## 使用例 ```tsx -import { useCallbackOncePerRender } from 'react-simplikit'; +import { useCallbackOnce } from 'react-simplikit'; function Component() { - const handleOneTimeEvent = useCallbackOncePerRender(() => { + const handleOneTimeEvent = useCallbackOnce(() => { console.log('This will only run once'); }, []); @@ -53,13 +54,13 @@ function Component() { ```tsx // 依存関係を指定する場合 function TrackingComponent({ userId }: { userId: string }) { - const trackUserVisit = useCallbackOncePerRender(() => { + const trackUserVisit = useCallbackOnce(() => { analytics.trackVisit(userId); }, [userId]); useEffect(() => { trackUserVisit(); - }, [trackUserVisit]); + }, [trackUserVisit, userId]); return
User page
; } diff --git a/packages/react-simplikit/src/hooks/useCallbackOnce/ko/useCallbackOnce.md b/packages/react-simplikit/src/hooks/useCallbackOnce/ko/useCallbackOnce.md new file mode 100644 index 00000000..1ba0bdc2 --- /dev/null +++ b/packages/react-simplikit/src/hooks/useCallbackOnce/ko/useCallbackOnce.md @@ -0,0 +1,65 @@ +# useCallbackOnce + +`deps`가 바뀌기 전까지는 반환된 함수를 몇 번 호출하든 콜백을 한 번만 실행하는 리액트 훅이에요. 이는 컴포넌트가 다시 렌더링되더라도 반복되지 말아야 하는 일회성 작업에 유용해요. v0에서는 이 훅의 이름이 `useCallbackOncePerRender`였어요. 이전 이름도 계속 동작하지만 deprecated 상태예요. + +## 인터페이스 + +```ts +function useCallbackOnce void>( + callback: F, + deps: DependencyList +): (...args: Parameters) => void; +``` + +### 파라미터 + + + + + +### 반환 값 + + + +## 예시 + +```tsx +import { useCallbackOnce } from 'react-simplikit'; + +function Component() { + const handleOneTimeEvent = useCallbackOnce(() => { + console.log('이것은 한 번만 실행될 거예요'); + }, []); + + return ; +} +``` + +```tsx +// 의존성과 함께 사용하는 경우 +function TrackingComponent({ userId }: { userId: string }) { + const trackUserVisit = useCallbackOnce(() => { + analytics.trackVisit(userId); + }, [userId]); + + useEffect(() => { + trackUserVisit(); + }, [trackUserVisit, userId]); + + return
사용자 페이지
; +} +``` diff --git a/packages/react-simplikit/src/hooks/useCallbackOnce/useCallbackOnce.md b/packages/react-simplikit/src/hooks/useCallbackOnce/useCallbackOnce.md new file mode 100644 index 00000000..b4c5b0d9 --- /dev/null +++ b/packages/react-simplikit/src/hooks/useCallbackOnce/useCallbackOnce.md @@ -0,0 +1,67 @@ +# useCallbackOnce + +`useCallbackOnce` is a React hook that runs a callback only once until `deps` change, no matter how many times the returned function is called. +This is useful for one-time operations that should not be repeated, even if the component re-renders. +In v0 this hook was named `useCallbackOncePerRender`. The old name still works but is deprecated. + +## Interface + +```ts +function useCallbackOnce void>( + callback: F, + deps: DependencyList +): (...args: Parameters) => void; +``` + +### Parameters + + + + + +### Return Value + + + +## Example + +```tsx +import { useCallbackOnce } from 'react-simplikit'; + +function Component() { + const handleOneTimeEvent = useCallbackOnce(() => { + console.log('This will only run once'); + }, []); + + return ; +} +``` + +```tsx +// With dependencies +function TrackingComponent({ userId }: { userId: string }) { + const trackUserVisit = useCallbackOnce(() => { + analytics.trackVisit(userId); + }, [userId]); + + useEffect(() => { + trackUserVisit(); + }, [trackUserVisit, userId]); + + return
User page
; +} +``` diff --git a/packages/react-simplikit/src/hooks/useCallbackOncePerRender/useCallbackOncePerRender.spec.ts b/packages/react-simplikit/src/hooks/useCallbackOnce/useCallbackOnce.test.ts similarity index 73% rename from packages/react-simplikit/src/hooks/useCallbackOncePerRender/useCallbackOncePerRender.spec.ts rename to packages/react-simplikit/src/hooks/useCallbackOnce/useCallbackOnce.test.ts index 102feb6c..e3cf3507 100644 --- a/packages/react-simplikit/src/hooks/useCallbackOncePerRender/useCallbackOncePerRender.spec.ts +++ b/packages/react-simplikit/src/hooks/useCallbackOnce/useCallbackOnce.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it, vi } from 'vitest'; import { renderHookSSR } from '../../_internal/test-utils/renderHookSSR.tsx'; +import { useCallbackOnce } from './useCallbackOnce.ts'; import { useCallbackOncePerRender } from './useCallbackOncePerRender.ts'; function useCaller(callback: (...args: any) => any, deps: DependencyList) { @@ -13,20 +14,17 @@ function useCaller(callback: (...args: any) => any, deps: DependencyList) { }, deps); } -describe('useCallbackOncePerRender', () => { +describe('useCallbackOnce', () => { it('is safe on server side rendering', () => { const mockFn = vi.fn(); - renderHookSSR.serverOnly(() => useCallbackOncePerRender(mockFn, [])); + renderHookSSR.serverOnly(() => useCallbackOnce(mockFn, [])); }); it('should execute callback only once', async () => { const mockFn = vi.fn(); - const { rerender } = await renderHookSSR( - ({ effect }) => useCaller(useCallbackOncePerRender(mockFn, []), [effect]), - { - initialProps: { effect: 0 }, - } - ); + const { rerender } = await renderHookSSR(({ effect }) => useCaller(useCallbackOnce(mockFn, []), [effect]), { + initialProps: { effect: 0 }, + }); rerender({ effect: 1 }); rerender({ effect: 2 }); @@ -38,7 +36,7 @@ describe('useCallbackOncePerRender', () => { it('should reset and execute again when dependencies change', async () => { const mockFn = vi.fn(); const { rerender } = await renderHookSSR( - ({ effect, call }) => useCaller(useCallbackOncePerRender(mockFn, [call]), [effect, call]), + ({ effect, call }) => useCaller(useCallbackOnce(mockFn, [call]), [effect, call]), { initialProps: { effect: 0, call: 0 }, } @@ -55,8 +53,12 @@ describe('useCallbackOncePerRender', () => { it('should pass arguments to callback', async () => { const mockFn = vi.fn(); - const { result } = await renderHookSSR(() => useCallbackOncePerRender(mockFn, [])); + const { result } = await renderHookSSR(() => useCallbackOnce(mockFn, [])); result.current('test', 123); expect(mockFn).toHaveBeenCalledWith('test', 123); }); + + it('is also exported under the deprecated name useCallbackOncePerRender', () => { + expect(useCallbackOncePerRender).toBe(useCallbackOnce); + }); }); diff --git a/packages/react-simplikit/src/hooks/useCallbackOncePerRender/useCallbackOncePerRender.ts b/packages/react-simplikit/src/hooks/useCallbackOnce/useCallbackOnce.ts similarity index 60% rename from packages/react-simplikit/src/hooks/useCallbackOncePerRender/useCallbackOncePerRender.ts rename to packages/react-simplikit/src/hooks/useCallbackOnce/useCallbackOnce.ts index ded97fc9..2fc02519 100644 --- a/packages/react-simplikit/src/hooks/useCallbackOncePerRender/useCallbackOncePerRender.ts +++ b/packages/react-simplikit/src/hooks/useCallbackOnce/useCallbackOnce.ts @@ -5,20 +5,21 @@ import { usePreservedCallback } from '../usePreservedCallback/index.ts'; /** * @description - * `useCallbackOncePerRender` is a React hook that ensures a callback function is executed only once, regardless of how many times it's called. + * `useCallbackOnce` is a React hook that runs a callback only once until `deps` change, no matter how many times the returned function is called. * This is useful for one-time operations that should not be repeated, even if the component re-renders. + * In v0 this hook was named `useCallbackOncePerRender`. The old name still works but is deprecated. * * @template {(...args: any[]) => void} F - The type of the callback function. - * @param {() => void} callback - The callback function to be executed once. - * @param {DependencyList} deps - Dependencies array that will trigger a new one-time execution when changed. + * @param {F} callback - The callback function to be executed once. It receives the arguments passed to the returned function. + * @param {DependencyList} deps - Dependencies array. When it changes, the returned function can run the callback once more. * - * @returns {(...args: any[]) => void} A memoized function that will only execute once until dependencies change. + * @returns {(...args: Parameters) => void} A function whose reference never changes. It runs the callback only once until `deps` change. * * @example - * import { useCallbackOncePerRender } from 'react-simplikit'; + * import { useCallbackOnce } from 'react-simplikit'; * * function Component() { - * const handleOneTimeEvent = useCallbackOncePerRender(() => { + * const handleOneTimeEvent = useCallbackOnce(() => { * console.log('This will only run once'); * }, []); * @@ -28,18 +29,18 @@ import { usePreservedCallback } from '../usePreservedCallback/index.ts'; * @example * // With dependencies * function TrackingComponent({ userId }: { userId: string }) { - * const trackUserVisit = useCallbackOncePerRender(() => { + * const trackUserVisit = useCallbackOnce(() => { * analytics.trackVisit(userId); * }, [userId]); * * useEffect(() => { * trackUserVisit(); - * }, [trackUserVisit]); + * }, [trackUserVisit, userId]); * * return
User page
; * } */ -export function useCallbackOncePerRender void>(callback: F, deps: DependencyList) { +export function useCallbackOnce void>(callback: F, deps: DependencyList) { // Same reason as `useAsyncEffect`: the body itself is compiler-clean, but React Compiler // bails on any function carrying a React ESLint suppression, and the // `react-hooks/exhaustive-deps` suppression below is unavoidable for a caller-supplied `deps`. diff --git a/packages/react-simplikit/src/hooks/useCallbackOnce/useCallbackOncePerRender.ts b/packages/react-simplikit/src/hooks/useCallbackOnce/useCallbackOncePerRender.ts new file mode 100644 index 00000000..ae6593b1 --- /dev/null +++ b/packages/react-simplikit/src/hooks/useCallbackOnce/useCallbackOncePerRender.ts @@ -0,0 +1,9 @@ +import { useCallbackOnce } from './useCallbackOnce.ts'; + +/** + * @description + * `useCallbackOncePerRender` is the name `useCallbackOnce` had in v0. + * + * @deprecated Use `useCallbackOnce` instead. + */ +export const useCallbackOncePerRender = useCallbackOnce; diff --git a/packages/react-simplikit/src/hooks/useCallbackOnce/zh-Hans/useCallbackOnce.md b/packages/react-simplikit/src/hooks/useCallbackOnce/zh-Hans/useCallbackOnce.md new file mode 100644 index 00000000..1cd584dd --- /dev/null +++ b/packages/react-simplikit/src/hooks/useCallbackOnce/zh-Hans/useCallbackOnce.md @@ -0,0 +1,65 @@ +# useCallbackOnce + +`useCallbackOnce` 是一个 React Hook,在 `deps` 变化之前,无论返回的函数被调用多少次,它都只会执行一次回调函数。它适用于那些即使组件重复渲染也不应重复执行的一次性操作。在 v0 中,这个 Hook 名为 `useCallbackOncePerRender`。旧名称仍然可用,但已弃用。 + +## 接口 + +```ts +function useCallbackOnce void>( + callback: F, + deps: DependencyList +): (...args: Parameters) => void; +``` + +### 参数 + + + + + +### 返回值 + + + +## 示例 + +```tsx +import { useCallbackOnce } from 'react-simplikit'; + +function Component() { + const handleOneTimeEvent = useCallbackOnce(() => { + console.log('This will only run once'); + }, []); + + return ; +} +``` + +```tsx +// With dependencies +function TrackingComponent({ userId }: { userId: string }) { + const trackUserVisit = useCallbackOnce(() => { + analytics.trackVisit(userId); + }, [userId]); + + useEffect(() => { + trackUserVisit(); + }, [trackUserVisit, userId]); + + return
User page
; +} +``` diff --git a/packages/react-simplikit/src/hooks/useCallbackOncePerRender/es/useCallbackOncePerRender.md b/packages/react-simplikit/src/hooks/useCallbackOncePerRender/es/useCallbackOncePerRender.md deleted file mode 100644 index 21699e92..00000000 --- a/packages/react-simplikit/src/hooks/useCallbackOncePerRender/es/useCallbackOncePerRender.md +++ /dev/null @@ -1,66 +0,0 @@ -# useCallbackOncePerRender - -`useCallbackOncePerRender` es un Hook de React que garantiza que un callback se ejecute una sola vez, sin importar cuántas veces lo llames. -Te resulta útil para operaciones que solo deben ejecutarse una vez, incluso si el componente vuelve a renderizarse. - -## Interfaz - -```ts -function useCallbackOncePerRender void>( - callback: () => void, - deps: DependencyList -): (...args: any[]) => void; -``` - -### Parámetros - - - - - -### Valor de retorno - - - -## Ejemplo - -```tsx -import { useCallbackOncePerRender } from 'react-simplikit'; - -function Component() { - const handleOneTimeEvent = useCallbackOncePerRender(() => { - console.log('Esto solo se ejecutará una vez'); - }, []); - - return ; -} -``` - -```tsx -// Con dependencias -function TrackingComponent({ userId }: { userId: string }) { - const trackUserVisit = useCallbackOncePerRender(() => { - analytics.trackVisit(userId); - }, [userId]); - - useEffect(() => { - trackUserVisit(); - }, [trackUserVisit]); - - return
Página del usuario
; -} -``` diff --git a/packages/react-simplikit/src/hooks/useCallbackOncePerRender/ko/useCallbackOncePerRender.md b/packages/react-simplikit/src/hooks/useCallbackOncePerRender/ko/useCallbackOncePerRender.md deleted file mode 100644 index 57eaec16..00000000 --- a/packages/react-simplikit/src/hooks/useCallbackOncePerRender/ko/useCallbackOncePerRender.md +++ /dev/null @@ -1,65 +0,0 @@ -# useCallbackOncePerRender - -리액트 훅으로, 콜백 함수가 한 번만 실행되도록 보장해요. 몇 번 호출되든 간에 단 한 번만 실행돼요. 이는 컴포넌트가 다시 렌더링되더라도 반복되지 말아야 하는 일회성 작업에 유용해요. - -## 인터페이스 - -```ts -function useCallbackOncePerRender void>( - callback: () => void, - deps: DependencyList -): (...args: any[]) => void; -``` - -### 파라미터 - - - - - -### 반환 값 - - - -## 예시 - -```tsx -import { useCallbackOncePerRender } from 'react-simplikit'; - -function Component() { - const handleOneTimeEvent = useCallbackOncePerRender(() => { - console.log('이것은 한 번만 실행될 거예요'); - }, []); - - return ; -} -``` - -```tsx -// 의존성과 함께 사용하는 경우 -function TrackingComponent({ userId }: { userId: string }) { - const trackUserVisit = useCallbackOncePerRender(() => { - analytics.trackVisit(userId); - }, [userId]); - - useEffect(() => { - trackUserVisit(); - }, [trackUserVisit]); - - return
사용자 페이지
; -} -``` diff --git a/packages/react-simplikit/src/hooks/useCallbackOncePerRender/useCallbackOncePerRender.md b/packages/react-simplikit/src/hooks/useCallbackOncePerRender/useCallbackOncePerRender.md deleted file mode 100644 index 00af226b..00000000 --- a/packages/react-simplikit/src/hooks/useCallbackOncePerRender/useCallbackOncePerRender.md +++ /dev/null @@ -1,66 +0,0 @@ -# useCallbackOncePerRender - -`useCallbackOncePerRender` is a React hook that ensures a callback function is executed only once, regardless of how many times it's called. -This is useful for one-time operations that should not be repeated, even if the component re-renders. - -## Interface - -```ts -function useCallbackOncePerRender void>( - callback: () => void, - deps: DependencyList -): (...args: any[]) => void; -``` - -### Parameters - - - - - -### Return Value - - - -## Example - -```tsx -import { useCallbackOncePerRender } from 'react-simplikit'; - -function Component() { - const handleOneTimeEvent = useCallbackOncePerRender(() => { - console.log('This will only run once'); - }, []); - - return ; -} -``` - -```tsx -// With dependencies -function TrackingComponent({ userId }: { userId: string }) { - const trackUserVisit = useCallbackOncePerRender(() => { - analytics.trackVisit(userId); - }, [userId]); - - useEffect(() => { - trackUserVisit(); - }, [trackUserVisit]); - - return
User page
; -} -``` diff --git a/packages/react-simplikit/src/hooks/useCallbackOncePerRender/zh-Hans/useCallbackOncePerRender.md b/packages/react-simplikit/src/hooks/useCallbackOncePerRender/zh-Hans/useCallbackOncePerRender.md deleted file mode 100644 index 9324d643..00000000 --- a/packages/react-simplikit/src/hooks/useCallbackOncePerRender/zh-Hans/useCallbackOncePerRender.md +++ /dev/null @@ -1,65 +0,0 @@ -# useCallbackOncePerRender - -`useCallbackOncePerRender` 是一个确保回调函数无论被调用多少次都只执行一次的 React Hook。它适用于那些即使组件重复渲染也不应重复执行的一次性操作。 - -## 接口 - -```ts -function useCallbackOncePerRender void>( - callback: () => void, - deps: DependencyList -): (...args: any[]) => void; -``` - -### 参数 - - - - - -### 返回值 - - - -## 示例 - -```tsx -import { useCallbackOncePerRender } from 'react-simplikit'; - -function Component() { - const handleOneTimeEvent = useCallbackOncePerRender(() => { - console.log('This will only run once'); - }, []); - - return ; -} -``` - -```tsx -// With dependencies -function TrackingComponent({ userId }: { userId: string }) { - const trackUserVisit = useCallbackOncePerRender(() => { - analytics.trackVisit(userId); - }, [userId]); - - useEffect(() => { - trackUserVisit(); - }, [trackUserVisit]); - - return
User page
; -} -``` diff --git a/packages/react-simplikit/src/index.ts b/packages/react-simplikit/src/index.ts index b0fc24fd..99337942 100644 --- a/packages/react-simplikit/src/index.ts +++ b/packages/react-simplikit/src/index.ts @@ -5,7 +5,7 @@ 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';