@powerhousedao/connect-core 6.2.3-dev.66 → 6.2.3-dev.67

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@powerhousedao/connect-core",
3
- "version": "6.2.3-dev.66",
3
+ "version": "6.2.3-dev.67",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -66,17 +66,17 @@
66
66
  "./package.json": "./package.json"
67
67
  },
68
68
  "dependencies": {
69
- "@powerhousedao/host-core": "6.2.3-dev.66",
70
- "@powerhousedao/reactor": "6.2.3-dev.66",
71
- "@powerhousedao/reactor-browser": "6.2.3-dev.66",
72
- "@powerhousedao/reactor-router": "6.2.3-dev.66",
73
- "@powerhousedao/shared": "6.2.3-dev.66",
74
- "document-model": "6.2.3-dev.66"
69
+ "@powerhousedao/host-core": "6.2.3-dev.67",
70
+ "@powerhousedao/reactor": "6.2.3-dev.67",
71
+ "@powerhousedao/reactor-browser": "6.2.3-dev.67",
72
+ "@powerhousedao/reactor-router": "6.2.3-dev.67",
73
+ "@powerhousedao/shared": "6.2.3-dev.67",
74
+ "document-model": "6.2.3-dev.67"
75
75
  },
76
76
  "devDependencies": {
77
77
  "@electric-sql/pglite": "0.3.15",
78
78
  "@playwright/test": "^1.60.0",
79
- "@renown/sdk": "6.2.3-dev.66",
79
+ "@renown/sdk": "6.2.3-dev.67",
80
80
  "@tanstack/react-router": "^1.170.41",
81
81
  "@tanstack/router-cli": "1.167.40",
82
82
  "@tanstack/router-core": "1.171.34",
@@ -30,9 +30,3 @@ export function isPluginActive(module: string): boolean {
30
30
  const active = getActivePluginModules();
31
31
  return active === null ? true : active.has(module);
32
32
  }
33
-
34
- /** Hook form; the composition is fixed for the page's lifetime, so this is a
35
- * plain synchronous read (no subscription needed). */
36
- export function useIsPluginActive(module: string): boolean {
37
- return isPluginActive(module);
38
- }
@@ -1,14 +1,23 @@
1
- export { applyComposition, registerConnectPlugins } from "./plugin-host.js";
2
1
  export {
3
- getActivePluginModules,
4
- isPluginActive,
5
- useIsPluginActive,
6
- } from "./composition.js";
7
- export { IfPlugin } from "./if-plugin.js";
2
+ applyComposition,
3
+ knownPlugins,
4
+ registerConnectPlugins,
5
+ } from "./plugin-host.js";
6
+ export { getActivePluginModules, isPluginActive } from "./composition.js";
7
+ export {
8
+ applyConnectPluginUi,
9
+ getConnectPluginUi,
10
+ loadConnectPluginUi,
11
+ mergeConnectPluginUi,
12
+ resetConnectPluginUi,
13
+ } from "./plugin-ui.js";
14
+ export type { ConnectPluginUiSet } from "./plugin-ui.js";
8
15
  export type {
9
16
  ConnectConfigHooks,
10
17
  ConnectPlugin,
11
18
  ConnectPluginContext,
12
19
  ConnectPluginDefinition,
13
20
  ConnectPluginFactory,
21
+ ConnectPluginUi,
22
+ ConnectRouteUi,
14
23
  } from "./types.js";
@@ -28,7 +28,8 @@ export function registerConnectPlugins(
28
28
  else Object.assign(registry.plugins, plugins);
29
29
  }
30
30
 
31
- async function knownPlugins(): Promise<PluginDefinitions> {
31
+ /** Every plugin module made known, its loaders run. */
32
+ export async function knownPlugins(): Promise<PluginDefinitions> {
32
33
  const registry = getConnectRegistry();
33
34
  const pending = registry.pluginLoaders;
34
35
  registry.pluginLoaders = [];
@@ -0,0 +1,153 @@
1
+ import type { AnyRoute, ErrorRouteComponent } from "@tanstack/react-router";
2
+ import type { ComponentType } from "react";
3
+ import type { ConnectRouteRegistration } from "../registry.js";
4
+ import { getConnectRegistry } from "../registry.js";
5
+ import type { ConnectModals } from "../router/modals.js";
6
+ import { isCoreView } from "../router/core-views.js";
7
+ import {
8
+ addConnectRoutes,
9
+ removeConnectRoutes,
10
+ resolveConnectRoutes,
11
+ } from "../router/route-tree.js";
12
+ import { knownPlugins } from "./plugin-host.js";
13
+ import type { ConnectPluginUi, ConnectRouteUi } from "./types.js";
14
+
15
+ /** The active plugins' UI; a later plugin's routes and modals win. */
16
+ export type ConnectPluginUiSet = {
17
+ routes: Record<string, ConnectRouteUi>;
18
+ addRoutes: ConnectRouteRegistration[];
19
+ modals: ConnectModals;
20
+ roots: ComponentType[];
21
+ errorComponent?: ErrorRouteComponent;
22
+ commonEditors: boolean;
23
+ consent: boolean;
24
+ };
25
+
26
+ const NO_UI: ConnectPluginUiSet = {
27
+ routes: {},
28
+ addRoutes: [],
29
+ modals: {},
30
+ roots: [],
31
+ commonEditors: false,
32
+ consent: false,
33
+ };
34
+
35
+ export function mergeConnectPluginUi(
36
+ uis: readonly ConnectPluginUi[],
37
+ ): ConnectPluginUiSet {
38
+ const set: ConnectPluginUiSet = {
39
+ routes: {},
40
+ addRoutes: [],
41
+ modals: {},
42
+ roots: [],
43
+ commonEditors: false,
44
+ consent: false,
45
+ };
46
+ for (const ui of uis) {
47
+ for (const [id, route] of Object.entries(ui.routes ?? {})) {
48
+ set.routes[id] = { ...set.routes[id], ...route };
49
+ }
50
+ if (ui.addRoutes) set.addRoutes.push(ui.addRoutes);
51
+ Object.assign(set.modals, ui.modals);
52
+ if (ui.Root) set.roots.push(ui.Root);
53
+ set.errorComponent = ui.errorComponent ?? set.errorComponent;
54
+ set.commonEditors ||= ui.commonEditors ?? false;
55
+ set.consent ||= ui.consent ?? false;
56
+ }
57
+ return set;
58
+ }
59
+
60
+ /** Loads the UI of the plugins `composition` names, or of every known plugin
61
+ * without one (classic Connect), and keeps it for `getConnectPluginUi`. */
62
+ export async function loadConnectPluginUi(
63
+ composition: readonly { module: string }[] | undefined,
64
+ ): Promise<ConnectPluginUiSet> {
65
+ const definitions = await knownPlugins();
66
+ const modules = composition
67
+ ? composition.map((entry) => entry.module)
68
+ : Object.keys(definitions);
69
+ const uis = await Promise.all(
70
+ modules.map(
71
+ (module) => definitions[module]?.ui?.() ?? Promise.resolve(undefined),
72
+ ),
73
+ );
74
+ const set = mergeConnectPluginUi(
75
+ uis.filter((ui): ui is ConnectPluginUi => !!ui),
76
+ );
77
+ getConnectRegistry().ui = set;
78
+ return set;
79
+ }
80
+
81
+ /** The active plugins' UI; none until `loadRuntimeConfig` has loaded it. */
82
+ export function getConnectPluginUi(): ConnectPluginUiSet {
83
+ return getConnectRegistry().ui ?? NO_UI;
84
+ }
85
+
86
+ type RouteOptions = Record<string, unknown> & { id?: string; path?: string };
87
+
88
+ const ROOT_ID = "__root__";
89
+
90
+ function forEachRoute(
91
+ route: AnyRoute,
92
+ visit: (route: AnyRoute, id: string) => void,
93
+ parentId?: string,
94
+ ) {
95
+ const options = route.options as unknown as RouteOptions;
96
+ const segment = options.id ?? options.path;
97
+ const own =
98
+ parentId === undefined || segment === undefined
99
+ ? ROOT_ID
100
+ : `${parentId === ROOT_ID ? "" : parentId}/${segment.replace(/^\//, "")}`;
101
+ visit(route, own);
102
+ for (const child of (route.children ?? []) as AnyRoute[]) {
103
+ forEachRoute(child, visit, own);
104
+ }
105
+ }
106
+
107
+ type Applied = { originals: Map<AnyRoute, RouteOptions>; added: AnyRoute[] };
108
+ const appliedTo = new WeakMap<AnyRoute, Applied>();
109
+
110
+ /** Sets `ui`'s route options on the tree's routes that still show core's view,
111
+ * and adds its routes; `resetConnectPluginUi` undoes both. */
112
+ export function applyConnectPluginUi(
113
+ routeTree: AnyRoute,
114
+ ui: ConnectPluginUiSet = getConnectPluginUi(),
115
+ ) {
116
+ resetConnectPluginUi(routeTree);
117
+ const applied: Applied = { originals: new Map(), added: [] };
118
+ forEachRoute(routeTree, (route, id) => {
119
+ const routeUi = ui.routes[id];
120
+ const options = route.options as unknown as RouteOptions;
121
+ if (!routeUi || !isCoreView(options.component)) return;
122
+ const { staticData, ...views } = routeUi;
123
+ const keys = [...Object.keys(views), ...(staticData ? ["staticData"] : [])];
124
+ applied.originals.set(
125
+ route,
126
+ Object.fromEntries(keys.map((key) => [key, options[key]])),
127
+ );
128
+ Object.assign(options, views);
129
+ if (staticData) {
130
+ options.staticData = {
131
+ ...(options.staticData as object | undefined),
132
+ ...staticData,
133
+ };
134
+ }
135
+ });
136
+ for (const registration of ui.addRoutes) {
137
+ const routes = resolveConnectRoutes(registration, routeTree);
138
+ addConnectRoutes(routes);
139
+ applied.added.push(...routes);
140
+ }
141
+ appliedTo.set(routeTree, applied);
142
+ }
143
+
144
+ /** Restores the routes `applyConnectPluginUi` changed and removes the ones it added. */
145
+ export function resetConnectPluginUi(routeTree: AnyRoute) {
146
+ const applied = appliedTo.get(routeTree);
147
+ if (!applied) return;
148
+ for (const [route, originals] of applied.originals) {
149
+ Object.assign(route.options, originals);
150
+ }
151
+ removeConnectRoutes(applied.added);
152
+ appliedTo.delete(routeTree);
153
+ }
@@ -1,5 +1,14 @@
1
1
  import type { HostPlugin } from "@powerhousedao/host-core/plugins";
2
2
  import type { PHConnectRuntimeConfig } from "@powerhousedao/shared/clis";
3
+ import type {
4
+ ErrorRouteComponent,
5
+ NotFoundRouteComponent,
6
+ RouteComponent,
7
+ StaticDataRouteOption,
8
+ } from "@tanstack/react-router";
9
+ import type { ComponentType } from "react";
10
+ import type { ConnectRouteRegistration } from "../registry.js";
11
+ import type { ConnectModals } from "../router/modals.js";
3
12
 
4
13
  /**
5
14
  * The hook vocabulary ConnectCore exposes to its built-in plugins (rewire R3).
@@ -29,9 +38,41 @@ export type ConnectPluginFactory = (
29
38
  config: Record<string, unknown>,
30
39
  ) => ConnectPlugin;
31
40
 
41
+ /** Options a plugin sets on one of the tree's routes; `staticData` merges over the route's. */
42
+ export type ConnectRouteUi = {
43
+ component?: RouteComponent;
44
+ pendingComponent?: RouteComponent;
45
+ errorComponent?: ErrorRouteComponent;
46
+ notFoundComponent?: NotFoundRouteComponent;
47
+ staticData?: Partial<StaticDataRouteOption>;
48
+ };
49
+
50
+ /** What an active plugin shows. */
51
+ export type ConnectPluginUi = {
52
+ /** By route id, e.g. `/_app/`; applied only while the route shows core's view,
53
+ * so a project's own route file wins. */
54
+ routes?: Record<string, ConnectRouteUi>;
55
+ /** Code routes the plugin adds, e.g. settings tabs. */
56
+ addRoutes?: ConnectRouteRegistration;
57
+ /** Modal views by type, over core's. */
58
+ modals?: ConnectModals;
59
+ /** Rendered once beside the app, e.g. a consent banner. */
60
+ Root?: ComponentType;
61
+ /** Any route's loader or render error. */
62
+ errorComponent?: ErrorRouteComponent;
63
+ /** Keeps common's editors (the drive explorer and model editors), which core leaves out. */
64
+ commonEditors?: boolean;
65
+ /** Needs the viewer's consent to analytics cookies, which the host asks for once. */
66
+ consent?: boolean;
67
+ };
68
+
32
69
  /** A plugin module the host knows by specifier; `whenAbsent` zeroes the
33
70
  * config slice it owns when a composition leaves it out. */
34
71
  export type ConnectPluginDefinition = {
35
72
  create: ConnectPluginFactory;
36
73
  whenAbsent?: (config: PHConnectRuntimeConfig) => PHConnectRuntimeConfig;
74
+ /** The plugin's UI, loaded only while it is active. */
75
+ ui?: () => Promise<ConnectPluginUi>;
76
+ /** The prerendered loading shell that shows this plugin's UI. */
77
+ shell?: string;
37
78
  };
package/src/registry.ts CHANGED
@@ -3,6 +3,7 @@
3
3
  import type { ConfigLoader } from "@powerhousedao/shared/connect/config-loader";
4
4
  import type { PHStore } from "@powerhousedao/reactor-browser";
5
5
  import type { AnyRoute } from "@tanstack/react-router";
6
+ import type { ConnectPluginUiSet } from "./plugins/plugin-ui.js";
6
7
  import type { ConnectPluginDefinition } from "./plugins/types.js";
7
8
  import type { ConnectRouterBundle } from "./router/connect-router.js";
8
9
  import type { ConnectRoutes } from "./router/route-tree.js";
@@ -35,6 +36,8 @@ export type ConnectRegistry = {
35
36
  /** Route sources still loading, e.g. packages; unknown paths wait for them. */
36
37
  routeHolds?: { count: number; settled: Set<() => void> };
37
38
  app?: ConnectAppRouterBinding;
39
+ /** The active plugins' UI, loaded with the runtime config. */
40
+ ui?: ConnectPluginUiSet;
38
41
  runtimeConfig?: ConfigLoader;
39
42
  };
40
43
 
@@ -1,5 +1,6 @@
1
1
  import type { PHStore } from "@powerhousedao/reactor-browser";
2
2
  import { createDriveArrivals } from "@powerhousedao/reactor-browser";
3
+ import { getConnectPluginUi } from "../plugins/plugin-ui.js";
3
4
  import { getConnectRegistry } from "../registry.js";
4
5
  import type {
5
6
  ConnectRouterBundle,
@@ -31,6 +32,7 @@ export function getConnectAppRouter(
31
32
  const bundle = createConnectRouter({
32
33
  store,
33
34
  arrivals: createDriveArrivals(store),
35
+ pluginUi: getConnectPluginUi(),
34
36
  ...rest,
35
37
  routes: (tree) => [
36
38
  ...(routes ? resolveConnectRoutes(routes, tree.routeTree) : []),
@@ -14,16 +14,15 @@ import type {
14
14
  } from "@tanstack/react-router";
15
15
  import { createRouter } from "@tanstack/react-router";
16
16
  import type { ErrorInfo, PropsWithChildren, ReactElement } from "react";
17
+ import type { ConnectPluginUiSet } from "../plugins/plugin-ui.js";
18
+ import { applyConnectPluginUi } from "../plugins/plugin-ui.js";
17
19
  import { markRouterNavigations } from "./perf-marks.js";
18
20
  import { bindConnectScroll, connectScrollRestoration } from "./scroll.js";
19
21
  import type { DriveRouteData } from "./drive-route-loader.js";
20
22
  import type { ConnectRootRoute, ConnectRoutes } from "./route-tree.js";
21
23
  import { createConnectNavigation } from "./navigation.js";
22
- import {
23
- CONTENT_VIEW_ID,
24
- CoreNotFound,
25
- CoreError,
26
- } from "./route-components.js";
24
+ import { CoreError, CoreNotFound } from "./core-views.js";
25
+ import { CONTENT_VIEW_ID } from "./route-components.js";
27
26
  import { addConnectRoutes, connectRoutes } from "./route-tree.js";
28
27
  import { setSettingsRoute } from "./settings.js";
29
28
  import { parseConnectSearch, stringifyConnectSearch } from "./search.js";
@@ -37,8 +36,10 @@ export type ConnectRouterOptions = {
37
36
  InnerWrap?: (props: PropsWithChildren) => ReactElement;
38
37
  /** Shown while a route's loader or chunks are pending, after the router's `pendingMs`. */
39
38
  pendingComponent?: RouteComponent;
40
- /** Any route's loader or render error; core's error view by default. */
39
+ /** Any route's loader or render error; the plugins', else core's error view. */
41
40
  errorComponent?: ErrorRouteComponent;
41
+ /** The active plugins' UI, set on the routes that show core's views. */
42
+ pluginUi?: ConnectPluginUiSet;
42
43
  /** Loaders' notFound(), e.g. a missing drive's; core's page by default. */
43
44
  notFoundComponent?: NotFoundRouteComponent;
44
45
  /** The tree to route with: `@powerhousedao/connect/route-tree`, core's or a project's. */
@@ -80,6 +81,7 @@ export function createConnectRouter(
80
81
  ): ConnectRouterBundle {
81
82
  const { routeTree, homeRoute, driveRoute, nodeRoute, settingsRoute } =
82
83
  connectRoutes(options.routeTree);
84
+ if (options.pluginUi) applyConnectPluginUi(routeTree, options.pluginUi);
83
85
  if (options.routes) {
84
86
  addConnectRoutes(
85
87
  typeof options.routes === "function"
@@ -104,7 +106,8 @@ export function createConnectRouter(
104
106
  scrollToTopSelectors: [`#${CONTENT_VIEW_ID}`],
105
107
  InnerWrap: options.InnerWrap,
106
108
  defaultPendingComponent: options.pendingComponent,
107
- defaultErrorComponent: options.errorComponent ?? CoreError,
109
+ defaultErrorComponent:
110
+ options.errorComponent ?? options.pluginUi?.errorComponent ?? CoreError,
108
111
  // Not per route: a route's notFoundComponent mounts a boundary that
109
112
  // re-renders on every navigation.
110
113
  defaultNotFoundComponent: options.notFoundComponent ?? CoreNotFound,
@@ -146,9 +149,11 @@ export function bindConnectRouter(
146
149
  }
147
150
  };
148
151
 
149
- // A routed modal is the URL's: the store holds what the location names.
152
+ // A routed modal is the URL's: the store holds what the location names,
153
+ // unless a confirmation is open over it.
150
154
  const syncModal = () => {
151
155
  const routed = modalFromSearch(router.state.location.search);
156
+ if (store.modal && !isRoutedModal(store.modal)) return;
152
157
  if (routed) {
153
158
  if (JSON.stringify(store.modal) !== JSON.stringify(routed)) {
154
159
  store.modal = routed;
@@ -2,8 +2,8 @@ import type { PHStore } from "@powerhousedao/reactor-browser";
2
2
  import { PHStoreProvider } from "@powerhousedao/reactor-browser";
3
3
  import type { AnyRoute } from "@tanstack/react-router";
4
4
  import { RouterProvider } from "@tanstack/react-router";
5
- import type { ConnectAppRouterOptions } from "../router/app-router.js";
6
- import { getConnectAppRouter } from "../router/app-router.js";
5
+ import type { ConnectAppRouterOptions } from "./app-router.js";
6
+ import { getConnectAppRouter } from "./app-router.js";
7
7
 
8
8
  /** A core-only app: `routeTree`'s views over `store`. */
9
9
  export function ConnectCoreApp(props: {
@@ -0,0 +1,57 @@
1
+ // The router's one edge to core's views: views never import this module, so
2
+ // loading them lazily makes no import cycle.
3
+ import type {
4
+ ErrorRouteComponent,
5
+ NotFoundRouteProps,
6
+ RouteComponent,
7
+ } from "@tanstack/react-router";
8
+ import { lazyRouteComponent } from "@tanstack/react-router";
9
+ import type { ComponentType } from "react";
10
+ import { isDriveNotFound } from "./drive-route-loader.js";
11
+ import type { ConnectModals } from "./modals.js";
12
+ import { CORE_MODAL_VIEWS } from "./modals.js";
13
+
14
+ // Core's views load only when a page shows one.
15
+ const views = () => import("@powerhousedao/connect-core/views");
16
+
17
+ const CORE_VIEW = Symbol.for("@powerhousedao/connect-core/core-view");
18
+
19
+ /** One of core's views, loaded with the route that shows it. */
20
+ export function coreView(name: keyof Awaited<ReturnType<typeof views>>) {
21
+ return Object.assign(lazyRouteComponent(views, name), {
22
+ [CORE_VIEW]: true,
23
+ }) as RouteComponent;
24
+ }
25
+
26
+ /** Whether a route's component is one of core's views, which a plugin's UI replaces. */
27
+ export function isCoreView(component: unknown) {
28
+ return (
29
+ typeof component === "function" &&
30
+ (component as { [CORE_VIEW]?: true })[CORE_VIEW] === true
31
+ );
32
+ }
33
+
34
+ const NotFoundPage = coreView("NotFoundPage");
35
+ const DriveNotFoundPage = coreView("DriveNotFoundPage") as ComponentType<{
36
+ driveSlug: string;
37
+ }>;
38
+
39
+ /** Core's not-found page; a missing drive's names the drive. */
40
+ export function CoreNotFound({ data }: Partial<NotFoundRouteProps>) {
41
+ return isDriveNotFound(data) ? (
42
+ <DriveNotFoundPage driveSlug={data.driveSlug} />
43
+ ) : (
44
+ <NotFoundPage />
45
+ );
46
+ }
47
+
48
+ /** Core's view of a route's loader or render error. */
49
+ export const CoreError = coreView("CoreRouteError") as ErrorRouteComponent;
50
+
51
+ /** Core's modals, loaded when one opens. */
52
+ export const coreModals: ConnectModals = Object.fromEntries(
53
+ Object.entries(CORE_MODAL_VIEWS).map(([type, name]) => [
54
+ type,
55
+ coreView(name),
56
+ ]),
57
+ );
@@ -54,13 +54,18 @@ export type {
54
54
  export {
55
55
  CONTENT_VIEW_ID,
56
56
  ContentOutlet,
57
+ DrivePending,
58
+ } from "./route-components.js";
59
+ export {
57
60
  CoreNotFound,
58
61
  CoreError,
62
+ coreModals,
59
63
  coreView,
60
- DrivePending,
61
- } from "./route-components.js";
64
+ isCoreView,
65
+ } from "./core-views.js";
66
+ export { ConnectCoreApp } from "./core-app.js";
62
67
  export type { DocumentViewProps, DriveViewProps } from "./route-components.js";
63
- export { coreModals, ModalOutlet } from "./modals.js";
68
+ export { CORE_MODAL_VIEWS, ModalOutlet } from "./modals.js";
64
69
  export { ModalDialog, useModalFrame } from "./modal-frame.js";
65
70
  export type { ModalFrameProps } from "./modal-frame.js";
66
71
  export type { ConnectModals } from "./modals.js";
@@ -3,9 +3,9 @@ import { usePHModal } from "@powerhousedao/reactor-browser";
3
3
  import { useMatches } from "@tanstack/react-router";
4
4
  import type { ComponentType, ErrorInfo, ReactNode } from "react";
5
5
  import { Component, createElement, Suspense } from "react";
6
+ import type * as ModalViews from "../views/modals.js";
6
7
  import type { ModalFrameProps } from "./modal-frame.js";
7
8
  import { ModalFrameContext } from "./modal-frame.js";
8
- import { coreView } from "./route-components.js";
9
9
 
10
10
  /** Modal views by modal type. Each reads the open modal with `usePHModal()`. */
11
11
  export type ConnectModals = Partial<Record<PHModal["type"], ComponentType>>;
@@ -18,16 +18,16 @@ declare module "@tanstack/react-router" {
18
18
  }
19
19
 
20
20
  /** Core's modals: the ones editors, drive apps and the reactor open, so every
21
- * host has them. Loaded when one opens. */
22
- export const coreModals: ConnectModals = {
23
- createDocument: coreView("CreateDocumentModal"),
24
- login: coreView("LoginModal"),
25
- deleteItem: coreView("DeleteItemModal"),
26
- confirmDocumentUpgrade: coreView("ConfirmDocumentUpgradeModal"),
27
- downloadDocumentWithErrors: coreView("DownloadDocumentWithErrorsModal"),
28
- documentVersionUnsupported: coreView("DocumentVersionUnsupportedModal"),
29
- driveAuthRequired: coreView("DriveAuthRequiredModal"),
30
- };
21
+ * host has them. Each names its view in `@powerhousedao/connect-core/views`. */
22
+ export const CORE_MODAL_VIEWS = {
23
+ createDocument: "CreateDocumentModal",
24
+ login: "LoginModal",
25
+ deleteItem: "DeleteItemModal",
26
+ confirmDocumentUpgrade: "ConfirmDocumentUpgradeModal",
27
+ downloadDocumentWithErrors: "DownloadDocumentWithErrorsModal",
28
+ documentVersionUnsupported: "DocumentVersionUnsupportedModal",
29
+ driveAuthRequired: "DriveAuthRequiredModal",
30
+ } as const satisfies Partial<Record<PHModal["type"], keyof typeof ModalViews>>;
31
31
 
32
32
  type BoundaryProps = { modal: string; children: ReactNode };
33
33
 
@@ -1,24 +1,16 @@
1
1
  import type {
2
2
  AnyRoute,
3
3
  AnyRouter,
4
- ErrorRouteComponent,
5
- NotFoundRouteProps,
6
4
  RouteComponent,
7
5
  } from "@tanstack/react-router";
8
- import {
9
- lazyRouteComponent,
10
- Outlet,
11
- useMatch,
12
- useRouter,
13
- } from "@tanstack/react-router";
14
- import type { ComponentType, ReactNode } from "react";
6
+ import { Outlet, useMatch, useRouter } from "@tanstack/react-router";
7
+ import type { ReactNode } from "react";
15
8
  import type {
16
9
  DocumentDriveDocument,
17
10
  FolderNode,
18
11
  } from "@powerhousedao/shared/document-drive";
19
12
  import type { PHDocument } from "@powerhousedao/shared/document-model";
20
13
  import { useIsColdStart } from "./cold-start.js";
21
- import { isDriveNotFound } from "./drive-route-loader.js";
22
14
 
23
15
  /** A drive view's props: drive apps and core's directory listing get the same. */
24
16
  export type DriveViewProps = {
@@ -72,28 +64,3 @@ export function DrivePending() {
72
64
  </p>
73
65
  );
74
66
  }
75
-
76
- // Core's views load only when a page shows one.
77
- const views = () => import("@powerhousedao/connect-core/views");
78
-
79
- /** One of core's views, loaded with the route that shows it. */
80
- export function coreView(name: keyof Awaited<ReturnType<typeof views>>) {
81
- return lazyRouteComponent(views, name) as RouteComponent;
82
- }
83
-
84
- const NotFoundPage = coreView("NotFoundPage");
85
- const DriveNotFoundPage = coreView("DriveNotFoundPage") as ComponentType<{
86
- driveSlug: string;
87
- }>;
88
-
89
- /** Core's not-found page; a missing drive's names the drive. */
90
- export function CoreNotFound({ data }: Partial<NotFoundRouteProps>) {
91
- return isDriveNotFound(data) ? (
92
- <DriveNotFoundPage driveSlug={data.driveSlug} />
93
- ) : (
94
- <NotFoundPage />
95
- );
96
- }
97
-
98
- /** Core's view of a route's loader or render error. */
99
- export const CoreError = coreView("CoreRouteError") as ErrorRouteComponent;
@@ -58,12 +58,11 @@ export function stringifyConnectSearch(search: Record<string, unknown>) {
58
58
  }
59
59
 
60
60
  /** The drive route's params: `?history` opens the document's revision history,
61
- * and `page` (a positive integer; 1 is dropped) pages it. */
61
+ * and `historyPage` (a positive integer; 1 is dropped) pages it. */
62
62
  export function validateDriveSearch(
63
63
  search: Record<string, unknown>,
64
64
  ): ConnectSearch {
65
65
  const result = { ...search } as ConnectSearch;
66
- // Without `history`, a `page` is the document's own, e.g. a drive app's.
67
66
  if (result[REVISION_HISTORY_PARAM] === undefined) return result;
68
67
  result[REVISION_HISTORY_PARAM] = "";
69
68
  const page = parseRevisionHistoryPage(result[REVISION_HISTORY_PAGE_PARAM]);
@@ -17,7 +17,7 @@ import { createFileRoute } from "@tanstack/react-router";
17
17
 
18
18
  /** The node route's options: its loader, `?history` search and title. */
19
19
  export const options = {
20
- // `?history` and its `page`; inline type, as in __root.tsx.
20
+ // `?history` and its `historyPage`; inline type, as in __root.tsx.
21
21
  validateSearch: (search: Record<string, unknown>) =>
22
22
  validateDriveSearch(search) as Record<string, string | undefined>,
23
23
  loader: {
@@ -14,13 +14,29 @@ const loader = () =>
14
14
  ));
15
15
 
16
16
  export type LoadRuntimeConfigOptions = {
17
- /** The host's plugin modules; loaded only when the config declares a composition. */
17
+ /** The host's plugin modules: the composition's transform the config, and
18
+ * the active ones' UI loads (every one's without a composition). */
18
19
  plugins?: () => Promise<Record<string, ConnectPluginDefinition>>;
19
20
  };
20
21
 
21
22
  export async function loadRuntimeConfig(
22
23
  options: LoadRuntimeConfigOptions = {},
23
24
  ): Promise<RuntimePowerhouseConfig> {
25
+ // Beside the config's fetch: the plugin modules and the UI loader don't
26
+ // depend on it; only which plugins' UI loads does.
27
+ const pluginHost = options.plugins
28
+ ? import("../plugins/plugin-host.js").then(async (host) => {
29
+ host.registerConnectPlugins(options.plugins!);
30
+ await host.knownPlugins();
31
+ return host;
32
+ })
33
+ : undefined;
34
+ const pluginUi = options.plugins
35
+ ? import("../plugins/plugin-ui.js")
36
+ : undefined;
37
+ // Handled here so a failed config read doesn't leave them unhandled.
38
+ void pluginHost?.catch(() => undefined);
39
+ void pluginUi?.catch(() => undefined);
24
40
  const config = (await loader().read()) as RuntimePowerhouseConfig;
25
41
  // R3: a served composition (projected from host.js runner.plugins by the
26
42
  // uniform-runners connect runner) runs the plugin pipeline over the config
@@ -36,13 +52,17 @@ export async function loadRuntimeConfig(
36
52
  readConfigFlag: () => true,
37
53
  });
38
54
  if (enabled) {
39
- const { applyComposition, registerConnectPlugins } =
40
- await import("../plugins/plugin-host.js");
41
- if (options.plugins) registerConnectPlugins(options.plugins);
55
+ const { applyComposition } =
56
+ (await pluginHost) ?? (await import("../plugins/plugin-host.js"));
42
57
  config.connect = await applyComposition(config.connect);
43
58
  loader().prime(config);
44
59
  }
45
60
  }
61
+ if (pluginHost && pluginUi) {
62
+ await pluginHost;
63
+ const { loadConnectPluginUi } = await pluginUi;
64
+ await loadConnectPluginUi(config.connect.composition);
65
+ }
46
66
  return config;
47
67
  }
48
68
 
@@ -1,5 +1,4 @@
1
1
  // Core's plain views; a host loads them only when it shows them.
2
- export { ConnectCoreApp } from "./core-app.js";
3
2
  export { DocumentView, DriveLayout, NodeView } from "./content.js";
4
3
  export { DirectoryListing } from "./directory-listing.js";
5
4
  export { DocumentHistory, HISTORY_PAGE_SIZE } from "./document-history.js";
@@ -5,13 +5,23 @@ import {
5
5
  } from "@powerhousedao/reactor-browser";
6
6
  import { HeadContent, Link, Outlet, useRouter } from "@tanstack/react-router";
7
7
  import { Suspense } from "react";
8
- import { coreModals, ModalOutlet } from "../router/modals.js";
8
+ import type { ConnectModals } from "../router/modals.js";
9
+ import { CORE_MODAL_VIEWS, ModalOutlet } from "../router/modals.js";
9
10
  import { ContentOutlet } from "../router/route-components.js";
10
11
  import { useNavRoutes, useSidebarMode } from "../router/sidebar.js";
11
12
  import { appTitle } from "../router/titles.js";
12
13
  import { useIsEmbedded } from "../runtime/embedded.js";
13
14
  import { ThemeToggle } from "./theme-toggle.js";
14
15
  import { linkClass } from "./index-table.js";
16
+ import * as modalViews from "./modals.js";
17
+
18
+ // The layout shares the modals' chunk, so it renders them directly.
19
+ const coreModals: ConnectModals = Object.fromEntries(
20
+ Object.entries(CORE_MODAL_VIEWS).map(([type, name]) => [
21
+ type,
22
+ modalViews[name],
23
+ ]),
24
+ );
15
25
 
16
26
  /** Core's root: the page, in the theme. */
17
27
  export function CoreRoot() {
@@ -1,26 +0,0 @@
1
- import type { ReactNode } from "react";
2
- import { useIsPluginActive } from "./composition.js";
3
-
4
- /**
5
- * Renders its children only when a plugin is present in the served
6
- * composition — the runtime gate for generic-chrome deletability (rewire R3).
7
- *
8
- * This is the demonstration step ahead of extracting the generic components
9
- * into their own package: it makes "remove the generic plugin → the chrome is
10
- * gone" true at render time while the code still lives in the Connect app.
11
- * When those components move to `@powerhousedao/connect-generic`, this gate
12
- * becomes the seam where the package is dynamically imported (or not) instead
13
- * of merely not rendered.
14
- *
15
- * Presence is activation, classic-preserving: with no declared composition,
16
- * every plugin reads active, so the chrome renders unchanged.
17
- */
18
- export function IfPlugin(props: {
19
- module: string;
20
- children: ReactNode;
21
- /** Rendered when the plugin is absent from a declared composition. */
22
- fallback?: ReactNode;
23
- }): ReactNode {
24
- const active = useIsPluginActive(props.module);
25
- return active ? props.children : (props.fallback ?? null);
26
- }