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 Compiled only Failed only + Skipped only All error types @@ -317,10 +319,12 @@ export function getReportHtml(options: ReportHtmlOptions): string { function renderSummary() { var t = reportData.totals; + var skippedCount = t.skippedCount || 0; document.getElementById('summary').innerHTML = '' + t.filesScanned + 'scanned' + '' + t.successCount + ' ' + emojis.success + 'compiled' + '' + t.failedCount + ' ' + emojis.error + 'failed' + + '' + skippedCount + ' ' + emojis.skipped + 'skipped' + '' + t.filesWithResults + 'files with results'; document.getElementById('generatedAt').textContent = 'Generated: ' + new Date(reportData.generatedAt).toLocaleString(); } @@ -362,6 +366,7 @@ export function getReportHtml(options: ReportHtmlOptions): string { if (node.type === 'file') { if (sf === 'compiled' && node.successCount === 0) return false; if (sf === 'failed' && node.failedCount === 0) return false; + if (sf === 'skipped' && (node.skippedCount || 0) === 0) return false; if (sq && !node.path.toLowerCase().includes(sq)) return false; if (ef) { var hasMatchingError = node.entries && node.entries.some(function(e) { @@ -390,11 +395,16 @@ export function getReportHtml(options: ReportHtmlOptions): string { var col = e.column || 0; var locText = line !== undefined ? ':' + line : ''; var isSuccess = e.kind === 'success'; + var isSkip = e.kind === 'skip'; + var isFailure = e.kind === 'failure'; - if (!isSuccess && filterState.errorTypeFilter && e.reason !== filterState.errorTypeFilter) continue; + if (isFailure && filterState.errorTypeFilter && e.reason !== filterState.errorTypeFilter) continue; + if (filterState.statusFilter === 'compiled' && !isSuccess) continue; + if (filterState.statusFilter === 'failed' && !isFailure) continue; + if (filterState.statusFilter === 'skipped' && !isSkip) continue; - var emoji = isSuccess ? emojis.success : emojis.error; - var textClass = isSuccess ? 'success-text' : 'failed-text'; + var emoji = isSuccess ? emojis.success : isSkip ? emojis.skipped : emojis.error; + var textClass = isSuccess ? 'success-text' : isSkip ? 'skipped-text' : 'failed-text'; var nameHtml = name ? '' + escapeHtml(name) + '' : ''; var reasonHtml = !isSuccess && e.reason ? '' + escapeHtml(e.reason) + '' : ''; @@ -426,9 +436,16 @@ export function getReportHtml(options: ReportHtmlOptions): string { html += '' + nodeIcon + ''; html += '' + escapeHtml(node.name) + ''; var countsHtml = ''; + var skippedCount = node.skippedCount || 0; if (node.successCount > 0) countsHtml += node.successCount + emojis.success; - if (node.successCount > 0 && node.failedCount > 0) countsHtml += ' '; - if (node.failedCount > 0) countsHtml += node.failedCount + emojis.error; + if (node.failedCount > 0) { + if (countsHtml) countsHtml += ' '; + countsHtml += node.failedCount + emojis.error; + } + if (skippedCount > 0) { + if (countsHtml) countsHtml += ' '; + countsHtml += skippedCount + emojis.skipped; + } if (countsHtml) html += '' + countsHtml + ''; html += ''; diff --git a/packages/server/src/server.ts b/packages/server/src/server.ts index f8fbcc7..914cf7f 100644 --- a/packages/server/src/server.ts +++ b/packages/server/src/server.ts @@ -46,12 +46,14 @@ const documents: TextDocuments = new TextDocuments(TextDocument); interface Settings { successEmoji: string | null; errorEmoji: string | null; + skippedEmoji: string | null; babelPluginPath: string; } let globalSettings: Settings = { successEmoji: "✨", errorEmoji: "🚫", + skippedEmoji: "⏭️", babelPluginPath: "node_modules/babel-plugin-react-compiler", }; @@ -136,6 +138,7 @@ connection.onDidChangeConfiguration((change) => { globalSettings = { successEmoji: settings.successEmoji ?? "✨", errorEmoji: settings.errorEmoji ?? "🚫", + skippedEmoji: settings.skippedEmoji ?? "⏭️", babelPluginPath: settings.babelPluginPath ?? "node_modules/babel-plugin-react-compiler", }; @@ -175,19 +178,22 @@ connection.languages.inlayHint.on(async (params: InlayHintParams): Promise { try { const sourceCode = document.getText(); - const { successfulCompilations, failedCompilations } = checkReactCompiler( + const { successfulCompilations, failedCompilations, skippedCompilations } = checkReactCompiler( sourceCode, fileNameForCompiler, workspaceFolder, @@ -235,8 +241,10 @@ connection.onHover((params: HoverParams): Hover | null => { document, successfulCompilations, failedCompilations, + skippedCompilations, globalSettings.successEmoji, globalSettings.errorEmoji, + globalSettings.skippedEmoji, params.textDocument.uri, tooltipFormat, clientName @@ -330,7 +338,7 @@ connection.onExecuteCommand(async (params: ExecuteCommandParams) => { : undefined, }); logMessage( - `Report generated: scanned=${report.totals.filesScanned} files=${report.totals.filesWithResults} success=${report.totals.successCount} failed=${report.totals.failedCount}` + `Report generated: scanned=${report.totals.filesScanned} files=${report.totals.filesWithResults} success=${report.totals.successCount} failed=${report.totals.failedCount} skipped=${report.totals.skippedCount}` ); return { success: true, report }; } catch (error: any) { @@ -368,6 +376,7 @@ connection.onExecuteCommand(async (params: ExecuteCommandParams) => { const emojis = { success: htmlOptions?.emojis?.success ?? globalSettings.successEmoji ?? "✨", error: htmlOptions?.emojis?.error ?? globalSettings.errorEmoji ?? "🚫", + skipped: htmlOptions?.emojis?.skipped ?? globalSettings.skippedEmoji ?? "⏭️", }; const html = getReportHtml({ data: treeData, @@ -377,7 +386,7 @@ connection.onExecuteCommand(async (params: ExecuteCommandParams) => { scriptExtra: htmlOptions?.scriptExtra, }); logMessage( - `HTML report generated: scanned=${report.totals.filesScanned} files=${report.totals.filesWithResults} success=${report.totals.successCount} failed=${report.totals.failedCount}` + `HTML report generated: scanned=${report.totals.filesScanned} files=${report.totals.filesWithResults} success=${report.totals.successCount} failed=${report.totals.failedCount} skipped=${report.totals.skippedCount}` ); return { success: true, html, report }; } catch (error: any) { diff --git a/packages/vscode-client/package.json b/packages/vscode-client/package.json index 0b4cc8d..0977557 100644 --- a/packages/vscode-client/package.json +++ b/packages/vscode-client/package.json @@ -193,6 +193,12 @@ "nullable": true, "default": "✨", "description": "Emoji marker to display next to components that were successfully memoized" + }, + "reactCompilerMarker.skippedEmoji": { + "type": "string", + "nullable": true, + "default": "⏭️", + "description": "Emoji marker to display next to components that opted out via the \"use no memo\" directive" } } } diff --git a/packages/vscode-client/src/extension.ts b/packages/vscode-client/src/extension.ts index 0563e5a..36a1e8a 100644 --- a/packages/vscode-client/src/extension.ts +++ b/packages/vscode-client/src/extension.ts @@ -169,6 +169,7 @@ export function activate(context: vscode.ExtensionContext): void { const emojis = { success: config.get("successEmoji") ?? "\u2728", error: config.get("errorEmoji") ?? "\uD83D\uDEAB", + skipped: config.get("skippedEmoji") ?? "\u23ED\uFE0F", }; const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; if (!workspaceFolder) { @@ -414,6 +415,7 @@ function registerCommands( const emojis = { success: config.get("successEmoji") ?? "✨", error: config.get("errorEmoji") ?? "🚫", + skipped: config.get("skippedEmoji") ?? "⏭️", }; ReportPanel.createOrShow(workspaceFolder.uri, treeData, emojis); } finally { diff --git a/packages/vscode-client/src/report/ReportPanel.ts b/packages/vscode-client/src/report/ReportPanel.ts index 6f31855..832ac74 100644 --- a/packages/vscode-client/src/report/ReportPanel.ts +++ b/packages/vscode-client/src/report/ReportPanel.ts @@ -81,6 +81,7 @@ export class ReportPanel { --rcm-list-hover-bg: var(--vscode-list-hoverBackground); --rcm-success: var(--vscode-testing-iconPassed, #4caf50); --rcm-failed: var(--vscode-testing-iconFailed, #f44336); + --rcm-skipped: var(--vscode-testing-iconSkipped, var(--vscode-descriptionForeground)); --rcm-font-family: var(--vscode-font-family); --rcm-font-size: var(--vscode-font-size); --rcm-editor-font-family: var(--vscode-editor-font-family, monospace); diff --git a/packages/vscode-client/src/sidebar/ReportsTreeProvider.ts b/packages/vscode-client/src/sidebar/ReportsTreeProvider.ts index 97ac382..1f8d01e 100644 --- a/packages/vscode-client/src/sidebar/ReportsTreeProvider.ts +++ b/packages/vscode-client/src/sidebar/ReportsTreeProvider.ts @@ -22,9 +22,11 @@ export class ReportItem extends vscode.TreeItem { super(label, vscode.TreeItemCollapsibleState.None); - this.description = `\u2728 ${report.totals.successCount} \uD83D\uDEAB ${report.totals.failedCount}`; + const skippedCount = report.totals.skippedCount ?? 0; + const skippedDesc = skippedCount > 0 ? ` \u23ED\uFE0F ${skippedCount}` : ""; + this.description = `\u2728 ${report.totals.successCount} \uD83D\uDEAB ${report.totals.failedCount}${skippedDesc}`; this.iconPath = new vscode.ThemeIcon("graph"); - this.tooltip = `Files scanned: ${report.totals.filesScanned}\nCompiled: ${report.totals.successCount}\nFailed: ${report.totals.failedCount}`; + this.tooltip = `Files scanned: ${report.totals.filesScanned}\nCompiled: ${report.totals.successCount}\nFailed: ${report.totals.failedCount}\nSkipped: ${skippedCount}`; this.contextValue = "reportItem"; this.command = { command: "react-compiler-marker.openReport", diff --git a/packages/vscode-client/test/decorations.test.ts b/packages/vscode-client/test/decorations.test.ts index febff55..f966c33 100644 --- a/packages/vscode-client/test/decorations.test.ts +++ b/packages/vscode-client/test/decorations.test.ts @@ -132,6 +132,39 @@ suite("Critical error handling", () => { ); }); + test('use-no-memo.tsx: opted-out functions are reported as skipped, not failed', () => { + const text = readFixture("use-no-memo.tsx").trim(); + const filename = "/mock/use-no-memo.tsx"; + + const { successfulCompilations, failedCompilations, skippedCompilations } = + compileFixture(text, filename); + + assert.ok(Array.isArray(skippedCompilations), "skippedCompilations should be an array"); + assert.strictEqual( + skippedCompilations.length, + 1, + `Expected 1 skipped compilation, got ${skippedCompilations.length}` + ); + assert.strictEqual( + successfulCompilations.length, + 1, + `Expected 1 successful compilation, got ${successfulCompilations.length}` + ); + + // No remaining failure should overlap the opted-out component's range. + const skipStart = skippedCompilations[0].fnLoc?.start?.line ?? 0; + const skipEnd = skippedCompilations[0].fnLoc?.end?.line ?? skipStart; + for (const failed of failedCompilations) { + const failStart = failed.fnLoc?.start?.line ?? 0; + const failEnd = failed.fnLoc?.end?.line ?? failStart; + const overlaps = failStart <= skipEnd && skipStart <= failEnd; + assert.ok( + !overlaps, + `Failure at line ${failStart} overlaps the opted-out component (lines ${skipStart}-${skipEnd}); opt-outs must not be reported as failures.` + ); + } + }); + test("error-without-ranges.tsx: handles errors without location ranges gracefully", () => { const text = readFixture("error-without-ranges.tsx").trim(); const filename = "/mock/error-without-ranges.tsx"; diff --git a/packages/vscode-client/test/fixtures/use-no-memo.tsx b/packages/vscode-client/test/fixtures/use-no-memo.tsx new file mode 100644 index 0000000..4589198 --- /dev/null +++ b/packages/vscode-client/test/fixtures/use-no-memo.tsx @@ -0,0 +1,22 @@ +import React from "react"; + +// Opted out via "use no memo" — the compiler emits CompileSkip for this one. +export function OptedOutComponent({ name }: { name: string }) { + "use no memo"; + + return Hello {name}; +} + +// Compiles cleanly. +export function CompiledComponent({ name }: { name: string }) { + return Hello {name}; +} + +// Genuine compile failure — mutates a ref outside an effect — must remain in the failed bucket. +export function FailingComponent() { + const ref = React.useRef("initial"); + + ref.current = "updated"; + + return {ref.current}; +} diff --git a/packages/zed-client/README.md b/packages/zed-client/README.md index a187c36..bf843cc 100644 --- a/packages/zed-client/README.md +++ b/packages/zed-client/README.md @@ -54,6 +54,7 @@ Add settings to your Zed `settings.json` (`cmd+,`): "settings": { "successEmoji": "✨", "errorEmoji": "🚫", + "skippedEmoji": "⏭️", "babelPluginPath": "node_modules/babel-plugin-react-compiler" } } @@ -67,6 +68,7 @@ Add settings to your Zed `settings.json` (`cmd+,`): |---------|---------|-------------| | `successEmoji` | `✨` | Emoji shown for successfully optimized components | | `errorEmoji` | `🚫` | Emoji shown for components with optimization errors | +| `skippedEmoji` | `⏭️` | Emoji shown for components that opted out via `"use no memo"` | | `babelPluginPath` | `node_modules/babel-plugin-react-compiler` | Path to the babel-plugin-react-compiler package | ## Limitations