-
Notifications
You must be signed in to change notification settings - Fork 8.3k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
[RFC] ApplicationService mounting #36477
Merged
Merged
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
5ec0bcf
[RFC] ApplicationService mounting
joshdover 0af7534
Update for Handler RFC
joshdover f7360cc
Make example use a dynamic import
joshdover d0c318f
Update context interface, add a complete example
joshdover 641e5de
RFC comments
joshdover File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,327 @@ | ||
- Start Date: 2019-05-10 | ||
- RFC PR: (leave this empty) | ||
- Kibana Issue: (leave this empty) | ||
|
||
# Summary | ||
|
||
A front-end service to manage registration and root-level routing for | ||
first-class applications. | ||
|
||
# Basic example | ||
|
||
|
||
```tsx | ||
// my_plugin/public/application.js | ||
|
||
import React from 'react'; | ||
import ReactDOM from 'react-dom'; | ||
|
||
import { MyApp } from './componnets'; | ||
|
||
export function renderApp(context, targetDomElement) { | ||
ReactDOM.render( | ||
<MyApp mountContext={context} deps={pluginStart} />, | ||
targetDomElement | ||
); | ||
|
||
return () => { | ||
ReactDOM.unmountComponentAtNode(targetDomElement); | ||
}; | ||
} | ||
``` | ||
|
||
```tsx | ||
// my_plugin/public/plugin.js | ||
|
||
class MyPlugin { | ||
setup({ application }) { | ||
application.register({ | ||
id: 'my-app', | ||
title: 'My Application', | ||
async mount(context, targetDomElement) { | ||
const { renderApp } = await import('./applcation'); | ||
return renderApp(context, targetDomElement); | ||
} | ||
}); | ||
} | ||
} | ||
``` | ||
|
||
# Motivation | ||
|
||
By having centralized management of applications we can have a true single page | ||
application. It also gives us a single place to enforce authorization and/or | ||
licensing constraints on application access. | ||
|
||
By making the mounting interface of the ApplicationService generic, we can | ||
support many different rendering technologies simultaneously to avoid framework | ||
lock-in. | ||
|
||
# Detailed design | ||
|
||
## Interface | ||
|
||
```ts | ||
/** A context type that implements the Handler Context pattern from RFC-0003 */ | ||
export interface MountContext { | ||
/** This is the base path for setting up your router. */ | ||
basename: string; | ||
/** These services serve as an example, but are subject to change. */ | ||
core: { | ||
http: { | ||
fetch(...): Promise<any>; | ||
}; | ||
i18n: { | ||
translate( | ||
id: string, | ||
defaultMessage: string, | ||
values?: Record<string, string> | ||
): string; | ||
}; | ||
notifications: { | ||
toasts: { | ||
add(...): void; | ||
}; | ||
}; | ||
overlays: { | ||
showFlyout(render: (domElement) => () => void): Flyout; | ||
showModal(render: (domElement) => () => void): Modal; | ||
}; | ||
uiSettings: { ... }; | ||
}; | ||
/** Other plugins can inject context by registering additional context providers */ | ||
[contextName: string]: unknown; | ||
} | ||
|
||
export type Unmount = () => Promise<void> | void; | ||
|
||
export interface AppSpec { | ||
/** | ||
* A unique identifier for this application. Used to build the route for this | ||
* application in the browser. | ||
*/ | ||
id: string; | ||
|
||
/** | ||
* The title of the application. | ||
*/ | ||
title: string; | ||
|
||
/** | ||
* A mount function called when the user navigates to this app's route. | ||
* @param context the `MountContext generated for this app | ||
* @param targetDomElement An HTMLElement to mount the application onto. | ||
* @returns An unmounting function that will be called to unmount the application. | ||
*/ | ||
mount(context: MountContext, targetDomElement: HTMLElement): Unmount | Promise<Unmount>; | ||
|
||
/** | ||
* A EUI iconType that will be used for the app's icon. This icon | ||
* takes precendence over the `icon` property. | ||
*/ | ||
euiIconType?: string; | ||
|
||
/** | ||
* A URL to an image file used as an icon. Used as a fallback | ||
* if `euiIconType` is not provided. | ||
*/ | ||
icon?: string; | ||
|
||
/** | ||
* Custom capabilities defined by the app. | ||
*/ | ||
capabilities?: Partial<Capabilities>; | ||
} | ||
|
||
export interface ApplicationSetup { | ||
/** | ||
* Registers an application with the system. | ||
*/ | ||
register(app: AppSpec): void; | ||
registerMountContext<T extends keyof MountContext>( | ||
contextName: T, | ||
provider: (context: Partial<MountContext>) => MountContext[T] | Promise<MountContext[T]> | ||
): void; | ||
} | ||
|
||
export interface ApplicationStart { | ||
/** | ||
* The UI capabilities for the current user. | ||
*/ | ||
capabilities: Capabilties; | ||
} | ||
``` | ||
|
||
## Mounting | ||
|
||
When an app is registered via `register`, it must provide a `mount` function | ||
that will be invoked whenever the window's location has changed from another app | ||
to this app. | ||
|
||
This function is called with a `MountContext` and an `HTMLElement` for the | ||
application to render itself to. The mount function must also return a function | ||
that can be called by the ApplicationService to unmount the application at the | ||
given DOM node. The mount function may return a Promise of an unmount function | ||
in order to import UI code dynamically. | ||
|
||
The ApplicationService's `register` method will only be available during the | ||
*setup* lifecycle event. This allows the system to know when all applications | ||
have been registered. | ||
|
||
The `mount` function will also get access to the `MountContext` that has many of | ||
the same core services available during the `start` lifecycle. Plugins can also | ||
register additional context attributes via the `registerMountContext` function. | ||
|
||
## Routing | ||
|
||
The ApplicationService will serve as the global frontend router for Kibana, | ||
enabling Kibana to be a 100% single page application. However, the router will | ||
only manage top-level routes. Applications themselves will need to implement | ||
their own routing as subroutes of the top-level route. | ||
|
||
An example: | ||
- "MyApp" is registered with `id: 'my-app'` | ||
- User navigates from mykibana.com/app/home to mykibana.com/app/my-app | ||
- ApplicationService sees the root app has changed and mounts the new | ||
application: | ||
- Calls the `Unmount` function returned my "Home"'s `mount` | ||
- Calls the `mount` function registered by "MyApp" | ||
- MyApp's internal router takes over rest of routing. Redirects to initial | ||
"overview" page: mykibana.com/app/my-app/overview | ||
|
||
When setting up a router, your application should only handle the part of the | ||
URL following the `context.basename` provided when you application is mounted. | ||
|
||
### Legacy Applications | ||
|
||
In order to introduce this service now, the ApplicationService will need to be | ||
able to handle "routing" to legacy applications. We will not be able to run | ||
multiple legacy applications on the same page load due to shared stateful | ||
modules in `ui/public`. | ||
|
||
Instead, the ApplicationService should do a full-page refresh when rendering | ||
legacy applications. Internally, this will be managed by registering legacy apps | ||
with the ApplicationService separately and handling those top-level routes by | ||
starting a full-page refresh rather than a mounting cycle. | ||
|
||
## Complete Example | ||
|
||
Here is a complete example that demonstrates rendering a React application with | ||
a full-featured router and code-splitting. Note that using React or any other | ||
3rd party tools featured here is not required to build a Kibana Application. | ||
|
||
```tsx | ||
// my_plugin/public/application.ts | ||
|
||
import React from 'react'; | ||
import ReactDOM from 'react-dom'; | ||
import { BrowserRouter, Route } from 'react-router-dom'; | ||
import loadable from '@loadable/component'; | ||
|
||
// Apps can choose to load components statically in the same bundle or | ||
// dynamically when routes are rendered. | ||
import { HomePage } from './pages'; | ||
const LazyDashboard = loadable(() => import('./pages/dashboard')); | ||
|
||
const MyApp = ({ basename }) => ( | ||
// Setup router's basename from the basename provided from MountContext | ||
<BrowserRouter basename={basename}> | ||
|
||
{/* mykibana.com/app/my-app/ */} | ||
<Route path="/" exact component={HomePage} /> | ||
|
||
{/* mykibana.com/app/my-app/dashboard/42 */} | ||
<Route | ||
path="/dashboard/:id" | ||
render={({ match }) => <LazyDashboard dashboardId={match.params.id} />} | ||
/> | ||
|
||
</BrowserRouter>, | ||
); | ||
|
||
export function renderApp(context, targetDomElement) { | ||
ReactDOM.render( | ||
// `context.basename` would be `/app/my-app` in this example. | ||
// This exact string is not guaranteed to be stable, always reference | ||
// `context.basename`. | ||
<MyApp basename={context.basename} />, | ||
targetDomElem | ||
); | ||
|
||
return () => ReactDOM.unmountComponentAtNode(targetDomElem); | ||
} | ||
``` | ||
|
||
```tsx | ||
// my_plugin/public/plugin.tsx | ||
|
||
export class MyPlugin { | ||
setup({ application }) { | ||
application.register({ | ||
id: 'my-app', | ||
async mount(context, targetDomElem) { | ||
const { renderApp } = await import('./applcation'); | ||
return renderApp(context, targetDomElement); | ||
} | ||
}); | ||
} | ||
} | ||
``` | ||
|
||
## Core Entry Point | ||
|
||
Once we can support application routing for new and legacy applications, we | ||
should create a new entry point bundle that only includes Core and any necessary | ||
uiExports (hacks for example). This should be served by the backend whenever a | ||
`/app/<app-id>` request is received for an app that the legacy platform does not | ||
have a bundle for. | ||
|
||
# Drawbacks | ||
|
||
- Implementing this will be significant work and requires migrating legacy code | ||
from `ui/chrome` | ||
- Making Kibana a single page application may lead to problems if applications | ||
do not clean themselves up properly when unmounted | ||
- Application `mount` functions will have access to *setup* via the closure. We | ||
may want to lock down these APIs from being used after *setup* to encourage | ||
usage of the `MountContext` instead. | ||
- In order to support new applications being registered in the legacy platform, | ||
we will need to create a new `uiExport` that is imported during the new | ||
platform's *setup* lifecycle event. This is necessary because app registration | ||
must happen prior to starting the legacy platform. This is only an issue for | ||
plugins that are migrating using a shim in the legacy platform. | ||
|
||
# Alternatives | ||
|
||
- We could provide a full featured react-router instance that plugins could | ||
plug directly into. The downside is this locks us more into React and makes | ||
code splitting a bit more challenging. | ||
|
||
# Adoption strategy | ||
|
||
Adoption of the application service will have to happen as part of the migration | ||
of each plugin. We should be able to support legacy plugins registering new | ||
platform-style applications before they actually move all of their code | ||
over to the new platform. | ||
|
||
# How we teach this | ||
|
||
Introducing this service makes applications a first-class feature of the Kibana | ||
platform. Right now, plugins manage their own routes and can export "navlinks" | ||
that get rendered in the navigation UI, however there is a not a self-contained | ||
concept like an application to encapsulate these related responsibilities. It | ||
will need to be emphasized that plugins can register zero, one, or multiple | ||
applications. | ||
|
||
Most new and existing Kibana developers will need to understand how the | ||
ApplicationService works and how multiple apps run in a single page application. | ||
This should be accomplished through thorough documentation in the | ||
ApplicationService's API implementation as well as in general plugin development | ||
tutorials and documentation. | ||
|
||
# Unresolved questions | ||
|
||
- Are there any major caveats to having multiple routers on the page? If so, how | ||
can these be prevented or worked around? | ||
- How should global URL state be shared across applications, such as timepicker | ||
state? |
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We might be able to work around this by using this instead:
We could even use eslint to validate that this function is doing nothing other than importing the module and returning the promise (maybe we would want a more unique/identifiable name to assist with this). The application service would then need to call the
mount
/renderApp
function exported by the app module, and since the module is completely outside the scope of the closure it can't reuse the start context.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I considered this approach but some downsides to this:
As the API surface of setup shrinks to only registration APIs, I'm less concerned about this closure issue. I'm going to proceed as is, but open to update this RFC before 8.0 if we start running into issues here.