-
-
Notifications
You must be signed in to change notification settings - Fork 668
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
[WIP] redux: Add transform to keep parsed ZulipVersion in Redux state. #3952
[WIP] redux: Add transform to keep parsed ZulipVersion in Redux state. #3952
Conversation
9db89ea
to
b68d3ab
Compare
My first question on this is here. Also, I wonder about all my imports of the ZulipVersion class, particularly where I only use it as a type, like in src/actionTypes.js. It's the first import of runtime code in that file ( |
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.
My first question on this is here.
Assuming you mean
The other way we could go is more general; we apply the inbound and outbound transforms to all fields named zulipVersion that appear anywhere in the state.`
then (as implied by one of my comments below) that's definitely not a thing we want to do: we have a number of maps with (almost?) arbitrary string keys, which might well have a field zulipVersion
.
Also, I wonder about all my imports of the ZulipVersion class, particularly where I only use it as a type, like in src/actionTypes.js. It's the first import of runtime code in that file (
import...
instead ofimport type...
); is this a pattern we'd like to avoid?
It is, yes. I believe import type { ZulipVersion } from ...
will work just as well in that case.
src/boot/store.js
Outdated
zulipVersion: | ||
// $FlowMigrationFudge | ||
a.zulipVersion instanceof ZulipVersion ? a.zulipVersion : new ZulipVersion(a.zulipVersion), |
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 $FlowMigrationFudge
is covering an actual type error. If the account does not have a zulipVersion
, this transformation will try to instantiate new ZulipVersion(undefined)
. The migration needs to retain the property of "not having a zulipVersion
field at all".
In fact, I'm pretty sure the migration should be the identity function! The on-disk form of the data stored is identical in the old and new worlds – we're (still) storing zulipTransform
as the original raw 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.
Good catch, thanks!
src/boot/store.js
Outdated
*/ | ||
const zulipVersionTransform = createTransform( | ||
// Only applies to the `zulipVersion` field in state.accounts. | ||
(inboundState, 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.
(It doesn't help matters that their choice of definition of "in" and "out" are the opposite polarity I was expecting.)
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.
Me too 😉
Happily this name belongs to a parameter, so the library's choice for it is effectively just documentation -- we're free to call it what we like. "storageBoundState" vs "reduxBoundState"? "writingState" vs "readingState"?
src/boot/store.js
Outdated
} | ||
const result = inboundState.map(account => { | ||
if (account.zulipVersion === undefined) { | ||
return { ...account, zulipVersion: 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.
Type error: this should just be return account;
.
src/boot/store.js
Outdated
if (account.zulipVersion === undefined) { | ||
return 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.
Far worse type error: this should also be return account;
. (As written, it will lose the entire account!)
Thanks for the review, @ray-kraesig!
In particular, this is a strong reason to go straight to What we're left with is a difficulty in having a system that's agnostic to the particular way ZulipVersion instances are currently stored in Redux (i.e., in objects in the Thoughts, @gnprice? |
I agree that it'll be better to do this in a shape that operates on a particular spot in the structure, like this prototype, rather than looking for keys anywhere with the name If we wanted to do this with
For this to work, naturally it's important that you don't have any objects that accidentally have |
I could also see us deciding that this strategy, of transforming particular parts of the state tree based on where they're found, is what we want to do for handling Within this strategy, I think there are two main things I'd like to refine starting from here:
(savingState, key) => {
switch (key) {
case 'accounts':
return accountsTransformSave(savingState);
default:
return savingState;
}
} (The migration functions feel different to me, more OK to centralize like this -- perhaps partly because they aren't relevant to the steady state of the app, while these transform functions are.)
|
Storing the parsed ZulipVersion instance in Redux makes it easier to work with, so we don't have to think about constructing a new instance as part of every server feature check. The one tricky bit is that our Redux state is serialized with JSON.stringify by redux-persist so that it can be stored in ZulipAsyncStorage, but the ZulipVersion instance itself is not serializable. Thankfully, it can be fully represented by its raw version string, which is serializable. So, on entry into ZulipAsyncStorage, convert the ZulipVersion instance into the raw version string with .raw(), and on exit, call the ZulipVersion constructor on that raw version string. We use redux-persist's `createTransform`. We locate a value to apply this transformation to with an "outside-in" approach, in particular, by the value's extrinsic property of where it is in the Redux store. That is, we know that it's at the `zulipVersion` key on all Account objects in state.accounts. Instead, we might have used an "inside-out" approach to locate a value by its *intrinsic* property of being a ZulipVersion instance. (We don't insist on using a single approach for all future transforms, but it's useful to compare the two because it's possible that some kinds of transformations fit one way better than the other. See zulip#3950, "Use Map rather than object-as-Map in our Redux state", and zulip#3949, "Use good data structures in Redux state, to avoid quadratic work", for more things we'll need to transform.) The "outside-in" approach requires that we write and maintain code to establish the location of the values to transform. But the complexity of that code will be no more than the complexity of the reducer(s) in charge of the same part(s) of the state, so the burden isn't worrisome. Establishing the location is a two-step process: 1) In src/boot/store, tell the redux-persist transform, created with createTransform, what key you want to handle. (In this case, 'accounts'.) 2) In the reducer file corresponding to that key, write "save" and "load" functions that know where to find the value. (They also do the actual transformation.) It's helpful in step 2 to consolidate code into the reducer files, which already have code that's aware of the structure of the part of state being treated. (Further consolidation like this is zulip#3934.) If we had instead gone with the "inside-out" approach (i.e., locating the value by its being `instanceof ZulipVersion`), which doesn't depend on knowing the value's location in the Redux state, we might have used a handy feature of `remotedev-serialize` called "custom serialization functions" (see https://github.com/zalmoxisus/remotedev-serialize#passing-custom-serialization-functions). As noted in zulip#3949, at some point we'll probably use remotedev-serialize for the particular purpose of transforming Immutable.js data structures, so it may be convenient to piggy-back on that for this custom functionality. Greg explains that strategy at zulip#3952 (comment): """ * at save time, values that are `instanceof ZulipVersion` get turned into objects like `{__serializedType__: 'ZulipVersion', data: '2.0.0-rc1'}`, before being JSON-serialized * at load time, after JSON deserialization, values that are objects with a `__serializedType__` property get transformed in a way identified by that property's value: in particular if `__serializedType__: 'ZulipVersion'`, then `data` is fed to `new ZulipVersion` """ This would eliminate the need for custom code that tracks the location of the values to be transformed. Reasons we don't use this strategy here are that we don't expect the location of the ZulipVersion value to change, and that the remotedev-serialize strategy always requires writing a migration; old data has never been persisted with that __serializedType__ property. Fixes: zulip#3951
b68d3ab
to
97b70fe
Compare
@chrisbobbe , you wrote over at #3745 (comment) referring to this PR:
Copying that here, because otherwise this thread looks to me like it's waiting for the next action from you 🙂 (A comment is a good way to say "this is ready for another review now".) Also, do you still want the "WIP" in the title? |
Ah, right — sorry about that, I should indeed have commented. And it looks like I do need to resolve some merge conflicts; I'll do that tomorrow.
This is a more complete version of the work toward this strategy, but I think the (from a commit message in this revision)
I think, when we last spoke about this in the office, you were still weighing the pros and cons of each, and didn't favor one over the other. If it's helpful, I can put together another PR with the other approach, just to see what that might look like. |
Keys with value `undefined` don't survive round-trips into JSON, and the zulipVersion will be persisted in JSON. Use `null`, to maintain that Account objects always have the same number of keys, and we don't have to worry about the distinction between an optional `string` property and a property that's `string | void`. The same strategy is in use for the ackedPushToken.
Storing the parsed ZulipVersion instance in Redux makes it easier to work with, so we don't have to think about constructing a new instance as part of every server feature check. The one tricky bit is that our Redux state is serialized with JSON.stringify by redux-persist so that it can be stored in ZulipAsyncStorage, but the ZulipVersion instance itself is not serializable. Thankfully, it can be fully represented by its raw version string, which is serializable. So, on entry into ZulipAsyncStorage, convert the ZulipVersion instance into the raw version string with .raw(), and on exit, call the ZulipVersion constructor on that raw version string. We use redux-persist's `createTransform`. We locate a value to apply this transformation to with an "outside-in" approach, in particular, by the value's extrinsic property of where it is in the Redux store. That is, we know that it's at the `zulipVersion` key on all Account objects in state.accounts. Instead, we might have used an "inside-out" approach to locate a value by its *intrinsic* property of being a ZulipVersion instance. (We don't insist on using a single approach for all future transforms, but it's useful to compare the two because it's possible that some kinds of transformations fit one way better than the other. See zulip#3950, "Use Map rather than object-as-Map in our Redux state", and zulip#3949, "Use good data structures in Redux state, to avoid quadratic work", for more things we'll need to transform.) The "outside-in" approach requires that we write and maintain code to establish the location of the values to transform. But the complexity of that code will be no more than the complexity of the reducer(s) in charge of the same part(s) of the state, so the burden isn't worrisome. Establishing the location is a two-step process: 1) In src/boot/store, tell the redux-persist transform, created with createTransform, what key you want to handle. (In this case, 'accounts'.) 2) In the reducer file corresponding to that key, write "save" and "load" functions that know where to find the value. (They also do the actual transformation.) It's helpful in step 2 to consolidate code into the reducer files, which already have code that's aware of the structure of the part of state being treated. (Further consolidation like this is zulip#3934.) If we had instead gone with the "inside-out" approach (i.e., locating the value by its being `instanceof ZulipVersion`), which doesn't depend on knowing the value's location in the Redux state, we might have used a handy feature of `remotedev-serialize` called "custom serialization functions" (see https://github.com/zalmoxisus/remotedev-serialize#passing-custom-serialization-functions). As noted in zulip#3949, at some point we'll probably use remotedev-serialize for the particular purpose of transforming Immutable.js data structures, so it may be convenient to piggy-back on that for this custom functionality. Greg explains that strategy at zulip#3952 (comment): """ * at save time, values that are `instanceof ZulipVersion` get turned into objects like `{__serializedType__: 'ZulipVersion', data: '2.0.0-rc1'}`, before being JSON-serialized * at load time, after JSON deserialization, values that are objects with a `__serializedType__` property get transformed in a way identified by that property's value: in particular if `__serializedType__: 'ZulipVersion'`, then `data` is fed to `new ZulipVersion` """ This would eliminate the need for custom code that tracks the location of the values to be transformed. Reasons we don't use this strategy here are that we don't expect the location of the ZulipVersion value to change, and that the remotedev-serialize strategy always requires writing a migration; old data has never been persisted with that __serializedType__ property. Fixes: zulip#3951
97b70fe
to
33e4f19
Compare
OK, I've just resolved those merge conflicts. In that first commit, "redux types: Make zulipVersion string | null.", I made the necessary changes to the |
Great!
Yeah, I agree -- let's go ahead and do that too. Could be either the same or a following commit. |
Mmm, yeah, thanks for refreshing my memory on the state of this.
Yeah, I think that alternate PR would be quite useful! |
Storing the parsed ZulipVersion instance in Redux makes it easier to work with, so we don't have to think about constructing a new instance as part of every server feature check. The one tricky bit is that our Redux state is serialized with JSON.stringify by redux-persist so that it can be stored in ZulipAsyncStorage, but the ZulipVersion instance itself is not serializable. Thankfully, it can be fully represented by its raw version string, which is serializable. So, on entry into ZulipAsyncStorage, convert the ZulipVersion instance into the raw version string with .raw(), and on exit, call the ZulipVersion constructor on that raw version string. We use redux-persist's `createTransform`. We locate a value to apply this transformation to with an "outside-in" approach, in particular, by the value's extrinsic property of where it is in the Redux store. That is, we know that it's at the `zulipVersion` key on all Account objects in state.accounts. Instead, we might have used an "inside-out" approach to locate a value by its *intrinsic* property of being a ZulipVersion instance. (We don't insist on using a single approach for all future transforms, but it's useful to compare the two because it's possible that some kinds of transformations fit one way better than the other. See zulip#3950, "Use Map rather than object-as-Map in our Redux state", and zulip#3949, "Use good data structures in Redux state, to avoid quadratic work", for more things we'll need to transform.) The "outside-in" approach requires that we write and maintain code to establish the location of the values to transform. But the complexity of that code will be no more than the complexity of the reducer(s) in charge of the same part(s) of the state, so the burden isn't worrisome. Establishing the location is a two-step process: 1) In src/boot/store, tell the redux-persist transform, created with createTransform, what key you want to handle. (In this case, 'accounts'.) 2) In the reducer file corresponding to that key, write "save" and "load" functions that know where to find the value. (They also do the actual transformation.) It's helpful in step 2 to consolidate code into the reducer files, which already have code that's aware of the structure of the part of state being treated. (Further consolidation like this is zulip#3934.) If we had instead gone with the "inside-out" approach (i.e., locating the value by its being `instanceof ZulipVersion`), which doesn't depend on knowing the value's location in the Redux state, we might have used a handy feature of `remotedev-serialize` called "custom serialization functions" (see https://github.com/zalmoxisus/remotedev-serialize#passing-custom-serialization-functions). As noted in zulip#3949, at some point we'll probably use remotedev-serialize for the particular purpose of transforming Immutable.js data structures, so it may be convenient to piggy-back on that for this custom functionality. Greg explains that strategy at zulip#3952 (comment): """ * at save time, values that are `instanceof ZulipVersion` get turned into objects like `{__serializedType__: 'ZulipVersion', data: '2.0.0-rc1'}`, before being JSON-serialized * at load time, after JSON deserialization, values that are objects with a `__serializedType__` property get transformed in a way identified by that property's value: in particular if `__serializedType__: 'ZulipVersion'`, then `data` is fed to `new ZulipVersion` """ This would eliminate the need for custom code that tracks the location of the values to be transformed. Reasons we don't use this strategy here are that we don't expect the location of the ZulipVersion value to change, and that the remotedev-serialize strategy always requires writing a migration; old data has never been persisted with that __serializedType__ property. Fixes: zulip#3951
33e4f19
to
905f9d8
Compare
Done. |
…d of string. This commit should be read for its contrast with its counterpart, with the same title, at zulip#3952. I've tried to make this PR minimally different from that one, so a `git diff` or `git range-diff` might be informative. Here, we use the "inside-out" approach described there: > (i.e., locating the value by its being `instanceof ZulipVersion`), > which doesn't depend on knowing the value's location in the Redux > state As expected, it requires writing a (straightforward) migration. Unfortunately, I haven't gotten far enough to actually see this working, but I expect that our code in a working version won't need to be drastically different, so I'm submitting this for comparison anyhow. There appear to be some inaccuracies in the Flow libdef I cobbled together from the TypeScript with `flowgen` and `flow-typed create-stub`, but these shouldn't be difficult to fix. The current blocker, which blocks any use of ImmutableJS with Redux (an integration this PR depends on, as I noted at the bottom of zulip#3951 (comment)), is that we're stuck with an old version of `redux-persist`. Not until rt2zz/redux-persist@a94f29139, released in v6.0.0 (we're on ^4.10.2), do we get the ability to supply our own serialize/deserialize functions in the config passed to `persistStore`. The only other customization for replace/revive transforms that `redux-persist` allows is via a Transform created by `createTransform`, as we use in zulip#3952. But its contract requires that you supply functions that translate between serializable and non-serializable objects, without allowing us to take responsibility for the actual serialization. It would be a silly exercise, but it might be possible, to make it work with `createTransform` with an extra round-trip into JSAN [sic]. JSAN is what `remotedev-serialize` serializes into, and it claims to be more flexible than JSON. I believe zulip#2915 is our latest written knowledge about the drawbacks of `redux-persist` and why it ceases to be useful for us at version 5 and above, and it looks like we have a plan to vendor it.
Opened as #4047! 🙂 |
Storing the parsed ZulipVersion instance in Redux makes it easier to work with, so we don't have to think about constructing a new instance as part of every server feature check. The one tricky bit is that our Redux state is serialized, with JSON.stringify, by redux-persist so that it can be stored in ZulipAsyncStorage, but the ZulipVersion instance itself is not serializable. Thankfully, it can be fully represented by its raw version string, which is serializable. So, on entry into ZulipAsyncStorage, convert the ZulipVersion instance into the raw version string with .raw() ("replace" it), and on exit, call the ZulipVersion constructor on that raw version string ("revive" it). We considered two main strategies for locating the bit of state to be transformed (in this case, the `zulipVersion` field, which stores a ZulipVersion value, in elements of the `state.accounts` array): 1) The "outside-in" strategy, of identifying the value by the extrinsic property of where it is; i.e., that it's at the field named 'zulipVersion' on elements of the `state.accounts` array, or 2) The "inside-out" strategy, of identifying the value by its intrinsic property of being `instanceof ZulipVersion`. We chose the latter. When we work on zulip#3950, converting our object-as-map fields to use Map or Immutable.Map, we'll be making similar, sweeping changes to many different parts of the state, so it's most natural for the bulk of our logic to be independent of the location in state, and focus instead on the type of non-serializable object being stored. This approach conveniently clears the path to use ImmutableJS for additional reasons discussed later in this message. An exploration of the "outside-in" approach is left as the un-merged PR zulip#3952. The main advantage of that approach is that we wouldn't need to write migrations, but it had the significant drawback of requiring a lot of code (for locating the bit of state to be transformed) that was 1) boilerplate, and 2) difficult to get fully type-checked, which is a bad combination. These concerns were not sufficiently alleviated by the complexity of that boilerplate being bounded by the complexity of the reducer(s) in charge of the corresponding part(s) of the state. There's nothing stopping us from mixing the two approaches, in future, but it would be nice to stick to one as far as possible, for simplicity. For the "inside-out" implementation, we use `remotedev-serialize` (added in a recent commit), with custom replacer and reviver functions. As Greg explains at zulip#3952 (comment): """ * at save time, values that are `instanceof ZulipVersion` get turned into objects like `{__serializedType__: 'ZulipVersion', data: '2.0.0-rc1'}`, before being JSON-serialized * at load time, after JSON deserialization, values that are objects with a `__serializedType__` property get transformed in a way identified by that property's value: in particular if `__serializedType__: 'ZulipVersion'`, then `data` is fed to `new ZulipVersion` """ Since nothing has ever been stored in this `__serializedType__` format, we have to write a migration. It turns out to be very straightforward, and we expect it will be in the future. But we MUST remember to do so every time we use this approach in future. As mentioned above, this approach further clears the way for ImmutableJS. I've neglected to mention that the primary purpose of `remotedev-serialize` is to replace and revive ImmutableJS objects! (Note that we added `immutable` as a dependency in a recent commit.) So zulip#3949 and zulip#3950 will be much easier after this. The custom replacer/reviver functions we use here are a nice bit of flexibility provided on the side. Fixes: zulip#3951
Storing the parsed ZulipVersion instance in Redux makes it easier to work with, so we don't have to think about constructing a new instance as part of every server feature check. The one tricky bit is that our Redux state is serialized by redux-persist so that it can be stored in ZulipAsyncStorage, but the ZulipVersion instance itself is not serializable. Thankfully, it can be fully represented by its raw version string, which is serializable. So, on entry into ZulipAsyncStorage, we just have to convert the ZulipVersion instance into the raw version string, with .raw() ("replace" it), and on exit, make a new ZulipVersion instance, by calling the constructor with that raw version string ("revive" it). We considered two main strategies for locating the bit of state to be transformed (in this case, the `zulipVersion` field, which stores a ZulipVersion value, in elements of the `state.accounts` array): 1) The "outside-in" strategy, of identifying the value by the extrinsic property of where it is; i.e., that it's at the field named 'zulipVersion' on elements of the `state.accounts` array, or 2) The "inside-out" strategy, of identifying the value by its intrinsic property of being `instanceof ZulipVersion`. We chose the latter. When we work on zulip#3950, converting our object-as-map fields to use Map or Immutable.Map, we'll be making similar, sweeping changes to many different parts of the state, so it's most natural for the bulk of our logic to be independent of the location in state, and focus instead on the type of non-serializable object being stored. This approach conveniently clears the path to use ImmutableJS for additional reasons discussed later in this message. An exploration of the "outside-in" approach is left as the un-merged PR zulip#3952. The main advantage of that approach is that we wouldn't need to write migrations, but it had the significant drawback of requiring a lot of code (for locating the bit of state to be transformed) that was 1) boilerplate, and 2) difficult to get fully type-checked, which is a bad combination. These concerns were not sufficiently alleviated by the complexity of that boilerplate being bounded by the complexity of the reducer(s) in charge of the corresponding part(s) of the state: it's better just to not have to write boilerplate every time. There's nothing stopping us from mixing the two approaches, in future, but it would be nice to stick to one as far as possible, for simplicity. For the "inside-out" implementation, we use `remotedev-serialize` (added in a recent commit) [1], with custom replacer and reviver functions, defined in the previous commit. As Greg explains at zulip#3952 (comment): """ * at save time, values that are `instanceof ZulipVersion` get turned into objects like `{__serializedType__: 'ZulipVersion', data: '2.0.0-rc1'}`, before being JSON-serialized * at load time, after JSON deserialization, values that are objects with a `__serializedType__` property get transformed in a way identified by that property's value: in particular if `__serializedType__: 'ZulipVersion'`, then `data` is fed to `new ZulipVersion` """ Since we've never dealt with the Zulip version in this "live" (i.e., non-serializable) form, we have to write a migration. This was straightforward, but we MUST remember to write this kind of migration in the future. For making a value "live", where it wasn't before, the migration needs to: 1) As input, take the previous way of storing the data. Don't confuse this with the *current* way of storing the "dead" form. Just like any other migration, the previous shape is the input. 2) As output, give the "live" form of the data. Once it's in Redux, the replacer will take care of persisting it in the correct form. As mentioned above, this approach further clears the way for ImmutableJS; zulip#3949 and zulip#3950 will be much easier after this. I've neglected to mention that the primary purpose of `remotedev-serialize` is to replace and revive ImmutableJS objects! Its "default" replacers and revivers do this. The custom replacer/reviver functions we use here are a nice bit of flexibility provided on the side. (We added `immutable` as a dependency in a recent commit, since `remotedev-serialize` depends on it.) Fixes: zulip#3951 [1]: Actually, we use a wrapper around it, developed in a recent commit, to avoid a security hole. As long as we follow the pattern introduced in the two previous commits, using SerializeEscaped.immutable and its exported constant SERIALIZED_TYPE_FIELD_NAME, instead of Serialize.immutable (where Serialize is the default export of `remotedev-serialize`), we're fine.
Storing the parsed ZulipVersion instance in Redux makes it easier to work with, so we don't have to think about constructing a new instance as part of every server feature check. The one tricky bit is that our Redux state is serialized by redux-persist so that it can be stored in ZulipAsyncStorage, but the ZulipVersion instance itself is not serializable. Thankfully, it can be fully represented by its raw version string, which is serializable. So, on entry into ZulipAsyncStorage, we just have to convert the ZulipVersion instance into the raw version string, with .raw() ("replace" it), and on exit, make a new ZulipVersion instance, by calling the constructor with that raw version string ("revive" it). We considered two main strategies for locating the bit of state to be transformed (in this case, the `zulipVersion` field, which stores a ZulipVersion value, in elements of the `state.accounts` array): 1) The "outside-in" strategy, of identifying the value by the extrinsic property of where it is; i.e., that it's at the field named 'zulipVersion' on elements of the `state.accounts` array, or 2) The "inside-out" strategy, of identifying the value by its intrinsic property of being `instanceof ZulipVersion`. We chose the latter. When we work on zulip#3950, converting our object-as-map fields to use Map or Immutable.Map, we'll be making similar, sweeping changes to many different parts of the state, so it's most natural for the bulk of our logic to be independent of the location in state, and focus instead on the type of non-serializable object being stored. This approach conveniently clears the path to use ImmutableJS for additional reasons discussed later in this message. An exploration of the "outside-in" approach is left as the un-merged PR zulip#3952. The main advantage of that approach is that we wouldn't need to write migrations, but it had the significant drawback of requiring a lot of code (for locating the bit of state to be transformed) that was 1) boilerplate, and 2) difficult to get fully type-checked, which is a bad combination. These concerns were not sufficiently alleviated by the complexity of that boilerplate being bounded by the complexity of the reducer(s) in charge of the corresponding part(s) of the state: it's better just to not have to write boilerplate every time. There's nothing stopping us from mixing the two approaches, in future, but it would be nice to stick to one as far as possible, for simplicity. For the "inside-out" implementation, we use `remotedev-serialize` (added in a recent commit) [1], with custom replacer and reviver functions, defined in the previous commit. As Greg explains at zulip#3952 (comment): """ * at save time, values that are `instanceof ZulipVersion` get turned into objects like `{__serializedType__: 'ZulipVersion', data: '2.0.0-rc1'}`, before being JSON-serialized * at load time, after JSON deserialization, values that are objects with a `__serializedType__` property get transformed in a way identified by that property's value: in particular if `__serializedType__: 'ZulipVersion'`, then `data` is fed to `new ZulipVersion` """ Since we've never dealt with the Zulip version in this "live" (i.e., non-serializable) form, we have to write a migration. This was straightforward, but we MUST remember to write this kind of migration in the future. For making a value "live", where it wasn't before, the migration needs to: 1) As input, take the previous way of storing the data. Don't confuse this with the *current* way of storing the "dead" form. Just like any other migration, the previous shape is the input. 2) As output, give the "live" form of the data. Once it's in Redux, the replacer will take care of persisting it in the correct form. As mentioned above, this approach further clears the way for ImmutableJS; zulip#3949 and zulip#3950 will be much easier after this. I've neglected to mention that the primary purpose of `remotedev-serialize` is to replace and revive ImmutableJS objects! Its "default" replacers and revivers do this. The custom replacer/reviver functions we use here are a nice bit of flexibility provided on the side. (We added `immutable` as a dependency in a recent commit, since `remotedev-serialize` depends on it.) Fixes: zulip#3951 [1]: Actually, we use a wrapper around it, developed in a recent commit, to avoid a security hole. As long as we follow the pattern introduced in the two previous commits, using SerializeEscaped.immutable and its exported constant SERIALIZED_TYPE_FIELD_NAME, instead of Serialize.immutable (where Serialize is the default export of `remotedev-serialize`), we're fine.
Storing the parsed ZulipVersion instance in Redux makes it easier to work with, so we don't have to think about constructing a new instance as part of every server feature check. The one tricky bit is that our Redux state is serialized by redux-persist so that it can be stored in ZulipAsyncStorage, but the ZulipVersion instance itself is not serializable. Thankfully, it can be fully represented by its raw version string, which is serializable. So, on entry into ZulipAsyncStorage, we just have to convert the ZulipVersion instance into the raw version string, with .raw() ("replace" it), and on exit, make a new ZulipVersion instance, by calling the constructor with that raw version string ("revive" it). We considered two main strategies for locating the bit of state to be transformed (in this case, the `zulipVersion` field, which stores a ZulipVersion value, in elements of the `state.accounts` array): 1) The "outside-in" strategy, of identifying the value by the extrinsic property of where it is; i.e., that it's at the field named 'zulipVersion' on elements of the `state.accounts` array, or 2) The "inside-out" strategy, of identifying the value by its intrinsic property of being `instanceof ZulipVersion`. We chose the latter. When we work on zulip#3950, converting our object-as-map fields to use Map or Immutable.Map, we'll be making similar, sweeping changes to many different parts of the state, so it's most natural for the bulk of our logic to be independent of the location in state, and focus instead on the type of non-serializable object being stored. This approach conveniently clears the path to use ImmutableJS for additional reasons discussed later. An exploration of the "outside-in" approach is left as the un-merged PR zulip#3952. The main advantage of that approach is that we wouldn't need to write migrations, but it had the significant drawback of requiring a lot of code (for locating the bit of state to be transformed) that was 1) boilerplate, and 2) difficult to get fully type-checked, which is a bad combination. These concerns were not sufficiently alleviated by the complexity of that boilerplate being bounded by the complexity of the reducer(s) in charge of the corresponding part(s) of the state: it's better just to not have to write the boilerplate. There's nothing stopping us from mixing the two approaches, in future, but it would be nice to stick to one as far as possible, for simplicity. For the "inside-out" implementation, we use `remotedev-serialize` (added in a recent commit) [1], with custom replacer and reviver functions, defined in the previous commit. As Greg explains at zulip#3952 (comment): """ * at save time, values that are `instanceof ZulipVersion` get turned into objects like `{__serializedType__: 'ZulipVersion', data: '2.0.0-rc1'}`, before being JSON-serialized * at load time, after JSON deserialization, values that are objects with a `__serializedType__` property get transformed in a way identified by that property's value: in particular if `__serializedType__: 'ZulipVersion'`, then `data` is fed to `new ZulipVersion` """ Since we've never dealt with the Zulip version in this "live" (i.e., non-serializable) form, we have to write a migration. This was straightforward, but we MUST remember to write this kind of migration in the future. For making a value "live", where it wasn't before, the migration needs to: 1) As input, take the previous shape of the data. Don't confuse this with the *current* way of storing the "dead" shape. Just like any other migration, the previous shape is the input. 2) As output, give the "live" form of the data. Once it's in Redux, the replacer will take care of persisting it in the correct "dead" form. As mentioned above, this approach further clears the way for ImmutableJS; zulip#3949 and zulip#3950 will be much easier after this. I've neglected to mention that the primary purpose of `remotedev-serialize` is to replace and revive ImmutableJS objects! Its "default" replacers and revivers do this. The custom replacer/reviver functions we use here are a nice feature provided on the side. (We added `immutable` as a dependency in a recent commit, since `remotedev-serialize` depends on it.) Fixes: zulip#3951 [1]: We actually use our own fork, `zulip/remotedev-serialize@5f9f759a4`, which fixes a bug where "__serializedType__" keys in data fail to round-trip properly.
Storing the parsed ZulipVersion instance in Redux makes it easier to work with, so we don't have to think about constructing a new instance as part of every server feature check. The one tricky bit is that our Redux state is serialized by redux-persist so that it can be stored in ZulipAsyncStorage, but the ZulipVersion instance itself is not serializable. Thankfully, it can be fully represented by its raw version string, which is serializable. So, on entry into ZulipAsyncStorage, we just have to convert the ZulipVersion instance into the raw version string, with .raw() ("replace" it), and on exit, make a new ZulipVersion instance, by calling the constructor with that raw version string ("revive" it). We considered two main strategies for locating the bit of state to be transformed (in this case, the `zulipVersion` field, which stores a ZulipVersion value, in elements of the `state.accounts` array): 1) The "outside-in" strategy, of identifying the value by the extrinsic property of where it is; i.e., that it's at the field named 'zulipVersion' on elements of the `state.accounts` array, or 2) The "inside-out" strategy, of identifying the value by its intrinsic property of being `instanceof ZulipVersion`. We chose the latter. When we work on zulip#3950, converting our object-as-map fields to use Map or Immutable.Map, we'll be making similar, sweeping changes to many different parts of the state, so it's most natural for the bulk of our logic to be independent of the location in state, and focus instead on the type of non-serializable object being stored. This approach conveniently clears the path to use ImmutableJS for additional reasons discussed later. An exploration of the "outside-in" approach is left as the un-merged PR zulip#3952. The main advantage of that approach is that we wouldn't need to write migrations, but it had the significant drawback of requiring a lot of code (for locating the bit of state to be transformed) that was 1) boilerplate, and 2) difficult to get fully type-checked, which is a bad combination. These concerns were not sufficiently alleviated by the complexity of that boilerplate being bounded by the complexity of the reducer(s) in charge of the corresponding part(s) of the state: it's better just to not have to write the boilerplate. There's nothing stopping us from mixing the two approaches, in future, but it would be nice to stick to one as far as possible, for simplicity. For the "inside-out" implementation, we use `remotedev-serialize` (added in a recent commit) [1], with custom replacer and reviver functions, defined in the previous commit. As Greg explains at zulip#3952 (comment): """ * at save time, values that are `instanceof ZulipVersion` get turned into objects like `{__serializedType__: 'ZulipVersion', data: '2.0.0-rc1'}`, before being JSON-serialized * at load time, after JSON deserialization, values that are objects with a `__serializedType__` property get transformed in a way identified by that property's value: in particular if `__serializedType__: 'ZulipVersion'`, then `data` is fed to `new ZulipVersion` """ Since we've never dealt with the Zulip version in this "live" (i.e., non-serializable) form, we have to write a migration. This was straightforward, but we MUST remember to write this kind of migration in the future. For making a value "live", where it wasn't before, the migration needs to: 1) As input, take the previous shape of the data. Don't confuse this with the *current* way of storing the "dead" shape. Just like any other migration, the previous shape is the input. 2) As output, give the "live" form of the data. Once it's in Redux, the replacer will take care of persisting it in the correct "dead" form. As mentioned above, this approach further clears the way for ImmutableJS; zulip#3949 and zulip#3950 will be much easier after this. I've neglected to mention that the primary purpose of `remotedev-serialize` is to replace and revive ImmutableJS objects! Its "default" replacers and revivers do this. The custom replacer/reviver functions we use here are a nice feature provided on the side. (We added `immutable` as a dependency in a recent commit, since `remotedev-serialize` depends on it.) Fixes: zulip#3951 [1]: We actually use our own fork, `zulip/remotedev-serialize@5f9f759a4`, which fixes a bug where "__serializedType__" keys in data fail to round-trip properly.
Storing the parsed ZulipVersion instance in Redux makes it easier to work with, so we don't have to think about constructing a new instance as part of every server feature check. The one tricky bit is that our Redux state is serialized by redux-persist so that it can be stored in ZulipAsyncStorage, but the ZulipVersion instance itself is not serializable. Thankfully, it can be fully represented by its raw version string, which is serializable. So, on entry into ZulipAsyncStorage, we just have to convert the ZulipVersion instance into the raw version string, with .raw() ("replace" it), and on exit, make a new ZulipVersion instance, by calling the constructor with that raw version string ("revive" it). We considered two main strategies for locating the bit of state to be transformed (in this case, the `zulipVersion` field, which stores a ZulipVersion value, in elements of the `state.accounts` array): 1) The "outside-in" strategy, of identifying the value by the extrinsic property of where it is; i.e., that it's at the field named 'zulipVersion' on elements of the `state.accounts` array, or 2) The "inside-out" strategy, of identifying the value by its intrinsic property of being `instanceof ZulipVersion`. We chose the latter. When we work on zulip#3950, converting our object-as-map fields to use Map or Immutable.Map, we'll be making similar, sweeping changes to many different parts of the state, so it's most natural for the bulk of our logic to be independent of the location in state, and focus instead on the type of non-serializable object being stored. This approach conveniently clears the path to use ImmutableJS for additional reasons discussed later. An exploration of the "outside-in" approach is left as the un-merged PR zulip#3952. The main advantage of that approach is that we wouldn't need to write migrations, but it had the significant drawback of requiring a lot of code (for locating the bit of state to be transformed) that was 1) boilerplate, and 2) difficult to get fully type-checked, which is a bad combination. These concerns were not sufficiently alleviated by the complexity of that boilerplate being bounded by the complexity of the reducer(s) in charge of the corresponding part(s) of the state: it's better just to not have to write the boilerplate. There's nothing stopping us from mixing the two approaches, in future, but it would be nice to stick to one as far as possible, for simplicity. For the "inside-out" implementation, we use `remotedev-serialize` (added in a recent commit) [1], with custom replacer and reviver functions, defined in the previous commit. As Greg explains at zulip#3952 (comment): """ * at save time, values that are `instanceof ZulipVersion` get turned into objects like `{__serializedType__: 'ZulipVersion', data: '2.0.0-rc1'}`, before being JSON-serialized * at load time, after JSON deserialization, values that are objects with a `__serializedType__` property get transformed in a way identified by that property's value: in particular if `__serializedType__: 'ZulipVersion'`, then `data` is fed to `new ZulipVersion` """ Since we've never dealt with the Zulip version in this "live" (i.e., non-serializable) form, we have to write a migration. This was straightforward, but we MUST remember to write this kind of migration in the future. For making a value "live", where it wasn't before, the migration needs to: 1) As input, take the previous shape of the data. Don't confuse this with the *current* way of storing the "dead" shape. Just like any other migration, the previous shape is the input. 2) As output, give the "live" form of the data. Once it's in Redux, the replacer will take care of persisting it in the correct "dead" form. As mentioned above, this approach further clears the way for ImmutableJS; zulip#3949 and zulip#3950 will be much easier after this. I've neglected to mention that the primary purpose of `remotedev-serialize` is to replace and revive ImmutableJS objects! Its "default" replacers and revivers do this. The custom replacer/reviver functions we use here are a nice feature provided on the side. (We added `immutable` as a dependency in a recent commit, since `remotedev-serialize` depends on it.) Fixes: zulip#3951 [1]: We actually use our own fork, `zulip/remotedev-serialize@5f9f759a4`, which fixes a bug where "__serializedType__" keys in data fail to round-trip properly.
#4047 is merged! Closing this one. Thanks for prototyping both designs. |
Storing the parsed ZulipVersion instance in Redux makes it easier to
work with, so we don't have to think about constructing a new
instance as part of every server feature check.
The one tricky bit is that our Redux state is serialized with
JSON.stringify by redux-persist so that it can be stored in
ZulipAsyncStorage, but the ZulipVersion instance itself is not
serializable. Thankfully, it can be represented as its raw version
string, which is serializable.
So, on entry into ZulipAsyncStorage, convert the ZulipVersion
instance into the raw version string with .raw(), and on exit, call
the ZulipVersion constructor on that raw version string, with
redux-persist's
createTransform
.Fixes: #3951