Skip to content
Open
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
153 changes: 95 additions & 58 deletions apps/desktop/src/renderer/error-boundary.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,12 @@ import { ICON_SIZE, AlertTriangle, Check, Clipboard, RotateCw } from '@maka/ui/i
import { Button as UiButton, Card, redactSecrets } from '@maka/ui';
import { getShellCopy } from './locales/shell-copy.js';

export type ErrorBoundaryCopyState = 'idle' | 'pending' | 'copied' | 'failed';

type State = {
error: Error | null;
errorInfo: ErrorInfo | null;
copyState: 'idle' | 'pending' | 'copied' | 'failed';
copyState: ErrorBoundaryCopyState;
};

const RENDERER_ERROR_DETAILS_MAX_BYTES = 24 * 1024;
Expand Down Expand Up @@ -135,68 +137,103 @@ export class ErrorBoundary extends Component<{ children: ReactNode; locale: UiLo
render(): ReactNode {
const { error, errorInfo, copyState } = this.state;
if (!error) return this.props.children;
const safeStack = redactSecrets(`${error.name}: ${error.message}${error.stack ? `\n\n${error.stack}` : ''}`);
const copyPending = copyState === 'pending';
const copy = getShellCopy(this.props.locale).errorBoundary;
const copyLabel = copyPending
? copy.copyPending
: copyState === 'copied'
? copy.copied
: copyState === 'failed'
? copy.copyFailed
: copy.copyReport;
const CopyIcon = copyState === 'copied' ? Check : Clipboard;

return (
<div className="maka-error-surface" role="alert" aria-live="assertive">
{/* Astryx Card owns the card face: red tint for the destructive
surface, high elevation for the former shadow-modal. The class
keeps only the icon/copy grid geometry. */}
<Card variant="red" elevation="high" padding={0} className="maka-error-card">
<span className="maka-error-icon" aria-hidden="true">
<AlertTriangle size={ICON_SIZE.empty} /> {/* 20 in the 32px plate — the ladder's fill convention */}
</span>
<div className="maka-error-copy">
<h2>{copy.title}</h2>
<p>
{copy.descriptionBeforeRetry} <strong>{copy.retry}</strong> {copy.descriptionBeforeReload}{' '}
<strong>{copy.reload}</strong> {copy.descriptionAfterReload}
</p>
<pre className="maka-error-stack" role="group" aria-label={copy.errorDetails}>
{safeStack}
</pre>
{errorInfo?.componentStack && (
<pre className="maka-error-stack" role="group" aria-label={copy.componentStack}>
{redactSecrets(errorInfo.componentStack.trim())}
</pre>
)}
<div className="maka-error-actions">
<UiButton
variant="secondary"
className="maka-error-copy-action"
data-copy-state={copyState}
isDisabled={copyPending}
aria-busy={copyPending ? 'true' : undefined}
onClick={this.handleCopyReport}
icon={<CopyIcon size={ICON_SIZE.control} aria-hidden="true" />}
label={copyLabel}
/>
<UiButton
variant="secondary"
onClick={this.handleReset}
icon={<RotateCw size={ICON_SIZE.control} aria-hidden="true" />}
label={copy.retry}
/>
<UiButton variant="primary" onClick={this.handleReload} label={copy.reload} />
</div>
{copyState === 'failed' && <p className="maka-error-copy-status">{copy.clipboardFailure}</p>}
</div>
</Card>
</div>
<ErrorBoundaryFallback
error={error}
errorInfo={errorInfo}
copyState={copyState}
locale={this.props.locale}
onCopyReport={this.handleCopyReport}
onReset={this.handleReset}
onReload={this.handleReload}
/>
);
}
}

