Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 58 additions & 25 deletions src/renderer/components/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import '../i18n';

import ConfigTab from './ConfigTab';
import DarkModeToggle from './DarkModeToggle';
import ErrorBoundary from './ErrorBoundary';
import LanguageSelector from './LanguageSelector';
import ProcessedTab from './ProcessedTab';
import SourceTab from './SourceTab';
Expand Down Expand Up @@ -60,6 +61,9 @@ const AppContent = () => {
} = useApp();

const appWindow = globalThis as Window & typeof globalThis;
const tabFallbackTitle = t('errors.tabCrashedTitle');
const tabFallbackMessage = t('errors.tabCrashedDescription');
const retryLabel = t('errors.retryRender');

return (
<div className='mx-auto flex h-screen w-full max-w-screen-2xl flex-col p-4'>
Expand Down Expand Up @@ -134,7 +138,14 @@ const AppContent = () => {
aria-labelledby='tab-config'
className={`absolute inset-0 overflow-y-auto bg-white dark:bg-gray-800 p-4 text-gray-900 dark:text-gray-100 transition-colors duration-200 ${activeTab === 'config' ? '' : 'hidden'}`}
>
<ConfigTab configContent={configContent} onConfigChange={updateConfig} />
<ErrorBoundary
fallbackTitle={tabFallbackTitle}
fallbackMessage={tabFallbackMessage}
resetLabel={retryLabel}
resetKeys={[activeTab, configContent]}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

1. Activetab in resetkeys auto-retries crashed tabs 🐞 Bug ✓ Correctness

All three tab error boundaries include activeTab as the first resetKey. Since all tabs stay
permanently mounted (hidden via CSS), every tab switch changes activeTab for all three boundaries
simultaneously. Any boundary in error state will auto-reset and re-render its still-broken child on
every tab switch, causing repeated throws, noisy console output, and fallback UI flickering.
Agent Prompt
## Issue description
All three tab `ErrorBoundary` instances include `activeTab` as the first element of their `resetKeys`. Because all tabs remain permanently mounted (hidden via CSS), every tab switch changes `activeTab` for all three boundaries simultaneously. Any boundary currently in error state will auto-reset and immediately re-render its still-broken child, causing repeated throws, noisy `console.error` output, and fallback UI flickering on every tab switch.

## Issue Context
The tabs are kept mounted via a CSS `hidden` class (not unmounted), so all three `ErrorBoundary` instances are always live and receive prop updates. `haveResetKeysChanged` uses `Object.is` comparison — a tab switch from `'source'` to `'config'` is detected as a changed key for all three boundaries, triggering `setState({ hasError: false })` on any that are in error state.

## Fix Focus Areas
Remove `activeTab` from all three `resetKeys` arrays, keeping only the content-specific keys:
- `src/renderer/components/App.tsx[145-145]` → change to `resetKeys={[configContent]}`
- `src/renderer/components/App.tsx[161-161]` → change to `resetKeys={[rootPath, selectedFiles.size, selectedFolders.size]}`
- `src/renderer/components/App.tsx[190-190]` → change to `resetKeys={[processedResult?.content]}`

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

>
<ConfigTab configContent={configContent} onConfigChange={updateConfig} />
</ErrorBoundary>
</div>

<div
Expand All @@ -143,20 +154,27 @@ const AppContent = () => {
aria-labelledby='tab-source'
className={`absolute inset-0 overflow-y-auto bg-white dark:bg-gray-800 p-4 text-gray-900 dark:text-gray-100 transition-colors duration-200 ${activeTab === 'source' ? '' : 'hidden'}`}
>
<SourceTab
isActive={activeTab === 'source'}
rootPath={rootPath}
directoryTree={directoryTree}
selectedFiles={selectedFiles}
selectedFolders={selectedFolders}
configContent={configContent}
onDirectorySelect={selectDirectory}
onFileSelect={handleFileSelect}
onFolderSelect={handleFolderSelect}
onBatchSelect={handleBatchSelect}
onAnalyze={handleAnalyze}
onRefreshTree={refreshDirectoryTree}
/>
<ErrorBoundary
fallbackTitle={tabFallbackTitle}
fallbackMessage={tabFallbackMessage}
resetLabel={retryLabel}
resetKeys={[activeTab, rootPath, selectedFiles.size, selectedFolders.size]}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Using selectedFiles.size and selectedFolders.size as reset keys for the error boundary is not robust. If a user replaces one file with another in the selection, the size might remain the same, but the content of the selection has changed. If the previous selection caused a crash, the error boundary would not reset on this new selection, preventing the component from recovering and re-rendering with the valid selection.

To fix this, the reset key should depend on the content of the selection sets, not just their size. A simple way to create a stable key from the sets is to convert them to a sorted string.

              resetKeys={[activeTab, rootPath, [...selectedFiles].sort().join(','), [...selectedFolders].sort().join(',')]}

>
<SourceTab
isActive={activeTab === 'source'}
rootPath={rootPath}
directoryTree={directoryTree}
selectedFiles={selectedFiles}
selectedFolders={selectedFolders}
configContent={configContent}
onDirectorySelect={selectDirectory}
onFileSelect={handleFileSelect}
onFolderSelect={handleFolderSelect}
onBatchSelect={handleBatchSelect}
onAnalyze={handleAnalyze}
onRefreshTree={refreshDirectoryTree}
/>
</ErrorBoundary>
</div>

<div
Expand All @@ -165,11 +183,18 @@ const AppContent = () => {
aria-labelledby='tab-processed'
className={`absolute inset-0 overflow-y-auto bg-white dark:bg-gray-800 p-4 text-gray-900 dark:text-gray-100 transition-colors duration-200 ${activeTab === 'processed' ? '' : 'hidden'}`}
>
<ProcessedTab
processedResult={processedResult}
onSave={handleSaveOutput}
onRefresh={handleRefreshProcessed}
/>
<ErrorBoundary
fallbackTitle={tabFallbackTitle}
fallbackMessage={tabFallbackMessage}
resetLabel={retryLabel}
resetKeys={[activeTab, processedResult?.content]}
>
<ProcessedTab
processedResult={processedResult}
onSave={handleSaveOutput}
onRefresh={handleRefreshProcessed}
/>
</ErrorBoundary>
</div>
</div>
</div>
Expand All @@ -178,12 +203,20 @@ const AppContent = () => {
};

const App = () => {
const { t } = useTranslation();

return (
<DarkModeProvider>
<AppProvider>
<AppContent />
</AppProvider>
</DarkModeProvider>
<ErrorBoundary
fallbackTitle={t('errors.rendererRootCrashedTitle')}
fallbackMessage={t('errors.rendererRootCrashedDescription')}
resetLabel={t('errors.retryRender')}
>
<DarkModeProvider>
<AppProvider>
<AppContent />
</AppProvider>
</DarkModeProvider>
</ErrorBoundary>
);
};

Expand Down
85 changes: 85 additions & 0 deletions src/renderer/components/ErrorBoundary.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import React, { Component, ErrorInfo, ReactNode } from 'react';

type ErrorBoundaryProps = {
children: ReactNode;
fallbackTitle: string;
fallbackMessage: string;
resetLabel: string;
onReset?: () => void;
resetKeys?: ReadonlyArray<unknown>;
};

type ErrorBoundaryState = {
hasError: boolean;
};

const haveResetKeysChanged = (
previousKeys: ReadonlyArray<unknown> = [],
nextKeys: ReadonlyArray<unknown> = []
) => {
if (previousKeys.length !== nextKeys.length) {
return true;
}

for (let index = 0; index < previousKeys.length; index += 1) {
if (!Object.is(previousKeys[index], nextKeys[index])) {
return true;
}
}

return false;
};

class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
state: ErrorBoundaryState = {
hasError: false,
};

static getDerivedStateFromError() {
return { hasError: true };
}

componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error('Renderer ErrorBoundary captured an error:', error, errorInfo);
}

componentDidUpdate(previousProps: ErrorBoundaryProps) {
if (!this.state.hasError) {
return;
}

if (haveResetKeysChanged(previousProps.resetKeys, this.props.resetKeys)) {
this.setState({ hasError: false });
}
}
Comment on lines +46 to +54

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

2. Componentdidupdate missing previousstate guard 🐞 Bug ✓ Correctness

The componentDidUpdate override only accepts previousProps, omitting previousState. After
getDerivedStateFromError transitions hasError to true, React commits the fallback UI and then
calls componentDidUpdate with previousState.hasError === false. Without checking
previousState.hasError, if any resetKey also changed in the same render cycle, the just-caught
error is immediately cleared, the broken child re-renders, throws again, and the cycle repeats.
Agent Prompt
## Issue description
`componentDidUpdate` only receives `previousProps`, so there is no way to distinguish between "we just entered error state this cycle" and "we were already in error state from a previous cycle". The existing guard `if (!this.state.hasError) return` checks the *current* state (which is already `true` after `getDerivedStateFromError` ran), not the *previous* state. This means if any resetKey changed in the same render cycle the error was caught, the error is immediately cleared, the broken child re-renders, throws again, and the cycle repeats.

## Issue Context
React's lifecycle sequence when a child throws:
1. `getDerivedStateFromError` → sets `hasError: true` (previousState.hasError was `false`)
2. React renders fallback UI and commits to DOM
3. `componentDidUpdate(previousProps, previousState)` is called — `previousState.hasError === false`, `this.state.hasError === true`

The guard must check `previousState.hasError` to detect this transition and skip the reset logic during the initial error-catch cycle.

## Fix Focus Areas
- `src/renderer/components/ErrorBoundary.tsx[46-54]`

Change the method to:
```typescript
componentDidUpdate(previousProps: ErrorBoundaryProps, previousState: ErrorBoundaryState) {
  // Only auto-reset if we were ALREADY in error state, not when we just entered it.
  if (!previousState.hasError) {
    return;
  }

  if (haveResetKeysChanged(previousProps.resetKeys, this.props.resetKeys)) {
    this.setState({ hasError: false });
  }
}
```

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


private readonly handleReset = () => {
this.props.onReset?.();
this.setState({ hasError: false });
};

render() {
if (!this.state.hasError) {
return this.props.children;
}

return (
<div
role='alert'
className='m-4 rounded-md border border-red-200 bg-red-50 p-4 text-sm text-red-900 dark:border-red-800 dark:bg-red-900/30 dark:text-red-100'
>
<h2 className='text-base font-semibold'>{this.props.fallbackTitle}</h2>
<p className='mt-2'>{this.props.fallbackMessage}</p>
<button
type='button'
onClick={this.handleReset}
className='mt-3 rounded border border-red-300 bg-white px-3 py-1 text-xs font-medium text-red-700 hover:bg-red-100 dark:border-red-700 dark:bg-red-900/40 dark:text-red-100 dark:hover:bg-red-900/60'
>
{this.props.resetLabel}
</button>
</div>
);
}
}

export default ErrorBoundary;
7 changes: 6 additions & 1 deletion src/renderer/i18n/locales/de/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,11 @@
"noFilesSelectedForProcessing": "Es sind keine Dateien zur Verarbeitung ausgewählt. Wechsle zum Quell-Tab und wähle Dateien aus.",
"refreshFailed": "Beim Aktualisieren des Inhalts ist ein Fehler aufgetreten. Details in der Konsole.",
"noProcessedContentToSave": "Kein verarbeiteter Inhalt zum Speichern vorhanden.",
"saveFailed": "Beim Speichern der Datei ist ein Fehler aufgetreten. Details in der Konsole."
"saveFailed": "Beim Speichern der Datei ist ein Fehler aufgetreten. Details in der Konsole.",
"rendererRootCrashedTitle": "Die App hat einen unerwarteten Fehler festgestellt.",
"rendererRootCrashedDescription": "Bitte erneut versuchen. Wenn das erneut passiert, starte die App neu.",
"tabCrashedTitle": "Dieser Tab konnte nicht gerendert werden.",
"tabCrashedDescription": "Versuche diese Ansicht erneut. Falls das Problem bleibt, wechsle den Tab und versuche es noch einmal.",
"retryRender": "Erneut versuchen"
}
}
7 changes: 6 additions & 1 deletion src/renderer/i18n/locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,11 @@
"noFilesSelectedForProcessing": "No files are selected for processing. Please go to the Source tab and select files.",
"refreshFailed": "An error occurred while refreshing content. Check the console for details.",
"noProcessedContentToSave": "No processed content to save.",
"saveFailed": "An error occurred while saving the file. Check the console for details."
"saveFailed": "An error occurred while saving the file. Check the console for details.",
"rendererRootCrashedTitle": "The app hit an unexpected error.",
"rendererRootCrashedDescription": "Please retry. If this keeps happening, restart the app.",
"tabCrashedTitle": "This tab failed to render.",
"tabCrashedDescription": "Retry this view. If the issue persists, switch tabs and try again.",
"retryRender": "Retry"
}
}
7 changes: 6 additions & 1 deletion src/renderer/i18n/locales/es/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,11 @@
"noFilesSelectedForProcessing": "No hay archivos seleccionados para procesar. Ve a la pestaña Fuente y selecciona archivos.",
"refreshFailed": "Se produjo un error al actualizar el contenido. Revisa la consola para más detalles.",
"noProcessedContentToSave": "No hay contenido procesado para guardar.",
"saveFailed": "Se produjo un error al guardar el archivo. Revisa la consola para más detalles."
"saveFailed": "Se produjo un error al guardar el archivo. Revisa la consola para más detalles.",
"rendererRootCrashedTitle": "La aplicación encontró un error inesperado.",
"rendererRootCrashedDescription": "Intenta de nuevo. Si vuelve a ocurrir, reinicia la aplicación.",
"tabCrashedTitle": "Esta pestaña no se pudo renderizar.",
"tabCrashedDescription": "Vuelve a intentarlo en esta vista. Si persiste, cambia de pestaña e inténtalo otra vez.",
"retryRender": "Reintentar"
}
}
7 changes: 6 additions & 1 deletion src/renderer/i18n/locales/fr/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,11 @@
"noFilesSelectedForProcessing": "Aucun fichier sélectionné pour le traitement. Allez dans l'onglet Source et sélectionnez des fichiers.",
"refreshFailed": "Une erreur s'est produite lors de l'actualisation du contenu. Consultez la console pour plus de détails.",
"noProcessedContentToSave": "Aucun contenu traité à enregistrer.",
"saveFailed": "Une erreur s'est produite lors de l'enregistrement du fichier. Consultez la console pour plus de détails."
"saveFailed": "Une erreur s'est produite lors de l'enregistrement du fichier. Consultez la console pour plus de détails.",
"rendererRootCrashedTitle": "L'application a rencontré une erreur inattendue.",
"rendererRootCrashedDescription": "Réessayez. Si le problème persiste, redémarrez l'application.",
"tabCrashedTitle": "Cet onglet n'a pas pu être affiché.",
"tabCrashedDescription": "Réessayez cette vue. Si le problème persiste, changez d'onglet puis recommencez.",
"retryRender": "Réessayer"
}
}
1 change: 1 addition & 0 deletions tests/catalog.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ Purpose: quick map of what is covered, why it exists, and which command to run.
| ---------------------------------------------------------- | --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `tests/unit/components/app.test.tsx` | `src/renderer/components/App.tsx` | Tab switching, config load, directory selection, processing flow, error handling |
| `tests/unit/components/app-source-tab-activity.test.tsx` | `src/renderer/components/App.tsx` + `src/renderer/components/SourceTab.tsx` | Guards against hidden-tab background token counting after tab switch |
| `tests/unit/components/error-boundary.test.tsx` | `src/renderer/components/ErrorBoundary.tsx` | Child render failure capture, fallback rendering, reset-key recovery, and retry callback behavior |
| `tests/unit/components/config-tab.test.tsx` | `src/renderer/components/ConfigTab.tsx` | Config toggles/inputs, dev-only provider surface gating, provider validation/connection wiring, provider-config preservation, directory picker trigger |
| `tests/unit/components/file-tree.test.tsx` | `src/renderer/components/FileTree.tsx` | Tree render, folder expand/collapse, select all, empty-state behavior |
| `tests/unit/components/language-selector.test.tsx` | `src/renderer/components/LanguageSelector.tsx` | Locale selector rendering, language switching, and localStorage persistence |
Expand Down
97 changes: 97 additions & 0 deletions tests/unit/components/error-boundary.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import React, { useState } from 'react';
import { fireEvent, render, screen } from '@testing-library/react';

