Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/accept-mime-extensions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"@uploadthing/shared": patch
"@uploadthing/react": patch
"@uploadthing/vue": patch
"@uploadthing/solid": patch
"uploadthing": patch
---

Include file extensions alongside MIME types in client `accept` filters.

Some browsers and OSes (notably Chrome on Windows) ignore MIME-only `accept` values like `application/java-archive` and fall back to "All Files". `generateMimeTypes` and `generateClientDropzoneAccept` now also emit known extensions from `@uploadthing/mime-types` (for example `.jar`), so the native file picker can filter correctly.
95 changes: 87 additions & 8 deletions packages/shared/src/component-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,17 @@ import type { JSX } from "solid-js/jsx-runtime";
import type { RenderFunction, StyleValue } from "vue";

/**
* Use granular imports to better tree-shake
* We don't need all the types, and `/application`
* entrypoint is ~7k gzip which we can shave off
* Prefer granular mime-type imports for tree-shaking. `application` is included
* so specific MIME keys (e.g. `application/java-archive`) can resolve their
* file extensions for Windows file-picker `accept` filters.
*/
import { application } from "@uploadthing/mime-types/application";
import { audio } from "@uploadthing/mime-types/audio";
import { image } from "@uploadthing/mime-types/image";
import { text } from "@uploadthing/mime-types/text";
import { video } from "@uploadthing/mime-types/video";

import type { AcceptProp } from "./dropzone-utils";
import type { ExpandedRouteConfig } from "./types";
import { objectKeys } from "./utils";

Expand All @@ -25,6 +27,29 @@ export const roundProgress = (
return Math.floor(progress / 10) * 10;
};

const mimeTables: Array<Record<string, { extensions: readonly string[] }>> = [
application,
audio,
image,
text,
video,
];
Comment on lines +30 to +36

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Extension lookup omits MIME families

When a route uses a supported MIME type from the vendored misc table, such as font/woff2 or model/obj, extensionsForMime returns no extension because mimeTables excludes that table, causing Windows Chrome to retain the MIME-only picker behavior this change is intended to fix.

Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/shared/src/component-utils.ts
Line: 30-36

Comment:
**Extension lookup omits MIME families**

When a route uses a supported MIME type from the vendored `misc` table, such as `font/woff2` or `model/obj`, `extensionsForMime` returns no extension because `mimeTables` excludes that table, causing Windows Chrome to retain the MIME-only picker behavior this change is intended to fix.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.


/**
* Look up dotted extensions for a specific MIME type from the vendored tables.
* Used so HTML `accept` and dropzone filters include both the MIME and e.g.
* `.jar`, which Windows Chrome needs when the MIME alone is ignored.
*/
const extensionsForMime = (mime: string): string[] => {
for (const table of mimeTables) {
const entry = table[mime];
if (entry) {
return entry.extensions.map((ext) => `.${ext}`);
}
}
return [];
};

export const generateMimeTypes = (
typesOrRouteConfig: string[] | ExpandedRouteConfig,
) => {
Expand All @@ -34,8 +59,14 @@ export const generateMimeTypes = (
if (fileTypes.includes("blob")) return [];

return fileTypes.map((type) => {
if (type === "pdf") return "application/pdf";
if (type.includes("/")) return type;
if (type === "pdf") {
return ["application/pdf", ...extensionsForMime("application/pdf")].join(
", ",
);
}
if (type.includes("/")) {
return [type, ...extensionsForMime(type)].join(", ");
}

// Add wildcard to support all subtypes, e.g. image => "image/*"
// But some browsers/OSes don't support it, so we'll also dump all the mime types
Expand All @@ -49,9 +80,57 @@ export const generateMimeTypes = (
});
};

export const generateClientDropzoneAccept = (fileTypes: string[]) => {
const mimeTypes = generateMimeTypes(fileTypes);
return Object.fromEntries(mimeTypes.map((type) => [type, []]));
export const generateClientDropzoneAccept = (
fileTypes: string[],
): AcceptProp => {
if (fileTypes.includes("blob")) return {};

const accept: AcceptProp = {};

for (const type of fileTypes) {
if (type === "pdf") {
accept["application/pdf"] = extensionsForMime("application/pdf");
continue;
}

if (type.includes("/")) {
accept[type] = extensionsForMime(type);
continue;
}

if (type === "audio") {
accept["audio/*"] = [];
for (const mime of objectKeys(audio)) {
accept[mime] = extensionsForMime(mime);
}
continue;
}
if (type === "image") {
accept["image/*"] = [];
for (const mime of objectKeys(image)) {
accept[mime] = extensionsForMime(mime);
}
continue;
}
if (type === "text") {
accept["text/*"] = [];
for (const mime of objectKeys(text)) {
accept[mime] = extensionsForMime(mime);
}
continue;
}
if (type === "video") {
accept["video/*"] = [];
for (const mime of objectKeys(video)) {
accept[mime] = extensionsForMime(mime);
}
continue;
}

accept[`${type}/*`] = [];
}

return accept;
};

export function getFilesFromClipboardEvent(event: ClipboardEvent) {
Expand Down
36 changes: 35 additions & 1 deletion packages/shared/test/component-utils.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import * as E from "effect/Effect";
import { describe, expect, it } from "vitest";

import { generateMimeTypes } from "../src/component-utils";
import {
generateClientDropzoneAccept,
generateMimeTypes,
} from "../src/component-utils";
import { acceptPropAsAcceptAttr } from "../src/dropzone-utils";
import { fillInputRouteConfig } from "../src/utils";

describe("generateMimeTypes", () => {
Expand Down Expand Up @@ -37,4 +41,34 @@ describe("generateMimeTypes", () => {
expect(videoMimes).toContain("video/mp4");
expect(videoMimes).toContain("video/webm");
});

it("includes file extensions for specific MIME types", () => {
const [jarAccept] = generateMimeTypes(["application/java-archive"]);
expect(jarAccept).toContain("application/java-archive");
expect(jarAccept).toContain(".jar");
expect(jarAccept).toContain(".war");
expect(jarAccept).toContain(".ear");

const [pdfAccept] = generateMimeTypes(["pdf"]);
expect(pdfAccept).toContain("application/pdf");
expect(pdfAccept).toContain(".pdf");
});
});

describe("generateClientDropzoneAccept", () => {
it("maps specific MIME types to their extensions for the file picker", () => {
const accept = generateClientDropzoneAccept(["application/java-archive"]);
expect(accept).toEqual({
"application/java-archive": [".jar", ".war", ".ear"],
});

const acceptAttr = acceptPropAsAcceptAttr(accept);
expect(acceptAttr).toContain("application/java-archive");
expect(acceptAttr).toContain(".jar");
});

it("returns an empty accept map when blob is allowed", () => {
expect(generateClientDropzoneAccept(["blob"])).toEqual({});
expect(generateClientDropzoneAccept(["image", "blob"])).toEqual({});
});
});
Loading