@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 +119 -0
- package/package.json +9 -10
- package/src/components/ExtensionSlot.tsx +53 -7
- package/src/components/LazyContribution.tsx +104 -0
- package/src/index.ts +1 -0
- package/src/orpc-query.tsx +15 -1
- package/src/plugin-registry.ts +32 -3
- package/src/plugin.ts +76 -18
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.
|
|
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.
|
|
13
|
+
"react": "^18.3.1"
|
|
14
14
|
},
|
|
15
15
|
"dependencies": {
|
|
16
|
-
"@checkstack/common": "0.
|
|
17
|
-
"@checkstack/signal-common": "0.2.
|
|
18
|
-
"@orpc/client": "^1.
|
|
19
|
-
"@orpc/contract": "^1.
|
|
20
|
-
"@orpc/
|
|
21
|
-
"@
|
|
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.
|
|
28
|
+
"@checkstack/scripts": "0.3.4"
|
|
30
29
|
},
|
|
31
30
|
"checkstack": {
|
|
32
31
|
"type": "tooling"
|
|
@@ -1,6 +1,47 @@
|
|
|
1
|
-
import {
|
|
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 =
|
|
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
|
-
|
|
41
|
-
|
|
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";
|
package/src/orpc-query.tsx
CHANGED
|
@@ -3,7 +3,7 @@ import {
|
|
|
3
3
|
createRouterUtils,
|
|
4
4
|
type RouterUtils,
|
|
5
5
|
type ProcedureUtils,
|
|
6
|
-
} from "@orpc/
|
|
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
|
}
|
package/src/plugin-registry.ts
CHANGED
|
@@ -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
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|