@gooddata/sdk-ui-pluggable-host 11.57.0-alpha.2 → 11.57.0-alpha.3

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.
@@ -0,0 +1,45 @@
1
+ import { Component, type ErrorInfo, type ReactNode } from "react";
2
+ import { type ILocale } from "@gooddata/sdk-model";
3
+ import "./Root.scss";
4
+ /**
5
+ * Properties of {@link HostErrorBoundary}.
6
+ *
7
+ * @alpha
8
+ */
9
+ export interface IHostErrorBoundaryProps {
10
+ /**
11
+ * Reports the caught error together with the React component stack it was thrown from.
12
+ */
13
+ onError?: (error: string, context: string) => void;
14
+ locale?: ILocale;
15
+ /**
16
+ * A caught error is cleared when this value changes, so the subtree gets a fresh
17
+ * mount attempt (e.g. on navigation) instead of showing the fallback forever.
18
+ */
19
+ resetKey?: string | number;
20
+ children: ReactNode;
21
+ }
22
+ /**
23
+ * State of {@link HostErrorBoundary}.
24
+ *
25
+ * @alpha
26
+ */
27
+ export interface IHostErrorBoundaryState {
28
+ hasError: boolean;
29
+ resetKey?: string | number;
30
+ }
31
+ /**
32
+ * Last-resort boundary for errors escaping to the host application root. The host page is
33
+ * composed of nested React roots rendering into DOM owned by the topmost root, so without
34
+ * a boundary React unmounts the entire tree — header included — leaving a blank page.
35
+ *
36
+ * @alpha
37
+ */
38
+ export declare class HostErrorBoundary extends Component<IHostErrorBoundaryProps, IHostErrorBoundaryState> {
39
+ state: IHostErrorBoundaryState;
40
+ static getDerivedStateFromError(): Partial<IHostErrorBoundaryState>;
41
+ static getDerivedStateFromProps(props: IHostErrorBoundaryProps, state: IHostErrorBoundaryState): Partial<IHostErrorBoundaryState> | null;
42
+ componentDidCatch(error: Error, info: ErrorInfo): void;
43
+ render(): ReactNode;
44
+ }
45
+ //# sourceMappingURL=HostErrorBoundary.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"HostErrorBoundary.d.ts","sourceRoot":"","sources":["../../src/components/HostErrorBoundary.tsx"],"names":[],"mappings":"AAEA,OAAO,EAAE,SAAS,EAAE,KAAK,SAAS,EAAE,KAAK,SAAS,EAAE,MAAM,OAAO,CAAC;AAIlE,OAAO,EAAE,KAAK,OAAO,EAAE,MAAM,qBAAqB,CAAC;AAMnD,OAAO,aAAa,CAAC;AAIrB;;;;GAIG;AACH,MAAM,WAAW,uBAAuB;IACpC;;OAEG;IACH,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;IACnD,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB;;;OAGG;IACH,QAAQ,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;IAC3B,QAAQ,EAAE,SAAS,CAAC;CACvB;AAED;;;;GAIG;AACH,MAAM,WAAW,uBAAuB;IACpC,QAAQ,EAAE,OAAO,CAAC;IAClB,QAAQ,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;CAC9B;AAED;;;;;;GAMG;AACH,qBAAa,iBAAkB,SAAQ,SAAS,CAAC,uBAAuB,EAAE,uBAAuB,CAAC;IACrF,KAAK,EAAE,uBAAuB,CAAuB;IAE9D,MAAM,CAAC,wBAAwB,IAAI,OAAO,CAAC,uBAAuB,CAAC,CAElE;IAED,MAAM,CAAC,wBAAwB,CAC3B,KAAK,EAAE,uBAAuB,EAC9B,KAAK,EAAE,uBAAuB,GAC/B,OAAO,CAAC,uBAAuB,CAAC,GAAG,IAAI,CAKzC;IAEQ,iBAAiB,CAAC,KAAK,EAAE,KAAK,EAAE,IAAI,EAAE,SAAS,GAAG,IAAI,CAE9D;IAEQ,MAAM,IAAI,SAAS,CAkB3B;CACJ"}
@@ -0,0 +1,39 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ // (C) 2026 GoodData Corporation
3
+ import { Component } from "react";
4
+ import { FormattedMessage } from "react-intl";
5
+ import { DEFAULT_LANGUAGE } from "@gooddata/sdk-ui";
6
+ import { bemFactory } from "@gooddata/sdk-ui-kit";
7
+ import { HostIntlProvider } from "../ui/HostIntlProvider.js";
8
+ import "./Root.scss";
9
+ const { e } = bemFactory("gd-host-root");
10
+ /**
11
+ * Last-resort boundary for errors escaping to the host application root. The host page is
12
+ * composed of nested React roots rendering into DOM owned by the topmost root, so without
13
+ * a boundary React unmounts the entire tree — header included — leaving a blank page.
14
+ *
15
+ * @alpha
16
+ */
17
+ export class HostErrorBoundary extends Component {
18
+ state = { hasError: false };
19
+ static getDerivedStateFromError() {
20
+ return { hasError: true };
21
+ }
22
+ static getDerivedStateFromProps(props, state) {
23
+ if (props.resetKey !== state.resetKey) {
24
+ return { resetKey: props.resetKey, hasError: false };
25
+ }
26
+ return null;
27
+ }
28
+ componentDidCatch(error, info) {
29
+ this.props.onError?.(String(error), info.componentStack ?? "");
30
+ }
31
+ render() {
32
+ if (!this.state.hasError) {
33
+ return this.props.children;
34
+ }
35
+ // Exception details go to telemetry (onError) and the console only — rendering them
36
+ // could expose internal backend details in the UI.
37
+ return (_jsx(HostIntlProvider, { locale: this.props.locale ?? DEFAULT_LANGUAGE, children: _jsx("div", { className: e("error"), role: "alert", children: _jsx("h1", { children: _jsx(FormattedMessage, { id: "gs.host.error.somethingWentWrong" }) }) }) }));
38
+ }
39
+ }
@@ -7,11 +7,12 @@ export interface IHostUiContainerProps {
7
7
  apps: PluggableApplicationRegistryItem[];
8
8
  pathname: string;
9
9
  routerNavigate: NavigateFunction;
10
+ onError?: (error: string, context: string) => void;
10
11
  }
