From bce7e9091f5825db4d0f972e7e033e6bd8bb51d6 Mon Sep 17 00:00:00 2001 From: hyesungoh Date: Wed, 30 Sep 2026 11:29:15 +0900 Subject: [PATCH 1/3] feat(docs): support deprecated exports in the docs and skill tooling Render a JSDoc @deprecated tag as a warning block at the top of the generated page, move deprecated exports into a separate Deprecated table in the generated skill, and replace the blanket @deprecated ban in verifySkill with checks that the table matches the deprecated exports and that the Common needs table names none of them. --- .scripts/commands/generateDocs/index.spec.ts | 135 +++++++++++++++++- .scripts/commands/generateDocs/index.ts | 36 ++++- .../commands/generateSkill/catalog.spec.ts | 90 +++++++++++- .scripts/commands/generateSkill/catalog.ts | 73 +++++++++- .scripts/commands/generateSkill/index.spec.ts | 69 +++++++++ .scripts/commands/generateSkill/index.ts | 10 +- .scripts/verifySkill.ts | 39 +++-- 7 files changed, 428 insertions(+), 24 deletions(-) diff --git a/.scripts/commands/generateDocs/index.spec.ts b/.scripts/commands/generateDocs/index.spec.ts index 75cdcf4c..123316d7 100644 --- a/.scripts/commands/generateDocs/index.spec.ts +++ b/.scripts/commands/generateDocs/index.spec.ts @@ -4,7 +4,7 @@ import path from 'node:path'; import { describe, expect, it } from 'vitest'; import { compileTemplate } from 'vue/compiler-sfc'; -import { renderEnglishDoc } from './index.ts'; +import { readDeprecation, renderEnglishDoc } from './index.ts'; async function render(name: string, source: string) { const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'generate-docs-')); @@ -677,4 +677,137 @@ export function useNoReturnValue() {}` expect(document).toContain('\nThis function does not return anything.'); }); + + it('renders @deprecated as a warning container between the heading and the description', async () => { + const document = await render( + 'useOld', + `/** + * @deprecated Use \`useNew\` instead. + * + * @description + * \`useOld\` does something. + * + * @returns {void} + * + * @example + * useOld(); + */ +export function useOld() {}` + ); + + expect( + document.startsWith( + '# useOld\n\n::: warning Deprecated\nUse `useNew` instead.\n:::\n\n`useOld` does something.\n' + ) + ).toBe(true); + }); + + it('keeps the line breaks of a wrapped @deprecated text inside the container', async () => { + const document = await render( + 'useOld', + `/** + * @description + * \`useOld\` does something. + * + * @returns {void} + * + * @example + * useOld(); + * + * @deprecated + * Use \`useNew\` instead. + * It will be removed in the next major version. + */ +export function useOld() {}` + ); + + expect(document).toContain( + '::: warning Deprecated\nUse `useNew` instead.\nIt will be removed in the next major version.\n:::' + ); + }); + + it('renders no container when there is no @deprecated', async () => { + const document = await render( + 'useCurrent', + `/** + * @description + * \`useCurrent\` does something. + * + * @returns {void} + * + * @example + * useCurrent(); + */ +export function useCurrent() {}` + ); + + expect(document.startsWith('# useCurrent\n\n`useCurrent` does something.\n')).toBe(true); + expect(document).not.toContain(':::'); + }); +}); + +describe('readDeprecation', () => { + it('reads @deprecated from the comment the page is generated from', () => { + 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 page 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/commands/generateDocs/index.ts b/.scripts/commands/generateDocs/index.ts index 4d80822f..cf701243 100644 --- a/.scripts/commands/generateDocs/index.ts +++ b/.scripts/commands/generateDocs/index.ts @@ -82,7 +82,7 @@ export async function generateDocs(names: string[]) { // 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 NAMELESS_TAGS = new Set(['returns', 'remarks', 'description', 'deprecated']); const skipNameForNamelessTags = (spec: OriginSpec) => (NAMELESS_TAGS.has(spec.tag) ? spec : tokenizers.name()(spec)); const parseOptions = (spacing: 'compact' | 'preserve') => ({ @@ -105,6 +105,7 @@ function parseJSDoc(source: string) { preservedComment?.tags.find(tag => tag.tag === 'description')?.description ?? preservedComment?.description ?? '' ); const remarks = trimBlock(preservedComment?.tags.find(tag => tag.tag === 'remarks')?.description ?? ''); + const deprecation = readDeprecation(source); const params = targetComment.tags.filter(tag => tag.tag === 'param'); @@ -142,6 +143,7 @@ function parseJSDoc(source: string) { return { description, remarks, + deprecation, templates, examples, params, @@ -153,6 +155,32 @@ function parseJSDoc(source: string) { }; } +/** + * The `@deprecated` text of the comment a page is generated from (the last one in the file), or + * `undefined` when there is none. `verifySkill.ts` reads it too, so a `@deprecated` on another + * comment in the file, such as an options type, does not mark the export itself. + */ +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; +} + /** JSDoc's own caption syntax; the title renders as a heading above the example's code block. */ const EXAMPLE_CAPTION = /^(.*?)<\/caption>\s*/; @@ -214,7 +242,7 @@ function endSentence(description: string) { } async function jsdocToMd(name: string, jsdoc: ReturnType) { - const { templates, description, remarks, examples, params, returns, nestedValueOfReturns } = jsdoc; + const { templates, description, remarks, deprecation, examples, params, returns, nestedValueOfReturns } = jsdoc; const paramsProps = params.reduce>( (acc, param) => { @@ -251,9 +279,11 @@ async function jsdocToMd(name: string, jsdoc: ReturnType) { return `${rest}${param.name}${optional}: ${type}${param.default == null ? '' : ` = ${param.default}`}`; }); + // The notice sits above the description so the page opens with it. `extractDescription` skips it, + // and the skill catalog reads it back to list the export as deprecated. return `# ${name} -${description} +${deprecation == null ? '' : `::: warning Deprecated\n${deprecation}\n:::\n\n`}${description} ## Interface diff --git a/.scripts/commands/generateSkill/catalog.spec.ts b/.scripts/commands/generateSkill/catalog.spec.ts index af4cb54d..1589d140 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 { extractDeprecation, extractDescription, getCategory, readDeprecatedNames, renderSkill } from './catalog.ts'; describe('getCategory', () => { it('derives the category from the export source path', () => { @@ -43,12 +43,42 @@ into one. assert.equal(extractDescription(markdown, 'useX'), 'A React hook that manages a Set as state'); }); + it('skips the deprecation notice above the paragraph, whatever its title', () => { + const english = `# useOld\n\n::: warning Deprecated\nUse \`useNew\` instead.\n:::\n\n\`useOld\` does something. More.\n`; + const korean = `# useOld\n\n::: warning 더 이상 권장하지 않음\n대신 \`useNew\` 훅을 사용하세요.\n:::\n\n무언가를 하는 훅이에요. 더.\n`; + + assert.equal(extractDescription(english, 'useOld'), '`useOld` does something.'); + assert.equal(extractDescription(korean, 'useOld'), '무언가를 하는 훅이에요.'); + }); + it('rejects a page that does not open with a paragraph', () => { assert.throws( () => extractDescription('# useX\n\n## Interface\n', 'useX'), /must open with a description paragraph/ ); assert.throws(() => extractDescription('# useX\n', 'useX'), /must open with a description paragraph/); + assert.throws( + () => extractDescription('# useX\n\n::: warning Deprecated\nUse `useY` instead.\n:::\n', 'useX'), + /must open with a description paragraph/ + ); + }); +}); + +describe('extractDeprecation', () => { + it('returns the notice under the heading on one line', () => { + const markdown = `# useOld\n\n::: warning Deprecated\nUse \`useNew\` instead.\nIt goes away\nlater.\n:::\n\n\`useOld\` does something.\n`; + + assert.equal(extractDeprecation(markdown), 'Use `useNew` instead. It goes away later.'); + }); + + it('returns undefined for a page without the notice under its heading', () => { + assert.equal(extractDeprecation('# useX\n\n`useX` does something.\n'), undefined); + assert.equal( + extractDeprecation( + '# useX\n\n`useX` does something.\n\n## Notes\n\n::: warning Deprecated\nThe `wait` option.\n:::\n' + ), + undefined + ); }); }); @@ -80,6 +110,40 @@ describe('renderSkill', () => { | --- | --- | | [\`isIOS\`](references/isIOS.md) | Detects iOS. | +## Learn more +` + ); + }); + + it('lists a deprecated entry under Deprecated, not under its category', () => { + const rendered = renderSkill({ + template: '# Skill\n\n## Catalog\n\n\n\n## Learn more\n', + entries: [ + { name: 'useNew', category: 'hooks', description: 'Does it.' }, + { name: 'useOld', category: 'hooks', description: 'Does it.', deprecation: 'Use `useNew` | not this.' }, + ], + }); + + 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\`](references/useOld.md) | Use \`useNew\` \\| not this. | + ## Learn more ` ); @@ -89,3 +153,27 @@ describe('renderSkill', () => { assert.throws(() => renderSkill({ template: '# Skill\n', entries: [] }), //); }); }); + +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| Toggle | `useToggle` |\n\n\n', + entries: [ + { name: 'isIOS', category: 'utils', description: 'Detects iOS.' }, + { name: 'useNew', category: 'hooks', description: 'Does it.' }, + { name: 'useOld', category: 'hooks', description: 'Does it.', deprecation: '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.' }], + }); + + assert.deepEqual(readDeprecatedNames(skill), []); + }); +}); diff --git a/.scripts/commands/generateSkill/catalog.ts b/.scripts/commands/generateSkill/catalog.ts index 16930698..9a40769d 100644 --- a/.scripts/commands/generateSkill/catalog.ts +++ b/.scripts/commands/generateSkill/catalog.ts @@ -6,6 +6,8 @@ export type CatalogEntry = { name: string; category: Category; description: string; + /** The page's deprecation notice; a deprecated entry is listed under Deprecated instead of its category. */ + deprecation?: string; }; type RenderSkillOptions = { @@ -15,6 +17,18 @@ type RenderSkillOptions = { const CATALOG_PLACEHOLDER = ''; +const DEPRECATED_HEADING = '### Deprecated'; + +const CATALOG_ROW = /^\| \[`([^`]+)`\]\(references\/\1\.md\) \| .+ \|$/gm; + +// A translated page carries the notice under its own title, so any container directly under the +// heading is skipped, not only the English one. +const LEADING_CONTAINER = /^:::[^\n]*\n[\s\S]*?\n:::(?:\n|$)/; + +// Only where `docs:gen` writes it: a `::: warning Deprecated` further down, in the Notes for +// example, says something about the page, not that the export is deprecated. +const DEPRECATION_NOTICE = /^# [^\n]*\n\n::: warning Deprecated\n([\s\S]*?)\n:::(?:\n|$)/; + /** 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]; @@ -29,10 +43,15 @@ export function getCategory(sourcePath: string): Category { /** * First sentence of a documentation page's opening paragraph, which `docs:gen` writes from the - * JSDoc `@description`. A period inside backticks (`options.leading`) does not end the sentence. + * JSDoc `@description`. A period inside backticks (`options.leading`) does not end the sentence, + * and a deprecation notice above the paragraph is not part of it. */ export function extractDescription(markdown: string, name: string): string { - const body = markdown.replace(/^# .*\n/, '').trimStart(); + const body = markdown + .replace(/^# .*\n/, '') + .trimStart() + .replace(LEADING_CONTAINER, '') + .trimStart(); const paragraph = body .split(/\n\s*\n/)[0] .replace(/\n/g, ' ') @@ -66,8 +85,19 @@ 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). + * Text of the deprecation notice `docs:gen` writes under a page's heading from the JSDoc + * `@deprecated`, on one line so it fits a table cell; `undefined` when the page has none. + */ +export function extractDeprecation(markdown: string): string | undefined { + return DEPRECATION_NOTICE.exec(markdown)?.[1] + .trim() + .replace(/\s*\n\s*/g, ' '); +} + +/** + * Fills the template's `` with one table per category, then a Deprecated table + * when an entry is deprecated. Categories keep the order of `CATEGORIES`; rows keep the order + * they are given (sorted by name upstream). */ export function renderSkill({ template, entries }: RenderSkillOptions): string { if (!template.includes(CATALOG_PLACEHOLDER)) { @@ -75,7 +105,7 @@ export function renderSkill({ template, entries }: RenderSkillOptions): string { } const sections = CATEGORIES.flatMap(category => { - const rows = entries.filter(entry => entry.category === category); + const rows = entries.filter(entry => entry.category === category && entry.deprecation == null); if (rows.length === 0) { return []; @@ -93,7 +123,38 @@ export function renderSkill({ template, entries }: RenderSkillOptions): string { ]; }); - return template.replace(CATALOG_PLACEHOLDER, sections.join('\n').trimEnd()); + const deprecatedRows = entries.flatMap(({ name, deprecation }) => + deprecation == null ? [] : [`| [\`${name}\`](references/${name}.md) | ${escapeTableCell(deprecation)} |`] + ); + const deprecatedSection = + deprecatedRows.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 |', + '| --- | --- |', + ...deprecatedRows, + '', + ]; + + return template.replace(CATALOG_PLACEHOLDER, [...sections, ...deprecatedSection].join('\n').trimEnd()); +} + +/** + * Names in the Deprecated table of a rendered `SKILL.md`, which `renderSkill` writes last in the + * catalog; empty when there is none. Only catalog rows link `references/.md` from their first cell. + */ +export function readDeprecatedNames(skill: string): string[] { + const deprecatedStart = skill.indexOf(`\n${DEPRECATED_HEADING}\n`); + + if (deprecatedStart === -1) { + return []; + } + + return [...skill.slice(deprecatedStart).matchAll(CATALOG_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..95c39ccb 100644 --- a/.scripts/commands/generateSkill/index.spec.ts +++ b/.scripts/commands/generateSkill/index.spec.ts @@ -4,6 +4,8 @@ import os from 'node:os'; import path from 'node:path'; import { afterEach, describe, it } from 'vitest'; +import { renderEnglishDoc } from '../generateDocs/index.ts'; + import { generateSkill, PACKAGE_INDEX_FILE } from './index.ts'; const fixtureDirectories: string[] = []; @@ -43,6 +45,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,6 +81,62 @@ 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, from the page docs:gen renders', async () => { + const [oldPage, newPage] = await Promise.all([ + renderFixtureDoc( + 'useOld', + `/** + * @deprecated Use \`useNew\` instead. + * + * @description + * \`useOld\` flips a boolean. + * + * @returns {void} + * + * @example + * useOld(); + */ +export function useOld() {}` + ), + renderFixtureDoc( + 'useNew', + `/** + * @description + * \`useNew\` flips a boolean. + * + * @returns {void} + * + * @example + * useNew(); + */ +export function useNew() {}` + ), + ]); + const root = await writeFixtureRoot({ + index: ` +export { useNew } from './hooks/useNew/index.ts'; +export { useOld } from './hooks/useOld/index.ts'; +`, + pages: { 'hooks/useNew/useNew.md': newPage, 'hooks/useOld/useOld.md': oldPage }, + }); + 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`](references/useOld.md) | Use `useNew` instead. |'), + true + ); + assert.equal(await fs.readFile(path.join(outputDirectory, 'references', 'useOld.md'), 'utf8'), oldPage); + }); + it('fails when an export has no documentation page', async () => { const root = await writeFixtureRoot({ index: `export { useToggle } from './hooks/useToggle/index.ts';\n`, @@ -107,3 +166,13 @@ async function writeFixtureRoot({ index, pages }: { index: string; pages: Record return root; } + +async function renderFixtureDoc(name: string, source: string): Promise { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'react-simplikit-skill-doc-')); + fixtureDirectories.push(directory); + const sourceFilePath = path.join(directory, `${name}.ts`); + + await fs.writeFile(sourceFilePath, source); + + return renderEnglishDoc(name, sourceFilePath); +} diff --git a/.scripts/commands/generateSkill/index.ts b/.scripts/commands/generateSkill/index.ts index 222c8a82..511483f0 100644 --- a/.scripts/commands/generateSkill/index.ts +++ b/.scripts/commands/generateSkill/index.ts @@ -4,7 +4,7 @@ import path from 'node:path'; import { collectPublicExportEntries } from '../../utils/collectPublicExports.ts'; import { getRootPath } from '../../utils/getRootPath.ts'; -import { CatalogEntry, extractDescription, getCategory, renderSkill } from './catalog.ts'; +import { CatalogEntry, extractDeprecation, 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'; @@ -20,6 +20,7 @@ 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. + * That is also why a deprecated export is recognised by the notice on its page, not by its source's JSDoc. */ export async function generateSkill({ root = getRootPath(), @@ -40,7 +41,12 @@ export async function generateSkill({ await fs.writeFile(path.join(referencesDirectory, `${name}.md`), markdown); - return { name, category: getCategory(sourcePath), description: extractDescription(markdown, name) }; + return { + name, + category: getCategory(sourcePath), + description: extractDescription(markdown, name), + deprecation: extractDeprecation(markdown), + }; }) ); diff --git a/.scripts/verifySkill.ts b/.scripts/verifySkill.ts index 94c57411..57783603 100644 --- a/.scripts/verifySkill.ts +++ b/.scripts/verifySkill.ts @@ -4,6 +4,8 @@ import * as fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; +import { readDeprecation } from './commands/generateDocs/index.ts'; +import { readDeprecatedNames } from './commands/generateSkill/catalog.ts'; import { generateSkill, PACKAGE_INDEX_FILE, SKILL_DIRECTORY } from './commands/generateSkill/index.ts'; import { collectPublicExports } from './utils/collectPublicExports.ts'; import { getRootPath } from './utils/getRootPath.ts'; @@ -84,25 +86,40 @@ 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.*'], -}); +// The generator only sees the pages, so this reads the source to catch a deprecated export that +// the skill would still list as current. +const deprecatedExports: string[] = []; -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` - ); +for (const name of publicExports) { + 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`); + + if (readDeprecation(await fs.readFile(sourceFilePath, 'utf8')) != null) { + deprecatedExports.push(name); + } } +assert.deepEqual( + readDeprecatedNames(skill).toSorted(), + deprecatedExports, + 'the Deprecated section must list exactly the exports whose JSDoc is @deprecated — run `yarn docs:gen `' +); + // 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 { From dfa9a28e5519947c12d7bf0ea6c8cf6610ff8158 Mon Sep 17 00:00:00 2001 From: hyesungoh Date: Wed, 30 Sep 2026 11:29:31 +0900 Subject: [PATCH 2/3] feat(useCallbackOnce): rename useCallbackOncePerRender to useCallbackOnce useCallbackOncePerRender runs the callback once until deps change, not once per render. Add useCallbackOnce with the same signature and behavior, and keep useCallbackOncePerRender as a deprecated alias of it. The JSDoc now describes the actual behavior, types the callback and the returned function from F, and lists userId in the second example's dependencies because the returned function keeps the same reference. --- .changeset/callback-once.md | 5 ++ .../plugin/skills/react-simplikit/SKILL.md | 10 ++- .../references/useCallbackOnce.md | 66 +++++++++++++++++++ .../references/useCallbackOncePerRender.md | 22 ++++--- .../useCallbackOnce/es/useCallbackOnce.md | 66 +++++++++++++++++++ .../src/hooks/useCallbackOnce/index.ts | 1 + .../useCallbackOnce/ja/useCallbackOnce.md | 66 +++++++++++++++++++ .../useCallbackOnce/ko/useCallbackOnce.md | 65 ++++++++++++++++++ .../hooks/useCallbackOnce/useCallbackOnce.md | 66 +++++++++++++++++++ .../useCallbackOnce.test.ts} | 24 +++---- .../hooks/useCallbackOnce/useCallbackOnce.ts | 63 ++++++++++++++++++ .../zh-Hans/useCallbackOnce.md | 65 ++++++++++++++++++ .../es/useCallbackOncePerRender.md | 22 ++++--- .../ja/useCallbackOncePerRender.md | 20 +++--- .../ko/useCallbackOncePerRender.md | 22 ++++--- .../useCallbackOncePerRender.md | 22 ++++--- .../useCallbackOncePerRender.ts | 40 +++-------- .../zh-Hans/useCallbackOncePerRender.md | 22 ++++--- packages/react-simplikit/src/index.ts | 1 + 19 files changed, 572 insertions(+), 96 deletions(-) create mode 100644 .changeset/callback-once.md create mode 100644 packages/plugin/skills/react-simplikit/references/useCallbackOnce.md create mode 100644 packages/react-simplikit/src/hooks/useCallbackOnce/es/useCallbackOnce.md create mode 100644 packages/react-simplikit/src/hooks/useCallbackOnce/index.ts create mode 100644 packages/react-simplikit/src/hooks/useCallbackOnce/ja/useCallbackOnce.md create mode 100644 packages/react-simplikit/src/hooks/useCallbackOnce/ko/useCallbackOnce.md create mode 100644 packages/react-simplikit/src/hooks/useCallbackOnce/useCallbackOnce.md rename packages/react-simplikit/src/hooks/{useCallbackOncePerRender/useCallbackOncePerRender.spec.ts => useCallbackOnce/useCallbackOnce.test.ts} (69%) create mode 100644 packages/react-simplikit/src/hooks/useCallbackOnce/useCallbackOnce.ts create mode 100644 packages/react-simplikit/src/hooks/useCallbackOnce/zh-Hans/useCallbackOnce.md 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/packages/plugin/skills/react-simplikit/SKILL.md b/packages/plugin/skills/react-simplikit/SKILL.md index 0004a1fd..93a540fc 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`](references/useCallbackOncePerRender.md) | 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..d071ccaf --- /dev/null +++ b/packages/plugin/skills/react-simplikit/references/useCallbackOnce.md @@ -0,0 +1,66 @@ +# 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. + +## 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 index 00af226b..b708d523 100644 --- a/packages/plugin/skills/react-simplikit/references/useCallbackOncePerRender.md +++ b/packages/plugin/skills/react-simplikit/references/useCallbackOncePerRender.md @@ -1,15 +1,19 @@ # useCallbackOncePerRender -`useCallbackOncePerRender` is a React hook that ensures a callback function is executed only once, regardless of how many times it's called. +::: warning Deprecated +Use `useCallbackOnce` instead. +::: + +`useCallbackOncePerRender` 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. ## Interface ```ts function useCallbackOncePerRender void>( - callback: () => void, + callback: F, deps: DependencyList -): (...args: any[]) => void; +): (...args: Parameters) => void; ``` ### Parameters @@ -17,23 +21,23 @@ function useCallbackOncePerRender void>( ### Return Value ## Example @@ -59,7 +63,7 @@ function TrackingComponent({ userId }: { userId: string }) { useEffect(() => { trackUserVisit(); - }, [trackUserVisit]); + }, [trackUserVisit, userId]); 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..5895df97 --- /dev/null +++ b/packages/react-simplikit/src/hooks/useCallbackOnce/es/useCallbackOnce.md @@ -0,0 +1,66 @@ +# 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. + +## 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/useCallbackOnce/index.ts b/packages/react-simplikit/src/hooks/useCallbackOnce/index.ts new file mode 100644 index 00000000..0aed0c4e --- /dev/null +++ b/packages/react-simplikit/src/hooks/useCallbackOnce/index.ts @@ -0,0 +1 @@ +export { useCallbackOnce } from './useCallbackOnce.ts'; diff --git a/packages/react-simplikit/src/hooks/useCallbackOnce/ja/useCallbackOnce.md b/packages/react-simplikit/src/hooks/useCallbackOnce/ja/useCallbackOnce.md new file mode 100644 index 00000000..2715b2f1 --- /dev/null +++ b/packages/react-simplikit/src/hooks/useCallbackOnce/ja/useCallbackOnce.md @@ -0,0 +1,66 @@ +# useCallbackOnce + +`useCallbackOnce` は、返された関数が何度呼び出されても、`deps` が変わるまではコールバック関数を一度だけ実行する React フックです。 +コンポーネントが再レンダリングされても繰り返すべきでない、一度限りの処理に便利です。 + +## インターフェース + +```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 +// 依存関係を指定する場合 +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/useCallbackOnce/ko/useCallbackOnce.md b/packages/react-simplikit/src/hooks/useCallbackOnce/ko/useCallbackOnce.md new file mode 100644 index 00000000..c47d200e --- /dev/null +++ b/packages/react-simplikit/src/hooks/useCallbackOnce/ko/useCallbackOnce.md @@ -0,0 +1,65 @@ +# useCallbackOnce + +`deps`가 바뀌기 전까지는 반환된 함수를 몇 번 호출하든 콜백을 한 번만 실행하는 리액트 훅이에요. 이는 컴포넌트가 다시 렌더링되더라도 반복되지 말아야 하는 일회성 작업에 유용해요. + +## 인터페이스 + +```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..d071ccaf --- /dev/null +++ b/packages/react-simplikit/src/hooks/useCallbackOnce/useCallbackOnce.md @@ -0,0 +1,66 @@ +# 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. + +## 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 69% rename from packages/react-simplikit/src/hooks/useCallbackOncePerRender/useCallbackOncePerRender.spec.ts rename to packages/react-simplikit/src/hooks/useCallbackOnce/useCallbackOnce.test.ts index 102feb6c..dacb6f5a 100644 --- a/packages/react-simplikit/src/hooks/useCallbackOncePerRender/useCallbackOncePerRender.spec.ts +++ b/packages/react-simplikit/src/hooks/useCallbackOnce/useCallbackOnce.test.ts @@ -3,8 +3,9 @@ import { DependencyList, useEffect } from 'react'; import { describe, expect, it, vi } from 'vitest'; import { renderHookSSR } from '../../_internal/test-utils/renderHookSSR.tsx'; +import { useCallbackOncePerRender } from '../useCallbackOncePerRender/useCallbackOncePerRender.ts'; -import { useCallbackOncePerRender } from './useCallbackOncePerRender.ts'; +import { useCallbackOnce } from './useCallbackOnce.ts'; function useCaller(callback: (...args: any) => any, deps: DependencyList) { useEffect(() => { @@ -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/useCallbackOnce/useCallbackOnce.ts b/packages/react-simplikit/src/hooks/useCallbackOnce/useCallbackOnce.ts new file mode 100644 index 00000000..bd255258 --- /dev/null +++ b/packages/react-simplikit/src/hooks/useCallbackOnce/useCallbackOnce.ts @@ -0,0 +1,63 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { DependencyList, useEffect, useRef } from 'react'; + +import { usePreservedCallback } from '../usePreservedCallback/index.ts'; + +/** + * @description + * `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. + * + * @template {(...args: any[]) => void} F - The type of the callback function. + * @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: Parameters) => void} A function whose reference never changes. It runs the callback only once until `deps` change. + * + * @example + * import { useCallbackOnce } from 'react-simplikit'; + * + * function Component() { + * const handleOneTimeEvent = useCallbackOnce(() => { + * console.log('This will only run once'); + * }, []); + * + * return ; + * } + * + * @example + * // With dependencies + * function TrackingComponent({ userId }: { userId: string }) { + * const trackUserVisit = useCallbackOnce(() => { + * analytics.trackVisit(userId); + * }, [userId]); + * + * useEffect(() => { + * trackUserVisit(); + * }, [trackUserVisit, userId]); + * + * return
User page
; + * } + */ +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`. + 'use no memo'; + + const hasFired = useRef(false); + + useEffect(() => { + hasFired.current = false; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, deps); + + return usePreservedCallback((...args: Parameters) => { + if (hasFired.current) { + return; + } + + callback(...args); + hasFired.current = true; + }); +} 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..10923d01 --- /dev/null +++ b/packages/react-simplikit/src/hooks/useCallbackOnce/zh-Hans/useCallbackOnce.md @@ -0,0 +1,65 @@ +# useCallbackOnce + +`useCallbackOnce` 是一个 React Hook,在 `deps` 变化之前,无论返回的函数被调用多少次,它都只会执行一次回调函数。它适用于那些即使组件重复渲染也不应重复执行的一次性操作。 + +## 接口 + +```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 index 21699e92..8e0bd417 100644 --- a/packages/react-simplikit/src/hooks/useCallbackOncePerRender/es/useCallbackOncePerRender.md +++ b/packages/react-simplikit/src/hooks/useCallbackOncePerRender/es/useCallbackOncePerRender.md @@ -1,15 +1,19 @@ # useCallbackOncePerRender -`useCallbackOncePerRender` es un Hook de React que garantiza que un callback se ejecute una sola vez, sin importar cuántas veces lo llames. +::: warning Obsoleto +Usa `useCallbackOnce` en su lugar. +::: + +`useCallbackOncePerRender` 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. ## Interfaz ```ts function useCallbackOncePerRender void>( - callback: () => void, + callback: F, deps: DependencyList -): (...args: any[]) => void; +): (...args: Parameters) => void; ``` ### Parámetros @@ -17,23 +21,23 @@ function useCallbackOncePerRender void>( ### Valor de retorno ## Ejemplo @@ -59,7 +63,7 @@ function TrackingComponent({ userId }: { userId: string }) { useEffect(() => { trackUserVisit(); - }, [trackUserVisit]); + }, [trackUserVisit, userId]); return
Página del usuario
; } diff --git a/packages/react-simplikit/src/hooks/useCallbackOncePerRender/ja/useCallbackOncePerRender.md b/packages/react-simplikit/src/hooks/useCallbackOncePerRender/ja/useCallbackOncePerRender.md index ef860a4c..38fc9124 100644 --- a/packages/react-simplikit/src/hooks/useCallbackOncePerRender/ja/useCallbackOncePerRender.md +++ b/packages/react-simplikit/src/hooks/useCallbackOncePerRender/ja/useCallbackOncePerRender.md @@ -1,15 +1,19 @@ # useCallbackOncePerRender -`useCallbackOncePerRender` は、何度呼び出されてもコールバック関数を一度だけ実行する React フックです。 +::: warning 非推奨 +代わりに `useCallbackOnce` を使用してください。 +::: + +`useCallbackOncePerRender` は、返された関数が何度呼び出されても、`deps` が変わるまではコールバック関数を一度だけ実行する React フックです。 コンポーネントが再レンダリングされても繰り返すべきでない、一度限りの処理に便利です。 ## インターフェース ```ts function useCallbackOncePerRender void>( - callback: () => void, + callback: F, deps: DependencyList -): (...args: any[]) => void; +): (...args: Parameters) => void; ``` ### パラメータ @@ -17,8 +21,8 @@ function useCallbackOncePerRender void>( void>( ## 使用例 @@ -59,7 +63,7 @@ function TrackingComponent({ userId }: { userId: string }) { useEffect(() => { trackUserVisit(); - }, [trackUserVisit]); + }, [trackUserVisit, userId]); return
User page
; } diff --git a/packages/react-simplikit/src/hooks/useCallbackOncePerRender/ko/useCallbackOncePerRender.md b/packages/react-simplikit/src/hooks/useCallbackOncePerRender/ko/useCallbackOncePerRender.md index 57eaec16..7742c4cf 100644 --- a/packages/react-simplikit/src/hooks/useCallbackOncePerRender/ko/useCallbackOncePerRender.md +++ b/packages/react-simplikit/src/hooks/useCallbackOncePerRender/ko/useCallbackOncePerRender.md @@ -1,14 +1,18 @@ # useCallbackOncePerRender -리액트 훅으로, 콜백 함수가 한 번만 실행되도록 보장해요. 몇 번 호출되든 간에 단 한 번만 실행돼요. 이는 컴포넌트가 다시 렌더링되더라도 반복되지 말아야 하는 일회성 작업에 유용해요. +::: warning 더 이상 권장하지 않음 +대신 `useCallbackOnce` 훅을 사용하세요. +::: + +`deps`가 바뀌기 전까지는 반환된 함수를 몇 번 호출하든 콜백을 한 번만 실행하는 리액트 훅이에요. 이는 컴포넌트가 다시 렌더링되더라도 반복되지 말아야 하는 일회성 작업에 유용해요. ## 인터페이스 ```ts function useCallbackOncePerRender void>( - callback: () => void, + callback: F, deps: DependencyList -): (...args: any[]) => void; +): (...args: Parameters) => void; ``` ### 파라미터 @@ -16,23 +20,23 @@ function useCallbackOncePerRender void>( ### 반환 값 ## 예시 @@ -58,7 +62,7 @@ function TrackingComponent({ userId }: { userId: string }) { useEffect(() => { trackUserVisit(); - }, [trackUserVisit]); + }, [trackUserVisit, userId]); return
사용자 페이지
; } diff --git a/packages/react-simplikit/src/hooks/useCallbackOncePerRender/useCallbackOncePerRender.md b/packages/react-simplikit/src/hooks/useCallbackOncePerRender/useCallbackOncePerRender.md index 00af226b..b708d523 100644 --- a/packages/react-simplikit/src/hooks/useCallbackOncePerRender/useCallbackOncePerRender.md +++ b/packages/react-simplikit/src/hooks/useCallbackOncePerRender/useCallbackOncePerRender.md @@ -1,15 +1,19 @@ # useCallbackOncePerRender -`useCallbackOncePerRender` is a React hook that ensures a callback function is executed only once, regardless of how many times it's called. +::: warning Deprecated +Use `useCallbackOnce` instead. +::: + +`useCallbackOncePerRender` 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. ## Interface ```ts function useCallbackOncePerRender void>( - callback: () => void, + callback: F, deps: DependencyList -): (...args: any[]) => void; +): (...args: Parameters) => void; ``` ### Parameters @@ -17,23 +21,23 @@ function useCallbackOncePerRender void>( ### Return Value ## Example @@ -59,7 +63,7 @@ function TrackingComponent({ userId }: { userId: string }) { useEffect(() => { trackUserVisit(); - }, [trackUserVisit]); + }, [trackUserVisit, userId]); return
User page
; } diff --git a/packages/react-simplikit/src/hooks/useCallbackOncePerRender/useCallbackOncePerRender.ts b/packages/react-simplikit/src/hooks/useCallbackOncePerRender/useCallbackOncePerRender.ts index ded97fc9..037b9a73 100644 --- a/packages/react-simplikit/src/hooks/useCallbackOncePerRender/useCallbackOncePerRender.ts +++ b/packages/react-simplikit/src/hooks/useCallbackOncePerRender/useCallbackOncePerRender.ts @@ -1,18 +1,17 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -import { DependencyList, useEffect, useRef } from 'react'; - -import { usePreservedCallback } from '../usePreservedCallback/index.ts'; +import { useCallbackOnce } from '../useCallbackOnce/index.ts'; /** + * @deprecated Use `useCallbackOnce` instead. + * * @description - * `useCallbackOncePerRender` is a React hook that ensures a callback function is executed only once, regardless of how many times it's called. + * `useCallbackOncePerRender` 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. * * @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'; @@ -34,30 +33,9 @@ import { usePreservedCallback } from '../usePreservedCallback/index.ts'; * * useEffect(() => { * trackUserVisit(); - * }, [trackUserVisit]); + * }, [trackUserVisit, userId]); * * return
User page
; * } */ -export function useCallbackOncePerRender 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`. - 'use no memo'; - - const hasFired = useRef(false); - - useEffect(() => { - hasFired.current = false; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, deps); - - return usePreservedCallback((...args: Parameters) => { - if (hasFired.current) { - return; - } - - callback(...args); - hasFired.current = true; - }); -} +export const useCallbackOncePerRender = useCallbackOnce; diff --git a/packages/react-simplikit/src/hooks/useCallbackOncePerRender/zh-Hans/useCallbackOncePerRender.md b/packages/react-simplikit/src/hooks/useCallbackOncePerRender/zh-Hans/useCallbackOncePerRender.md index 9324d643..f0612a85 100644 --- a/packages/react-simplikit/src/hooks/useCallbackOncePerRender/zh-Hans/useCallbackOncePerRender.md +++ b/packages/react-simplikit/src/hooks/useCallbackOncePerRender/zh-Hans/useCallbackOncePerRender.md @@ -1,14 +1,18 @@ # useCallbackOncePerRender -`useCallbackOncePerRender` 是一个确保回调函数无论被调用多少次都只执行一次的 React Hook。它适用于那些即使组件重复渲染也不应重复执行的一次性操作。 +::: warning 已弃用 +请改用 `useCallbackOnce`。 +::: + +`useCallbackOncePerRender` 是一个 React Hook,在 `deps` 变化之前,无论返回的函数被调用多少次,它都只会执行一次回调函数。它适用于那些即使组件重复渲染也不应重复执行的一次性操作。 ## 接口 ```ts function useCallbackOncePerRender void>( - callback: () => void, + callback: F, deps: DependencyList -): (...args: any[]) => void; +): (...args: Parameters) => void; ``` ### 参数 @@ -16,23 +20,23 @@ function useCallbackOncePerRender void>( ### 返回值 ## 示例 @@ -58,7 +62,7 @@ function TrackingComponent({ userId }: { userId: string }) { useEffect(() => { trackUserVisit(); - }, [trackUserVisit]); + }, [trackUserVisit, userId]); return
User page
; } diff --git a/packages/react-simplikit/src/index.ts b/packages/react-simplikit/src/index.ts index b0fc24fd..7367bed9 100644 --- a/packages/react-simplikit/src/index.ts +++ b/packages/react-simplikit/src/index.ts @@ -5,6 +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 { useCallbackOnce } from './hooks/useCallbackOnce/index.ts'; export { useCallbackOncePerRender } from './hooks/useCallbackOncePerRender/index.ts'; export { useConditionalEffect } from './hooks/useConditionalEffect/index.ts'; export { useControlledState } from './hooks/useControlledState/index.ts'; From d1b0681cf9e948ab9b4c8e6d916f1d19dff9f309 Mon Sep 17 00:00:00 2001 From: hyesungoh Date: Wed, 7 Oct 2026 17:06:21 +0900 Subject: [PATCH 3/3] refactor(useCallbackOnce): drop the documentation page of the deprecated alias A deprecated alias no longer has a documentation page. useCallbackOncePerRender moves into the useCallbackOnce folder as a one-line alias, its pages are removed, and the useCallbackOnce page says it was renamed. The docs and skill tooling now expects a deprecated export to have no page, lists it in the skill's Deprecated table without a link, and the old URL redirects to the new page. --- .scripts/commands/generateDocs/index.spec.ts | 131 +++------------ .scripts/commands/generateDocs/index.ts | 149 +++++++----------- .../commands/generateSkill/catalog.spec.ts | 66 +++----- .scripts/commands/generateSkill/catalog.ts | 67 ++++---- .scripts/commands/generateSkill/index.spec.ts | 111 ++++++------- .scripts/commands/generateSkill/index.ts | 46 ++++-- .scripts/utils/assertLlmsOutput.ts | 18 ++- .../utils/collectDeprecatedExports.spec.ts | 46 ++++++ .scripts/utils/collectDeprecatedExports.ts | 30 ++++ .scripts/utils/jsdoc.spec.ts | 69 ++++++++ .scripts/utils/jsdoc.ts | 52 ++++++ .scripts/verifyDocs.ts | 28 ++++ .scripts/verifyDocsI18n.ts | 30 +++- .scripts/verifySkill.ts | 44 ++---- .vitepress/libs/legacyRedirects.mts | 9 +- .../plugin/skills/react-simplikit/SKILL.md | 2 +- .../references/useCallbackOnce.md | 1 + .../references/useCallbackOncePerRender.md | 70 -------- .../useCallbackOnce/es/useCallbackOnce.md | 1 + .../src/hooks/useCallbackOnce/index.ts | 1 + .../useCallbackOnce/ja/useCallbackOnce.md | 1 + .../useCallbackOnce/ko/useCallbackOnce.md | 2 +- .../hooks/useCallbackOnce/useCallbackOnce.md | 1 + .../useCallbackOnce/useCallbackOnce.test.ts | 2 +- .../hooks/useCallbackOnce/useCallbackOnce.ts | 1 + .../useCallbackOncePerRender.ts | 9 ++ .../zh-Hans/useCallbackOnce.md | 2 +- .../es/useCallbackOncePerRender.md | 70 -------- .../hooks/useCallbackOncePerRender/index.ts | 1 - .../ja/useCallbackOncePerRender.md | 70 -------- .../ko/useCallbackOncePerRender.md | 69 -------- .../useCallbackOncePerRender.md | 70 -------- .../useCallbackOncePerRender.ts | 41 ----- .../zh-Hans/useCallbackOncePerRender.md | 69 -------- packages/react-simplikit/src/index.ts | 3 +- 35 files changed, 533 insertions(+), 849 deletions(-) create mode 100644 .scripts/utils/collectDeprecatedExports.spec.ts create mode 100644 .scripts/utils/collectDeprecatedExports.ts create mode 100644 .scripts/utils/jsdoc.spec.ts create mode 100644 .scripts/utils/jsdoc.ts delete mode 100644 packages/plugin/skills/react-simplikit/references/useCallbackOncePerRender.md create mode 100644 packages/react-simplikit/src/hooks/useCallbackOnce/useCallbackOncePerRender.ts delete mode 100644 packages/react-simplikit/src/hooks/useCallbackOncePerRender/es/useCallbackOncePerRender.md delete mode 100644 packages/react-simplikit/src/hooks/useCallbackOncePerRender/index.ts delete mode 100644 packages/react-simplikit/src/hooks/useCallbackOncePerRender/ja/useCallbackOncePerRender.md delete mode 100644 packages/react-simplikit/src/hooks/useCallbackOncePerRender/ko/useCallbackOncePerRender.md delete mode 100644 packages/react-simplikit/src/hooks/useCallbackOncePerRender/useCallbackOncePerRender.md delete mode 100644 packages/react-simplikit/src/hooks/useCallbackOncePerRender/useCallbackOncePerRender.ts delete mode 100644 packages/react-simplikit/src/hooks/useCallbackOncePerRender/zh-Hans/useCallbackOncePerRender.md diff --git a/.scripts/commands/generateDocs/index.spec.ts b/.scripts/commands/generateDocs/index.spec.ts index 123316d7..f0d2b5fc 100644 --- a/.scripts/commands/generateDocs/index.spec.ts +++ b/.scripts/commands/generateDocs/index.spec.ts @@ -4,7 +4,7 @@ import path from 'node:path'; import { describe, expect, it } from 'vitest'; import { compileTemplate } from 'vue/compiler-sfc'; -import { readDeprecation, renderEnglishDoc } from './index.ts'; +import { renderEnglishDoc } from './index.ts'; async function render(name: string, source: string) { const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'generate-docs-')); @@ -678,58 +678,30 @@ export function useNoReturnValue() {}` expect(document).toContain('\nThis function does not return anything.'); }); - it('renders @deprecated as a warning container between the heading and the description', async () => { - const document = await render( - 'useOld', - `/** - * @deprecated Use \`useNew\` instead. - * + it('refuses an export whose JSDoc is @deprecated, because it has no page', async () => { + await expect( + render( + 'useOld', + `/** * @description - * \`useOld\` does something. + * \`useOld\` is the name \`useNew\` had before. * - * @returns {void} - * - * @example - * useOld(); + * @deprecated Use \`useNew\` instead. */ -export function useOld() {}` - ); - - expect( - document.startsWith( - '# useOld\n\n::: warning Deprecated\nUse `useNew` instead.\n:::\n\n`useOld` does something.\n' +export const useOld = useNew;` ) - ).toBe(true); - }); - - it('keeps the line breaks of a wrapped @deprecated text inside the container', async () => { - const document = await render( - 'useOld', - `/** - * @description - * \`useOld\` does something. - * - * @returns {void} - * - * @example - * useOld(); - * - * @deprecated - * Use \`useNew\` instead. - * It will be removed in the next major version. - */ -export function useOld() {}` - ); - - expect(document).toContain( - '::: warning Deprecated\nUse `useNew` instead.\nIt will be removed in the next major version.\n:::' - ); + ).rejects.toThrow('useOld is @deprecated, and a deprecated export has no documentation page'); }); - it('renders no container when there is no @deprecated', async () => { + 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. * @@ -738,76 +710,9 @@ export function useOld() {}` * @example * useCurrent(); */ -export function useCurrent() {}` +export function useCurrent(options: Options) {}` ); expect(document.startsWith('# useCurrent\n\n`useCurrent` does something.\n')).toBe(true); - expect(document).not.toContain(':::'); - }); -}); - -describe('readDeprecation', () => { - it('reads @deprecated from the comment the page is generated from', () => { - 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 page 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/commands/generateDocs/index.ts b/.scripts/commands/generateDocs/index.ts index cf701243..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', 'deprecated']); -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')); @@ -105,7 +118,6 @@ function parseJSDoc(source: string) { preservedComment?.tags.find(tag => tag.tag === 'description')?.description ?? preservedComment?.description ?? '' ); const remarks = trimBlock(preservedComment?.tags.find(tag => tag.tag === 'remarks')?.description ?? ''); - const deprecation = readDeprecation(source); const params = targetComment.tags.filter(tag => tag.tag === 'param'); @@ -143,7 +155,6 @@ function parseJSDoc(source: string) { return { description, remarks, - deprecation, templates, examples, params, @@ -155,32 +166,6 @@ function parseJSDoc(source: string) { }; } -/** - * The `@deprecated` text of the comment a page is generated from (the last one in the file), or - * `undefined` when there is none. `verifySkill.ts` reads it too, so a `@deprecated` on another - * comment in the file, such as an options type, does not mark the export itself. - */ -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; -} - /** JSDoc's own caption syntax; the title renders as a heading above the example's code block. */ const EXAMPLE_CAPTION = /^(.*?)<\/caption>\s*/; @@ -242,7 +227,7 @@ function endSentence(description: string) { } async function jsdocToMd(name: string, jsdoc: ReturnType) { - const { templates, description, remarks, deprecation, examples, params, returns, nestedValueOfReturns } = jsdoc; + const { templates, description, remarks, examples, params, returns, nestedValueOfReturns } = jsdoc; const paramsProps = params.reduce>( (acc, param) => { @@ -279,11 +264,9 @@ async function jsdocToMd(name: string, jsdoc: ReturnType) { return `${rest}${param.name}${optional}: ${type}${param.default == null ? '' : ` = ${param.default}`}`; }); - // The notice sits above the description so the page opens with it. `extractDescription` skips it, - // and the skill catalog reads it back to list the export as deprecated. return `# ${name} -${deprecation == null ? '' : `::: warning Deprecated\n${deprecation}\n:::\n\n`}${description} +${description} ## Interface @@ -314,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 1589d140..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 { extractDeprecation, extractDescription, getCategory, readDeprecatedNames, renderSkill } from './catalog.ts'; +import { extractDescription, getCategory, readCatalogNames, readDeprecatedNames, renderSkill } from './catalog.ts'; describe('getCategory', () => { it('derives the category from the export source path', () => { @@ -43,42 +43,12 @@ into one. assert.equal(extractDescription(markdown, 'useX'), 'A React hook that manages a Set as state'); }); - it('skips the deprecation notice above the paragraph, whatever its title', () => { - const english = `# useOld\n\n::: warning Deprecated\nUse \`useNew\` instead.\n:::\n\n\`useOld\` does something. More.\n`; - const korean = `# useOld\n\n::: warning 더 이상 권장하지 않음\n대신 \`useNew\` 훅을 사용하세요.\n:::\n\n무언가를 하는 훅이에요. 더.\n`; - - assert.equal(extractDescription(english, 'useOld'), '`useOld` does something.'); - assert.equal(extractDescription(korean, 'useOld'), '무언가를 하는 훅이에요.'); - }); - it('rejects a page that does not open with a paragraph', () => { assert.throws( () => extractDescription('# useX\n\n## Interface\n', 'useX'), /must open with a description paragraph/ ); assert.throws(() => extractDescription('# useX\n', 'useX'), /must open with a description paragraph/); - assert.throws( - () => extractDescription('# useX\n\n::: warning Deprecated\nUse `useY` instead.\n:::\n', 'useX'), - /must open with a description paragraph/ - ); - }); -}); - -describe('extractDeprecation', () => { - it('returns the notice under the heading on one line', () => { - const markdown = `# useOld\n\n::: warning Deprecated\nUse \`useNew\` instead.\nIt goes away\nlater.\n:::\n\n\`useOld\` does something.\n`; - - assert.equal(extractDeprecation(markdown), 'Use `useNew` instead. It goes away later.'); - }); - - it('returns undefined for a page without the notice under its heading', () => { - assert.equal(extractDeprecation('# useX\n\n`useX` does something.\n'), undefined); - assert.equal( - extractDeprecation( - '# useX\n\n`useX` does something.\n\n## Notes\n\n::: warning Deprecated\nThe `wait` option.\n:::\n' - ), - undefined - ); }); }); @@ -90,6 +60,7 @@ describe('renderSkill', () => { { name: 'isIOS', category: 'utils', description: 'Detects iOS.' }, { name: 'useToggle', category: 'hooks', description: 'Toggles a | boolean.' }, ], + deprecatedEntries: [], }); assert.equal( @@ -115,13 +86,11 @@ describe('renderSkill', () => { ); }); - it('lists a deprecated entry under Deprecated, not under its category', () => { + 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.' }, - { name: 'useOld', category: 'hooks', description: 'Does it.', deprecation: 'Use `useNew` | not this.' }, - ], + entries: [{ name: 'useNew', category: 'hooks', description: 'Does it.' }], + deprecatedEntries: [{ name: 'useOld', notice: 'Use `useNew` | not this.\nIt goes away\nlater.' }], }); assert.equal( @@ -142,7 +111,7 @@ These still work but are kept only for backward compatibility. Do not use them i | Name | Notice | | --- | --- | -| [\`useOld\`](references/useOld.md) | Use \`useNew\` \\| not this. | +| \`useOld\` | Use \`useNew\` \\| not this. It goes away later. | ## Learn more ` @@ -150,19 +119,35 @@ These still work but are kept only for backward compatibility. Do not use them i }); 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| Toggle | `useToggle` |\n\n\n', + 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.' }, - { name: 'useOld', category: 'hooks', description: 'Does it.', deprecation: 'Use `useNew` instead.' }, ], + deprecatedEntries: [{ name: 'useOld', notice: 'Use `useNew` instead.' }], }); assert.deepEqual(readDeprecatedNames(skill), ['useOld']); @@ -172,6 +157,7 @@ describe('readDeprecatedNames', () => { 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 9a40769d..07ef78db 100644 --- a/.scripts/commands/generateSkill/catalog.ts +++ b/.scripts/commands/generateSkill/catalog.ts @@ -6,13 +6,18 @@ export type CatalogEntry = { name: string; category: Category; description: string; - /** The page's deprecation notice; a deprecated entry is listed under Deprecated instead of its category. */ - deprecation?: 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 = ''; @@ -21,13 +26,7 @@ const DEPRECATED_HEADING = '### Deprecated'; const CATALOG_ROW = /^\| \[`([^`]+)`\]\(references\/\1\.md\) \| .+ \|$/gm; -// A translated page carries the notice under its own title, so any container directly under the -// heading is skipped, not only the English one. -const LEADING_CONTAINER = /^:::[^\n]*\n[\s\S]*?\n:::(?:\n|$)/; - -// Only where `docs:gen` writes it: a `::: warning Deprecated` further down, in the Notes for -// example, says something about the page, not that the export is deprecated. -const DEPRECATION_NOTICE = /^# [^\n]*\n\n::: warning Deprecated\n([\s\S]*?)\n:::(?:\n|$)/; +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 { @@ -43,15 +42,10 @@ export function getCategory(sourcePath: string): Category { /** * First sentence of a documentation page's opening paragraph, which `docs:gen` writes from the - * JSDoc `@description`. A period inside backticks (`options.leading`) does not end the sentence, - * and a deprecation notice above the paragraph is not part of it. + * JSDoc `@description`. A period inside backticks (`options.leading`) does not end the sentence. */ export function extractDescription(markdown: string, name: string): string { - const body = markdown - .replace(/^# .*\n/, '') - .trimStart() - .replace(LEADING_CONTAINER, '') - .trimStart(); + const body = markdown.replace(/^# .*\n/, '').trimStart(); const paragraph = body .split(/\n\s*\n/)[0] .replace(/\n/g, ' ') @@ -84,28 +78,19 @@ export function extractDescription(markdown: string, name: string): string { return paragraph; } -/** - * Text of the deprecation notice `docs:gen` writes under a page's heading from the JSDoc - * `@deprecated`, on one line so it fits a table cell; `undefined` when the page has none. - */ -export function extractDeprecation(markdown: string): string | undefined { - return DEPRECATION_NOTICE.exec(markdown)?.[1] - .trim() - .replace(/\s*\n\s*/g, ' '); -} - /** * Fills the template's `` with one table per category, then a Deprecated table - * when an entry is deprecated. Categories keep the order of `CATEGORIES`; rows keep the order - * they are given (sorted by name upstream). + * 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}`); } const sections = CATEGORIES.flatMap(category => { - const rows = entries.filter(entry => entry.category === category && entry.deprecation == null); + const rows = entries.filter(entry => entry.category === category); if (rows.length === 0) { return []; @@ -123,11 +108,8 @@ export function renderSkill({ template, entries }: RenderSkillOptions): string { ]; }); - const deprecatedRows = entries.flatMap(({ name, deprecation }) => - deprecation == null ? [] : [`| [\`${name}\`](references/${name}.md) | ${escapeTableCell(deprecation)} |`] - ); const deprecatedSection = - deprecatedRows.length === 0 + deprecatedEntries.length === 0 ? [] : [ DEPRECATED_HEADING, @@ -136,16 +118,25 @@ export function renderSkill({ template, entries }: RenderSkillOptions): string { '', '| Name | Notice |', '| --- | --- |', - ...deprecatedRows, + // 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. Only catalog rows link `references/.md` from their first cell. + * 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`); @@ -154,7 +145,9 @@ export function readDeprecatedNames(skill: string): string[] { return []; } - return [...skill.slice(deprecatedStart).matchAll(CATALOG_ROW)].map(match => match[1]); + 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 95c39ccb..20c8e1d5 100644 --- a/.scripts/commands/generateSkill/index.spec.ts +++ b/.scripts/commands/generateSkill/index.spec.ts @@ -4,8 +4,6 @@ import os from 'node:os'; import path from 'node:path'; import { afterEach, describe, it } from 'vitest'; -import { renderEnglishDoc } from '../generateDocs/index.ts'; - import { generateSkill, PACKAGE_INDEX_FILE } from './index.ts'; const fixtureDirectories: string[] = []; @@ -81,43 +79,23 @@ 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, from the page docs:gen renders', async () => { - const [oldPage, newPage] = await Promise.all([ - renderFixtureDoc( - 'useOld', - `/** - * @deprecated Use \`useNew\` instead. - * - * @description - * \`useOld\` flips a boolean. - * - * @returns {void} - * - * @example - * useOld(); - */ -export function useOld() {}` - ), - renderFixtureDoc( - 'useNew', - `/** + 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 - * \`useNew\` flips a boolean. - * - * @returns {void} + * \`useOld\` is the name \`useNew\` had before. * - * @example - * useNew(); + * @deprecated Use \`useNew\` instead. */ -export function useNew() {}` - ), - ]); - const root = await writeFixtureRoot({ - index: ` -export { useNew } from './hooks/useNew/index.ts'; -export { useOld } from './hooks/useOld/index.ts'; +export const useOld = useNew; `, - pages: { 'hooks/useNew/useNew.md': newPage, 'hooks/useOld/useOld.md': oldPage }, + }, }); const outputDirectory = path.join(root, 'out'); @@ -130,17 +108,42 @@ export { useOld } from './hooks/useOld/index.ts'; ), true ); - assert.equal( - skill.includes('| Name | Notice |\n| --- | --- |\n| [`useOld`](references/useOld.md) | Use `useNew` instead. |'), - 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/ ); - assert.equal(await fs.readFile(path.join(outputDirectory, 'references', 'useOld.md'), 'utf8'), oldPage); }); 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( @@ -150,29 +153,29 @@ export { useOld } from './hooks/useOld/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; } - -async function renderFixtureDoc(name: string, source: string): Promise { - const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'react-simplikit-skill-doc-')); - fixtureDirectories.push(directory); - const sourceFilePath = path.join(directory, `${name}.ts`); - - await fs.writeFile(sourceFilePath, source); - - return renderEnglishDoc(name, sourceFilePath); -} diff --git a/.scripts/commands/generateSkill/index.ts b/.scripts/commands/generateSkill/index.ts index 511483f0..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, extractDeprecation, 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,8 +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. - * That is also why a deprecated export is recognised by the notice on its page, not by its source's JSDoc. + * 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(), @@ -34,25 +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), - deprecation: extractDeprecation(markdown), - }; + 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 57783603..9f40499a 100644 --- a/.scripts/verifySkill.ts +++ b/.scripts/verifySkill.ts @@ -4,9 +4,9 @@ import * as fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; -import { readDeprecation } from './commands/generateDocs/index.ts'; -import { readDeprecatedNames } from './commands/generateSkill/catalog.ts'; +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'; @@ -66,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]); +const deprecatedExports = await collectDeprecatedExports(root, publicExports); +const documentedExports = publicExports.filter(name => !deprecatedExports.includes(name)); -assert.deepEqual(catalogNames.toSorted(), publicExports, 'the catalog must list exactly the public exports'); +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 publicExports) { +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. @@ -86,30 +96,6 @@ for (const name of publicExports) { ); } -// The generator only sees the pages, so this reads the source to catch a deprecated export that -// the skill would still list as current. -const deprecatedExports: string[] = []; - -for (const name of publicExports) { - 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`); - - if (readDeprecation(await fs.readFile(sourceFilePath, 'utf8')) != null) { - deprecatedExports.push(name); - } -} - -assert.deepEqual( - readDeprecatedNames(skill).toSorted(), - deprecatedExports, - 'the Deprecated section must list exactly the exports whose JSDoc is @deprecated — run `yarn docs:gen `' -); - // 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')); 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 93a540fc..a4d6afd5 100644 --- a/packages/plugin/skills/react-simplikit/SKILL.md +++ b/packages/plugin/skills/react-simplikit/SKILL.md @@ -146,7 +146,7 @@ These still work but are kept only for backward compatibility. Do not use them i | Name | Notice | | --- | --- | -| [`useCallbackOncePerRender`](references/useCallbackOncePerRender.md) | Use `useCallbackOnce` instead. | +| `useCallbackOncePerRender` | Use `useCallbackOnce` instead. | ## Learn more diff --git a/packages/plugin/skills/react-simplikit/references/useCallbackOnce.md b/packages/plugin/skills/react-simplikit/references/useCallbackOnce.md index d071ccaf..b4c5b0d9 100644 --- a/packages/plugin/skills/react-simplikit/references/useCallbackOnce.md +++ b/packages/plugin/skills/react-simplikit/references/useCallbackOnce.md @@ -2,6 +2,7 @@ `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 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 b708d523..00000000 --- a/packages/plugin/skills/react-simplikit/references/useCallbackOncePerRender.md +++ /dev/null @@ -1,70 +0,0 @@ -# useCallbackOncePerRender - -::: warning Deprecated -Use `useCallbackOnce` instead. -::: - -`useCallbackOncePerRender` 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. - -## Interface - -```ts -function useCallbackOncePerRender void>( - callback: F, - deps: DependencyList -): (...args: Parameters) => 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, userId]); - - 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 index 5895df97..100d9d2d 100644 --- a/packages/react-simplikit/src/hooks/useCallbackOnce/es/useCallbackOnce.md +++ b/packages/react-simplikit/src/hooks/useCallbackOnce/es/useCallbackOnce.md @@ -2,6 +2,7 @@ `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 diff --git a/packages/react-simplikit/src/hooks/useCallbackOnce/index.ts b/packages/react-simplikit/src/hooks/useCallbackOnce/index.ts index 0aed0c4e..892bf361 100644 --- a/packages/react-simplikit/src/hooks/useCallbackOnce/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/useCallbackOnce/ja/useCallbackOnce.md b/packages/react-simplikit/src/hooks/useCallbackOnce/ja/useCallbackOnce.md index 2715b2f1..f2dc619c 100644 --- a/packages/react-simplikit/src/hooks/useCallbackOnce/ja/useCallbackOnce.md +++ b/packages/react-simplikit/src/hooks/useCallbackOnce/ja/useCallbackOnce.md @@ -2,6 +2,7 @@ `useCallbackOnce` は、返された関数が何度呼び出されても、`deps` が変わるまではコールバック関数を一度だけ実行する React フックです。 コンポーネントが再レンダリングされても繰り返すべきでない、一度限りの処理に便利です。 +v0 では、このフックは `useCallbackOncePerRender` という名前でした。以前の名前も引き続き動作しますが、非推奨です。 ## インターフェース diff --git a/packages/react-simplikit/src/hooks/useCallbackOnce/ko/useCallbackOnce.md b/packages/react-simplikit/src/hooks/useCallbackOnce/ko/useCallbackOnce.md index c47d200e..1ba0bdc2 100644 --- a/packages/react-simplikit/src/hooks/useCallbackOnce/ko/useCallbackOnce.md +++ b/packages/react-simplikit/src/hooks/useCallbackOnce/ko/useCallbackOnce.md @@ -1,6 +1,6 @@ # useCallbackOnce -`deps`가 바뀌기 전까지는 반환된 함수를 몇 번 호출하든 콜백을 한 번만 실행하는 리액트 훅이에요. 이는 컴포넌트가 다시 렌더링되더라도 반복되지 말아야 하는 일회성 작업에 유용해요. +`deps`가 바뀌기 전까지는 반환된 함수를 몇 번 호출하든 콜백을 한 번만 실행하는 리액트 훅이에요. 이는 컴포넌트가 다시 렌더링되더라도 반복되지 말아야 하는 일회성 작업에 유용해요. v0에서는 이 훅의 이름이 `useCallbackOncePerRender`였어요. 이전 이름도 계속 동작하지만 deprecated 상태예요. ## 인터페이스 diff --git a/packages/react-simplikit/src/hooks/useCallbackOnce/useCallbackOnce.md b/packages/react-simplikit/src/hooks/useCallbackOnce/useCallbackOnce.md index d071ccaf..b4c5b0d9 100644 --- a/packages/react-simplikit/src/hooks/useCallbackOnce/useCallbackOnce.md +++ b/packages/react-simplikit/src/hooks/useCallbackOnce/useCallbackOnce.md @@ -2,6 +2,7 @@ `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 diff --git a/packages/react-simplikit/src/hooks/useCallbackOnce/useCallbackOnce.test.ts b/packages/react-simplikit/src/hooks/useCallbackOnce/useCallbackOnce.test.ts index dacb6f5a..e3cf3507 100644 --- a/packages/react-simplikit/src/hooks/useCallbackOnce/useCallbackOnce.test.ts +++ b/packages/react-simplikit/src/hooks/useCallbackOnce/useCallbackOnce.test.ts @@ -3,9 +3,9 @@ import { DependencyList, useEffect } from 'react'; import { describe, expect, it, vi } from 'vitest'; import { renderHookSSR } from '../../_internal/test-utils/renderHookSSR.tsx'; -import { useCallbackOncePerRender } from '../useCallbackOncePerRender/useCallbackOncePerRender.ts'; import { useCallbackOnce } from './useCallbackOnce.ts'; +import { useCallbackOncePerRender } from './useCallbackOncePerRender.ts'; function useCaller(callback: (...args: any) => any, deps: DependencyList) { useEffect(() => { diff --git a/packages/react-simplikit/src/hooks/useCallbackOnce/useCallbackOnce.ts b/packages/react-simplikit/src/hooks/useCallbackOnce/useCallbackOnce.ts index bd255258..2fc02519 100644 --- a/packages/react-simplikit/src/hooks/useCallbackOnce/useCallbackOnce.ts +++ b/packages/react-simplikit/src/hooks/useCallbackOnce/useCallbackOnce.ts @@ -7,6 +7,7 @@ import { usePreservedCallback } from '../usePreservedCallback/index.ts'; * @description * `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 {F} callback - The callback function to be executed once. It receives the arguments passed to the returned function. 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 index 10923d01..1cd584dd 100644 --- a/packages/react-simplikit/src/hooks/useCallbackOnce/zh-Hans/useCallbackOnce.md +++ b/packages/react-simplikit/src/hooks/useCallbackOnce/zh-Hans/useCallbackOnce.md @@ -1,6 +1,6 @@ # useCallbackOnce -`useCallbackOnce` 是一个 React Hook,在 `deps` 变化之前,无论返回的函数被调用多少次,它都只会执行一次回调函数。它适用于那些即使组件重复渲染也不应重复执行的一次性操作。 +`useCallbackOnce` 是一个 React Hook,在 `deps` 变化之前,无论返回的函数被调用多少次,它都只会执行一次回调函数。它适用于那些即使组件重复渲染也不应重复执行的一次性操作。在 v0 中,这个 Hook 名为 `useCallbackOncePerRender`。旧名称仍然可用,但已弃用。 ## 接口 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 8e0bd417..00000000 --- a/packages/react-simplikit/src/hooks/useCallbackOncePerRender/es/useCallbackOncePerRender.md +++ /dev/null @@ -1,70 +0,0 @@ -# useCallbackOncePerRender - -::: warning Obsoleto -Usa `useCallbackOnce` en su lugar. -::: - -`useCallbackOncePerRender` 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. - -## Interfaz - -```ts -function useCallbackOncePerRender void>( - callback: F, - deps: DependencyList -): (...args: Parameters) => 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, userId]); - - return
Página del usuario
; -} -``` diff --git a/packages/react-simplikit/src/hooks/useCallbackOncePerRender/index.ts b/packages/react-simplikit/src/hooks/useCallbackOncePerRender/index.ts deleted file mode 100644 index c4f884e5..00000000 --- a/packages/react-simplikit/src/hooks/useCallbackOncePerRender/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { useCallbackOncePerRender } from './useCallbackOncePerRender.ts'; diff --git a/packages/react-simplikit/src/hooks/useCallbackOncePerRender/ja/useCallbackOncePerRender.md b/packages/react-simplikit/src/hooks/useCallbackOncePerRender/ja/useCallbackOncePerRender.md deleted file mode 100644 index 38fc9124..00000000 --- a/packages/react-simplikit/src/hooks/useCallbackOncePerRender/ja/useCallbackOncePerRender.md +++ /dev/null @@ -1,70 +0,0 @@ -# useCallbackOncePerRender - -::: warning 非推奨 -代わりに `useCallbackOnce` を使用してください。 -::: - -`useCallbackOncePerRender` は、返された関数が何度呼び出されても、`deps` が変わるまではコールバック関数を一度だけ実行する React フックです。 -コンポーネントが再レンダリングされても繰り返すべきでない、一度限りの処理に便利です。 - -## インターフェース - -```ts -function useCallbackOncePerRender void>( - callback: F, - deps: DependencyList -): (...args: Parameters) => void; -``` - -### パラメータ - - - - - -### 戻り値 - - - -## 使用例 - -```tsx -import { useCallbackOncePerRender } from 'react-simplikit'; - -function Component() { - const handleOneTimeEvent = useCallbackOncePerRender(() => { - console.log('This will only run once'); - }, []); - - return ; -} -``` - -```tsx -// 依存関係を指定する場合 -function TrackingComponent({ userId }: { userId: string }) { - const trackUserVisit = useCallbackOncePerRender(() => { - analytics.trackVisit(userId); - }, [userId]); - - useEffect(() => { - trackUserVisit(); - }, [trackUserVisit, userId]); - - return
User page
; -} -``` 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 7742c4cf..00000000 --- a/packages/react-simplikit/src/hooks/useCallbackOncePerRender/ko/useCallbackOncePerRender.md +++ /dev/null @@ -1,69 +0,0 @@ -# useCallbackOncePerRender - -::: warning 더 이상 권장하지 않음 -대신 `useCallbackOnce` 훅을 사용하세요. -::: - -`deps`가 바뀌기 전까지는 반환된 함수를 몇 번 호출하든 콜백을 한 번만 실행하는 리액트 훅이에요. 이는 컴포넌트가 다시 렌더링되더라도 반복되지 말아야 하는 일회성 작업에 유용해요. - -## 인터페이스 - -```ts -function useCallbackOncePerRender void>( - callback: F, - deps: DependencyList -): (...args: Parameters) => 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, userId]); - - 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 b708d523..00000000 --- a/packages/react-simplikit/src/hooks/useCallbackOncePerRender/useCallbackOncePerRender.md +++ /dev/null @@ -1,70 +0,0 @@ -# useCallbackOncePerRender - -::: warning Deprecated -Use `useCallbackOnce` instead. -::: - -`useCallbackOncePerRender` 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. - -## Interface - -```ts -function useCallbackOncePerRender void>( - callback: F, - deps: DependencyList -): (...args: Parameters) => 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, userId]); - - return
User page
; -} -``` diff --git a/packages/react-simplikit/src/hooks/useCallbackOncePerRender/useCallbackOncePerRender.ts b/packages/react-simplikit/src/hooks/useCallbackOncePerRender/useCallbackOncePerRender.ts deleted file mode 100644 index 037b9a73..00000000 --- a/packages/react-simplikit/src/hooks/useCallbackOncePerRender/useCallbackOncePerRender.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { useCallbackOnce } from '../useCallbackOnce/index.ts'; - -/** - * @deprecated Use `useCallbackOnce` instead. - * - * @description - * `useCallbackOncePerRender` 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. - * - * @template {(...args: any[]) => void} F - The type of the callback function. - * @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: Parameters) => void} A function whose reference never changes. It runs the callback only once until `deps` change. - * - * @example - * import { useCallbackOncePerRender } from 'react-simplikit'; - * - * function Component() { - * const handleOneTimeEvent = useCallbackOncePerRender(() => { - * console.log('This will only run once'); - * }, []); - * - * return ; - * } - * - * @example - * // With dependencies - * function TrackingComponent({ userId }: { userId: string }) { - * const trackUserVisit = useCallbackOncePerRender(() => { - * analytics.trackVisit(userId); - * }, [userId]); - * - * useEffect(() => { - * trackUserVisit(); - * }, [trackUserVisit, userId]); - * - * return
User page
; - * } - */ -export const useCallbackOncePerRender = useCallbackOnce; 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 f0612a85..00000000 --- a/packages/react-simplikit/src/hooks/useCallbackOncePerRender/zh-Hans/useCallbackOncePerRender.md +++ /dev/null @@ -1,69 +0,0 @@ -# useCallbackOncePerRender - -::: warning 已弃用 -请改用 `useCallbackOnce`。 -::: - -`useCallbackOncePerRender` 是一个 React Hook,在 `deps` 变化之前,无论返回的函数被调用多少次,它都只会执行一次回调函数。它适用于那些即使组件重复渲染也不应重复执行的一次性操作。 - -## 接口 - -```ts -function useCallbackOncePerRender void>( - callback: F, - deps: DependencyList -): (...args: Parameters) => 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, userId]); - - return
User page
; -} -``` diff --git a/packages/react-simplikit/src/index.ts b/packages/react-simplikit/src/index.ts index 7367bed9..99337942 100644 --- a/packages/react-simplikit/src/index.ts +++ b/packages/react-simplikit/src/index.ts @@ -5,8 +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 { useCallbackOnce } from './hooks/useCallbackOnce/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';