Skip to content
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

[discuss] Global state & URLs in the New Platform #39855

Closed
joshdover opened this issue Jun 27, 2019 · 5 comments
Closed

[discuss] Global state & URLs in the New Platform #39855

joshdover opened this issue Jun 27, 2019 · 5 comments
Labels
discuss Feature:New Platform Team:Core Core services & architecture: plugins, logging, config, saved objects, http, ES client, i18n, etc

Comments

@joshdover
Copy link
Contributor

joshdover commented Jun 27, 2019

In this issue I want to describe how shared global state is consumed and modified by applications, and how that relates to any state present in the current URL.

Related:

Context

In the legacy platform, there is ever only 1 plugin "running" at a time. Yes, there are exceptions, such as the "hacks" uiExport which allows any plugin to run some code on any page. But in general, a single plugin has control of the entire browsing context at a time.

Additionally, each legacy plugin runs in the context of ui/chrome's global Angular.js application, along with all of the global behaviors and goodies. This includes a single global router.

This design choice makes syncing UI state tricky:

  • Switching applications involves doing a full-page refresh, state cannot just be objects in memory.
  • Having a single global Angular.js app that all apps use makes it "convenient" for any of these globally configured Angular modules to modify global things like the browser's URL.
  • URL became the defacto way of sharing data across applications. Right now, each and every state change to global data like the timepicker or filter bar requires a listener that updates every single href in the navbar to have the same URL state.
  • This global state is an implicit contract that is easy to break.

What's different now

In the New Platform:

  • Every plugin is always running on the page.
  • Applications are distinct from plugins, and we can navigate between them without requiring a full page refresh.
  • The Core APIs are very minimal by design. We want to rely on as few global behaviors as possible in order to build a Kibana plugin. This allows us to innovate and experiment with new technologies and patterns easily.

This is all great, but at the end of the day, plugins still need to share UI state that is relevant to most plugins.

The proposal

Goals:

  • A pattern for reading and updating state. Plugins need a way to subscribe to and update shared global state.
  • A pattern for persisting state in the URL. This allows the user to bookmark or share a link to a page in a specific state.
  • A balance between good ergonomics and fewer unexpected behaviors. We want to minimize any magic that could create bugs in the future.

The basis of my proposal is that any "global state plugin", that is a plugin that manages state for other plugins (eg. timepicker), should expose functions for reading and writing state, but should never integrate automatically with any URL updates.

Data flow

