Files
tubestation/browser/components/urlbar/private/SuggestBackendRust.sys.mjs
Drew Willcoxon 1817ce0847 Bug 1952588 - Vendor application-services to 138 for Suggest geo expansion. r=bdk,daisuke
This vendors the `desktop-138` branch [1] of application-services. The `main`
branch currently does not have some PRs that desktop needs because they break
mobile, and we need this on desktop ASAP.

This patch is larger than usual because the vendor includes some major changes
to the application-services `suggest` component, including new and changed API.
As a consequence this patch includes the following important changes:

## New `suggest` API & uniffi

`SuggestStoreBuilder::remote_settings_service` and `RemoteSettingsService::new`
are exposed to JS as synchronous functions. There's no need for them to be
off-main-thread.

## Telemetry

The labels of `suggest.ingest_time`, `ingest_download_time` and `query_time` had
to be updated due to changes in the Rust component. These are minor updates that
don't need a data review.

## Urlbar

I had to make the following changes to urlbar. I tried to keep them to a minimum
for now. There are opportunities for improvements in follow-ups.

* Appropriate minimal integration changes to `SuggestBackendRust` for creating
  the `SuggestStore` and setting up the RS config
* The Rust component uses new RS collections, which breaks tests. I tried to fix
  them without touching too many lines. There are definitely opportunities to
  improve these tests and test helpers that I'd like to come back to.
* A fix to `RemoteSettingsServer` that's required due to the new RS client used
  by the Rust component

## Late writes due to the new RS client & `AsyncShutdown`

This is a urlbar change but it's worth calling out separately. I pushed all
these changes to tryserver, and there was a failure at the end of the browser
Suggest tests due to `LateWriteObserver` [2].

