@alepha/react 0.13.6 → 0.13.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,163 +1,411 @@
1
1
  import type { Alepha } from "alepha";
2
- import { useState } from "react";
2
+ import { type CSSProperties, useState } from "react";
3
3
 
4
4
  interface ErrorViewerProps {
5
5
  error: Error;
6
6
  alepha: Alepha;
7
7
  }
8
8
 
9
- // TODO: design this better
9
+ interface StackFrame {
10
+ fn: string;
11
+ file: string;
12
+ line: string;
13
+ col: string;
14
+ raw: string;
15
+ }
10
16
 
17
+ /**
18
+ * Error viewer component that displays error details in development mode
19
+ */
11
20
  const ErrorViewer = ({ error, alepha }: ErrorViewerProps) => {
12
21
  const [expanded, setExpanded] = useState(false);
13
22
  const isProduction = alepha.isProduction();
14
- // const status = isHttpError(error) ? error.status : 500;
15
23
 
16
24
  if (isProduction) {
17
25
  return <ErrorViewerProduction />;
18
26
  }
19
27
 
20
- const stackLines = error.stack?.split("\n") ?? [];
21
- const previewLines = stackLines.slice(0, 5);
22
- const hiddenLineCount = stackLines.length - previewLines.length;
28
+ const frames = parseStackTrace(error.stack);
29
+ const visibleFrames = expanded ? frames : frames.slice(0, 6);
30
+ const hiddenCount = frames.length - 6;
23
31
 
24
- const copyToClipboard = (text: string) => {
25
- navigator.clipboard.writeText(text).catch((err) => {
26
- console.error("Clipboard error:", err);
27
- });
28
- };
32
+ return (
33
+ <div style={styles.overlay}>
34
+ <div style={styles.container}>
35
+ <Header error={error} />
36
+ <StackTraceSection
37
+ frames={frames}
38
+ visibleFrames={visibleFrames}
39
+ expanded={expanded}
40
+ hiddenCount={hiddenCount}
41
+ onToggle={() => setExpanded(!expanded)}
42
+ />
43
+ </div>
44
+ </div>
45
+ );
46
+ };
47
+
48
+ export default ErrorViewer;
49
+
50
+ /**
51
+ * Parse stack trace string into structured frames
52
+ */
53
+ function parseStackTrace(stack?: string): StackFrame[] {
54
+ if (!stack) return [];
55
+
56
+ const lines = stack.split("\n").slice(1);
57
+ const frames: StackFrame[] = [];
58
+
59
+ for (const line of lines) {
60
+ const trimmed = line.trim();
61
+ if (!trimmed.startsWith("at ")) continue;
62
+
63
+ const frame = parseStackLine(trimmed);
64
+ if (frame) frames.push(frame);
65
+ }
66
+
67
+ return frames;
68
+ }
69
+
70
+ /**
71
+ * Parse a single stack trace line into a structured frame
72
+ */
73
+ function parseStackLine(line: string): StackFrame | null {
74
+ const withFn = line.match(/^at\s+(.+?)\s+\((.+):(\d+):(\d+)\)$/);
75
+ if (withFn) {
76
+ return {
77
+ fn: withFn[1],
78
+ file: withFn[2],
79
+ line: withFn[3],
80
+ col: withFn[4],
81
+ raw: line,
82
+ };
83
+ }
84
+
85
+ const withoutFn = line.match(/^at\s+(.+):(\d+):(\d+)$/);
86
+ if (withoutFn) {
87
+ return {
88
+ fn: "<anonymous>",
89
+ file: withoutFn[1],
90
+ line: withoutFn[2],
91
+ col: withoutFn[3],
92
+ raw: line,
93
+ };
94
+ }
95
+
96
+ return { fn: "", file: line.replace(/^at\s+/, ""), line: "", col: "", raw: line };
97
+ }
98
+
99
+ /**
100
+ * Copy text to clipboard
101
+ */
102
+ function copyToClipboard(text: string): void {
103
+ navigator.clipboard.writeText(text).catch((err) => {
104
+ console.error("Clipboard error:", err);
105
+ });
106
+ }
107
+
108
+ /**
109
+ * Header section with error type and message
110
+ */
111
+ function Header({ error }: { error: Error }) {
112
+ const [copied, setCopied] = useState(false);
29
113
 
30
- const styles = {
31
- container: {
32
- padding: "24px",
33
- backgroundColor: "#FEF2F2",
34
- color: "#7F1D1D",
35
- border: "1px solid #FECACA",
36
- borderRadius: "16px",
37
- boxShadow: "0 8px 24px rgba(0,0,0,0.05)",
38
- fontFamily: "monospace",
39
- maxWidth: "768px",
40
- margin: "40px auto",
41
- },
42
- heading: {
43
- fontSize: "20px",
44
- fontWeight: "bold",
45
- marginBottom: "10px",
46
- },
47
- name: {
48
- fontSize: "16px",
49
- fontWeight: 600,
50
- },
51
- message: {
52
- fontSize: "14px",
53
- marginBottom: "16px",
54
- },
55
- sectionHeader: {
56
- display: "flex",
57
- justifyContent: "space-between",
58
- alignItems: "center",
59
- fontSize: "12px",
60
- marginBottom: "4px",
61
- color: "#991B1B",
62
- },
63
- copyButton: {
64
- fontSize: "12px",
65
- color: "#DC2626",
66
- background: "none",
67
- border: "none",
68
- cursor: "pointer",
69
- textDecoration: "underline",
70
- },
71
- stackContainer: {
72
- backgroundColor: "#FEE2E2",
73
- padding: "12px",
74
- borderRadius: "8px",
75
- fontSize: "13px",
76
- lineHeight: "1.4",
77
- overflowX: "auto" as const,
78
- whiteSpace: "pre-wrap" as const,
79
- },
80
- expandLine: {
81
- color: "#F87171",
82
- cursor: "pointer",
83
- marginTop: "8px",
84
- },
114
+ const handleCopy = () => {
115
+ copyToClipboard(error.stack || error.message);
116
+ setCopied(true);
117
+ setTimeout(() => setCopied(false), 2000);
85
118
  };
86
119
 
87
120
  return (
88
- <div style={styles.container}>
89
- <div>
90
- <div style={styles.heading}>🔥 Error</div>
91
- <div style={styles.name}>{error.name}</div>
92
- <div style={styles.message}>{error.message}</div>
121
+ <div style={styles.header}>
122
+ <div style={styles.headerTop}>
123
+ <div style={styles.badge}>{error.name}</div>
124
+ <button type="button" onClick={handleCopy} style={styles.copyBtn}>
125
+ {copied ? "Copied" : "Copy Stack"}
126
+ </button>
93
127
  </div>
128
+ <h1 style={styles.message}>{error.message}</h1>
129
+ </div>
130
+ );
131
+ }
94
132
 
95
- {stackLines.length > 0 && (
96
- <div>
97
- <div style={styles.sectionHeader}>
98
- <span>Stack trace</span>
99
- <button
100
- type="button"
101
- onClick={() => copyToClipboard(error.stack!)}
102
- style={styles.copyButton}
103
- >
104
- Copy all
105
- </button>
106
- </div>
107
- <pre style={styles.stackContainer}>
108
- {(expanded ? stackLines : previewLines).map((line, i) => (
109
- <div key={i}>{line}</div>
110
- ))}
111
- {!expanded && hiddenLineCount > 0 && (
112
- <div style={styles.expandLine} onClick={() => setExpanded(true)}>
113
- + {hiddenLineCount} more lines...
114
- </div>
115
- )}
116
- </pre>
117
- </div>
118
- )}
133
+ /**
134
+ * Stack trace section with expandable frames
135
+ */
136
+ function StackTraceSection({
137
+ frames,
138
+ visibleFrames,
139
+ expanded,
140
+ hiddenCount,
141
+ onToggle,
142
+ }: {
143
+ frames: StackFrame[];
144
+ visibleFrames: StackFrame[];
145
+ expanded: boolean;
146
+ hiddenCount: number;
147
+ onToggle: () => void;
148
+ }) {
149
+ if (frames.length === 0) return null;
150
+
151
+ return (
152
+ <div style={styles.stackSection}>
153
+ <div style={styles.stackHeader}>Call Stack</div>
154
+ <div style={styles.frameList}>
155
+ {visibleFrames.map((frame, i) => (
156
+ <StackFrameRow key={i} frame={frame} index={i} />
157
+ ))}
158
+ {!expanded && hiddenCount > 0 && (
159
+ <button type="button" onClick={onToggle} style={styles.expandBtn}>
160
+ Show {hiddenCount} more frames
161
+ </button>
162
+ )}
163
+ {expanded && hiddenCount > 0 && (
164
+ <button type="button" onClick={onToggle} style={styles.expandBtn}>
165
+ Show less
166
+ </button>
167
+ )}
168
+ </div>
119
169
  </div>
120
170
  );
121
- };
171
+ }
122
172
 
