Skip to content

Commit

Permalink
Add support for acceptsUndefined: false to safe references
Browse files Browse the repository at this point in the history
MST proper supports an `acceptsUndefined: false` on safe references that automatically prunes invalid references from parent maps and arrays. I wanna use it in Gadget but noticed that it no worky -- we pass the option through to observable instances fine but didn't do anything with it in readonly instances. This adds handling for the special case, which requires arrays and maps to look at the child type, and if it is a safe reference with the option set, omit unresolved entries from the result. Woop woop.
  • Loading branch information
airhorns committed Oct 17, 2024
1 parent 1a3b837 commit 330abed
Show file tree
Hide file tree
Showing 5 changed files with 269 additions and 96 deletions.
316 changes: 233 additions & 83 deletions spec/reference.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { IAnyClassModelType, Instance, SnapshotOrInstance } from "../src";
import { ClassModel, register, types } from "../src";
import { TestClassModel } from "./fixtures/TestClassModel";
import { TestModel, TestModelSnapshot } from "./fixtures/TestModel";
import { create } from "./helpers";

const Referrable = types.model("Referenced", {
key: types.identifier,
Expand Down Expand Up @@ -32,107 +33,256 @@ const Root = types.model("Reference Model", {
});

describe("references", () => {
test("can resolve valid references", () => {
const root = Root.createReadOnly({
model: {
ref: "item-a",
},
refs: [
{ key: "item-a", count: 12 },
{ key: "item-b", count: 523 },
],
describe.each([
["read-only", true],
["observable", false],
])("%s", (_name, readOnly) => {
test("can resolve valid references", () => {
const root = create(
Root,
{
model: {
ref: "item-a",
},
refs: [
{ key: "item-a", count: 12 },
{ key: "item-b", count: 523 },
],
},
readOnly,
);

expect(root.model.ref).toEqual(
expect.objectContaining({
key: "item-a",
count: 12,
}),
);
});

expect(root.model.ref).toEqual(
expect.objectContaining({
key: "item-a",
count: 12,
}),
);
});
test("can resolve valid safe references", () => {
const root = create(
Root,
{
model: {
ref: "item-a",
safeRef: "item-b",
},
refs: [
{ key: "item-a", count: 12 },
{ key: "item-b", count: 523 },
],
},
readOnly,
);

expect(root.model.safeRef).toEqual(
expect.objectContaining({
key: "item-b",
count: 523,
}),
);
});

test("throws for invalid refs", () => {
const createRoot = () =>
Root.createReadOnly({
model: {
ref: "item-c",
test("does not throw for invalid safe references", () => {
const root = create(
Root,
{
model: {
ref: "item-a",
safeRef: "item-c",
},
refs: [
{ key: "item-a", count: 12 },
{ key: "item-b", count: 523 },
],
},
refs: [
{ key: "item-a", count: 12 },
{ key: "item-b", count: 523 },
],
readOnly,
);

expect(root.model.safeRef).toBeUndefined();
});

test("safe references marked with allowUndefined false are non-nullable in types-style arrays", () => {
const Referencer = types.model("Referencer", {
safeRefs: types.array(types.safeReference(Referrable, { acceptsUndefined: false })),
});

expect(createRoot).toThrow();
});
const Root = types.model("Reference Model", {
refs: types.array(Referrable),
model: Referencer,
});
const root = create(
Root,
{
refs: [
{ key: "item-a", count: 12 },
{ key: "item-b", count: 523 },
],
model: {
safeRefs: ["item-a", "item-c"],
},
},
readOnly,
);

test("can resolve valid safe references", () => {
const root = Root.createReadOnly({
model: {
ref: "item-a",
safeRef: "item-b",
},
refs: [
{ key: "item-a", count: 12 },
{ key: "item-b", count: 523 },
],
expect(root.model.safeRefs.map((obj) => obj.key)).toEqual(["item-a"]);

type instanceType = (typeof root.model.safeRefs)[0];
assert<Has<instanceType, undefined>>(false);
assert<Has<instanceType, null>>(false);
});

expect(root.model.safeRef).toEqual(
expect.objectContaining({
key: "item-b",
count: 523,
}),
);
});
test("safe references marked with allowUndefined false are non-nullable in types-style maps", () => {
const Referencer = types.model("Referencer", {
safeRefs: types.map(types.safeReference(Referrable, { acceptsUndefined: false })),
});

const Root = types.model("Reference Model", {
refs: types.array(Referrable),
model: Referencer,
});
const root = create(
Root,
{
refs: [
{ key: "item-a", count: 12 },
{ key: "item-b", count: 523 },
],
model: {
safeRefs: {
"item-a": "item-a",
"item-c": "item-c",
},
},
},
readOnly,
);

test("does not throw for invalid safe references", () => {
const root = Root.createReadOnly({
model: {
ref: "item-a",
safeRef: "item-c",
},
refs: [
{ key: "item-a", count: 12 },
{ key: "item-b", count: 523 },
],
expect([...root.model.safeRefs.keys()]).toEqual(["item-a"]);
});

expect(root.model.safeRef).toBeUndefined();
});
test("safe references marked with allowUndefined false are non-nullable in class model arrays", () => {
@register
class Referencer extends ClassModel({
safeRefs: types.array(types.safeReference(Referrable, { acceptsUndefined: false })),
}) {}

@register
class Root extends ClassModel({
refs: types.array(Referrable),
model: Referencer,
}) {}

test("references are equal to the instances they refer to", () => {
const root = Root.createReadOnly({
model: {
ref: "item-a",
safeRef: "item-b",
},
refs: [
{ key: "item-a", count: 12 },
{ key: "item-b", count: 523 },
],
const root = create(
Root,
{
refs: [
{ key: "item-a", count: 12 },
{ key: "item-b", count: 523 },
],
model: {
safeRefs: ["item-a", "item-c"],
},
},
readOnly,
);

expect(root.model.safeRefs.map((obj) => obj.key)).toEqual(["item-a"]);

type instanceType = (typeof root.model.safeRefs)[0];
assert<Has<instanceType, undefined>>(false);
assert<Has<instanceType, null>>(false);
});

expect(root.model.ref).toBe(root.refs[0]);
expect(root.model.ref).toEqual(root.refs[0]);
expect(root.model.ref).toStrictEqual(root.refs[0]);
});
test("safe references marked with allowUndefined false are non-nullable in class model maps", () => {
@register
class Referencer extends ClassModel({
safeRefs: types.map(types.safeReference(Referrable, { acceptsUndefined: false })),
}) {}

@register
class Root extends ClassModel({
refs: types.array(Referrable),
model: Referencer,
}) {}

test("safe references are equal to the instances they refer to", () => {
const root = Root.createReadOnly({
model: {
ref: "item-a",
safeRef: "item-b",
},
refs: [
{ key: "item-a", count: 12 },
{ key: "item-b", count: 523 },
],
const root = create(
Root,
{
refs: [
{ key: "item-a", count: 12 },
{ key: "item-b", count: 523 },
],
model: {
safeRefs: {
"item-a": "item-a",
"item-c": "item-c",
},
},
},
readOnly,
);

expect([...root.model.safeRefs.keys()]).toEqual(["item-a"]);
});

test("references are equal to the instances they refer to", () => {
const root = create(
Root,
{
model: {
ref: "item-a",
safeRef: "item-b",
},
refs: [
{ key: "item-a", count: 12 },
{ key: "item-b", count: 523 },
],
},
readOnly,
);

expect(root.model.ref).toBe(root.refs[0]);
expect(root.model.ref).toEqual(root.refs[0]);
expect(root.model.ref).toStrictEqual(root.refs[0]);
});

expect(root.model.safeRef).toBe(root.refs[1]);
expect(root.model.safeRef).toEqual(root.refs[1]);
expect(root.model.safeRef).toStrictEqual(root.refs[1]);
test("safe references are equal to the instances they refer to", () => {
const root = create(
Root,
{
model: {
ref: "item-a",
safeRef: "item-b",
},
refs: [
{ key: "item-a", count: 12 },
{ key: "item-b", count: 523 },
],
},
readOnly,
);

expect(root.model.safeRef).toBe(root.refs[1]);
expect(root.model.safeRef).toEqual(root.refs[1]);
expect(root.model.safeRef).toStrictEqual(root.refs[1]);
});
});

test("throws for invalid refs", () => {
const createRoot = () =>
Root.createReadOnly({
model: {
ref: "item-c",
},
refs: [
{ key: "item-a", count: 12 },
{ key: "item-b", count: 523 },
],
});

expect(createRoot).toThrow();
});

test("instances of a model reference are assignable to instances of the model", () => {
Expand Down
8 changes: 7 additions & 1 deletion src/array.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { ensureRegistered } from "./class-model";
import { getSnapshot } from "./snapshot";
import { $context, $parent, $readOnly, $type } from "./symbols";
import type { IAnyStateTreeNode, IAnyType, IArrayType, IMSTArray, IStateTreeNode, Instance, TreeContext } from "./types";
import { SafeReferenceType } from "./reference";

export class QuickArray<T extends IAnyType> extends Array<Instance<T>> implements IMSTArray<T> {
static get [Symbol.species]() {
Expand Down Expand Up @@ -82,8 +83,13 @@ export class ArrayType<T extends IAnyType> extends BaseType<Array<T["InputType"]
instantiate(snapshot: this["InputType"] | undefined, context: TreeContext, parent: IStateTreeNode | null): this["InstanceType"] {
const array = new QuickArray<T>(this, parent, context);
if (snapshot) {
array.push(...snapshot.map((element) => this.childrenType.instantiate(element, context, array)));
let instances = snapshot.map((element) => this.childrenType.instantiate(element, context, array));
if (this.childrenType instanceof SafeReferenceType && this.childrenType.options?.acceptsUndefined === false) {
instances = instances.filter((instance) => instance !== null && instance !== undefined);
}
array.push(...instances);
}

return array as this["InstanceType"];
}

Expand Down
Loading

0 comments on commit 330abed

Please sign in to comment.