@checkstack/frontend-api 0.5.2 → 0.7.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,124 @@
1
1
  # @checkstack/frontend-api
2
2
 
3
+ ## 0.7.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 9dcc848: Cut initial-load JS: lazy plugin contributions, a hardened lazy-by-default contribution contract, on-demand Monaco, and a lighter icon/chart load.
8
+
9
+ - Lazy plugin route pages: each plugin's route `element` references a `React.lazy`-wrapped page rendered inside a shared `<Suspense>` boundary. Plugins still register synchronously, so nav, slots, commands, API factories, and `foreignSignals` are available on first paint. This moves ~37 route-page chunks (~600 KB) out of the entry; the entry chunk drops from ~2.4 MB to ~190 KB. Auth flow pages stay eager. The `@checkstack/scripts` scaffold template generates lazy route pages too.
10
+ - Hardened contribution contract (BREAKING, frontend plugin contract): plugins declare contributions lazily and let the framework own code-splitting, Suspense, and per-plugin error isolation. Routes use `load: () => import("./Page").then((m) => ({ default: m.Page }))` instead of `element: <Page />` (`element` is still accepted for the rare page that must paint without a chunk fetch; provide exactly one). Slot extensions accept either an eager `component` or a lazy `load`; new `getLazyContribution` + `ExtensionComponent` exports from `@checkstack/frontend-api` render either kind. This also fixes runtime-installed plugins: `ExtensionSlot` subscribes to the plugin registry, and the API registry rebuilds when the plugin set changes (`getPlugins()` returns an immutable snapshot via `useSyncExternalStore`). A per-plugin error boundary contains a bad contribution.
11
+ - On-demand Monaco: the `@checkstack/ui` barrel no longer pulls the `@codingame/*` / `monaco-languageclient` stack into the initial load. `CodeEditor` lazy-loads its Monaco-backed editor behind `React.lazy` + Suspense, `validateTypeScriptSources` imports the editor API via in-body `await import(...)`, and the "vscode services ready" signal moved to a Monaco-free module. The ~10 MB editor body loads only when a `CodeEditor` mounts. A `react-vendor` `manualChunks` split was added for stable vendor caching.
12
+ - lucide-react 1.x + lighter icons/charts (BREAKING for icon consumers): lucide-react unified from three drifting ranges to `^1.17.0`. lucide v1 removed brand icons, so the GitHub/GitLab marks are vendored in `@checkstack/ui` (`GithubIcon`, `GitlabIcon`, `brandIcons`); a new `IconName` type (`LucideIconName | BrandIconName`) in `@checkstack/common` is canonical, accepted by `AuthStrategy.icon` and the card components, so data-driven brand names keep working. `DynamicIcon` no longer eagerly imports lucide's ~1600-icon map (~1 MB) - it lives in a `React.lazy` `iconRegistry` chunk fetched on first data-driven render, while statically named-imported icons tree-shake normally. The recharts-backed health-check charts (~300 KB) and the `HealthCheckSystemOverview` drawer leave the initial load.
13
+
14
+ BREAKING CHANGES:
15
+
16
+ - Frontend plugin contract: routes/slot contributions are lazy-by-default (`load` instead of `element`/eager elements) as described above.
17
+ - Any external consumer importing a brand icon from `lucide-react` (e.g. `import { Github } from "lucide-react"`) must switch to the vendored `@checkstack/ui` brand icons or a custom SVG.
18
+
19
+ This is a beta minor.
20
+
21
+ - 9dcc848: Move primary navigation into a left sidebar, and serve the user guide in-app.
22
+
23
+ Feature navigation (a ~20-item user-menu dropdown) now lives in a persistent left sidebar (a slide-over drawer on mobile), grouped by section with the active route highlighted; the user menu keeps only account actions. A route opts into the sidebar with new `nav` metadata (`{ group, icon, label?, order?, accessRule? }`) on its registration, co-located with path + access + title. The sidebar filters entries with the same access check as page guards. `@checkstack/common` gains `isAccessRuleSatisfied` and a centralized set of in-app doc slugs (`APP_DOC_SLUGS` + `docsPath`, with a test asserting each resolves to a real docs page); `@checkstack/auth-frontend` exports `useAccessRules`.
24
+
25
+ The backend now serves the Astro Starlight docs build same-origin at `/checkstack/*` (the same artifact deployed to GitHub Pages), so the user guide is available inside the app including for self-hosted / air-gapped installs (served verbatim, no rebuild, no link rewriting; from `CHECKSTACK_DOCS_DIST`, before the SPA catch-all, degrading gracefully when absent; the Docker image builds and ships `docs/dist`; Vite proxies `/checkstack` in dev). The "Docs" link is a shell-owned external sidebar entry under the Documentation group (book icon), opening `/checkstack/user-guide/` in a new tab; the group renders even when no plugin route contributes to it.
26
+
27
+ BREAKING (plugin authors): `UserMenuItemsSlot` is no longer the way to add navigation - registering a top user-menu item no longer surfaces it anywhere. Add `nav` to the page's route instead. `UserMenuItemsBottomSlot` (account items) is unchanged. All bundled plugins have been migrated.
28
+
29
+ This is a beta minor.
30
+
31
+ - 9dcc848: Align workspace dependency versions and migrate React Router to v7.
32
+
33
+ BREAKING CHANGES (React Router v7): All frontend packages now depend on `react-router-dom@^7.16.0`. Previously the workspace declared four divergent ranges (`^6.20.0`, `^6.22.0`, `^7.1.1`, `^7.14.2`), which resolved both `react-router@6` and `react-router@7` into a single bundle. Everything is now unified on v7. The public imports the app uses (`BrowserRouter`, `Routes`, `Route`, `Link`, `NavLink`, `MemoryRouter`, `useNavigate`, `useParams`, `useSearchParams`, `useLocation`) are unchanged between v6 and v7, so no source rewrites were required - but any out-of-tree plugin still on react-router v6 should upgrade to v7 (see the React Router v6 -> v7 upgrade guide) to share the host's single router instance via the import map.
34
+
35
+ Other unified ranges (no API change): `react` -> `^18.3.1`, the `@orpc/*` family (`contract`, `server`, `client`, `tanstack-query`, `openapi`, `zod`) -> `^1.14.4`, and `better-auth` -> `^1.6.13`.
36
+
37
+ Removed the pre-rename `@orpc/react-query` leftover from `@checkstack/frontend-api`; its `createRouterUtils` / `RouterUtils` / `ProcedureUtils` now come from `@orpc/tanstack-query` (the package already in use).
38
+
39
+ Stale in-range runtime deps pulled up to current published versions: `hono` `^4.12.23`, `@tanstack/react-query` (+devtools) `^5.100.14`, `date-fns` `^4.4.0`, `jose` `^6.2.3`, `tar` `^7.5.16`, `semver` `^7.8.1`, `@xyflow/react` `^12.11.0`.
40
+
41
+ ### Patch Changes
42
+
43
+ - Updated dependencies [9dcc848]
44
+ - Updated dependencies [9dcc848]
45
+ - Updated dependencies [9dcc848]
46
+ - Updated dependencies [9dcc848]
47
+ - @checkstack/common@0.13.0
48
+ - @checkstack/signal-common@0.2.6
49
+
50
+ ## 0.6.0
51
+
52
+ ### Minor Changes
53
+
54
+ - e2d6f25: feat(automation): connection picker for integration actions + restore Integrations menu
55
+
56
+ Connection-backed automation actions (Jira, Teams, Webex) now render a
57
+ working connection picker plus cascading provider dropdowns in the
58
+ visual editor, and the Integrations entry is back in the user menu.
59
+
60
+ **Contract.** `ActionDefinition` gained an optional
61
+ `connectionProviderId` (and it is surfaced on `ActionInfoSchema` and
62
+ mapped in the `listActions` router). It carries the integration
63
+ provider's fully-qualified id, derived from the provider plugin's own
64
+ `pluginMetadata.pluginId` (never a hardcoded string), so the editor
65
+ knows which provider backs an action's dropdowns and it matches the
66
+ `qualifiedId` the integration provider registry assigns.
67
+
68
+ **Providers.** Jira, Teams and Webex each export
69
+ `*_PROVIDER_LOCAL_ID` / `*_PROVIDER_QUALIFIED_ID`, register their
70
+ provider with the local id, and add a `CONNECTION_OPTIONS`
71
+ (`"connectionOptions"`) resolver name. Their `post_message` /
72
+ issue actions set `connectionProviderId` and expose `connectionId`
73
+ as an `x-options-resolver` dropdown instead of a hidden field.
74
+
75
+ **Frontend bridge.** A new `useConnectionOptionResolvers` hook
76
+ (`@checkstack/automation-frontend`, which now depends on
77
+ `@checkstack/integration-common`) turns an action's
78
+ `x-options-resolver` schema fields into live data: the
79
+ `connectionOptions` resolver lists the provider's connections via
80
+ `listConnections`, and every other resolver name is forwarded to
81
+ `getConnectionOptions` for the selected `connectionId`, passing the
82
+ live form values as `context` for dependent fields. `ProviderActionBody`
83
+ now passes this map to `DynamicForm` (it was previously missing
84
+ entirely, so connection-backed actions had no working dropdowns).
85
+
86
+ **frontend-api.** `usePluginClient` procedures now also expose a typed
87
+ imperative `.call(input)` alongside `.useQuery` / `.useMutation`, for
88
+ async callbacks that cannot host a hook (such as a `DynamicForm`
89
+ options resolver). Additive, non-breaking.
90
+
91
+ **Integrations menu.** Re-added `IntegrationMenuItem` and a new
92
+ `IntegrationsLandingPage`, wired into `integration-frontend` as a list
93
+ route and a `UserMenuItemsSlot` entry under the "Configuration" group.
94
+
95
+ **Action card polish.** The action editor's secondary metadata (id,
96
+ description, failure behaviour) is now grouped into one quiet settings
97
+ panel with consistent small uppercase "eyebrow" labels, so the action's
98
+ own configuration stays the focal point. The raw failure checkbox was
99
+ replaced with the standard `Checkbox` control, and the provider action
100
+ picker / configuration sections gained consistent section headers and a
101
+ divider. The per-step "type" dropdown was removed: an action's kind is
102
+ fixed at creation, so changing it now means adding a new step and
103
+ deleting the old one (avoids the surprising full-config reset that
104
+ switching kinds used to trigger).
105
+
106
+ **Add-step picker.** Adding a step now opens a Home-Assistant-style
107
+ dialog where the operator decides the step type up front: an "Actions"
108
+ tab lists the registered provider actions grouped by category
109
+ (searchable; picking one presets the step's `action`), and a "Blocks"
110
+ tab lists the structural building blocks (choose / parallel / repeat /
111
+ etc.). Because the concrete action is chosen here, the in-card action
112
+ switcher was removed - a step's action is fixed once created. Composite
113
+ blocks now start with an empty child list (filled via the nested
114
+ add-step picker) instead of seeding an unconfigurable empty action.
115
+
116
+ ### Patch Changes
117
+
118
+ - Updated dependencies [6d52276]
119
+ - @checkstack/common@0.12.0
120
+ - @checkstack/signal-common@0.2.5
121
+
3
122
  ## 0.5.2
4
123
 
5
124
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/frontend-api",
3
- "version": "0.5.2",
3
+ "version": "0.7.0",
4
4
  "license": "Elastic-2.0",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -10,23 +10,22 @@
10
10
  "lint:code": "eslint . --max-warnings 0"
11
11
  },