123
- export default ErrorViewer;
173
+ /**
174
+ * Single stack frame row
175
+ */
176
+ function StackFrameRow({ frame, index }: { frame: StackFrame; index: number }) {
177
+ const isFirst = index === 0;
178
+ const fileName = frame.file.split("/").pop() || frame.file;
179
+ const dirPath = frame.file.substring(0, frame.file.length - fileName.length);
124
180
 
125
- const ErrorViewerProduction = () => {
126
- const styles = {
127
- container: {
128
- padding: "24px",
129
- backgroundColor: "#FEF2F2",
130
- color: "#7F1D1D",
131
- border: "1px solid #FECACA",
132
- borderRadius: "16px",
133
- boxShadow: "0 8px 24px rgba(0,0,0,0.05)",
134
- fontFamily: "monospace",
135
- maxWidth: "768px",
136
- margin: "40px auto",
137
- textAlign: "center" as const,
138
- },
139
- heading: {
140
- fontSize: "20px",
141
- fontWeight: "bold",
142
- marginBottom: "8px",
143
- },
144
- name: {
145
- fontSize: "16px",
146
- fontWeight: 600,
147
- marginBottom: "4px",
148
- },
149
- message: {
150
- fontSize: "14px",
151
- opacity: 0.85,
152
- },
153
- };
181
+ return (
182
+ <div
183
+ style={{
184
+ ...styles.frame,
185
+ ...(isFirst ? styles.frameFirst : {}),
186
+ }}
187
+ >
188
+ <div style={styles.frameIndex}>{index + 1}</div>
189
+ <div style={styles.frameContent}>
190
+ {frame.fn && (
191
+ <div style={styles.fnName}>
192
+ {frame.fn}
193
+ </div>
194
+ )}
195
+ <div style={styles.filePath}>
196
+ <span style={styles.dirPath}>{dirPath}</span>
197
+ <span style={styles.fileName}>{fileName}</span>
198
+ {frame.line && (
199
+ <span style={styles.lineCol}>
200
+ :{frame.line}:{frame.col}
201
+ </span>
202
+ )}
203
+ </div>
204
+ </div>
205
+ </div>
206
+ );
207
+ }
154
208
 
209
+ /**
210
+ * Production error view - minimal information
211
+ */
212
+ function ErrorViewerProduction() {
155
213
  return (
156
- <div style={styles.container}>
157
- <div style={styles.heading}>🚨 An error occurred</div>
158
- <div style={styles.message}>
159
- Something went wrong. Please try again later.
214
+ <div style={styles.overlay}>
215
+ <div style={styles.prodContainer}>
216
+ <div style={styles.prodIcon}>!</div>
217
+ <h1 style={styles.prodTitle}>Application Error</h1>
218
+ <p style={styles.prodMessage}>
219
+ An unexpected error occurred. Please try again later.
220
+ </p>
221
+ <button
222
+ type="button"
223
+ onClick={() => window.location.reload()}
224
+ style={styles.prodButton}
225
+ >
226
+ Reload Page
227
+ </button>
160
228
  </div>
161
229
  </div>
162
230
  );
231
+ }
232
+
233
+ const styles: Record<string, CSSProperties> = {
234
+ overlay: {
235
+ position: "fixed",
236
+ inset: 0,
237
+ backgroundColor: "rgba(0, 0, 0, 0.8)",
238
+ display: "flex",
239
+ alignItems: "flex-start",
240
+ justifyContent: "center",
241
+ padding: "40px 20px",
242
+ overflow: "auto",
243
+ fontFamily:
244
+ '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
245
+ zIndex: 99999,
246
+ },
247
+ container: {
248
+ width: "100%",
249
+ maxWidth: "960px",
250
+ backgroundColor: "#1a1a1a",
251
+ borderRadius: "12px",
252
+ overflow: "hidden",
253
+ boxShadow: "0 25px 50px -12px rgba(0, 0, 0, 0.5)",
254
+ },
255
+ header: {
256
+ padding: "24px 28px",
257
+ borderBottom: "1px solid #333",
258
+ background: "linear-gradient(to bottom, #1f1f1f, #1a1a1a)",
259
+ },
260
+ headerTop: {
261
+ display: "flex",
262
+ alignItems: "center",
263
+ justifyContent: "space-between",
264
+ marginBottom: "16px",
265
+ },
266
+ badge: {
267
+ display: "inline-block",
268
+ padding: "6px 12px",
269
+ backgroundColor: "#dc2626",
270
+ color: "#fff",
271
+ fontSize: "12px",
272
+ fontWeight: 600,
273
+ borderRadius: "6px",
274
+ letterSpacing: "0.025em",
275
+ },
276
+ copyBtn: {
277
+ padding: "8px 16px",
278
+ backgroundColor: "transparent",
279
+ color: "#888",
280
+ fontSize: "13px",
281
+ fontWeight: 500,
282
+ border: "1px solid #444",
283
+ borderRadius: "6px",
284
+ cursor: "pointer",
285
+ transition: "all 0.15s",
286
+ },
287
+ message: {
288
+ margin: 0,
289
+ fontSize: "20px",
290
+ fontWeight: 500,
291
+ color: "#fff",
292
+ lineHeight: 1.5,
293
+ wordBreak: "break-word",
294
+ },
295
+ stackSection: {
296
+ padding: "0",
297
+ },
298
+ stackHeader: {
299
+ padding: "16px 28px",
300
+ fontSize: "11px",
301
+ fontWeight: 600,
302
+ color: "#666",
303
+ textTransform: "uppercase",
304
+ letterSpacing: "0.1em",
305
+ borderBottom: "1px solid #2a2a2a",
306
+ },
307
+ frameList: {
308
+ display: "flex",
309
+ flexDirection: "column",
310
+ },
311
+ frame: {
312
+ display: "flex",
313
+ alignItems: "flex-start",
314
+ padding: "14px 28px",
315
+ borderBottom: "1px solid #252525",
316
+ transition: "background-color 0.15s",
317
+ },
318
+ frameFirst: {
319
+ backgroundColor: "rgba(220, 38, 38, 0.1)",
320
+ },
321
+ frameIndex: {
322
+ width: "28px",
323
+ flexShrink: 0,
324
+ fontSize: "12px",
325
+ fontWeight: 500,
326
+ color: "#555",
327
+ fontFamily: "monospace",
328
+ },
329
+ frameContent: {
330
+ flex: 1,
331
+ minWidth: 0,
332
+ },
333
+ fnName: {
334
+ fontSize: "14px",
335
+ fontWeight: 500,
336
+ color: "#e5e5e5",
337
+ marginBottom: "4px",
338
+ fontFamily:
339
+ 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace',
340
+ },
341
+ filePath: {
342
+ fontSize: "13px",
343
+ color: "#888",
344
+ fontFamily:
345
+ 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace',
346
+ wordBreak: "break-all",
347
+ },
348
+ dirPath: {
349
+ color: "#555",
350
+ },
351
+ fileName: {
352
+ color: "#0ea5e9",
353
+ },
354
+ lineCol: {
355
+ color: "#eab308",
356
+ },
357
+ expandBtn: {
358
+ padding: "16px 28px",
359
+ backgroundColor: "transparent",
360
+ color: "#666",
361
+ fontSize: "13px",
362
+ fontWeight: 500,
363
+ border: "none",
364
+ borderTop: "1px solid #252525",
365
+ cursor: "pointer",
366
+ textAlign: "left",
367
+ transition: "all 0.15s",
368
+ },
369
+ prodContainer: {
370
+ textAlign: "center",
371
+ padding: "60px 40px",
372
+ backgroundColor: "#1a1a1a",
373
+ borderRadius: "12px",
374
+ maxWidth: "400px",
375
+ },
376
+ prodIcon: {
377
+ width: "64px",
378
+ height: "64px",
379
+ margin: "0 auto 24px",
380
+ backgroundColor: "#dc2626",
381
+ borderRadius: "50%",
382
+ display: "flex",
383
+ alignItems: "center",
384
+ justifyContent: "center",
385
+ fontSize: "32px",
386
+ fontWeight: 700,
387
+ color: "#fff",
388
+ },
389
+ prodTitle: {
390
+ margin: "0 0 12px",
391
+ fontSize: "24px",
392
+ fontWeight: 600,
393
+ color: "#fff",
394
+ },
395
+ prodMessage: {
396
+ margin: "0 0 28px",
397
+ fontSize: "15px",
398
+ color: "#888",
399
+ lineHeight: 1.6,
400
+ },
401
+ prodButton: {
402
+ padding: "12px 24px",
403
+ backgroundColor: "#fff",
404
+ color: "#000",
405
+ fontSize: "14px",
406
+ fontWeight: 600,
407
+ border: "none",
408
+ borderRadius: "8px",
409
+ cursor: "pointer",
410
+ },
163
411
  };
@@ -1,11 +1,13 @@
1
1
  import { memo, type ReactNode, use, useRef, useState } from "react";
2
2
  import { RouterLayerContext } from "../contexts/RouterLayerContext.ts";
3
- import type { PageAnimation } from "../primitives/$page.ts";
3
+ import type { ErrorHandler, PageAnimation } from "../primitives/$page.ts";
4
4
  import { Redirection } from "../errors/Redirection.ts";
5
5
  import { useEvents } from "../hooks/useEvents.ts";
6
6
  import { useRouterState } from "../hooks/useRouterState.ts";
7
7
  import type { ReactRouterState } from "../providers/ReactPageProvider.ts";
8
8
  import ErrorBoundary from "./ErrorBoundary.tsx";
9
+ import ErrorViewer from "./ErrorViewer.tsx";
10
+ import { useAlepha } from "../hooks/useAlepha.ts";
9
11
 
10
12
  export interface NestedViewProps {
11
13
  children?: ReactNode;
@@ -34,8 +36,11 @@ export interface NestedViewProps {
34
36
  * ```
35
37
  */
36
38
  const NestedView = (props: NestedViewProps) => {
37
- const index = use(RouterLayerContext)?.index ?? 0;
39
+ const routerLayer = use(RouterLayerContext);
40
+ const index = routerLayer?.index ?? 0;
41
+ const onError = routerLayer?.onError;
38
42
  const state = useRouterState();
43
+ const alepha = useAlepha();
39
44
 
40
45
  const [view, setView] = useState<ReactNode | undefined>(
41
46
  state.layers[index]?.element,
@@ -148,16 +153,16 @@ const NestedView = (props: NestedViewProps) => {
148
153
  );
149
154
  }
150
155
 
156
+ const fallback = (error: Error) => {
157
+ const result = onError?.(error, state) ?? <ErrorViewer error={error} alepha={alepha}/>;
158
+ if (result instanceof Redirection) {
159
+ return "Redirection inside ErrorBoundary is not allowed.";
160
+ }
161
+ return result as ReactNode;
162
+ }
163
+
151
164
  return (
152
- <ErrorBoundary
153
- fallback={(error) => {
154
- const result = state.onError(error, state); // TODO: onError is not refreshed
155
- if (result instanceof Redirection) {
156
- return "Redirection inside ErrorBoundary is not allowed.";
157
- }
158
- return result as ReactNode;
159
- }}
160
- >
165
+ <ErrorBoundary fallback={fallback}>
161
166
  {element}
162
167
  </ErrorBoundary>
163
168
  );
@@ -1,8 +1,10 @@
1
1
  import { createContext } from "react";
2
+ import type { ErrorHandler } from "../primitives/$page.ts";
2
3
 
3
4
  export interface RouterLayerContextValue {
4
5
  index: number;
5
6
  path: string;
7
+ onError: ErrorHandler;
6
8
  }
7
9
 
8
10
  export const RouterLayerContext = createContext<
@@ -13,6 +13,7 @@ import {
13
13
  } from "react";
14
14
  import { useAlepha } from "./useAlepha.ts";
15
15
  import { useInject } from "./useInject.ts";
16
+ import type { Async } from "alepha";
16
17
 
17
18
  /**
18
19
  * Hook for handling async actions with automatic error handling and event emission.
@@ -361,7 +362,7 @@ export interface UseActionOptions<Args extends any[] = any[], Result = any> {
361
362
  * The async action handler function.
362
363
  * Receives the action arguments plus an ActionContext as the last parameter.
363
364
  */
364
- handler: (...args: [...Args, ActionContext]) => Promise<Result>;
365
+ handler: (...args: [...Args, ActionContext]) => Async<Result>;
365
366
 
366
367
  /**
367
368
  * Custom error handler. If provided, prevents default error re-throw.
@@ -378,6 +379,8 @@ export interface UseActionOptions<Args extends any[] = any[], Result = any> {
378
379
  */
379
380
  id?: string;
380
381
 
382
+ name?: string;
383
+
381
384
  /**
382
385
  * Debounce delay in milliseconds. If specified, the action will only execute
383
386
  * after the specified delay has passed since the last call. Useful for search inputs
@@ -150,10 +150,10 @@ export interface PagePrimitiveOptions<
150
150
  * Load data before rendering the page.
151
151
  *
152
152
  * This function receives
153
- * - the request context and
153
+ * - the request context (params, query, etc.)
154
154
  * - the parent props (if page has a parent)
155
155
  *
156
- * In SSR, the returned data will be serialized and sent to the client, then reused during the client-side hydration.
156
+ * > In SSR, the returned data will be serialized and sent to the client, then reused during the client-side hydration.
157
157
  *
158
158
  * Resolve can be stopped by throwing an error, which will be handled by the `errorHandler` function.
159
159
  * It's common to throw a `NotFoundError` to display a 404 page.
@@ -162,6 +162,13 @@ export interface PagePrimitiveOptions<
162
162
  */
163
163
  resolve?: (context: PageResolve<TConfig, TPropsParent>) => Async<TProps>;
164
164
 
165
+ /**
166
+ * Default props to pass to the component when rendering the page.
167
+ *
168
+ * Resolved props from the `resolve` function will override these default props.
169
+ */
170
+ props?: () => Partial<TProps>;
171
+
165
172
  /**
166
173
  * The component to render when the page is loaded.
167
174
  *
@@ -189,6 +196,12 @@ export interface PagePrimitiveOptions<
189
196
  */
190
197
  parent?: PagePrimitive<PageConfigSchema, TPropsParent, any>;
191
198
 
199
+ /**
200
+ * Function to determine if the page can be accessed.
201
+ *
202
+ * If it returns false, the page will not be accessible and a 403 Forbidden error will be returned.
203
+ * This function can be used to implement permission-based access control.
204
+ */
192
205
  can?: () => boolean;
193
206
 
194
207
  /**