-
-
Notifications
You must be signed in to change notification settings - Fork 4.4k
WebUI: Store durable settings in client data API #23191
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Conversation
bbffd53
to
4b1bf7b
Compare
4b1bf7b
to
1bd7ae3
Compare
let clientDataPromise; | ||
const setup = () => { | ||
// fetch various data and store it in memory | ||
clientDataPromise = window.qBittorrent.ClientData.get([ |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
.
011ac5a
to
30807d0
Compare
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)]))); | ||
} |
There was a problem hiding this comment.
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:
get()
will first try to get the value from#cache
and if it does not exist, fetch it from the server.fetch()
will just get the values from server and store it in cache. It will not return any value but onlyPromise
. This function will become a "prefetch" action. The caller should only useget()
for getting the values. The API usage would be simpler this way.
There was a problem hiding this comment.
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
.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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)
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
77c79d1
to
dcec1fd
Compare
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)]))); | ||
} |
There was a problem hiding this comment.
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([ |
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
@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; |
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
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)]))); | ||
} |
There was a problem hiding this comment.
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.
dcec1fd
to
83978ea
Compare
83978ea
to
7c539f3
Compare
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