12
12
  "peerDependencies": {
13
- "react": "^18.0.0"
13
+ "react": "^18.3.1"
14
14
  },
15
15
  "dependencies": {
16
- "@checkstack/common": "0.10.0",
17
- "@checkstack/signal-common": "0.2.3",
18
- "@orpc/client": "^1.13.14",
19
- "@orpc/contract": "^1.13.14",
20
- "@orpc/react-query": "1.13.4",
21
- "@orpc/tanstack-query": "^1.13.2",
22
- "@tanstack/react-query": "^5.64.0"
16
+ "@checkstack/common": "0.12.0",
17
+ "@checkstack/signal-common": "0.2.5",
18
+ "@orpc/client": "^1.14.4",
19
+ "@orpc/contract": "^1.14.4",
20
+ "@orpc/tanstack-query": "^1.14.4",
21
+ "@tanstack/react-query": "^5.100.14"
23
22
  },
24
23
  "devDependencies": {
25
24
  "@types/bun": "^1.3.5",
26
25
  "@types/react": "^18.0.0",
27
26
  "typescript": "^5.0.0",
28
27
  "@checkstack/tsconfig": "0.0.7",
29
- "@checkstack/scripts": "0.3.2"
28
+ "@checkstack/scripts": "0.3.4"
30
29
  },
