Skip to content

Commit e5deade

Browse files
authored
feat(greenhouse): optimize plugin fetching and loading strategies (#1540)
* feat(greenhouse): filter plugins by ui-plugin label * chore(greenhouse): add changeset for ui-plugin filtering * feat(greenhouse): optimize plugin loading with dynamic imports - Add dynamic imports for plugins to reduce initial bundle size - Implement module-level caching to eliminate loading flicker on return visits - Use remountDeps to ensure clean URL state when switching between plugins - Update TypeScript declarations for plugin modules - Export PluginModule type from each plugin for better documentation * chore(greenhouse): update changeset for plugin loading optimization * refactor(greenhouse): extract plugin loading logic to usePluginLoader hook * test(greenhouse): add tests for usePluginLoader * chore(juno): export typed apps
1 parent c2bc301 commit e5deade

16 files changed

Lines changed: 330 additions & 114 deletions

File tree

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
"@cloudoperators/juno-app-greenhouse": minor
3+
"@cloudoperators/juno-app-doop": patch
4+
"@cloudoperators/juno-app-supernova": patch
5+
"@cloudoperators/juno-app-heureka": patch
6+
---
7+
8+
Optimize plugin loading with dynamic imports and improve navigation between plugins. Plugins now load on-demand instead of being bundled upfront, reducing initial bundle size by 66%. Added module caching to eliminate loading spinners on return visits. Fixed URL state pollution when switching between plugins using TanStack Router's remountDeps. Also filters plugins server-side to fetch only UI plugins.

apps/doop/package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,10 @@
55
"type": "module",
66
"repository": "https://github.com/cloudoperators/juno/tree/main/apps/doop",
77
"exports": {
8-
".": "./build/index.js"
8+
".": {
9+
"types": "./src/types/index.d.ts",
10+
"default": "./build/index.js"
11+
}
912
},
1013
"license": "Apache-2.0",
1114
"private": true,

apps/doop/src/index.ts

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,21 @@
33
* SPDX-License-Identifier: Apache-2.0
44
*/
55

6-
import { createRoot } from "react-dom/client"
6+
import { createRoot, Root } from "react-dom/client"
77
import React from "react"
8+
import App from "./App"
9+
10+
let root: Root | null = null
811

912
// export mount and unmount functions
10-
export const mount = (container: any, options = {}) => {
11-
import("./App").then((App) => {
12-
// @ts-expect-error TS(2339) FIXME: Property 'root' does not exist on type '(container... Remove this comment to see the full error message
13-
mount.root = createRoot(container)
14-
// @ts-expect-error TS(2339) FIXME: Property 'root' does not exist on type '(container... Remove this comment to see the full error message
15-
mount.root.render(React.createElement(App.default, options?.props))
16-
})
13+
export const mount = (container: HTMLElement, options: any = {}) => {
14+
root = createRoot(container)
15+
root.render(React.createElement(App, options?.props))
1716
}
1817

19-
// @ts-expect-error TS(2339) FIXME: Property 'root' does not exist on type '(container... Remove this comment to see the full error message
20-
export const unmount = () => mount.root && mount.root.unmount()
18+
export const unmount = () => {
19+
if (root) {
20+
root.unmount()
21+
root = null
22+
}
23+
}

apps/doop/src/types/index.d.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,13 @@
44
*/
55

66
/// <reference types="vite/client" />
7+
8+
declare module "@cloudoperators/juno-app-doop" {
9+
export type PluginModule = {
10+
mount: (container: HTMLElement, options?: Record<string, any>) => void
11+
unmount: () => void
12+
}
13+
14+
export function mount(container: HTMLElement, options?: Record<string, any>): void
15+
export function unmount(): void
16+
}

apps/greenhouse/src/components/Extension.tsx

Lines changed: 17 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -3,75 +3,35 @@
33
* SPDX-License-Identifier: Apache-2.0
44
*/
55

6-
import React, { Suspense, useEffect, useRef } from "react"
7-
import { useRouter } from "@tanstack/react-router"
8-
import * as supernova from "@cloudoperators/juno-app-supernova"
9-
import * as doop from "@cloudoperators/juno-app-doop"
10-
import * as heureka from "@cloudoperators/juno-app-heureka"
11-
import * as admin from "../components/core-apps/org-admin"
6+
import React from "react"
127
import { AppProps } from "../Shell"
13-
import type { AuthStore, EmbeddedAuth } from "@cloudoperators/greenhouse-auth-provider"
14-
15-
const getApp = (appName: string) => {
16-
switch (appName) {
17-
case "supernova":
18-
return supernova
19-
case "doop":
20-
return doop
21-
case "heureka":
22-
return heureka
23-
default:
24-
return null
25-
}
26-
}
8+
import type { AuthStore } from "@cloudoperators/greenhouse-auth-provider"
9+
import { usePluginLoader } from "../hooks/usePluginLoader"
2710

2811
type ExtensionProps = {
2912
id: string
3013
config: any
31-
auth: any
3214
appProps: AppProps
3315
pluginAuth: AuthStore
3416
}
3517

36-
function Extension({ id, config, auth, appProps, pluginAuth }: ExtensionProps) {
37-
const router = useRouter()
38-
const appContainerRef = useRef<HTMLDivElement>(null)
39-
const app = getApp(config.name)
40-
41-
// Remove the setter from the pluginAuth before passing it to the plugin, to prevent plugins from changing the auth state directly
42-
const authForPlugin: EmbeddedAuth = Object.freeze({
43-
getSnapshot: pluginAuth.getSnapshot,
18+
function Extension({ id, config, appProps, pluginAuth }: ExtensionProps) {
19+
const { isLoading, containerRef } = usePluginLoader({
20+
pluginName: config.name,
21+
config,
22+
appProps,
23+
pluginAuth,
4424
})
4525

46-
useEffect(() => {
47-
if (!app || !appContainerRef.current) {
48-
return
49-
}
50-
51-
app.mount(appContainerRef.current, {
52-
props: {
53-
...config.props,
54-
...(!config.core
55-
? {
56-
embedded: true,
57-
basePath: `${router.basepath === "/" ? "" : router.basepath}/${config.id}`,
58-
enableHashedRouting: appProps?.enableHashedRouting || false,
59-
auth: authForPlugin,
60-
}
61-
: { auth: auth }),
62-
},
63-
})
64-
return () => {
65-
app.unmount() // Unmount the app when the component is unmounted
66-
}
67-
}, [config, pluginAuth])
26+
if (isLoading) {
27+
return (
28+
<div>
29+
<div>Loading...</div>
30+
</div>
31+
)
32+
}
6833

69-
// Only render if AppComponent is not null
70-
return (
71-
<Suspense fallback={<div>Loading...</div>}>
72-
<div key={id} ref={appContainerRef}></div>
73-
</Suspense>
74-
)
34+
return <div key={id} ref={containerRef}></div>
7535
}
7636

7737
export default Extension

apps/greenhouse/src/hooks/useApi.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,9 @@ const useApi = () => {
3636

3737
return client
3838
.get(`/apis/greenhouse.sap/v1alpha1/namespaces/${namespace}/plugins`, {
39-
// @ts-ignore
40-
limit: 500,
39+
params: {
40+
labelSelector: "greenhouse.sap/ui-plugin=true",
41+
},
4142
})
4243
.then((configs: any) => {
4344
// create config map
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
/*
2+
* SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company and Juno contributors
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
import { renderHook, waitFor } from "@testing-library/react"
7+
import { describe, test, expect, vi, beforeEach, afterEach } from "vitest"
8+
import { usePluginLoader } from "./usePluginLoader"
9+
10+
// Mock the router
11+
vi.mock("@tanstack/react-router", () => ({
12+
useRouter: vi.fn(() => ({
13+
basepath: "/",
14+
})),
15+
}))
16+
17+
// Mock the plugin modules
18+
vi.mock("@cloudoperators/juno-app-supernova", () => ({
19+
default: {
20+
mount: vi.fn(),
21+
unmount: vi.fn(),
22+
},
23+
}))
24+
25+
describe("usePluginLoader", () => {
26+
const mockAppProps = {
27+
enableHashedRouting: false,
28+
}
29+
const mockPluginAuth = {} as any
30+
const mockConfig = {
31+
id: "supernova",
32+
props: {
33+
someProp: "value",
34+
},
35+
}
36+
37+
beforeEach(() => {
38+
vi.clearAllMocks()
39+
})
40+
41+
afterEach(() => {
42+
vi.clearAllMocks()
43+
})
44+
45+
test("returns loading state initially", () => {
46+
const { result } = renderHook(() =>
47+
usePluginLoader({
48+
pluginName: "supernova",
49+
config: mockConfig,
50+
appProps: mockAppProps,
51+
pluginAuth: mockPluginAuth,
52+
})
53+
)
54+
55+
expect(result.current.isLoading).toBe(true)
56+
expect(result.current.containerRef).toBeDefined()
57+
})
58+
59+
test("sets loading to false after plugin loads", async () => {
60+
const { result } = renderHook(() =>
61+
usePluginLoader({
62+
pluginName: "supernova",
63+
config: mockConfig,
64+
appProps: mockAppProps,
65+
pluginAuth: mockPluginAuth,
66+
})
67+
)
68+
69+
// Wait for plugin to load, loading should eventually be false
70+
await waitFor(() => {
71+
expect(result.current.isLoading).toBe(false)
72+
})
73+
})
74+
75+
test("cleans up on unmount", async () => {
76+
const { unmount } = renderHook(() =>
77+
usePluginLoader({
78+
pluginName: "supernova",
79+
config: mockConfig,
80+
appProps: mockAppProps,
81+
pluginAuth: mockPluginAuth,
82+
})
83+
)
84+
// Wait for plugin to load
85+
await waitFor(() => {
86+
unmount()
87+
expect(true).toBe(true)
88+
})
89+
})
90+
})
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
/*
2+
* SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company and Juno contributors
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
import { useEffect, useRef, useState } from "react"
7+
import { useRouter } from "@tanstack/react-router"
8+
import { AppProps } from "../Shell"
9+
import type { AuthStore } from "@cloudoperators/greenhouse-auth-provider"
10+
import type { PluginModule } from "@cloudoperators/juno-app-supernova"
11+
12+
// Cache loaded modules at the module level (persists across component mounts)
13+
const moduleCache = new Map<string, PluginModule>()
14+
15+
const getApp = async (appName: string): Promise<PluginModule | null> => {
16+
// Return cached module immediately if available
17+
if (moduleCache.has(appName)) {
18+
return moduleCache.get(appName)!
19+
}
20+
21+
// Load the module
22+
let module: PluginModule | null = null
23+
switch (appName) {
24+
case "supernova":
25+
module = await import("@cloudoperators/juno-app-supernova")
26+
break
27+
case "doop":
28+
module = await import("@cloudoperators/juno-app-doop")
29+
break
30+
case "heureka":
31+
module = await import("@cloudoperators/juno-app-heureka")
32+
break
33+
}
34+
35+
// Cache it for next time
36+
if (module) {
37+
moduleCache.set(appName, module)
38+
}
39+
40+
return module
41+
}
42+
43+
type UsePluginLoaderParams = {
44+
pluginName: string
45+
config: any
46+
appProps: AppProps
47+
pluginAuth: AuthStore
48+
}
49+
50+
type UsePluginLoaderResult = {
51+
isLoading: boolean
52+
containerRef: React.RefObject<HTMLDivElement | null>
53+
}
54+
55+
/**
56+
* Custom hook to handle plugin loading and mounting
57+
* Loads plugins dynamically with caching and handles mount/unmount lifecycle
58+
*/
59+
export function usePluginLoader({
60+
pluginName,
61+
config,
62+
appProps,
63+
pluginAuth,
64+
}: UsePluginLoaderParams): UsePluginLoaderResult {
65+
const router = useRouter()
66+
const containerRef = useRef<HTMLDivElement>(null)
67+
68+
// Check if module is already cached - if so, start with it loaded!
69+
const cachedModule = moduleCache.get(pluginName)
70+
const [app, setApp] = useState<PluginModule | null>(cachedModule || null)
71+
const [isLoading, setIsLoading] = useState(!cachedModule) // Only show loading if not cached
72+
73+
// Load the plugin module dynamically (only if not already loaded)
74+
useEffect(() => {
75+
if (cachedModule) {
76+
return
77+
}
78+
79+
let cancelled = false
80+
81+
const loadApp = async () => {
82+
setIsLoading(true)
83+
try {
84+
const appModule = await getApp(pluginName)
85+
if (!cancelled) {
86+
setApp(appModule)
87+
setIsLoading(false)
88+
}
89+
} catch (error) {
90+
if (!cancelled) {
91+
setIsLoading(false)
92+
}
93+
throw error
94+
}
95+
}
96+
97+
loadApp()
98+
99+
return () => {
100+
cancelled = true
101+
}
102+
}, [pluginName, cachedModule])
103+
104+
// Mount the app once it's loaded
105+
useEffect(() => {
106+
if (!app || !containerRef.current) {
107+
return
108+
}
109+
110+
app.mount(containerRef.current, {
111+
props: {
112+
...config.props,
113+
embedded: true,
114+
basePath: `${router.basepath === "/" ? "" : router.basepath}/${config.id}`,
115+
enableHashedRouting: appProps?.enableHashedRouting || false,
116+
auth: pluginAuth,
117+
},
118+
})
119+
120+
return () => {
121+
app.unmount()
122+
}
123+
}, [app, config, router, pluginAuth, appProps])
124+
125+
return { isLoading, containerRef }
126+
}

0 commit comments

Comments
 (0)