ui: Improve router args parsing
1. Parse repeated search params in URL as arrays:
E.g.
?foo=bar&foo=baz -> {foo: ['bar', 'baz']}
2. Parse params with no value as boolean true
E.g.
?foo&bar -> {foo: true, bar: true}
diff --git a/ui/src/base/route_parser.ts b/ui/src/base/route_parser.ts
new file mode 100644
index 0000000..4fb78db
--- /dev/null
+++ b/ui/src/base/route_parser.ts
@@ -0,0 +1,80 @@
+// Copyright (C) 2025 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+export type ParsedUrlParams = {
+ [key: string]: string | boolean | (string | boolean)[];
+};
+
+/**
+ * Parses URL search parameters with special handling for repeated arguments
+ * and valueless arguments.
+ * - Repeated search arguments are treated as an array of strings.
+ * - Arguments without a value (e.g. "?foo") are treated as a boolean `true`.
+ *
+ * Example:
+ * `?foo=bar&baz&baz=qux&a=1&a=2` will be parsed as:
+ * `{foo: 'bar', baz: ['', 'qux'], a: ['1', '2']}`
+ * A valueless parameter is only treated as true if it is not repeated.
+ * e.g. `?foo` is parsed as `{foo: true}`.
+ */
+export function parseUrlSearchParams(params: URLSearchParams): ParsedUrlParams {
+ const result: ParsedUrlParams = {};
+ const keys = new Set<string>();
+ for (const key of params.keys()) {
+ keys.add(key);
+ }
+
+ const convert = (s: string): string | boolean => {
+ if (s === '') return true;
+ if (s === 'true') return true;
+ if (s === 'false') return false;
+ return s;
+ };
+
+ for (const key of keys) {
+ const values = params.getAll(key);
+ if (values.length > 1) {
+ result[key] = values.map(convert);
+ } else {
+ result[key] = convert(values[0]);
+ }
+ }
+ return result;
+}
+
+export function buildUrlSearchParams(params: ParsedUrlParams): string {
+ const parts: string[] = [];
+ for (const key of Object.keys(params).sort()) {
+ const value = params[key];
+ if (value === true) {
+ parts.push(encodeURIComponent(key));
+ } else if (value === false) {
+ parts.push(`${encodeURIComponent(key)}=false`);
+ } else if (typeof value === 'string') {
+ parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(value)}`);
+ } else if (Array.isArray(value)) {
+ for (const item of value) {
+ const encodedKey = encodeURIComponent(key);
+ if (item === true || item === '') {
+ parts.push(encodedKey);
+ } else if (item === false) {
+ parts.push(`${encodedKey}=false`);
+ } else {
+ parts.push(`${encodedKey}=${encodeURIComponent(item as string)}`);
+ }
+ }
+ }
+ }
+ return parts.join('&');
+}
diff --git a/ui/src/base/route_parser_unittest.ts b/ui/src/base/route_parser_unittest.ts
new file mode 100644
index 0000000..5a3b40b
--- /dev/null
+++ b/ui/src/base/route_parser_unittest.ts
@@ -0,0 +1,155 @@
+// Copyright (C) 2025 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import {buildUrlSearchParams, parseUrlSearchParams} from './route_parser';
+
+describe('parseUrlSearchParams', () => {
+ it('handles empty params', () => {
+ const params = new URLSearchParams('');
+ expect(parseUrlSearchParams(params)).toEqual({});
+ });
+
+ it('handles single value params', () => {
+ const params = new URLSearchParams('foo=bar&baz=qux');
+ expect(parseUrlSearchParams(params)).toEqual({
+ foo: 'bar',
+ baz: 'qux',
+ });
+ });
+
+ it('handles repeated params', () => {
+ const params = new URLSearchParams('foo=bar&foo=baz');
+ expect(parseUrlSearchParams(params)).toEqual({
+ foo: ['bar', 'baz'],
+ });
+ });
+
+ it('handles valueless params as true', () => {
+ const params = new URLSearchParams('foo&bar=baz');
+ expect(parseUrlSearchParams(params)).toEqual({
+ foo: true,
+ bar: 'baz',
+ });
+ });
+
+ it('handles true string as boolean', () => {
+ const params = new URLSearchParams('foo=true&bar=baz');
+ expect(parseUrlSearchParams(params)).toEqual({
+ foo: true,
+ bar: 'baz',
+ });
+ });
+
+ it('handles false string as boolean', () => {
+ const params = new URLSearchParams('foo=false&bar=baz');
+ expect(parseUrlSearchParams(params)).toEqual({
+ foo: false,
+ bar: 'baz',
+ });
+ });
+
+ it('handles mixed params', () => {
+ const params = new URLSearchParams('a=1&b&c=2&c=3&d=true&e=false');
+ expect(parseUrlSearchParams(params)).toEqual({
+ a: '1',
+ b: true,
+ c: ['2', '3'],
+ d: true,
+ e: false,
+ });
+ });
+
+ it('handles valueless repeated params', () => {
+ const params = new URLSearchParams('foo&foo');
+ expect(parseUrlSearchParams(params)).toEqual({
+ foo: [true, true],
+ });
+ });
+
+ it('handles mixed valueless and valued repeated params', () => {
+ const params = new URLSearchParams('foo&foo=bar');
+ expect(parseUrlSearchParams(params)).toEqual({
+ foo: [true, 'bar'],
+ });
+ });
+
+ it('handles mixed booleans in repeated params', () => {
+ const params = new URLSearchParams('foo=true&foo=bar&foo=false');
+ expect(parseUrlSearchParams(params)).toEqual({
+ foo: [true, 'bar', false],
+ });
+ });
+});
+
+describe('buildUrlSearchParams', () => {
+ it('handles empty params', () => {
+ expect(buildUrlSearchParams({})).toEqual('');
+ });
+
+ it('handles single value params', () => {
+ expect(buildUrlSearchParams({foo: 'bar', baz: 'qux'})).toEqual(
+ 'baz=qux&foo=bar',
+ );
+ });
+
+ it('handles repeated params', () => {
+ expect(buildUrlSearchParams({foo: ['bar', 'baz']})).toEqual(
+ 'foo=bar&foo=baz',
+ );
+ });
+
+ it('handles valueless params as true', () => {
+ expect(buildUrlSearchParams({foo: true, bar: 'baz'})).toEqual(
+ 'bar=baz&foo',
+ );
+ });
+
+ it('handles boolean false', () => {
+ expect(buildUrlSearchParams({foo: false, bar: 'baz'})).toEqual(
+ 'bar=baz&foo=false',
+ );
+ });
+
+ it('handles mixed params', () => {
+ expect(
+ buildUrlSearchParams({
+ a: '1',
+ b: true,
+ c: ['2', '3'],
+ d: true,
+ e: false,
+ }),
+ ).toEqual('a=1&b&c=2&c=3&d&e=false');
+ });
+
+ it('handles valueless repeated params', () => {
+ expect(buildUrlSearchParams({foo: [true, true]})).toEqual('foo&foo');
+ });
+
+ it('handles mixed valueless and valued repeated params', () => {
+ expect(buildUrlSearchParams({foo: [true, 'bar']})).toEqual('foo&foo=bar');
+ });
+
+ it('handles mixed booleans in repeated params', () => {
+ expect(buildUrlSearchParams({foo: [true, 'bar', false]})).toEqual(
+ 'foo&foo=bar&foo=false',
+ );
+ });
+
+ it('handles special characters', () => {
+ expect(buildUrlSearchParams({a: ' ', b: '&', c: ['=', '?']})).toEqual(
+ 'a=%20&b=%26&c=%3D&c=%3F',
+ );
+ });
+});
diff --git a/ui/src/core/app_impl.ts b/ui/src/core/app_impl.ts
index 4fed138..28043e3 100644
--- a/ui/src/core/app_impl.ts
+++ b/ui/src/core/app_impl.ts
@@ -209,7 +209,7 @@
export class AppImpl implements App {
readonly pluginId: string;
- readonly initialPluginRouteArgs: RouteArgs;
+ readonly initialPluginRouteArgs: {[key: string]: RouteArg | undefined};
private readonly appCtx: AppContext;
private readonly pageMgrProxy: PageManagerImpl;
diff --git a/ui/src/core/router.ts b/ui/src/core/router.ts
index 9bd6451..9e763a3 100644
--- a/ui/src/core/router.ts
+++ b/ui/src/core/router.ts
@@ -12,7 +12,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.
-import m from 'mithril';
+import {buildUrlSearchParams, parseUrlSearchParams} from '../base/route_parser';
import {assertTrue} from '../base/logging';
import {RouteArgs, ROUTE_SCHEMA} from '../public/route_schema';
@@ -107,7 +107,7 @@
newRoute.args.local_cache_key = oldRoute.args.local_cache_key;
}
- const args = m.buildQueryString(newRoute.args);
+ const args = buildUrlSearchParams(newRoute.args);
let normalizedFragment = `#!${newRoute.page}${newRoute.subpage}`;
if (args.length) {
normalizedFragment += `?${args}`;
@@ -162,7 +162,7 @@
let rawArgs = {};
if (url.search) {
- rawArgs = Router.parseQueryString(url.search);
+ rawArgs = parseUrlSearchParams(url.searchParams);
}
const args = safeParseRoute(rawArgs);
@@ -186,14 +186,9 @@
return {page, subpage, args, fragment};
}
- private static parseQueryString(query: string) {
- query = query.replaceAll('+', ' ');
- return m.parseQueryString(query);
- }
-
private static parseSearchParams(url: string): RouteArgs {
- const query = new URL(url).search;
- const rawArgs = Router.parseQueryString(query);
+ const searchParams = new URL(url).searchParams;
+ const rawArgs = parseUrlSearchParams(searchParams);
const args = safeParseRoute(rawArgs);
return args;
}
diff --git a/ui/src/frontend/index.ts b/ui/src/frontend/index.ts
index 57f4da3..984e0f6 100644
--- a/ui/src/frontend/index.ts
+++ b/ui/src/frontend/index.ts
@@ -415,6 +415,9 @@
NON_CORE_PLUGINS.forEach((p) => pluginManager.registerPlugin(p));
const route = Router.parseUrl(window.location.href);
const overrides = (route.args.enablePlugins ?? '').split(',');
+
+ console.log(route.args);
+
pluginManager.activatePlugins(overrides);
}
diff --git a/ui/src/plugins/dev.perfetto.AutoPinAndExpandTracks/index.ts b/ui/src/plugins/dev.perfetto.AutoPinAndExpandTracks/index.ts
index f9dbd17..2fe4e52 100644
--- a/ui/src/plugins/dev.perfetto.AutoPinAndExpandTracks/index.ts
+++ b/ui/src/plugins/dev.perfetto.AutoPinAndExpandTracks/index.ts
@@ -34,10 +34,15 @@
function getParamValues(param: RouteArg | undefined): string[] {
if (typeof param === 'boolean') return [];
if (param === undefined) return [];
-
- const trimmed = param.trim();
- if (trimmed === '') return [];
- return [trimmed];
+ if (typeof param === 'string') {
+ const trimmed = param.trim();
+ if (trimmed === '') return [];
+ return [trimmed];
+ }
+ return param
+ .filter((p) => typeof p === 'string')
+ .map((p) => p.trim())
+ .filter((p) => p !== '');
}
/**
diff --git a/ui/src/public/route_schema.ts b/ui/src/public/route_schema.ts
index fac02dc..177d187 100644
--- a/ui/src/public/route_schema.ts
+++ b/ui/src/public/route_schema.ts
@@ -16,7 +16,8 @@
// Args are parsed by the Router into strings or booleans only.
// E.g. ?hideSidebar=true&url=a?b?c -> {hideSidebar: true, url: 'a?b?c'}
-const argSchema = z.union([z.string(), z.boolean()]);
+const stringOrBool = z.union([z.string(), z.boolean()]);
+const argSchema = z.union([stringOrBool, z.array(stringOrBool)]);
export type RouteArg = z.infer<typeof argSchema>;
// We use .catch(undefined) on every field below to make sure that passing an