31
30
  "checkstack": {
32
31
  "type": "tooling"
@@ -1,6 +1,47 @@
1
- import { pluginRegistry } from "../plugin-registry";
2
- import type { SlotContext } from "../plugin";
1
+ import type { ComponentType } from "react";
2
+ import type { Extension, SlotContext } from "../plugin";
3
3
  import type { SlotDefinition } from "../slots";
4
+ import { useSlotExtensions } from "../use-slot-extensions";
5
+ import { getLazyContribution } from "./LazyContribution";
6
+
7
+ /**
8
+ * Render a single extension's UI, handling both contribution kinds: an eager
9
+ * `component` renders directly; a lazy `load` renders through
10
+ * {@link getLazyContribution} (code-split + Suspense + per-plugin error
11
+ * boundary). Use this when you drive rendering yourself from
12
+ * {@link useSlotExtensions} (e.g. a tab bar that renders only the active tab);
13
+ * for rendering ALL of a slot's extensions, use {@link ExtensionSlot}.
14
+ */
15
+ export function ExtensionComponent<
16
+ TSlot extends SlotDefinition<unknown, unknown>
17
+ >({
18
+ extension,
19
+ context,
20
+ }: {
21
+ extension: Extension<TSlot>;
22
+ context?: SlotContext<TSlot>;
23
+ }) {
24
+ // The slot's context was type-checked against this extension at registration
25
+ // time (createSlotExtension); it's erased to a props bag for rendering.
26
+ const props = (context ?? {}) as Record<string, unknown>;
27
+
28
+ if (extension.component) {
29
+ const Component = extension.component as ComponentType<
30
+ Record<string, unknown>
31
+ >;
32
+ return <Component {...props} />;
33
+ }
34
+ if (extension.load) {
35
+ const Lazy = getLazyContribution({
36
+ id: extension.id,
37
+ load: extension.load as () => Promise<{
38
+ default: ComponentType<Record<string, unknown>>;
39
+ }>,
40
+ });
41
+ return <Lazy {...props} />;
42
+ }
43
+ return null;
44
+ }
4
45
 
5
46
  /**
6
47
  * Type-safe props for ExtensionSlot.
@@ -15,6 +56,12 @@ type ExtensionSlotProps<TSlot extends SlotDefinition<unknown, unknown>> =
15
56
  /**
16
57
  * Renders all extensions registered for the given slot.
17
58
  *
59
+ * Subscribes to the plugin registry (via {@link useSlotExtensions}), so
60
+ * extensions contributed by plugins loaded AT RUNTIME (installed remotely)
61
+ * appear without a manual refresh. Eager (`component`) extensions render
62
+ * directly; lazy (`load`) extensions render through {@link getLazyContribution}
63
+ * (code-split + Suspense + per-plugin error boundary).
64
+ *
18
65
  * @example
19
66
  * ```tsx
20
67
  * // Slot with context - context is required and type-checked
@@ -28,7 +75,7 @@ export function ExtensionSlot<TSlot extends SlotDefinition<unknown, unknown>>({
28
75
  slot,
29
76
  context,
30
77
  }: ExtensionSlotProps<TSlot>) {
31
- const extensions = pluginRegistry.getExtensions(slot.id);
78
+ const extensions = useSlotExtensions(slot);
32
79
 
33
80
  if (extensions.length === 0) {
34
81
  return <></>;
@@ -36,10 +83,9 @@ export function ExtensionSlot<TSlot extends SlotDefinition<unknown, unknown>>({
36
83
 
37
84
  return (
38
85
  <>
39
- {extensions.map((ext) => {
40
- const Component = ext.component as React.ComponentType<Record<string, unknown>>;
41
- return <Component key={ext.id} {...(context ?? {})} />;
42
- })}
86
+ {extensions.map((ext) => (
87
+ <ExtensionComponent key={ext.id} extension={ext} context={context} />
88
+ ))}
43
89
  </>
44
90
  );
45
91
  }
@@ -0,0 +1,104 @@
1
+ import React, { Suspense, type ComponentType } from "react";
2
+
3
+ /**
4
+ * Framework-owned wrapper for a lazily-loaded plugin contribution (a route page
5
+ * or a heavy slot extension declared via a `load` thunk).
6
+ *
7
+ * It does three things a plugin should NOT have to reimplement:
8
+ * 1. `React.lazy(load)` — code-splits the contribution so its chunk is fetched
9
+ * on demand, not in the initial app load.
10
+ * 2. `<Suspense>` — shows a fallback while the chunk loads.
11
+ * 3. `<PluginErrorBoundary>` — contains a load/render failure to this one
12
+ * contribution. A third-party plugin that throws (bad bundle, runtime
13
+ * error) degrades to a small inline fallback instead of white-screening the
14
+ * whole shell. This is essential once external plugins exist.
15
+ *
16
+ * Components are cached by a stable `id` so the same `React.lazy` instance is
17
+ * reused across renders (recreating it would discard its loaded state and
18
+ * remount on every parent render).
19
+ */
20
+
21
+ interface PluginErrorBoundaryProps {
22
+ /** Human-readable contribution id, used in the console error + fallback. */
23
+ label: string;
24
+ fallback: React.ReactNode;
25
+ children: React.ReactNode;
26
+ }
27
+
28
+ interface PluginErrorBoundaryState {
29
+ hasError: boolean;
30
+ }
31
+
32
+ class PluginErrorBoundary extends React.Component<
33
+ PluginErrorBoundaryProps,
34
+ PluginErrorBoundaryState
35
+ > {
36
+ state: PluginErrorBoundaryState = { hasError: false };
37
+
38
+ static getDerivedStateFromError(): PluginErrorBoundaryState {
39
+ return { hasError: true };
40
+ }
41
+
42
+ componentDidCatch(error: unknown) {
43
+ console.error(
44
+ `❌ Plugin contribution "${this.props.label}" failed to load/render:`,
45
+ error,
46
+ );
47
+ }
48
+
49
+ render() {
50
+ if (this.state.hasError) {
51
+ return this.props.fallback;
52
+ }
53
+ return this.props.children;
54
+ }
55
+ }
56
+
57
+ /** Props a plugin contribution component may receive (slot context, or none). */
58
+ type ContributionProps = Record<string, unknown>;
59
+
60
+ type LazyLoader = () => Promise<{ default: ComponentType<ContributionProps> }>;
61
+
62
+ interface GetLazyContributionArgs {
63
+ /** Stable identity for caching (route path or extension id). */
64
+ id: string;
65
+ load: LazyLoader;
66
+ /** Shown while the chunk loads. Defaults to nothing (invisible). */
67
+ suspenseFallback?: React.ReactNode;
68
+ /** Shown if the chunk fails to load/render. Defaults to nothing. */
69
+ errorFallback?: React.ReactNode;
70
+ }
71
+
72
+ // Cache keyed by `id` so the lazy component (and its loaded state) is stable
73
+ // across renders. Contributions have heterogeneous prop shapes, so the cache is
74
+ // typed by the shared `ContributionProps` upper bound.
75
+ const lazyComponentCache = new Map<string, ComponentType<ContributionProps>>();
76
+
77
+ /**
78
+ * Return a stable component that lazily loads `load` and renders it inside a
79
+ * Suspense + error boundary. Safe to call on every render — memoised by `id`.
80
+ */
81
+ export function getLazyContribution({
82
+ id,
83
+ load,
84
+ suspenseFallback = null,
85
+ errorFallback = null,
86
+ }: GetLazyContributionArgs): ComponentType<ContributionProps> {
87
+ const cached = lazyComponentCache.get(id);
88
+ if (cached) {
89
+ return cached;
90
+ }
91
+
92
+ const Lazy = React.lazy(load);
93
+ const Wrapped: ComponentType<ContributionProps> = (props) => (
94
+ <PluginErrorBoundary label={id} fallback={errorFallback}>
95
+ <Suspense fallback={suspenseFallback}>
96
+ <Lazy {...props} />
97
+ </Suspense>
98
+ </PluginErrorBoundary>
99
+ );
100
+ Wrapped.displayName = `LazyContribution(${id})`;
101
+
102
+ lazyComponentCache.set(id, Wrapped);
103
+ return Wrapped;
104
+ }
package/src/index.ts CHANGED
@@ -4,6 +4,7 @@ export * from "./core-apis";
4
4
  export * from "./plugin";
5
5
  export * from "./plugin-registry";
6
6
  export * from "./components/ExtensionSlot";
7
+ export * from "./components/LazyContribution";
7
8
  export * from "./use-slot-extensions";
8
9
  export * from "./utils";
9
10
  export * from "./slots";
@@ -3,7 +3,7 @@ import {
3
3
  createRouterUtils,
4
4
  type RouterUtils,
5
5
  type ProcedureUtils,
6
- } from "@orpc/react-query";
6
+ } from "@orpc/tanstack-query";
7
7
  import type { NestedClient, ClientContext } from "@orpc/client";
