1 /* This Source Code Form is subject to the terms of the Mozilla Public
2 * License, v. 2.0. If a copy of the MPL was not distributed with this
3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
6 * This module exports a component used to sort results in a UrlbarQueryContext.
12 } from "resource:///modules/UrlbarUtils.sys.mjs";
16 ChromeUtils.defineESModuleGetters(lazy, {
17 QuickSuggest: "resource:///modules/QuickSuggest.sys.mjs",
18 UrlbarPrefs: "resource:///modules/UrlbarPrefs.sys.mjs",
19 UrlbarProviderOpenTabs: "resource:///modules/UrlbarProviderOpenTabs.sys.mjs",
20 UrlbarProviderQuickSuggest:
21 "resource:///modules/UrlbarProviderQuickSuggest.sys.mjs",
22 UrlbarProviderTabToSearch:
23 "resource:///modules/UrlbarProviderTabToSearch.sys.mjs",
24 UrlbarSearchUtils: "resource:///modules/UrlbarSearchUtils.sys.mjs",
27 ChromeUtils.defineLazyGetter(lazy, "logger", () =>
28 UrlbarUtils.getLogger({ prefix: "MuxerUnifiedComplete" })
31 const MS_PER_DAY = 1000 * 60 * 60 * 24;
34 * Constructs the map key by joining the url with the userContextId if
35 * 'browser.urlbar.switchTabs.searchAllContainers' is set to true.
36 * Otherwise, just the url is used.
38 * @param {UrlbarResult} result The result object.
39 * @returns {string} map key
41 function makeMapKeyForTabResult(result) {
42 return UrlbarUtils.tupleString(
44 lazy.UrlbarPrefs.get("switchTabs.searchAllContainers") &&
45 result.type == UrlbarUtils.RESULT_TYPE.TAB_SWITCH &&
46 lazy.UrlbarProviderOpenTabs.isNonPrivateUserContextId(
47 result.payload.userContextId
49 ? result.payload.userContextId
55 * Class used to create a muxer.
56 * The muxer receives and sorts results in a UrlbarQueryContext.
58 class MuxerUnifiedComplete extends UrlbarMuxer {
64 return "UnifiedComplete";
68 * Sorts results in the given UrlbarQueryContext.
70 * @param {UrlbarQueryContext} context
72 * @param {Array} unsortedResults
73 * The array of UrlbarResult that is not sorted yet.
75 sort(context, unsortedResults) {
76 // This method is called multiple times per keystroke, so it should be as
77 // fast and efficient as possible. We do two passes through the results:
78 // one to collect state for the second pass, and then a second to build the
79 // sorted list of results. If you find yourself writing something like
80 // context.results.find(), filter(), sort(), etc., modify one or both passes
83 // Global state we'll use to make decisions during this sort.
86 // RESULT_GROUP => array of results belonging to the group, excluding
87 // group-relative suggestedIndex results
88 resultsByGroup: new Map(),
89 // RESULT_GROUP => array of group-relative suggestedIndex results
90 // belonging to the group
91 suggestedIndexResultsByGroup: new Map(),
92 // This is analogous to `maxResults` except it's the total available
93 // result span instead of the total available result count. We'll add
94 // results until `usedResultSpan` would exceed `availableResultSpan`.
95 availableResultSpan: context.maxResults,
96 // The total result span taken up by all global (non-group-relative)
97 // suggestedIndex results.
98 globalSuggestedIndexResultSpan: 0,
99 // The total span of results that have been added so far.
101 strippedUrlToTopPrefixAndTitle: new Map(),
102 baseAndTitleToTopRef: new Map(),
103 urlToTabResultType: new Map(),
104 addedRemoteTabUrls: new Set(),
105 addedSwitchTabUrls: new Set(),
106 addedResultUrls: new Set(),
107 canShowPrivateSearch: unsortedResults.length > 1,
108 canShowTailSuggestions: true,
109 // Form history and remote suggestions added so far. Used for deduping
110 // suggestions. Also includes the heuristic query string if the heuristic
111 // is a search result. All strings in the set are lowercased.
112 suggestions: new Set(),
113 canAddTabToSearch: true,
114 hasUnitConversionResult: false,
115 maxHeuristicResultSpan: 0,
116 maxTabToSearchResultSpan: 0,
117 // When you add state, update _copyState() as necessary.
120 // Do the first pass over all results to build some state.
121 for (let result of unsortedResults) {
122 // Add each result to the appropriate `resultsByGroup` map.
123 let group = UrlbarUtils.getResultGroup(result);
125 result.hasSuggestedIndex && result.isSuggestedIndexRelativeToGroup
126 ? state.suggestedIndexResultsByGroup
127 : state.resultsByGroup;
128 let results = resultsByGroup.get(group);
131 resultsByGroup.set(group, results);
133 results.push(result);
135 // Update pre-add state.
136 this._updateStatePreAdd(result, state);
139 // Now that the first pass is done, adjust the available result span. More
140 // than one tab-to-search result may be present but only one will be shown;
141 // add the max TTS span to the total span of global suggestedIndex results.
142 state.globalSuggestedIndexResultSpan += state.maxTabToSearchResultSpan;
144 // Leave room for global suggestedIndex results at the end of the sort, by
145 // subtracting their total span from the total available span. For very
146 // small values of `maxRichResults`, their total span may be larger than
147 // `state.availableResultSpan`.
148 let globalSuggestedIndexAvailableSpan = Math.min(
149 state.availableResultSpan,
150 state.globalSuggestedIndexResultSpan
152 state.availableResultSpan -= globalSuggestedIndexAvailableSpan;
154 if (state.maxHeuristicResultSpan) {
155 if (lazy.UrlbarPrefs.get("experimental.hideHeuristic")) {
156 // The heuristic is hidden. The muxer will include it but the view will
157 // hide it. Increase the available span to compensate so that the total
158 // visible span accurately reflects `context.maxResults`.
159 state.availableResultSpan += state.maxHeuristicResultSpan;
160 } else if (context.maxResults > 0) {
161 // `context.maxResults` is positive. Make sure there's room for the
162 // heuristic even if it means exceeding `context.maxResults`.
163 state.availableResultSpan = Math.max(
164 state.availableResultSpan,
165 state.maxHeuristicResultSpan
170 // Show Top Sites above trending results.
171 let showSearchSuggestionsFirst =
172 context.searchString ||
173 (!lazy.UrlbarPrefs.get("suggest.trending") &&
174 !lazy.UrlbarPrefs.get("suggest.recentsearches"));
176 // Determine the result groups to use for this sort. In search mode with
177 // an engine, show search suggestions first.
179 context.searchMode?.engineName || !showSearchSuggestionsFirst
180 ? lazy.UrlbarPrefs.makeResultGroups({ showSearchSuggestionsFirst })
181 : lazy.UrlbarPrefs.resultGroups;
182 lazy.logger.debug("Root groups", rootGroup);
184 // Fill the root group.
185 let [sortedResults] = this._fillGroup(
187 { availableSpan: state.availableResultSpan, maxResultCount: Infinity },
191 // Add global suggestedIndex results.
192 let globalSuggestedIndexResults = state.resultsByGroup.get(
193 UrlbarUtils.RESULT_GROUP.SUGGESTED_INDEX
195 if (globalSuggestedIndexResults) {
196 this._addSuggestedIndexResults(
197 globalSuggestedIndexResults,
200 availableSpan: globalSuggestedIndexAvailableSpan,
201 maxResultCount: Infinity,
207 context.results = sortedResults;
211 * Returns a *deep* copy of state (except for `state.context`, which is
212 * shallow copied). i.e., any Maps, Sets, and arrays in the state should be
213 * recursively copied so that the original state is not modified when the copy
216 * @param {object} state
217 * The muxer state to copy.
219 * A deep copy of the state.
222 let copy = Object.assign({}, state, {
223 resultsByGroup: new Map(),
224 suggestedIndexResultsByGroup: new Map(),
225 strippedUrlToTopPrefixAndTitle: new Map(
226 state.strippedUrlToTopPrefixAndTitle
228 baseAndTitleToTopRef: new Map(state.baseAndTitleToTopRef),
229 urlToTabResultType: new Map(state.urlToTabResultType),
230 addedRemoteTabUrls: new Set(state.addedRemoteTabUrls),
231 addedSwitchTabUrls: new Set(state.addedSwitchTabUrls),
232 suggestions: new Set(state.suggestions),
233 addedResultUrls: new Set(state.addedResultUrls),
236 // Deep copy the `resultsByGroup` maps.
237 for (let key of ["resultsByGroup", "suggestedIndexResultsByGroup"]) {
238 for (let [group, results] of state[key]) {
239 copy[key].set(group, [...results]);
247 * Recursively fills a result group and its children.
249 * There are two ways to limit the number of results in a group:
251 * (1) By max total result span using the `availableSpan` property. The group
252 * will be filled so that the total span of its results does not exceed this
255 * (2) By max total result count using the `maxResultCount` property. The
256 * group will be filled so that the total number of its results does not
259 * Both `availableSpan` and `maxResultCount` may be defined, and the group's
260 * results will be capped to whichever limit is reached first. If either is
261 * not defined, then the group inherits that limit from its parent group.
263 * In addition to limiting their total number of results, groups can also
264 * control the composition of their child groups by using flex ratios. A group
265 * can define a `flexChildren: true` property, and in that case each of its
266 * children should have a `flex` property. Each child will be filled according
267 * to the ratio of its flex value and the sum of the flex values of all the
268 * children, similar to HTML flexbox. If some children do not fill up but
269 * others do, the filled-up children will be allowed to grow to use up the
272 * @param {object} group
273 * The result group to fill.
274 * @param {object} limits
275 * An object with optional `availableSpan` and `maxResultCount` properties
276 * as described above. They will be used as the limits for the group.
277 * @param {object} state
280 * `[results, usedLimits, hasMoreResults]` -- see `_addResults`.
282 _fillGroup(group, limits, state) {
283 // Get the group's suggestedIndex results. Reminder: `group.group` is a
284 // `RESULT_GROUP` constant.
285 let suggestedIndexResults;
286 let suggestedIndexAvailableSpan = 0;
287 let suggestedIndexAvailableCount = 0;
288 if ("group" in group) {
289 let results = state.suggestedIndexResultsByGroup.get(group.group);
291 // Subtract them from the group's limits so there will be room for them
292 // later. Discard results that can't be added.
295 for (let result of results) {
296 if (this._canAddResult(result, state)) {
297 suggestedIndexResults ??= [];
298 suggestedIndexResults.push(result);
299 const spanSize = UrlbarUtils.getSpanForResult(result);
307 suggestedIndexAvailableSpan = Math.min(limits.availableSpan, span);
308 suggestedIndexAvailableCount = Math.min(
309 limits.maxResultCount,
313 // Create a new `limits` object so we don't modify the caller's.
314 limits = { ...limits };
315 limits.availableSpan -= suggestedIndexAvailableSpan;
316 limits.maxResultCount -= suggestedIndexAvailableCount;
320 // Fill the group. If it has children, fill them recursively. Otherwise fill
321 // the group directly.
322 let [results, usedLimits, hasMoreResults] = group.children
323 ? this._fillGroupChildren(group, limits, state)
324 : this._addResults(group.group, limits, state);
326 // Add the group's suggestedIndex results.
327 if (suggestedIndexResults) {
328 let suggestedIndexUsedLimits = this._addSuggestedIndexResults(
329 suggestedIndexResults,
332 availableSpan: suggestedIndexAvailableSpan,
333 maxResultCount: suggestedIndexAvailableCount,
337 for (let [key, value] of Object.entries(suggestedIndexUsedLimits)) {
338 usedLimits[key] += value;
342 return [results, usedLimits, hasMoreResults];
346 * Helper for `_fillGroup` that fills a group's children.
348 * @param {object} group
349 * The result group to fill. It's assumed to have a `children` property.
350 * @param {object} limits
351 * An object with optional `availableSpan` and `maxResultCount` properties
352 * as described in `_fillGroup`.
353 * @param {object} state
355 * @param {Array} flexDataArray
356 * See `_updateFlexData`.
358 * `[results, usedLimits, hasMoreResults]` -- see `_addResults`.
360 _fillGroupChildren(group, limits, state, flexDataArray = null) {
361 // If the group has flexed children, update the data we use during flex
364 // Handling flex is complicated so we discuss it briefly. We may do multiple
365 // passes for a group with flexed children in order to try to optimally fill
366 // them. If after one pass some children do not fill up but others do, we'll
367 // do another pass that tries to overfill the filled-up children while still
368 // respecting their flex ratios. We'll continue to do passes until all
369 // children stop filling up or we reach the parent's limits. The way we
370 // overfill children is by increasing their individual limits to make up for
371 // the unused space in their underfilled siblings. Before starting a new
372 // pass, we discard the results from the current pass so the new pass starts
373 // with a clean slate. That means we need to copy the global sort state
374 // (`state`) before modifying it in the current pass so we can use its
375 // original value in the next pass [1].
377 // [1] Instead of starting each pass with a clean slate in this way, we
378 // could accumulate results with each pass since we only ever add results to
379 // flexed children and never remove them. However, that would subvert muxer
380 // logic related to the global state (deduping, `_canAddResult`) since we
381 // generally assume the muxer adds results in the order they appear.
383 if (group.flexChildren) {
384 stateCopy = this._copyState(state);
385 flexDataArray = this._updateFlexData(group, limits, flexDataArray);
388 // Fill each child group, collecting all results in the `results` array.
391 for (let key of Object.keys(limits)) {
394 let anyChildUnderfilled = false;
395 let anyChildHasMoreResults = false;
396 for (let i = 0; i < group.children.length; i++) {
397 let child = group.children[i];
398 let flexData = flexDataArray?.[i];
400 // Compute the child's limits.
401 let childLimits = {};
402 for (let key of Object.keys(limits)) {
403 childLimits[key] = flexData
404 ? flexData.limits[key]
406 typeof child[key] == "number" ? child[key] : Infinity,
407 limits[key] - usedLimits[key]
411 // Recurse and fill the child.
412 let [childResults, childUsedLimits, childHasMoreResults] =
413 this._fillGroup(child, childLimits, state);
414 results = results.concat(childResults);
415 for (let key of Object.keys(usedLimits)) {
416 usedLimits[key] += childUsedLimits[key];
418 anyChildHasMoreResults = anyChildHasMoreResults || childHasMoreResults;
420 if (flexData?.hasMoreResults) {
421 // The child is flexed and we possibly added more results to it.
422 flexData.usedLimits = childUsedLimits;
423 flexData.hasMoreResults = childHasMoreResults;
424 anyChildUnderfilled =
425 anyChildUnderfilled ||
426 (!childHasMoreResults &&
427 [...Object.entries(childLimits)].every(
428 ([key, limit]) => flexData.usedLimits[key] < limit
433 // If the children are flexed and some underfilled but others still have
434 // more results, do another pass.
435 if (anyChildUnderfilled && anyChildHasMoreResults) {
436 [results, usedLimits, anyChildHasMoreResults] = this._fillGroupChildren(
443 // Update `state` in place so that it's also updated in the caller.
444 for (let [key, value] of Object.entries(stateCopy)) {
449 return [results, usedLimits, anyChildHasMoreResults];
453 * Updates flex-related state used while filling a group.
455 * @param {object} group
456 * The result group being filled.
457 * @param {object} limits
458 * An object defining the group's limits as described in `_fillGroup`.
459 * @param {Array} flexDataArray
460 * An array parallel to `group.children`. The object at index i corresponds
461 * to the child in `group.children` at index i. Each object maintains some
462 * flex-related state for its child and is updated during each pass in
463 * `_fillGroup` for `group`. When this method is called in the first pass,
464 * this argument should be null, and the method will create and return a new
465 * `flexDataArray` array that should be used in the remainder of the first
466 * pass and all subsequent passes.
468 * A new `flexDataArray` when called in the first pass, and `flexDataArray`
469 * itself when called in subsequent passes.
471 _updateFlexData(group, limits, flexDataArray) {
474 group.children.map((child, index) => {
476 // The index of the corresponding child in `group.children`.
478 // The child's limits.
480 // The fractional parts of the child's unrounded limits; see below.
482 // The used-up portions of the child's limits.
484 // True if `state.resultsByGroup` has more results of the child's
485 // `RESULT_GROUP`. This is not related to the child's limits.
486 hasMoreResults: true,
487 // The child's flex value.
488 flex: typeof child.flex == "number" ? child.flex : 0,
490 for (let key of Object.keys(limits)) {
491 data.limits[key] = 0;
492 data.limitFractions[key] = 0;
493 data.usedLimits[key] = 0;
498 // The data objects for children with more results (i.e., that are still
500 let fillableDataArray = [];
502 // The sum of the flex values of children with more results.
503 let fillableFlexSum = 0;
505 for (let data of flexDataArray) {
506 if (data.hasMoreResults) {
507 fillableFlexSum += data.flex;
508 fillableDataArray.push(data);
512 // Update each limit.
513 for (let [key, limit] of Object.entries(limits)) {
514 // Calculate the group's limit only including children with more results.
515 let fillableLimit = limit;
516 for (let data of flexDataArray) {
517 if (!data.hasMoreResults) {
518 fillableLimit -= data.usedLimits[key];
522 // Allow for the possibility that some children may have gone over limit.
523 // `fillableLimit` will be negative in that case.
524 fillableLimit = Math.max(fillableLimit, 0);
526 // Next we'll compute the limits of children with more results. This value
527 // is the sum of those limits. It may differ from `fillableLimit` due to
528 // the fact that each individual child limit must be an integer.
529 let summedFillableLimit = 0;
531 // Compute the limits of children with more results. If there are also
532 // children that don't have more results, then these new limits will be
533 // larger than they were in the previous pass.
534 for (let data of fillableDataArray) {
535 let unroundedLimit = fillableLimit * (data.flex / fillableFlexSum);
536 // `limitFraction` is the fractional part of the unrounded ideal limit.
537 // e.g., for 5.234 it will be 0.234. We use this to minimize the
538 // mathematical error when tweaking limits below.
539 data.limitFractions[key] = unroundedLimit - Math.floor(unroundedLimit);
540 data.limits[key] = Math.round(unroundedLimit);
541 summedFillableLimit += data.limits[key];
544 // As mentioned above, the sum of the individual child limits may not
545 // equal the group's fillable limit. If the sum is smaller, the group will
546 // end up with too few results. If it's larger, the group will have the
547 // correct number of results (since we stop adding results once limits are
548 // reached) but it may end up with a composition that does not reflect the
549 // child flex ratios as accurately as possible.
551 // In either case, tweak the individual limits so that (1) their sum
552 // equals the group's fillable limit, and (2) the composition respects the
553 // flex ratios with as little mathematical error as possible.
554 if (summedFillableLimit != fillableLimit) {
555 // Collect the flex datas with a non-zero limit fractions. We'll round
556 // them up or down depending on whether the sum is larger or smaller
557 // than the group's fillable limit.
558 let fractionalDataArray = fillableDataArray.filter(
559 data => data.limitFractions[key]
563 if (summedFillableLimit < fillableLimit) {
564 // The sum is smaller. We'll increment individual limits until the sum
565 // is equal, starting with the child whose limit fraction is closest
566 // to 1 in order to minimize error.
568 fractionalDataArray.sort((a, b) => {
569 // Sort by fraction descending so larger fractions are first.
570 let cmp = b.limitFractions[key] - a.limitFractions[key];
571 // Secondarily sort by index ascending so that children with the
572 // same fraction are incremented in the order they appear, allowing
573 // earlier children to have larger spans.
574 return cmp || a.index - b.index;
576 } else if (fillableLimit < summedFillableLimit) {
577 // The sum is larger. We'll decrement individual limits until the sum
578 // is equal, starting with the child whose limit fraction is closest
579 // to 0 in order to minimize error.
581 fractionalDataArray.sort((a, b) => {
582 // Sort by fraction ascending so smaller fractions are first.
583 let cmp = a.limitFractions[key] - b.limitFractions[key];
584 // Secondarily sort by index descending so that children with the
585 // same fraction are decremented in reverse order, allowing earlier
586 // children to retain larger spans.
587 return cmp || b.index - a.index;
591 // Now increment or decrement individual limits until their sum is equal
592 // to the group's fillable limit.
593 while (summedFillableLimit != fillableLimit) {
594 if (!fractionalDataArray.length) {
595 // This shouldn't happen, but don't let it break us.
596 lazy.logger.error("fractionalDataArray is empty!");
599 let data = flexDataArray[fractionalDataArray.shift().index];
600 data.limits[key] += diff;
601 summedFillableLimit += diff;
606 return flexDataArray;
610 * Adds results to a group using the results from its `RESULT_GROUP` in
611 * `state.resultsByGroup`.
613 * @param {UrlbarUtils.RESULT_GROUP} groupConst
614 * The group's `RESULT_GROUP`.
615 * @param {object} limits
616 * An object defining the group's limits as described in `_fillGroup`.
617 * @param {object} state
618 * Global state that we use to make decisions during this sort.
620 * `[results, usedLimits, hasMoreResults]` where:
621 * results: A flat array of results in the group, empty if no results
623 * usedLimits: An object defining the amount of each limit that the
624 * results use. For each possible limit property (see `_fillGroup`),
625 * there will be a corresponding property in this object. For example,
626 * if 3 results are added with a total span of 4, then this object will
627 * be: { maxResultCount: 3, availableSpan: 4 }
628 * hasMoreResults: True if `state.resultsByGroup` has more results of
629 * the same `RESULT_GROUP`. This is not related to the group's limits.
631 _addResults(groupConst, limits, state) {
633 for (let key of Object.keys(limits)) {
637 // For form history, maxHistoricalSearchSuggestions == 0 implies the user
638 // has opted out of form history completely, so we override the max result
639 // count here in that case. Other values of maxHistoricalSearchSuggestions
640 // are ignored and we use the flex defined on the form history group.
642 groupConst == UrlbarUtils.RESULT_GROUP.FORM_HISTORY &&
643 !lazy.UrlbarPrefs.get("maxHistoricalSearchSuggestions")
645 // Create a new `limits` object so we don't modify the caller's.
646 limits = { ...limits };
647 limits.maxResultCount = 0;
650 let addedResults = [];
651 let groupResults = state.resultsByGroup.get(groupConst);
653 groupResults?.length &&
654 state.usedResultSpan < state.availableResultSpan &&
655 [...Object.entries(limits)].every(([k, limit]) => usedLimits[k] < limit)
657 let result = groupResults[0];
658 if (this._canAddResult(result, state)) {
659 if (!this.#updateUsedLimits(result, limits, usedLimits, state)) {
660 // Adding the result would exceed the group's available span, so stop
661 // adding results to it. Skip the shift() below so the result can be
662 // added to later groups.
665 addedResults.push(result);
668 // We either add or discard results in the order they appear in
669 // `groupResults`, so shift() them off. That way later groups with the
670 // same `RESULT_GROUP` won't include results that earlier groups have
671 // added or discarded.
672 groupResults.shift();
675 return [addedResults, usedLimits, !!groupResults?.length];
679 * Returns whether a result can be added to its group given the current sort
682 * @param {UrlbarResult} result
684 * @param {object} state
685 * Global state that we use to make decisions during this sort.
687 * True if the result can be added and false if it should be discarded.
689 // TODO (Bug 1741273): Refactor this method to avoid an eslint complexity
690 // error or increase the complexity threshold.
691 // eslint-disable-next-line complexity
692 _canAddResult(result, state) {
693 // Typically the first visible Suggest result is always added.
694 if (result.providerName == lazy.UrlbarProviderQuickSuggest.name) {
695 if (result.isHiddenExposure) {
696 // Always allow hidden exposure Suggest results.
700 if (state.quickSuggestResult && state.quickSuggestResult != result) {
701 // A Suggest result was already added.
705 // Don't add navigational suggestions that dupe the heuristic.
706 let heuristicUrl = state.context.heuristicResult?.payload.url;
709 result.payload.telemetryType == "top_picks" &&
710 !lazy.UrlbarPrefs.get("experimental.hideHeuristic")
718 result.payload.dupedHeuristic =
719 UrlbarUtils.stripPrefixAndTrim(heuristicUrl, opts)[0] ==
720 UrlbarUtils.stripPrefixAndTrim(result.payload.url, opts)[0];
721 return !result.payload.dupedHeuristic;
727 // We expect UrlbarProviderPlaces sent us the highest-ranked www. and non-www
728 // origins, if any. Now, compare them to each other and to the heuristic
731 // 1. If the heuristic result is lower ranked than both, discard the www
732 // origin, unless it has a different page title than the non-www
733 // origin. This is a guard against deduping when www.site.com and
734 // site.com have different content.
735 // 2. If the heuristic result is higher than either the www origin or
737 // 2a. If the heuristic is a www origin, discard the non-www origin.
738 // 2b. If the heuristic is a non-www origin, discard the www origin.
741 result.type == UrlbarUtils.RESULT_TYPE.URL &&
744 let [strippedUrl, prefix] = UrlbarUtils.stripPrefixAndTrim(
750 trimEmptyQuery: true,
753 let topPrefixData = state.strippedUrlToTopPrefixAndTitle.get(strippedUrl);
754 // If the condition below is not met, we are deduping a result against
758 (prefix != topPrefixData.prefix ||
759 result.providerName != topPrefixData.providerName)
761 let prefixRank = UrlbarUtils.getPrefixRank(prefix);
763 (prefixRank < topPrefixData.rank &&
764 (prefix.endsWith("www.") == topPrefixData.prefix.endsWith("www.") ||
765 result.payload?.title == topPrefixData.title)) ||
766 (prefix == topPrefixData.prefix &&
767 result.providerName != topPrefixData.providerName)
774 // Discard results that dupe autofill.
776 state.context.heuristicResult &&
777 state.context.heuristicResult.autofill &&
779 state.context.heuristicResult.payload?.url == result.payload.url &&
780 state.context.heuristicResult.type == result.type &&
781 !lazy.UrlbarPrefs.get("experimental.hideHeuristic")
786 // HeuristicFallback may add non-heuristic results in some cases, but those
787 // should be retained only if the heuristic result comes from it.
790 result.providerName == "HeuristicFallback" &&
791 state.context.heuristicResult?.providerName != "HeuristicFallback"
796 if (result.providerName == lazy.UrlbarProviderTabToSearch.name) {
797 // Discard the result if a tab-to-search result was added already.
798 if (!state.canAddTabToSearch) {
802 // In cases where the heuristic result is not a URL and we have a
803 // tab-to-search result, the tab-to-search provider determined that the
804 // typed string is similar to an engine domain. We can let the
805 // tab-to-search result through.
806 if (state.context.heuristicResult?.type == UrlbarUtils.RESULT_TYPE.URL) {
807 // Discard the result if the heuristic result is not autofill and we are
808 // not making an exception for a fuzzy match.
810 !state.context.heuristicResult.autofill &&
811 !result.payload.satisfiesAutofillThreshold
816 let autofillHostname = new URL(
817 state.context.heuristicResult.payload.url
819 let [autofillDomain] = UrlbarUtils.stripPrefixAndTrim(
825 // Strip the public suffix because we want to allow matching "domain.it"
826 // with "domain.com".
827 autofillDomain = UrlbarUtils.stripPublicSuffixFromHost(autofillDomain);
828 if (!autofillDomain) {
832 // `searchUrlDomainWithoutSuffix` is the engine's domain with the public
833 // suffix already stripped, for example "www.mozilla.".
834 let [engineDomain] = UrlbarUtils.stripPrefixAndTrim(
835 result.payload.searchUrlDomainWithoutSuffix,
840 // Discard if the engine domain does not end with the autofilled one.
841 if (!engineDomain.endsWith(autofillDomain)) {
847 // Discard "Search in a Private Window" if appropriate.
849 result.type == UrlbarUtils.RESULT_TYPE.SEARCH &&
850 result.payload.inPrivateWindow &&
851 !state.canShowPrivateSearch
856 // Discard form history and remote suggestions that dupe previously added
857 // suggestions or the heuristic. We do not deduplicate rich suggestions so
858 // they do not visually disapear as the suggestion is completed and
859 // becomes the same url as the heuristic result.
861 result.type == UrlbarUtils.RESULT_TYPE.SEARCH &&
862 result.payload.lowerCaseSuggestion &&
863 !result.isRichSuggestion
865 let suggestion = result.payload.lowerCaseSuggestion.trim();
866 if (!suggestion || state.suggestions.has(suggestion)) {
871 // Discard tail suggestions if appropriate.
873 result.type == UrlbarUtils.RESULT_TYPE.SEARCH &&
874 result.payload.tail &&
875 !result.isRichSuggestion &&
876 !state.canShowTailSuggestions
881 // Discard remote tab results that dupes another remote tab or a
882 // switch-to-tab result.
883 if (result.type == UrlbarUtils.RESULT_TYPE.REMOTE_TAB) {
884 if (state.addedRemoteTabUrls.has(result.payload.url)) {
887 let maybeDupeType = state.urlToTabResultType.get(result.payload.url);
888 if (maybeDupeType == UrlbarUtils.RESULT_TYPE.TAB_SWITCH) {
893 // Discard switch-to-tab results that dupes another switch-to-tab result.
895 result.type == UrlbarUtils.RESULT_TYPE.TAB_SWITCH &&
896 state.addedSwitchTabUrls.has(makeMapKeyForTabResult(result))
901 // Discard history results that dupe either remote or switch-to-tab results.
904 result.type == UrlbarUtils.RESULT_TYPE.URL &&
905 result.payload.url &&
906 state.urlToTabResultType.has(result.payload.url)
911 // Discard SERPs from browser history that dupe either the heuristic or
912 // previously added suggestions.
914 result.source == UrlbarUtils.RESULT_SOURCE.HISTORY &&
915 result.type == UrlbarUtils.RESULT_TYPE.URL &&
916 // If there's no suggestions, we're not going to have anything to match
917 // against, so avoid processing the url.
918 state.suggestions.size
920 let submission = Services.search.parseSubmissionURL(result.payload.url);
922 let resultQuery = submission.terms.trim().toLocaleLowerCase();
923 if (state.suggestions.has(resultQuery)) {
924 // If the result's URL is the same as a brand new SERP URL created
925 // from the query string modulo certain URL params, then treat the
926 // result as a dupe and discard it.
927 let [newSerpURL] = UrlbarUtils.getSearchQueryUrl(
932 lazy.UrlbarSearchUtils.serpsAreEquivalent(
943 // When in an engine search mode, discard URL results whose hostnames don't
944 // include the root domain of the search mode engine.
945 if (state.context.searchMode?.engineName && result.payload.url) {
946 let engine = Services.search.getEngineByName(
947 state.context.searchMode.engineName
950 let searchModeRootDomain =
951 lazy.UrlbarSearchUtils.getRootDomainFromEngine(engine);
952 let resultUrl = new URL(result.payload.url);
953 // Add a trailing "." to increase the stringency of the check. This
954 // check covers most general cases. Some edge cases are not covered,
955 // like `resultUrl` being ebay.mydomain.com, which would escape this
956 // check if `searchModeRootDomain` was "ebay".
957 if (!resultUrl.hostname.includes(`${searchModeRootDomain}.`)) {
963 // Discard history results that dupe the quick suggest result.
965 state.quickSuggestResult &&
967 result.type == UrlbarUtils.RESULT_TYPE.URL &&
968 lazy.QuickSuggest.isUrlEquivalentToResultUrl(
970 state.quickSuggestResult
976 // Discard history results whose URLs were originally sponsored. We use the
977 // presence of a partner's URL search param to detect these. The param is
978 // defined in the pref below, which is also used for the newtab page.
980 result.source == UrlbarUtils.RESULT_SOURCE.HISTORY &&
981 result.type == UrlbarUtils.RESULT_TYPE.URL
983 let param = Services.prefs.getCharPref(
984 "browser.newtabpage.activity-stream.hideTopSitesWithSearchParam"
987 let [key, value] = param.split("=");
990 ({ searchParams } = new URL(result.payload.url));
993 (value === undefined && searchParams?.has(key)) ||
994 (value !== undefined && searchParams?.getAll(key).includes(value))
1001 // Heuristic results must always be the first result. If this result is a
1002 // heuristic but we've already added results, discard it. Normally this
1003 // should never happen because the standard result groups are set up so
1004 // that there's always at most one heuristic and it's always first, but
1005 // since result groups are stored in a pref and can therefore be modified
1006 // by the user, we perform this check.
1007 if (result.heuristic && state.usedResultSpan) {
1011 // Google search engine might suggest a result for unit conversion with
1012 // format that starts with "= ". If our UnitConversion can provide the
1013 // result, we discard the suggestion of Google in order to deduplicate.
1015 result.type == UrlbarUtils.RESULT_TYPE.SEARCH &&
1016 result.payload.engine == "Google" &&
1017 result.payload.suggestion?.startsWith("= ") &&
1018 state.hasUnitConversionResult
1023 // Discard results that have an embedded "url" param with the same value
1024 // as another result's url
1025 if (result.payload.url) {
1026 let urlParams = result.payload.url.split("?").pop();
1027 let embeddedUrl = new URLSearchParams(urlParams).get("url");
1029 if (state.addedResultUrls.has(embeddedUrl)) {
1034 // Dedupe history results with different ref.
1036 lazy.UrlbarPrefs.get("deduplication.enabled") &&
1037 result.source == UrlbarUtils.RESULT_SOURCE.HISTORY &&
1038 result.type == UrlbarUtils.RESULT_TYPE.URL &&
1039 !result.heuristic &&
1040 result.payload.lastVisit
1042 let { base, ref } = UrlbarUtils.extractRefFromUrl(result.payload.url);
1043 let baseAndTitle = `${base} ${result.payload.title}`;
1044 let topRef = state.baseAndTitleToTopRef.get(baseAndTitle);
1046 let msSinceLastVisit = Date.now() - result.payload.lastVisit;
1047 let daysSinceLastVisit = msSinceLastVisit / MS_PER_DAY;
1048 let thresholdDays = lazy.UrlbarPrefs.get("deduplication.thresholdDays");
1050 if (daysSinceLastVisit >= thresholdDays && ref != topRef) {
1055 // Include the result.
1060 * Updates the global state that we use to make decisions during sort. This
1061 * should be called for results before we've decided whether to add or discard
1064 * @param {UrlbarResult} result
1066 * @param {object} state
1067 * Global state that we use to make decisions during this sort.
1069 _updateStatePreAdd(result, state) {
1070 // Check whether the result should trigger exposure telemetry. If it will
1071 // not be visible because it's a hidden exposure, it should not affect the
1072 // muxer's state, so bail now.
1073 this.#setExposureTelemetryProperty(result);
1074 if (result.isHiddenExposure) {
1078 // Keep track of the largest heuristic result span.
1079 if (result.heuristic && this._canAddResult(result, state)) {
1080 state.maxHeuristicResultSpan = Math.max(
1081 state.maxHeuristicResultSpan,
1082 UrlbarUtils.getSpanForResult(result)
1086 // Keep track of the total span of global suggestedIndex results so we can
1087 // make room for them at the end of the sort. Tab-to-search results are an
1088 // exception: There can be multiple TTS results but only one will be shown,
1089 // so we track the max TTS span separately.
1091 result.hasSuggestedIndex &&
1092 !result.isSuggestedIndexRelativeToGroup &&
1093 this._canAddResult(result, state)
1095 let span = UrlbarUtils.getSpanForResult(result);
1096 if (result.providerName == lazy.UrlbarProviderTabToSearch.name) {
1097 state.maxTabToSearchResultSpan = Math.max(
1098 state.maxTabToSearchResultSpan,
1102 state.globalSuggestedIndexResultSpan += span;
1106 // Save some state we'll use later to dedupe URL results.
1108 (result.type == UrlbarUtils.RESULT_TYPE.URL ||
1109 result.type == UrlbarUtils.RESULT_TYPE.KEYWORD) &&
1110 result.payload.url &&
1111 (!result.heuristic || !lazy.UrlbarPrefs.get("experimental.hideHeuristic"))
1113 let [strippedUrl, prefix] = UrlbarUtils.stripPrefixAndTrim(
1119 trimEmptyQuery: true,
1122 let prefixRank = UrlbarUtils.getPrefixRank(prefix);
1123 let topPrefixData = state.strippedUrlToTopPrefixAndTitle.get(strippedUrl);
1124 let topPrefixRank = topPrefixData ? topPrefixData.rank : -1;
1126 topPrefixRank < prefixRank ||
1127 // If a quick suggest result has the same stripped URL and prefix rank
1128 // as another result, store the quick suggest as the top rank so we
1129 // discard the other during deduping. That happens after the user picks
1130 // the quick suggest: The URL is added to history and later both a
1131 // history result and the quick suggest may match a query.
1132 (topPrefixRank == prefixRank &&
1133 result.providerName == lazy.UrlbarProviderQuickSuggest.name)
1135 // strippedUrl => { prefix, title, rank, providerName }
1136 state.strippedUrlToTopPrefixAndTitle.set(strippedUrl, {
1138 title: result.payload.title,
1140 providerName: result.providerName,
1145 // Save some state to dedupe results that only differ by the ref of their URL.
1146 // Even though we are considering tab results and URL results of all sources
1147 // here to find the top ref, we will only dedupe URL results with history source.
1149 result.type == UrlbarUtils.RESULT_TYPE.URL ||
1150 result.type == UrlbarUtils.RESULT_TYPE.TAB_SWITCH
1152 let { base, ref } = UrlbarUtils.extractRefFromUrl(result.payload.url);
1154 // This is unique because all spaces in base will be url-encoded
1155 // so the part before the space is always the base url.
1156 let baseAndTitle = `${base} ${result.payload.title}`;
1158 // The first result should have the highest frecency so we set it as the top
1159 // ref for its base url. If a result is heuristic, always override an existing
1160 // top ref for its base url in case the heuristic provider was slow and a non
1161 // heuristic result was added first for the same base url.
1162 if (!state.baseAndTitleToTopRef.has(baseAndTitle) || result.heuristic) {
1163 state.baseAndTitleToTopRef.set(baseAndTitle, ref);
1167 // Save some state we'll use later to dedupe results from open/remote tabs.
1169 result.payload.url &&
1170 (result.type == UrlbarUtils.RESULT_TYPE.TAB_SWITCH ||
1171 (result.type == UrlbarUtils.RESULT_TYPE.REMOTE_TAB &&
1172 !state.urlToTabResultType.has(makeMapKeyForTabResult(result))))
1174 // url => result type
1175 state.urlToTabResultType.set(makeMapKeyForTabResult(result), result.type);
1178 // If we find results other than the heuristic, "Search in Private
1179 // Window," or tail suggestions, then we should hide tail suggestions
1180 // since they're a last resort.
1182 state.canShowTailSuggestions &&
1183 !result.heuristic &&
1184 (result.type != UrlbarUtils.RESULT_TYPE.SEARCH ||
1185 (!result.payload.inPrivateWindow && !result.payload.tail))
1187 state.canShowTailSuggestions = false;
1190 if (result.providerName == lazy.UrlbarProviderQuickSuggest.name) {
1191 state.quickSuggestResult ??= result;
1194 state.hasUnitConversionResult =
1195 state.hasUnitConversionResult || result.providerName == "UnitConversion";
1197 // Keep track of result urls to dedupe results with the same url embedded
1198 // in its query string
1199 if (result.payload.url) {
1200 state.addedResultUrls.add(result.payload.url);
1205 * Updates the global state that we use to make decisions during sort. This
1206 * should be called for results after they've been added. It should not be
1207 * called for discarded results.
1209 * @param {UrlbarResult} result
1211 * @param {object} state
1212 * Global state that we use to make decisions during this sort.
1214 _updateStatePostAdd(result, state) {
1215 // bail early if the result will be hidden from the final view.
1216 if (result.isHiddenExposure) {
1220 // Update heuristic state.
1221 if (result.heuristic) {
1222 state.context.heuristicResult = result;
1224 result.type == UrlbarUtils.RESULT_TYPE.SEARCH &&
1225 result.payload.query &&
1226 !lazy.UrlbarPrefs.get("experimental.hideHeuristic")
1228 let query = result.payload.query.trim().toLocaleLowerCase();
1230 state.suggestions.add(query);
1235 // The "Search in a Private Window" result should only be shown when there
1236 // are other results and all of them are searches. It should not be shown
1237 // if the user typed an alias because that's an explicit engine choice.
1239 !Services.search.separatePrivateDefaultUrlbarResultEnabled ||
1240 (state.canShowPrivateSearch &&
1241 (result.type != UrlbarUtils.RESULT_TYPE.SEARCH ||
1242 result.payload.providesSearchMode ||
1243 (result.heuristic && result.payload.keyword)))
1245 state.canShowPrivateSearch = false;
1248 // Update suggestions.
1250 result.type == UrlbarUtils.RESULT_TYPE.SEARCH &&
1251 result.payload.lowerCaseSuggestion
1253 let suggestion = result.payload.lowerCaseSuggestion.trim();
1255 state.suggestions.add(suggestion);
1259 // Avoid multiple tab-to-search results.
1260 // TODO (Bug 1670185): figure out better strategies to manage this case.
1261 if (result.providerName == lazy.UrlbarProviderTabToSearch.name) {
1262 state.canAddTabToSearch = false;
1265 // Sync will send us duplicate remote tabs if multiple copies of a tab are
1266 // open on a synced client. Keep track of which remote tabs we've added to
1268 if (result.type == UrlbarUtils.RESULT_TYPE.REMOTE_TAB) {
1269 state.addedRemoteTabUrls.add(result.payload.url);
1272 // Keep track of which switch tabs we've added to dedupe switch tabs.
1273 if (result.type == UrlbarUtils.RESULT_TYPE.TAB_SWITCH) {
1274 state.addedSwitchTabUrls.add(makeMapKeyForTabResult(result));
1279 * Inserts results with suggested indexes. This can be called for either
1280 * global or group-relative suggestedIndex results. It should be called after
1281 * `sortedResults` has been filled in.
1283 * @param {Array} suggestedIndexResults
1284 * Results with a `suggestedIndex` property.
1285 * @param {Array} sortedResults
1286 * The sorted results. For global suggestedIndex results, this should be the
1287 * final list of all results before suggestedIndex results are inserted. For
1288 * group-relative suggestedIndex results, this should be the final list of
1289 * results in the group before group-relative suggestedIndex results are
1291 * @param {object} limits
1292 * An object defining span and count limits. See `_fillGroup()`.
1293 * @param {object} state
1294 * Global state that we use to make decisions during this sort.
1296 * A `usedLimits` object that describes the total span and count of all the
1297 * added results. See `_addResults`.
1299 _addSuggestedIndexResults(
1300 suggestedIndexResults,
1310 if (!suggestedIndexResults?.length) {
1311 // This is just a slight optimization; no need to continue.
1315 // Partition the results into positive- and negative-index arrays. Positive
1316 // indexes are relative to the start of the list and negative indexes are
1317 // relative to the end.
1320 for (let result of suggestedIndexResults) {
1321 let results = result.suggestedIndex < 0 ? negative : positive;
1322 results.push(result);
1325 // Sort the positive results ascending so that results at the end of the
1326 // array don't end up offset by later insertions at the front.
1327 positive.sort((a, b) => {
1328 if (a.suggestedIndex !== b.suggestedIndex) {
1329 return a.suggestedIndex - b.suggestedIndex;
1332 if (a.providerName === b.providerName) {
1336 // If same suggestedIndex, change the displaying order along to following
1337 // provider priority.
1338 // TabToSearch > QuickSuggest > Other providers
1339 if (a.providerName === lazy.UrlbarProviderTabToSearch.name) {
1342 if (b.providerName === lazy.UrlbarProviderTabToSearch.name) {
1345 if (a.providerName === lazy.UrlbarProviderQuickSuggest.name) {
1348 if (b.providerName === lazy.UrlbarProviderQuickSuggest.name) {
1355 // Conversely, sort the negative results descending so that results at the
1356 // front of the array don't end up offset by later insertions at the end.
1357 negative.sort((a, b) => b.suggestedIndex - a.suggestedIndex);
1359 // Insert the results. We start with the positive results because we have
1360 // tests that assume they're inserted first. In practice it shouldn't matter
1361 // because there's no good reason we would ever have a negative result come
1362 // before a positive result in the same query. Even if we did, we have to
1363 // insert one before the other, and there's no right or wrong order.
1364 for (let results of [positive, negative]) {
1367 for (let result of results) {
1368 if (this._canAddResult(result, state)) {
1369 if (!this.#updateUsedLimits(result, limits, usedLimits, state)) {
1376 prevResult.suggestedIndex == result.suggestedIndex
1381 result.suggestedIndex >= 0
1382 ? Math.min(result.suggestedIndex, sortedResults.length)
1383 : Math.max(result.suggestedIndex + sortedResults.length + 1, 0);
1385 prevResult = result;
1387 sortedResults.splice(index, 0, result);
1396 * Checks whether adding a result would exceed the given limits. If the limits
1397 * would be exceeded, this returns false and does nothing else. If the limits
1398 * would not be exceeded, the given used limits and state are updated to
1399 * account for the result, true is returned, and the caller should then add
1400 * the result to its list of sorted results.
1402 * @param {UrlbarResult} result
1404 * @param {object} limits
1405 * An object defining span and count limits. See `_fillGroup()`.
1406 * @param {object} usedLimits
1407 * An object with parallel properties to `limits` that describes how much of
1408 * the limits have been used. See `_addResults()`.
1409 * @param {object} state
1411 * @returns {boolean}
1412 * True if the limits were updated and the result can be added and false
1415 #updateUsedLimits(result, limits, usedLimits, state) {
1416 let span = UrlbarUtils.getSpanForResult(result);
1417 let newUsedSpan = usedLimits.availableSpan + span;
1418 if (limits.availableSpan < newUsedSpan) {
1419 // Adding the result would exceed the available span.
1423 usedLimits.availableSpan = newUsedSpan;
1425 usedLimits.maxResultCount++;
1428 state.usedResultSpan += span;
1429 this._updateStatePostAdd(result, state);
1435 * Checks exposure eligibility and visibility for the given result.
1436 * If the result passes the exposure check, we set `result.exposureTelemetry`
1437 * to the appropriate `UrlbarUtils.EXPOSURE_TELEMETRY` value.
1439 * @param {UrlbarResult} result
1442 #setExposureTelemetryProperty(result) {
1443 const exposureResults = lazy.UrlbarPrefs.get("exposureResults");
1444 if (exposureResults.size) {
1445 const telemetryType = UrlbarUtils.searchEngagementTelemetryType(result);
1446 if (exposureResults.has(telemetryType)) {
1447 result.exposureTelemetry = lazy.UrlbarPrefs.get("showExposureResults")
1448 ? UrlbarUtils.EXPOSURE_TELEMETRY.SHOWN
1449 : UrlbarUtils.EXPOSURE_TELEMETRY.HIDDEN;
1455 export var UrlbarMuxerUnifiedComplete = new MuxerUnifiedComplete();