Based on the Application RFC, the data flow of mounting an application looks like this:

  1. User navigates to http://localhost:5601/myBasePath/app/dashboard/dash/1234?timepicker=2019-01-01
  2. Kibana backend serves up the bootstrapping page which loads the core bundle (no plugin code)
  3. CoreSystem sets up, loads the bundles for each plugin, runs the setup and start function for each plugin
  4. The global router initialized and mounts the "dashboard" application that was registered by a plugin during the setup phase.
  5. The dashboard application starts its own router, based on the /myBasePath/app/dashboard "basename". (It's internal route would be /dash/1234?timepicker=2019-01-01).
  6. The dashboard app mounts the corresponding component for that route (let's say the <Dash /> component).
  7. The <Dash /> component uses the current URL to update the timepicker plugin's state.
  8. The <Dash /> component subscribes to future timepicker state updates.

Timepicker Updates

  1. The user sets the time window to 2020-05-05 via the timepicker UI.
  2. The timepicker emits a state update.
  3. The dashboard app receives the state update and updates the URL via a history.push() call on its router.

Switching apps

Let's say the user navigates to http://localhost:5601/myBasePath/app/apm:

  1. The global router unmounts the "dashboard" application and mounts the "apm" application.
  2. The apm application starts its own router, based on the /myBasePath/app/apm "basename". (It's internal route would be /).
  3. The apm app mounts the corresponding component for that route (let's say the <Home /> component).
  4. The <Home /> component doesn't see any timepicker query parameter, so it does not update the timepicker.
  5. The <Home /> component subscribes to timepicker state updates, which will start with previous state: 2020-05-05.

Full example

Here is a full example demonstrating the flow above. This example is the boilerplate-filled one without any of the ergonomics mentioned in the next section. This is meant to show explicitly how this all fits together.

The glue code for wiring up an application with a router:

import React from 'react';
import ReactDOM from 'react-dom';
import { BrowserRouter, Route } from 'react-router-dom';

import { Dash } from './dash';

const DashboardApp = ({ context }) => (
  // Setup router's basename from the basename provided from MountContext
  <BrowserRouter basename={context.basename}>

    {/* localhost:5601/app/dashboard/dash/42 */}
    <Route
      path="/dashboard/:id"
      render={(renderProps) => <Dash {...{context, ...renderProps}} />}
    />

  </BrowserRouter>,
);

export function renderApp(context, targetDomElement) {
  ReactDOM.render(
    <DashboardApp context={context} />,
    targetDomElem
  );

  return () => ReactDOM.unmountComponentAtNode(targetDomElem);
}

The component that does the state syncing with the URL:

import { TimepickerUi } from '../../timepicker';

class Dash extends React.Component {
  constructor(props) {
    super(props);
    this.state = { timepicker: props.timepicker.getState() };
  }

  componentDidMount() {
    // history and match props provided by react-router
    const { context, dashboardId, history, match } = this.props;
    const dashboardId = match.params.id;

    // Keeps the URL in sync
    this.timepickerSubcription = context.timepicker.update$.subscribe((state) => {
      this.setState({ timepicker: state });
      history.push(`/dash/${dashboardId}?${context.timepicker.serializeQueryParams()}`);
    });
  }

  componentDidUnmount() {
    this.timepickerSubscription.unsubscribe();
    delete this.timepickerSubscription;
  }

  render() {
    return <TimepickerUi state={this.state.timepicker} />
  }
}

Making this easy

There's many different ways that consuming and combining these different states can be made easier. Below are just some ideas.

Plugins that expose global behavior should provide utilities for serializing and deserializing query param state. For instance, the timepicker plugin could expose:

interface TimepickerStart {
  toParam(): string;
  fromParam(param: string): void;
}

One challenge with this is that multiple states need to be synced to the same query string. We could introduce an interface that plugins can implement to hook into a shared sync object:

interface ParamProvider {
  /** An Observable of serialized states */
  param$(): Observable<string>;
  /** A method for setting the state from a serialized query string */
  from(param: string): void;
}

class QueryParamSync {
  constructor(history: History);
  add(name: string, provider: ParamProvider): this {}
  start(): void {}
  stop(): void {}
}

class MyApp extends React.Component {
  constructor(props) {
    const { context: { timepicker, filterManager } } = this.props;
    this.paramSync = new QueryParamSync(history)
      .add('time', timepicker.getParamProvider())
      .add('filters', filterManager.getParamProvider())
  }

  componentDidMount() {
    this.paramSync.start();
  }

  componentDidUnmount() {
    this.paramsSync.stop();
  }
}

To make this even simpler, hooks or a HOC can be used to handle this syncing of state to the URL bar. Though this may seem like global syncing, it is not because each application must opt in explicitly, which avoids some of the bad behaviors listed above.

@joshdover joshdover added discuss Team:Core Core services & architecture: plugins, logging, config, saved objects, http, ES client, i18n, etc Feature:New Platform Team:AppArch labels Jun 27, 2019
@elasticmachine
Copy link
Contributor

Pinging @elastic/kibana-platform

@elasticmachine
Copy link
Contributor

Pinging @elastic/kibana-app-arch

@sebelga
Copy link
Contributor

sebelga commented Jul 9, 2019

  1. The component uses the current URL to update the timepicker plugin's state.

I was wondering: what if each plugin had a namespace for its state in URL (in this example above: timepicker and then dot concatenate the state to set (for example: date). So the full URL would be

http://localhost:5601/myBasePath/app/dashboard/dash/1234?timepicker.date=2019-01-01

From there, it would be the plugin's responsibility (here the Timepicker) to have the logic in place to read and subscribe to URL changes (through a HOC probably) and update its state. And all the other plugin (the consumers) would simply subscribe to the timepicker'state.

So the HOC that would wrap the Timepicker plugin would

  1. On mount, fetch all the url query params that start with "timepicker."
  2. Update the Timepicker state (through props probably)
  3. Subscribe to URL changes and update the state
  4. On unmount: stop listening to URL changes

The consumer of the plugin would simply:

  1. On mount: subscribe to timepicker state.
  2. On unmount : unsubscribe from timepicker state.

From what I understood, in your proposal if there are 2 or more timepicker consumer plugin on the page, they would all 3 read the URL and set the state on the Timepicker? @joshdover

@mshustov
Copy link
Contributor

mshustov commented Jul 9, 2019

The component subscribes to timepicker state updates, which will start with previous state: 2020-05-05.

How can user copy URL now and do not lose the date value?
Or we sync state with URL explicitly only when user initiates "Share via URL"?

@joshdover
Copy link
Contributor Author

I believe this discussion is complete now with the new State Sync service available. Closing, but feel free to reopen if there are unresolved issues.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
discuss Feature:New Platform Team:Core Core services & architecture: plugins, logging, config, saved objects, http, ES client, i18n, etc
Projects
None yet
Development

No branches or pull requests

4 participants