8
8
  import {
9
9
  useQuery,
@@ -89,6 +89,12 @@ interface QueryProcedure<TInput, TOutput> {
89
89
  input?: TInput,
90
90
  options?: Omit<UseQueryOptions<TOutput, Error>, "queryKey" | "queryFn">,
91
91
  ) => UseQueryResult<TOutput, Error>;
92
+ /**
93
+ * Imperative one-shot call, outside React Query. Use inside async
94
+ * callbacks that can't host a hook (e.g. a DynamicForm options
95
+ * resolver). Prefer `useQuery` for anything rendered.
96
+ */
97
+ call: (input: TInput) => Promise<TOutput>;
92
98
  }
93
99
 
94
100
  /**
@@ -107,6 +113,12 @@ interface MutationProcedure<TInput, TOutput> {
107
113
  "mutationFn" | "mutationKey"
108
114
  >,
109
115
  ) => UseMutationResult<TOutput, Error, TInput, TContext>;
116
+ /**
117
+ * Imperative one-shot call, outside React Query. Use inside async
118
+ * callbacks that can't host a hook (e.g. a DynamicForm options
119
+ * resolver). Prefer `useMutation` for anything tied to UI lifecycle.
120
+ */
121
+ call: (input: TInput) => Promise<TOutput>;
110
122
  }
