Skip to content
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

Merged
merged 12 commits into from
May 31, 2024
Merged

Conversation

bertmad3400
Copy link
Contributor

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:

  • The input shape to the serializer stringify function and the output shape from the serializer stringify function is now no longer the shape of content of the store, but rather any, as the functions beforeRead and beforeWrite are intended to be used to transform the data into the shape of the store, but it is worth keeping in mind that the serializer functions are now typed with any instead of a specific shape
  • This does however bring some interesting possibilities like storing a version number only in the localStorage and the use these proxy functions (beforeRead and beforeWrite) to not only validate this version number, but to strip it if it isn't of use for the application

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

Copy link
Owner

@joshnuss joshnuss left a 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.

const store = persisted("beforeRead-init-test", 0, { beforeRead: (v) => v * 2 })
expect(get(store)).toEqual(4)
})
it("allows modfiying value before reading upon event", () => {
Copy link
Owner

Choose a reason for hiding this comment

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

should be "modifying" 😸

Copy link
Contributor Author

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

test/localStorageStore.test.ts Show resolved Hide resolved
@bertmad3400
Copy link
Contributor Author

Thank you for your quick response @joshnuss (and your amazing work on this library)! Hope the changes fixes your concerns

@bertmad3400
Copy link
Contributor Author

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?

@joshnuss
Copy link
Owner

joshnuss commented May 5, 2024

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
}

@joshnuss
Copy link
Owner

joshnuss commented May 5, 2024

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?

@bertmad3400
Copy link
Contributor Author

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

@bertmad3400
Copy link
Contributor Author

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")
Copy link
Owner

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?

Copy link
Contributor Author

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.

Copy link
Owner

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.

Copy link
Contributor Author

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:

  1. 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.
  2. 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.

Copy link
Owner

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.

Copy link
Contributor Author

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.

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*/},
Copy link
Owner

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

Copy link
Contributor Author

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> {
Copy link
Owner

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

Copy link
Contributor Author

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> {
Copy link
Owner

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

Copy link
Contributor Author

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

Copy link
Contributor Author

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

Copy link
Owner

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)

Copy link
Contributor Author

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) {
Copy link
Owner

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

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I actually didn't introduce this function (merely changed its type) and as such I deemed it to be outside the scope of this PR (though yes, I agree, it shouldn't be a separate, scope-wide function). It was introduced back in d77aeaa, as a fix for #231

@joshnuss joshnuss merged commit 1a83f84 into joshnuss:master May 31, 2024
4 checks passed
@joshnuss
Copy link
Owner

Thanks @bertmad3400!!

You rock sir! Thanks for sticking with this.

Copy link

🎉 This PR is included in version 0.10.0 🎉

The release is available on:

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants