From 8b9e57769411c06f2b8d8bc80d9d0cc787be84be Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 10 May 2026 14:18:07 +0200 Subject: [PATCH] refactor(AppShell): pure renderer, drop browser fetch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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: 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) --- package-lock.json | 25 ++-- package.json | 4 +- src/components/Popover.tsx | 2 +- src/components/Tooltip.tsx | 2 +- src/layout/AppShell.tsx | 279 +++++++++++-------------------------- 5 files changed, 98 insertions(+), 214 deletions(-) diff --git a/package-lock.json b/package-lock.json index 245ff44..b1d15ed 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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": { diff --git a/package.json b/package.json index e909344..6fbebbd 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/src/components/Popover.tsx b/src/components/Popover.tsx index ff398d4..81ce994 100644 --- a/src/components/Popover.tsx +++ b/src/components/Popover.tsx @@ -5,7 +5,7 @@ export type PopoverProps = { title?: React.ReactNode; content: React.ReactNode; placement?: Placement; - children: React.ReactElement; + children: React.ReactElement; className?: string; }; diff --git a/src/components/Tooltip.tsx b/src/components/Tooltip.tsx index ffe8fc4..d351478 100644 --- a/src/components/Tooltip.tsx +++ b/src/components/Tooltip.tsx @@ -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; className?: string; }; diff --git a/src/layout/AppShell.tsx b/src/layout/AppShell.tsx index 4319740..0afbdf8 100644 --- a/src/layout/AppShell.tsx +++ b/src/layout/AppShell.tsx @@ -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 ; 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>; }; -// ─── Context ────────────────────────────────────────────────────────────────── +// ─── Context — exposes config to descendants via useShell() ─────────────────── -type ShellContextValue = { - config: ShellConfig | null; - loading: boolean; - error: Error | null; - refresh: () => Promise; -}; +const ShellContext = createContext(null); -const ShellContext = createContext(null); - -export function useShell(): ShellContextValue { - const ctx = useContext(ShellContext); - if (!ctx) { - throw new Error("useShell must be used inside / "); +/** Read the current ShellConfig from anywhere inside . */ +export function useShell(): ShellConfig { + const cfg = useContext(ShellContext); + if (!cfg) { + throw new Error("useShell must be used inside "); } - 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; - /** 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(initialConfig ?? null); - const [loading, setLoading] = useState(!initialConfig); - const [error, setError] = useState(null); - const [etag, setEtag] = useState(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( - () => ({ config, loading, error, refresh: fetcher }), - [config, loading, error, fetcher], - ); - - return {children}; -} - -// ─── 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; - 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 slot. */ + /** Optional content for the 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 ( - - - {props.children} - - - ); -} - -type ShellChromeProps = Pick; - -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 ( - …} - brandHref="/" - showSidebarToggle={false} - /> - } - className={className} - > - {error ? ( -
- Chrome unavailable: {error.message}. Showing app content only. -
- ) : null} - {children} -
- ); - } - 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 = ( - + {config.branding.productName} @@ -270,7 +153,9 @@ function ShellChrome({ currentPath, translate, onSignOut, pageHeader, className, data-bs-toggle="dropdown" aria-expanded="false" > - {config.user.displayName || t("menu.account")} + + {config.user.displayName || t("menu.account")} +
{userMenuItems}
@@ -279,46 +164,50 @@ function ShellChrome({ currentPath, translate, onSignOut, pageHeader, className, ); return ( - - } - pageHeader={pageHeader} - mainSidebar={ - - } - footer={ -