111
123
 
112
124
  /**
@@ -355,6 +367,7 @@ function createProcedureHook<TInput, TOutput>(
355
367
  const { onSuccess: _, ...restOptions } = options ?? {};
356
368
  return useMutation({ ...mutationOpts, ...restOptions });
357
369
  },
370
+ call: (input) => proc.call(input),
358
371
  };
359
372
  }
360
373
 
@@ -367,5 +380,6 @@ function createProcedureHook<TInput, TOutput>(
367
380
  // Spread caller options AFTER to ensure they take precedence (e.g., enabled: false)
368
381
  return useQuery({ ...queryOpts, ...options });
369
382
  },
383
+ call: (input) => proc.call(input),
370
384
  };
371
385
  }
@@ -1,5 +1,9 @@
1
+ import type { ComponentType, ReactNode } from "react";
1
2
  import { FrontendPlugin, Extension } from "./plugin";
2
3
 
4
+ /** Lazy page loader, as declared on a {@link PluginRoute}. */
5
+ type RouteLoader = () => Promise<{ default: ComponentType }>;
6
+
3
7
  /**
4
8
  * Listener function for registry changes
5
9
  */
@@ -12,13 +16,20 @@ interface ResolvedRoute {
12
16
  id: string;
13
17
  path: string;
14
18
  pluginId: string;
15
- element?: React.ReactNode;
19
+ /** Exactly one of `load` (lazy) / `element` (eager) is set. */
20
+ load?: RouteLoader;
21
+ element?: ReactNode;
16
22
  title?: string;
17
23
  accessRule?: string;
18
24
  }
