tycho-components 0.41.6 → 0.42.0

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.
@@ -79,35 +79,33 @@ export default function AppToast() {
79
79
  }
80
80
  }, [state.toastLoading, state.toastLoadingText]);
81
81
  useEffect(() => {
82
- if (state.message && state.message.value !== '') {
83
- const persistent = state.message.autoClose === false;
84
- const toastOptions = {
85
- onClose: () => handleClose(),
86
- onClick: () => handleToastAction(),
87
- ...(persistent
88
- ? { autoClose: false, closeOnClick: false }
89
- : {}),
90
- ...(state.message.action === 'report'
91
- ? { bodyClassName: 'toast-report-action' }
92
- : {}),
93
- };
94
- switch (state.message.type) {
95
- case 'error':
96
- toast.error(state.message.value, toastOptions);
97
- break;
98
- case 'warning':
99
- toast.warning(state.message.value, toastOptions);
100
- break;
101
- case 'success':
102
- toast.success(state.message.value, toastOptions);
103
- break;
104
- default:
105
- toast(state.message.value, toastOptions);
106
- break;
107
- }
82
+ if (!state.message?.value) {
83
+ return;
108
84
  }
109
- else {
110
- dispatch(message(EMPTY_TOAST));
85
+ const persistent = state.message.autoClose === false;
86
+ const isReport = state.message.action === 'report';
87
+ const toastOptions = {
88
+ toastId: isReport ? 'tycho-error-report' : undefined,
89
+ onClose: () => handleClose(),
90
+ onClick: () => handleToastAction(),
91
+ ...(persistent
92
+ ? { autoClose: false, closeOnClick: false }
93
+ : {}),
94
+ ...(isReport ? { bodyClassName: 'toast-report-action' } : {}),
95
+ };
96
+ switch (state.message.type) {
97
+ case 'error':
98
+ toast.error(state.message.value, toastOptions);
99
+ break;
100
+ case 'warning':
101
+ toast.warning(state.message.value, toastOptions);
102
+ break;
103
+ case 'success':
104
+ toast.success(state.message.value, toastOptions);
105
+ break;
106
+ default:
107
+ toast(state.message.value, toastOptions);
108
+ break;
111
109
  }
112
110
  }, [state.message]);
113
111
  return (_jsxs(_Fragment, { children: [_jsx(ToastContainer, { closeOnClick: state.message.action !== 'report' }), reportModalOpen && (_jsx(ServerErrorReport, { errorContext: reportErrorContext, corpus: state.corpus, onClose: handleReportModalClose, onSent: handleReportSent }))] }));
@@ -7,6 +7,10 @@ export type ServerErrorContext = {
7
7
  responseDescription?: string;
8
8
  requestId?: string;
9
9
  occurredAt?: string;
10
+ /** Distinguishes API 5xx reports from unexpected client/runtime errors. */
11
+ source?: 'api' | 'client';
12
+ /** Client/runtime stack for the report payload only (not shown in UI). */
13
+ stack?: string;
10
14
  };
11
15
  export default interface ToastMessage {
12
16
  value: string;
@@ -1,5 +1,6 @@
1
1
  import type { AxiosResponseHeaders, RawAxiosResponseHeaders } from 'axios';
2
2
  import type { TFunction } from 'i18next';
3
+ import type { ServerErrorContext } from '../../common/AppToast/ToastMessage';
3
4
  type DispatchApiErrorOptions = {
4
5
  err: unknown;
5
6
  t?: TFunction;
@@ -7,10 +8,16 @@ type DispatchApiErrorOptions = {
7
8
  autoClose?: boolean;
8
9
  };
9
10
  export declare function extractRequestId(headers?: RawAxiosResponseHeaders | AxiosResponseHeaders): string | undefined;
11
+ export declare function buildClientErrorContext(err: unknown): ServerErrorContext;
10
12
  export declare function dispatchApiError({ err, t, key, autoClose, }: DispatchApiErrorOptions): void;
11
13
  /** Persistent 5xx toast that opens the server-error report modal on click. */
12
14
  export declare function dispatchServerApiError({ err, t, }: {
13
15
  err: unknown;
14
16
  t?: TFunction;
15
17
  }): void;
18
+ /** Persistent toast for unexpected client/runtime errors; opens report modal on click. */
19
+ export declare function dispatchClientError({ err, t, }: {
20
+ err: unknown;
21
+ t?: TFunction;
22
+ }): void;
16
23
  export {};
@@ -30,6 +30,7 @@ export function extractRequestId(headers) {
30
30
  }
31
31
  return undefined;
32
32
  }
33
+ const CLIENT_STACK_MAX_LENGTH = 4000;
33
34
  function buildServerErrorContext(err) {
34
35
  if (!err || typeof err !== 'object' || !('response' in err)) {
35
36
  return {};
@@ -44,6 +45,40 @@ function buildServerErrorContext(err) {
44
45
  responseDescription: typeof data?.description === 'string' ? data.description : undefined,
45
46
  requestId: extractRequestId(axiosErr.response?.headers),
46
47
  occurredAt: new Date().toISOString(),
48
+ source: 'api',
49
+ };
50
+ }
51
+ function normalizeToError(err) {
52
+ if (err instanceof Error) {
53
+ return err;
54
+ }
55
+ if (typeof err === 'string' && err.trim()) {
56
+ return new Error(err);
57
+ }
58
+ try {
59
+ return new Error(JSON.stringify(err));
60
+ }
61
+ catch {
62
+ return new Error(String(err));
63
+ }
64
+ }
65
+ function truncateStack(stack) {
66
+ if (!stack) {
67
+ return undefined;
68
+ }
69
+ if (stack.length <= CLIENT_STACK_MAX_LENGTH) {
70
+ return stack;
71
+ }
72
+ return `${stack.slice(0, CLIENT_STACK_MAX_LENGTH)}…`;
73
+ }
74
+ export function buildClientErrorContext(err) {
75
+ const error = normalizeToError(err);
76
+ return {
77
+ responseMessage: error.message,
78
+ stack: truncateStack(error.stack),
79
+ url: typeof window !== 'undefined' ? window.location.href : undefined,
80
+ occurredAt: new Date().toISOString(),
81
+ source: 'client',
47
82
  };
48
83
  }
49
84
  export function dispatchApiError({ err, t, key, autoClose, }) {
@@ -69,3 +104,16 @@ export function dispatchServerApiError({ err, t, }) {
69
104
  errorContext,
70
105
  }));
71
106
  }
107
+ /** Persistent toast for unexpected client/runtime errors; opens report modal on click. */
108
+ export function dispatchClientError({ err, t, }) {
109
+ const translate = t ?? i18n.t.bind(i18n);
110
+ const value = translate('server.error.report.prompt', { ns: 'common' });
111
+ const errorContext = buildClientErrorContext(err);
112
+ getCommonDispatch()?.(message({
113
+ value,
114
+ type: 'error',
115
+ autoClose: false,
116
+ action: 'report',
117
+ errorContext,
118
+ }));
119
+ }
@@ -13,6 +13,6 @@ export { applyAuthErrorHandling } from './applyAuthErrorHandling';
13
13
  export type { ApplyAuthErrorHandlingOptions, AuthErrorHandling, } from './applyAuthErrorHandling';
14
14
  export { isHandledApiError, isHandledAuthError, markApiErrorHandled, markAuthErrorHandled, } from './apiHandledError';
15
15
  export type { ApiErrorHandled, AuthErrorHandled } from './apiHandledError';
16
- export { dispatchApiError, dispatchServerApiError } from './dispatchApiError';
16
+ export { buildClientErrorContext, dispatchApiError, dispatchClientError, dispatchServerApiError, } from './dispatchApiError';
17
17
  export { resolveAccessDeniedMessage } from './resolveAccessDeniedMessage';
18
18
  export default api;
@@ -13,6 +13,6 @@ const api = applyAuthErrorHandling(createApiClient({ baseURL: platformApiBase })
13
13
  export { createApiClient } from './createApiClient';
14
14
  export { applyAuthErrorHandling } from './applyAuthErrorHandling';
15
15
  export { isHandledApiError, isHandledAuthError, markApiErrorHandled, markAuthErrorHandled, } from './apiHandledError';
16
- export { dispatchApiError, dispatchServerApiError } from './dispatchApiError';
16
+ export { buildClientErrorContext, dispatchApiError, dispatchClientError, dispatchServerApiError, } from './dispatchApiError';
17
17
  export { resolveAccessDeniedMessage } from './resolveAccessDeniedMessage';
18
18
  export default api;
@@ -2,7 +2,7 @@ export { CommonProvider } from "./CommonContext";
2
2
  export { default as CommonContext } from "./CommonContext";
3
3
  export { default as CommonDispatchBridge } from "./CommonDispatchBridge";
4
4
  export { default as CookieStorage } from "./CookieStorage";
5
- export { createApiClient, applyAuthErrorHandling, dispatchApiError, dispatchServerApiError, resolveAccessDeniedMessage, isHandledApiError, isHandledAuthError, markApiErrorHandled, markAuthErrorHandled, platformApi, parserApiBase, } from "./api";
5
+ export { createApiClient, applyAuthErrorHandling, dispatchApiError, dispatchClientError, dispatchServerApiError, resolveAccessDeniedMessage, isHandledApiError, isHandledAuthError, markApiErrorHandled, markAuthErrorHandled, platformApi, parserApiBase, } from "./api";
6
6
  export type { ApplyAuthErrorHandlingOptions, AuthErrorHandling, CreateApiClientOptions, ServerErrorHandling, ApiErrorHandled, AuthErrorHandled, } from "./api";
7
7
  export { getCommonDispatch, registerCommonDispatch, } from "./store/commonDispatchBridge";
8
8
  export { commonResources, featureResources, mergeResources, commonLocalization, } from "./Localization";
@@ -2,7 +2,7 @@ export { CommonProvider } from "./CommonContext";
2
2
  export { default as CommonContext } from "./CommonContext";
3
3
  export { default as CommonDispatchBridge } from "./CommonDispatchBridge";
4
4
  export { default as CookieStorage } from "./CookieStorage";
5
- export { createApiClient, applyAuthErrorHandling, dispatchApiError, dispatchServerApiError, resolveAccessDeniedMessage, isHandledApiError, isHandledAuthError, markApiErrorHandled, markAuthErrorHandled, platformApi, parserApiBase, } from "./api";
5
+ export { createApiClient, applyAuthErrorHandling, dispatchApiError, dispatchClientError, dispatchServerApiError, resolveAccessDeniedMessage, isHandledApiError, isHandledAuthError, markApiErrorHandled, markAuthErrorHandled, platformApi, parserApiBase, } from "./api";
6
6
  export { getCommonDispatch, registerCommonDispatch, } from "./store/commonDispatchBridge";
7
7
  export { commonResources, featureResources, mergeResources, commonLocalization, } from "./Localization";
8
8
  export { default as ProfileService } from "./services/ProfileService";
@@ -11,8 +11,12 @@ export type Chunk = {
11
11
  f: number;
12
12
  l: number;
13
13
  t: string;
14
+ /** Stable chunk identity (4-char alphanumeric), mirrors token `tid`. */
15
+ kid?: string;
14
16
  coidx?: number[];
15
17
  ep?: boolean;
18
+ /** Runtime: empty-category phrase node. */
19
+ empty?: boolean;
16
20
  };
17
21
  export type Token = {
18
22
  p: number;
@@ -1,5 +1,4 @@
1
1
  import { ReactNode } from 'react';
2
- import './style.scss';
3
2
  type Props = {
4
3
  children: ReactNode;
5
4
  };
@@ -1,25 +1,44 @@
1
- import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
- import { useEffect, useState } from 'react';
3
- import { useTranslation } from 'react-i18next';
4
- import { isHandledApiError } from '../../configs/api';
5
- import logo from './logo.png';
6
- import './style.scss';
1
+ import { Fragment as _Fragment, jsx as _jsx } from "react/jsx-runtime";
2
+ import { useEffect, useRef } from 'react';
3
+ import { dispatchClientError, isHandledApiError } from '../../configs/api';
4
+ /** Survives remounts; one client-report toast per full page load. */
5
+ let globalErrorReported = false;
6
+ function toError(reason) {
7
+ if (reason instanceof Error) {
8
+ return reason;
9
+ }
10
+ if (typeof reason === 'string' && reason.trim()) {
11
+ return new Error(reason);
12
+ }
13
+ try {
14
+ return new Error(JSON.stringify(reason));
15
+ }
16
+ catch {
17
+ return new Error(String(reason));
18
+ }
19
+ }
7
20
  export default function ErrorBoundary({ children }) {
8
- const { t } = useTranslation('base');
9
- const [state, setState] = useState({
10
- hasError: false,
11
- error: null,
12
- });
21
+ const listenerReadyRef = useRef(false);
13
22
  useEffect(() => {
23
+ if (listenerReadyRef.current) {
24
+ return;
25
+ }
26
+ listenerReadyRef.current = true;
14
27
  const handleError = (error) => {
15
- console.error('Caught by ErrorBoundary:', error);
16
- setState({ hasError: true, error });
28
+ if (globalErrorReported) {
29
+ return;
30
+ }
31
+ globalErrorReported = true;
32
+ const normalized = toError(error);
33
+ console.error('Caught by ErrorBoundary:', normalized);
34
+ dispatchClientError({ err: normalized });
17
35
  };
18
36
  const onUnhandledRejection = (event) => {
19
37
  if (isHandledApiError(event.reason)) {
20
38
  event.preventDefault();
21
39
  return;
22
40
  }
41
+ event.preventDefault();
23
42
  handleError(event.reason);
24
43
  };
25
44
  const onError = (event) => {
@@ -27,6 +46,7 @@ export default function ErrorBoundary({ children }) {
27
46
  event.preventDefault();
28
47
  return;
29
48
  }
49
+ event.preventDefault();
30
50
  handleError(event.error);
31
51
  };
32
52
  window.addEventListener('unhandledrejection', onUnhandledRejection);
@@ -34,10 +54,8 @@ export default function ErrorBoundary({ children }) {
34
54
  return () => {
35
55
  window.removeEventListener('unhandledrejection', onUnhandledRejection);
36
56
  window.removeEventListener('error', onError);
57
+ listenerReadyRef.current = false;
37
58
  };
38
59
  }, []);
39
- if (state.hasError && state.error) {
40
- return (_jsx("div", { className: "box-container", children: _jsxs("div", { className: "box", children: [_jsx("img", { src: logo }), _jsx("div", { className: "title", children: t('label.error.found') }), _jsx("div", { className: "message", children: state.error?.message }), _jsx("pre", { className: "error-stack", children: state.error?.stack })] }) }));
41
- }
42
60
  return _jsx(_Fragment, { children: children });
43
61
  }
@@ -1,2 +1,6 @@
1
- import './style.scss';
1
+ import "./style.scss";
2
+ /**
3
+ * React Router errorElement: template page + report toast (stack only in report payload).
4
+ * Does not auto-redirect on mount (that remounts failing routes like `/` → `/catalog`).
5
+ */
2
6
  export default function ErrorFound(): import("react").JSX.Element;
@@ -1,22 +1,64 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useState } from 'react';
3
- import { useTranslation } from 'react-i18next';
4
- import { useNavigate, useRouteError } from 'react-router-dom';
5
- import { Button } from 'tycho-storybook';
6
- import CookieStorage from '../../configs/CookieStorage';
7
- import logo from './logo.png';
8
- import './style.scss';
2
+ import { useEffect } from "react";
3
+ import { useTranslation } from "react-i18next";
4
+ import { useNavigate, useRouteError } from "react-router-dom";
5
+ import { dispatchClientError } from "../../configs/api";
6
+ import CookieStorage from "../../configs/CookieStorage";
7
+ import logo from "./logo.png";
8
+ import "./style.scss";
9
+ /** Survives remounts so context updates from the toast cannot re-dispatch forever. */
10
+ let routeErrorReported = false;
11
+ function toError(reason) {
12
+ if (reason instanceof Error) {
13
+ return reason;
14
+ }
15
+ if (typeof reason === "string" && reason.trim()) {
16
+ return new Error(reason);
17
+ }
18
+ if (reason &&
19
+ typeof reason === "object" &&
20
+ "statusText" in reason &&
21
+ typeof reason.statusText === "string") {
22
+ return new Error(reason.statusText);
23
+ }
24
+ if (reason &&
25
+ typeof reason === "object" &&
26
+ "message" in reason &&
27
+ typeof reason.message === "string") {
28
+ return new Error(reason.message);
29
+ }
30
+ try {
31
+ return new Error(JSON.stringify(reason));
32
+ }
33
+ catch {
34
+ return new Error(String(reason));
35
+ }
36
+ }
37
+ /**
38
+ * React Router errorElement: template page + report toast (stack only in report payload).
39
+ * Does not auto-redirect on mount (that remounts failing routes like `/` → `/catalog`).
40
+ */
9
41
  export default function ErrorFound() {
10
42
  const navigate = useNavigate();
11
- const { t } = useTranslation('base');
12
- const error = useRouteError();
13
- const [show, setShow] = useState(false);
43
+ const { t } = useTranslation("base");
44
+ const routeError = useRouteError();
45
+ const error = toError(routeError);
46
+ useEffect(() => {
47
+ if (routeErrorReported) {
48
+ return;
49
+ }
50
+ routeErrorReported = true;
51
+ const id = window.setTimeout(() => {
52
+ dispatchClientError({ err: toError(routeError) });
53
+ }, 0);
54
+ return () => window.clearTimeout(id);
55
+ }, [routeError]);
14
56
  const handleRedirect = () => {
15
- navigate('/', { replace: true });
57
+ navigate("/", { replace: true });
16
58
  };
17
59
  const handleRetry = () => {
18
60
  const redirectUri = CookieStorage.getRedirectUri();
19
61
  redirectUri ? (window.location.href = redirectUri) : handleRedirect();
20
62
  };
21
- return (_jsxs("div", { className: "box-container", children: [_jsxs("div", { className: "box", children: [_jsx("img", { src: logo }), _jsx("div", { className: "title", children: t('label.error.found') }), _jsxs("div", { className: "buttons", children: [_jsx(Button, { text: t('label.retry'), size: "small", onClick: handleRetry }), _jsx(Button, { text: t('label.redirect'), size: "small", onClick: handleRedirect }), _jsx(Button, { text: t('label.error.details'), size: "small", onClick: () => setShow(!show) })] })] }), show && (_jsx("pre", { className: "error-stack", children: JSON.stringify(error, null, 2) }))] }));
63
+ return (_jsx("div", { className: "box-container", children: _jsxs("div", { className: "box", children: [_jsx("img", { src: logo, alt: "" }), _jsx("div", { className: "title", children: t("label.error.found") })] }) }));
22
64
  }
@@ -25,6 +25,14 @@
25
25
  margin: 16px 0px;
26
26
  }
27
27
 
28
+ > .message {
29
+ @include body-small-1;
30
+ color: var(--text-secondary);
31
+ text-align: center;
32
+ margin-bottom: 24px;
33
+ word-break: break-word;
34
+ }
35
+
28
36
  > .subtitle {
29
37
  @include label-medium-1;
30
38
  color: var(text-secondary);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "tycho-components",
3
3
  "private": false,
4
- "version": "0.41.6",
4
+ "version": "0.42.0",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
7
7
  "exports": {