The late write happens when the app exits: `SuggestStore` is dropped, which
causes the new app-services RS client to drop its Sqlite connection, which
causes Sqlite to sync the RS client's DB to disk. This hasn't been a problem
before because `suggest` currently uses the old RS client, which doesn't keep a
DB. (`suggest` does have its own separate Sqlite DB, and I didn't investigate
why this isn't a problem for it, mainly because it makes sense that the new RS
client would sync its DB when it's dropped and that might be considered a "late
write" when it happens on app shutdown.)

According to the stack in the log, `SuggestStore` is dropped by
`nsCycleCollector`. I can't see how `SuggestBackendRust.#store` is involved in a
cycle, and I don't know if something in this patch is causing a cycle where
there wasn't one before. Maybe there always was. And I don't know if the cycle
is what's causing the all this to happen too late on shutdown. Maybe it's
unrelated. (I'll paste the stack in a Phabricator comment.)

The `SuggestStore` is definitely kept alive until
`AsyncShutdown.profileBeforeChange` since we have a barrier for that phase that
calls `interrupt()` on it. Maybe that's simply the problem and we're using a
phase that's too late in shutdown. But again I don't know why it wouldn't also
be a problem for Suggest's own DB.

The only fix I found is to replace `AsyncShutdown.profileBeforeChange` with
either `quitApplicationGranted` or `profileChangeTeardown`, and then null out
`#store` in the callback (after we call `interrupt()` on it). I assume that
fixes it because `profileBeforeChange` runs later than those other two phases.

So I replaced `profileBeforeChange` with `profileChangeTeardown`. I don't know
which of `quitApplicationGranted` or `profileChangeTeardown` is better. I think
it probably doesn't matter. I chose `profileChangeTeardown` because it's closer
to `profileBeforeChange`. (The order is: `quitApplicationGranted`,
`profileChangeTeardown`, `profileBeforeChange`.)

[1] https://github.com/mozilla/application-services/tree/desktop-138
[2] https://treeherder.mozilla.org/jobs?repo=try&revision=1639f87aa46f1afaf50901d80c8282861700019b

Differential Revision: https://phabricator.services.mozilla.com/D240919
2025-03-12 20:20:09 +00:00

693 lines
24 KiB
JavaScript

/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import { SuggestBackend } from "resource:///modules/urlbar/private/SuggestFeature.sys.mjs";
import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
AsyncShutdown: "resource://gre/modules/AsyncShutdown.sys.mjs",
InterruptKind: "resource://gre/modules/RustSuggest.sys.mjs",
ObjectUtils: "resource://gre/modules/ObjectUtils.sys.mjs",
QuickSuggest: "resource:///modules/QuickSuggest.sys.mjs",
RemoteSettingsConfig2: "resource://gre/modules/RustRemoteSettings.sys.mjs",
RemoteSettingsContext: "resource://gre/modules/RustRemoteSettings.sys.mjs",
RemoteSettingsServer: "resource://gre/modules/RustRemoteSettings.sys.mjs",
RemoteSettingsService: "resource://gre/modules/RustRemoteSettings.sys.mjs",
SuggestIngestionConstraints: "resource://gre/modules/RustSuggest.sys.mjs",
SuggestStoreBuilder: "resource://gre/modules/RustSuggest.sys.mjs",
Suggestion: "resource://gre/modules/RustSuggest.sys.mjs",
SuggestionProvider: "resource://gre/modules/RustSuggest.sys.mjs",
SuggestionProviderConstraints: "resource://gre/modules/RustSuggest.sys.mjs",
SuggestionQuery: "resource://gre/modules/RustSuggest.sys.mjs",
TaskQueue: "resource:///modules/UrlbarUtils.sys.mjs",
UrlbarPrefs: "resource:///modules/UrlbarPrefs.sys.mjs",
Utils: "resource://services-settings/Utils.sys.mjs",
});
XPCOMUtils.defineLazyServiceGetter(
lazy,
"timerManager",
"@mozilla.org/updates/timer-manager;1",
"nsIUpdateTimerManager"
);
const SUGGEST_DATA_STORE_BASENAME = "suggest.sqlite";
// This ID is used to register our ingest timer with nsIUpdateTimerManager.
const INGEST_TIMER_ID = "suggest-ingest";
const INGEST_TIMER_LAST_UPDATE_PREF = `app.update.lastUpdateTime.${INGEST_TIMER_ID}`;
// Maps from `suggestion.constructor` to the corresponding name of the
// suggestion type. See `getSuggestionType()` for details.
const gSuggestionTypesByCtor = new WeakMap();
/**
* The Suggest Rust backend. Not used when the remote settings JS backend is
* enabled.
*
* This class returns suggestions served by the Rust component. These are the
* primary related architectural pieces (see bug 1851256 for details):
*
* (1) The `suggest` Rust component, which lives in the application-services
* repo [1] and is periodically vendored into mozilla-central [2] and then
* built into the Firefox binary.
* (2) `suggest.udl`, which is part of the Rust component's source files and
* defines the interface exposed to foreign-function callers like JS [3, 4].
* (3) `RustSuggest.sys.mjs` [5], which contains the JS bindings generated from
* `suggest.udl` by UniFFI. The classes defined in `RustSuggest.sys.mjs` are
* what we consume here in this file. If you have a question about the JS
* interface to the Rust component, try checking `RustSuggest.sys.mjs`, but
* as you get accustomed to UniFFI JS conventions you may find it simpler to
* refer directly to `suggest.udl`.
* (4) `config.toml` [6], which defines which functions in the JS bindings are
* sync and which are async. Functions default to the "worker" thread, which
* means they are async. Some functions are "main", which means they are
* sync. Async functions return promises. This information is reflected in
* `RustSuggest.sys.mjs` of course: If a function is "worker", its JS
* binding will return a promise, and if it's "main" it won't.
*
* [1] https://github.com/mozilla/application-services/tree/main/components/suggest
* [2] https://searchfox.org/mozilla-central/source/third_party/rust/suggest
* [3] https://github.com/mozilla/application-services/blob/main/components/suggest/src/suggest.udl
* [4] https://searchfox.org/mozilla-central/source/third_party/rust/suggest/src/suggest.udl
* [5] https://searchfox.org/mozilla-central/source/toolkit/components/uniffi-bindgen-gecko-js/components/generated/RustSuggest.sys.mjs
* [6] https://searchfox.org/mozilla-central/source/toolkit/components/uniffi-bindgen-gecko-js/config.toml
*/
export class SuggestBackendRust extends SuggestBackend {
constructor(...args) {
super(...args);
this.#ingestQueue = new lazy.TaskQueue();
// The remote settings server URL returned by `Utils.SERVER_URL` comes from
// the `services.settings.server` pref. The xpcshell and browser test
// harnesses set this pref to `"data:,#remote-settings-dummy/v1"` so that
// browser features that use RS and remain enabled during tests don't hit
// the real server. Suggest tests use a mock RS server and set this pref to
// that server's URL, but during other tests the pref remains the dummy URL.
// During those other tests, Suggest remains enabled, which means if we
// initialize the Suggest store with the dummy URL, the Rust Suggest and RS
// components will attempt to use it (when the store is initialized and on
// initial ingest). Unfortunately the Rust RS component logs an error each
// time it tries to manipulate the dummy URL because it's a `data` URI,
// which is a "cannot-be-a-base" URL. The error is harmless, but it can be
// logged many times during a test suite.
//
// To prevent Suggest from using the dummy URL, we skip setting the initial
// RS config here during tests, which prevents the Suggest store from being
// created, effectively disabling Rust suggestions. Suggest tests manually
// set the RS config when they set up the mock RS server, so they'll work
// fine. Alternatively the test harnesses could disable Suggest by default
// just like they set the server pref to the dummy URL, but Suggest is more
// than Rust suggestions.
if (!lazy.Utils.shouldSkipRemoteActivityDueToTests) {
this.#setRemoteSettingsConfig({
serverUrl: lazy.Utils.SERVER_URL,
bucketName: lazy.Utils.actualBucketName("main"),
});
}
}
get enablingPreferences() {
return ["quicksuggest.rustEnabled"];
}
/**
* @returns {object}
* The global Suggest config from the Rust component as returned from
* `SuggestStore.fetchGlobalConfig()`.
*/
get config() {
return this.#config || {};
}
/**
* @returns {Promise}
* Resolved when all pending ingests are done.
*/
get ingestPromise() {
return this.#ingestQueue.emptyPromise;
}
enable(enabled) {
if (enabled) {
this.#init();
} else {
this.#uninit();
}
}
/**
* Queries the Rust component and returns all matching suggestions.
*
* @param {string} searchString
* The search string.
* @param {object} options
* Options object.
* @param {UrlbarQueryContext} options._queryContext
* The query context.
* @param {Array} options.types
* This is only intended to be used in special circumstances and normally
* should not be specified. Array of suggestion types to query. By default
* all enabled suggestion types are queried.
* @returns {Array}
* Matching Rust suggestions.
*/
async query(searchString, { _queryContext, types = null } = {}) {
if (!this.#store) {
return [];
}
this.logger.debug("Handling query", { searchString });
if (!types) {
types = this.#enabledSuggestionTypes;
} else {
types = types.map(type => {
let provider = this.#providerFromSuggestionType(type);
if (!provider) {
throw new Error("Unknown Rust suggestion type: " + type);
}
return { type, provider };
});
}
let providers = [];
let allProviderConstraints = {};
for (let { type, provider } of types) {
this.logger.debug("Adding type to query", { type, provider });
providers.push(provider);
let providerConstraints =
lazy.QuickSuggest.getFeatureByRustSuggestionType(
type
).rustProviderConstraints;
if (providerConstraints) {
allProviderConstraints = {
...allProviderConstraints,
...providerConstraints,
};
}
}
const { suggestions, queryTimes } = await this.#store.queryWithMetrics(
new lazy.SuggestionQuery({
providers,
keyword: searchString,
providerConstraints: new lazy.SuggestionProviderConstraints(
allProviderConstraints
),
})
);
for (let { label, value } of queryTimes) {
Glean.suggest.queryTime[label].accumulateSingleSample(value);
}
for (let suggestion of suggestions) {
let type = getSuggestionType(suggestion);
if (!type) {
continue;
}
suggestion.source = "rust";
suggestion.provider = type;
if (suggestion.icon) {
suggestion.icon_blob = new Blob([suggestion.icon], {
type: suggestion.iconMimetype ?? "",
});
delete suggestion.icon;
delete suggestion.iconMimetype;
}
}
this.logger.debug("Got suggestions", suggestions);
return suggestions;
}
cancelQuery() {
this.#store?.interrupt(lazy.InterruptKind.READ);
}
/**
* Returns suggestion-type-specific configuration data set by the Rust
* backend.
*
* @param {string} type
* A Rust suggestion type name as defined in `suggest.udl`, e.g., "Amp",
* "Wikipedia", "Mdn", etc. See also `SuggestProvider.rustSuggestionType`.
* @returns {object} config
* The config data for the type.
*/
getConfigForSuggestionType(type) {
return this.#configsBySuggestionType.get(type);
}
/**
* Ingests a feature's enabled suggestion types and updates staleness
* bookkeeping. By default only stale suggestion types are ingested. A
* suggestion type is stale if (a) it hasn't been ingested during this app
* session or (b) the last time this method was called the suggestion type or
* its feature was disabled.
*
* @param {SuggestProvider} feature
* A feature that manages Rust suggestion types.
* @param {object} options
* Options object.
* @param {bool} options.evenIfFresh
* Set to true to force ingest for all the feature's suggestion types, even
* ones that aren't stale.
*/
ingestEnabledSuggestions(feature, { evenIfFresh = false } = {}) {
let type = feature.rustSuggestionType;
if (!type) {
return;
}
if (!this.isEnabled || !feature.isEnabled) {
// Mark this type as stale so we'll ingest next time this method is
// called.
this.#providerConstraintsByIngestedSuggestionType.delete(type);
} else {
let providerConstraints = feature.rustProviderConstraints;
if (
evenIfFresh ||
!this.#providerConstraintsByIngestedSuggestionType.has(type) ||
!lazy.ObjectUtils.deepEqual(
providerConstraints,
this.#providerConstraintsByIngestedSuggestionType.get(type)
)
) {
this.#providerConstraintsByIngestedSuggestionType.set(
type,
providerConstraints
);
this.#ingestSuggestionType({ type, providerConstraints });
}
}
}
/**
* Fetches geonames stored in the Suggest database. A geoname represents a
* geographic place.
*
* See `SuggestStore::fetch_geonames()` in the Rust component for full
* documentation.
*
* @param {string} searchString
* The string to match against geonames.
* @param {bool} matchNamePrefix
* Whether prefix matching is performed on names excluding abbreviations and
* airport codes.
* @param {GeonameType} geonameType
* Restricts returned geonames to a type.
* @param {Array} filter
* Restricts returned geonames to certain cities or regions. Optional.
* @returns {Array}
* Array of `GeonameMatch` objects. An empty array if there are no matches.
*/
fetchGeonames(searchString, matchNamePrefix, geonameType, filter) {
if (!this.#store) {
return [];
}
return this.#store.fetchGeonames(
searchString,
matchNamePrefix,
geonameType,
filter
);
}
/**
* nsITimerCallback
*/
notify() {
this.logger.info("Ingest timer fired");
this.#ingestAll();
}
/**
* @returns {string}
* The path of `suggest.sqlite`, where the Rust component stores ingested
* suggestions. It also stores dismissed suggestions, which is why we keep
* this file in the profile directory, but desktop doesn't currently use the
* Rust component for that.
*/
get #storeDataPath() {
return PathUtils.join(
Services.dirsvc.get("ProfD", Ci.nsIFile).path,
SUGGEST_DATA_STORE_BASENAME
);
}
/**
* @returns {string}
* The path of the directory that should contain the remote settings cache
* used internally by the Rust component.
*/
get #remoteSettingsStoragePath() {
return Services.dirsvc.get("ProfLD", Ci.nsIFile).path;
}
/**
* @returns {Array}
* Each item in this array identifies an enabled Rust suggestion type and
* related data. Items have the following properties:
*
* {string} type
* A Rust suggestion type name as defined in Rust, e.g., "Amp",
* "Wikipedia", "Mdn", etc.
* {number} provider
* An integer that identifies the provider of the suggestion type to Rust.
*/
get #enabledSuggestionTypes() {
let items = [];
for (let feature of lazy.QuickSuggest.rustFeatures) {
if (feature.isEnabled) {
let type = feature.rustSuggestionType;
let provider = this.#providerFromSuggestionType(type);
if (provider) {
items.push({ type, provider });
}
}
}
return items;
}
#init() {
this.#store = this.#makeStore();
if (!this.#store) {
return;
}
// Log the last ingest time for debugging.
let lastIngestSecs = Services.prefs.getIntPref(
INGEST_TIMER_LAST_UPDATE_PREF,
0
);
this.logger.debug("Last ingest time (seconds)", lastIngestSecs);
// Add our shutdown blocker.
this.#shutdownBlocker = () => {
// Interrupt any ongoing ingests (WRITE) and queries (READ).
// `interrupt()` runs on the main thread and is not async; see
// toolkit/components/uniffi-bindgen-gecko-js/config.toml
this.#store?.interrupt(lazy.InterruptKind.READ_WRITE);
// Null the store so it's destroyed now instead of later when `this` is
// collected. The store's Sqlite DBs are synced when dropped (its DB and
// its RS client's DB), which causes a `LateWriteObserver` test failure if
// it happens too late during shutdown.
this.#store = null;
this.#shutdownBlocker = null;
};
lazy.AsyncShutdown.profileChangeTeardown.addBlocker(
"QuickSuggest: Interrupt the Rust component",
this.#shutdownBlocker
);
// Register the ingest timer.
lazy.timerManager.registerTimer(
INGEST_TIMER_ID,
this,
lazy.UrlbarPrefs.get("quicksuggest.rustIngestIntervalSeconds"),
true // skipFirst
);
// Do an initial ingest for all enabled suggestion types. When a type
// becomes enabled after this point, its `SuggestProvider` will update and
// call `ingestEnabledSuggestions()`, which will be its initial ingest.
this.#ingestAll();
}
#makeStore() {
this.logger.info("Creating SuggestStore", {
server: this.#remoteSettingsServer,
bucketName: this.#remoteSettingsBucketName,
dataPath: this.#storeDataPath,
storagePath: this.#remoteSettingsStoragePath,
});
if (!this.#remoteSettingsServer) {
return null;
}
let rsService;
try {
rsService = lazy.RemoteSettingsService.init(
this.#remoteSettingsStoragePath,
new lazy.RemoteSettingsConfig2({
server: this.#remoteSettingsServer,
bucketName: this.#remoteSettingsBucketName,
appContext: new lazy.RemoteSettingsContext({
appName: Services.appinfo.name || "",
appId: Services.appinfo.ID || "",
channel: AppConstants.IS_ESR
? "esr"
: AppConstants.MOZ_UPDATE_CHANNEL,
}),
})
);
} catch (error) {
this.logger.error("Error creating RemoteSettingsService", error);
return null;
}
let builder;
try {
builder = lazy.SuggestStoreBuilder.init()
.dataPath(this.#storeDataPath)
.remoteSettingsService(rsService)
.loadExtension(
AppConstants.SQLITE_LIBRARY_FILENAME,
"sqlite3_fts5_init"
);
} catch (error) {
this.logger.error("Error creating SuggestStoreBuilder", error);
return null;
}
let store;
try {
store = builder.build();
} catch (error) {
this.logger.error("Error creating SuggestStore", error);
return null;
}
return store;
}
#uninit() {
this.#store = null;
this.#providerConstraintsByIngestedSuggestionType.clear();
this.#configsBySuggestionType.clear();
lazy.timerManager.unregisterTimer(INGEST_TIMER_ID);
lazy.AsyncShutdown.profileChangeTeardown.removeBlocker(
this.#shutdownBlocker
);
this.#shutdownBlocker = null;
}
/**
* Ingests the given suggestion type.
*
* @param {object} options
* Options object.
* @param {string} options.type
* A Rust suggestion type name as defined in `suggest.udl`, e.g., "Amp",
* "Wikipedia", "Mdn", etc.
* @param {object|null} options.providerConstraints
* A plain JS object version of the type's provider constraints, if any.
*/
#ingestSuggestionType({ type, providerConstraints }) {
this.#ingestQueue.queueIdleCallback(async () => {
if (!this.#store) {
return;
}
let provider = this.#providerFromSuggestionType(type);
if (!provider) {
return;
}
this.logger.debug("Starting ingest", { type });
try {
const metrics = await this.#store.ingest(
new lazy.SuggestIngestionConstraints({
providers: [provider],
providerConstraints: providerConstraints
? new lazy.SuggestionProviderConstraints(providerConstraints)
: null,
})
);
for (let { label, value } of metrics.downloadTimes) {
Glean.suggest.ingestDownloadTime[label].accumulateSingleSample(value);
}
for (let { label, value } of metrics.ingestionTimes) {
Glean.suggest.ingestTime[label].accumulateSingleSample(value);
}
} catch (error) {
// Ingest can throw a `SuggestApiError` subclass called `Other` with a
// `reason` message, which is very helpful for diagnosing problems with
// remote settings data in tests in particular.
this.logger.error("Ingest error", {
type,
error,
reason: error.reason,
});
}
this.logger.debug("Finished ingest", { type });
if (!this.#store) {
return;
}
// Fetch the provider config.
this.logger.debug("Fetching provider config", { type });
let config = await this.#store.fetchProviderConfig(provider);
this.logger.debug("Got provider config", { type, config });
this.#configsBySuggestionType.set(type, config);
this.logger.debug("Finished fetching provider config", { type });
});
}
#ingestAll() {
// Ingest all enabled suggestion types.
for (let feature of lazy.QuickSuggest.rustFeatures) {
this.ingestEnabledSuggestions(feature, { evenIfFresh: true });
}
// Fetch the global config.
this.#ingestQueue.queueIdleCallback(async () => {
if (!this.#store) {
return;
}
this.logger.debug("Fetching global config");
this.#config = await this.#store.fetchGlobalConfig();
this.logger.debug("Got global config", this.#config);
});
}
/**
* Given a Rust suggestion type, gets the integer value that identifies the
* corresponding suggestion provider to Rust.
*
* @param {string} type
* A Rust suggestion type name as defined in `suggest.udl`, e.g., "Amp",
* "Wikipedia", "Mdn", etc.
* @returns {number}
* An integer that identifies the provider of the suggestion type to Rust.
*/
#providerFromSuggestionType(type) {
let key = type.toUpperCase();
if (!lazy.SuggestionProvider.hasOwnProperty(key)) {
// Normally this shouldn't happen but it can during development when the
// Rust component and desktop integration are out of sync.
this.logger.error("SuggestionProvider[key] not defined!", { key });
return null;
}
return lazy.SuggestionProvider[key];
}
#setRemoteSettingsConfig(options) {
let { serverUrl, bucketName } = options || {};
this.#remoteSettingsServer = serverUrl
? new lazy.RemoteSettingsServer.Custom(serverUrl)
: null;
this.#remoteSettingsBucketName = bucketName;
}
get _test_store() {
return this.#store;
}
get _test_enabledSuggestionTypes() {
return this.#enabledSuggestionTypes;
}
async _test_setRemoteSettingsConfig(options) {
this.#setRemoteSettingsConfig(options);
if (this.isEnabled) {
// Recreate the store and re-ingest.
Services.prefs.clearUserPref(INGEST_TIMER_LAST_UPDATE_PREF);
this.#uninit();
this.#init();
await this.ingestPromise;
}
}
async _test_ingest() {
this.#ingestAll();
await this.ingestPromise;
}
// The `SuggestStore` instance.
#store;
// Global Suggest config as returned from `SuggestStore.fetchGlobalConfig()`.
#config = {};
// Maps from suggestion type to provider config as returned from
// `SuggestStore.fetchProviderConfig()`.
#configsBySuggestionType = new Map();
// Keeps track of suggestion types with fresh (non-stale) ingests. Maps
// ingested suggestion types to `feature.rustProviderConstraints`.
#providerConstraintsByIngestedSuggestionType = new Map();
#ingestQueue;
#shutdownBlocker;
#remoteSettingsServer;
#remoteSettingsBucketName;
}
/**
* Returns the type of a suggestion.
*
* @param {Suggestion} suggestion
* A suggestion object, an instance of one of the `Suggestion` subclasses.
* @returns {string}
* The suggestion's type, e.g., "Amp", "Wikipedia", etc.
*/
function getSuggestionType(suggestion) {
// Suggestion objects served by the Rust component don't have any inherent
// type information other than the classes they are instances of. There's no
// `type` property, for example. There's a base `Suggestion` class and many
// `Suggestion` subclasses, one per type of suggestion. Each suggestion object
// is an instance of one of these subclasses. We derive a suggestion's type
// from the subclass it's an instance of.
//
// Unfortunately the subclasses are all anonymous, which means
// `suggestion.constructor.name` is always an empty string. (This is due to
// how UniFFI generates JS bindings.) Instead, the subclasses are defined as
// properties on the base `Suggestion` class. For example,
// `Suggestion.Wikipedia` is the (anonymous) Wikipedia suggestion class. To
// find a suggestion's subclass, we loop through the keys on `Suggestion`
// until we find the value the suggestion is an instance of. To avoid doing
// this every time, we cache the mapping from suggestion constructor to key
// the first time we encounter a new suggestion subclass.
let type = gSuggestionTypesByCtor.get(suggestion.constructor);
if (!type) {
type = Object.keys(lazy.Suggestion).find(
key => suggestion instanceof lazy.Suggestion[key]
);
if (type) {
gSuggestionTypesByCtor.set(suggestion.constructor, type);
} else {
console.error(
"Unexpected error: Suggestion class not found on `Suggestion`. " +
"Did the Rust component or its JS bindings change? ",
{ suggestion }
);
}
}
return type;
}