19
25
 
20
26
  class PluginRegistry {
21
27
  private plugins: FrontendPlugin[] = [];
28
+ // Immutable snapshot of `plugins`, rebuilt on every change. `getPlugins()`
29
+ // returns this so callers (and `useSyncExternalStore`) get a referentially
30
+ // stable value between changes and a NEW reference when the set changes —
31
+ // `plugins` itself is mutated in place, so its reference never changes.
32
+ private pluginsSnapshot: readonly FrontendPlugin[] = [];
22
33
  private extensions = new Map<string, Extension[]>();
23
34
  private routeMap = new Map<string, ResolvedRoute>();
24
35
 
@@ -59,6 +70,7 @@ class PluginRegistry {
59
70
  id: route.route.id,
60
71
  path: fullPath,
61
72
  pluginId: route.route.pluginId,
73
+ load: route.load,
62
74
  element: route.element,
63
75
  title: route.title,
64
76
  accessRule: route.accessRule?.id,
@@ -151,8 +163,8 @@ class PluginRegistry {
151
163
  return this.plugins.some((p) => p.metadata.pluginId === pluginId);
152
164
  }
153
165
 
154
- getPlugins() {
155
- return this.plugins;
166
+ getPlugins(): readonly FrontendPlugin[] {
167
+ return this.pluginsSnapshot;
156
168
  }
157
169
 
158
170
  getExtensions(slotId: string): Extension[] {
@@ -173,9 +185,23 @@ class PluginRegistry {
173
185
 
174
186
  return {
175
187
  path: fullPath,
188
+ pluginId: route.route.pluginId,
189
+ load: route.load,
176
190
  element: route.element,
177
191
  title: route.title,
178
192
  accessRule: route.accessRule?.id,
193
+ // Resolved sidebar entry (defaults applied) for routes that opt in.
194
+ // `accessRule` here is the EFFECTIVE rule object (nav override, else
195
+ // the route's own rule) the sidebar gates visibility on.
196
+ nav: route.nav
197
+ ? {
198
+ group: route.nav.group,
199
+ icon: route.nav.icon,
200
+ label: route.nav.label ?? route.title ?? route.route.id,
201
+ order: route.nav.order ?? 0,
202
+ accessRule: route.nav.accessRule ?? route.accessRule,
203
+ }
204
+ : undefined,
179
205
  };
180
206
  });
181
207
  });
@@ -229,6 +255,9 @@ class PluginRegistry {
229
255
 
230
256
  private incrementVersion() {
231
257
  this.version++;
258
+ // Rebuild the immutable snapshot so external subscribers see a new
259
+ // reference (required for useSyncExternalStore / useMemo to recompute).
260
+ this.pluginsSnapshot = [...this.plugins];
232
261
  for (const listener of this.listeners) {
233
262
  listener();
234
263
  }
package/src/plugin.ts CHANGED
@@ -41,26 +41,47 @@ export interface Extension<
41
41
  > {
42
42
  id: string;
43
43
  slot: TSlot;
44
- component: React.ComponentType<SlotContext<TSlot>>;
44
+ /**
45
+ * Eager component. Use for LIGHT, always-rendered contributions (navbar,
46
+ * user-menu items) where code-splitting would just add a load flash. Exactly
47
+ * one of `component` / `load` is set.
48
+ */
49
+ component?: React.ComponentType<SlotContext<TSlot>>;
50
+ /**
51
+ * Lazy loader for HEAVY or page-scoped contributions. The framework wraps it
52
+ * in React.lazy + Suspense + a per-plugin error boundary (see
53
+ * `getLazyContribution`), so the chunk loads on demand and a failure is
54
+ * contained to this contribution. Named exports: `() => import("./X").then(
55
+ * (m) => ({ default: m.X }))`.
56
+ */
57
+ load?: () => Promise<{
58
+ default: React.ComponentType<SlotContext<TSlot>>;
59
+ }>;
45
60
  metadata?: SlotMetadata<TSlot>;
46
61
  }
47
62
 
63
+ /** A contribution is provided either eagerly (`component`) or lazily (`load`). */
64
+ type SlotExtensionImpl<TSlot extends SlotDefinition<unknown, unknown>> =
65
+ | {
66
+ component: React.ComponentType<SlotContext<TSlot>>;
67
+ load?: never;
68
+ }
69
+ | {
70
+ load: () => Promise<{
71
+ default: React.ComponentType<SlotContext<TSlot>>;
72
+ }>;
73
+ component?: never;
74
+ };
75
+
48
76
  /**
49
77
  * Input shape for `createSlotExtension`. Requires `metadata` when the slot
50
- * declares a non-`undefined` metadata type, forbids it otherwise.
78
+ * declares a non-`undefined` metadata type, forbids it otherwise, and requires
79
+ * exactly one of `component` (eager) / `load` (lazy).
51
80
  */
52
81
  type SlotExtensionInput<TSlot extends SlotDefinition<unknown, unknown>> =
53
82
  SlotMetadata<TSlot> extends undefined
54
- ? {
55
- id: string;
56
- component: React.ComponentType<SlotContext<TSlot>>;
57
- metadata?: undefined;
58
- }
59
- : {
60
- id: string;
61
- component: React.ComponentType<SlotContext<TSlot>>;
62
- metadata: SlotMetadata<TSlot>;
63
- };
83
+ ? { id: string; metadata?: undefined } & SlotExtensionImpl<TSlot>
84
+ : { id: string; metadata: SlotMetadata<TSlot> } & SlotExtensionImpl<TSlot>;
64
85
 
65
86
  /**
66
87
  * Helper to create a type-safe extension from a slot definition.
@@ -81,20 +102,57 @@ export function createSlotExtension<
81
102
  * Route configuration for a frontend plugin.
82
103
  * Uses RouteDefinition from the plugin's common package.
83
104
  */
84
- export interface PluginRoute {
105
+ /**
106
+ * Sidebar navigation metadata. A route opts into the left sidebar by setting
107
+ * `nav` on its registration; routes without `nav` are reachable (deep links,
108
+ * detail pages) but are not listed in the sidebar.
109
+ */
110
+ export interface NavEntry {
111
+ /** Sidebar section heading, e.g. "Workspace" / "Reliability" / "Configuration". */
112
+ group: string;
113
+ /** Icon component for the entry (lucide-react icons satisfy this shape). */
114
+ icon: React.ComponentType<{ className?: string }>;
115
+ /** Sidebar label; defaults to the route's `title`. */
116
+ label?: string;
117
+ /** Sort order within the group (lower first; defaults to 0). */
118
+ order?: number;
119
+ /**
120
+ * Access rule gating sidebar VISIBILITY. Defaults to the route's `accessRule`.
121
+ * Override to surface the entry on a broader rule than the page requires
122
+ * (e.g. show on `read` while the page itself needs `manage`).
123
+ */
124
+ accessRule?: AccessRule;
125
+ }
126
+
127
+ /** Fields common to every route, regardless of eager/lazy. */
128
+ interface PluginRouteBase {
85
129
  /** Route definition from common package */
86
130
  route: RouteDefinition;
87
-
88
- /** React element to render */
89
- element?: React.ReactNode;
90
-
91
131
  /** Page title */
92
132
  title?: string;
93
-
94
133
  /** Access rule required to access this route (use access object from common package) */
95
134
  accessRule?: AccessRule;
135
+ /** Optional sidebar navigation entry for this route. */
136
+ nav?: NavEntry;
96
137
  }
97
138
 
139
+ /**
140
+ * Route configuration for a frontend plugin. Provide EXACTLY one of:
141
+ * - `load` (default): a lazy page loader. The framework code-splits the page
142
+ * and wraps it in Suspense + a per-plugin error boundary (see
143
+ * `getLazyContribution`), so the chunk is fetched on navigation, never in
144
+ * the initial load. Named-export pages: `() => import("./pages/Foo").then(
145
+ * (m) => ({ default: m.Foo }))`.
146
+ * - `element`: an eagerly-rendered element. Reserve for the rare page that
147
+ * must paint without a chunk fetch (e.g. the login page on the
148
+ * unauthenticated critical path).
149
+ */
150
+ export type PluginRoute = PluginRouteBase &
151
+ (
152
+ | { load: () => Promise<{ default: React.ComponentType }>; element?: never }
153
+ | { element: React.ReactNode; load?: never }
154
+ );
155
+
98
156
  /**
99
157
  * Frontend plugin configuration.
100
158
  * Uses PluginMetadata from the common package for consistent plugin identification.