-
Notifications
You must be signed in to change notification settings - Fork 42
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
Implementing beforeRead and beforeWrite #250
Conversation
Previously it would set the store value to null, which would break with the type safety
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.
Left one comment about null handling.
We should probably update the README (can just add examples of the signature in the options)
Maybe in the future we can have a section in the README about migration using beforeRead
and beforeWrite
. I can write something after merging.
test/localStorageStore.test.ts
Outdated
const store = persisted("beforeRead-init-test", 0, { beforeRead: (v) => v * 2 }) | ||
expect(get(store)).toEqual(4) | ||
}) | ||
it("allows modfiying value before reading upon event", () => { |
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.
should be "modifying" 😸
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.
Omg, my spelling is actually terrible
Thank you for your quick response @joshnuss (and your amazing work on this library)! Hope the changes fixes your concerns |
So I have been doing some thinking, and it annoys me that we don't have any pre-read validation capability. What I mean is, what if a value is written to local storage, the event fires, the persisted store serializes the value, and it then reaches beforeRead, but we in beforeRead decides to discard the value and want to keep the current value? I could see plenty of scenarios where this would be the case (imagine migration where we don't truly want to migrate the data, but just use the initial value every time the data in local storage is out of date). Honestly, I think the same capability would be useful for beforeWrite (to cancel the write to localStorage). Now, the question becomes, how do we do this? I considered passing in the currentValue in beforeRead to enable the developer to just return that, but the same approach doesn't really work for beforeWrite. We could also maybe passe in a function to beforeRead and beforeWrite which when called would cancel the operation. What do you think would be the cleanest? |
I think passing the value makes sense, for example: beforeRead(val, existing) {
// abort, return existing
return existing
} Also makes it easy to skip: beforeRead(val) {
// no need to take extra parameter
} |
Otherwise this PR looks good! Thanks @bertmad3400!! Should I merge? or do you want to add the new parameter to beforeRead/beforeWrite in this PR? |
Uses a symbol which can be returned by either function to cancel the operation
Yeah, so I wasn't 100% satisfied with the current solution, especially as it would leave the beforeWrite without a cancel operation, and would also make this a rather asymetric solution. As such, I had a thought and some discussion, and ended up implementing a cancel parameter that if returned (instead of a new value) cancels this operation. Under this hood, this uses symbols to differentiate between what could be a new value for the store, and the cancel param |
I would love for someone to quickly look over the typing (as it has become rather complex), but except for that, I think we are good to merge |
index.ts
Outdated
@@ -57,7 +57,10 @@ export function persisted<T>(key: string, initialValue: T, options?: Options<T>) | |||
const storage = browser ? getStorage(storageType) : null | |||
|
|||
function updateStorage(key: string, value: T) { | |||
const newVal = beforeWrite(value) | |||
const cancel = Symbol("cancel") |
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.
Why the need for a symbol?
Can't beforeWrite
just return a value?
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.
As described before, the symbol here is used to cancel the write operation. Imagine a case where the beforeWrite function would want to cancel the writing to localStorage (this could maybe be because of invalid data, because of cases like #217, or maybe something else). As we don't know of the shape of the store data, beforeWrite should be able to return any kind of value (primitives (numbers, strings, etc.), objects, arrays and even null/undefined) and have it written to localStorage. As such, if we want to implement cancel by return value, we have to create a value that we know is unique, and that we know that beforeWrite couldn't possibly return, unless it's explicitly our cancel value, which is why symbols are used (as this is their entire purpose). The only other way of implementing a cancel operation would be to passe in a cancel function that the when called would cancel the write operation, but not only is this more code in the library, but I also believe it to be more confusing for the user, as the return value suddenly have no effect then.
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 still unclear on why beforeWrite()
should be allowed to cancel and how often this is needed. (Just trying to keep implementation and docs simple)
Cancelation means "keep the last value", but we could do that with a return statement. for example
beforeWrite(val) {
// cancel write by writing last value
return JSON.parse(localStorage.getItem(key))
}
But still unclear on why anyone would want to cancel a write, and let the store's state get out of sync with local storage.
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.
Se there's a couple of sides to this:
- First of, while the code you presented here technically allows for keeping the same value, I don't think it's very nice for the user to have to write that. The reason they're using this library is to avoid accessing localStorage in the first place.
- Second of, what you have written here isn't actually equivalent to cancelling the operation, due to the fact that just returning the same value as currently present will still write that value to localstorage. This has a couple of implications:
- It means a case like the one described in Delete from storage when setting back to default value #217, where there shouldn't be written anything to localStorage before the user has accepted it, becomes near impossible. I do realize that a potential solution could be to just write null to localStorage as I would imagine this would be fine for the legislation, but honestly, I don't know and I do find it to be a messy solution.
- It means that there's no way to - on a case by case basis - prevent the store from triggering the re-rendering on other tabs as even writting the same value to localStorage will send the localStorage event. I personally work with a particular case where this is needed, as a lot of the DOM rendering is dependent on the store value (which can be really heavy for the users device to rerender), and as such we only want to sync the store across tabs under specific conditions - something which this would enable, and which is really difficult to do under the current implementation (I do realize the existence of the sync option, but that doesn't work on a case-by-case basis). I would imagine that there would be other cases like this
Ultimately, it would be up to you whether we want to keep this functionality or not (I can just remove it if not desired), but I would personally recommend for it, both due to the previously mentioned cases (where I don't see a simple solution without it), and because I believe - from a developer perspective - that it's a simple and intuitive solution. Just returning the existingVal to beforeRead would mean that the input signatures for beforeRead and beforeWrite would be different, and will - in my mind - definitely at some point lead to developers asking us "It's great I can prevent updates through beforeRead, but how do I do the same for beforeWrite?". I think the solution of keeping the input signature, and the mechanism for cancelling (by returning the cancel param) the same for both functions is the simplest for developers to understand and get used to.
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.
To clarify, I was questioning why cancel is needed for beforeRead & before write.
I think it's overkill for this library right now.
As with all decisions, I'm open to changing it if more people ask for it.
It's important when building things to target the 80% - what most people want. If we try to target 100% this project will get very complicated, and it will impact the users and the maintenance.
I appreciate your work on this, but I think we should not worry about edge cases untill we hear many people need this.
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.
Got it. While I'm still not sure I agree, I definitely see your point, and will just go ahead and remove this functionality.
…e-Write and -Read
README.md
Outdated
@@ -51,6 +51,8 @@ export const preferences = persisted('local-storage-key', 'default-value', { | |||
syncTabs: true, // choose whether to sync localStorage across tabs, default is true | |||
onWriteError: (error) => {/* handle or rethrow */}, // Defaults to console.error with the error object | |||
onParseError: (raw, error) => {/* handle or rethrow */}, // Defaults to console.error with the error object | |||
beforeRead: (value) => {/* change value after serialization but before setting store to return value. Return cancel to cancel the operation*/}, |
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.
Small thing: Can we remove the messaging about cancel for beforeWrite
and beforeRead
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.
Whoopsi, got it fixed now
@@ -21,71 +21,85 @@ export interface Serializer<T> { | |||
|
|||
export type StorageType = 'local' | 'session' | |||
|
|||
export interface Options<T> { | |||
serializer?: Serializer<T> | |||
export interface Options<StoreType, SerializerType> { |
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.
Should the SerializerType
default to StoreType
? or will it be a breaking change
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 the trouble here is the same as in your other question regarding whether to include more types, and as such much of my answer is the same. Technically though, no, SerializerType and StoreType are two different types, as beforeRead may be used to alter the shape coming from deserialize to fit the store shape
index.ts
Outdated
} | ||
export function persisted<T>(key: string, initialValue: T, options?: Options<T>): Writable<T> { | ||
export function persisted<StoreType, SerializerType>(key: string, initialValue: StoreType, options?: Options<StoreType, SerializerType>): Writable<StoreType> { |
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.
Are more types absolutely required? This will impact everyone using TypeScript even if they don't use beforeRead
/beforeWrite
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.
Yeah, that was one of my pet peeves as well with this change. I would love to have you look over the types quickly, and see if you have a better solution. Currently, this is the most accurate solution, but thinking about it, it might be better to just type the output of Parse and input to Stringify as unknown
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.
Uh yeah, just experimented with it and typing it as unknown definitely isn't the right move (as this requires immense data-validation efforts on the app-dev side, something better handled by just handling errors when they arrive). I think we might have to keep the split types, although I'm open to potential suggestions
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.
What about setting a default type for SerializerType?
persisted<T, SerializerType = T>(...)
Then it's not a breaking change. And people that don't use beforeRead/Write aren't impacted.
Also, I don't use TS, so I'd rather get the opinion from people that do (p.s. I feel like its a little weird that I am maintaining a TS lib, kinda wish it was just JS, and let TS people define a .d.ts)
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.
Damn, you don't do TS? I guess, if you mainly do libraries it makes sense, but as an app developer, TS over JS is the single biggest productivity improvement I have made in my 10 years of programming. I could never, ever, never go back to untyped code-bases.
Regarding this, I agree your solution is awesome! I wasn't actually aware that this was possible, but just implemented it, so I think we are ready to merge. Once again, thank you for your awesome work on this library!
|
||
if (json) { | ||
function maybeLoadInitial(): StoreType { | ||
function serialize(json: any) { |
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.
Is there a reason for extracting this function?
I think it's only called once, inside the current scope
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.
Thanks @bertmad3400!! You rock sir! Thanks for sticking with this. |
🎉 This PR is included in version 0.10.0 🎉 The release is available on: Your semantic-release bot 📦🚀 |
Implemented the beforeRead and beforeWrite methods described in #244. When considering this PR it's important to have the following in mind:
The type of the data stored in localStorage may no longer be the same or even have the same shape as the data stored in the store with the corresponding key. This is absolutely intentional, but it does bring some "quirks" when it comes to stuff like typing, as the data shapes may be different:
It should also be noted, that as discussed in #244 (comment), I have removed the behavior where the store contents would be set to null if the storage event contents are null. As mentioned by #244 (comment) this is a type check specifically to ensure that the value of the event isn't null (and it's important), but the correct response here should be to avoid a null value