From 4e1ee91b204f7871d0f1c2251a94071d94b8261d Mon Sep 17 00:00:00 2001 From: Harry Brundage Date: Thu, 16 Nov 2023 20:46:53 -0500 Subject: [PATCH] feat: pass the node to postProcess snapshot transformers (#2116) When transforming the snapshot for a node, it would be quite handy to get access to the node the snapshot was generated for, so you can traverse the tree to get at data you want to put in the snapshot. Stored properties of that node are already given to you in the hook in the snapshot, but computeds, data from parents, tree environment data, or volatiles aren't accessible for use in snapshot transformers unless the node is passed in. Woop woop! --- __tests__/core/snapshotProcessor.test.ts | 55 ++++++- docs/concepts/snapshots.md | 2 + docs/overview/hooks.md | 143 ++++++++++++++----- src/core/node/object-node.ts | 4 + src/types/utility-types/snapshotProcessor.ts | 38 ++--- 5 files changed, 179 insertions(+), 63 deletions(-) diff --git a/__tests__/core/snapshotProcessor.test.ts b/__tests__/core/snapshotProcessor.test.ts index c0dab1d0d..96b1d30ff 100644 --- a/__tests__/core/snapshotProcessor.test.ts +++ b/__tests__/core/snapshotProcessor.test.ts @@ -1,3 +1,4 @@ +import { observable } from "mobx" import { types, getSnapshot, @@ -6,7 +7,10 @@ import { detach, clone, SnapshotIn, - getNodeId + getNodeId, + Instance, + getType, + onSnapshot } from "../../src" describe("snapshotProcessor", () => { @@ -51,26 +55,58 @@ describe("snapshotProcessor", () => { }) test("post processor", () => { + let model: Instance const Model = types.model({ m: types.snapshotProcessor(M, { - postProcessor(sn): { x: number } { + postProcessor(sn, node): { x: number; val?: string } { + expect(node).toBeTruthy() + return { ...sn, - x: Number(sn.x) + x: Number(sn.x), + val: node.x } } }) }) - const model = Model.create({ + model = Model.create({ m: { x: "5" } }) unprotect(model) expect(model.m.x).toBe("5") expect(getSnapshot(model).m.x).toBe(5) + expect(getSnapshot(model).m.val).toBe("5") // reconciliation model.m = cast({ x: "6" }) expect(model.m.x).toBe("6") expect(getSnapshot(model).m.x).toBe(6) + expect(getSnapshot(model).m.val).toBe("6") + }) + + test("post processor that observes other observables recomputes when they change", () => { + let model: Instance + const atom = observable.box("foo") + + const Model = types.model({ + m: types.snapshotProcessor(M, { + postProcessor(sn, node): { x: number; val: string } { + return { + ...sn, + x: Number(sn.x), + val: atom.get() + } + } + }) + }) + model = Model.create({ + m: { x: "5" } + }) + const newSnapshot = jest.fn() + onSnapshot(model, newSnapshot) + expect(getSnapshot(model).m.val).toBe("foo") + atom.set("bar") + expect(getSnapshot(model).m.val).toBe("bar") + expect(newSnapshot).toHaveBeenCalledTimes(1) }) test("pre and post processor", () => { @@ -143,7 +179,8 @@ describe("snapshotProcessor", () => { test("post processor", () => { const Model = types.model({ m: types.snapshotProcessor(M, { - postProcessor(sn): number { + postProcessor(sn, node): number { + expect(node).toMatch(/5|6/) return Number(sn) } }) @@ -224,7 +261,9 @@ describe("snapshotProcessor", () => { test("post processor", () => { const Model = types.model({ m: types.snapshotProcessor(M, { - postProcessor(sn): number[] { + postProcessor(sn, node): number[] { + expect(node).toBeDefined() + expect(node.length).toEqual(1) return sn.map((n) => Number(n)) } }) @@ -308,7 +347,9 @@ describe("snapshotProcessor", () => { test("post processor", () => { const Model = types.model({ m: types.snapshotProcessor(M, { - postProcessor(sn): { x: number } { + postProcessor(sn, node): { x: number } { + expect(node.size).toBe(1) + return { ...sn, x: Number(sn.x) diff --git a/docs/concepts/snapshots.md b/docs/concepts/snapshots.md index 334e8fecd..f5db5db46 100644 --- a/docs/concepts/snapshots.md +++ b/docs/concepts/snapshots.md @@ -56,3 +56,5 @@ Useful methods: - `getSnapshot(model, applyPostProcess)`: returns a snapshot representing the current state of the model - `onSnapshot(model, callback)`: creates a listener that fires whenever a new snapshot is available (but only one per MobX transaction). - `applySnapshot(model, snapshot)`: updates the state of the model and all its descendants to the state represented by the snapshot + +`mobx-state-tree` also supports customizing snapshots when they are generated or when they are applied with [`types.snapshotProcessor`](/overview/hooks). \ No newline at end of file diff --git a/docs/overview/hooks.md b/docs/overview/hooks.md index ce8ec5c3b..e14c0c0d2 100644 --- a/docs/overview/hooks.md +++ b/docs/overview/hooks.md @@ -14,60 +14,129 @@ title: Lifecycle hooks overview Hosted on egghead.io -All of the below hooks can be created by returning an action with the given name, like: +`mobx-state-tree` supports passing a variety of hooks that are called throughout a node's lifecycle. Hooks are passes as actions with the name of the hook, like: ```javascript -const Todo = types.model("Todo", { done: true }).actions(self => ({ - afterCreate() { - console.log("Created a new todo!") - } +const Todo = types.model("Todo", { done: true }).actions((self) => ({ + afterCreate() { + console.log("Created a new todo!") + } })) ``` -_⚠ The section below is outdated, and should be updated to use [`types.snapshotProcessor`](/API/#snapshotprocessor) instead of the snapshot hooks⚠_ +| Hook | Meaning | +| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `afterCreate` | Immediately after an instance is created and initial values are applied. Children will fire this event before parents. You can't make assumptions about the parent safely, use `afterAttach` if you need to. | +| `afterAttach` | As soon as the _direct_ parent is assigned (this node is attached to another node). If an element is created as part of a parent, `afterAttach` is also fired. Unlike `afterCreate`, `afterAttach` will fire breadth first. So, in `afterAttach` one can safely make assumptions about the parent, but in `afterCreate` not | +| `beforeDetach` | As soon as the node is removed from the _direct_ parent, but only if the node is _not_ destroyed. In other words, when `detach(node)` is used | +| `beforeDestroy` | Called before the node is destroyed, as a result of calling `destroy`, or by removing or replacing the node from the tree. Child destructors will fire before parents | +| `preProcessSnapshot` | Deprecated, prefer `types.snapshotProcessor`. Before creating an instance or applying a snapshot to an existing instance, this hook is called to give the option to transform the snapshot before it is applied. The hook should be a _pure_ function that returns a new snapshot. This can be useful to do some data conversion, enrichment, property renames, etc. This hook is not called for individual property updates. _\*\*Note 1: Unlike the other hooks, this one is \_not_ created as part of the `actions` initializer, but directly on the type!**\_ \_**Note 2: The `preProcessSnapshot` transformation must be pure; it should not modify its original input argument!\*\*\_ | +| `postProcessSnapshot` | Deprecated, prefer `types.snapshotProcessor`. This hook is called every time a new snapshot is being generated. Typically it is the inverse function of `preProcessSnapshot`. This function should be a pure function that returns a new snapshot. _\*\*Note: Unlike the other hooks, this one is \_not_ created as part of the `actions` initializer, but directly on the type!\*\*\_ | + +All hooks can be defined multiple times and can be composed automatically. + +## Lifecycle hooks for `types.array`/`types.map` + +Hooks for `types.array`/`types.map` can be defined by using the `.hooks(self => ({}))` method. + +Calling `.hooks(...)` produces new type, same as calling `.actions()` for `types.model`. + +Available hooks are: + +| Hook | Meaning | +| --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `afterCreate` | Immediately after an instance is initialized: right after `.create()` for root node or after the first access for the nested one. Children will fire this event before parents. You can't make assumptions about the parent safely, use `afterAttach` if you need to. | +| `afterAttach` | As soon as the _direct_ parent is assigned (this node is attached to another node). If an element is created as part of a parent, `afterAttach` is also fired. Unlike `afterCreate`, `afterAttach` will fire breadth first. So, in `afterAttach` one can safely make assumptions about the parent, but in `afterCreate` not | +| `beforeDetach` | As soon as the node is removed from the _direct_ parent, but only if the node is _not_ destroyed. In other words, when `detach(node)` is used | +| `beforeDestroy` | Called before the node is destroyed, as a result of calling `destroy`, or by removing or replacing the node from the tree. Child destructors will fire before parents | + +### Snapshot processing hooks -The exception to this rule are the `preProcessSnapshot` and `postProcessSnapshot` hooks (see `types.snapshotProcessor` as an alternative): +You can also modify snapshots as they are generated from your nodes, or applied to your nodes with `types.snapshotProcessor`. This type wraps an existing type and allows defining custom hooks for snapshot modifications. + +For example, you can wrap an existing model in a snapshot processor which transforms a snapshot from the server into the shape your model expects with `preProcess`: ```javascript -types - .model("Todo", { done: true }) - .preProcessSnapshot(snapshot => ({ +const TodoModel = types.model("Todo", { + done: types.boolean, +}); + +const Todo = types.snapshotProcessor(TodoModel, { + preProcess(snapshot) { + return { // auto convert strings to booleans as part of preprocessing done: snapshot.done === "true" ? true : snapshot.done === "false" ? false : snapshot.done - })) - .actions(self => ({ - afterCreate() { - console.log("Created a new todo!") - } - })) + } +}); + +const todo = Todo.create({ done: "true" }) // snapshot will be transformed on the way in ``` -Note: pre and post processing are just meant to convert your data into types that are more acceptable to MST. Typically it should be the case that `postProcess(preProcess(snapshot)) === snapshot. If that isn't the case, consider whether you shouldn't be using a dedicated a view instead to normalize your snapshot to some other format you need. +Snapshots can also be transformed from the base shape generated by `mobx-quick-tree` using the `postProcess` hook. For example, we can format a date object in the snapshot with a specific date format that a backend might accept: -| Hook | Meaning | -| --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `afterCreate` | Immediately after an instance is created and initial values are applied. Children will fire this event before parents. You can't make assumptions about the parent safely, use `afterAttach` if you need to. | -| `afterAttach` | As soon as the _direct_ parent is assigned (this node is attached to another node). If an element is created as part of a parent, `afterAttach` is also fired. Unlike `afterCreate`, `afterAttach` will fire breadth first. So, in `afterAttach` one can safely make assumptions about the parent, but in `afterCreate` not | -| `beforeDetach` | As soon as the node is removed from the _direct_ parent, but only if the node is _not_ destroyed. In other words, when `detach(node)` is used | -| `beforeDestroy` | Called before the node is destroyed, as a result of calling `destroy`, or by removing or replacing the node from the tree. Child destructors will fire before parents | -| `preProcessSnapshot` | Before creating an instance or applying a snapshot to an existing instance, this hook is called to give the option to transform the snapshot before it is applied. The hook should be a _pure_ function that returns a new snapshot. This can be useful to do some data conversion, enrichment, property renames, etc. This hook is not called for individual property updates. _\*\*Note 1: Unlike the other hooks, this one is \_not_ created as part of the `actions` initializer, but directly on the type!**\_ \_**Note 2: The `preProcessSnapshot` transformation must be pure; it should not modify its original input argument!\*\*\_ | -| `postProcessSnapshot` | This hook is called every time a new snapshot is being generated. Typically it is the inverse function of `preProcessSnapshot`. This function should be a pure function that returns a new snapshot. _\*\*Note: Unlike the other hooks, this one is \_not_ created as part of the `actions` initializer, but directly on the type!\*\*\_ | +```javascript +const TodoModel = types.model("Todo", { + done: types.boolean, + createdAt: types.Date +}); + +const Todo = types.snapshotProcessor(TodoModel, { + postProcess(snapshot, node) { + return { + ...snapshot, + createdAt: node.createdAt.getTime() + } +}); -Note, except for `preProcessSnapshot` and `postProcessSnapshot`, all hooks should be defined as actions. +const todo = Todo.create({done: true, createdAt: new Date()}); +const snapshot = getSnapshot(todo); +// { done: true, createdAt: 1699504649386 } +``` -All hooks can be defined multiple times and can be composed automatically. +| Hook | Meaning | +| --------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `preProcess(inputSnapshot)` | Transform a snapshot before it is applied to a node. The output snapshot must be valid for application to the wrapped type. The `preProcess` hook is passed the input snapshot, but not passed the node, as it is not done being constructed yet, and not attached to the tree. If you need to modify the node in the context of the tree, use the `afterCreate` hook. | +| `postProcess(outputSnapshot, node)` | Transform a snapshot after it has been generated from a node. The transformed value will be returned by `getSnapshot`. The `postProcess` hook is passed the initial outputSnapshot, as well as the instance object the snapshot has been generated for. It is safe to access properties of the node or other nodes when post processing snapshots. | -## LifeCycle hooks for `types.array`/`types.map` +#### When to use snapshot hooks -Hooks for `types.array`/`types.map` can be defined by using the `.hooks(self => ({}))` method. +`preProcess` and `postProcess` hooks should be used to convert your data into types that are more acceptable to MST. Snapshots are often JSON serialized, so if you need to use richly typed objects like `URL`s or `Date`s that can't be JSON serialized, you can use snapshot processors to convert to and from the serialized form. -Calling `.hooks(...)` produces new type, same as calling `.actions()` for `types.model`. +Typically, it should be the case that `postProcess(preProcess(snapshot)) === snapshot`. If your snapshot processor hooks are non-deterministic, or rely on state beyond just the base snapshot, it's easy to introduce subtle bugs and is best avoided. + +If you are considering adding a snapshot processor that is non-deterministic or relies on other state, consider using a dedicated property or view that produces the same information. Like snapshots, properties and views are observable and memoized, but they don't need to have an inverse for serializing back to a snapshot. + +For example, if you want to capture the current time a snapshot was generated, you may be tempted to use a snapshot processor: + +```javascript +const TodoModel = types.model("Todo", { + done: types.boolean, +}); + +const Todo = types.snapshotProcessor(TodoModel, { + // discouraged, try not to do this + postProcess(snapshot, node) { + return { + ...snapshot, + createdAt: new Date().toISOString(); + } +}); + +const todo = Todo.create({ done: false }) +getSnapshot(todo) // will have a `createdAt property` +``` + +Instead, this data could be better represented as a property right on the model, which is included in the snapshot by default: + +```javascript +const Todo = types.model("Todo", { + done: types.boolean, + createdAt: types.optioanl(types.Date, () => new Date()) +}); + +const todo = Todo.create({ done: false }) +getSnapshot(todo) // will also have a `createdAt property` +``` -Available hooks are: -| Hook | Meaning | -| --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `afterCreate` | Immediately after an instance is initialized: right after `.create()` for root node or after the first access for the nested one. Children will fire this event before parents. You can't make assumptions about the parent safely, use `afterAttach` if you need to. | -| `afterAttach` | As soon as the _direct_ parent is assigned (this node is attached to another node). If an element is created as part of a parent, `afterAttach` is also fired. Unlike `afterCreate`, `afterAttach` will fire breadth first. So, in `afterAttach` one can safely make assumptions about the parent, but in `afterCreate` not | -| `beforeDetach` | As soon as the node is removed from the _direct_ parent, but only if the node is _not_ destroyed. In other words, when `detach(node)` is used | -| `beforeDestroy` | Called before the node is destroyed, as a result of calling `destroy`, or by removing or replacing the node from the tree. Child destructors will fire before parents +Advanced use-cases that require impure or otherwise inconsistent snapshot processors are however supported by MST. \ No newline at end of file diff --git a/src/core/node/object-node.ts b/src/core/node/object-node.ts index 7b4939968..2d79f7b28 100644 --- a/src/core/node/object-node.ts +++ b/src/core/node/object-node.ts @@ -92,6 +92,7 @@ export class ObjectNode extends BaseNode { identifierCache?: IdentifierCache isProtectionEnabled = true middlewares?: IMiddleware[] + hasSnapshotPostProcessor = false private _applyPatches?: (patches: IJsonPatch[]) => void @@ -350,6 +351,9 @@ export class ObjectNode extends BaseNode { // advantage of using computed for a snapshot is that nicely respects transactions etc. get snapshot(): S { + if (this.hasSnapshotPostProcessor) { + this.createObservableInstanceIfNeeded() + } return this._snapshotComputed.get() } diff --git a/src/types/utility-types/snapshotProcessor.ts b/src/types/utility-types/snapshotProcessor.ts index b94b94fed..ac0d16566 100644 --- a/src/types/utility-types/snapshotProcessor.ts +++ b/src/types/utility-types/snapshotProcessor.ts @@ -14,7 +14,9 @@ import { devMode, ComplexType, typeCheckFailure, - isUnionType + isUnionType, + Instance, + ObjectNode } from "../../internal" /** @hidden */ @@ -44,12 +46,7 @@ class SnapshotProcessor extends BaseType< constructor( private readonly _subtype: IT, - private readonly _processors: ISnapshotProcessors< - IT["CreationType"], - CustomC, - IT["SnapshotType"], - CustomS - >, + private readonly _processors: ISnapshotProcessors, name?: string ) { super(name || _subtype.name) @@ -74,9 +71,9 @@ class SnapshotProcessor extends BaseType< } } - private postProcessSnapshot(sn: IT["SnapshotType"]): this["S"] { + private postProcessSnapshot(sn: IT["SnapshotType"], node: this["N"]): this["S"] { if (this._processors.postProcessor) { - return this._processors.postProcessor.call(null, sn) as any + return this._processors.postProcessor!.call(null, sn, node.storedValue) as any } return sn } @@ -85,10 +82,11 @@ class SnapshotProcessor extends BaseType< // the node has to use these methods rather than the original type ones proxyNodeTypeMethods(node.type, this, "create") - const oldGetSnapshot = node.getSnapshot - node.getSnapshot = () => { - return this.postProcessSnapshot(oldGetSnapshot.call(node)) as any + if (node instanceof ObjectNode) { + node.hasSnapshotPostProcessor = !!this._processors.postProcessor } + const oldGetSnapshot = node.getSnapshot + node.getSnapshot = () => this.postProcessSnapshot(oldGetSnapshot.call(node), node) as any if (!isUnionType(this._subtype)) { node.getReconciliationType = () => { return this @@ -135,7 +133,7 @@ class SnapshotProcessor extends BaseType< getSnapshot(node: this["N"], applyPostProcess: boolean = true): this["S"] { const sn = this._subtype.getSnapshot(node) - return applyPostProcess ? this.postProcessSnapshot(sn) : sn + return applyPostProcess ? this.postProcessSnapshot(sn, node) : sn } isValidSnapshot(value: this["C"], context: IValidationContext): IValidationResult { @@ -200,16 +198,16 @@ export interface ISnapshotProcessor /** * Snapshot processors. */ -export interface ISnapshotProcessors { +export interface ISnapshotProcessors { /** * Function that transforms an input snapshot. */ - preProcessor?(snapshot: CustomC): C + preProcessor?(snapshot: CustomC): IT["CreationType"] /** * Function that transforms an output snapshot. * @param snapshot */ - postProcessor?(snapshot: S): CustomS + postProcessor?(snapshot: IT["SnapshotType"], node: Instance): CustomS } /** @@ -222,15 +220,17 @@ export interface ISnapshotProcessors { * interface BackendTodo { * text: string | null * } + * * const Todo2 = types.snapshotProcessor(Todo1, { * // from snapshot to instance - * preProcessor(sn: BackendTodo) { + * preProcessor(snapshot: BackendTodo) { * return { * text: sn.text || ""; * } * }, + * * // from instance to snapshot - * postProcessor(sn): BackendTodo { + * postProcessor(snapshot, node): BackendTodo { * return { * text: !sn.text ? null : sn.text * } @@ -249,7 +249,7 @@ export function snapshotProcessor< CustomS = _NotCustomized >( type: IT, - processors: ISnapshotProcessors, + processors: ISnapshotProcessors, name?: string ): ISnapshotProcessor { assertIsType(type, 1)