-
Notifications
You must be signed in to change notification settings - Fork 141
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
RFC - Storage Layer for Plugins V2 💿 #638
Conversation
🦋 Changeset detectedLatest commit: b18d0fb The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
@@ -183,18 +200,92 @@ export interface CookieOptions { | |||
sameSite?: string | |||
} | |||
|
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.
Typescript nitpick -- Is there a way we can make this fully generic -- like the event emitter?
That way, you can pass in the object and get fully typed keys and values:
export class UniversalStorage<Data extends Record<string, unknown>> {
..
public get<K extends keyof Data>(
key: K,
storeTypes?: StoreType[]
): Data[K] | null {
let val = null
for (const store of this.getStores(storeTypes)) {
val = store.get<Data[K]>(key as string) // store can be made generic too
if (val) {
return val
}
}
return null
}
would lead to something like:
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.
Great suggestion. Added. Could you please if there is any TS smells in my changes or anything can be improved?
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 don't see any of the changes yet -- did you push?
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.
Yes, here is the commit: c17e5fc
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 see the build error -- you could force people to always explicitly pass data or, in the rare case we don't care (maybe a test?), we might want to add add a default generic parameter like so:
type StorageObject = Record<string, unknown>
class UniversalStorage<Data extends StorageObject> = StorageObject { ...
}
I know one person that really thinks default generic parameters can be sometimes "harmful", but they can also be quite convenient
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 know one person that really thinks default generic parameters can be sometimes "harmful"
What? But they're so handy!
|
||
return this.set( | ||
key, | ||
typeof val === 'number' ? val.toString() : val, |
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's the reasoning behind coercing to number here rather than deferring to set? This feels a bit confusing if that makes sense =) Can we move it elsewhere?
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 is a legacy behavior. I think the reason seems to be AJS classic may set number cookies, but in AJS next we want to convert those to string ( not sure why, maybe because our cookie library doesn't support them 🤔 ) ... I think the problem with this code shows even more with stronger typings that I just added, hence had to add the @ts-ignore
. It breaks the assumption that a given cookie key will always have a certain value type :(
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.
Gotcha, I kind of missed that this logic already existed -- all of this context would be great as a comment!
this.stores.push(store) | ||
} | ||
|
||
public getAndSync<T>(key: string, storeTypes?: StoreType[]): T | null { |
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.
Something feels a bit off about this method -- it breaks command-query separation, but that's maybe not it (returning data in an update isn't that uncommon) -- maybe calling it just resync
or resyncAndGet
would be a bit better, to show that its primary function should be to sync (and the "get" is just a kind of an extra).
Silly question: If .set
"syncs" all of the stores by default, why do they later need to be resynced? Is something happening outside that would cause them to get out of whack? If so, we might probably still want an explicit sync to make that extra clear.
This feels a bit weird here:
return this.identityStore.getAndSync(this.anonKey) |
I lean towards replacing with a resync()
method to make it extra explicit that something weird happened and we might need to resync stores. Would that work?
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.
Same, not a fan of this method either. The reason it exists is probably two folds:
- If a value exists in one storage but not others ( for whatever reason, i.e., cookies are cleared but localStorage is not ) it syncs the identities across the storages again
- There is some formatting mismatch between AJS classic and next cookies, and this makes sure that old AJS cookies are re-written in the new format as soon as we call
getAndSync
The reason not refactoring this is to reduce the blast reduce of this PR. This touches fairly critical part of the code, and don't want to break things in one huge PR.
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.
Same as ^.... I kind of missed that this logic already existed -- these kind of WTF / legacy stuff is the kind of thing I absolutely love as comments.
} | ||
|
||
public set<K extends keyof Data>( | ||
key: string, |
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.
was this meant to be "K" rather than string?
} | ||
|
||
public getAndSync<K extends keyof Data>( | ||
key: string, |
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.
was this meant to be "K" rather than string?
1c7c64f
to
11a9258
Compare
: this.stores | ||
} | ||
|
||
public push(store: Store) { |
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 push
public so that anyone can write their own implementation and add 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.
Good catch, removed it as it is not used anywhere. I think if we see a need to add an storage on the fly after creation of a UniversalStorage object we can add it back.
@@ -109,7 +113,7 @@ function referrerId( | |||
} | |||
|
|||
if (!disablePersistance) { |
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 probably don't need this check anymore since the universal storage handles this for us now right?
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 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.
🇮🇷 🇨🇦 🚀
Carrying over the comment from previous PR about scoping the storage access - #594 (review) |
get = (_key: string): null => null | ||
set = (_key: string, _val: unknown): null => null | ||
remove = (_key: string): void => {} | ||
getType = (): StoreType => 'cookie' | ||
} | ||
|
||
export class LocalStorage extends Store { |
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 know you didn't write this code, but maybe we can remove some of the smells from this class?
get<T>(key: string): T | null {
const val = localStorage.getItem(key) // this should be wrapped in a try/catch in case storage is not available
if (val) {
try {
return JSON.parse(val)
} catch (e) {
// this will just return empty object if it's a parse error? This is a bug waiting to happen -- and `JSON.parse(JSON.stringify(val))` is always a smell
return
JSON.parse(JSON.stringify(val))
}
}
return null
}
would be more idiomatically written as:
get<T>(key: string): T | null {
try {
const val = localStorage.getItem(key) ?? null // this should be wrapped in a try/catch in case storage is not available
return JSON.parse(val);
} catch (e) {
console.error(e)
return null
}
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 can probably refactor in a subsequent PR. This code has changed since the comment, and don't want to complicate this PR further.
static available(): boolean { | ||
if (LocalStorage._available !== undefined) { |
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.
If we decide wrap localStorage.getItem
and .setItem
in a try/catch (per common practice), we can get rid of this extra "availability" logic? I feel like it's more typical to just return null on any thrown errors -- such as those from LS being unavailable.
Also would fix: #631
Other thoughts:
- I still remember the CDN complexity brought on by that global state -- having the caching part (like CDN) is an optimization that comes with significant trade offs in making code harder for test and harder to reason about, and adds the possibility of bugs having to do with a stale cache / race conditions. So a few reasons why I'd be interested in brainstorming another approach.
_available
won't be minified (per many people reminding me!) -- even if you doprivate static _available
/my 2 cents =)
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.
Yes, good point. Probably the available can be replaced with try/catch. Another one of those changes that I want to avoid in the scope of this PR. Maybe we can improve in a subsequent work. Also as for the minification, I think this is a bigger problem across the code-base. The regular class methods/properties are all remain un-minified unfortunately. I think bigger piece of work would be to refactor this whole thing to not be class based 😆
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.
Refactored to not rely on caching anymore, but keep availability logic to avoid breaking anything.
@@ -173,6 +190,8 @@ export class LocalStorage extends Store { | |||
remove(key: string): void { | |||
return localStorage.removeItem(key) | |||
} | |||
|
|||
getType = (): StoreType => 'localStorage' |
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.
Hey, do we really need this? Typical way of narrowing that is by checking instanceOf -- in general, since it's a class and not a plain object, I don't generally ever seen a reason for labels like this on classes unless it's a like field on a class that's meant to be serialized (like a custom error) .
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.
Revising this comment, after delay. The UniversalStorage will be passed into the action-destinations, and I want the action-destinations to have a nice way to choose their storage targets: i.e., storage.get('test', ['cookies'])
... Not sure possible cleanly here 🤔
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.
Refactored to use getters, at list slightly cleaner/nicer
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.
👍
da031b5
to
275d5a3
Compare
03daae9
to
f1bbdd4
Compare
1e71aa5
to
9549176
Compare
Second version of the RFC ( first version here )
Problem: If a plugin needs to use browser storage ( cookies, LS, etc. ), it has to implement the storage handling functionality, and Cookie handling in particular requires decent amount of code. Furthermore, plugins aren't aware of AJS storage configuration, so they may use storage regardless of how users configured the AJS.
In order to enable plugins ( our particular use-case is action destinations ) access the storage layer of AJS, we are creating a universalStorage object during initialization and pass it down to plugins as a dependency when registering plugins.
UniversalStorage
is an interface over a collection of different storage types like cookie, localStorage, and memory that can persist to or read data from those collection of storage objects.Few things of note:
cookie
localStorage
memory
User
storage, we create three instance of universal storage: one for identity, one for legacy identity, and one for traitsset
/get
methods