From 561ee42ff4f0d099585cdb938f470144da6f8b16 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Thu, 1 May 2025 11:00:28 -0400 Subject: [PATCH 1/4] Inline turbo-stream v2.4.1 --- packages/react-router/__tests__/setup.ts | 5 + .../__tests__/vendor/turbo-stream-test.ts | 577 ++++++++++++++++++ .../react-router/lib/dom/ssr/single-fetch.tsx | 3 +- .../lib/server-runtime/single-fetch.ts | 3 +- packages/react-router/package.json | 3 +- .../vendor/turbo-stream-v2/flatten.ts | 223 +++++++ .../vendor/turbo-stream-v2/turbo-stream.ts | 280 +++++++++ .../vendor/turbo-stream-v2/unflatten.ts | 275 +++++++++ .../vendor/turbo-stream-v2/utils.ts | 84 +++ pnpm-lock.yaml | 20 +- 10 files changed, 1454 insertions(+), 19 deletions(-) create mode 100644 packages/react-router/__tests__/vendor/turbo-stream-test.ts create mode 100644 packages/react-router/vendor/turbo-stream-v2/flatten.ts create mode 100644 packages/react-router/vendor/turbo-stream-v2/turbo-stream.ts create mode 100644 packages/react-router/vendor/turbo-stream-v2/unflatten.ts create mode 100644 packages/react-router/vendor/turbo-stream-v2/utils.ts diff --git a/packages/react-router/__tests__/setup.ts b/packages/react-router/__tests__/setup.ts index 8c208adb43..1b5235ec15 100644 --- a/packages/react-router/__tests__/setup.ts +++ b/packages/react-router/__tests__/setup.ts @@ -29,6 +29,11 @@ if (!globalThis.TextEncoderStream) { globalThis.TextEncoderStream = TextEncoderStream; } +if (!globalThis.TextDecoderStream) { + const { TextDecoderStream } = require("node:stream/web"); + globalThis.TextDecoderStream = TextDecoderStream; +} + if (!globalThis.TransformStream) { const { TransformStream } = require("node:stream/web"); globalThis.TransformStream = TransformStream; diff --git a/packages/react-router/__tests__/vendor/turbo-stream-test.ts b/packages/react-router/__tests__/vendor/turbo-stream-test.ts new file mode 100644 index 0000000000..e3b3b34b9d --- /dev/null +++ b/packages/react-router/__tests__/vendor/turbo-stream-test.ts @@ -0,0 +1,577 @@ +import { decode, encode } from "../../vendor/turbo-stream-v2/turbo-stream"; +import { + Deferred, + type EncodePlugin, +} from "../../vendor/turbo-stream-v2/utils"; + +async function quickDecode(stream: ReadableStream) { + const decoded = await decode(stream); + await decoded.done; + return decoded.value; +} + +test("should encode and decode undefined", async () => { + const input = undefined; + const output = await quickDecode(encode(input)); + expect(output).toEqual(input); +}); + +test("should encode and decode null", async () => { + const input = null; + const output = await quickDecode(encode(input)); + expect(output).toEqual(input); +}); + +test("should encode and decode boolean", async () => { + const input = true; + const output = await quickDecode(encode(input)); + expect(output).toEqual(input); + + const input2 = false; + const output2 = await quickDecode(encode(input2)); + expect(output2).toEqual(input2); +}); + +test("should encode and decode number", async () => { + const input = 42; + const output = await quickDecode(encode(input)); + expect(output).toEqual(input); +}); + +test("should encode and decode string", async () => { + const input = "Hello World"; + const output = await quickDecode(encode(input)); + expect(output).toEqual(input); +}); + +test("should encode and decode Date", async () => { + const input = new Date(); + const output = await quickDecode(encode(input)); + expect(output).toEqual(input); +}); + +test("should encode and decode NaN", async () => { + const input = NaN; + const output = await quickDecode(encode(input)); + expect(output).toEqual(input); +}); + +test("should encode and decode Number.NaN", async () => { + const input = Number.NaN; + const output = await quickDecode(encode(input)); + expect(output).toEqual(input); +}); + +test("should encode and decode Infinity", async () => { + const input = Infinity; + const output = await quickDecode(encode(input)); + expect(output).toEqual(input); +}); + +test("should encode and decode -Infinity", async () => { + const input = -Infinity; + const output = await quickDecode(encode(input)); + expect(output).toEqual(input); +}); + +test("should encode and decode -0", async () => { + const input = -0; + const output = await quickDecode(encode(input)); + expect(output).toEqual(input); +}); + +test("should encode and decode BigInt", async () => { + const input = BigInt(42); + const output = await quickDecode(encode(input)); + expect(output).toEqual(input); +}); + +test("should encode and decode RegExp", async () => { + const input = /foo/g; + const output = await quickDecode(encode(input)); + expect(output).toEqual(input); +}); + +test("should encode and decode Symbol", async () => { + const input = Symbol.for("foo"); + const output = await quickDecode(encode(input)); + expect(output).toEqual(input); +}); + +test("should encode and decode URL", async () => { + const input = new URL("https://example.com"); + const output = await quickDecode(encode(input)); + expect(output).toEqual(input); +}); + +test("should encode and decode object with null prototype", async () => { + const input = Object.create(null); + input.foo = "bar"; + const output = await quickDecode(encode(input)); + expect(output).toEqual(input); +}); + +test("should encode and decode Map", async () => { + const input = new Map([ + ["foo", "bar"], + ["baz", "qux"], + ]); + const output = await quickDecode(encode(input)); + expect(output).toEqual(input); +}); + +test("should encode and decode empty Map", async () => { + const input = new Map(); + const output = await quickDecode(encode(input)); + expect(output).toEqual(input); +}); + +test("should encode and decode Set", async () => { + const input = new Set(["foo", "bar"]); + const output = await quickDecode(encode(input)); + expect(output).toEqual(input); +}); + +test("should encode and decode empty Set", async () => { + const input = new Set(); + const output = await quickDecode(encode(input)); + expect(output).toEqual(input); +}); + +test("should encode and decode an Error", async () => { + const input = new Error("foo"); + const output = await quickDecode(encode(input)); + expect(output).toEqual(input); +}); + +test("should encode and decode an EvalError", async () => { + const input = new EvalError("foo"); + const output = await quickDecode(encode(input)); + expect(output).toEqual(input); +}); + +test("should encode and decode array", async () => { + const input = [1, 2, 3]; + const output = await quickDecode(encode(input)); + expect(output).toEqual(input); +}); + +test("should encode and decode array with holes", async () => { + // eslint-disable-next-line + const input = [1, , 3]; + const output = await quickDecode(encode(input)); + expect(output).toEqual(input); +}); + +test("should encode and decode object", async () => { + const input = { foo: "bar" }; + const output = await quickDecode(encode(input)); + expect(output).toEqual(input); +}); + +test("should encode and decode large payload", async () => { + const input: unknown[] = []; + for (let i = 0; i < 100000; i++) { + input.push({ + [Math.random().toString(36).slice(2)]: Math.random() + .toString(36) + .slice(2), + }); + } + const output = await quickDecode(encode(input)); + expect(output).toEqual(input); +}); + +test("should encode and decode object maintaining property order for re-used keys", async () => { + const input = [ + { a: "a value 1", b: "b value" }, + { c: "c value", a: "a value 2" }, + ]; + const output = await quickDecode(encode(input)); + expect(JSON.stringify(output)).toEqual(JSON.stringify(input)); +}); + +test("should encode and decode null prototype object maintaining property order for re-used keys", async () => { + const input = Object.create(null); + const test1 = Object.create(null); + test1.a = "a value 1"; + test1.b = "b value"; + input.test1 = test1; + const test2 = Object.create(null); + test2.c = "c value"; + test2.a = "a value 2"; + input.test2 = test2; + + const output = await quickDecode(encode(input)); + expect(JSON.stringify(output)).toEqual(JSON.stringify(input)); +}); + +test("should encode and decode object and dedupe object key, value, and promise value", async () => { + const input = { foo: "bar", bar: "bar", baz: Promise.resolve("bar") }; + const output = await quickDecode(encode(input)); + const { baz: bazResult, ...partialResult } = output as typeof input; + const { baz: bazInput, ...partialInput } = input; + + expect(partialResult).toEqual(partialInput); + expect(await bazResult).toEqual(await bazInput); + + let encoded = ""; + const stream = encode(input); + await stream.pipeThrough(new TextDecoderStream()).pipeTo( + new WritableStream({ + write(chunk) { + encoded += chunk; + }, + }) + ); + + expect(Array.from(encoded.matchAll(/"foo"/g))).toHaveLength(1); + expect(Array.from(encoded.matchAll(/"bar"/g))).toHaveLength(1); + expect(Array.from(encoded.matchAll(/"baz"/g))).toHaveLength(1); +}); + +test("should encode and decode object with undefined", async () => { + const input = { foo: undefined }; + const output = (await quickDecode(encode(input))) as typeof input; + expect(output).toEqual(input); + expect("foo" in output).toBe(true); +}); + +test("should encode and decode promise", async () => { + const input = Promise.resolve("foo"); + const decoded = await decode(encode(input)); + expect(decoded.value).toBeInstanceOf(Promise); + expect(await decoded.value).toEqual(await input); + await decoded.done; +}); + +test("should encode and decode subsequent null from promise in object value", async () => { + const input = { root: null, promise: Promise.resolve(null) }; + const decoded = await decode(encode(input)); + const value = decoded.value as typeof input; + expect(await value.promise).toEqual(await input.promise); + await decoded.done; +}); + +test("should encode and decode subsequent undefined from promise in object value", async () => { + const input = { root: undefined, promise: Promise.resolve(undefined) }; + const decoded = await decode(encode(input)); + const value = decoded.value as typeof input; + expect(await value.promise).toEqual(await input.promise); + await decoded.done; +}); + +test("should encode and decode rejected promise", async () => { + const input = Promise.reject(new Error("foo")); + const decoded = await decode(encode(input)); + expect(decoded.value).toBeInstanceOf(Promise); + await expect(decoded.value).rejects.toEqual( + await input.catch((reason) => reason) + ); + await decoded.done; +}); + +test("should encode and decode object with promises as values", async () => { + const input = { foo: Promise.resolve("bar") }; + const decoded = await decode(encode(input)); + const value = decoded.value as typeof input; + expect(value).toEqual({ foo: expect.any(Promise) }); + expect(await value.foo).toEqual(await input.foo); + await decoded.done; +}); + +test("should encode and decode object with rejected promise", async () => { + const input = { foo: Promise.reject(new Error("bar")) }; + const decoded = await decode(encode(input)); + const value = decoded.value as typeof input; + expect(value.foo).toBeInstanceOf(Promise); + await expect(value.foo).rejects.toEqual( + await input.foo.catch((reason) => reason) + ); + return decoded.done; +}); + +test("should encode and decode set with promises as values", async () => { + const prom = Promise.resolve("foo"); + const input = new Set([prom, prom]); + const decoded = await decode(encode(input)); + const value = decoded.value as typeof input; + expect(value).toEqual(new Set([expect.any(Promise)])); + const proms = Array.from(value); + expect(await proms[0]).toEqual(await Array.from(input)[0]); + await decoded.done; +}); + +test("should encode and decode custom type", async () => { + class Custom { + child: Custom | undefined; + constructor(public foo: string) {} + } + const input = new Custom("bar"); + input.child = new Custom("baz"); + + const decoder = jest.fn((type, foo, child) => { + if (type === "Custom") { + const value = new Custom(foo as string); + value.child = child as Custom | undefined; + return { value }; + } + }); + + const encoder = jest.fn((value) => { + if (value instanceof Custom) { + return ["Custom", value.foo, value.child]; + } + }); + + const decoded = await decode( + encode(input, { + plugins: [encoder as EncodePlugin], + }), + { + plugins: [decoder], + } + ); + const value = decoded.value as Custom; + expect(value).toBeInstanceOf(Custom); + expect(value.foo).toEqual(input.foo); + expect(value.child).toBeInstanceOf(Custom); + expect(value.child?.foo).toEqual(input.child.foo); + + expect(encoder.mock.calls.length).toBe(2); + expect(decoder.mock.calls.length).toBe(2); +}); + +test("should encode and decode custom type when nested alongside Promise", async () => { + class Custom { + constructor(public foo: string) {} + } + const input = { + number: 1, + array: [2, "foo", 3], + set: new Set(["bar", "baz"]), + custom: new Custom("qux"), + promise: Promise.resolve("resolved"), + }; + const decoded = (await decode( + encode(input, { + plugins: [ + (value) => { + if (value instanceof Custom) { + return ["Custom", value.foo]; + } + }, + ], + }), + { + plugins: [ + (type, foo) => { + if (type === "Custom") { + return { value: new Custom(foo as string) }; + } + }, + ], + } + )) as unknown as { + value: { + number: number; + array: []; + set: Set; + custom: Custom; + promise: Promise; + }; + }; + expect(decoded.value.number).toBe(input.number); + expect(decoded.value.array).toEqual(input.array); + expect(decoded.value.set).toEqual(input.set); + expect(decoded.value.custom).toBeInstanceOf(Custom); + expect(decoded.value.custom.foo).toBe("qux"); + expect(await decoded.value.promise).toBe("resolved"); +}); + +test("should allow plugins to encode and decode functions", async () => { + const input = () => "foo"; + const decoded = await decode( + encode(input, { + plugins: [ + (value) => { + if (typeof value === "function") { + return ["Function"]; + } + }, + ], + }), + { + plugins: [ + (type) => { + if (type === "Function") { + return { value: () => "foo" }; + } + }, + ], + } + ); + expect(decoded.value).toBeInstanceOf(Function); + expect((decoded.value as typeof input)()).toBe("foo"); + await decoded.done; +}); + +test("should allow postPlugins to handle values that would otherwise throw", async () => { + class Class {} + const input = { + func: () => null, + class: new Class(), + }; + const decoded = await decode( + encode(input, { + postPlugins: [ + (value) => { + return ["u"]; + }, + ], + }), + { + plugins: [ + (type) => { + if (type === "u") { + return { value: undefined }; + } + }, + ], + } + ); + expect(decoded.value).toEqual({ func: undefined, class: undefined }); + await decoded.done; +}); + +test("should propagate abort reason to deferred promises for sync resolved promise", async () => { + const abortController = new AbortController(); + const reason = new Error("reason"); + abortController.abort(reason); + const decoded = await decode( + encode(Promise.resolve("foo"), { signal: abortController.signal }) + ); + await expect(decoded.value).rejects.toEqual(reason); +}); + +test("should propagate abort reason to deferred promises for async resolved promise", async () => { + const abortController = new AbortController(); + const deferred = new Deferred(); + const reason = new Error("reason"); + const decoded = await decode( + encode(deferred.promise, { signal: abortController.signal }) + ); + abortController.abort(reason); + await expect(decoded.value).rejects.toEqual(reason); +}); + +test("should encode and decode objects with multiple promises resolving to the same values", async () => { + const input = { + foo: Promise.resolve("baz"), + bar: Promise.resolve("baz"), + }; + + const decoded = await decode(encode(input)); + const value = decoded.value as typeof input; + expect(value).toEqual({ + foo: expect.any(Promise), + bar: expect.any(Promise), + }); + expect(await value.foo).toEqual(await input.foo); + expect(await value.bar).toEqual(await input.bar); + await decoded.done; + + // Ensure we aren't duplicating values in the stream + let encoded = ""; + const stream = encode(input); + await stream.pipeThrough(new TextDecoderStream()).pipeTo( + new WritableStream({ + write(chunk) { + encoded += chunk; + }, + }) + ); + expect(Array.from(encoded.matchAll(/"baz"/g))).toHaveLength(1); +}); + +test("should encode and decode objects with reused values", async () => { + const input = { + foo: Promise.resolve({ use: "baz" }), + bar: Promise.resolve("baz"), + data: Promise.resolve({ quux: "quux" }), + }; + + const decoded = await decode(encode(input)); + const value = decoded.value as typeof input; + expect(value).toEqual({ + foo: expect.any(Promise), + bar: expect.any(Promise), + data: expect.any(Promise), + }); + expect(await value.foo).toEqual(await input.foo); + expect(await value.bar).toEqual(await input.bar); + expect(await value.data).toEqual(await input.data); + + // Ensure we aren't duplicating values in the stream + let encoded = ""; + const stream = encode(input); + await stream.pipeThrough(new TextDecoderStream()).pipeTo( + new WritableStream({ + write(chunk) { + encoded += chunk; + }, + }) + ); + expect(Array.from(encoded.matchAll(/"baz"/g))).toHaveLength(1); + await decoded.done; +}); + +test("should encode and decode objects with multiple promises rejecting to the same values", async () => { + const err = new Error("baz"); + const input = { + foo: Promise.reject(err), + bar: Promise.reject(err), + }; + + const decoded = await decode(encode(input)); + const value = decoded.value as typeof input; + expect(value).toEqual({ + foo: expect.any(Promise), + bar: expect.any(Promise), + }); + await expect(value.foo).rejects.toEqual(err); + await expect(value.bar).rejects.toEqual(err); + await decoded.done; + + // Ensure we aren't duplicating values in the stream + let encoded = ""; + const stream = encode(input); + await stream.pipeThrough(new TextDecoderStream()).pipeTo( + new WritableStream({ + write(chunk) { + encoded += chunk; + }, + }) + ); + expect(Array.from(encoded.matchAll(/"baz"/g))).toHaveLength(1); +}); + +test("should allow many nested promises without a memory leak", async () => { + const depth = 2000; + type Nested = { i: number; next: Promise | null }; + const input: Nested = { i: 0, next: null }; + let current: Nested = input; + for (let i = 1; i < depth; i++) { + const next = { i, next: null }; + current.next = Promise.resolve(next); + current = next; + } + + const decoded = await decode(encode(input)); + let currentDecoded: Nested = decoded.value as Nested; + while (currentDecoded.next) { + currentDecoded = await currentDecoded.next; + } + expect(currentDecoded.i).toBe(depth - 1); + await decoded.done; +}); diff --git a/packages/react-router/lib/dom/ssr/single-fetch.tsx b/packages/react-router/lib/dom/ssr/single-fetch.tsx index 9b5b4c092c..5b50fee25b 100644 --- a/packages/react-router/lib/dom/ssr/single-fetch.tsx +++ b/packages/react-router/lib/dom/ssr/single-fetch.tsx @@ -1,5 +1,6 @@ import * as React from "react"; -import { decode } from "turbo-stream"; + +import { decode } from "../../../vendor/turbo-stream-v2/turbo-stream"; import type { Router as DataRouter } from "../../router/router"; import { isResponse } from "../../router/router"; import type { diff --git a/packages/react-router/lib/server-runtime/single-fetch.ts b/packages/react-router/lib/server-runtime/single-fetch.ts index a7c21eb1b8..a1613092ee 100644 --- a/packages/react-router/lib/server-runtime/single-fetch.ts +++ b/packages/react-router/lib/server-runtime/single-fetch.ts @@ -1,5 +1,4 @@ -import { encode } from "turbo-stream"; - +import { encode } from "../../vendor/turbo-stream-v2/turbo-stream"; import type { StaticHandler, StaticHandlerContext } from "../router/router"; import { isRedirectResponse, diff --git a/packages/react-router/package.json b/packages/react-router/package.json index 81f92c3c75..9f33548f18 100644 --- a/packages/react-router/package.json +++ b/packages/react-router/package.json @@ -85,8 +85,7 @@ }, "dependencies": { "cookie": "^1.0.1", - "set-cookie-parser": "^2.6.0", - "turbo-stream": "2.4.0" + "set-cookie-parser": "^2.6.0" }, "devDependencies": { "@types/set-cookie-parser": "^2.4.1", diff --git a/packages/react-router/vendor/turbo-stream-v2/flatten.ts b/packages/react-router/vendor/turbo-stream-v2/flatten.ts new file mode 100644 index 0000000000..77082330e5 --- /dev/null +++ b/packages/react-router/vendor/turbo-stream-v2/flatten.ts @@ -0,0 +1,223 @@ +import { + HOLE, + NAN, + NEGATIVE_INFINITY, + NEGATIVE_ZERO, + NULL, + POSITIVE_INFINITY, + UNDEFINED, + TYPE_BIGINT, + TYPE_DATE, + TYPE_ERROR, + TYPE_MAP, + TYPE_NULL_OBJECT, + TYPE_PREVIOUS_RESOLVED, + TYPE_PROMISE, + TYPE_REGEXP, + TYPE_SET, + TYPE_SYMBOL, + TYPE_URL, + type ThisEncode, +} from "./utils"; + +export function flatten(this: ThisEncode, input: unknown): number | [number] { + const { indices } = this; + const existing = indices.get(input); + if (existing) return [existing]; + + if (input === undefined) return UNDEFINED; + if (input === null) return NULL; + if (Number.isNaN(input)) return NAN; + if (input === Number.POSITIVE_INFINITY) return POSITIVE_INFINITY; + if (input === Number.NEGATIVE_INFINITY) return NEGATIVE_INFINITY; + if (input === 0 && 1 / input < 0) return NEGATIVE_ZERO; + + const index = this.index++; + indices.set(input, index); + stringify.call(this, input, index); + return index; +} + +function stringify(this: ThisEncode, input: unknown, index: number) { + const { deferred, plugins, postPlugins } = this; + const str = this.stringified; + + const stack: [unknown, number][] = [[input, index]]; + while (stack.length > 0) { + const [input, index] = stack.pop()!; + + const partsForObj = (obj: any) => + Object.keys(obj) + .map((k) => `"_${flatten.call(this, k)}":${flatten.call(this, obj[k])}`) + .join(","); + let error: Error | null = null; + + switch (typeof input) { + case "boolean": + case "number": + case "string": + str[index] = JSON.stringify(input); + break; + case "bigint": + str[index] = `["${TYPE_BIGINT}","${input}"]`; + break; + case "symbol": { + const keyFor = Symbol.keyFor(input); + if (!keyFor) { + error = new Error( + "Cannot encode symbol unless created with Symbol.for()" + ); + } else { + str[index] = `["${TYPE_SYMBOL}",${JSON.stringify(keyFor)}]`; + } + break; + } + case "object": { + if (!input) { + str[index] = `${NULL}`; + break; + } + + const isArray = Array.isArray(input); + let pluginHandled = false; + if (!isArray && plugins) { + for (const plugin of plugins) { + const pluginResult = plugin(input); + if (Array.isArray(pluginResult)) { + pluginHandled = true; + const [pluginIdentifier, ...rest] = pluginResult; + str[index] = `[${JSON.stringify(pluginIdentifier)}`; + if (rest.length > 0) { + str[index] += `,${rest + .map((v) => flatten.call(this, v)) + .join(",")}`; + } + str[index] += "]"; + break; + } + } + } + + if (!pluginHandled) { + let result = isArray ? "[" : "{"; + if (isArray) { + for (let i = 0; i < input.length; i++) + result += + (i ? "," : "") + + (i in input ? flatten.call(this, input[i]) : HOLE); + str[index] = `${result}]`; + } else if (input instanceof Date) { + str[index] = `["${TYPE_DATE}",${input.getTime()}]`; + } else if (input instanceof URL) { + str[index] = `["${TYPE_URL}",${JSON.stringify(input.href)}]`; + } else if (input instanceof RegExp) { + str[index] = `["${TYPE_REGEXP}",${JSON.stringify( + input.source + )},${JSON.stringify(input.flags)}]`; + } else if (input instanceof Set) { + if (input.size > 0) { + str[index] = `["${TYPE_SET}",${[...input] + .map((val) => flatten.call(this, val)) + .join(",")}]`; + } else { + str[index] = `["${TYPE_SET}"]`; + } + } else if (input instanceof Map) { + if (input.size > 0) { + str[index] = `["${TYPE_MAP}",${[...input] + .flatMap(([k, v]) => [ + flatten.call(this, k), + flatten.call(this, v), + ]) + .join(",")}]`; + } else { + str[index] = `["${TYPE_MAP}"]`; + } + } else if (input instanceof Promise) { + str[index] = `["${TYPE_PROMISE}",${index}]`; + deferred[index] = input; + } else if (input instanceof Error) { + str[index] = `["${TYPE_ERROR}",${JSON.stringify(input.message)}`; + if (input.name !== "Error") { + str[index] += `,${JSON.stringify(input.name)}`; + } + str[index] += "]"; + } else if (Object.getPrototypeOf(input) === null) { + str[index] = `["${TYPE_NULL_OBJECT}",{${partsForObj(input)}}]`; + } else if (isPlainObject(input)) { + str[index] = `{${partsForObj(input)}}`; + } else { + error = new Error("Cannot encode object with prototype"); + } + } + break; + } + default: { + const isArray = Array.isArray(input); + let pluginHandled = false; + if (!isArray && plugins) { + for (const plugin of plugins) { + const pluginResult = plugin(input); + if (Array.isArray(pluginResult)) { + pluginHandled = true; + const [pluginIdentifier, ...rest] = pluginResult; + str[index] = `[${JSON.stringify(pluginIdentifier)}`; + if (rest.length > 0) { + str[index] += `,${rest + .map((v) => flatten.call(this, v)) + .join(",")}`; + } + str[index] += "]"; + break; + } + } + } + + if (!pluginHandled) { + error = new Error("Cannot encode function or unexpected type"); + } + } + } + + if (error) { + let pluginHandled = false; + + if (postPlugins) { + for (const plugin of postPlugins) { + const pluginResult = plugin(input); + if (Array.isArray(pluginResult)) { + pluginHandled = true; + const [pluginIdentifier, ...rest] = pluginResult; + str[index] = `[${JSON.stringify(pluginIdentifier)}`; + if (rest.length > 0) { + str[index] += `,${rest + .map((v) => flatten.call(this, v)) + .join(",")}`; + } + str[index] += "]"; + break; + } + } + } + + if (!pluginHandled) { + throw error; + } + } + } +} + +const objectProtoNames = Object.getOwnPropertyNames(Object.prototype) + .sort() + .join("\0"); + +function isPlainObject( + thing: unknown +): thing is Record { + const proto = Object.getPrototypeOf(thing); + return ( + proto === Object.prototype || + proto === null || + Object.getOwnPropertyNames(proto).sort().join("\0") === objectProtoNames + ); +} diff --git a/packages/react-router/vendor/turbo-stream-v2/turbo-stream.ts b/packages/react-router/vendor/turbo-stream-v2/turbo-stream.ts new file mode 100644 index 0000000000..e8e18bc601 --- /dev/null +++ b/packages/react-router/vendor/turbo-stream-v2/turbo-stream.ts @@ -0,0 +1,280 @@ +import { flatten } from "./flatten"; +import { unflatten } from "./unflatten"; +import { + Deferred, + TYPE_ERROR, + TYPE_PREVIOUS_RESOLVED, + TYPE_PROMISE, + createLineSplittingTransform, + type DecodePlugin, + type EncodePlugin, + type ThisDecode, + type ThisEncode, +} from "./utils"; + +export type { DecodePlugin, EncodePlugin }; + +export async function decode( + readable: ReadableStream, + options?: { plugins?: DecodePlugin[] } +) { + const { plugins } = options ?? {}; + + const done = new Deferred(); + const reader = readable + .pipeThrough(createLineSplittingTransform()) + .getReader(); + + const decoder: ThisDecode = { + values: [], + hydrated: [], + deferred: {}, + plugins, + }; + + const decoded = await decodeInitial.call(decoder, reader); + + let donePromise = done.promise; + if (decoded.done) { + done.resolve(); + } else { + donePromise = decodeDeferred + .call(decoder, reader) + .then(done.resolve) + .catch((reason) => { + for (const deferred of Object.values(decoder.deferred)) { + deferred.reject(reason); + } + + done.reject(reason); + }); + } + + return { + done: donePromise.then(() => reader.closed), + value: decoded.value, + }; +} + +async function decodeInitial( + this: ThisDecode, + reader: ReadableStreamDefaultReader +) { + const read = await reader.read(); + if (!read.value) { + throw new SyntaxError(); + } + + let line: unknown; + try { + line = JSON.parse(read.value); + } catch (reason) { + throw new SyntaxError(); + } + + return { + done: read.done, + value: unflatten.call(this, line), + }; +} + +async function decodeDeferred( + this: ThisDecode, + reader: ReadableStreamDefaultReader +) { + let read = await reader.read(); + while (!read.done) { + if (!read.value) continue; + const line = read.value; + switch (line[0]) { + case TYPE_PROMISE: { + const colonIndex = line.indexOf(":"); + const deferredId = Number(line.slice(1, colonIndex)); + const deferred = this.deferred[deferredId]; + if (!deferred) { + throw new Error(`Deferred ID ${deferredId} not found in stream`); + } + const lineData = line.slice(colonIndex + 1); + let jsonLine: unknown; + try { + jsonLine = JSON.parse(lineData); + } catch (reason) { + throw new SyntaxError(); + } + + const value = unflatten.call(this, jsonLine); + deferred.resolve(value); + + break; + } + case TYPE_ERROR: { + const colonIndex = line.indexOf(":"); + const deferredId = Number(line.slice(1, colonIndex)); + const deferred = this.deferred[deferredId]; + if (!deferred) { + throw new Error(`Deferred ID ${deferredId} not found in stream`); + } + const lineData = line.slice(colonIndex + 1); + let jsonLine: unknown; + try { + jsonLine = JSON.parse(lineData); + } catch (reason) { + throw new SyntaxError(); + } + const value = unflatten.call(this, jsonLine); + deferred.reject(value); + break; + } + default: + throw new SyntaxError(); + } + read = await reader.read(); + } +} + +export function encode( + input: unknown, + options?: { + plugins?: EncodePlugin[]; + postPlugins?: EncodePlugin[]; + signal?: AbortSignal; + } +) { + const { plugins, postPlugins, signal } = options ?? {}; + + const encoder: ThisEncode = { + deferred: {}, + index: 0, + indices: new Map(), + stringified: [], + plugins, + postPlugins, + signal, + }; + const textEncoder = new TextEncoder(); + let lastSentIndex = 0; + const readable = new ReadableStream({ + async start(controller) { + const id = flatten.call(encoder, input); + if (Array.isArray(id)) { + throw new Error("This should never happen"); + } + if (id < 0) { + controller.enqueue(textEncoder.encode(`${id}\n`)); + } else { + controller.enqueue( + textEncoder.encode(`[${encoder.stringified.join(",")}]\n`) + ); + lastSentIndex = encoder.stringified.length - 1; + } + + const seenPromises = new WeakSet>(); + if (Object.keys(encoder.deferred).length) { + let raceDone!: () => void; + const racePromise = new Promise((resolve, reject) => { + raceDone = resolve as () => void; + if (signal) { + const rejectPromise = () => + reject(signal.reason || new Error("Signal was aborted.")); + if (signal.aborted) { + rejectPromise(); + } else { + signal.addEventListener("abort", (event) => { + rejectPromise(); + }); + } + } + }); + while (Object.keys(encoder.deferred).length > 0) { + for (const [deferredId, deferred] of Object.entries( + encoder.deferred + )) { + if (seenPromises.has(deferred)) continue; + seenPromises.add( + // biome-ignore lint/suspicious/noAssignInExpressions: + (encoder.deferred[Number(deferredId)] = Promise.race([ + racePromise, + deferred, + ]) + .then( + (resolved) => { + const id = flatten.call(encoder, resolved); + if (Array.isArray(id)) { + controller.enqueue( + textEncoder.encode( + `${TYPE_PROMISE}${deferredId}:[["${TYPE_PREVIOUS_RESOLVED}",${id[0]}]]\n` + ) + ); + encoder.index++; + lastSentIndex++; + } else if (id < 0) { + controller.enqueue( + textEncoder.encode( + `${TYPE_PROMISE}${deferredId}:${id}\n` + ) + ); + } else { + const values = encoder.stringified + .slice(lastSentIndex + 1) + .join(","); + controller.enqueue( + textEncoder.encode( + `${TYPE_PROMISE}${deferredId}:[${values}]\n` + ) + ); + lastSentIndex = encoder.stringified.length - 1; + } + }, + (reason) => { + if ( + !reason || + typeof reason !== "object" || + !(reason instanceof Error) + ) { + reason = new Error("An unknown error occurred"); + } + + const id = flatten.call(encoder, reason); + if (Array.isArray(id)) { + controller.enqueue( + textEncoder.encode( + `${TYPE_ERROR}${deferredId}:[["${TYPE_PREVIOUS_RESOLVED}",${id[0]}]]\n` + ) + ); + encoder.index++; + lastSentIndex++; + } else if (id < 0) { + controller.enqueue( + textEncoder.encode(`${TYPE_ERROR}${deferredId}:${id}\n`) + ); + } else { + const values = encoder.stringified + .slice(lastSentIndex + 1) + .join(","); + controller.enqueue( + textEncoder.encode( + `${TYPE_ERROR}${deferredId}:[${values}]\n` + ) + ); + lastSentIndex = encoder.stringified.length - 1; + } + } + ) + .finally(() => { + delete encoder.deferred[Number(deferredId)]; + })) + ); + } + await Promise.race(Object.values(encoder.deferred)); + } + + raceDone(); + } + await Promise.all(Object.values(encoder.deferred)); + + controller.close(); + }, + }); + + return readable; +} diff --git a/packages/react-router/vendor/turbo-stream-v2/unflatten.ts b/packages/react-router/vendor/turbo-stream-v2/unflatten.ts new file mode 100644 index 0000000000..b75248ad5e --- /dev/null +++ b/packages/react-router/vendor/turbo-stream-v2/unflatten.ts @@ -0,0 +1,275 @@ +import { + Deferred, + HOLE, + NAN, + NEGATIVE_INFINITY, + NEGATIVE_ZERO, + NULL, + POSITIVE_INFINITY, + UNDEFINED, + TYPE_BIGINT, + TYPE_DATE, + TYPE_ERROR, + TYPE_MAP, + TYPE_NULL_OBJECT, + TYPE_PREVIOUS_RESOLVED, + TYPE_PROMISE, + TYPE_REGEXP, + TYPE_SET, + TYPE_SYMBOL, + TYPE_URL, + type ThisDecode, +} from "./utils"; + +const globalObj = ( + typeof window !== "undefined" + ? window + : typeof globalThis !== "undefined" + ? globalThis + : undefined +) as Record | undefined; + +export function unflatten(this: ThisDecode, parsed: unknown): unknown { + const { hydrated, values } = this; + if (typeof parsed === "number") return hydrate.call(this, parsed); + + if (!Array.isArray(parsed) || !parsed.length) throw new SyntaxError(); + + const startIndex = values.length; + for (const value of parsed) { + values.push(value); + } + hydrated.length = values.length; + + return hydrate.call(this, startIndex); +} + +function hydrate(this: ThisDecode, index: number): any { + const { hydrated, values, deferred, plugins } = this; + + let result: unknown; + const stack = [ + [ + index, + (v: unknown) => { + result = v; + }, + ] as const, + ]; + + let postRun: Array<() => void> = []; + + while (stack.length > 0) { + const [index, set] = stack.pop()!; + + switch (index) { + case UNDEFINED: + set(undefined); + continue; + case NULL: + set(null); + continue; + case NAN: + set(NaN); + continue; + case POSITIVE_INFINITY: + set(Infinity); + continue; + case NEGATIVE_INFINITY: + set(-Infinity); + continue; + case NEGATIVE_ZERO: + set(-0); + continue; + } + + if (hydrated[index]) { + set(hydrated[index]); + continue; + } + + const value = values[index]; + if (!value || typeof value !== "object") { + hydrated[index] = value; + set(value); + continue; + } + + if (Array.isArray(value)) { + if (typeof value[0] === "string") { + const [type, b, c] = value; + switch (type) { + case TYPE_DATE: + set((hydrated[index] = new Date(b))); + continue; + case TYPE_URL: + set((hydrated[index] = new URL(b))); + continue; + case TYPE_BIGINT: + set((hydrated[index] = BigInt(b))); + continue; + case TYPE_REGEXP: + set((hydrated[index] = new RegExp(b, c))); + continue; + case TYPE_SYMBOL: + set((hydrated[index] = Symbol.for(b))); + continue; + case TYPE_SET: + const newSet = new Set(); + hydrated[index] = newSet; + for (let i = 1; i < value.length; i++) + stack.push([ + value[i], + (v) => { + newSet.add(v); + }, + ]); + set(newSet); + continue; + case TYPE_MAP: + const map = new Map(); + hydrated[index] = map; + for (let i = 1; i < value.length; i += 2) { + const r: any[] = []; + stack.push([ + value[i + 1], + (v) => { + r[1] = v; + }, + ]); + stack.push([ + value[i], + (k) => { + r[0] = k; + }, + ]); + postRun.push(() => { + map.set(r[0], r[1]); + }); + } + set(map); + continue; + case TYPE_NULL_OBJECT: + const obj = Object.create(null); + hydrated[index] = obj; + for (const key of Object.keys(b).reverse()) { + const r: any[] = []; + stack.push([ + b[key], + (v) => { + r[1] = v; + }, + ]); + stack.push([ + Number(key.slice(1)), + (k) => { + r[0] = k; + }, + ]); + postRun.push(() => { + obj[r[0]] = r[1]; + }); + } + set(obj); + continue; + case TYPE_PROMISE: + if (hydrated[b]) { + set((hydrated[index] = hydrated[b])); + } else { + const d = new Deferred(); + deferred[b] = d; + set((hydrated[index] = d.promise)); + } + continue; + case TYPE_ERROR: + const [, message, errorType] = value; + let error = + errorType && globalObj && globalObj[errorType] + ? new globalObj[errorType](message) + : new Error(message); + hydrated[index] = error; + set(error); + continue; + case TYPE_PREVIOUS_RESOLVED: + set((hydrated[index] = hydrated[b])); + continue; + default: + // Run plugins at the end so we have a chance to resolve primitives + // without running into a loop + if (Array.isArray(plugins)) { + const r: unknown[] = []; + const vals = value.slice(1); + for (let i = 0; i < vals.length; i++) { + const v = vals[i]; + stack.push([ + v, + (v) => { + r[i] = v; + }, + ]); + } + postRun.push(() => { + for (const plugin of plugins) { + const result = plugin(value[0], ...r); + if (result) { + set((hydrated[index] = result.value)); + return; + } + } + throw new SyntaxError(); + }); + continue; + } + throw new SyntaxError(); + } + } else { + const array: unknown[] = []; + hydrated[index] = array; + + for (let i = 0; i < value.length; i++) { + const n = value[i]; + if (n !== HOLE) { + stack.push([ + n, + (v) => { + array[i] = v; + }, + ]); + } + } + set(array); + continue; + } + } else { + const object: Record = {}; + hydrated[index] = object; + + for (const key of Object.keys(value).reverse()) { + const r: any[] = []; + stack.push([ + (value as Record)[key], + (v) => { + r[1] = v; + }, + ]); + stack.push([ + Number(key.slice(1)), + (k) => { + r[0] = k; + }, + ]); + postRun.push(() => { + object[r[0]] = r[1]; + }); + } + set(object); + continue; + } + } + + while (postRun.length > 0) { + postRun.pop()!(); + } + + return result; +} diff --git a/packages/react-router/vendor/turbo-stream-v2/utils.ts b/packages/react-router/vendor/turbo-stream-v2/utils.ts new file mode 100644 index 0000000000..fc2f393264 --- /dev/null +++ b/packages/react-router/vendor/turbo-stream-v2/utils.ts @@ -0,0 +1,84 @@ +export const HOLE = -1; +export const NAN = -2; +export const NEGATIVE_INFINITY = -3; +export const NEGATIVE_ZERO = -4; +export const NULL = -5; +export const POSITIVE_INFINITY = -6; +export const UNDEFINED = -7; + +export const TYPE_BIGINT = "B"; +export const TYPE_DATE = "D"; +export const TYPE_ERROR = "E"; +export const TYPE_MAP = "M"; +export const TYPE_NULL_OBJECT = "N"; +export const TYPE_PROMISE = "P"; +export const TYPE_REGEXP = "R"; +export const TYPE_SET = "S"; +export const TYPE_SYMBOL = "Y"; +export const TYPE_URL = "U"; +export const TYPE_PREVIOUS_RESOLVED = "Z"; + +export type DecodePlugin = ( + type: string, + ...data: unknown[] +) => { value: unknown } | false | null | undefined; + +export type EncodePlugin = ( + value: unknown +) => [string, ...unknown[]] | false | null | undefined; + +export interface ThisDecode { + values: unknown[]; + hydrated: unknown[]; + deferred: Record>; + plugins?: DecodePlugin[]; +} + +export interface ThisEncode { + index: number; + indices: Map; + stringified: string[]; + deferred: Record>; + plugins?: EncodePlugin[]; + postPlugins?: EncodePlugin[]; + signal?: AbortSignal; +} + +export class Deferred { + promise: Promise; + resolve!: (value: T) => void; + reject!: (reason: unknown) => void; + + constructor() { + this.promise = new Promise((resolve, reject) => { + this.resolve = resolve; + this.reject = reject; + }); + } +} + +export function createLineSplittingTransform() { + const decoder = new TextDecoder(); + let leftover = ""; + + return new TransformStream({ + transform(chunk, controller) { + const str = decoder.decode(chunk, { stream: true }); + const parts = (leftover + str).split("\n"); + + // The last part might be a partial line, so keep it for the next chunk. + leftover = parts.pop() || ""; + + for (const part of parts) { + controller.enqueue(part); + } + }, + + flush(controller) { + // If there's any leftover data, enqueue it before closing. + if (leftover) { + controller.enqueue(leftover); + } + }, + }); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ad1676be74..a7fb72821d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -156,7 +156,7 @@ importers: version: 7.34.1(eslint@8.57.0) eslint-plugin-react-hooks: specifier: next - version: 5.2.0-canary-c69a5fc5-20250318(eslint@8.57.0) + version: 6.1.0-canary-408d055a-20250430(eslint@8.57.0) fs-extra: specifier: ^10.1.0 version: 10.1.0 @@ -739,9 +739,6 @@ importers: set-cookie-parser: specifier: ^2.6.0 version: 2.6.0 - turbo-stream: - specifier: 2.4.0 - version: 2.4.0 devDependencies: '@types/set-cookie-parser': specifier: ^2.4.1 @@ -5469,8 +5466,8 @@ packages: peerDependencies: eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 - eslint-plugin-react-hooks@5.2.0-canary-c69a5fc5-20250318: - resolution: {integrity: sha512-NPceobfzu27xPs00tcceAF5rclbQ0FmBKqGO8CJP0d+FqSpPXCrvKLrjZL+42gwfIH+yjXJprjVd34BUL5WXcQ==} + eslint-plugin-react-hooks@6.1.0-canary-408d055a-20250430: + resolution: {integrity: sha512-alsX4a7wAQti0tP2CziiAIsGCtdEAb7KQkx41eHq98HN6Kl4d/j7wFpEJ3DicS6A0Sd4GIoBoFsLfth4OhAdsA==} engines: {node: '>=18'} peerDependencies: eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 @@ -8543,9 +8540,6 @@ packages: engines: {node: '>=8.0.0'} hasBin: true - turbo-stream@2.4.0: - resolution: {integrity: sha512-FHncC10WpBd2eOmGwpmQsWLDoK4cqsA/UT/GqNoaKOQnT8uzhtCbg3EoUDMvqpOSAI0S26mr0rkjzbOO6S3v1g==} - type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} @@ -13631,7 +13625,7 @@ snapshots: dependencies: eslint: 8.57.0 - eslint-plugin-react-hooks@5.2.0-canary-c69a5fc5-20250318(eslint@8.57.0): + eslint-plugin-react-hooks@6.1.0-canary-408d055a-20250430(eslint@8.57.0): dependencies: '@babel/core': 7.26.10 '@babel/parser': 7.26.10 @@ -14794,7 +14788,7 @@ snapshots: jest-message-util@29.7.0: dependencies: - '@babel/code-frame': 7.24.2 + '@babel/code-frame': 7.26.2 '@jest/types': 29.6.3 '@types/stack-utils': 2.0.1 chalk: 4.1.2 @@ -16302,7 +16296,7 @@ snapshots: parse-json@5.2.0: dependencies: - '@babel/code-frame': 7.24.2 + '@babel/code-frame': 7.26.2 error-ex: 1.3.2 json-parse-even-better-errors: 2.3.1 lines-and-columns: 1.2.4 @@ -17610,8 +17604,6 @@ snapshots: wcwidth: 1.0.1 yargs: 17.7.2 - turbo-stream@2.4.0: {} - type-check@0.4.0: dependencies: prelude-ls: 1.2.1 From 3e17c2d9d426d94ca28868c16daa410785e04d94 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Thu, 1 May 2025 11:02:38 -0400 Subject: [PATCH 2/4] Fix map/set ordering on decode --- .../__tests__/vendor/turbo-stream-test.ts | 15 +++++++++++++++ .../vendor/turbo-stream-v2/unflatten.ts | 4 ++-- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/packages/react-router/__tests__/vendor/turbo-stream-test.ts b/packages/react-router/__tests__/vendor/turbo-stream-test.ts index e3b3b34b9d..0ddc1131dd 100644 --- a/packages/react-router/__tests__/vendor/turbo-stream-test.ts +++ b/packages/react-router/__tests__/vendor/turbo-stream-test.ts @@ -120,6 +120,15 @@ test("should encode and decode Map", async () => { expect(output).toEqual(input); }); +test("should maintain order of Map entries", async () => { + const input = new Map([ + ["foo", "bar"], + ["baz", "qux"], + ]); + const output = await quickDecode(encode(input)); + expect(Array.from(output as typeof input)).toEqual(Array.from(input)); +}); + test("should encode and decode empty Map", async () => { const input = new Map(); const output = await quickDecode(encode(input)); @@ -132,6 +141,12 @@ test("should encode and decode Set", async () => { expect(output).toEqual(input); }); +test("should maintain order of Set entries", async () => { + const input = new Set(["foo", "bar"]); + const output = await quickDecode(encode(input)); + expect(Array.from(output as typeof input)).toEqual(Array.from(input)); +}); + test("should encode and decode empty Set", async () => { const input = new Set(); const output = await quickDecode(encode(input)); diff --git a/packages/react-router/vendor/turbo-stream-v2/unflatten.ts b/packages/react-router/vendor/turbo-stream-v2/unflatten.ts index b75248ad5e..21b75f746f 100644 --- a/packages/react-router/vendor/turbo-stream-v2/unflatten.ts +++ b/packages/react-router/vendor/turbo-stream-v2/unflatten.ts @@ -117,7 +117,7 @@ function hydrate(this: ThisDecode, index: number): any { case TYPE_SET: const newSet = new Set(); hydrated[index] = newSet; - for (let i = 1; i < value.length; i++) + for (let i = value.length - 1; i > 0; i--) stack.push([ value[i], (v) => { @@ -129,7 +129,7 @@ function hydrate(this: ThisDecode, index: number): any { case TYPE_MAP: const map = new Map(); hydrated[index] = map; - for (let i = 1; i < value.length; i += 2) { + for (let i = value.length - 2; i > 0; i -= 2) { const r: any[] = []; stack.push([ value[i + 1], From d15e32ded3973c61e88221b5f2a4a36ca0b41694 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Thu, 1 May 2025 11:03:18 -0400 Subject: [PATCH 3/4] Add changeset --- .changeset/six-squids-tickle.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/six-squids-tickle.md diff --git a/.changeset/six-squids-tickle.md b/.changeset/six-squids-tickle.md new file mode 100644 index 0000000000..793d3b723b --- /dev/null +++ b/.changeset/six-squids-tickle.md @@ -0,0 +1,5 @@ +--- +"react-router": patch +--- + +Inline `turbo-stream@2.4.1` dependency and fix decoding ordering of Map/Set instances From 6f6b9278c9e9ff2abfe91659946bd954336f5f8d Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Thu, 1 May 2025 11:23:19 -0400 Subject: [PATCH 4/4] Fix lint issues --- packages/react-router/.eslintrc.js | 1 - packages/react-router/vendor/turbo-stream-v2/flatten.ts | 1 - 2 files changed, 2 deletions(-) diff --git a/packages/react-router/.eslintrc.js b/packages/react-router/.eslintrc.js index a634ea0440..6af77a3cad 100644 --- a/packages/react-router/.eslintrc.js +++ b/packages/react-router/.eslintrc.js @@ -7,7 +7,6 @@ module.exports = { }, rules: { strict: 0, - "no-restricted-syntax": ["error", "LogicalExpression[operator='??']"], "no-restricted-globals": [ "error", { name: "__dirname", message: restrictedGlobalsError }, diff --git a/packages/react-router/vendor/turbo-stream-v2/flatten.ts b/packages/react-router/vendor/turbo-stream-v2/flatten.ts index 77082330e5..a5e4567825 100644 --- a/packages/react-router/vendor/turbo-stream-v2/flatten.ts +++ b/packages/react-router/vendor/turbo-stream-v2/flatten.ts @@ -11,7 +11,6 @@ import { TYPE_ERROR, TYPE_MAP, TYPE_NULL_OBJECT, - TYPE_PREVIOUS_RESOLVED, TYPE_PROMISE, TYPE_REGEXP, TYPE_SET,