Handling Omnibox Input Entered Navigation
Handle Enter in an omnibox session correctly — the three dispositions, resolving free text to a safe URL, suggestion content versus typed text, actions that are not navigations, and restricted targets.
Table of Contents
onInputEntered fires when the user presses Enter or clicks a suggestion, with two arguments: the text and a disposition. The text is either the content of the suggestion they chose or whatever they typed if they pressed Enter on the default row — and it is user-controlled input about to become a URL. The disposition says whether they wanted the current tab, a new foreground tab or a new background tab. Getting both right is what makes the keyword feel like part of the browser rather than a toy. This guide is part of omnibox and address bar integration.
What arrives on Enter
Step-by-step
1. Honour the disposition
1async function openFor(url, disposition) {
2 switch (disposition) {
3 case "newForegroundTab": return chrome.tabs.create({ url, active: true });
4 case "newBackgroundTab": return chrome.tabs.create({ url, active: false });
5 default: return chrome.tabs.update({ url }); // currentTab
6 }
7}
8
9chrome.omnibox.onInputEntered.addListener(async (text, disposition) => {
10 const target = await resolve(text);
11 if (target.kind === "url") return openFor(target.url, disposition);
12 if (target.kind === "action") return runAction(target.action, target.arg);
13});
Execution context: the service worker, registered at the top level. tabs.update with no tab id updates the active tab in the current window, which is where the address bar the user typed into belongs. Ignoring the disposition — always opening a new tab, say — breaks the Alt+Enter habit users bring from every other address-bar interaction.
2. Distinguish chosen suggestions from typed text
Give suggestion content a recognisable shape so the handler knows it came from you.
1// in onInputChanged
2suggest(hits.map((h) => ({ content: `doc:${h.id}`, description: /* … */ "" })));
3
4// in onInputEntered
5async function resolve(text) {
6 if (text.startsWith("doc:")) {
7 const doc = (await getIndex()).find((d) => d.id === text.slice(4));
8 if (doc) return { kind: "url", url: doc.url };
9 }
10 if (text.startsWith("action:")) {
11 const [, action, arg] = text.split(":");
12 return { kind: "action", action, arg };
13 }
14 return resolveTyped(text);
15}
Execution context: the service worker. Using an id rather than a raw URL as content means a stale suggestion resolves against the current index and cannot be used to smuggle an arbitrary URL — a user who edits the text before pressing Enter produces something that does not start with doc:, and falls through to the typed-text path.
3. Resolve typed text safely
The typed-text path handles input you did not generate. It must never become a navigation to whatever the user — or something that pasted into the address bar — wrote.
1const SEARCH = "https://docs.example.com/search?q=";
2
3function resolveTyped(text) {
4 const q = text.trim();
5 if (!q) return { kind: "url", url: "https://docs.example.com/" };
6
7 // An exact title match goes straight to the page.
8 const exact = indexByTitle.get(q.toLowerCase());
9 if (exact) return { kind: "url", url: exact.url };
10
11 // Anything else becomes a search on your own site — never a raw navigation.
12 return { kind: "url", url: SEARCH + encodeURIComponent(q) };
13}
Execution context: the service worker. Encoding the query and prefixing your own search URL means javascript:alert(1) or file:///etc/passwd typed after the keyword becomes a harmless search. If the extension genuinely supports “go to this URL”, validate with new URL() and allow only https: — the same rule as in sanitising untrusted page data in an extension.
4. Support actions that are not navigations
Some omnibox extensions do things rather than go places — create a note, add a task, start a timer. Those still need to acknowledge the user, because the address bar gives no feedback of its own.
1async function runAction(action, arg) {
2 if (action === "note") {
3 await addNote(decodeURIComponent(arg ?? ""));
4 await chrome.action.setBadgeText({ text: "✓" });
5 chrome.alarms.create("clear-badge", { delayInMinutes: 1 });
6 }
7}
Execution context: the service worker. After Enter, the address bar reverts to the current page’s URL; without the badge, the user cannot tell whether the note was saved. Clearing the badge with an alarm rather than a timer is the pattern from alarms vs setTimeout in service workers.
5. Handle targets the extension cannot open
tabs.update to a restricted URL — a chrome:// page, another extension’s page — fails. If your index can contain such targets, catch the failure rather than leaving the user on an unchanged page with no explanation.
1try {
2 await openFor(target.url, disposition);
3} catch (err) {
4 await chrome.tabs.create({ url: chrome.runtime.getURL(`pages/cannot-open.html?u=${encodeURIComponent(target.url)}`) });
5}
Execution context: the service worker. An explanatory extension page is better than silence. Most documentation or link-shortener extensions never meet this, but internal tools that index browser settings pages will.
Learning from what users choose
Every onInputEntered is a small signal about what the user wanted, and folding it back into ranking makes the keyword noticeably better within a few days of use — without any data leaving the device.
1chrome.omnibox.onInputEntered.addListener(async (text) => {
2 if (!text.startsWith("doc:")) return;
3 const id = text.slice(4);
4 const { picks = {} } = await chrome.storage.local.get("picks");
5 picks[id] = (picks[id] ?? 0) + 1;
6 await chrome.storage.local.set({ picks });
7});
Execution context: the service worker, as a second listener on the same event — both run. The ranking function reads picks as a popularity term, so a page this user opens often rises above an equally good textual match they never choose.
Two refinements keep it well-behaved. Decay the counts over time — halving them monthly — so a page the user needed heavily for one week does not dominate forever. And cap the stored map to the few hundred most-picked ids, so it stays small enough to read on every query. The ranking side is described in providing omnibox suggestions asynchronously.
Cross-browser variation
- Chrome / Edge:
onInputEntered(text, disposition)withcurrentTab,newForegroundTabandnewBackgroundTab.tabs.updatewithout a tab id targets the active tab of the current window. - Firefox:
browser.omnibox.onInputEnteredwith the same three dispositions. Firefox’s address bar may pass typed text that includes the keyword in some edge cases — trim defensively. - Safari: no omnibox. The same
resolvefunction can serve a popup search box’s Enter handler. - All three: navigation to restricted URLs fails. Validate typed input and route unknown text to your own search rather than navigating to it.
Verification
- Press Enter, Alt+Enter and middle-click on a suggestion; confirm current tab, new foreground tab and new background tab respectively.
- Type
javascript:alert(1)after the keyword and press Enter; confirm you land on your search page with that string as the query. - Check the recorded picks after a few uses:
1(await chrome.storage.local.get("picks")).picks;
2// { alarms: 3, "service-worker": 1 }
Execution context: the service worker console. The keys should be ids from your index, never raw URLs or typed text.
- Run an
action:suggestion and confirm the badge acknowledges it.
FAQ
Why does the default row sometimes pass the keyword in text?
It should not in current Chrome, but some engine and version combinations have. Strip a leading keyword and whitespace defensively in resolveTyped.
Can Enter open the extension’s popup instead of navigating?
No — action.openPopup is gesture-restricted and the omnibox Enter does not count in every version. Open an extension page in a tab instead.
Should typed URLs be allowed at all?
Only if the extension’s purpose is navigation, and then only https: URLs after validation. Most search extensions are better off always routing typed text to their own search.
Related
- Providing omnibox suggestions asynchronously — where the
contentvalues come from. - Opening and tracking extension pages in tabs — opening your own pages from Enter.
- Handling restricted URLs and tab permissions — targets that cannot be opened.
- Omnibox and address bar integration — the parent guide.