11
12
  /**
12
13
  * Mounts the host UI module into a container div, then renders the active
13
14
  * pluggable application into the host's app slot. Handles all lifecycle
14
15
  * updates (context, apps, pathname) via the host mount handle.
15
16
  */
16
- export declare function HostUiContainer({ ctx, apps, pathname, routerNavigate }: IHostUiContainerProps): import("react/jsx-runtime").JSX.Element;
17
+ export declare function HostUiContainer({ ctx, apps, pathname, routerNavigate, onError }: IHostUiContainerProps): import("react/jsx-runtime").JSX.Element;
17
18
  //# sourceMappingURL=HostUiContainer.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"HostUiContainer.d.ts","sourceRoot":"","sources":["../../src/components/HostUiContainer.tsx"],"names":[],"mappings":"AAKA,OAAO,EAAE,KAAK,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAErD,OAAO,EAA0B,KAAK,gCAAgC,EAAE,MAAM,qBAAqB,CAAC;AACpG,OAAO,EAGH,KAAK,gBAAgB,EAExB,MAAM,2CAA2C,CAAC;AAiBnD,OAAO,wBAAwB,CAAC;AAKhC,MAAM,WAAW,qBAAqB;IAClC,GAAG,EAAE,gBAAgB,CAAC;IACtB,IAAI,EAAE,gCAAgC,EAAE,CAAC;IACzC,QAAQ,EAAE,MAAM,CAAC;IACjB,cAAc,EAAE,gBAAgB,CAAC;CACpC;AAED;;;;GAIG;AACH,wBAAgB,eAAe,CAAC,EAAE,GAAG,EAAE,IAAI,EAAE,QAAQ,EAAE,cAAc,EAAE,EAAE,qBAAqB,2CAqT7F"}
1
+ {"version":3,"file":"HostUiContainer.d.ts","sourceRoot":"","sources":["../../src/components/HostUiContainer.tsx"],"names":[],"mappings":"AAMA,OAAO,EAAE,KAAK,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAErD,OAAO,EAA0B,KAAK,gCAAgC,EAAE,MAAM,qBAAqB,CAAC;AACpG,OAAO,EAGH,KAAK,gBAAgB,EAExB,MAAM,2CAA2C,CAAC;AAiBnD,OAAO,wBAAwB,CAAC;AAMhC,MAAM,WAAW,qBAAqB;IAClC,GAAG,EAAE,gBAAgB,CAAC;IACtB,IAAI,EAAE,gCAAgC,EAAE,CAAC;IACzC,QAAQ,EAAE,MAAM,CAAC;IACjB,cAAc,EAAE,gBAAgB,CAAC;IACjC,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;CACtD;AAED;;;;GAIG;AACH,wBAAgB,eAAe,CAAC,EAAE,GAAG,EAAE,IAAI,EAAE,QAAQ,EAAE,cAAc,EAAE,OAAO,EAAE,EAAE,qBAAqB,2CA0VtG"}
@@ -1,6 +1,7 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  // (C) 2026 GoodData Corporation
3
3
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
4
+ import { isEqual, omit } from "lodash-es";
4
5
  import { createRoot } from "react-dom/client";
5
6
  import { resolveLocale, useAutoupdateRef } from "@gooddata/sdk-ui";
6
7
  import { now } from "../debug.js";
@@ -12,19 +13,40 @@ import { HostIntlProvider } from "../ui/HostIntlProvider.js";
12
13
  import { PluggableApplicationRenderer } from "../ui/PluggableApplicationRenderer.js";
13
14
  import "./HostUiContainer.scss";
14
15
  import { resolveHostUiModule } from "../ui/resolveHostUiModule.js";
16
+ import { HostErrorBoundary } from "./HostErrorBoundary.js";
15
17
  import { runGuardedNavigation } from "./navigationGuard.js";
16
18
  /**
17
19
  * Mounts the host UI module into a container div, then renders the active
18
20
  * pluggable application into the host's app slot. Handles all lifecycle
19
21
  * updates (context, apps, pathname) via the host mount handle.
20
22
  */
21
- export function HostUiContainer({ ctx, apps, pathname, routerNavigate }) {
23
+ export function HostUiContainer({ ctx, apps, pathname, routerNavigate, onError }) {
22
24
  const activeInternalApplication = useMemo(() => getActiveInternalApplication(apps, ctx, pathname), [apps, ctx, pathname]);
23
25
  const containerRef = useRef(null);
24
26
  const handleRef = useRef(undefined);
25
27
  const appRootRef = useRef(undefined);
26
28
  const [hostReady, setHostReady] = useState(false);
27
29
  const latestMountStateRef = useAutoupdateRef({ ctx, apps, pathname });
30
+ const onErrorRef = useAutoupdateRef(onError);
31
+ const reportError = useCallback((error, context) => onErrorRef.current?.(error, context), [onErrorRef]);
32
+ // Mounted apps hot-apply only auth from updateContext — every other context field
33
+ // (permissions, settings, entitlements, org permissions, …) is seeded into their stores
34
+ // at mount only. A refresh that lands ANY content change outside auth must therefore
35
+ // remount the app. Content comparison, not identity: every refresh produces new objects,
36
+ // and remounting on each token refresh would defeat keeping the app mounted at all.
37
+ // The color palette is also excluded: its load is not part of the platform-context gate,
38
+ // so it legitimately arrives after the first ready context — a styling update, not a
39
+ // reason to abort the mounted app.
40
+ const [appContextEpoch, setAppContextEpoch] = useState(0);
41
+ const prevAppContextRef = useRef(ctx);
42
+ useEffect(() => {
43
+ const prev = prevAppContextRef.current;
44
+ prevAppContextRef.current = ctx;
45
+ const epochFields = (c) => omit(c, ["auth", "colorPalette"]);
46
+ if (prev !== ctx && !isEqual(epochFields(prev), epochFields(ctx))) {
47
+ setAppContextEpoch((epoch) => epoch + 1);
48
+ }
49
+ }, [ctx]);
28
50
  const [headerOptions, setHeaderOptions] = useState(undefined);
29
51
  const [pageTitle, setPageTitle] = useState(undefined);
30
52
  // Host-owned chat open-state, sourced from HostChat and forwarded to the active pluggable app.
@@ -124,6 +146,7 @@ export function HostUiContainer({ ctx, apps, pathname, routerNavigate }) {
124
146
  replace: navigationMountRef.current.replace,
125
147
  onChatToggleRequested: navigationMountRef.current.onChatToggleRequested,
126
148
  onAskAiAssistant: navigationMountRef.current.onAskAiAssistant,
149
+ onError: reportError,
127
150
  });
128
151
  handleRef.current = handle;
129
152
  appRootRef.current = createRoot(handle.getAppContainer());
@@ -152,7 +175,7 @@ export function HostUiContainer({ ctx, apps, pathname, routerNavigate }) {
152
175
  });
