Skip to content
Merged
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
2 changes: 1 addition & 1 deletion deno.json
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@
},
"imports": {
"react": "npm:react@^19.2.4",
"@types/react": "npm:@types/react@^19.2.13"
"@types/react": "npm:@types/react@^19.2.14"
},
"compilerOptions": {
"lib": [
Expand Down
745 changes: 375 additions & 370 deletions deno.lock

Large diffs are not rendered by default.

71 changes: 67 additions & 4 deletions docs/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -284,10 +284,13 @@ Test loaders with `createRoutesStub`:
```tsx
import "@udibo/juniper/utils/global-jsdom";
import { afterEach, beforeEach, describe, it } from "@std/testing/bdd";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import { cleanup, render, screen } from "@testing-library/react";
import { stub } from "@std/testing/mock";
import { FakeTime } from "@std/testing/time";
import { createRoutesStub } from "@udibo/juniper/utils/testing";
import {
createRoutesStub,
waitForFakeTime,
} from "@udibo/juniper/utils/testing";

import * as loaderRoute from "./loader.tsx";

Expand All @@ -310,7 +313,7 @@ describe("LoaderDemo route", () => {
const Stub = createRoutesStub([loaderRoute]);
render(<Stub />);

await waitFor(() => {
await waitForFakeTime(time, () => {
screen.getByText("Loading data...");
});
});
Expand All @@ -321,7 +324,7 @@ describe("LoaderDemo route", () => {

await time.tickAsync(600);

await waitFor(() => {
await waitForFakeTime(time, () => {
screen.getByText("Data loaded successfully!");
});
});
Expand Down Expand Up @@ -482,6 +485,66 @@ describe("Time-dependent tests", () => {
});
```

> **Important:** `FakeTime` is incompatible with `waitFor` from
> `@testing-library/react` — see [waitForFakeTime](#waitforfaketime) below for
> the solution.

### waitForFakeTime

`waitFor` from `@testing-library/react` hangs when `FakeTime` is active. This
happens because `@testing-library/react` internally uses
`setTimeout(resolve, 0)` to drain microtasks after `waitFor` resolves. With
`FakeTime`, that timer is captured by the fake queue and never fires.

Use `waitForFakeTime` from `@udibo/juniper/utils/testing` as a drop-in
replacement whenever `FakeTime` is active. It uses `FakeTime.restoreFor` to
periodically flush the fake timer queue with a real interval:

```tsx
import "@udibo/juniper/utils/global-jsdom";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import { afterEach, describe, it } from "@std/testing/bdd";
import { FakeTime } from "@std/testing/time";
import {
createRoutesStub,
waitForFakeTime,
} from "@udibo/juniper/utils/testing";

import * as indexRoute from "./index.tsx";

describe("Home route", () => {
afterEach(cleanup);

it("should render with fake time", async () => {
using time = new FakeTime("2025-01-15T12:00:00.000Z");
const Stub = createRoutesStub([indexRoute]);
render(<Stub />);

// Use waitForFakeTime when FakeTime is active
await waitForFakeTime(time, () => {
screen.getByRole("heading", { name: "Hello, World!" });
});
screen.getByText("Current time: 2025-01-15T12:00:00.000Z");
});

it("should render with stubbed data", async () => {
// No FakeTime — use regular waitFor
const Stub = createRoutesStub([{
...indexRoute,
loader: () => ({ message: "Custom!", now: new Date() }),
}]);
render(<Stub />);

await waitFor(() => {
screen.getByRole("heading", { name: "Custom!" });
});
});
});
```

**Rule of thumb:** If `FakeTime` is active in the test, use `waitForFakeTime`.
Otherwise, use the regular `waitFor` from `@testing-library/react`.

### Testing with Deno KV

Use in-memory KV for isolated tests:
Expand Down
16 changes: 8 additions & 8 deletions example/deno.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,21 +36,21 @@
"@udibo/juniper": "jsr:@udibo/juniper@^0.3.3",
"@udibo/esbuild-plugin-postcss": "jsr:@udibo/esbuild-plugin-postcss@^0.3.0",
"@std/testing": "jsr:@std/testing@^1.0.17",
"@std/assert": "jsr:@std/assert@^1.0.18",
"@std/async": "jsr:@std/async@^1.1.1",
"@std/collections": "jsr:@std/collections@^1.1.5",
"@std/assert": "jsr:@std/assert@^1.0.19",
"@std/async": "jsr:@std/async@^1.2.0",
"@std/collections": "jsr:@std/collections@^1.1.6",
"@std/encoding": "jsr:@std/encoding@^1.0.10",
"@std/path": "jsr:@std/path@^1.1.4",
"@std/streams": "jsr:@std/streams@^1.0.17",
"@std/uuid": "jsr:@std/uuid@^1.1.0",
"@opentelemetry/api": "npm:@opentelemetry/api@^1.9.0",
"playwright": "npm:playwright@^1.58.2",
"react": "npm:react@^19.2.4",
"@types/react": "npm:@types/react@^19.2.13",
"react-router": "npm:react-router@^7.13.0",
"hono": "npm:hono@^4.11.9",
"tailwindcss": "npm:tailwindcss@^4.1.18",
"@tailwindcss/postcss": "npm:@tailwindcss/postcss@^4.1.18",
"@types/react": "npm:@types/react@^19.2.14",
"react-router": "npm:react-router@^7.13.2",
"hono": "npm:hono@^4.12.9",
"tailwindcss": "npm:tailwindcss@^4.2.2",
"@tailwindcss/postcss": "npm:@tailwindcss/postcss@^4.2.2",
"zod": "npm:zod@^4.3.6",
"@testing-library/react": "npm:@testing-library/react@^16.3.2",
"@testing-library/user-event": "npm:@testing-library/user-event@^14.6.1"
Expand Down
17 changes: 10 additions & 7 deletions example/routes/features/data/loader.test.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import "@udibo/juniper/utils/global-jsdom";

import { cleanup, render, screen, waitFor } from "@testing-library/react";
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, it } from "@std/testing/bdd";
import { stub } from "@std/testing/mock";
import { FakeTime } from "@std/testing/time";

import { createRoutesStub } from "@udibo/juniper/utils/testing";
import {
createRoutesStub,
waitForFakeTime,
} from "@udibo/juniper/utils/testing";

import * as loaderRoute from "./loader.tsx";

Expand All @@ -28,7 +31,7 @@ describe("LoaderDemo route", () => {
const Stub = createRoutesStub([loaderRoute]);
render(<Stub />);

await waitFor(() => {
await waitForFakeTime(time, () => {
screen.getByText("Loading data...");
});
});
Expand All @@ -37,13 +40,13 @@ describe("LoaderDemo route", () => {
const Stub = createRoutesStub([loaderRoute]);
render(<Stub />);

await waitFor(() => {
await waitForFakeTime(time, () => {
screen.getByText("Loading data...");
});

await time.tickAsync(600);

await waitFor(() => {
await waitForFakeTime(time, () => {
screen.getByText("Data loaded successfully!");
});

Expand All @@ -57,7 +60,7 @@ describe("LoaderDemo route", () => {

await time.tickAsync(600);

await waitFor(() => {
await waitForFakeTime(time, () => {
screen.getByRole("heading", { name: "Loader" });
});
});
Expand All @@ -75,7 +78,7 @@ describe("LoaderDemo route", () => {
}]);
render(<Stub />);

await waitFor(() => {
await waitForFakeTime(time, () => {
screen.getByText("Stubbed data!");
});

Expand Down
39 changes: 34 additions & 5 deletions src/_server.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -414,18 +414,34 @@ async function renderDocument(
c.status(statusCode);

for (const [key, value] of actionHeaders?.entries() ?? []) {
c.header(key, value);
if (key.toLowerCase() !== "set-cookie") {
c.header(key, value);
}
}
for (const cookie of actionHeaders?.getSetCookie() ?? []) {
c.header("Set-Cookie", cookie, { append: true });
}
for (const [key, value] of loaderHeaders?.entries() ?? []) {
c.header(key, value);
if (key.toLowerCase() !== "set-cookie") {
c.header(key, value);
}
}
for (const cookie of loaderHeaders?.getSetCookie() ?? []) {
c.header("Set-Cookie", cookie, { append: true });
}

if (presetError?.headers) {
for (const [key, value] of presetError.headers.entries()) {
if (key.toLowerCase() !== "content-type") {
if (
key.toLowerCase() !== "content-type" &&
key.toLowerCase() !== "set-cookie"
) {
c.header(key, value);
}
}
for (const cookie of presetError.headers.getSetCookie()) {
c.header("Set-Cookie", cookie, { append: true });
}
}

c.header("Content-Type", "text/html; charset=utf-8");
Expand Down Expand Up @@ -738,11 +754,18 @@ export function createHandlers<
if (
lowerKey !== "location" &&
lowerKey !== "content-type" &&
lowerKey !== "content-length"
lowerKey !== "content-length" &&
lowerKey !== "set-cookie"
) {
c.header(key, value);
}
}
// Handle Set-Cookie headers separately to avoid corruption
// from Headers.entries() which merges multiple Set-Cookie
// values with commas
for (const cookie of dataOrResponse.headers.getSetCookie()) {
c.header("Set-Cookie", cookie, { append: true });
}
c.header("X-Juniper", "redirect");
return c.json({ location });
}
Expand Down Expand Up @@ -791,10 +814,16 @@ export function createHandlers<
});
if (error.headers) {
for (const [key, value] of error.headers.entries()) {
if (key.toLowerCase() !== "content-type") {
if (
key.toLowerCase() !== "content-type" &&
key.toLowerCase() !== "set-cookie"
) {
headers.set(key, value);
}
}
for (const cookie of error.headers.getSetCookie()) {
headers.append("Set-Cookie", cookie);
}
}
return new Response(cborData as unknown as BodyInit, {
status: error.status,
Expand Down
1 change: 1 addition & 0 deletions src/client.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,7 @@ describe("createRoute", () => {
unstable_data: undefined,
unstable_allowRouteDeterminism: undefined,
unstable_pattern: "/blog",
unstable_url: new URL("http://localhost/blog"),
} as ActionFunctionArgs;
const result = await routeObject.action(actionArgs);
assertEquals(result, payload);
Expand Down
28 changes: 14 additions & 14 deletions src/deno.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,30 +25,30 @@
}
},
"imports": {
"@babel/core": "npm:@babel/core@^7.28.6",
"@modelcontextprotocol/sdk": "npm:@modelcontextprotocol/sdk@^1.25.3",
"@babel/core": "npm:@babel/core@^7.29.0",
"@modelcontextprotocol/sdk": "npm:@modelcontextprotocol/sdk@^1.27.1",
"@udibo/http-error": "jsr:@udibo/http-error@^0.11.1",
"@std/testing": "jsr:@std/testing@^1.0.17",
"@std/assert": "jsr:@std/assert@^1.0.17",
"@std/async": "jsr:@std/async@^1.1.0",
"@std/cli": "jsr:@std/cli@^1.0.26",
"@std/collections": "jsr:@std/collections@^1.1.4",
"@std/fs": "jsr:@std/fs@^1.0.22",
"@std/assert": "jsr:@std/assert@^1.0.19",
"@std/async": "jsr:@std/async@^1.2.0",
"@std/cli": "jsr:@std/cli@^1.0.28",
"@std/collections": "jsr:@std/collections@^1.1.6",
"@std/fs": "jsr:@std/fs@^1.0.23",
"@std/path": "jsr:@std/path@^1.1.4",
"@std/streams": "jsr:@std/streams@^1.0.17",
"@deno/esbuild-plugin": "jsr:@deno/esbuild-plugin@^1.2.1",
"@opentelemetry/api": "npm:@opentelemetry/api@^1.9.0",
"babel-plugin-react-compiler": "npm:babel-plugin-react-compiler@^1.0.0",
"cbor2": "npm:cbor2@^2.2.1",
"esbuild": "npm:esbuild@^0.27.2",
"isbot": "npm:isbot@^5.1.34",
"cbor2": "npm:cbor2@^2.3.0",
"esbuild": "npm:esbuild@^0.27.4",
"isbot": "npm:isbot@^5.1.36",
"quick-lru": "npm:quick-lru@^7.3.0",
"hono": "npm:hono@^4.11.6",
"hono": "npm:hono@^4.12.9",
"react": "npm:react@^19.2.4",
"@types/react": "npm:@types/react@^19.2.9",
"@types/react": "npm:@types/react@^19.2.14",
"react-dom": "npm:react-dom@^19.2.4",
"react-error-boundary": "npm:react-error-boundary@^6.1.0",
"react-router": "npm:react-router@^7.13.0",
"react-error-boundary": "npm:react-error-boundary@^6.1.1",
"react-router": "npm:react-router@^7.13.2",
"@testing-library/react": "npm:@testing-library/react@^16.3.2",
"global-jsdom": "npm:global-jsdom@^27"
},
Expand Down
44 changes: 44 additions & 0 deletions src/server.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -767,6 +767,50 @@ describe("redirect header preservation", () => {
const data = await res.json();
assertEquals(data, { location: "/dashboard" });
});

it("should preserve multiple Set-Cookie headers from redirect response", async () => {
const client = new Client({
path: "/",
main: { default: () => <div>Home</div> },
});

const server = createServer(import.meta.url, client, {
path: "/",
main: {
loader: () => {
const headers = new Headers();
headers.append("Location", "/dashboard");
headers.append(
"Set-Cookie",
"access_token=abc; Path=/; HttpOnly",
);
headers.append(
"Set-Cookie",
"refresh_token=xyz; Path=/auth/refresh; HttpOnly",
);
return Promise.resolve(
new Response(null, { status: 302, headers }),
);
},
},
});

const res = await server.request("http://localhost/", {
headers: { "X-Juniper-Route-Id": "/" },
});

assertEquals(res.status, 200);
assertEquals(res.headers.get("X-Juniper"), "redirect");

// Multiple Set-Cookie headers should be preserved individually
const cookies = res.headers.getSetCookie();
assertEquals(cookies.length, 2);
assertEquals(cookies[0], "access_token=abc; Path=/; HttpOnly");
assertEquals(
cookies[1],
"refresh_token=xyz; Path=/auth/refresh; HttpOnly",
);
});
});

describe("build artifact cache control", () => {
Expand Down
Loading
Loading