//
// Copyright 2025 DXOS.org
//
import { RegistryContext } from '@effect-rx/rx-react';
import { effect } from '@preact/signals-core';
import React, { useCallback, useEffect, useMemo, useState, type FC, type PropsWithChildren } from 'react';
import { invariant } from '@dxos/invariant';
import { live } from '@dxos/live-object';
import { useDefaultValue } from '@dxos/react-hooks';
import { Capabilities, Events } from './common';
import { PluginManager, type PluginManagerOptions, type Plugin } from './core';
import { topologicalSort } from './helpers';
import { ErrorBoundary, PluginManagerProvider, useCapabilities } from './react';
const ENABLED_KEY = 'dxos.org/app-framework/enabled';
export type CreateAppOptions = {
pluginManager?: PluginManager;
pluginLoader?: PluginManagerOptions['pluginLoader'];
plugins?: Plugin[];
core?: string[];
defaults?: string[];
placeholder?: FC<{ stage: number }>;
fallback?: ErrorBoundary['props']['fallback'];
cacheEnabled?: boolean;
safeMode?: boolean;
};
/**
* Expected usage is for this to be the entrypoint of the application.
* Initializes plugins and renders the root components.
*
* @example
* const plugins = [LayoutPlugin(), MyPlugin()];
* const core = [LayoutPluginId];
* const default = [MyPluginId];
* const fallback =
Initializing Plugins...
;
* const App = createApp({ plugins, core, default, fallback });
* createRoot(document.getElementById('root')!).render(
*
*
* ,
* );
*
* @param params.pluginLoader A function which loads new plugins.
* @param params.plugins All plugins available to the application.
* @param params.core Core plugins which will always be enabled.
* @param params.defaults Default plugins are enabled by default but can be disabled by the user.
* @param params.placeholder Placeholder component to render during startup.
* @param params.fallback Fallback component to render if an error occurs during startup.
* @param params.cacheEnabled Whether to cache enabled plugins in localStorage.
* @param params.safeMode Whether to enable safe mode, which disables optional plugins.
*/
export const useApp = ({
pluginManager,
pluginLoader: _pluginLoader,
plugins: _plugins,
core: _core,
defaults: _defaults,
placeholder,
fallback = DefaultFallback,
cacheEnabled = false,
safeMode = false,
}: CreateAppOptions) => {
const plugins = useDefaultValue(_plugins, () => []);
const core = useDefaultValue(_core, () => plugins.map(({ meta }) => meta.id));
const defaults = useDefaultValue(_defaults, () => []);
// TODO(wittjosiah): Provide a custom plugin loader which supports loading via url.
const pluginLoader = useMemo(
() =>
_pluginLoader ??
((id: string) => {
const plugin = plugins.find((plugin) => plugin.meta.id === id);
invariant(plugin, `Plugin not found: ${id}`);
return plugin;
}),
[_pluginLoader, plugins],
);
const state = useMemo(() => live({ ready: false, error: null }), []);
const cached: string[] = useMemo(() => JSON.parse(localStorage.getItem(ENABLED_KEY) ?? '[]'), []);
const enabled = useMemo(
() => (safeMode ? [] : cacheEnabled && cached.length > 0 ? cached : defaults),
[safeMode, cacheEnabled, cached, defaults],
);
const manager = useMemo(
() => pluginManager ?? new PluginManager({ pluginLoader, plugins, core, enabled }),
[pluginManager, pluginLoader, plugins, core, enabled],
);
useEffect(() => {
return manager.activation.on(({ event, state: _state, error }) => {
// Once the app is ready the first time, don't show the fallback again.
if (!state.ready && event === Events.Startup.id) {
state.ready = _state === 'activated';
}
if (error && !state.ready && !state.error) {
state.error = error;
}
});
}, [manager, state]);
useEffect(() => {
effect(() => {
cacheEnabled && localStorage.setItem(ENABLED_KEY, JSON.stringify(manager.enabled));
});
}, [cacheEnabled, manager]);
useEffect(() => {
manager.context.contributeCapability({
interface: Capabilities.PluginManager,
implementation: manager,
module: 'dxos.org/app-framework/plugin-manager',
});
manager.context.contributeCapability({
interface: Capabilities.RxRegistry,
implementation: manager.registry,
module: 'dxos.org/app-framework/rx-registry',
});
return () => {
manager.context.removeCapability(Capabilities.PluginManager, manager);
manager.context.removeCapability(Capabilities.RxRegistry, manager.registry);
};
}, [manager]);
useEffect(() => {
setupDevtools(manager);
}, [manager]);
useEffect(() => {
const timeout = setTimeout(async () => {
await Promise.all([
// TODO(wittjosiah): Factor out such that this could be called per surface role when attempting to render.
manager.activate(Events.SetupReactSurface),
manager.activate(Events.Startup),
]);
});
return () => clearTimeout(timeout);
}, [manager]);
return useCallback(
() => (
),
[fallback, manager, placeholder, state],
);
};
const DELAY_PLACEHOLDER = 2_000;
enum LoadingState {
Loading = 0,
FadeIn = 1,
FadeOut = 2,
Done = 3,
}
/**
* To avoid "flashing" the placeholder, we wait a period of time before starting the loading animation.
* If loading completes during this time the placehoder is not shown, otherwise is it displayed for a minimum period of time.
*
* States:
* 0: Loading - Wait for a period of time before starting the loading animation.
* 1: Fade-in - Display a loading animation.
* 2: Fade-out - Fade out the loading animation.
* 3: Done - Remove the placeholder.
*/
const useLoading = (state: AppProps['state']) => {
const [stage, setStage] = useState(LoadingState.Loading);
useEffect(() => {
const i = setInterval(() => {
setStage((tick) => {
switch (tick) {
case LoadingState.Loading:
if (!state.ready) {
return LoadingState.FadeIn;
} else {
clearInterval(i);
return LoadingState.Done;
}
case LoadingState.FadeIn:
if (state.ready) {
return LoadingState.FadeOut;
}
break;
case LoadingState.FadeOut:
clearInterval(i);
return LoadingState.Done;
}
return tick;
});
}, DELAY_PLACEHOLDER);
return () => clearInterval(i);
}, []);
return stage;
};
type AppProps = Pick & {
state: { ready: boolean; error: unknown };
};
const App = ({ placeholder: Placeholder, state }: AppProps) => {
const reactContexts = useCapabilities(Capabilities.ReactContext);
const reactRoots = useCapabilities(Capabilities.ReactRoot);
const stage = useLoading(state);
if (state.error) {
// This triggers the error boundary to provide UI feedback for the startup error.
throw state.error;
}
// TODO(wittjosiah): Consider using Suspense instead?
if (stage < LoadingState.Done) {
if (!Placeholder) {
return null;
}
return ;
}
const ComposedContext = composeContexts(reactContexts);
return (
{reactRoots.map(({ id, root: Component }) => (
))}
);
};
// Default fallback does not use tailwind or theme.
const DefaultFallback = ({ error }: { error: Error }) => {
return (
{/* TODO(wittjosiah): Link to docs for replacing default. */}
{error.message}
{error.stack}
);
};
const composeContexts = (contexts: Capabilities.ReactContext[]) => {
if (contexts.length === 0) {
return ({ children }: PropsWithChildren) => <>{children}>;
}
return topologicalSort(contexts)
.map(({ context }) => context)
.reduce((Acc, Next) => ({ children }) => (
{children}
));
};
const setupDevtools = (manager: PluginManager) => {
(globalThis as any).composer ??= {};
(globalThis as any).composer.manager = manager;
};