Skip to content

bug: when serializer in httpLink can not return FormData, even though tRPC can serialize it when using in input directly #7180

Description

@lucacasonato

Provide environment information

  System:
    OS: Linux 5.0 undefined
    CPU: (8) x64 Intel(R) Core(TM) i9-9880H CPU @ 2.30GHz
    Memory: 0 Bytes / 0 Bytes
    Shell: 1.0 - /bin/jsh
  Binaries:
    Node: 20.19.1 - /usr/local/bin/node
    Yarn: 1.22.19 - /usr/local/bin/yarn
    npm: 10.8.2 - /usr/local/bin/npm
    pnpm: 8.15.6 - /usr/local/bin/pnpm
  npmPackages:
    @tanstack/react-query: ^5.80.3 => 5.90.21 
    @trpc/client: canary => 11.9.1-canary.9+d92cc45b3 
    @trpc/next: canary => 11.9.1-canary.9+d92cc45b3 
    @trpc/react-query: canary => 11.9.1-canary.9+d92cc45b3 
    @trpc/server: canary => 11.9.1-canary.9+d92cc45b3 
    next: ^15.3.8 => 15.5.12 
    react: ^19.1.0 => 19.2.4 
    typescript: ^5.9.2 => 5.9.3 

Describe the bug

I have written the following serializer/deserializer pair for use with httpLink. It allows serializing Blob objects contained inside of a plain object to be serialized as FormData fields. When you pass it an object { file: new Blob(["foo"]) }, it returns a formdata with field data set to {"file": <pointer to file 0>} and field blob set to new Blob(["foo"]).

If I pass this FormData object directly to a tRPC mutation, it gets correctly serialized. In this case I have to manually deserialize it on the server though. If I instead pass this function as a serializer to httpLink, the FormData returned from serializer is encoded as JSON (resulting in {} being the value sent). This obviously then breaks things.

Serializer/deserializer for Blob
import { parse, registerCustom, stringify } from "superjson";

let BLOBS: Blob[] = [];

registerCustom({
  isApplicable: (v) => v instanceof Blob,
  serialize: (blob) => {
    BLOBS.push(blob);
    return { index: BLOBS.length - 1 };
  },
  deserialize: (obj) => BLOBS[obj.index],
}, "Blob");

export function serialize(data: unknown): FormData | string {
  BLOBS = [];
  try {
    const serialized = stringify(data);
    if (BLOBS.length === 0) return serialized;
    const formData = new FormData();
    formData.append("data", serialized);
    for (const blob of BLOBS) formData.append("file", blob);
    console.log(formData);
    return formData;
  } finally {
    BLOBS = [];
  }
}

export function deserialize(val: FormData | string): unknown {
  let serialized: string;
  if (typeof val === "string") {
    serialized = val;
    BLOBS = [];
  } else {
    console.log(val);
    const data = val.get("data");
    if (typeof data !== "string") throw new Error("Expected 'data' field to be a string");
    const files = val.getAll("file");
    for (const file of files) {
      if (!(file instanceof Blob)) throw new Error("Expected 'file' field to be a Blob");
    }
    serialized = data;
    BLOBS = files as Blob[];
  }
  try {
    return parse(serialized);
  } finally {
    BLOBS = [];
  }
}

Link to reproduction

https://stackblitz.com/edit/trpc-serializing-formdata?file=src%2Fpages%2Findex.tsx

To reproduce

Press button in the demo above.

Additional information

My proposal is to allow serializer to return FormData, and to call deserializer even with FormData values from the wire. The latter is a breaking change, and would require a major version bump.

For now, I suggest a new option in the transformer called deserializeNonJsonTypes that defaults to false, but can be set to true to enable this behaviour.

For consistency, serializer should probable be called also for FormData inputs (right now it is not). This is again a breaking change, so should be flagged by a serializeNonJsonTypes option.

And furthermore I am saying FormData, but it should probably apply equally to all values that isNonJsonSerializable returns true for.

👨‍👧‍👦 Contributing

  • 🙋‍♂️ Yes, I'd be down to file a PR fixing this bug!

Metadata

Metadata

Assignees

No one assigned

    Labels

    ✅ accepted-PRs-welcomeFeature proposal is accepted and ready to work on

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions