mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-11-01 08:05:23 +00:00
feat: improve UI loading time and jitter
This commit is contained in:
1 parent
0f8956dcf3
commit
4a320a2258
8 files changed
+167
-25
No files matched your search
+38
-3
@@ -24,7 +24,15 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { UiPreferencesProvider } from "@/contexts/UiPreferencesContext";
|
import { UiPreferencesProvider } from "@/contexts/UiPreferencesContext";
|
||||||
import { ConnectionDefaultsProvider } from "@/contexts/ConnectionDefaultsContext";
|
import { ConnectionDefaultsProvider } from "@/contexts/ConnectionDefaultsContext";
|
||||||
import { BrandingProvider } from "@/contexts/BrandingContext";
|
import { BrandingProvider } from "@/contexts/BrandingContext";
|
||||||
import { fetchGuestViews, startPluginRuntime } from "@/plugin-host/loader";
|
import {
|
||||||
|
fetchGuestViews,
|
||||||
|
startPluginRuntime,
|
||||||
|
stopPluginRuntime,
|
||||||
|
} from "@/plugin-host/loader";
|
||||||
|
import { settledPromise } from "@/plugin-host/plugin-store";
|
||||||
|
import { resetShellBridge } from "@/plugin-host/shell-bridge";
|
||||||
|
import { preloadPermissions } from "@/hooks/use-permissions";
|
||||||
|
import { getUserPreferences } from "@/api/open-tabs-api";
|
||||||
import { standaloneViewFor } from "@/shell/tab-registry";
|
import { standaloneViewFor } from "@/shell/tab-registry";
|
||||||
import { PluginViewPlaceholder } from "@/plugin-host/PluginViewPlaceholder";
|
import { PluginViewPlaceholder } from "@/plugin-host/PluginViewPlaceholder";
|
||||||
|
|
||||||
@@ -39,7 +47,12 @@ const ElectronVersionCheck = lazy(() =>
|
|||||||
);
|
);
|
||||||
|
|
||||||
type Phase =
|
type Phase =
|
||||||
"verifying" | "idle-auth" | "fading-in" | "idle-app" | "fading-out";
|
| "verifying"
|
||||||
|
| "idle-auth"
|
||||||
|
| "loading-app"
|
||||||
|
| "fading-in"
|
||||||
|
| "idle-app"
|
||||||
|
| "fading-out";
|
||||||
|
|
||||||
type LogoutOptions = {
|
type LogoutOptions = {
|
||||||
manual?: boolean;
|
manual?: boolean;
|
||||||
@@ -180,6 +193,14 @@ function App() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
fadingInFromLoginRef.current = false;
|
fadingInFromLoginRef.current = false;
|
||||||
|
setPhase("loading-app");
|
||||||
|
await Promise.all([
|
||||||
|
startPluginRuntime()
|
||||||
|
.catch(() => {})
|
||||||
|
.then(() => settledPromise()),
|
||||||
|
preloadPermissions(),
|
||||||
|
getUserPreferences().catch(() => undefined),
|
||||||
|
]);
|
||||||
setPhase("fading-in");
|
setPhase("fading-in");
|
||||||
timerRef.current = setTimeout(() => setPhase("idle-app"), 450);
|
timerRef.current = setTimeout(() => setPhase("idle-app"), 450);
|
||||||
})
|
})
|
||||||
@@ -238,8 +259,18 @@ function App() {
|
|||||||
clearDesktopManualLogout();
|
clearDesktopManualLogout();
|
||||||
setAuthUsername(u);
|
setAuthUsername(u);
|
||||||
fadingInFromLoginRef.current = true;
|
fadingInFromLoginRef.current = true;
|
||||||
|
setPhase("loading-app");
|
||||||
|
void (async () => {
|
||||||
|
await Promise.all([
|
||||||
|
startPluginRuntime()
|
||||||
|
.catch(() => {})
|
||||||
|
.then(() => settledPromise()),
|
||||||
|
preloadPermissions(),
|
||||||
|
getUserPreferences().catch(() => undefined),
|
||||||
|
]);
|
||||||
setPhase("fading-in");
|
setPhase("fading-in");
|
||||||
timerRef.current = setTimeout(() => setPhase("idle-app"), 450);
|
timerRef.current = setTimeout(() => setPhase("idle-app"), 450);
|
||||||
|
})();
|
||||||
if (isElectron()) {
|
if (isElectron()) {
|
||||||
window.electronAPI?.startC2SAutoStartTunnels?.().catch(() => {});
|
window.electronAPI?.startC2SAutoStartTunnels?.().catch(() => {});
|
||||||
const localJwt = localStorage.getItem("jwt");
|
const localJwt = localStorage.getItem("jwt");
|
||||||
@@ -264,6 +295,10 @@ function App() {
|
|||||||
// isn't subject to batching, so it correctly dedupes within one tick.
|
// isn't subject to batching, so it correctly dedupes within one tick.
|
||||||
if (loggingOutRef.current) return;
|
if (loggingOutRef.current) return;
|
||||||
loggingOutRef.current = true;
|
loggingOutRef.current = true;
|
||||||
|
// The next user may see different plugins, so nothing they registered
|
||||||
|
// should survive into the next session.
|
||||||
|
void stopPluginRuntime();
|
||||||
|
resetShellBridge();
|
||||||
clearStoredAuth();
|
clearStoredAuth();
|
||||||
localStorage.removeItem("jwt");
|
localStorage.removeItem("jwt");
|
||||||
if (isElectron() && options?.manual) {
|
if (isElectron() && options?.manual) {
|
||||||
@@ -297,7 +332,7 @@ function App() {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const isTransitioning = phase === "fading-in" || phase === "fading-out";
|
const isTransitioning = phase === "fading-in" || phase === "fading-out";
|
||||||
|
|
||||||
if (phase === "verifying") {
|
if (phase === "verifying" || phase === "loading-app") {
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 flex items-center justify-center bg-background">
|
<div className="fixed inset-0 flex items-center justify-center bg-background">
|
||||||
<div className="flex flex-col items-center gap-4">
|
<div className="flex flex-col items-center gap-4">
|
||||||
|
|||||||
+1
-13
@@ -175,12 +175,10 @@ import {
|
|||||||
} from "@/shell/tab-registry";
|
} from "@/shell/tab-registry";
|
||||||
import { getPanel, usePanels } from "@/shell/panel-registry";
|
import { getPanel, usePanels } from "@/shell/panel-registry";
|
||||||
import { invokeAction } from "@/shell/action-registry";
|
import { invokeAction } from "@/shell/action-registry";
|
||||||
import { startPluginRuntime, stopPluginRuntime } from "@/plugin-host/loader";
|
|
||||||
import { usePluginStore } from "@/plugin-host/plugin-store";
|
import { usePluginStore } from "@/plugin-host/plugin-store";
|
||||||
import {
|
import {
|
||||||
notifyShellReady,
|
notifyShellReady,
|
||||||
notifyTabsChanged,
|
notifyTabsChanged,
|
||||||
resetShellBridge,
|
|
||||||
setShellCallbacks,
|
setShellCallbacks,
|
||||||
setShellHosts,
|
setShellHosts,
|
||||||
setShellLayoutProvider,
|
setShellLayoutProvider,
|
||||||
@@ -401,17 +399,6 @@ export function AppShell({
|
|||||||
.catch(() => setIsAdmin(false));
|
.catch(() => setIsAdmin(false));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Plugin frontends register the tabs, panels and actions that a saved
|
|
||||||
// session may need, so they load before tabs are restored. On logout every
|
|
||||||
// plugin is deactivated, since the next user may see different ones.
|
|
||||||
useEffect(() => {
|
|
||||||
startPluginRuntime().catch(() => {});
|
|
||||||
return () => {
|
|
||||||
void stopPluginRuntime();
|
|
||||||
resetShellBridge();
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleDismissDonationModal = useCallback(() => {
|
const handleDismissDonationModal = useCallback(() => {
|
||||||
setShowDonationModal(false);
|
setShowDonationModal(false);
|
||||||
dismissDonationModal().catch(() => {});
|
dismissDonationModal().catch(() => {});
|
||||||
@@ -2661,6 +2648,7 @@ export function AppShell({
|
|||||||
splitMode={splitMode}
|
splitMode={splitMode}
|
||||||
username={username}
|
username={username}
|
||||||
isAdmin={showAdminUI}
|
isAdmin={showAdminUI}
|
||||||
|
pluginsSettled={pluginsSettled}
|
||||||
onRailClick={handleRailClick}
|
onRailClick={handleRailClick}
|
||||||
onOpenTab={openSingletonTab}
|
onOpenTab={openSingletonTab}
|
||||||
onOpenInRightDock={openInRightDock}
|
onOpenInRightDock={openInRightDock}
|
||||||
|
|||||||
@@ -169,9 +169,19 @@ export function parseCustomKeybindings(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const userPreferencesCache = createTtlRequestCache<UserPreferences>(10_000);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cached briefly so the loading gate's prefetch and the shell's own read on
|
||||||
|
* mount share one request instead of two, which is what let stale
|
||||||
|
* localStorage flags (hiddenRailTabs, pinAppRail) flash before the real
|
||||||
|
* values landed a moment after the shell first rendered.
|
||||||
|
*/
|
||||||
export async function getUserPreferences(): Promise<UserPreferences> {
|
export async function getUserPreferences(): Promise<UserPreferences> {
|
||||||
|
return userPreferencesCache.get(async () => {
|
||||||
const response = await authApi.get("/user-preferences");
|
const response = await authApi.get("/user-preferences");
|
||||||
return response.data;
|
return response.data;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function saveUserPreferences(
|
export async function saveUserPreferences(
|
||||||
|
|||||||
@@ -3,9 +3,11 @@ import { enabledHostProtocols, protocolPort } from "@/sidebar/host-protocols";
|
|||||||
import { ComponentSlot } from "@/shell/ActionSlot";
|
import { ComponentSlot } from "@/shell/ActionSlot";
|
||||||
import { useActionSlot } from "@/hooks/use-action-slot";
|
import { useActionSlot } from "@/hooks/use-action-slot";
|
||||||
import { useIsMobile } from "@/hooks/use-mobile";
|
import { useIsMobile } from "@/hooks/use-mobile";
|
||||||
|
import { usePluginStore } from "@/plugin-host/plugin-store";
|
||||||
import { Button } from "@/components/button";
|
import { Button } from "@/components/button";
|
||||||
import { Card } from "@/components/card";
|
import { Card } from "@/components/card";
|
||||||
import { Separator } from "@/components/separator";
|
import { Separator } from "@/components/separator";
|
||||||
|
import { Skeleton } from "@/components/skeleton";
|
||||||
import {
|
import {
|
||||||
Activity,
|
Activity,
|
||||||
Database,
|
Database,
|
||||||
@@ -1049,6 +1051,7 @@ export function DashboardTab({
|
|||||||
// canvas preview. Only one is offered today; "dashboard" always exists.
|
// canvas preview. Only one is offered today; "dashboard" always exists.
|
||||||
const secondaryViews = useActionSlot("dashboard.secondaryView");
|
const secondaryViews = useActionSlot("dashboard.secondaryView");
|
||||||
const secondaryView = secondaryViews[0];
|
const secondaryView = secondaryViews[0];
|
||||||
|
const { settled: pluginsSettled } = usePluginStore();
|
||||||
|
|
||||||
const [dashboardView, setDashboardView] = useState<string>(() => {
|
const [dashboardView, setDashboardView] = useState<string>(() => {
|
||||||
try {
|
try {
|
||||||
@@ -1057,11 +1060,14 @@ export function DashboardTab({
|
|||||||
return "dashboard";
|
return "dashboard";
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
// Until plugins settle, a plugin view saved from last session hasn't had a
|
||||||
|
// chance to register yet, so its absence doesn't mean it's really gone.
|
||||||
|
const viewPending = !pluginsSettled && dashboardView !== "dashboard";
|
||||||
// Falls back to the dashboard when the view a plugin contributed is off.
|
// Falls back to the dashboard when the view a plugin contributed is off.
|
||||||
const isDashboardView =
|
const isDashboardView =
|
||||||
dashboardView === "dashboard" ||
|
dashboardView === "dashboard" ||
|
||||||
!secondaryView ||
|
(!viewPending && !secondaryView) ||
|
||||||
dashboardView !== secondaryView.actionId;
|
(!!secondaryView && dashboardView !== secondaryView.actionId);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
try {
|
try {
|
||||||
@@ -1482,6 +1488,9 @@ export function DashboardTab({
|
|||||||
<div className="flex flex-col w-full h-full min-h-0 overflow-hidden">
|
<div className="flex flex-col w-full h-full min-h-0 overflow-hidden">
|
||||||
<Card className="flex-row items-center justify-between px-5 py-3 shrink-0 mx-5 mt-5 gap-0">
|
<Card className="flex-row items-center justify-between px-5 py-3 shrink-0 mx-5 mt-5 gap-0">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
|
{viewPending ? (
|
||||||
|
<Skeleton className="h-7 w-40" />
|
||||||
|
) : (
|
||||||
<div className="flex items-center gap-0 bg-muted/40 border border-border p-0.5">
|
<div className="flex items-center gap-0 bg-muted/40 border border-border p-0.5">
|
||||||
<button
|
<button
|
||||||
onClick={() => setDashboardView("dashboard")}
|
onClick={() => setDashboardView("dashboard")}
|
||||||
@@ -1498,7 +1507,8 @@ export function DashboardTab({
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{isDashboardView && (
|
)}
|
||||||
|
{isDashboardView && !viewPending && (
|
||||||
<p className="text-xs text-muted-foreground hidden sm:block">
|
<p className="text-xs text-muted-foreground hidden sm:block">
|
||||||
{todayLabel}
|
{todayLabel}
|
||||||
</p>
|
</p>
|
||||||
@@ -1621,7 +1631,13 @@ export function DashboardTab({
|
|||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{!isDashboardView && secondaryView?.component ? (
|
{viewPending ? (
|
||||||
|
<div className="flex-1 min-h-0 overflow-hidden mx-5 mb-5 mt-4 border border-border flex flex-col p-5 gap-3">
|
||||||
|
<Skeleton className="h-24 w-full" />
|
||||||
|
<Skeleton className="h-24 w-full" />
|
||||||
|
<Skeleton className="h-24 w-full flex-1" />
|
||||||
|
</div>
|
||||||
|
) : !isDashboardView && secondaryView?.component ? (
|
||||||
<div className="flex-1 min-h-0 overflow-hidden mx-5 mb-5 mt-4 border border-border flex flex-col">
|
<div className="flex-1 min-h-0 overflow-hidden mx-5 mb-5 mt-4 border border-border flex flex-col">
|
||||||
<secondaryView.component onOpenSingletonTab={onOpenSingletonTab} />
|
<secondaryView.component onOpenSingletonTab={onOpenSingletonTab} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -56,6 +56,15 @@ export async function hasPermission(permission: string): Promise<boolean> {
|
|||||||
return matchesPermission(permissions, isAdmin, permission);
|
return matchesPermission(permissions, isAdmin, permission);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Warms the permissions cache before the shell mounts, so permission-gated
|
||||||
|
* rail items are already known on first paint instead of popping in once
|
||||||
|
* usePermissions' own fetch resolves.
|
||||||
|
*/
|
||||||
|
export function preloadPermissions(): Promise<void> {
|
||||||
|
return load().then(() => undefined);
|
||||||
|
}
|
||||||
|
|
||||||
export function notifyPermissionsChanged(): void {
|
export function notifyPermissionsChanged(): void {
|
||||||
cache = null;
|
cache = null;
|
||||||
window.dispatchEvent(new Event(PERMISSIONS_CHANGED_EVENT));
|
window.dispatchEvent(new Event(PERMISSIONS_CHANGED_EVENT));
|
||||||
|
|||||||
@@ -300,8 +300,33 @@ async function reconcile(summaries: PluginSummary[]): Promise<void> {
|
|||||||
if (!wanted.has(id)) failedVersions.delete(id);
|
if (!wanted.has(id)) failedVersions.delete(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const summary of order) {
|
await activateConcurrently(order);
|
||||||
if (active.has(summary.id)) continue;
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Activates every plugin in `order`, running independent plugins in
|
||||||
|
* parallel instead of one at a time. Each plugin still waits for its own
|
||||||
|
* hard dependencies to finish activating first (success or failure - a
|
||||||
|
* blocked or failed dependency must not hang its dependents forever).
|
||||||
|
*/
|
||||||
|
async function activateConcurrently(order: PluginSummary[]): Promise<void> {
|
||||||
|
const byId = new Map(order.map((summary) => [summary.id, summary]));
|
||||||
|
const started = new Map<string, Promise<void>>();
|
||||||
|
|
||||||
|
const run = (summary: PluginSummary): Promise<void> => {
|
||||||
|
const existing = started.get(summary.id);
|
||||||
|
if (existing) return existing;
|
||||||
|
|
||||||
|
const task = (async () => {
|
||||||
|
const dependencyIds = Object.keys(summary.dependencies ?? {});
|
||||||
|
await Promise.all(
|
||||||
|
dependencyIds.map((id) => {
|
||||||
|
const dependency = byId.get(id);
|
||||||
|
return dependency ? run(dependency) : Promise.resolve();
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (active.has(summary.id)) return;
|
||||||
// Do not retry a bundle that already failed until it changes.
|
// Do not retry a bundle that already failed until it changes.
|
||||||
if (
|
if (
|
||||||
failedVersions.has(summary.id) &&
|
failedVersions.has(summary.id) &&
|
||||||
@@ -312,10 +337,16 @@ async function reconcile(summaries: PluginSummary[]): Promise<void> {
|
|||||||
"failed",
|
"failed",
|
||||||
getPluginRecord(summary.id)?.error,
|
getPluginRecord(summary.id)?.error,
|
||||||
);
|
);
|
||||||
continue;
|
return;
|
||||||
}
|
}
|
||||||
await activate(summary);
|
await activate(summary);
|
||||||
}
|
})();
|
||||||
|
|
||||||
|
started.set(summary.id, task);
|
||||||
|
return task;
|
||||||
|
};
|
||||||
|
|
||||||
|
await Promise.all(order.map(run));
|
||||||
}
|
}
|
||||||
|
|
||||||
function enqueue(task: () => Promise<void>): Promise<void> {
|
function enqueue(task: () => Promise<void>): Promise<void> {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
User,
|
User,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { SplitMode, TabType, ToolsTab } from "@/types/ui-types";
|
import type { SplitMode, TabType, ToolsTab } from "@/types/ui-types";
|
||||||
|
import { Skeleton } from "@/components/skeleton";
|
||||||
import { readRailPreference, setRailPreference } from "./rail-preferences";
|
import { readRailPreference, setRailPreference } from "./rail-preferences";
|
||||||
import { useRailItems, type RailItemDef } from "./rail-items";
|
import { useRailItems, type RailItemDef } from "./rail-items";
|
||||||
|
|
||||||
@@ -99,6 +100,7 @@ export function AppRail({
|
|||||||
splitMode,
|
splitMode,
|
||||||
username,
|
username,
|
||||||
isAdmin,
|
isAdmin,
|
||||||
|
pluginsSettled = true,
|
||||||
onRailClick,
|
onRailClick,
|
||||||
onOpenTab,
|
onOpenTab,
|
||||||
onOpenInRightDock,
|
onOpenInRightDock,
|
||||||
@@ -109,6 +111,8 @@ export function AppRail({
|
|||||||
splitMode: SplitMode;
|
splitMode: SplitMode;
|
||||||
username: string;
|
username: string;
|
||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
|
/** False while plugins are still registering their rail items. */
|
||||||
|
pluginsSettled?: boolean;
|
||||||
onRailClick: (view: RailView) => void;
|
onRailClick: (view: RailView) => void;
|
||||||
onOpenTab?: (type: TabType) => void;
|
onOpenTab?: (type: TabType) => void;
|
||||||
onOpenInRightDock?: (view: RailView) => void;
|
onOpenInRightDock?: (view: RailView) => void;
|
||||||
@@ -236,7 +240,17 @@ export function AppRail({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col flex-1 gap-1 overflow-y-auto scrollbar-none min-h-0">
|
<div className="flex flex-col flex-1 gap-1 overflow-y-auto scrollbar-none min-h-0">
|
||||||
{railButtons.map((item, i) =>
|
{!pluginsSettled
|
||||||
|
? Array.from({ length: railButtons.length || 8 }, (_, i) => (
|
||||||
|
<div
|
||||||
|
key={`rail-skeleton-${i}`}
|
||||||
|
style={btnStyle}
|
||||||
|
className="flex items-center h-7 shrink-0"
|
||||||
|
>
|
||||||
|
<Skeleton className="size-4 shrink-0" />
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
: railButtons.map((item, i) =>
|
||||||
item.kind === "separator" ? (
|
item.kind === "separator" ? (
|
||||||
<div
|
<div
|
||||||
key={`sep-${i}`}
|
key={`sep-${i}`}
|
||||||
|
|||||||
@@ -260,4 +260,43 @@ describe("activation order", () => {
|
|||||||
expect(order).toEqual([]);
|
expect(order).toEqual([]);
|
||||||
expect([...blocked.keys()].sort()).toEqual(["a", "b"]);
|
expect([...blocked.keys()].sort()).toEqual(["a", "b"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("activates independent plugins concurrently rather than one at a time", async () => {
|
||||||
|
modules.alpha = railPlugin("alpha");
|
||||||
|
modules.beta = railPlugin("beta");
|
||||||
|
plugins = [summary("alpha"), summary("beta")];
|
||||||
|
|
||||||
|
// Each import blocks until the other has started, which only resolves
|
||||||
|
// if both imports were kicked off before either finished.
|
||||||
|
let releaseAlpha: () => void;
|
||||||
|
let releaseBeta: () => void;
|
||||||
|
const alphaStarted = new Promise<void>((resolve) => {
|
||||||
|
releaseAlpha = resolve;
|
||||||
|
});
|
||||||
|
const betaStarted = new Promise<void>((resolve) => {
|
||||||
|
releaseBeta = resolve;
|
||||||
|
});
|
||||||
|
configurePluginLoader({
|
||||||
|
fetchPlugins: async () => plugins,
|
||||||
|
importFrontend: async (entry) => {
|
||||||
|
if (entry.id === "alpha") {
|
||||||
|
releaseAlpha!();
|
||||||
|
await betaStarted;
|
||||||
|
} else if (entry.id === "beta") {
|
||||||
|
releaseBeta!();
|
||||||
|
await alphaStarted;
|
||||||
|
}
|
||||||
|
const module = modules[entry.id];
|
||||||
|
if (!module) throw new Error(`no module for ${entry.id}`);
|
||||||
|
return module;
|
||||||
|
},
|
||||||
|
loadLocale: async () => null,
|
||||||
|
injectCss: () => null,
|
||||||
|
});
|
||||||
|
|
||||||
|
await syncPlugins();
|
||||||
|
|
||||||
|
expect(railIds()).toContain("alpha-panel");
|
||||||
|
expect(railIds()).toContain("beta-panel");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
Reference in new issue
Block a user