refactor(AppShell): pure renderer, drop browser fetch

The browser-fetching variant ran into the cross-origin / cert-trust
wall that came up the moment a real user loaded the page. Move the
network call out of the library: <AppShell> now takes a pre-resolved
ShellConfig as a required prop. Apps fetch from gsc-shell-api in
their RSC layout (server-to-server, no CORS, no cert dance) and pass
the result down.

Public surface:
  AppShell, useShell, ShellConfig + sub-types

Removed:
  ShellProvider (network logic gone), all fetcher props
  (apiUrl, getToken, appKey, initialConfig, revalidateMs).

useShell() now returns ShellConfig directly (was ShellContextValue
with loading/error/refresh — no longer relevant).

Apps drive revalidation by re-rendering; chrome stays request-fresh
without any client-side polling.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude
2026-05-10 14:18:07 +02:00
parent cf068ce4ec
commit 8b9e577694
5 changed files with 98 additions and 214 deletions

25
package-lock.json generated
View File

@@ -19,8 +19,8 @@
"devDependencies": {
"@types/google.maps": "^3.55.0",
"@types/nodemailer": "^6.4.0",
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"typescript": "^5.4.0"
},
"peerDependencies": {
@@ -453,30 +453,23 @@
"integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==",
"license": "MIT"
},
"node_modules/@types/prop-types": {
"version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
"license": "MIT"
},
"node_modules/@types/react": {
"version": "18.3.27",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz",
"integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==",
"version": "19.2.14",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"license": "MIT",
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.2.2"
}
},
"node_modules/@types/react-dom": {
"version": "18.3.7",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
"version": "19.2.3",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"@types/react": "^18.0.0"
"@types/react": "^19.2.0"
}
},
"node_modules/@types/react-transition-group": {

View File

@@ -55,8 +55,8 @@
"devDependencies": {
"@types/google.maps": "^3.55.0",
"@types/nodemailer": "^6.4.0",
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"typescript": "^5.4.0"
}
}

View File

@@ -5,7 +5,7 @@ export type PopoverProps = {
title?: React.ReactNode;
content: React.ReactNode;
placement?: Placement;
children: React.ReactElement;
children: React.ReactElement<any>;
className?: string;
};

View File

@@ -4,7 +4,7 @@ import { useFloating, offset, shift, flip, arrow, Placement, Middleware } from '
export type TooltipProps = {
content: React.ReactNode;
placement?: Placement;
children: React.ReactElement;
children: React.ReactElement<any>;
className?: string;
};

View File

@@ -1,13 +1,16 @@
"use client";
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
import React, { createContext, useContext } from "react";
import { Navbar } from "./Navbar";
import { Sidebar, type SidebarNavItem } from "./Sidebar";
import { Footer, type FooterNavItem } from "./Footer";
import { PageShell } from "./PageShell";
// ─── ShellConfig types — mirror gsc-shell-api's response DTO ──────────────────
// ─── ShellConfig types ────────────────────────────────────────────────────────
//
// Mirrors gsc-shell-api's response DTO. Apps fetch this server-side (RSC)
// and pass it to <AppShell>; the library doesn't talk to the network.
export type ShellMenuZone = "topbar" | "sidebar" | "footer" | "user-menu";
@@ -52,112 +55,28 @@ export type ShellConfig = {
menus: Partial<Record<ShellMenuZone, ShellMenuItem[]>>;
};
// ─── Context ──────────────────────────────────────────────────────────────────
// ─── Context — exposes config to descendants via useShell() ───────────────────
type ShellContextValue = {
config: ShellConfig | null;
loading: boolean;
error: Error | null;
refresh: () => Promise<void>;
};
const ShellContext = createContext<ShellConfig | null>(null);
const ShellContext = createContext<ShellContextValue | null>(null);
export function useShell(): ShellContextValue {
const ctx = useContext(ShellContext);
if (!ctx) {
throw new Error("useShell must be used inside <AppShell> / <ShellProvider>");
/** Read the current ShellConfig from anywhere inside <AppShell>. */
export function useShell(): ShellConfig {
const cfg = useContext(ShellContext);
if (!cfg) {
throw new Error("useShell must be used inside <AppShell>");
}
return ctx;
return cfg;
}
// ─── Provider — handles fetch + revalidation + cache ──────────────────────────
export type ShellProviderProps = {
/** App identifier as registered in shell-api (e.g. "gsc-crm") */
appKey: string;
/** Base URL of gsc-shell-api (e.g. "https://shell-api.gosec.internal") */
apiUrl: string;
/** Returns a fresh Keycloak access token. Called on each fetch. */
getToken: () => Promise<string>;
/** Optional: a snapshot of ShellConfig to render before the first fetch finishes. */
initialConfig?: ShellConfig;
/** Optional: revalidate this often (ms). Default 5 minutes. */
revalidateMs?: number;
children: React.ReactNode;
};
export function ShellProvider({
appKey,
apiUrl,
getToken,
initialConfig,
revalidateMs = 5 * 60 * 1000,
children,
}: ShellProviderProps) {
const [config, setConfig] = useState<ShellConfig | null>(initialConfig ?? null);
const [loading, setLoading] = useState<boolean>(!initialConfig);
const [error, setError] = useState<Error | null>(null);
const [etag, setEtag] = useState<string | null>(null);
const fetcher = useCallback(async () => {
setLoading(true);
try {
const token = await getToken();
const res = await fetch(`${apiUrl}/api/v1/shell/${encodeURIComponent(appKey)}`, {
headers: {
Authorization: token ? `Bearer ${token}` : "",
...(etag ? { "If-None-Match": etag } : {}),
},
credentials: "omit",
});
if (res.status === 304) {
// Cached config still valid
setError(null);
return;
}
if (!res.ok) {
throw new Error(`shell-api ${res.status}: ${await res.text()}`);
}
const next = (await res.json()) as ShellConfig;
setConfig(next);
setError(null);
const newEtag = res.headers.get("ETag");
if (newEtag) setEtag(newEtag);
} catch (e) {
setError(e instanceof Error ? e : new Error(String(e)));
} finally {
setLoading(false);
}
}, [apiUrl, appKey, getToken, etag]);
useEffect(() => {
void fetcher();
if (!revalidateMs) return;
const id = window.setInterval(() => {
void fetcher();
}, revalidateMs);
return () => window.clearInterval(id);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [appKey, apiUrl]);
const value = useMemo<ShellContextValue>(
() => ({ config, loading, error, refresh: fetcher }),
[config, loading, error, fetcher],
);
return <ShellContext.Provider value={value}>{children}</ShellContext.Provider>;
}
// ─── AppShell — composite that renders chrome from ShellConfig ─────────────────
// ─── AppShell — pure renderer ─────────────────────────────────────────────────
export type AppShellProps = {
/** Same args as ShellProvider — AppShell wraps it. */
appKey: string;
apiUrl: string;
getToken: () => Promise<string>;
initialConfig?: ShellConfig;
revalidateMs?: number;
/**
* Pre-resolved shell config. Apps fetch this server-side from
* gsc-shell-api and pass it down. The library never makes a
* network call — no CORS, no cert trust, no browser auth dance.
*/
config: ShellConfig;
/** Current pathname for active-route highlight. Default: window.location.pathname. */
currentPath?: string;
@@ -168,68 +87,28 @@ export type AppShellProps = {
/** Optional: signs the user out. Hook this up to your NextAuth/Keycloak signout. */
onSignOut?: () => void;
/** Optional: rendered inside <PageHeader> slot. */
/** Optional content for the <PageHeader> slot (breadcrumbs, page title, etc.). */
pageHeader?: React.ReactNode;
/** Page content. */
children: React.ReactNode;
/** Override the wrapper className. */
/** Optional className on the outer wrapper. */
className?: string;
};
export function AppShell(props: AppShellProps) {
return (
<ShellProvider
appKey={props.appKey}
apiUrl={props.apiUrl}
getToken={props.getToken}
initialConfig={props.initialConfig}
revalidateMs={props.revalidateMs}
>
<ShellChrome
currentPath={props.currentPath}
translate={props.translate}
onSignOut={props.onSignOut}
pageHeader={props.pageHeader}
className={props.className}
>
{props.children}
</ShellChrome>
</ShellProvider>
);
}
type ShellChromeProps = Pick<AppShellProps, "currentPath" | "translate" | "onSignOut" | "pageHeader" | "className" | "children">;
function ShellChrome({ currentPath, translate, onSignOut, pageHeader, className, children }: ShellChromeProps) {
const { config, error } = useShell();
export function AppShell({
config,
currentPath,
translate,
onSignOut,
pageHeader,
className,
children,
}: AppShellProps) {
const t = translate ?? ((k: string) => k);
const path = currentPath ?? (typeof window !== "undefined" ? window.location.pathname : "/");
// Fallback chrome if config never loaded — minimal so the app still renders.
if (!config) {
return (
<PageShell
navbar={
<Navbar
brand={<span className="navbar-brand-text"></span>}
brandHref="/"
showSidebarToggle={false}
/>
}
className={className}
>
{error ? (
<div className="alert alert-warning mt-3">
Chrome unavailable: {error.message}. Showing app content only.
</div>
) : null}
{children}
</PageShell>
);
}
const sidebarItems = (config.menus.sidebar ?? []).map((m) => toSidebarNavItem(m, path, t));
const footerNavItems: FooterNavItem[] = (config.menus.footer ?? []).map((m) => ({
label: t(m.translationKey),
@@ -238,7 +117,11 @@ function ShellChrome({ currentPath, translate, onSignOut, pageHeader, className,
}));
const navbarBrand = (
<a className="d-flex align-items-center gap-2" href={config.app.baseUrl} style={{ color: "inherit", textDecoration: "none" }}>
<a
className="d-flex align-items-center gap-2"
href={config.app.baseUrl}
style={{ color: "inherit", textDecoration: "none" }}
>
<img src={config.branding.logoUrl} alt="" height={24} />
<span className="fw-semibold">{config.branding.productName}</span>
</a>
@@ -270,7 +153,9 @@ function ShellChrome({ currentPath, translate, onSignOut, pageHeader, className,
data-bs-toggle="dropdown"
aria-expanded="false"
>
<span className="d-none d-md-inline me-2">{config.user.displayName || t("menu.account")}</span>
<span className="d-none d-md-inline me-2">
{config.user.displayName || t("menu.account")}
</span>
<i className="ph-user-circle" />
</button>
<div className="dropdown-menu dropdown-menu-end">{userMenuItems}</div>
@@ -279,6 +164,7 @@ function ShellChrome({ currentPath, translate, onSignOut, pageHeader, className,
);
return (
<ShellContext.Provider value={config}>
<PageShell
className={className}
navbar={
@@ -316,9 +202,12 @@ function ShellChrome({ currentPath, translate, onSignOut, pageHeader, className,
>
{children}
</PageShell>
</ShellContext.Provider>
);
}
// ─── helpers ──────────────────────────────────────────────────────────────────
function toSidebarNavItem(
m: ShellMenuItem,
currentPath: string,
@@ -328,21 +217,23 @@ function toSidebarNavItem(
const item: SidebarNavItem = {
type: m.children && m.children.length > 0 ? "submenu" : "link",
label: t(m.translationKey),
href: m.isExternal ? m.href : m.href,
href: m.href,
iconClass: m.icon,
active,
};
if (m.children && m.children.length > 0) {
item.children = m.children.map((c) => toSidebarNavItem(c, currentPath, t));
// submenu open if any descendant is active
item.isOpen = item.children.some((c) => c.active || (c.children?.some((g) => g.active) ?? false));
item.isOpen = item.children.some(
(c) => c.active || (c.children?.some((g) => g.active) ?? false),
);
}
return item;
}
function isActiveHref(href: string, currentPath: string): boolean {
if (!href) return false;
// Strip locale prefix from current path so /en/dashboard matches /dashboard
// Strip a leading 2-letter locale prefix so /en/dashboard matches /dashboard.
const stripped = currentPath.replace(/^\/[a-z]{2}(?=\/|$)/, "") || "/";
if (href === "/") return stripped === "/";
return stripped === href || stripped.startsWith(`${href}/`);