Skip to content
Draft
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
23 changes: 23 additions & 0 deletions .changeset/effect-v4-upgrade.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
"@uploadthing/shared": major
"uploadthing": major
"@example/backend-adapters-server": patch
"next-playground": patch
"next-playground-v6": patch
"@uploadthing/tsconfig": patch
---

feat!: upgrade to Effect v4

- `effect` is bumped from `3.21.0` to `4.0.0-beta.93`. Since `@effect/platform`
has been merged into the core `effect` package in v4, the separate
`@effect/platform` dependency is removed and all HTTP modules are now
imported from `effect/unstable/http`.
- Client-side code previously built on `effect/Micro` (which was removed in v4)
now uses the core `Effect` module, which is aggressively tree-shakeable in v4.
- The `uploadthing/effect-platform` adapter now returns a plain
`Effect<HttpServerResponse, unknown, HttpClient | HttpServerRequest | Scope>`
handler that can be registered on an Effect v4 `HttpRouter` (e.g. via
`HttpRouter.add("*", "/api/uploadthing", handler)`).
- Public types that referenced Effect v3 concepts follow the v4 renames, e.g.
`logLevel` options are now typed as `LogLevel.LogLevel` string literals.
2 changes: 1 addition & 1 deletion .nvmrc
Original file line number Diff line number Diff line change
@@ -1 +1 @@
22.14
22.23
5 changes: 2 additions & 3 deletions examples/backend-adapters/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,14 @@
"dev:effect": "NODE_ENV=development PORT=3003 tsx watch src/effect-platform.ts"
},
"dependencies": {
"@effect/platform": "0.96.0",
"@effect/platform-node": "0.106.0",
"@effect/platform-node": "4.0.0-rc.110",
"@elysiajs/cors": "^1.2.0",
"@fastify/cors": "^10.0.1",
"@hono/node-server": "^1.13.7",
"@sinclair/typebox": "^0.34.13",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"effect": "3.21.0",
"effect": "4.0.0-rc.110",
"elysia": "^1.2.9",
"express": "^5.0.1",
"fastify": "^5.2.0",
Expand Down
72 changes: 27 additions & 45 deletions examples/backend-adapters/server/src/effect-platform.ts
Original file line number Diff line number Diff line change
@@ -1,70 +1,52 @@
import "dotenv/config";

import { createServer } from "node:http";
import { NodeHttpServer, NodeRuntime } from "@effect/platform-node";
import { Config, Effect, Layer, References } from "effect";
import {
FetchHttpClient,
Headers,
HttpMiddleware,
HttpRouter,
HttpServer,
HttpServerRequest,
HttpServerResponse,
} from "@effect/platform";
import { NodeHttpServer, NodeRuntime } from "@effect/platform-node";
import { Config, Effect, Layer, Logger, LogLevel } from "effect";
} from "effect/unstable/http";

import { createRouteHandler } from "uploadthing/effect-platform";

import { uploadRouter } from "./router";