// The fallback face, split out of the class so its four `copyState` values can
// be rendered directly in Storybook — the crash surface is otherwise reachable
// only by actually crashing the renderer (and the failed-copy state only by
// additionally failing the clipboard bridge).
// The class owns all state and side effects; this component only paints.
export function ErrorBoundaryFallback({
error,
errorInfo,
copyState,
locale,
onCopyReport,
onReset,
onReload,
}: {
error: Error;
errorInfo?: ErrorInfo | null;
copyState: ErrorBoundaryCopyState;
locale: UiLocale;
onCopyReport: () => void;
onReset: () => void;
onReload: () => void;
}): ReactNode {
const safeStack = redactSecrets(`${error.name}: ${error.message}${error.stack ? `\n\n${error.stack}` : ''}`);
const copyPending = copyState === 'pending';
const copy = getShellCopy(locale).errorBoundary;
const copyLabel = copyPending
? copy.copyPending
: copyState === 'copied'
? copy.copied
: copyState === 'failed'
? copy.copyFailed
: copy.copyReport;
const CopyIcon = copyState === 'copied' ? Check : Clipboard;

return (
<div className="maka-error-surface" role="alert" aria-live="assertive">
{/* Astryx Card owns the card face: red tint for the destructive
surface, high elevation for the former shadow-modal. The class
keeps only the icon/copy grid geometry. */}
<Card variant="red" elevation="high" padding={0} className="maka-error-card">
<span className="maka-error-icon" aria-hidden="true">
<AlertTriangle size={ICON_SIZE.empty} /> {/* 20 in the 32px plate — the ladder's fill convention */}
</span>
<div className="maka-error-copy">
<h2>{copy.title}</h2>
<p>
{copy.descriptionBeforeRetry} <strong>{copy.retry}</strong> {copy.descriptionBeforeReload}{' '}
<strong>{copy.reload}</strong> {copy.descriptionAfterReload}
</p>
<pre className="maka-error-stack" role="group" aria-label={copy.errorDetails}>
{safeStack}
</pre>
{errorInfo?.componentStack && (
<pre className="maka-error-stack" role="group" aria-label={copy.componentStack}>
{redactSecrets(errorInfo.componentStack.trim())}
</pre>
)}
<div className="maka-error-actions">
<UiButton
variant="secondary"
className="maka-error-copy-action"
data-copy-state={copyState}
isDisabled={copyPending}
aria-busy={copyPending ? 'true' : undefined}
onClick={onCopyReport}
icon={<CopyIcon size={ICON_SIZE.control} aria-hidden="true" />}
label={copyLabel}
/>
<UiButton
variant="secondary"
onClick={onReset}
icon={<RotateCw size={ICON_SIZE.control} aria-hidden="true" />}
label={copy.retry}
/>
<UiButton variant="primary" onClick={onReload} label={copy.reload} />
</div>
{copyState === 'failed' && <p className="maka-error-copy-status">{copy.clipboardFailure}</p>}
</div>
</Card>
</div>
);
}

function formatRendererErrorDetails(error: Error, info?: ErrorInfo | null): string {
const lines = [`${error.name}: ${error.message}`];
if (error.stack) lines.push('', 'Stack:', error.stack);
Expand Down
106 changes: 106 additions & 0 deletions apps/desktop/stories/error-boundary.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

import type { Meta, StoryObj } from '@storybook/react-vite';
import { fn } from 'storybook/test';
import type { ErrorInfo } from 'react';
import {
ErrorBoundaryFallback,
type ErrorBoundaryCopyState,
} from '../src/renderer/error-boundary';

const meta = {
title: 'Product/Shell/Error Boundary',
parameters: { layout: 'fullscreen' },
} satisfies Meta;

export default meta;
type Story = StoryObj<typeof meta>;

// The class owns the copy/reset/reload side effects; the fallback only paints,
// so these mocks stand in for the wired handlers without touching state.
const onCopyReport = fn();
const onReset = fn();
const onReload = fn();

// Explicitly synthetic diagnostics used only to exercise the fallback layout.
// The generic fixture names do not represent a Maka product call chain.
function buildRendererError(name: string, message: string, frames: string[]): Error {
const error = new Error(message);
error.name = name;
error.stack = [`${name}: ${message}`, ...frames.map((frame) => ` at ${frame}`)].join('\n');
return error;
}

const syntheticError = buildRendererError(
'TypeError',
"Cannot read properties of undefined (reading 'messages')",
[
'SyntheticCrashFixture (<synthetic-storybook-fixture>:42:7)',
'renderWithHooks (react-dom.development.js:15486:18)',
'mountIndeterminateComponent (react-dom.development.js:20103:13)',
'beginWork (react-dom.development.js:21626:16)',
],
);

const syntheticComponentStack: ErrorInfo = {
componentStack: [
'',
' at SyntheticCrashFixture (<synthetic-storybook-fixture>:42:7)',
' at SyntheticParentFixture (<synthetic-storybook-fixture>:18:3)',
' at ErrorBoundary',
].join('\n'),
};

const resolveLocale = (globals: Record<string, unknown>) => (globals.locale === 'en' ? 'en' : 'zh');

function fallback(copyState: ErrorBoundaryCopyState, error: Error, errorInfo: ErrorInfo) {
return (_args: unknown, { globals }: { globals: Record<string, unknown> }) => (
<ErrorBoundaryFallback
error={error}
errorInfo={errorInfo}
copyState={copyState}
locale={resolveLocale(globals)}
onCopyReport={onCopyReport}
onReset={onReset}
onReload={onReload}
/>
);
}

// Visual snapshot of the fallback's idle state. In production, ErrorBoundary supplies
// this Error/ErrorInfo shape after catching a renderer crash.
export const DefaultFallback: Story = {
render: fallback('idle', syntheticError, syntheticComponentStack),
};

// Visual snapshot of the fallback's pending state; it does not exercise the copy transition.
export const CopyPending: Story = {
render: fallback('pending', syntheticError, syntheticComponentStack),
};

// Visual snapshot of the fallback's copied state; it does not exercise the copy transition.
export const Copied: Story = {
render: fallback('copied', syntheticError, syntheticComponentStack),
};

// Visual snapshot of the fallback's failed state; it does not exercise the copy transition.
export const CopyFailed: Story = {
render: fallback('failed', syntheticError, syntheticComponentStack),
};