xref: /aosp_15_r20/external/perfetto/ui/src/core/router.ts (revision 6dbdd20afdafa5e3ca9b8809fa73465d530080dc)
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 {assertTrue} from '../base/logging';
17import {RouteArgs, ROUTE_SCHEMA} from '../public/route_schema';
18import {PageAttrs} from '../public/page';
19
20export const ROUTE_PREFIX = '#!';
21
22// The set of args that can be set on the route via #!/page?a=1&b2.
23// Route args are orthogonal to pages (i.e. should NOT make sense only in a
24// only within a specific page, use /page/subpages for that).
25// Args are !== the querystring (location.search) which is sent to the
26// server. The route args are NOT sent to the HTTP server.
27// Given this URL:
28// http://host/?foo=1&bar=2#!/page/subpage?local_cache_key=a0b1&baz=3.
29//
30// location.search = 'foo=1&bar=2'.
31//   This is seen by the HTTP server. We really don't use querystrings as the
32//   perfetto UI is client only.
33//
34// location.hash = '#!/page/subpage?local_cache_key=a0b1'.
35//   This is client-only. All the routing logic in the Perfetto UI uses only
36//   this.
37
38function safeParseRoute(rawRoute: unknown): RouteArgs {
39  const res = ROUTE_SCHEMA.safeParse(rawRoute);
40  return res.success ? res.data : {};
41}
42
43// A broken down representation of a route.
44// For instance: #!/record/gpu?local_cache_key=a0b1
45// becomes: {page: '/record', subpage: '/gpu', args: {local_cache_key: 'a0b1'}}
46export interface Route {
47  page: string;
48  subpage: string;
49  fragment: string;
50  args: RouteArgs;
51}
52
53export interface RoutesMap {
54  [key: string]: m.ComponentTypes<PageAttrs>;
55}
56
57// This router does two things:
58// 1) Maps fragment paths (#!/page/subpage) to Mithril components.
59// The route map is passed to the ctor and is later used when calling the
60// resolve() method.
61//
62// 2) Handles the (optional) args, e.g. #!/page?arg=1&arg2=2.
63// Route args are carry information that is orthogonal to the page (e.g. the
64// trace id).
65// local_cache_key has some special treatment: once a URL has a local_cache_key,
66// it gets automatically appended to further navigations that don't have one.
67// For instance if the current url is #!/viewer?local_cache_key=1234 and a later
68// action (either user-initiated or code-initited) navigates to #!/info, the
69// rotuer will automatically replace the history entry with
70// #!/info?local_cache_key=1234.
71// This is to keep propagating the trace id across page changes, for handling
72// tab discards (b/175041881).
73//
74// This class does NOT deal with the "load a trace when the url contains ?url=
75// or ?local_cache_key=". That logic lives in trace_url_handler.ts, which is
76// triggered by Router.onRouteChanged().
77export class Router {
78  private readonly recentChanges: number[] = [];
79
80  // frontend/index.ts calls maybeOpenTraceFromRoute() + redraw here.
81  // This event is decoupled for testing and to avoid circular deps.
82  onRouteChanged: (route: Route) => void = () => {};
83
84  constructor() {
85    window.onhashchange = (e: HashChangeEvent) => this.onHashChange(e);
86    const route = Router.parseUrl(window.location.href);
87    this.onRouteChanged(route);
88  }
89
90  private onHashChange(e: HashChangeEvent) {
91    this.crashIfLivelock();
92
93    const oldRoute = Router.parseUrl(e.oldURL);
94    const newRoute = Router.parseUrl(e.newURL);
95
96    if (
97      newRoute.args.local_cache_key === undefined &&
98      oldRoute.args.local_cache_key
99    ) {
100      // Propagate `local_cache_key across` navigations. When a trace is loaded,
101      // the URL becomes #!/viewer?local_cache_key=123. `local_cache_key` allows
102      // reopening the trace from cache in the case of a reload or discard.
103      // When using the UI we can hit "bare" links (e.g. just '#!/info') which
104      // don't have the trace_uuid:
105      // - When clicking on an <a> element from the sidebar.
106      // - When the code calls Router.navigate().
107      // - When the user pastes a URL from docs page.
108      // In all these cases we want to keep propagating the `local_cache_key`.
109      // We do so by re-setting the `local_cache_key` and doing a
110      // location.replace which overwrites the history entry (note
111      // location.replace is NOT just a String.replace operation).
112      newRoute.args.local_cache_key = oldRoute.args.local_cache_key;
113    }
114
115    const args = m.buildQueryString(newRoute.args);
116    let normalizedFragment = `#!${newRoute.page}${newRoute.subpage}`;
117    if (args.length) {
118      normalizedFragment += `?${args}`;
119    }
120    if (newRoute.fragment) {
121      normalizedFragment += `#${newRoute.fragment}`;
122    }
123
124    if (!e.newURL.endsWith(normalizedFragment)) {
125      location.replace(normalizedFragment);
126      return;
127    }
128
129    this.onRouteChanged(newRoute);
130  }
131
132  static navigate(newHash: string) {
133    assertTrue(newHash.startsWith(ROUTE_PREFIX));
134    window.location.hash = newHash;
135  }
136
137  // Breaks down a fragment into a Route object.
138  // Sample input:
139  // '#!/record/gpu?local_cache_key=abcd-1234#myfragment'
140  // Sample output:
141  // {
142  //  page: '/record',
143  //  subpage: '/gpu',
144  //  fragment: 'myfragment',
145  //  args: {local_cache_key: 'abcd-1234'}
146  // }
147  static parseFragment(hash: string): Route {
148    if (hash.startsWith(ROUTE_PREFIX)) {
149      hash = hash.substring(ROUTE_PREFIX.length);
150    } else {
151      hash = '';
152    }
153
154    const url = new URL(`https://example.com${hash}`);
155
156    const path = url.pathname;
157    let page = path;
158    let subpage = '';
159    const splittingPoint = path.indexOf('/', 1);
160    if (splittingPoint > 0) {
161      page = path.substring(0, splittingPoint);
162      subpage = path.substring(splittingPoint);
163    }
164    if (page === '/') {
165      page = '';
166    }
167
168    let rawArgs = {};
169    if (url.search) {
170      rawArgs = Router.parseQueryString(url.search);
171    }
172
173    const args = safeParseRoute(rawArgs);
174
175    // Javascript sadly distinguishes between foo[bar] === undefined
176    // and foo[bar] is not set at all. Here we need the second case to
177    // avoid making the URL ugly.
178    for (const key of Object.keys(args)) {
179      // eslint-disable-next-line @typescript-eslint/no-explicit-any
180      if ((args as any)[key] === undefined) {
181        // eslint-disable-next-line @typescript-eslint/no-explicit-any
182        delete (args as any)[key];
183      }
184    }
185
186    let fragment = url.hash;
187    if (fragment.startsWith('#')) {
188      fragment = fragment.substring(1);
189    }
190
191    return {page, subpage, args, fragment};
192  }
193
194  private static parseQueryString(query: string) {
195    query = query.replaceAll('+', ' ');
196    return m.parseQueryString(query);
197  }
198
199  private static parseSearchParams(url: string): RouteArgs {
200    const query = new URL(url).search;
201    const rawArgs = Router.parseQueryString(query);
202    const args = safeParseRoute(rawArgs);
203    return args;
204  }
205
206  // Like parseFragment() but takes a full URL.
207  static parseUrl(url: string): Route {
208    const searchArgs = Router.parseSearchParams(url);
209
210    const hashPos = url.indexOf('#');
211    const fragment = hashPos < 0 ? '' : url.substring(hashPos);
212    const route = Router.parseFragment(fragment);
213    route.args = Object.assign({}, searchArgs, route.args);
214
215    return route;
216  }
217
218  // Throws if EVENT_LIMIT onhashchange events occur within WINDOW_MS.
219  private crashIfLivelock() {
220    const WINDOW_MS = 1000;
221    const EVENT_LIMIT = 20;
222    const now = Date.now();
223    while (
224      this.recentChanges.length > 0 &&
225      now - this.recentChanges[0] > WINDOW_MS
226    ) {
227      this.recentChanges.shift();
228    }
229    this.recentChanges.push(now);
230    if (this.recentChanges.length > EVENT_LIMIT) {
231      throw new Error('History rewriting livelock');
232    }
233  }
234}
235