Skip to content
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

Improve TypeScript inference for common action pattern #3878

Closed
wants to merge 1 commit into from

Conversation

kentcdodds
Copy link
Member

@kentcdodds kentcdodds commented Jul 29, 2022

If you're interested in helping out, the best thing to do is rework this TypeScript Playground in a way that allows type inference (rather than explicit typing) and doesn't require weird stuff in the JSX.

I'm trying to figure out the best way to handle this situation. This sort of thing works fine with regular TS, so there's something in our serialized type that's not allowing this pattern. Help is appreciated!

The error I'm getting is:

Property 'email' does not exist on type 'SerializeObject<{ email: string; }> | SerializeObject<{ password: string; }>'.
  Property 'email' does not exist on type 'SerializeObject<{ password: string; }>'.ts(2339)

And

Property 'password' does not exist on type 'SerializeObject<{ email: string; }> | SerializeObject<{ password: string; }>'.
  Property 'password' does not exist on type 'SerializeObject<{ email: string; }>'.ts(2339)

image

@changeset-bot
Copy link

changeset-bot bot commented Jul 29, 2022

⚠️ No Changeset found

Latest commit: 73e6c7d

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets
Name Type
create-remix Patch
remix Patch
@remix-run/architect Patch
@remix-run/cloudflare Patch
@remix-run/cloudflare-pages Patch
@remix-run/cloudflare-workers Patch
@remix-run/deno Patch
@remix-run/dev Patch
@remix-run/eslint-config Patch
@remix-run/express Patch
@remix-run/netlify Patch
@remix-run/node Patch
@remix-run/react Patch
@remix-run/serve Patch
@remix-run/server-runtime Patch
@remix-run/vercel Patch

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@kentcdodds kentcdodds changed the base branch from main to dev July 29, 2022 22:25
@kentcdodds kentcdodds changed the title chore: format Improve TypeScript inference for common action pattern Jul 29, 2022
Comment on lines +140 to +144
if (actionData?.errors?.email) {
// focus email
} else if (actionData?.errors?.password) {
// focus password
}
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note: this test technically runs fine, but fails TypeScript on these two if statements.

@kentcdodds
Copy link
Member Author

kentcdodds commented Jul 29, 2022

Here's this test in a TypeScript playground

And here it is with what I think is everything we need to know how to change it to fix things for this case:

@just-toby
Copy link

would this approach work for you?

I think TS is complaining because the type is either { email: "bla" } or { password: "bla" }, so it can't know if either field actually exists. if you make them both optional in the same object/type, then it will work.

you can also get around this issue by just accessing the fields in a type-unsafe way:

if (actionData?.errors?.["email"]) {
  // focus email
} else if (actionData?.errors?.["password"]) {
  // focus password
}

@kentcdodds
Copy link
Member Author

Thanks @just-toby, but I'd rather not use that because what we're going for is inference. If I change "password" to "secret" in my code, I also have to update the type which is undesirable.

@kentcdodds
Copy link
Member Author

I think the bug is that null and undefined get turned into never which is incorrect. In the null case, it definitely should stay null. In the undefined case it should just not appear in the type. I think I saw another issue about this somewhere.

@sinansonmez
Copy link

What if you explicitly declare return type of the function such as:

async function action({ request }: ActionArgs): Promise<TypedResponse<{errors: {email: string, password: string}}> | null> {

@kulshekhar
Copy link

  React.useEffect(() => {
    if (actionData?.errors) {
      if ('email' in actionData.errors) {
        // focus email
      } else if (actionData?.errors?.password) {
        // focus password
      }
    }
  }, [actionData]);

satisfies the type checker. Does this work for your case?

@kentcdodds
Copy link
Member Author

Thanks @kulshekhar, that does indeed make TypeScript happy about that code, but it's a pain to do that in JSX. Try this example.

@sinansonmez, thanks, but your suggestion is the same as @just-toby's which I explained won't work for what we're trying to do with inference.

@kulshekhar
Copy link

Would something like this satisfy requirements?

const hasEmailError = (actionData: unknown): actionData is { errors: { email: string } } => {
  return false
}

const hasPasswordError = (actionData: unknown): actionData is { errors: { password: string } } => {
  return false
}

// eslint-disable-next-line @typescript-eslint/no-unused-vars
function Component() {
  let actionData = useActionData<typeof action>();

  const email = hasEmailError(actionData) ? actionData.errors.email : null;
  const password = hasPasswordError(actionData) ? actionData.errors.password : null;

  React.useEffect(() => {
    if (email) {
      // focus email
    } else if (password) {
      // focus password
    }
  }, [actionData]);

  return (
    <div>
      {email && (
        <div className="pt-1 text-red-700" id="email-error">
          {email}
        </div>
      )}
    </div>
  );
}

If so,

  • the hasEmailError and hasPasswordError could be combined into a single generic function, and
  • the return type of useActionData could possibly be updated to have it so that the consumers of this hook don't have to do this every time

@pcattori
Copy link
Contributor

pcattori commented Jul 29, 2022

Potential fix, replace the definition of UndefinedOptionals with:

// Turn `{ a: someType | undefined }` into `{ a?: undefined }`
type UndefinedOptionals<T> = Merge<
  {
    // For properties that aren't unions including `undefined`, keep them as is
    [k in keyof T as undefined extends T[k] ? never : k]: T[k];
  },
  {
    // For properties that _are_ unions including `undefined`, make them optional (`?`) and remove `undefined` from the union
    [k in keyof T as undefined extends T[k] ? k : never]?: Exclude<T[k], undefined>;
  }
>;

PR is here: #3879

@gustavoguichard
Copy link
Contributor

gustavoguichard commented Jul 30, 2022

Hey @kentcdodds I don't think it is possible to conform TS with the original approach.

// if (typeof email !== "string" || !email) {
  return json({ errors: { email: "Email is required" } });
// }
// if (typeof password !== "string" || !password) {
  return json({ errors: { password: "Password is required" } });
// }

The shape changes in the two return statements so it is resolving to a union: { email: string } | { password: string }.
If you don't assert to TS that "password" is in the type it won't let you access it as it could be { email: string }.

A way to satisfy TS without going crazy would be to return empty strings:

// if (typeof email !== "string" || !email) {
  return json({ errors: { email: "Email is required", password: '' } });
// }
// if (typeof password !== "string" || !password) {
  return json({ errors: { password: "Password is required", email: '' } });
// }

This way the type of the action will be: { email: string, password: string } and the types will pass.

@just-toby
Copy link

maybe the problem here is that union types, when used in generic conditional statements, become distributive?

so if T is A | B, then SerializeType<T> applies the conditional statements onto A and B individually and gives a union of each as the final result. i think you to process the type you pass to SerializeType to merge possible union branches, maybe with an approach like this (credit to this author).

@gustavoguichard
Copy link
Contributor

gustavoguichard commented Jul 30, 2022

Also, in my personal projects I've retyped UseDataFunctionReturn to avoid the Serialize types.. they are very limiting and I had to do something in order to use superjson.
If you skip those types you can use undefined | null, check this out

export type UseDataFunctionReturn<T extends DataOrFunction> = T extends (
  ...args: any[]
) => infer Output
  ? Awaited<Output> extends TypedResponse<infer U>
    ? U
    : Awaited<ReturnType<T>>
  : Awaited<T>;


// ...

//  if (typeof email !== "string" || !email) {
    return json({ errors: { email: "Email is required", password: undefined } });
//  }
//  if (typeof password !== "string" || !password) {
    return json({ errors: { password: "Password is required", email: null } });
//  }

@jca41
Copy link
Contributor

jca41 commented Jul 30, 2022

Using UnionToIntersection from type-fest removes the union so you can access the keys of the "merged" type with no issues. Seems to work for this case.

EDIT: it doesn't preserve the optional keys so that would need to be fixed

https://www.typescriptlang.org/play?#code/JYWwDg9gTgLgBAKjgQwM5wEoFNkGN4BmUEIcARFDvmQNwBQokscMAnmFnAN5wCC+wCADteUAOboAvnCIlyAAUohgADwC0UAK5CA9EIgATLLQbho8HgCtUwgDRxKB4JXxxps0mUVZl6rbv0jE0ZzFnZOHgBZLHEsewBVIUEhABUIAEkhGBjULAFhd2JPNg41AixUGBM6Es5eMDAAEWQYZDgAXhQhVno6LBUmeFq4FPCDbFRIIVyAHhSAPg7MCqncuAAybjo4OGthAAoASgAuOAAFIuBZhfpJXuHm1oAxbXyhJf2AOm-kcVRT5DdADaAF1Dh1FtoANb6ADuQhocB0OjgIBauAAFhUuqwZK8YMkauE4I9kAB5KAvIRvJb1JotNoAHxJDKpb3owwAUjYhBdQMACQA3bHtbZwZmVKDAIRiMXMoSaEAAIxicrgSogEAANjghGqAMowKUytUAOUVKqgaoAQpqdYC1QqtVqOcTTcJucI+cohSK4NojARpVgDOK4GyCcIw6hWMrtfdifqYsBkFrgAAvLCjDhzRadFJwfrZIQGdCe3lSn3AYWoMUAfhGYtOBaLWBL6HdQnL3oF1Yq9bgQiwwqtO2bhZUxdL3BYEE5+rJpqOp2l5SgcHibgH8SbIwnU-QoIHR7He9b7bgQOhcKE9m+n2vEHhoJBA64Yp2OyBULg0rgUKwVgIAIEYQWbb8QX3Ntp07btK17GsP0-HYGydLUkOQ04kylVMMyzcI5gg+Z6GQyRdxbSdoPQbBkAMYQtVYUQoGQVgZlXGIN3mAd9k3c8YI9Hke19dBUM0Z04Cw5NcMzbMsBmeJ5kOE8JLPSiLwgJVLDyGAB2wlM00zMlNO0+SSywIMhwMMkwEjIRU1QXMuNPIcRwTDg4D06SsCMrT8FzJZ3y-H8-wAoCQILNARggqCL1gwT4OEuBUOHDjTihMCPKkgz8JzJCUggsViLoO46CJdzEkDYMrJs5J7P8zpoliGYxUCz9v1-d5QuAvdIoDcyqpi6d8vSpLBxS9c0oyztzWdZAlR1Qj0qKnZJFsFqkPakLAO6iL0D6iyQ0G9Bhsghsf1OFyYhBOtTmmsStTmhaTuWrcir6AZQmGeJclJCNkmwGBNCgIQ5iOllWgpP7hDzDckmENJMmyKBcjeUG+PQfYxXvX4JABYFX3BdpFnY9cyU0GAwHJgdeFhZABRDGYyYp8nFnRkYxgmVY5JJzikIbTzstk+SnM-SScMFgiabp7IDBmAGgdSAiFnmEWxf0vChal+nZeVt7+kGOAjFwB7KDxalbP9XJ+Fs0lQc6OlSXmZc1W+rBfvxf6sEB4HczVfaqt6OhkWDkPQ7D8OI8jiPDYgQcIHgTFATEThAVYGAMWlMR1SwLUnxYDP0DTIdSrQVhqTNmk8Fs-YeEoABHTQKngSRTmt5JRAkcFWp1QhoBAUklmQWmBQcLAG6bz4CD70kjhIuAe8LNFgC1JYp6gfuGU+ZOYH2MgfDprUyEOOeF7ANBUFhaBQ06NeN9aLevd3s-UAvq+j7n4AQP2Wpuv35e4AAITtE6GQSUmcyDimZAAv+Wou5IUoN7d4ewhA10LFAYgyNTg8BgacMgABRJeK8rijwbs4EMEDpCSGPmKMiOxP5wG-uEbqz9X5QFDEAkBYCZQQMZFAlhl82FwOQgghWuweSoJiBg-4M5+FX1wWcc+AjQzEPrpoMhBgKFuGoStMUIjgajycC4HeZAdAYk1MxVg79iqlQIB7KMABhEgUw2w7yEfPL2KA3gD06JoK2XiGQzB-iBKuyQnbULFDRfAD5ch4IIOUfA+wjgQi2Mheh+wQnCFJHWT4kjoCoGyTAtxyFkQyAgLgXxi8D5IWkDnNYaSMlCCyTk9BeTsmyMESk5CSIURT3KegdpBhqk0PsECBppIwS9B2Ho94mNkIzCcIKEWyEuBjIZAUlpyMCmEI2JsWZXSdjzOrHAY259TTIBAFgdoZAbJqAAIwsCLBoEMagADsAAGN5EDgAGCuTAtQuSoBkCWfs7gqz74AtQDkwhtCQUzB0As4FOxDgwoOfC6sItqFkSAA

@kentcdodds
Copy link
Member Author

Thanks everyone for the help!

I'm beginning to think that what it's doing is more correct than what I want it to do. I'm going to play around with it a bit still. Will let you all know what I decide to recommend here.

@jca41
Copy link
Contributor

jca41 commented Jul 30, 2022

Thanks everyone for the help!

I'm beginning to think that what it's doing is more correct than what I want it to do. I'm going to play around with it a bit still. Will let you all know what I decide to recommend here.

Implementing a MergeDeep would solve the issue but it seems quite the task 😨
Imo that union is an accurate representation of that return type, you could always solve it with a type ActionErrors = {errors: {email?: string; password?: string}};

@itsMapleLeaf
Copy link
Contributor

itsMapleLeaf commented Jul 30, 2022

If you're open to recommending alternate approaches, in this scenario, I use as const strings to create discriminated unions. So instead of relying on the existence of fields, I pass an "error type" to the frontend:

async function action({ request }: ActionArgs) {
  let formData = await request.formData()
  let email = formData.get("email")
  let password = formData.get("password")
  if (typeof email !== "string" || !email) {
    return json({
      error: "emailRequired" as const,
      message: "Email is required",
    })
  }
  if (typeof password !== "string" || !password) {
    return json({
      error: "passwordRequired" as const,
      message: "Password is required",
    })
  }
  return redirect("/hooray")
}

function Component() {
  let actionData = useActionData<typeof action>()

  React.useEffect(() => {
    if (actionData?.error === "emailRequired") {
      // focus email
    } else if (actionData?.error === "passwordRequired") {
      // focus password
    }
  }, [actionData])

  return (
    <div>
      {actionData?.error === "emailRequired" && (
        <div className="pt-1 text-red-700" id="email-error">
          {actionData.message}
        </div>
      )}
      {actionData?.error === "passwordRequired" && (
        <div className="pt-1 text-red-700" id="password-error">
          {actionData.message}
        </div>
      )}
    </div>
  )
}

Playground

This exact approach might not look that nice, but hopefully it gives some ideas!

@tshddx
Copy link

tshddx commented Jul 30, 2022

For reference, I believe this is essentially the same conversation as these two threads regarding undefined fields in loader and action inferred types:

#3794

sindresorhus/type-fest#424

(There’s a simpler and more serious bug here, which is that properties which should have type string | undefined get inferred as type string, making it very easy to introduce a runtime error by incorrectly assuming a property is defined.)

@devanshj
Copy link

devanshj commented Jul 30, 2022

TLDR: Here's the solution https://tsplay.dev/w1PdlW

First, this is a typescript "problem", the following won't compile.

let x = {} as { a?: string } | { b?: string }
type Test = keyof typeof x
//    ^? never

if (x.a) {
//    ~ [1]
  console.log(x.a.slice())
//              ~ [1]   
}

// [1]:
// Property 'a' does not exist on type '{ a?: string | undefined; } | { b?: string | undefined; }'.
//   Property 'a' does not exist on type '{ b?: string | undefined; }'.(2339)

But it's not a "problem" because why should you be allowed to access property a when it's not a key in the type of x? (x could be { b?: string }).

This sort of thing works fine with regular TS

That works because the return type of getErrors is not { email: string } | { password: string } but rather { email: string, password?: undefined } | { password: string, email?: string } and that that indeed does work...

let x = {} as { a?: string, b?: undefined } | { b?: string, a?: undefined }
type Test = keyof typeof x
//    ^? "a" | "b"

if (x.a) { // compiles
  console.log(x.a.slice())  // compiles
}

Now you can access a because it's a key in the type of x.

The correct way to solve this problem is to not solve it but rather not create it in the first place, ie model your data in a way that is coherent with the way types work, which is could be like this #3878 (comment) for example.

But if for whatever reason we don't want to do that, then how can we solve the problem? Here are a few ways...

  1. Use "a" in x predicate which would narrow { a?: string } | { b?: string } to { a?: string } and now you will be able to access a
declare const createX: () => { a?: string } | { b?: string }
let x = createX()

if ("a" in x && x.a) {
  console.log(x.a.slice())
}
  1. Use @sthir/predicate...
import { p, pa } from "@sthir/predicate/macro"

declare const createX: () => { a?: string } | { b?: string }
let x = createX()

if (pa(x, p(".a")) {
  console.log(x.a.slice())
}
  1. Make a type assertion
declare const createX: () => { a?: string } | { b?: string }
let x = createX()

let y = x as { a?: string, b?: undefined } | { b?: string, a?: undefined } 
if (y.a) {
  console.log(y.a.slice())
}
  1. Make a type assertion and use a helper to normalize keys...
declare const createX: () => { a?: string } | { b?: string }
let x = createX()

let y = x as NormalizeKeys<typeof x>
if (y.a) {
  console.log(y.a.slice())
}

type NormalizeKeys<T, TCopy = T> =
  T extends unknown
    ? T extends object
        ? & T
          & { [_ in
                Exclude<TCopy extends unknown ? keyof TCopy : never, keyof T>
              ]?: never
            }
        : T
    : never
  1. Produce a normalized type in the first place...
declare const createX: () => NormalizeKeys<{ a?: string } | { b?: string }>
type NormalizeKeys<T, TCopy = T> =
  T extends unknown
    ? T extends object
        ? & T
          & { [_ in
                Exclude<TCopy extends unknown ? keyof TCopy : never, keyof T>
              ]?: never
            }
        : T
    : never

let x = createX()

if (x.a) {
  console.log(x.a.slice())
}

So in our case because we ourselves produce the type, we can go with the option 5.

Although our case is a bit different, we want to transform this type...

type X =
  | { foo: string, bar: { a?: string } }
  | { baz: string, bar: { b?: string } }
  | { hello: string } 

to...

type XNormalized =
  | { foo: string, bar: { a?: string, b?: never } }
  | { baz: string, bar: { b?: string, a?: never } }
  | { hello: string }

So we don't want to touch the root object, but rather normalize the object at bar wrt to all bars in the parent. For that we'd write this...

type X =
  | { foo: string, bar: { a?: string } }
  | { baz: string, bar: { b?: string } }
  | { hello: string }

type XNormalized =
  NormalizeBarKeys<X>

type NormalizeBarKeys<T, TCopy = T> =
  T extends { bar: infer B }
    ? & T 
      & { bar:
            { [_ in (
                  Exclude<
                    TCopy extends { bar: infer B }
                      ? B extends unknown ? keyof B : never
                      : never,
                    keyof B
                  >
                )]?: never
              }
        }
    : T

And in our case bar is errors, so we can make this diff...

  export type UseDataFunctionReturn<T extends DataOrFunction> =
+   NormalizeErrorKeys<
      T extends (...args: any[]) => infer Output
        ? Awaited<Output> extends TypedResponse<infer U>
          ? SerializeType<U>
          : SerializeType<Awaited<ReturnType<T>>>
        : SerializeType<Awaited<T>>
+   >;

+ type NormalizeErrorsKeys<T, TCopy = T> =
+   T extends { errors: infer E }
+     ? & T 
+       & { errors:
+             { [_ in (
+                   Exclude<
+                     TCopy extends { errors: infer E }
+                       ? E extends unknown ? keyof E : never
+                       : never,
+                   keyof E
+                   >
+                 )]?: never
+               }
+         }
+     : T

And here's the final code (same as TLDR)... https://tsplay.dev/w1PdlW

But remember NormalizeErrorsKeys will normalize even if normalization is not required, so let's say if we have something like this...

type X = 
  | { errors: { type: "foo", foo: string }
  | { errors: { type: "bar", bar: string }

(although this would be rare because no one would call it "errors" then but "error" instead)

This will turn into...

type XNormalized = 
  | { errors: { type: "foo", foo: string, bar?: never }
  | { errors: { type: "bar", bar: string, foo?: never  }

Which the user might not expect in the tooltip. Ofc normalization doesn't change the type mathematically as such, { a?: string, b?: never } is almost equal to { a?: string }. (Btw some people may call normalization as widening ie transforming { a?: string } to { a?: string, b?: never }, but I call it normalization because we're not just widening but widening in a way that "normalizes")

And ofc this is typescript you can do anything you want, so we can write a heuristic that checks if normalization is not required. An example heuristic can be "Don't normalize if you find a key type on each contituent"...

  type NormalizeErrorsKeys<T, TCopy = T> =
+   T extends { errors: { type: unknown } } ? T :
    T extends { errors: infer E }
      ? & T 
        & { errors:
              { [_ in (
                    Exclude<
                      TCopy extends { errors: infer E }
                        ? E extends unknown ? keyof E : never
                        : never,
                    keyof E
                    >
                  )]?: never
                }
          }
      : T

Or conversely you could come up with a heuristic that checks if the normalisation is required. I'm not sure what patterns of modeling are popular/recommended, but the point being we can tailor stuff for our needs.

What is the right solution to solve this problem depends what is "right" for us. The correct solution is to not use the error pattern in the OP. But how much correctness is right? The solution that caters to DX for people who use such error pattern is normalization. But how much catering to DX is right? The solution that has the most accessible/easy code (in the remix codebase) is the one without normalization. But how much having accessible/easy code is right? The solution that requires least typescript experts for maintaince is without normalization. But how much not requiring typescript experts is right? What is right?

Good luck ;)

Also feel free to ask how those types work (I didn't write because it could be lengthy and maybe there are people here who do understand them without an explanation), or any other questions.

@colinhacks
Copy link
Contributor

colinhacks commented Aug 11, 2022

@kentcdodds Sorry, just seeing this.

I think the NormalizeErrorKeys approach proposed by @devansh is interesting and could certainly work. Here's an other candidate for consideration:


This can be fixed with some postprocessing inside useActionData designed to flatten out the errors key. Here's a proposed implementation. This collapses the errors intersection type to a union of Partialed types by introducing a utility called PostprocessActionReturnType.

type TestType =
  | {errors: {a: string}}
  | {errors: {b: string; c: string}}
  | never; // redirect -> TypedResponse<never>

type Processed = PostprocessActionReturnType<TestType>;
/*
 {
    errors: {
        a?: string | undefined;
        b?: string | undefined;
        c?: string | undefined;
    };
 }
*/

Worth noting that this "postprocessing" step would happen inside UseDataFunctionReturn. Calling an action directly would give you back the "raw" type.

As implemented, there is always an errors key on the result. This could be changed but I kinda like it?

type TestType = {data: string}

type Processed = PostprocessActionReturnType<TestType>;
/*
 { 
    data: string;
    errors: undefined;
 }
*/

As some others have noted though, you probably don't want to short-circuit the error reporting every time a new error is discovered. Better to surface as many errors as possible to the user at once—may be worth recommending a pattern like this in official docs:

export const action: ActionFunction = async ({ request }) => {
  const formData = await request.formData();
  const errors: Partial<Record<"email" | "password">, string>;
  const email = formData.get("email");
  const password = formData.get("password");

   if (!validateEmail(email)) {
    errors.email = "Invalid email";
  }

  if (typeof password !== "string") {
    errors.password = "Invalid password";
  }

  return json({ errors }, { status: 400 });
}

@pcattori
Copy link
Contributor

I talked with @kentcdodds , and the original issue is solved by #3879 .

As for normalizing the types:

It seems that Typescript only normalizes the types when the function directly returns plain objects, but not when returning the result of other functions that return plain objects. See the TS playground example in my tweet and compare the types inferred for action1 and action2.

I don't think we want to diverge too far from what TS normally does, so going to hold off on forced normalizing.
If anyone feels strongly about normalizing the types, I encourage you to open a Github discussion and we can keep exploring that option there.

@pcattori pcattori closed this Aug 11, 2022
@pcattori
Copy link
Contributor

@kentcdodds feel free to reopen and merge the tests you added if you think that'd be valuable.

@MichaelDeBoey MichaelDeBoey deleted the typescript-actions branch September 21, 2022 01:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.