• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1// Copyright (C) 2018 The Android Open Source Project
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//      http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15import m from 'mithril';
16import {assertExists, assertTrue} from '../base/logging';
17import {PageAttrs} from './pages';
18
19export const ROUTE_PREFIX = '#!';
20const DEFAULT_ROUTE = '/';
21
22// A broken down representation of a route.
23// For instance: #!/record/gpu?local_cache_key=a0b1
24// becomes: {page: '/record', subpage: '/gpu', args: {local_cache_key: 'a0b1'}}
25export interface Route {
26  page: string;
27  subpage: string;
28  args: RouteArgs;
29}
30
31// The set of args that can be set on the route via #!/page?a=1&b2.
32// Route args are orthogonal to pages (i.e. should NOT make sense only in a
33// only within a specific page, use /page/subpages for that).
34// Args are !== the querystring (location.search) which is sent to the
35// server. The route args are NOT sent to the HTTP server.
36// Given this URL:
37// http://host/?foo=1&bar=2#!/page/subpage?local_cache_key=a0b1&baz=3.
38//
39// location.search = 'foo=1&bar=2'.
40//   This is seen by the HTTP server. We really don't use querystrings as the
41//   perfetto UI is client only.
42//
43// location.hash = '#!/page/subpage?local_cache_key=a0b1'.
44//   This is client-only. All the routing logic in the Perfetto UI uses only
45//   this.
46
47// This must be a type literial to avoid having to duplicate the
48// index type logic of Params.
49export type RouteArgs = {
50  // The local_cache_key is special and is persisted across navigations.
51  local_cache_key?: string;
52
53  // These are transient and are really set only on startup.
54  openFromAndroidBugTool?: string;
55  s?: string;    // For permalinks.
56  p?: string;    // DEPRECATED: for #!/record?p=cpu subpages (b/191255021).
57  url?: string;  // For fetching traces from Cloud Storage.
58};
59
60export interface RoutesMap {
61  [key: string]: m.Component<PageAttrs>;
62}
63
64// This router does two things:
65// 1) Maps fragment paths (#!/page/subpage) to Mithril components.
66// The route map is passed to the ctor and is later used when calling the
67// resolve() method.
68//
69// 2) Handles the (optional) args, e.g. #!/page?arg=1&arg2=2.
70// Route args are carry information that is orthogonal to the page (e.g. the
71// trace id).
72// local_cache_key has some special treatment: once a URL has a local_cache_key,
73// it gets automatically appended to further navigations that don't have one.
74// For instance if the current url is #!/viewer?local_cache_key=1234 and a later
75// action (either user-initiated or code-initited) navigates to #!/info, the
76// rotuer will automatically replace the history entry with
77// #!/info?local_cache_key=1234.
78// This is to keep propagating the trace id across page changes, for handling
79// tab discards (b/175041881).
80//
81// This class does NOT deal with the "load a trace when the url contains ?url=
82// or ?local_cache_key=". That logic lives in trace_url_handler.ts, which is
83// triggered by Router.onRouteChanged().
84export class Router {
85  private readonly recentChanges: number[] = [];
86  private routes: RoutesMap;
87
88  // frontend/index.ts calls maybeOpenTraceFromRoute() + redraw here.
89  // This event is decoupled for testing and to avoid circular deps.
90  onRouteChanged: (route: Route) => (void) = () => {};
91
92  constructor(routes: RoutesMap) {
93    assertExists(routes[DEFAULT_ROUTE]);
94    this.routes = routes;
95    window.onhashchange = (e: HashChangeEvent) => this.onHashChange(e);
96  }
97
98  private onHashChange(e: HashChangeEvent) {
99    this.crashIfLivelock();
100
101    const oldRoute = Router.parseUrl(e.oldURL);
102    const newRoute = Router.parseUrl(e.newURL);
103
104    if (newRoute.args.local_cache_key === undefined &&
105        oldRoute.args.local_cache_key) {
106      // Propagate `local_cache_key across` navigations. When a trace is loaded,
107      // the URL becomes #!/viewer?local_cache_key=123. `local_cache_key` allows
108      // reopening the trace from cache in the case of a reload or discard.
109      // When using the UI we can hit "bare" links (e.g. just '#!/info') which
110      // don't have the trace_uuid:
111      // - When clicking on an <a> element from the sidebar.
112      // - When the code calls Router.navigate().
113      // - When the user pastes a URL from docs page.
114      // In all these cases we want to keep propagating the `local_cache_key`.
115      // We do so by re-setting the `local_cache_key` and doing a
116      // location.replace which overwrites the history entry (note
117      // location.replace is NOT just a String.replace operation).
118      newRoute.args.local_cache_key = oldRoute.args.local_cache_key;
119    }
120
121    const args = m.buildQueryString(newRoute.args);
122    let normalizedFragment = `#!${newRoute.page}${newRoute.subpage}`;
123    normalizedFragment += args.length > 0 ? '?' + args : '';
124    if (!e.newURL.endsWith(normalizedFragment)) {
125      location.replace(normalizedFragment);
126      return;
127    }
128
129    this.onRouteChanged(newRoute);
130  }
131
132  // Returns the component for the current route in the URL.
133  // If no route matches the URL, returns a component corresponding to
134  // |this.defaultRoute|.
135  resolve(): m.Vnode<PageAttrs> {
136    const route = Router.parseFragment(location.hash);
137    let component = this.routes[route.page];
138    if (component === undefined) {
139      component = assertExists(this.routes[DEFAULT_ROUTE]);
140    }
141    return m(component, {subpage: route.subpage} as PageAttrs);
142  }
143
144  static navigate(newHash: string) {
145    assertTrue(newHash.startsWith(ROUTE_PREFIX));
146    window.location.hash = newHash;
147  }
148
149  // Breaks down a fragment into a Route object.
150  // Sample input:
151  // '#!/record/gpu?local_cache_key=abcd-1234'
152  // Sample output:
153  // {page: '/record', subpage: '/gpu', args: {local_cache_key: 'abcd-1234'}}
154  static parseFragment(hash: string): Route {
155    const prefixLength = ROUTE_PREFIX.length;
156    let route = '';
157    if (hash.startsWith(ROUTE_PREFIX)) {
158      route = hash.substring(prefixLength).split('?')[0];
159    }
160
161    let page = route;
162    let subpage = '';
163    const splittingPoint = route.indexOf('/', 1);
164    if (splittingPoint > 0) {
165      page = route.substring(0, splittingPoint);
166      subpage = route.substring(splittingPoint);
167    }
168
169    const argsStart = hash.indexOf('?');
170    const argsStr = argsStart < 0 ? '' : hash.substring(argsStart + 1);
171    const args = argsStr ? m.parseQueryString(hash.substring(argsStart)) : {};
172
173    return {page, subpage, args};
174  }
175
176  // Like parseFragment() but takes a full URL.
177  static parseUrl(url: string): Route {
178    const hashPos = url.indexOf('#');
179    const fragment = hashPos < 0 ? '' : url.substring(hashPos);
180    return Router.parseFragment(fragment);
181  }
182
183  // Throws if EVENT_LIMIT onhashchange events occur within WINDOW_MS.
184  private crashIfLivelock() {
185    const WINDOW_MS = 1000;
186    const EVENT_LIMIT = 20;
187    const now = Date.now();
188    while (this.recentChanges.length > 0 &&
189           now - this.recentChanges[0] > WINDOW_MS) {
190      this.recentChanges.shift();
191    }
192    this.recentChanges.push(now);
193    if (this.recentChanges.length > EVENT_LIMIT) {
194      throw new Error('History rewriting livelock');
195    }
196  }
197}
198