153
176
  };
154
177
  // Mount only once; updates are pushed via handle
155
- }, [latestMountStateRef, navigationMountRef]);
178
+ }, [latestMountStateRef, navigationMountRef, reportError]);
156
179
  // Push updates when context, apps, or pathname change after initial mount
157
180
  useEffect(() => {
158
181
  if (!hostReady) {
@@ -218,7 +241,7 @@ export function HostUiContainer({ ctx, apps, pathname, routerNavigate }) {
218
241
  return;
219
242
  }
220
243
  if (activeInternalApplication) {
221
- appRootRef.current.render(_jsx(HostIntlProvider, { locale: resolveLocale(ctx.preferredLocale), children: _jsx(PluggableApplicationRenderer, { app: activeInternalApplication, ctx: ctx, pathname: pathname, aiAssistantOpen: aiAssistantOpen, onOpenAiAssistant: requestOpenAi, onCloseAiAssistant: requestCloseAi, onAiAssistantContext: setAiAssistantContext, aiLinkClickHandlerRef: appAiLinkClickRef, navigationRequestRef: appNavigationRequestRef, aiEventReceiveRef: appEventReceiveRef, onHeaderChange: onHeaderChange, onDocumentTitleChange: onDocumentTitleChange }, activeInternalApplication.id) }));
244
+ appRootRef.current.render(_jsx(HostErrorBoundary, { resetKey: pathname, onError: reportError, locale: resolveLocale(ctx.preferredLocale), children: _jsx(HostIntlProvider, { locale: resolveLocale(ctx.preferredLocale), children: _jsx(PluggableApplicationRenderer, { app: activeInternalApplication, ctx: ctx, pathname: pathname, aiAssistantOpen: aiAssistantOpen, onOpenAiAssistant: requestOpenAi, onCloseAiAssistant: requestCloseAi, onAiAssistantContext: setAiAssistantContext, aiLinkClickHandlerRef: appAiLinkClickRef, navigationRequestRef: appNavigationRequestRef, aiEventReceiveRef: appEventReceiveRef, onHeaderChange: onHeaderChange, onDocumentTitleChange: onDocumentTitleChange }, activeInternalApplication.id) }) }, `${activeInternalApplication.id}:${appContextEpoch}`));
222
245
  }
223
246
  else {
224
247
  appRootRef.current.render(null);
@@ -226,11 +249,13 @@ export function HostUiContainer({ ctx, apps, pathname, routerNavigate }) {
226
249
  }, [
227
250
  hostReady,
228
251
  activeInternalApplication,
252
+ appContextEpoch,
229
253
  ctx,
230
254
  onHeaderChange,
231
255
  onDocumentTitleChange,
232
256
  pathname,
233
257
  aiAssistantOpen,
258
+ reportError,
234
259
  requestOpenAi,
235
260
  requestCloseAi,
236
261
  setAiAssistantContext,
@@ -62,5 +62,5 @@ function ReadyRoot({ ctx, callbacks }) {
62
62
  if (redirect.state === "error") {
63
63
  return (_jsx(HostIntlProvider, { locale: resolveLocale(ctx.preferredLocale), children: _jsxs("main", { className: e("error"), children: [_jsx("h1", { children: _jsx(FormattedMessage, { id: "gs.host.error.somethingWentWrong" }) }), _jsx("p", { children: redirect.error })] }) }));
64
64
  }
65
- return _jsx(HostUiContainer, { ctx: ctx, apps: apps, pathname: pathname, routerNavigate: routerNavigate });
65
+ return (_jsx(HostUiContainer, { ctx: ctx, apps: apps, pathname: pathname, routerNavigate: routerNavigate, onError: callbacks?.onError }));
66
66
  }
package/esm/index.d.ts CHANGED
@@ -6,6 +6,7 @@ export { registerLocalApplications } from "./registry/pluggableApplicationsRegis
6
6
  export { registerAppLifecycleCallbacks } from "./loader/pluggableApplicationsLoader.js";
7
7
  export { mapLegacyUrlToHost, type ILegacyLocation } from "./loader/legacyRedirect.js";
8
8
  export { Root, type IRootCallbacks } from "./components/Root.js";
9
+ export { HostErrorBoundary, type IHostErrorBoundaryProps, type IHostErrorBoundaryState, } from "./components/HostErrorBoundary.js";
9
10
  export { type IAppLifecycleCallbacks } from "./types/lifecycle.js";
10
11
  export { STALE_CHUNK_RELOAD_PARAM, installPreloadErrorHandler, installVersionWatcher, reloadForStaleChunks, setStaleChunkReloadListener, type IStaleChunkReloadInfo, type IVersionWatcherOptions, type StaleChunkReloadListener, } from "./lib/chunkReloadGuard.js";
11
12
  export { dispatchHostNotification } from "./lib/hostNotifications.js";
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,UAAU,EAAE,qBAAqB,EAAE,MAAM,8BAA8B,CAAC;AACjF,OAAO,EAAE,gCAAgC,EAAE,MAAM,6CAA6C,CAAC;AAC/F,OAAO,EAAE,KAAK,6BAA6B,EAAE,MAAM,0CAA0C,CAAC;AAE9F,OAAO,EACH,+BAA+B,EAC/B,KAAK,+BAA+B,GACvC,MAAM,yBAAyB,CAAC;AAEjC,OAAO,EAAE,yBAAyB,EAAE,MAAM,6CAA6C,CAAC;AACxF,OAAO,EAAE,6BAA6B,EAAE,MAAM,yCAAyC,CAAC;AAExF,OAAO,EAAE,kBAAkB,EAAE,KAAK,eAAe,EAAE,MAAM,4BAA4B,CAAC;AAEtF,OAAO,EAAE,IAAI,EAAE,KAAK,cAAc,EAAE,MAAM,sBAAsB,CAAC;AACjE,OAAO,EAAE,KAAK,sBAAsB,EAAE,MAAM,sBAAsB,CAAC;AAEnE,OAAO,EACH,wBAAwB,EACxB,0BAA0B,EAC1B,qBAAqB,EACrB,oBAAoB,EACpB,2BAA2B,EAC3B,KAAK,qBAAqB,EAC1B,KAAK,sBAAsB,EAC3B,KAAK,wBAAwB,GAChC,MAAM,2BAA2B,CAAC;AACnC,OAAO,EAAE,wBAAwB,EAAE,MAAM,4BAA4B,CAAC;AAEtE,OAAO,EACH,uBAAuB,EACvB,KAAK,uBAAuB,EAC5B,KAAK,kBAAkB,GAC1B,MAAM,8BAA8B,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,UAAU,EAAE,qBAAqB,EAAE,MAAM,8BAA8B,CAAC;AACjF,OAAO,EAAE,gCAAgC,EAAE,MAAM,6CAA6C,CAAC;AAC/F,OAAO,EAAE,KAAK,6BAA6B,EAAE,MAAM,0CAA0C,CAAC;AAE9F,OAAO,EACH,+BAA+B,EAC/B,KAAK,+BAA+B,GACvC,MAAM,yBAAyB,CAAC;AAEjC,OAAO,EAAE,yBAAyB,EAAE,MAAM,6CAA6C,CAAC;AACxF,OAAO,EAAE,6BAA6B,EAAE,MAAM,yCAAyC,CAAC;AAExF,OAAO,EAAE,kBAAkB,EAAE,KAAK,eAAe,EAAE,MAAM,4BAA4B,CAAC;AAEtF,OAAO,EAAE,IAAI,EAAE,KAAK,cAAc,EAAE,MAAM,sBAAsB,CAAC;AACjE,OAAO,EACH,iBAAiB,EACjB,KAAK,uBAAuB,EAC5B,KAAK,uBAAuB,GAC/B,MAAM,mCAAmC,CAAC;AAC3C,OAAO,EAAE,KAAK,sBAAsB,EAAE,MAAM,sBAAsB,CAAC;AAEnE,OAAO,EACH,wBAAwB,EACxB,0BAA0B,EAC1B,qBAAqB,EACrB,oBAAoB,EACpB,2BAA2B,EAC3B,KAAK,qBAAqB,EAC1B,KAAK,sBAAsB,EAC3B,KAAK,wBAAwB,GAChC,MAAM,2BAA2B,CAAC;AACnC,OAAO,EAAE,wBAAwB,EAAE,MAAM,4BAA4B,CAAC;AAEtE,OAAO,EACH,uBAAuB,EACvB,KAAK,uBAAuB,EAC5B,KAAK,kBAAkB,GAC1B,MAAM,8BAA8B,CAAC"}
package/esm/index.js CHANGED
@@ -6,6 +6,7 @@ export { registerLocalApplications } from "./registry/pluggableApplicationsRegis
6
6
  export { registerAppLifecycleCallbacks } from "./loader/pluggableApplicationsLoader.js";
7
7
  export { mapLegacyUrlToHost } from "./loader/legacyRedirect.js";
8
8
  export { Root } from "./components/Root.js";
9
+ export { HostErrorBoundary, } from "./components/HostErrorBoundary.js";
9
10
  export { STALE_CHUNK_RELOAD_PARAM, installPreloadErrorHandler, installVersionWatcher, reloadForStaleChunks, setStaleChunkReloadListener, } from "./lib/chunkReloadGuard.js";
10
11
  export { dispatchHostNotification } from "./lib/hostNotifications.js";
11
12
  export { setHostPricingExtension, } from "./ui/useHostChromePricing.js";
@@ -15,15 +15,6 @@ export interface ILoadPlatformContextOptions {
15
15
  auth?: IAuthCredentials;
16
16
  callbacks?: ILoadPlatformContextCallbacks;
17
17
  }
18
- /**
19
- * This approach is not correct - the host app should only care about the first part of the URL,
20
- * and the module should only care about the second part.
21
- * Here we are crossing the boundaries, and the host app is checking the full URL.
22
- * The proper fix requires larger changes not only here, but in gdc-nas as well.
23
- * This is scheduled to happen with the gdc-reports module.
24
- *
25
- * @internal - exported for testing
26
- */
27
18
  export declare function detectExportMode(): boolean;
28
19
  export declare function loadPlatformContext(options?: ILoadPlatformContextOptions): Promise<IBackendPlatformContext>;
29
20
  //# sourceMappingURL=loadPlatformContext.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"loadPlatformContext.d.ts","sourceRoot":"","sources":["../../src/platformContext/loadPlatformContext.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,KAAK,gBAAgB,EAAE,MAAM,2CAA2C,CAAC;AAMlF,OAAO,EAAE,KAAK,uBAAuB,EAAE,MAAM,YAAY,CAAC;AAE1D,qBAAa,4BAA6B,SAAQ,KAAK;IACnD,cAGC;CACJ;AAED;;GAEG;AACH,MAAM,WAAW,6BAA6B;IAC1C,gBAAgB,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;IAC5D,QAAQ,CAAC,EAAE,CAAC,UAAU,EAAE,MAAM,KAAK,IAAI,CAAC;CAC3C;AAED,MAAM,WAAW,2BAA2B;IACxC,MAAM,CAAC,EAAE,WAAW,CAAC;IACrB,IAAI,CAAC,EAAE,gBAAgB,CAAC;IACxB,SAAS,CAAC,EAAE,6BAA6B,CAAC;CAC7C;AAQD;;;;;;;;GAQG;AACH,wBAAgB,gBAAgB,IAAI,OAAO,CAgB1C;AAED,wBAAsB,mBAAmB,CACrC,OAAO,GAAE,2BAAgC,GAC1C,OAAO,CAAC,uBAAuB,CAAC,CA4ClC"}
1
+ {"version":3,"file":"loadPlatformContext.d.ts","sourceRoot":"","sources":["../../src/platformContext/loadPlatformContext.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,KAAK,gBAAgB,EAAE,MAAM,2CAA2C,CAAC;AAMlF,OAAO,EAAE,KAAK,uBAAuB,EAAE,MAAM,YAAY,CAAC;AAE1D,qBAAa,4BAA6B,SAAQ,KAAK;IACnD,cAGC;CACJ;AAED;;GAEG;AACH,MAAM,WAAW,6BAA6B;IAC1C,gBAAgB,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;IAC5D,QAAQ,CAAC,EAAE,CAAC,UAAU,EAAE,MAAM,KAAK,IAAI,CAAC;CAC3C;AAED,MAAM,WAAW,2BAA2B;IACxC,MAAM,CAAC,EAAE,WAAW,CAAC;IACrB,IAAI,CAAC,EAAE,gBAAgB,CAAC;IACxB,SAAS,CAAC,EAAE,6BAA6B,CAAC;CAC7C;AAqBD,wBAAgB,gBAAgB,IAAI,OAAO,CAkB1C;AAED,wBAAsB,mBAAmB,CACrC,OAAO,GAAE,2BAAgC,GAC1C,OAAO,CAAC,uBAAuB,CAAC,CA4ClC"}
@@ -22,19 +22,24 @@ function throwIfAborted(signal) {
22
22
  *
23
23
  * @internal - exported for testing
24
24
  */
25
+ // Dashboards name their export render mode `export`, reports `export_slideshow`; both are what the
26
+ // backend exporter opens.
27
+ const EXPORT_MODE_VALUES = new Set(["export", "export_slideshow"]);
25
28
  export function detectExportMode() {
26
29
  if (typeof window === "undefined") {
27
30
  return false;
28
31
  }
29
- const hash = window.location.hash;
30
- const query = hash.indexOf("?");
31
- if (query === -1) {
32
- return false;
33
- }
34
- for (const [key, value] of new URLSearchParams(hash.slice(query + 1))) {
35
- const name = key.toLowerCase();
36
- if ((name === "mode" || name === "displaymode") && value.toLowerCase() === "export") {
37
- return true;
32
+ // A hash-routed app carries its query inside the fragment; a browser-routed one in the search
33
+ // string. Either may name an export mode.
34
+ const { search, hash } = window.location;
35
+ const hashQuery = hash.indexOf("?");
36
+ const queries = [search ?? "", hashQuery === -1 ? "" : hash.slice(hashQuery + 1)];
37
+ for (const query of queries) {
38
+ for (const [key, value] of new URLSearchParams(query)) {
39
+ const name = key.toLowerCase();
40
+ if ((name === "mode" || name === "displaymode") && EXPORT_MODE_VALUES.has(value.toLowerCase())) {
41
+ return true;
42
+ }
38
43
  }
39
44
  }
40
45
  return false;
@@ -1 +1 @@
1
- {"version":3,"file":"useLoadPlatformContext.d.ts","sourceRoot":"","sources":["../../src/platformContext/useLoadPlatformContext.ts"],"names":[],"mappings":"AAOA,OAAO,EAA2B,KAAK,gBAAgB,EAAE,MAAM,2CAA2C,CAAC;AAM3G,OAAO,EACH,KAAK,6BAA6B,EAGrC,MAAM,0BAA0B,CAAC;AAClC,OAAO,EACH,KAAK,uBAAuB,EAC5B,KAAK,0BAA0B,EAElC,MAAM,YAAY,CAAC;AAYpB;;;;;;;GAOG;AACH,wBAAgB,sBAAsB,IAAI,0BAA0B,CAAC,gBAAgB,CAAC,CA+ErF;AAED;;;;GAIG;AACH,MAAM,WAAW,+BAA+B;IAC5C,YAAY,CAAC,SAAS,EAAE,6BAA6B,GAAG,IAAI,CAAC;IAC7D,SAAS,EAAE,MAAM,QAAQ,CAAC,0BAA0B,CAAC,uBAAuB,CAAC,CAAC,CAAC;IAC/E,SAAS,EAAE,CAAC,QAAQ,EAAE,MAAM,IAAI,KAAK,MAAM,IAAI,CAAC;IAChD,IAAI,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;CAC7B;AAmED;;GAEG;AACH,eAAO,MAAM,8BAA8B,EAAE,+BACA,CAAC;AAE9C;;;;;;GAMG;AACH,wBAAgB,gCAAgC,CAAC,SAAS,EAAE,6BAA6B,GAAG,IAAI,CAE/F"}
1
+ {"version":3,"file":"useLoadPlatformContext.d.ts","sourceRoot":"","sources":["../../src/platformContext/useLoadPlatformContext.ts"],"names":[],"mappings":"AAOA,OAAO,EAA2B,KAAK,gBAAgB,EAAE,MAAM,2CAA2C,CAAC;AAM3G,OAAO,EACH,KAAK,6BAA6B,EAGrC,MAAM,0BAA0B,CAAC;AAClC,OAAO,EACH,KAAK,uBAAuB,EAC5B,KAAK,0BAA0B,EAElC,MAAM,YAAY,CAAC;AAYpB;;;;;;;GAOG;AACH,wBAAgB,sBAAsB,IAAI,0BAA0B,CAAC,gBAAgB,CAAC,CAoJrF;AAED;;;;GAIG;AACH,MAAM,WAAW,+BAA+B;IAC5C,YAAY,CAAC,SAAS,EAAE,6BAA6B,GAAG,IAAI,CAAC;IAC7D,SAAS,EAAE,MAAM,QAAQ,CAAC,0BAA0B,CAAC,uBAAuB,CAAC,CAAC,CAAC;IAC/E,SAAS,EAAE,CAAC,QAAQ,EAAE,MAAM,IAAI,KAAK,MAAM,IAAI,CAAC;IAChD,IAAI,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;CAC7B;AAmED;;GAEG;AACH,eAAO,MAAM,8BAA8B,EAAE,+BACA,CAAC;AAE9C;;;;;;GAMG;AACH,wBAAgB,gCAAgC,CAAC,SAAS,EAAE,6BAA6B,GAAG,IAAI,CAE/F"}
@@ -1,5 +1,5 @@
1
1
  // (C) 2026 GoodData Corporation
2
- import { useMemo, useSyncExternalStore } from "react";
2
+ import { useEffect, useMemo, useState, useSyncExternalStore } from "react";
3
3
  import { useLocation } from "react-router";
4
4
  import { isLocale } from "@gooddata/sdk-model";
5
5
  import { isProduction } from "../lib/isProduction.js";
@@ -33,7 +33,8 @@ export function useLoadPlatformContext() {
33
33
  const workspaceSettingsState = useWorkspaceSettings(backend, workspaceId);
34
34
  const workspaceColorPaletteState = useWorkspaceColorPalette(backend, workspaceId);
35
35
  const workspaceThemeState = useWorkspaceTheme(backend, workspaceId);
36
- return useMemo(() => {
36
+ const [lastReady, setLastReady] = useState(undefined);
37
+ const result = useMemo(() => {
37
38
  if (backendContext.state !== "ready") {
38
39
  return backendContext;
39
40
  }
@@ -83,6 +84,57 @@ export function useLoadPlatformContext() {
83
84
  workspaceColorPaletteState,
84
85
  workspaceThemeState,
85
86
  ]);
87
+ // Remember the last COMMITTED result in state (an effect never runs for a discarded
88
+ // render, so an abandoned concurrent render cannot leak its context into the fallback).
89
+ useEffect(() => {
90
+ if (result.state === "ready") {
91
+ setLastReady({ workspaceId, result });
92
+ }
93
+ else if (result.state === "error") {
94
+ setLastReady(undefined);
95
+ }
96
+ }, [result, workspaceId]);
97
+ // Fallback served while the same workspace's data refreshes: the previous workspace-scoped
98
+ // values merged over the CURRENT backend context, so a refreshed auth reaches the mounted
99
+ // app immediately instead of waiting for every workspace request to finish.
100
+ const refreshingFallback = useMemo(() => {
101
+ if (backendContext.state !== "ready" ||
102
+ lastReady === undefined ||
103
+ lastReady.workspaceId !== workspaceId ||
104
+ lastReady.result.state !== "ready" ||
105
+ // A different principal or organization must never see the previous principal's
106
+ // permissions/settings, even for the duration of the refresh — fall through to
107
+ // the loader instead of merging across principals.
108
+ lastReady.result.ctx.user?.login !== backendContext.ctx.user?.login ||
109
+ lastReady.result.ctx.organization?.id !== backendContext.ctx.organization?.id) {
110
+ return undefined;
111
+ }
112
+ const { currentApplicationScope, currentWorkspaceId, workspacePermissions, workspaceSettings, colorPalette, settings, preferredLocale, theme, } = lastReady.result.ctx;
113
+ return {
114
+ state: "ready",
115
+ ctx: {
116
+ ...backendContext.ctx,
117
+ currentApplicationScope,
118
+ currentWorkspaceId,
119
+ workspacePermissions,
120
+ workspaceSettings,
121
+ colorPalette,
122
+ settings,
123
+ preferredLocale,
124
+ theme,
125
+ },
126
+ };
127
+ }, [backendContext, lastReady, workspaceId]);
128
+ // Keep the previously rendered context while the same workspace's data refreshes (backend
129
+ // re-creation after a token refresh, reload-platform-context requests). Returning "loading"
130
+ // here would swap the whole HostUiContainer for a loader, unmounting the mounted app and
131
+ // cancelling its in-flight work; the refreshed context is pushed via handle.updateContext
132
+ // instead. A workspace change must NOT reuse the old context (permissions/theme belong to
133
+ // the previous workspace), so it still falls through to the loader.
134
+ if (result.state === "loading" && refreshingFallback !== undefined) {
135
+ return refreshingFallback;
136
+ }
137
+ return result;
86
138
  }
87
139
  class BackendPlatformContextProviderClass {
88
140
  _loadingStateMemo = { state: "loading" };
@@ -1,5 +1,8 @@
1
+ import { Component } from 'react';
2
+ import { ErrorInfo } from 'react';
1
3
  import { IAnalyticalBackend } from '@gooddata/sdk-backend-spi';
2
4
  import { IHostUiNotification } from '@gooddata/sdk-pluggable-application-model';
5
+ import { ILocale } from '@gooddata/sdk-model';
3
6
  import { IPlatformContext } from '@gooddata/sdk-pluggable-application-model';
4
7
  import { IPluggableApp } from '@gooddata/sdk-pluggable-application-model';
5
8
  import { IPluggableAppTelemetryCallbacks } from '@gooddata/sdk-pluggable-application-model';
@@ -24,6 +27,21 @@ export declare function dispatchHostNotification(notification: IHostUiNotificati
24
27
  */
25
28
  export declare function getBackend(): IAnalyticalBackend;
26
29
 
30
+ /**
31
+ * Last-resort boundary for errors escaping to the host application root. The host page is
32
+ * composed of nested React roots rendering into DOM owned by the topmost root, so without
33
+ * a boundary React unmounts the entire tree — header included — leaving a blank page.
34
+ *
35
+ * @alpha
36
+ */
37
+ export declare class HostErrorBoundary extends Component<IHostErrorBoundaryProps, IHostErrorBoundaryState> {
38
+ state: IHostErrorBoundaryState;
39
+ static getDerivedStateFromError(): Partial<IHostErrorBoundaryState>;
40
+ static getDerivedStateFromProps(props: IHostErrorBoundaryProps, state: IHostErrorBoundaryState): Partial<IHostErrorBoundaryState> | null;
41
+ componentDidCatch(error: Error, info: ErrorInfo): void;
42
+ render(): ReactNode;
43
+ }
44
+
27
45
  /**
28
46
  * @alpha
29
47
  */
@@ -60,6 +78,35 @@ export declare interface IHostChromePricing {
60
78
  onUpsellButtonClick: () => void;
61
79
  }
62
80
 
81
+ /**
82
+ * Properties of {@link HostErrorBoundary}.
83
+ *
84
+ * @alpha
85
+ */
86
+ export declare interface IHostErrorBoundaryProps {
87
+ /**
88
+ * Reports the caught error together with the React component stack it was thrown from.
89
+ */
90
+ onError?: (error: string, context: string) => void;
91
+ locale?: ILocale;
92
+ /**
93
+ * A caught error is cleared when this value changes, so the subtree gets a fresh
94
+ * mount attempt (e.g. on navigation) instead of showing the fallback forever.
95
+ */
96
+ resetKey?: string | number;
97
+ children: ReactNode;
98
+ }
99
+
100
+ /**
101
+ * State of {@link HostErrorBoundary}.
102
+ *
103
+ * @alpha
104
+ */
105
+ export declare interface IHostErrorBoundaryState {
106
+ hasError: boolean;
107
+ resetKey?: string | number;
108
+ }
109
+
63
110
  /**
64
111
  * @alpha
65
112
  */
@@ -1 +1 @@
1
- {"version":3,"file":"DefaultHostUi.d.ts","sourceRoot":"","sources":["../../src/ui/DefaultHostUi.tsx"],"names":[],"mappings":"AAQA,OAAO,EAEH,KAAK,aAAa,EAKrB,MAAM,2CAA2C,CAAC;AAInD,OAAO,sBAAsB,CAAC;AAkN9B;;;;;;;;GAQG;AACH,eAAO,MAAM,mBAAmB,EAAE,aAEjC,CAAC"}
1
+ {"version":3,"file":"DefaultHostUi.d.ts","sourceRoot":"","sources":["../../src/ui/DefaultHostUi.tsx"],"names":[],"mappings":"AAQA,OAAO,EAEH,KAAK,aAAa,EAKrB,MAAM,2CAA2C,CAAC;AAOnD,OAAO,sBAAsB,CAAC;AA+N9B;;;;;;;;GAQG;AACH,eAAO,MAAM,mBAAmB,EAAE,aAEjC,CAAC"}
@@ -3,10 +3,12 @@ import { jsx as _jsx } from "react/jsx-runtime";
3
3
  import { useLayoutEffect, useRef, useState } from "react";
4
4
  import { flushSync } from "react-dom";
5
5
  import { createRoot } from "react-dom/client";
6
+ import { resolveLocale } from "@gooddata/sdk-ui";
7
+ import { HostErrorBoundary } from "../components/HostErrorBoundary.js";
6
8
  import { HostChrome } from "./HostChrome.js";
7
9
  import { e } from "./hostChromeBem.js";
8
10
  import "./DefaultHostUi.scss";
9
- function HostUiBridge({ initialCtx, initialApps, initialPathname, navigate, replace, onChatToggleRequested, onAskAiAssistant, onAppContainerReady, onReady, }) {
11
+ function HostUiBridge({ initialCtx, initialApps, initialPathname, navigate, replace, onChatToggleRequested, onAskAiAssistant, onError, onAppContainerReady, onReady, }) {
10
12
  const [ctx, setCtx] = useState(initialCtx);
11
13
  const [apps, setApps] = useState(initialApps);
12
14
  const [pathname, setPathname] = useState(initialPathname);
@@ -26,13 +28,13 @@ function HostUiBridge({ initialCtx, initialApps, initialPathname, navigate, repl
26
28
  // Only call once on mount
27
29
  // eslint-disable-next-line react-hooks/exhaustive-deps
28
30
  }, []);
29
- return (_jsx(HostChrome, { ctx: ctx, resolvedApplications: apps, pathname: pathname, onNavigate: navigate, onReplace: replace, headerOptions: headerOptions, notification: notification, showChatItem: chatState.showChatItem, chatIsOpen: chatState.isOpen, onChatToggle: onChatToggleRequested, onAskAiAssistant: onAskAiAssistant, appPageTitle: pageTitle, children: _jsx("div", { ref: appContainerRef, className: e("app-container") }) }));
31
+ return (_jsx(HostErrorBoundary, { onError: onError, locale: resolveLocale(ctx.preferredLocale), children: _jsx(HostChrome, { ctx: ctx, resolvedApplications: apps, pathname: pathname, onNavigate: navigate, onReplace: replace, headerOptions: headerOptions, notification: notification, showChatItem: chatState.showChatItem, chatIsOpen: chatState.isOpen, onChatToggle: onChatToggleRequested, onAskAiAssistant: onAskAiAssistant, appPageTitle: pageTitle, children: _jsx("div", { ref: appContainerRef, className: e("app-container") }) }) }));
30
32
  }
31
33
  // ---------------------------------------------------------------------------
32
34
  // Imperative mount function conforming to IHostUiModule
33
35
  // ---------------------------------------------------------------------------
34
36
  function mountDefaultHostUi(options) {
35
- const { container, ctx, resolvedApplications, pathname, navigate, replace, onChatToggleRequested, onAskAiAssistant, } = options;
37
+ const { container, ctx, resolvedApplications, pathname, navigate, replace, onChatToggleRequested, onAskAiAssistant, onError, } = options;
36
38
  let reactRoot = createRoot(container);
37
39
  let appContainer = null;
38
40
  let updateCtxFn = null;
@@ -45,17 +47,17 @@ function mountDefaultHostUi(options) {
45
47
  // Use flushSync so that the DOM is ready synchronously after mount() returns,
46
48
  // making getAppContainer() safe to call immediately.
47
49
  flushSync(() => {
48
- reactRoot?.render(_jsx(HostUiBridge, { initialCtx: ctx, initialApps: resolvedApplications, initialPathname: pathname, navigate: navigate, replace: replace, onChatToggleRequested: onChatToggleRequested, onAskAiAssistant: onAskAiAssistant, onAppContainerReady: (el) => {
49
- appContainer = el;
50
- }, onReady: (setCtx, setApps, setPathname, setHeaderOptions, setNotification, setPageTitle, setChatState) => {
51
- updateCtxFn = setCtx;
52
- updateAppsFn = setApps;
53
- updatePathnameFn = setPathname;
54
- updateHeaderFn = setHeaderOptions;
55
- updateNotificationFn = setNotification;
56
- updateDocumentTitleFn = setPageTitle;
57
- updateChatStateFn = setChatState;
58
- } }));
50
+ reactRoot?.render(_jsx(HostErrorBoundary, { onError: onError, locale: resolveLocale(ctx.preferredLocale), children: _jsx(HostUiBridge, { initialCtx: ctx, initialApps: resolvedApplications, initialPathname: pathname, navigate: navigate, replace: replace, onChatToggleRequested: onChatToggleRequested, onAskAiAssistant: onAskAiAssistant, onError: onError, onAppContainerReady: (el) => {
51
+ appContainer = el;
52
+ }, onReady: (setCtx, setApps, setPathname, setHeaderOptions, setNotification, setPageTitle, setChatState) => {
53
+ updateCtxFn = setCtx;
54
+ updateAppsFn = setApps;
55
+ updatePathnameFn = setPathname;
56
+ updateHeaderFn = setHeaderOptions;
57
+ updateNotificationFn = setNotification;
58
+ updateDocumentTitleFn = setPageTitle;
59
+ updateChatStateFn = setChatState;
60
+ } }) }));
59
61
  });
60
62
  return {
61
63
  unmount() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gooddata/sdk-ui-pluggable-host",
3
- "version": "11.57.0-alpha.2",
3
+ "version": "11.57.0-alpha.3",
4
4
  "description": "GoodData SDK runtime for hosting pluggable applications — registry, loader, routing, platform context, default UI chrome",
5
5
  "license": "MIT",
6
6
  "author": "GoodData Corporation",
@@ -29,20 +29,20 @@
29
29
  "dependencies": {
30
30
  "@module-federation/runtime": "2.6.0",
31
31
  "lodash-es": "^4.17.23",
32
- "@gooddata/sdk-backend-spi": "11.57.0-alpha.2",
33
- "@gooddata/sdk-backend-tiger": "11.57.0-alpha.2",
34
- "@gooddata/sdk-backend-base": "11.57.0-alpha.2",
35
- "@gooddata/sdk-embedding": "11.57.0-alpha.2",
36
- "@gooddata/sdk-model": "11.57.0-alpha.2",
37
- "@gooddata/sdk-pluggable-application-model": "11.57.0-alpha.2",
38
- "@gooddata/sdk-ui-application-header": "11.57.0-alpha.2",
39
- "@gooddata/sdk-ui-gen-ai": "11.57.0-alpha.2",
40
- "@gooddata/sdk-ui-ext": "11.57.0-alpha.2",
41
- "@gooddata/sdk-ui": "11.57.0-alpha.2",
42
- "@gooddata/sdk-ui-kit": "11.57.0-alpha.2",
43
- "@gooddata/sdk-ui-semantic-search": "11.57.0-alpha.2",
44
- "@gooddata/util": "11.57.0-alpha.2",
45
- "@gooddata/sdk-ui-theme-provider": "11.57.0-alpha.2"
32
+ "@gooddata/sdk-backend-base": "11.57.0-alpha.3",
33
+ "@gooddata/sdk-model": "11.57.0-alpha.3",
34
+ "@gooddata/sdk-embedding": "11.57.0-alpha.3",
35
+ "@gooddata/sdk-pluggable-application-model": "11.57.0-alpha.3",
36
+ "@gooddata/sdk-backend-tiger": "11.57.0-alpha.3",
37
+ "@gooddata/sdk-ui": "11.57.0-alpha.3",
38
+ "@gooddata/sdk-backend-spi": "11.57.0-alpha.3",
39
+ "@gooddata/sdk-ui-ext": "11.57.0-alpha.3",
40
+ "@gooddata/sdk-ui-application-header": "11.57.0-alpha.3",
41
+ "@gooddata/sdk-ui-gen-ai": "11.57.0-alpha.3",
42
+ "@gooddata/sdk-ui-theme-provider": "11.57.0-alpha.3",
43
+ "@gooddata/util": "11.57.0-alpha.3",
44
+ "@gooddata/sdk-ui-semantic-search": "11.57.0-alpha.3",
45
+ "@gooddata/sdk-ui-kit": "11.57.0-alpha.3"
46
46
  },
47
47
  "devDependencies": {
48
48
  "@microsoft/api-documenter": "7.30.10",
@@ -68,9 +68,9 @@
68
68
  "jiti": "2.6.1",
69
69
  "npm-run-all": "4.1.5",
70
70
  "oxfmt": "0.66.0",
71
- "oxlint": "1.66.0",
71
+ "oxlint": "1.67.0",
72
72
  "oxlint-plugin-eslint": "1.82.0",
73
- "oxlint-tsgolint": "0.18.1",
73
+ "oxlint-tsgolint": "0.22.1",
74
74
  "react": "19.1.1",
75
75
  "react-dom": "19.1.1",
76
76
  "react-intl": "10.1.25",
@@ -82,8 +82,8 @@
82
82
  "vite": "8.0.16",
83
83
  "vitest": "4.1.8",
84
84
  "vitest-dom": "0.1.1",
85
- "@gooddata/eslint-config": "11.57.0-alpha.2",
86
- "@gooddata/oxlint-config": "11.57.0-alpha.2"
85
+ "@gooddata/eslint-config": "11.57.0-alpha.3",
86
+ "@gooddata/oxlint-config": "11.57.0-alpha.3"
87
87
  },
88
88
  "peerDependencies": {
89
89
  "react": ">=18.3.1",