const uploadthingRouter = createRouteHandler({
const uploadthingHandler = createRouteHandler({
router: uploadRouter,
});

/**
* Simple CORS middleware that allows everything
* Adjust to your needs.
*/
const cors = HttpMiddleware.make((app) =>
Effect.gen(function* () {
const req = yield* HttpServerRequest.HttpServerRequest;

const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "*",
"Access-Control-Allow-Headers": "*",
};

if (req.method === "OPTIONS") {
return HttpServerResponse.empty({
status: 204,
headers: Headers.fromInput(corsHeaders),
});
}

const response = yield* app;

return response.pipe(HttpServerResponse.setHeaders(corsHeaders));
const Routes = Layer.mergeAll(
HttpRouter.add("GET", "/api", HttpServerResponse.text("Hello from Effect")),
HttpRouter.add(
"*",
"/api/uploadthing",
uploadthingHandler.pipe(Effect.orDie),
),
/**
* Simple CORS middleware that allows everything.
* Adjust to your needs.
*/
HttpRouter.cors({
allowedOrigins: ["*"],
allowedMethods: ["*"],
allowedHeaders: ["*"],
}),
);

const router = HttpRouter.empty.pipe(
HttpRouter.get("/api", HttpServerResponse.text("Hello from Effect")),
HttpRouter.mountApp("/api/uploadthing", uploadthingRouter),
const ServerLive = Layer.unwrap(
Effect.map(Config.port("PORT").pipe(Config.withDefault(3000)), (port) =>
NodeHttpServer.layer(() => createServer(), { port }),
),
);

const app = router.pipe(
cors,
HttpServer.serve(HttpMiddleware.logger),
const AppLive = HttpRouter.serve(Routes).pipe(
HttpServer.withLogAddress,
Layer.provide(FetchHttpClient.layer),
Layer.provide(ServerLive),
Layer.provide(Layer.succeed(References.MinimumLogLevel, "Debug")),
);

const Port = Config.integer("PORT").pipe(Config.withDefault(3000));
const ServerLive = Layer.unwrapEffect(
Effect.map(Port, (port) =>
NodeHttpServer.layer(() => createServer(), { port }),
).pipe(Effect.provide(Logger.minimumLogLevel(LogLevel.Debug))),
);

NodeRuntime.runMain(Layer.launch(Layer.provide(app, ServerLive)));
NodeRuntime.runMain(Layer.launch(AppLive));
8 changes: 8 additions & 0 deletions examples/minimal-expo/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,14 @@
"compilerOptions": {
"strict": true,
"jsx": "react-native",
/**
* Effect v4 only exposes its submodules (e.g. `effect/Schema`) through
* package.json `exports` (no `typesVersions` fallback), which the legacy
* `node` resolution from expo/tsconfig.base cannot follow. Metro resolves
* package exports since Expo SDK 53, so `bundler` matches runtime behavior.
*/
"module": "preserve",
"moduleResolution": "bundler",
"paths": {
"~/*": ["./*"]
}
Expand Down
2 changes: 1 addition & 1 deletion packages/nuxt/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@
"nuxt": "^3.15.0",
"uploadthing": "workspace:*",
"vite": "^6.3.4",
"vitest": "3.2.4"
"vitest": "4.1.10"
},
"peerDependencies": {
"uploadthing": "^7.2.0"
Expand Down
9 changes: 5 additions & 4 deletions packages/react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -84,8 +84,9 @@
"@uploadthing/eslint-config": "workspace:*",
"@uploadthing/tsconfig": "workspace:*",
"@uploadthing/vitest-config": "workspace:*",
"@vitest/browser": "3.2.4",
"@vitest/coverage-istanbul": "3.2.4",
"@vitest/browser": "4.1.10",
"@vitest/browser-playwright": "4.1.10",
"@vitest/coverage-istanbul": "4.1.10",
"concurrently": "^9.1.2",
"eslint": "9.25.1",
"msw": "2.7.5",
Expand All @@ -96,8 +97,8 @@
"tsdown": "0.14.1",
"typescript": "5.8.3",
"uploadthing": "workspace:*",
"vitest": "3.2.4",
"vitest-browser-react": "0.3.0",
"vitest": "4.1.10",
"vitest-browser-react": "2.2.0",
"wait-on": "^8.0.1",
"zod": "^3.24.1"
}
Expand Down
26 changes: 13 additions & 13 deletions packages/react/test/upload-button.browser.test.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { page, userEvent } from "@vitest/browser/context";
import { http, HttpResponse } from "msw";
import { setupWorker } from "msw/browser";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { render } from "vitest-browser-react";
import { page, userEvent } from "vitest/browser";

import {
createRouteHandler,
Expand Down Expand Up @@ -86,7 +86,7 @@ afterAll(() => worker.stop());

describe("UploadButton - basic", () => {
it("fetches and displays route config", async () => {
const utils = render(<UploadButton endpoint="image" />);
const utils = await render(<UploadButton endpoint="image" />);
const label = utils.container.querySelector("label");

if (!label) throw new Error("No label found");
Expand All @@ -106,7 +106,7 @@ describe("UploadButton - basic", () => {
});

it("fetches and displays route config (with callback endpoint arg)", async () => {
const utils = render(
const utils = await render(
<UploadButton endpoint={(routeRegistry) => routeRegistry.image} />,
);
const label = utils.container.querySelector("label");
Expand All @@ -128,7 +128,7 @@ describe("UploadButton - basic", () => {
});

it("shows plural when maxFileCount is > 1", async () => {
const utils = render(<UploadButton endpoint="multi" />);
const utils = await render(<UploadButton endpoint="multi" />);
const label = utils.container.querySelector("label");

if (!label) throw new Error("No label found");
Expand All @@ -146,15 +146,15 @@ describe("UploadButton - basic", () => {
it("picks up route config from global and skips fetch", async () => {
(window as any).__UPLOADTHING = extractRouterConfig(testRouter);

render(<UploadButton endpoint="image" />);
await render(<UploadButton endpoint="image" />);
await expect.element(page.getByText("Image (4MB)")).toBeVisible();
expect(utGet).not.toHaveBeenCalled();

delete (window as any).__UPLOADTHING;
});

it("requests URLs when a file is selected", async () => {
const utils = render(<UploadButton endpoint="image" />);
const utils = await render(<UploadButton endpoint="image" />);
const label = utils.container.querySelector("label");
await vi.waitFor(() =>
expect(label).toHaveAttribute("data-state", "ready"),
Expand All @@ -171,7 +171,7 @@ describe("UploadButton - basic", () => {
{
name: "foo.txt",
type: "text/plain",
size: 18,
size: 3,
lastModified: expect.any(Number),
},
],
Expand All @@ -182,7 +182,7 @@ describe("UploadButton - basic", () => {
});

it("manual mode requires extra click", async () => {
const utils = render(
const utils = await render(
<UploadButton endpoint="image" config={{ mode: "manual" }} />,
);
const label = utils.container.querySelector("label");
Expand All @@ -209,7 +209,7 @@ describe("UploadButton - basic", () => {

// https://discord.com/channels/966627436387266600/1102510616326967306/1267098160468197409
it.skip("disabled", async () => {
const utils = render(<UploadButton endpoint="image" disabled />);
const utils = await render(<UploadButton endpoint="image" disabled />);
const label = utils.container.querySelector("label");
await vi.waitFor(() => expect(label).toHaveTextContent("Choose File"));
expect(label).toHaveAttribute("disabled");
Expand All @@ -218,7 +218,7 @@ describe("UploadButton - basic", () => {

describe("UploadButton - lifecycle hooks", () => {
it("onBeforeUploadBegin alters the requested files", async () => {
const utils = render(
const utils = await render(
<UploadButton
endpoint="image"
onBeforeUploadBegin={() => {
Expand All @@ -245,7 +245,7 @@ describe("UploadButton - lifecycle hooks", () => {

it("onUploadBegin runs before uploading", async () => {
const onUploadBegin = vi.fn();
const utils = render(
const utils = await render(
<UploadButton endpoint="multi" onUploadBegin={onUploadBegin} />,
);

Expand All @@ -265,7 +265,7 @@ describe("UploadButton - lifecycle hooks", () => {

describe("UploadButton - Theming", () => {
it("renders custom styles", async () => {
render(
await render(
<UploadButton
endpoint="image"
appearance={{
Expand All @@ -284,7 +284,7 @@ describe("UploadButton - Theming", () => {

describe("UploadButton - Content Customization", () => {
it("renders custom content", async () => {
render(
await render(
<UploadButton
endpoint="image"
content={{
Expand Down
6 changes: 3 additions & 3 deletions packages/react/test/upload-dropzone.browser.test.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { page } from "@vitest/browser/context";
import { http, HttpResponse } from "msw";
import { setupWorker } from "msw/browser";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { render } from "vitest-browser-react";
import { page } from "vitest/browser";

import { createRouteHandler, createUploadthing } from "uploadthing/server";

Expand Down Expand Up @@ -53,7 +53,7 @@ afterAll(() => worker.stop());

describe("UploadDropzone - basic", () => {
it("fetches and displays route config", async () => {
const utils = render(<UploadDropzone endpoint="image" />);
const utils = await render(<UploadDropzone endpoint="image" />);
const label = utils.container.querySelector("label");

if (!label) throw new Error("No label found");
Expand All @@ -73,7 +73,7 @@ describe("UploadDropzone - basic", () => {
});

it("shows plural when maxFileCount is > 1", async () => {
const utils = render(<UploadDropzone endpoint="multi" />);
const utils = await render(<UploadDropzone endpoint="multi" />);
const label = utils.container.querySelector("label");

if (!label) throw new Error("No label found");
Expand Down
3 changes: 2 additions & 1 deletion packages/react/vitest.config.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { playwright } from "@vitest/browser-playwright";
import { defineConfig, mergeConfig } from "vitest/config";

import { baseConfig } from "@uploadthing/vitest-config/base";
Expand All @@ -9,7 +10,7 @@ export default mergeConfig(
name: "browser",
browser: {
instances: [{ browser: "chromium" }],
provider: "playwright",
provider: playwright(),
enabled: true,
},
},
Expand Down
8 changes: 4 additions & 4 deletions packages/shared/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,22 +42,22 @@
},
"dependencies": {
"@uploadthing/mime-types": "workspace:*",
"effect": "3.21.0",
"effect": "4.0.0-rc.110",
"sqids": "^0.3.0"
},
"devDependencies": {
"@effect/vitest": "0.29.0",
"@effect/vitest": "4.0.0-rc.110",
"@types/react": "19.2.7",
"@uploadthing/eslint-config": "workspace:",
"@uploadthing/tsconfig": "workspace:",
"@uploadthing/vitest-config": "workspace:",
"@vitest/coverage-istanbul": "3.2.4",
"@vitest/coverage-istanbul": "4.1.10",
"eslint": "9.25.1",
"react": "19.2.2",
"solid-js": "^1.9.3",
"tsdown": "0.14.1",
"typescript": "5.8.3",
"vitest": "3.2.4",
"vitest": "4.1.10",
"vue": "^3.4.21",
"wait-on": "^8.0.1"
}
Expand Down
Loading
Loading