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
6 changes: 2 additions & 4 deletions integration/rsc/rsc-nojs-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -240,8 +240,7 @@ implementations.forEach((implementation) => {
await page.goto(`http://localhost:${port}/render-redirect`);
await expect(page.getByText("home")).toBeAttached();
await page.getByText("External").click();
await page.waitForURL(`https://example.com/`);
await expect(page.getByText("Example Domain")).toBeAttached();
await expect(page).toHaveURL(`https://example.com/`);
});

test("Handles unsupported protocol redirect Responses from render", async ({
Expand Down Expand Up @@ -273,8 +272,7 @@ implementations.forEach((implementation) => {
"Playwright doesn't like external redirects for tests. It times out waiting for the URL even though it navigates.",
);
await page.goto(`http://localhost:${port}/render-redirect/lazy/external`);
await page.waitForURL(`https://example.com/`);
await expect(page.getByText("Example Domain")).toBeAttached();
await expect(page).toHaveURL(`https://example.com/`);
});

test("Handles unsupported protocol redirect Responses from suspended render", async ({
Expand Down
6 changes: 2 additions & 4 deletions integration/rsc/rsc-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1873,8 +1873,7 @@ implementations.forEach((implementation) => {
await page.goto(`http://localhost:${port}/render-redirect`);
await expect(page.getByText("home")).toBeAttached();
await page.getByText("External").click();
await page.waitForURL(`https://example.com/`);
await expect(page.getByText("Example Domain")).toBeAttached();
await expect(page).toHaveURL(`https://example.com/`);
});

test("Handles unsupported protocol redirect Responses from render", async ({
Expand Down Expand Up @@ -1912,8 +1911,7 @@ implementations.forEach((implementation) => {
await page.goto(`http://localhost:${port}/render-redirect/lazy`);
await expect(page.getByText("home")).toBeAttached();
await page.getByText("External").click();
await page.waitForURL(`https://example.com/`);
await expect(page.getByText("Example Domain")).toBeAttached();
await expect(page).toHaveURL(`https://example.com/`);
});

test("Handles unsupported protocol redirect Responses from suspended render", async ({
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Improve turbo-stream decoding validation and object property handling
118 changes: 118 additions & 0 deletions packages/react-router/__tests__/vendor/turbo-stream-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,124 @@ async function readStreamToString(stream: ReadableStream<Uint8Array>) {
return text;
}

describe("decoding", () => {
function stream(input: unknown) {
return new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(
new TextEncoder().encode(`${JSON.stringify(input)}\n`),
);
controller.close();
},
});
}

const invalidIndices = [
"1",
"value",
null,
true,
{},
[],
["1"],
"length",
"constructor",
"__proto__",
["__proto__"],
-8,
0.5,
2,
100,
Number.MAX_SAFE_INTEGER + 1,
];

describe.each(["P", "Z"])("%s references", (type) => {
test.each([...invalidIndices, -1, -2, -7, 1].map((index) => [index]))(
"rejects invalid index %j",
async (index) => {
await expect(decode(stream([[type, index]]))).rejects.toThrow(
SyntaxError,
);
},
);
});

describe.each<{
name: string;
payload: (index: unknown) => unknown;
}>([
{ name: "array element", payload: (index) => [[1, index], "value"] },
{ name: "object value", payload: (index) => [{ _1: index }, "key"] },
{
name: "null-prototype object value",
payload: (index) => [["N", { _1: index }], "key"],
},
{ name: "set element", payload: (index) => [["S", index]] },
{ name: "map key", payload: (index) => [["M", index, 1], "value"] },
{ name: "map value", payload: (index) => [["M", 1, index], "key"] },
{ name: "plugin argument", payload: (index) => [["Custom", index]] },
])("$name references", ({ payload }) => {
test.each(invalidIndices.map((index) => [index]))(
"rejects invalid index %j",
async (index) => {
await expect(
decode(stream(payload(index)), {
plugins: [(_type, value) => ({ value })],
}),
).rejects.toThrow(SyntaxError);
},
);
});

test.each(["_NaN", "_0.5", "_100"])(
"rejects invalid object key reference %s",
async (key) => {
await expect(decode(stream([{ [key]: 1 }, "value"]))).rejects.toThrow(
SyntaxError,
);
},
);

test.each(["name", "constructor", "__proto__"])(
"decodes object keys using property-key coercion (%s)",
async (key) => {
const output = await quickDecode<Record<string, unknown>>(
stream([{ _1: 3 }, [2], key, { _4: 5 }, "nested", "value"]),
);
expect(Object.getPrototypeOf(output)).toBe(Object.prototype);
expect(Object.getOwnPropertyDescriptor(output, key)).toEqual({
value: { nested: "value" },
enumerable: true,
configurable: true,
writable: true,
});
expect(output.nested).toBeUndefined();
},
);
});

describe.each(["name", "toString", "constructor", "__proto__", ""])(
"object property %j",
(key) => {
test.each([null, undefined, 1, "data", { nested: "value" }])(
"round trips an own property with value %j",
async (value) => {
const input = { [key]: value, other: "value" };
const output = await quickDecode(encode(input));
expect(Object.getPrototypeOf(output)).toBe(Object.prototype);
expect(Object.getOwnPropertyDescriptor(output, key)).toEqual({
value,
enumerable: true,
configurable: true,
writable: true,
});
expect(output).toEqual(input);
expect(JSON.stringify(output)).toBe(JSON.stringify(input));
},
);
},
);

test("should encode and decode undefined", async () => {
const input = undefined;
const output = await quickDecode(encode(input));
Expand Down
17 changes: 16 additions & 1 deletion packages/react-router/vendor/turbo-stream-v2/unflatten.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,8 @@ function hydrate(this: ThisDecode, index: number): any {
continue;
}

validateIndex(index, values.length);

if (hydrated[index]) {
set(hydrated[index]);
continue;
Expand Down Expand Up @@ -174,6 +176,7 @@ function hydrate(this: ThisDecode, index: number): any {
set(obj);
continue;
case TYPE_PROMISE:
validateIndex(b, values.length);
if (hydrated[b]) {
set((hydrated[index] = hydrated[b]));
} else {
Expand All @@ -196,6 +199,7 @@ function hydrate(this: ThisDecode, index: number): any {
set(error);
continue;
case TYPE_PREVIOUS_RESOLVED:
validateIndex(b, values.length);
set((hydrated[index] = hydrated[b]));
continue;
default:
Expand Down Expand Up @@ -264,7 +268,12 @@ function hydrate(this: ThisDecode, index: number): any {
},
]);
postRun.push(() => {
object[r[0]] = r[1];
Object.defineProperty(object, r[0], {
value: r[1],
enumerable: true,
configurable: true,
writable: true,
});
});
}
set(object);
Expand All @@ -278,3 +287,9 @@ function hydrate(this: ThisDecode, index: number): any {

return result;
}

function validateIndex(index: number, length: number) {
if (!Number.isSafeInteger(index) || index < 0 || index >= length) {
throw new SyntaxError();
}
}
Loading