Skip to content

Commit 40b2cc9

Browse files
committed
Hydrate snapshotted views lazily to avoid excessive root boot costs
[no-changelog-required]
1 parent 0986578 commit 40b2cc9

File tree

4 files changed

+94
-29
lines changed

4 files changed

+94
-29
lines changed

spec/class-model-snapshotted-views.spec.ts

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { observable } from "mobx";
1+
import { observable, runInAction } from "mobx";
22
import { ClassModel, action, snapshottedView, getSnapshot, register, types, onPatch } from "../src";
33
import { Apple } from "./fixtures/FruitAisle";
44
import { create } from "./helpers";
@@ -85,7 +85,9 @@ describe("class model snapshotted views", () => {
8585
const instance = MyViewExample.create({ key: "1", name: "Test" });
8686
onPatch(instance, fn);
8787

88-
observableArray.push("a");
88+
runInAction(() => {
89+
observableArray.push("a");
90+
});
8991
expect(fn).toMatchSnapshot();
9092
});
9193

@@ -141,8 +143,7 @@ describe("class model snapshotted views", () => {
141143
@register
142144
class HydrateExample extends ClassModel({ url: types.string }) {
143145
@snapshottedView<URL>({
144-
createReadOnly(value, snapshot, node) {
145-
expect(snapshot).toBeDefined();
146+
createReadOnly(value, node) {
146147
expect(node).toBeDefined();
147148
return value ? new URL(value) : undefined;
148149
},
@@ -178,6 +179,25 @@ describe("class model snapshotted views", () => {
178179
} as any);
179180
expect(instance.withoutParams).toEqual(new URL("https://gadget.dev/blog/feature/extra"));
180181
});
182+
183+
test("hydrators aren't called eagerly on readonly instances in case they are expensive", () => {
184+
const fn = jest.fn().mockReturnValue("whatever");
185+
@register
186+
class HydrateExampleSpy extends ClassModel({}) {
187+
@snapshottedView<URL>({
188+
createReadOnly: fn,
189+
})
190+
get someView() {
191+
return "view value";
192+
}
193+
}
194+
195+
const instance = HydrateExampleSpy.createReadOnly({ someView: "snapshot value" });
196+
expect(fn).not.toHaveBeenCalled();
197+
198+
expect(instance.someView).toEqual("whatever");
199+
expect(fn).toHaveBeenCalledTimes(1);
200+
});
181201
});
182202

183203
describe("references", () => {

src/class-model.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ type ActionMetadata = {
4444
/** Options that configure a snapshotted view */
4545
export interface SnapshottedViewOptions<V, T extends IAnyClassModelType> {
4646
/** A function for converting a stored value in the snapshot back to the rich type for the view to return */
47-
createReadOnly?: (value: V | undefined, snapshot: T["InputType"], node: Instance<T>) => V | undefined;
47+
createReadOnly?: (value: V | undefined, node: Instance<T>) => V | undefined;
4848

4949
/** A function for converting the view value to a snapshot value */
5050
createSnapshot?: (value: V) => any;
@@ -195,7 +195,7 @@ export function register<Instance, Klass extends { new (...args: any[]): Instanc
195195
if (descriptor.get) {
196196
Object.defineProperty(klass.prototype, property, {
197197
...descriptor,
198-
get: fastGetters.buildGetter(property, descriptor),
198+
get: fastGetters.buildViewGetter(metadata, descriptor),
199199
});
200200
}
201201

src/fast-getter.ts

Lines changed: 59 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import type { PropertyMetadata, ViewMetadata } from "./class-model";
1+
import { snapshotProcessor } from "mobx-state-tree/dist/internal";
2+
import type { PropertyMetadata, SnapshottedViewMetadata, ViewMetadata } from "./class-model";
23
import { getPropertyDescriptor } from "./class-model";
34
import { RegistrationError } from "./errors";
45
import { $notYetMemoized, $readOnly } from "./symbols";
@@ -28,6 +29,10 @@ export class FastGetBuilder {
2829
return `mqt/${property}-memo`;
2930
}
3031

32+
snapshottedViewInputSymbolName(property: string) {
33+
return `mqt/${property}-svi-memo`;
34+
}
35+
3136
outerClosureStatements(className: string) {
3237
return this.memoizableProperties
3338
.map(
@@ -39,30 +44,67 @@ export class FastGetBuilder {
3944
.join("\n");
4045
}
4146

42-
buildGetter(property: string, descriptor: PropertyDescriptor) {
47+
buildViewGetter(metadata: ViewMetadata | SnapshottedViewMetadata, descriptor: PropertyDescriptor) {
48+
const property = metadata.property;
4349
const $memo = Symbol.for(this.memoSymbolName(property));
44-
const source = `
45-
(
46-
function build({ $readOnly, $memo, $notYetMemoized, getValue }) {
47-
return function get${property}(model, imports) {
48-
if (!this[$readOnly]) return getValue.call(this);
49-
let value = this[$memo];
50-
if (value !== $notYetMemoized) {
50+
51+
let source;
52+
let args;
53+
54+
if (metadata.type === "snapshotted-view" && metadata.options.createReadOnly) {
55+
const $snapshotValue = Symbol.for(this.snapshottedViewInputSymbolName(property));
56+
57+
// this snapshotted view has a hydrator, so we need a special view function for readonly instances that lazily hydrates the snapshotted value
58+
source = `
59+
(
60+
function build({ $readOnly, $memo, $notYetMemoized, $snapshotValue, getValue, hydrate }) {
61+
return function get${property}(model, imports) {
62+
if (!this[$readOnly]) return getValue.call(this);
63+
let value = this[$memo];
64+
if (value !== $notYetMemoized) {
65+
return value;
66+
}
67+
68+
const dehydratedValue = this[$snapshotValue];
69+
if (typeof dehydratedValue !== "undefined") {
70+
value = hydrate(dehydratedValue, this);
71+
} else {
72+
value = getValue.call(this);
73+
}
74+
75+
this[$memo] = value;
5176
return value;
5277
}
78+
}
79+
)
80+
//# sourceURL=mqt-eval/dynamic/${this.klass.name}-${property}-get.js
81+
`;
82+
args = { $readOnly, $memo, $snapshotValue, $notYetMemoized, hydrate: metadata.options.createReadOnly, getValue: descriptor.get };
83+
} else {
84+
source = `
85+
(
86+
function build({ $readOnly, $memo, $notYetMemoized, getValue }) {
87+
return function get${property}(model, imports) {
88+
if (!this[$readOnly]) return getValue.call(this);
89+
let value = this[$memo];
90+
if (value !== $notYetMemoized) {
91+
return value;
92+
}
5393
54-
value = getValue.call(this);
55-
this[$memo] = value;
56-
return value;
94+
value = getValue.call(this);
95+
this[$memo] = value;
96+
return value;
97+
}
5798
}
58-
}
59-
)
60-
//# sourceURL=mqt-eval/dynamic/${this.klass.name}-${property}-get.js
61-
`;
99+
)
100+
//# sourceURL=mqt-eval/dynamic/${this.klass.name}-${property}-get.js
101+
`;
102+
args = { $readOnly, $memo, $notYetMemoized, getValue: descriptor.get };
103+
}
62104

63105
try {
64106
const builder = eval(source);
65-
return builder({ $readOnly, $memo, $notYetMemoized, getValue: descriptor.get });
107+
return builder(args);
66108
} catch (error) {
67109
console.error(`Error building getter for ${this.klass.name}#${property}`);
68110
console.error(`Compiled source:\n${source}`);

src/fast-instantiator.ts

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -332,21 +332,24 @@ export class InstantiatorBuilder<T extends IClassModelType<Record<string, IAnyTy
332332
}`;
333333
}
334334

335-
private assignSnapshottedViewExpression(snapshottedView: SnapshottedViewMetadata, index: number) {
335+
private assignSnapshottedViewExpression(snapshottedView: SnapshottedViewMetadata) {
336336
const varName = `view${snapshottedView.property}`;
337337

338-
let valueExpression = `snapshot?.["${snapshottedView.property}"]`;
338+
let destinationProp;
339339
if (snapshottedView.options.createReadOnly) {
340-
const alias = this.alias(`snapshottedViews[${index}].options.createReadOnly`);
341-
valueExpression = `${alias}(${valueExpression}, snapshot, this)`;
340+
// we're using a hydrator, so we don't store it right at the memo, and instead stash it where we'll lazily hydrate it in the getter
341+
destinationProp = this.alias(`Symbol.for("${this.getters.snapshottedViewInputSymbolName(snapshottedView.property)}")`);
342+
} else {
343+
// we're not using a hydrator, so we can stash the snapshotted value right into the memoized spot
344+
destinationProp = this.alias(`Symbol.for("${this.getters.memoSymbolName(snapshottedView.property)}")`);
342345
}
343-
const memoSymbolAlias = this.alias(`Symbol.for("${this.getters.memoSymbolName(snapshottedView.property)}")`);
344346

347+
const valueExpression = `snapshot?.["${snapshottedView.property}"]`;
345348
return `
346349
// setup snapshotted view for ${snapshottedView.property}
347350
const ${varName} = ${valueExpression};
348351
if (typeof ${varName} != "undefined") {
349-
this[${memoSymbolAlias}] = ${varName};
352+
this[${destinationProp}] = ${varName};
350353
}
351354
`;
352355
}

0 commit comments

Comments
 (0)