diff --git a/FEATURES.md b/FEATURES.md index 72e7813..49586cd 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -29,6 +29,7 @@ Overview of features supported by each editor client. |---------|:-------:|:--------:|:------:|:---:| | Success Emoji | ✅ | ✅ | ✅ | ✅ | | Error Emoji | ✅ | ✅ | ✅ | ✅ | +| Skipped Emoji (`"use no memo"`) | ✅ | ✅ | ✅ | ✅ | | Babel Plugin Path | ✅ | ✅ | ✅ | ✅ | | Excluded Directories | ✅ | ✅ | ❌ | ❌ | | Supported Extensions | ✅ | ✅ | ❌ | ❌ | diff --git a/README.md b/README.md index 2f540a3..4adae02 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ ## Features - Visual emoji markers next to React components (customizable) +- Respects the `"use no memo"` / `"use no forget"` opt-out directive — opted-out functions are reported as skipped (⏭️) rather than failed - Hover tooltips with optimization details and error messages - Preview compiled output to see what the React Compiler generates - Generate reports for a full-project compilation snapshot diff --git a/packages/cli/src/formatText.ts b/packages/cli/src/formatText.ts index cb2329f..69e7d7b 100644 --- a/packages/cli/src/formatText.ts +++ b/packages/cli/src/formatText.ts @@ -14,8 +14,10 @@ function parseFailure(filePath: string, log: LogEntry): ParsedFailure { log.detail?.options?.details?.at(0)?.loc?.start?.line ?? log.detail?.options?.loc?.start?.line ?? log.detail?.loc?.start?.line ?? + log.loc?.start?.line ?? + log.fnLoc?.start?.line ?? undefined; - const reason = log?.detail?.options?.reason || "Unknown reason"; + const reason = log?.detail?.options?.reason || log?.reason || "Unknown reason"; return { filePath, fnName: log.fnName, reason, line }; } @@ -29,6 +31,7 @@ export function formatText(report: ReactCompilerReport): string { lines.push(`Files with results: ${totals.filesWithResults}`); lines.push(`Compiled (success): ${totals.successCount}`); lines.push(`Failed: ${totals.failedCount}`); + lines.push(`Skipped: ${totals.skippedCount}`); if (report.errors.length > 0) { lines.push(""); @@ -57,6 +60,24 @@ export function formatText(report: ReactCompilerReport): string { } } + const skips: ParsedFailure[] = []; + for (const file of report.files) { + for (const log of file.skipped ?? []) { + skips.push(parseFailure(file.path, log)); + } + } + + if (skips.length > 0) { + lines.push(""); + lines.push("Skipped components:"); + lines.push("----------------------------------------"); + for (const s of skips) { + const loc = s.line ? `:${s.line}` : ""; + const name = s.fnName ?? "(anonymous)"; + lines.push(` ${s.filePath}${loc} - ${name}: ${s.reason}`); + } + } + lines.push(""); return lines.join("\n"); } diff --git a/packages/cli/src/main.ts b/packages/cli/src/main.ts index ae39d40..0e305bd 100644 --- a/packages/cli/src/main.ts +++ b/packages/cli/src/main.ts @@ -190,7 +190,7 @@ function formatOutput(report: ReactCompilerReport, format: string): string { const tree = buildReportTree(report); return getReportHtml({ data: tree, - emojis: { success: "\u2705", error: "\u274C" }, + emojis: { success: "\u2705", error: "\u274C", skipped: "\u23ED\uFE0F" }, theme: "auto", headExtra: ``, }); diff --git a/packages/intellij-client/src/main/kotlin/com/blazejkustra/reactcompilermarker/actions/GenerateReportAction.kt b/packages/intellij-client/src/main/kotlin/com/blazejkustra/reactcompilermarker/actions/GenerateReportAction.kt index 03e8dae..c8345e1 100644 --- a/packages/intellij-client/src/main/kotlin/com/blazejkustra/reactcompilermarker/actions/GenerateReportAction.kt +++ b/packages/intellij-client/src/main/kotlin/com/blazejkustra/reactcompilermarker/actions/GenerateReportAction.kt @@ -52,7 +52,8 @@ class GenerateReportAction : AnAction() { "respectGitignore" to settings.respectGitignore, "emojis" to mapOf( "success" to settings.successEmoji, - "error" to settings.errorEmoji + "error" to settings.errorEmoji, + "skipped" to settings.skippedEmoji ) ) @@ -136,6 +137,7 @@ class GenerateReportAction : AnAction() { val listHoverBg = shiftColor(bg, shift) val success = if (isDark) Color(0x73, 0xC9, 0x91) else Color(0x2E, 0x7D, 0x32) val failed = if (isDark) Color(0xF4, 0x87, 0x71) else Color(0xC6, 0x28, 0x28) + val skipped = if (isDark) Color(0xB0, 0xB7, 0xC2) else Color(0x5F, 0x66, 0x73) val fontFamily = UIManager.getFont("Label.font")?.family ?: "sans-serif" val fontSize = UIManager.getFont("Label.font")?.size ?: 13 val editorFont = UIManager.getFont("EditorPane.font")?.family ?: "monospace" @@ -157,6 +159,7 @@ class GenerateReportAction : AnAction() { --rcm-list-hover-bg: ${colorToCssAlpha(listHoverBg, 0.5)}; --rcm-success: ${colorToCss(success)}; --rcm-failed: ${colorToCss(failed)}; + --rcm-skipped: ${colorToCss(skipped)}; --rcm-font-family: '${fontFamily}', sans-serif; --rcm-font-size: ${fontSize}px; --rcm-editor-font-family: '${editorFont}', monospace; diff --git a/packages/intellij-client/src/main/kotlin/com/blazejkustra/reactcompilermarker/settings/ReactCompilerMarkerConfigurable.kt b/packages/intellij-client/src/main/kotlin/com/blazejkustra/reactcompilermarker/settings/ReactCompilerMarkerConfigurable.kt index 7b5b424..c99d89a 100644 --- a/packages/intellij-client/src/main/kotlin/com/blazejkustra/reactcompilermarker/settings/ReactCompilerMarkerConfigurable.kt +++ b/packages/intellij-client/src/main/kotlin/com/blazejkustra/reactcompilermarker/settings/ReactCompilerMarkerConfigurable.kt @@ -15,6 +15,7 @@ class ReactCompilerMarkerConfigurable(private val project: Project) : Configurab private var enabledCheckbox: JBCheckBox? = null private var successEmojiField: JBTextField? = null private var errorEmojiField: JBTextField? = null + private var skippedEmojiField: JBTextField? = null private var babelPluginPathField: JBTextField? = null private var excludedDirectoriesField: JBTextField? = null private var supportedExtensionsField: JBTextField? = null @@ -26,6 +27,7 @@ class ReactCompilerMarkerConfigurable(private val project: Project) : Configurab enabledCheckbox = JBCheckBox("Enable React Compiler Marker") successEmojiField = JBTextField() errorEmojiField = JBTextField() + skippedEmojiField = JBTextField() babelPluginPathField = JBTextField() excludedDirectoriesField = JBTextField() supportedExtensionsField = JBTextField() @@ -36,6 +38,7 @@ class ReactCompilerMarkerConfigurable(private val project: Project) : Configurab .addSeparator() .addLabeledComponent(JBLabel("Success emoji:"), successEmojiField!!, 1, false) .addLabeledComponent(JBLabel("Error emoji:"), errorEmojiField!!, 1, false) + .addLabeledComponent(JBLabel("Skipped emoji:"), skippedEmojiField!!, 1, false) .addSeparator() .addLabeledComponent(JBLabel("Babel plugin path:"), babelPluginPathField!!, 1, false) .addSeparator() @@ -52,6 +55,7 @@ class ReactCompilerMarkerConfigurable(private val project: Project) : Configurab return enabledCheckbox?.isSelected != settings.isEnabled || successEmojiField?.text != settings.successEmoji || errorEmojiField?.text != settings.errorEmoji || + skippedEmojiField?.text != settings.skippedEmoji || babelPluginPathField?.text != settings.babelPluginPath || excludedDirectoriesField?.text != settings.excludedDirectories || supportedExtensionsField?.text != settings.supportedExtensions || @@ -63,6 +67,7 @@ class ReactCompilerMarkerConfigurable(private val project: Project) : Configurab settings.isEnabled = enabledCheckbox?.isSelected ?: true settings.successEmoji = successEmojiField?.text ?: "\u2728" settings.errorEmoji = errorEmojiField?.text ?: "\uD83D\uDEAB" + settings.skippedEmoji = skippedEmojiField?.text ?: "\u23ED\uFE0F" settings.babelPluginPath = babelPluginPathField?.text ?: "node_modules/babel-plugin-react-compiler" settings.excludedDirectories = excludedDirectoriesField?.text ?: "" settings.supportedExtensions = supportedExtensionsField?.text ?: "" @@ -78,6 +83,7 @@ class ReactCompilerMarkerConfigurable(private val project: Project) : Configurab enabledCheckbox?.isSelected = settings.isEnabled successEmojiField?.text = settings.successEmoji errorEmojiField?.text = settings.errorEmoji + skippedEmojiField?.text = settings.skippedEmoji babelPluginPathField?.text = settings.babelPluginPath excludedDirectoriesField?.text = settings.excludedDirectories supportedExtensionsField?.text = settings.supportedExtensions @@ -88,6 +94,7 @@ class ReactCompilerMarkerConfigurable(private val project: Project) : Configurab enabledCheckbox = null successEmojiField = null errorEmojiField = null + skippedEmojiField = null babelPluginPathField = null excludedDirectoriesField = null supportedExtensionsField = null diff --git a/packages/intellij-client/src/main/kotlin/com/blazejkustra/reactcompilermarker/settings/ReactCompilerMarkerSettings.kt b/packages/intellij-client/src/main/kotlin/com/blazejkustra/reactcompilermarker/settings/ReactCompilerMarkerSettings.kt index e66d247..dc01a34 100644 --- a/packages/intellij-client/src/main/kotlin/com/blazejkustra/reactcompilermarker/settings/ReactCompilerMarkerSettings.kt +++ b/packages/intellij-client/src/main/kotlin/com/blazejkustra/reactcompilermarker/settings/ReactCompilerMarkerSettings.kt @@ -15,8 +15,9 @@ class ReactCompilerMarkerSettings : PersistentStateComponent = mapOf( "successEmoji" to successEmoji, "errorEmoji" to errorEmoji, + "skippedEmoji" to skippedEmoji, "babelPluginPath" to babelPluginPath, "excludedDirectories" to excludedDirectoriesList, "supportedExtensions" to supportedExtensionsList, diff --git a/packages/nvim-client/lua/react-compiler-marker/config.lua b/packages/nvim-client/lua/react-compiler-marker/config.lua index 2ffa8da..0cbca71 100644 --- a/packages/nvim-client/lua/react-compiler-marker/config.lua +++ b/packages/nvim-client/lua/react-compiler-marker/config.lua @@ -18,6 +18,8 @@ M.defaults = { success = "✨", -- Marker for components that failed to optimize error = "🚫", + -- Marker for components that opted out via "use no memo" + skipped = "⏭️", }, -- Path to babel-plugin-react-compiler (relative to workspace root) @@ -122,6 +124,7 @@ function M.get_server_settings() reactCompilerMarker = { successEmoji = M.config.emojis.success, errorEmoji = M.config.emojis.error, + skippedEmoji = M.config.emojis.skipped, babelPluginPath = M.config.babel_plugin_path, }, } diff --git a/packages/server/src/checkReactCompiler.ts b/packages/server/src/checkReactCompiler.ts index fa4cbe1..1894a15 100644 --- a/packages/server/src/checkReactCompiler.ts +++ b/packages/server/src/checkReactCompiler.ts @@ -27,6 +27,8 @@ export type LoggerEvent = { kind?: string; fnLoc: EventLocation; fnName?: string; + reason?: string; + loc?: EventLocation; detail?: Details & { options: Details; }; @@ -52,6 +54,7 @@ export function clearPluginCache(): void { interface CompilationResult { successfulCompilations: Array; failedCompilations: Array; + skippedCompilations: Array; } const compilationCache = new LRUCache(100); @@ -79,6 +82,7 @@ function runBabelPluginReactCompiler( ) { const successfulCompilations: Array = []; const failedCompilations: Array = []; + const skippedCompilations: Array = []; const logger = { logEvent(filename: string | null, rawEvent: LoggerEvent) { @@ -93,6 +97,9 @@ function runBabelPluginReactCompiler( case "PipelineError": failedCompilations.push(event); return; + case "CompileSkip": + skippedCompilations.push(event); + return; } }, }; @@ -126,6 +133,7 @@ function runBabelPluginReactCompiler( return { successfulCompilations, failedCompilations, + skippedCompilations, }; } @@ -180,7 +188,7 @@ export function checkReactCompiler( const BabelPluginReactCompiler = importBabelPluginReactCompiler(workspaceFolder, babelPluginPath); if (!BabelPluginReactCompiler) { - return { successfulCompilations: [], failedCompilations: [] }; + return { successfulCompilations: [], failedCompilations: [], skippedCompilations: [] }; } try { @@ -198,7 +206,11 @@ export function checkReactCompiler( return result; } catch (error: any) { throttledError(`Failed to compile the file. Please check the file content. ${error?.message}`); - const emptyResult: CompilationResult = { successfulCompilations: [], failedCompilations: [] }; + const emptyResult: CompilationResult = { + successfulCompilations: [], + failedCompilations: [], + skippedCompilations: [], + }; compilationCache.set(sourceCode, filename, emptyResult); return emptyResult; } diff --git a/packages/server/src/inlayHints.ts b/packages/server/src/inlayHints.ts index 3a6e8cf..e61a475 100644 --- a/packages/server/src/inlayHints.ts +++ b/packages/server/src/inlayHints.ts @@ -79,8 +79,10 @@ export function generateInlayHints( document: TextDocument, successfulCompilations: LoggerEvent[], failedCompilations: LoggerEvent[], + skippedCompilations: LoggerEvent[], successEmoji: string | null, errorEmoji: string | null, + skippedEmoji: string | null, documentUri: string, tooltipFormat: TooltipFormat = "markdown", clientName?: string @@ -119,6 +121,28 @@ export function generateInlayHints( } } + // Generate hints for skipped compilations (opt-out via "use no memo") + if (skippedEmoji) { + for (const log of skippedCompilations) { + const positionInfo = getInlayHintPosition(document, log); + if (!positionInfo) { + continue; + } + + const f = fmt[tooltipFormat]; + const tooltipValue = `${skippedEmoji} ${f.bold(positionInfo.functionName)} has been skipped by React Compiler due to a \`"use no memo"\` directive.`; + + const hint: InlayHint = { + position: positionInfo.position, + label: `${skippedEmoji} `, + kind: InlayHintKind.Type, + tooltip: { kind: "markdown", value: tooltipValue }, + }; + + hints.push(hint); + } + } + // Generate hint for failed compilations (one hint with all errors) if (errorEmoji && failedCompilations.length > 0) { const f = fmt[tooltipFormat]; diff --git a/packages/server/src/parseLog.ts b/packages/server/src/parseLog.ts index a22933c..fb0d590 100644 --- a/packages/server/src/parseLog.ts +++ b/packages/server/src/parseLog.ts @@ -21,6 +21,9 @@ export function parseLog(log: LoggerEvent): ParsedLog { log.detail?.options?.details?.at(0)?.loc?.[property]?.[field] ?? log.detail?.options?.loc?.[property]?.[field] ?? log.detail?.loc?.[property]?.[field] ?? + // CompileSkip events expose the directive location at the top level + log.loc?.[property]?.[field] ?? + log.fnLoc?.[property]?.[field] ?? defaultValue ); }; @@ -30,7 +33,7 @@ export function parseLog(log: LoggerEvent): ParsedLog { const startChar = getLocValue("start", "column", 0); const endChar = getLocValue("end", "column", 0); - const reason = log?.detail?.options?.reason || "Unknown reason"; + const reason = log?.detail?.options?.reason || log.reason || "Unknown reason"; const description = log?.detail?.options?.description || ""; return { diff --git a/packages/server/src/report/buildTree.ts b/packages/server/src/report/buildTree.ts index 1aa71c1..7a29473 100644 --- a/packages/server/src/report/buildTree.ts +++ b/packages/server/src/report/buildTree.ts @@ -10,6 +10,7 @@ export function buildReportTree(report: ReactCompilerReport): ReportTreeData { children: [], successCount: 0, failedCount: 0, + skippedCount: 0, }; for (const file of report.files) { @@ -34,6 +35,7 @@ export function buildReportTree(report: ReactCompilerReport): ReportTreeData { children: isFile ? undefined : [], successCount: 0, failedCount: 0, + skippedCount: 0, }; current.children.push(child); } @@ -41,7 +43,7 @@ export function buildReportTree(report: ReactCompilerReport): ReportTreeData { if (isFile) { const toEntry = ( event: (typeof file.success)[number], - kind: "success" | "failure" + kind: NormalizedEntry["kind"] ): NormalizedEntry => { const parsed = parseLog(event); return { @@ -53,13 +55,16 @@ export function buildReportTree(report: ReactCompilerReport): ReportTreeData { column: parsed.startChar, }; }; + const skipped = file.skipped ?? []; const entries: NormalizedEntry[] = [ ...file.success.map((event) => toEntry(event, "success")), ...file.failed.map((event) => toEntry(event, "failure")), + ...skipped.map((event) => toEntry(event, "skip")), ]; child.entries = entries; child.successCount = entries.filter((e) => e.kind === "success").length; child.failedCount = entries.filter((e) => e.kind === "failure").length; + child.skippedCount = entries.filter((e) => e.kind === "skip").length; } current = child; @@ -85,11 +90,13 @@ function aggregateCounts(node: TreeNode): void { node.successCount = 0; node.failedCount = 0; + node.skippedCount = 0; for (const child of node.children) { aggregateCounts(child); node.successCount += child.successCount; node.failedCount += child.failedCount; + node.skippedCount += child.skippedCount; } } diff --git a/packages/server/src/report/generate.ts b/packages/server/src/report/generate.ts index 469a191..3494f13 100644 --- a/packages/server/src/report/generate.ts +++ b/packages/server/src/report/generate.ts @@ -15,13 +15,16 @@ export interface ReactCompilerReport { filesWithResults: number; compiledFiles: number; failedFiles: number; + skippedFiles: number; successCount: number; failedCount: number; + skippedCount: number; }; files: Array<{ path: string; success: LoggerEvent[]; failed: LoggerEvent[]; + skipped: LoggerEvent[]; }>; errors: Array<{ path: string; @@ -220,16 +223,13 @@ export async function generateReport(options: ReportOptions): Promise { try { const sourceCode = await fs.readFile(filePath, "utf8"); - const { successfulCompilations, failedCompilations } = checkReactCompiler( - sourceCode, - filePath, - root, - options.babelPluginPath - ); + const { successfulCompilations, failedCompilations, skippedCompilations } = + checkReactCompiler(sourceCode, filePath, root, options.babelPluginPath); return { path: path.relative(root, filePath), success: successfulCompilations, failed: failedCompilations, + skipped: skippedCompilations, }; } catch (error: any) { errors.push({ @@ -244,22 +244,34 @@ export async function generateReport(options: ReportOptions): Promise => - !!result && (result.success.length > 0 || result.failed.length > 0) + !!result && + (result.success.length > 0 || result.failed.length > 0 || result.skipped.length > 0) ); const totals = filesWithResults.reduce( (acc, result) => { acc.successCount += result.success.length; acc.failedCount += result.failed.length; + acc.skippedCount += result.skipped.length; if (result.success.length > 0) { acc.compiledFiles += 1; } if (result.failed.length > 0) { acc.failedFiles += 1; } + if (result.skipped.length > 0) { + acc.skippedFiles += 1; + } return acc; }, - { successCount: 0, failedCount: 0, compiledFiles: 0, failedFiles: 0 } + { + successCount: 0, + failedCount: 0, + skippedCount: 0, + compiledFiles: 0, + failedFiles: 0, + skippedFiles: 0, + } ); return { @@ -269,8 +281,10 @@ export async function generateReport(options: ReportOptions): PromiseAll files +