import ErrorBoundary from '../../../src/renderer/components/ErrorBoundary';

const ProblemChild = ({ shouldThrow }: { shouldThrow: boolean }) => {
if (shouldThrow) {
throw new Error('render boom');
}
return <div>child rendered</div>;
};

describe('ErrorBoundary', () => {
let consoleErrorSpy: jest.SpyInstance;

beforeEach(() => {
consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
});

afterEach(() => {
consoleErrorSpy.mockRestore();
});

it('renders children when no error is thrown', () => {
render(
<ErrorBoundary
fallbackTitle='Fallback title'
fallbackMessage='Fallback message'
resetLabel='Retry'
>
<ProblemChild shouldThrow={false} />
</ErrorBoundary>
);

expect(screen.getByText('child rendered')).toBeInTheDocument();
});

it('shows fallback UI when a child throws', () => {
render(
<ErrorBoundary
fallbackTitle='Fallback title'
fallbackMessage='Fallback message'
resetLabel='Retry'
>
<ProblemChild shouldThrow />
</ErrorBoundary>
);

expect(screen.getByRole('alert')).toHaveTextContent('Fallback title');
expect(screen.getByRole('alert')).toHaveTextContent('Fallback message');
});

it('resets and renders children again when reset keys change', () => {
const Harness = () => {
const [shouldThrow, setShouldThrow] = useState(true);
return (
<>
<button type='button' onClick={() => setShouldThrow(false)}>
recover
</button>
<ErrorBoundary
fallbackTitle='Fallback title'
fallbackMessage='Fallback message'
resetLabel='Retry'
resetKeys={[shouldThrow]}
>
<ProblemChild shouldThrow={shouldThrow} />
</ErrorBoundary>
</>
);
};

render(<Harness />);
expect(screen.getByRole('alert')).toBeInTheDocument();

fireEvent.click(screen.getByText('recover'));
expect(screen.getByText('child rendered')).toBeInTheDocument();
});

it('calls onReset when retry button is clicked', () => {
const onReset = jest.fn();

render(
<ErrorBoundary
fallbackTitle='Fallback title'
fallbackMessage='Fallback message'
resetLabel='Retry'
onReset={onReset}
>
<ProblemChild shouldThrow />
</ErrorBoundary>
);

fireEvent.click(screen.getByRole('button', { name: 'Retry' }));
expect(onReset).toHaveBeenCalledTimes(1);
});
});