Skip to content

Conversation

Piccirello
Copy link
Member

@Piccirello Piccirello commented Aug 30, 2025

This PR moves durable settings from browser local storage to the new client data API. This allows these settings to be shared across multiple WebUI instances. This does not include settings that are specific to the local client, like window sizes, selected filters, selected tabs, and sorted columns. I also don't want to get hung up on a debate over whether these should be included - they can always be added later.

Depends on #23088. Closes #12100

@Piccirello Piccirello added this to the 5.2 milestone Aug 30, 2025
@Piccirello Piccirello added the WebUI WebUI-related issues/changes label Aug 30, 2025
@Piccirello Piccirello force-pushed the client-prefs-webui branch 2 times, most recently from bbffd53 to 4b1bf7b Compare September 8, 2025 03:16
@Piccirello Piccirello marked this pull request as ready for review September 12, 2025 17:50
@Piccirello Piccirello requested a review from a team September 12, 2025 17:50
let clientDataPromise;
const setup = () => {
// fetch various data and store it in memory
clientDataPromise = window.qBittorrent.ClientData.get([
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should have a "MigrationVersion" field. This functionality will be required eventually. I don't insist it to be done in this PR but you should add it in a later PR.
Look at LocalPreferences.upgrade() for an example.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree it will likely eventually be needed. Since this API accepts arbitrary values, I think we can wait to add it until we actually need to use it for the first time.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can wait to add it until we actually need to use it for the first time.

The actually upgrade() function can be added later but for now you should at least set the key MigrationVersion=1 so we don't have to handle it being missing later.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's no guarantee it'll be set later - our code will still need a null check. Going with YAGNI on this one. We can add it later when it's needed since there's absolutely zero downside to waiting.

Copy link
Member

@Chocobo1 Chocobo1 Sep 27, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's no guarantee it'll be set later - our code will still need a null check.

You don't need to check anything. Just set it to 1 unconditionally so that the actual migration code that needs it can just use it.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm referring to the code that fetches and checks for the current migration version. There's no guarantee that a migration version will have been set/will exist, so defensive code will looking something like clientData.get("migrationVersion") ?? 1.

@Piccirello Piccirello force-pushed the client-prefs-webui branch 2 times, most recently from 011ac5a to 30807d0 Compare September 12, 2025 20:29
Comment on lines +83 to +113
get(key) {
return this.#cache.get(key);
}

/**
* @param {string[]} keys
* @returns {Record<string, any>}
*/
async fetch(keys = []) {
const keysToFetch = keys.filter((key) => !this.#cache.has(key));
if (keysToFetch.length > 0) {
const fetchedData = await this.#fetch(keysToFetch);
for (const [key, value] of Object.entries(fetchedData))
this.#cache.set(key, value);
}

return Object.fromEntries(keys.map((key) => ([key, this.#cache.get(key)])));
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On a second look, I will redesign these two function:

  1. get() will first try to get the value from #cache and if it does not exist, fetch it from the server.
  2. fetch() will just get the values from server and store it in cache. It will not return any value but only Promise. This function will become a "prefetch" action. The caller should only use get() for getting the values. The API usage would be simpler this way.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This suggestion is not consistent with usage. See createtorrent.html.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This suggestion is not consistent with usage. See createtorrent.html.

My suggestion will work in createtorrent.html and it will be clearer.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I prefer the current interface and think it makes sense.

Copy link
Member

@Chocobo1 Chocobo1 Sep 27, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I prefer the current interface and think it makes sense.

The interface is confusing. It will produce questions like these: get() isn't really getting the value. It is only getting from the cache. If the cache doesn't already hold the value, it fails. This contracts the assumption that get() is getting value from the server.
And fetch() is also returning the values. Which function is actually preferred to get values? Someone not familiar with the API internals will probably just abuse fetch() for everything. And get() will just be left out.
Also, in order to use get() a previous fetch() is required. Why this restriction? It leads to duplicate code.
And not every time the returned value from fetch() is put into use which leads to wasted computation.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems to me you're imaging some convoluted, unlikely scenario because I made different design decisions than you would have. I can't imagine any developer actually facing the confusion you're describing. Good thing is, if people do somehow get confused by that, we can always change it later.

And not every time the returned value from fetch() is put into use which leads to wasted computation.

You're trying to over optimize JS code.

I would also like to note that get() was originally called getCached(), but was changed based on your recommendation. #23191 (comment)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On a second thought, I don't insist on suggested version anymore. It will bring await/async to everywhere which isn't necessary.

However parts of my previous reply still applies:

And fetch() is also returning the values. Which function is actually preferred to get values? Someone not familiar with the API internals will probably just abuse fetch() for everything. And get() will just be left out.

And not every time the returned value from fetch() is put into use which leads to wasted computation.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can add some jsdoc if you really think people will get confused about which to use.

@Piccirello Piccirello force-pushed the client-prefs-webui branch 2 times, most recently from 77c79d1 to dcec1fd Compare September 20, 2025 23:14
@Piccirello Piccirello requested review from a team September 27, 2025 02:41
thalieht
thalieht previously approved these changes Sep 27, 2025
Comment on lines +83 to +113
get(key) {
return this.#cache.get(key);
}

/**
* @param {string[]} keys
* @returns {Record<string, any>}
*/
async fetch(keys = []) {
const keysToFetch = keys.filter((key) => !this.#cache.has(key));
if (keysToFetch.length > 0) {
const fetchedData = await this.#fetch(keysToFetch);
for (const [key, value] of Object.entries(fetchedData))
this.#cache.set(key, value);
}

return Object.fromEntries(keys.map((key) => ([key, this.#cache.get(key)])));
}
Copy link
Member

@Chocobo1 Chocobo1 Sep 27, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I prefer the current interface and think it makes sense.

The interface is confusing. It will produce questions like these: get() isn't really getting the value. It is only getting from the cache. If the cache doesn't already hold the value, it fails. This contracts the assumption that get() is getting value from the server.
And fetch() is also returning the values. Which function is actually preferred to get values? Someone not familiar with the API internals will probably just abuse fetch() for everything. And get() will just be left out.
Also, in order to use get() a previous fetch() is required. Why this restriction? It leads to duplicate code.
And not every time the returned value from fetch() is put into use which leads to wasted computation.

let clientDataPromise;
const setup = () => {
// fetch various data and store it in memory
clientDataPromise = window.qBittorrent.ClientData.get([
Copy link
Member

@Chocobo1 Chocobo1 Sep 27, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's no guarantee it'll be set later - our code will still need a null check.

You don't need to check anything. Just set it to 1 unconditionally so that the actual migration code that needs it can just use it.


// Show Top Toolbar is enabled by default
let showTopToolbar = localPreferences.get("show_top_toolbar", "true") === "true";
let showTopToolbar = clientData.get("show_top_toolbar") !== false;
Copy link
Member

@Chocobo1 Chocobo1 Sep 27, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Continuing #23191 (comment)

The code has become less straightforward.
Later someone will probably try to change it to (or write new code) clientData.get("show_top_toolbar") === true and only to find out it doesn't work reliably.
This kind of usage hides a lot of elusive details and it just make the code harder to comprehend, maintain and write.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This code is now more straightforward. Rather than some additional parameter that applies in unknown situations, the caller can use native JS to get their default value.

See #23191 (comment)

clientData.get("show_top_toolbar") === true and only to find out it doesn't work reliably.

In what way does this not work reliably?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rather than some additional parameter that applies in unknown situations

It is always clear that the default value only applies when the actual value doesn't exist. This pattern is extensively used in our C++ code base and in Qt class methods. We never had a problem/confusion with it.

clientData.get("show_top_toolbar") === true and only to find out it doesn't work reliably.

In what way does this not work reliably?

You told me about it. === true comparison will fail on first use when the value doesn't exist. Which is the exact reason why you coded !== false instead.
This hidden pitfall is going to be a nightmare in terms of maintenance and readability. I rather let the default value be explicit and non optional than to allow such trap to exist.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is always clear that the default value only applies when the actual value doesn't exist. This pattern is extensively used in our C++ code base and in Qt class methods. We never had a problem/confusion with it.

Different languages have different paradigms. In JS, the nullish coalescing operator can be used to assign a default value when the value is nullish (either null or undefined). In our case, the value is only nullish when it does not exist.

This hidden pitfall is going to be a nightmare in terms of maintenance and readability. I rather let the default value be explicit and non optional than to allow such trap to exist.

Sorry, I really just don't see this as true. To me, my solution is modern idiomatic JS.

@Piccirello
Copy link
Member Author

@qbittorrent/bug-handlers can I get a review and get this merged? This will make a big difference for WebUI users and belongs in v5.2.


// Show Top Toolbar is enabled by default
let showTopToolbar = localPreferences.get("show_top_toolbar", "true") === "true";
let showTopToolbar = clientData.get("show_top_toolbar") !== false;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rather than some additional parameter that applies in unknown situations

It is always clear that the default value only applies when the actual value doesn't exist. This pattern is extensively used in our C++ code base and in Qt class methods. We never had a problem/confusion with it.

clientData.get("show_top_toolbar") === true and only to find out it doesn't work reliably.

In what way does this not work reliably?

You told me about it. === true comparison will fail on first use when the value doesn't exist. Which is the exact reason why you coded !== false instead.
This hidden pitfall is going to be a nightmare in terms of maintenance and readability. I rather let the default value be explicit and non optional than to allow such trap to exist.

/**
* @param {Record<string, any>} data
*/
async set(data, suppressError = false) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After looking closely at the backend API, I kept wondering why not just serialize the cached data into a single json string in WebUI and store/load it at the server under a single key? This seems a lot simpler to me.
Also, what benefits does it bring for using multiple keys than just a single one?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't want to transmit the entire JSON blob any time we want to update a single key/value. The current approach is more granular.

Comment on lines +83 to +113
get(key) {
return this.#cache.get(key);
}

/**
* @param {string[]} keys
* @returns {Record<string, any>}
*/
async fetch(keys = []) {
const keysToFetch = keys.filter((key) => !this.#cache.has(key));
if (keysToFetch.length > 0) {
const fetchedData = await this.#fetch(keysToFetch);
for (const [key, value] of Object.entries(fetchedData))
this.#cache.set(key, value);
}

return Object.fromEntries(keys.map((key) => ([key, this.#cache.get(key)])));
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On a second thought, I don't insist on suggested version anymore. It will bring await/async to everywhere which isn't necessary.

However parts of my previous reply still applies:

And fetch() is also returning the values. Which function is actually preferred to get values? Someone not familiar with the API internals will probably just abuse fetch() for everything. And get() will just be left out.

And not every time the returned value from fetch() is put into use which leads to wasted computation.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

WebUI WebUI-related issues/changes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Include webui settings as preference in config rather than in browser localstorage

4 participants