├── .eslintrc.cjs ├── .gitignore ├── README.md ├── app ├── entry.client.tsx ├── entry.server.tsx ├── hooks.ts ├── root.tsx ├── routes │ ├── _index.tsx │ ├── defer.tsx │ ├── eventsource.tsx │ ├── redirect.tsx │ ├── sse.time.tsx │ └── test.tsx └── turbo.ts ├── package-lock.json ├── package.json ├── public └── favicon.ico ├── tsconfig.json └── vite.config.ts /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | /** 2 | * This is intended to be a basic starting point for linting in your app. 3 | * It relies on recommended configs out of the box for simplicity, but you can 4 | * and should modify this configuration to best suit your team's needs. 5 | */ 6 | 7 | /** @type {import('eslint').Linter.Config} */ 8 | module.exports = { 9 | root: true, 10 | parserOptions: { 11 | ecmaVersion: "latest", 12 | sourceType: "module", 13 | ecmaFeatures: { 14 | jsx: true, 15 | }, 16 | }, 17 | env: { 18 | browser: true, 19 | commonjs: true, 20 | es6: true, 21 | }, 22 | ignorePatterns: ["!**/.server", "!**/.client"], 23 | 24 | // Base config 25 | extends: ["eslint:recommended"], 26 | 27 | overrides: [ 28 | // React 29 | { 30 | files: ["**/*.{js,jsx,ts,tsx}"], 31 | plugins: ["react", "jsx-a11y"], 32 | extends: [ 33 | "plugin:react/recommended", 34 | "plugin:react/jsx-runtime", 35 | "plugin:react-hooks/recommended", 36 | "plugin:jsx-a11y/recommended", 37 | ], 38 | settings: { 39 | react: { 40 | version: "detect", 41 | }, 42 | formComponents: ["Form"], 43 | linkComponents: [ 44 | { name: "Link", linkAttribute: "to" }, 45 | { name: "NavLink", linkAttribute: "to" }, 46 | ], 47 | "import/resolver": { 48 | typescript: {}, 49 | }, 50 | }, 51 | }, 52 | 53 | // Typescript 54 | { 55 | files: ["**/*.{ts,tsx}"], 56 | plugins: ["@typescript-eslint", "import"], 57 | parser: "@typescript-eslint/parser", 58 | settings: { 59 | "import/internal-regex": "^~/", 60 | "import/resolver": { 61 | node: { 62 | extensions: [".ts", ".tsx"], 63 | }, 64 | typescript: { 65 | alwaysTryTypes: true, 66 | }, 67 | }, 68 | }, 69 | extends: [ 70 | "plugin:@typescript-eslint/recommended", 71 | "plugin:import/recommended", 72 | "plugin:import/typescript", 73 | ], 74 | }, 75 | 76 | // Node 77 | { 78 | files: [".eslintrc.cjs"], 79 | env: { 80 | node: true, 81 | }, 82 | }, 83 | ], 84 | }; 85 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | /.cache 4 | /build 5 | .env 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Remix Single Data Fetch typed data 2 | 3 | This example shows how to use the new [Single Data Fetch RFC](https://github.com/remix-run/remix/discussions/7640) coming in Remix v2.9. 4 | 5 | ⚡️ StackBlitz https://stackblitz.com/github/kiliman/remix-single-fetch 6 | 7 | ## Configuration 8 | 9 | First, enable the `future` flag for this feature in _vite.config.ts_ 10 | 11 | ```ts 12 | export default defineConfig({ 13 | plugins: [ 14 | remix({ 15 | future: { 16 | unstable_singleFetch: true, 17 | }, 18 | }), 19 | tsconfigPaths(), 20 | ], 21 | }); 22 | ``` 23 | 24 | ## Returning data 25 | 26 | Now, in your `loader` and `action`, instead of using the `json` utility to return data, simply return a _naked_ object. 27 | 28 | ```diff 29 | - return json({ id: 123 }) 30 | + return { id: 123 } 31 | ``` 32 | 33 | In addition to returning all loader data in a single request, this feature will 34 | stream native results automatically. This includes native types like `Date`, `BigInt`, 35 | `Map`, `Set`, and even `Promise` values. You no longer need to use the `defer` utility 36 | to return promises. 37 | 38 | See [turbo-stream](https://github.com/jacob-ebey/turbo-stream) for more details. 39 | 40 | ```ts 41 | export function loader() { 42 | return { 43 | fastData: { message: "This is fast data", today: new Date() }, 44 | slowData: new Promise((resolve) => setTimeout(resolve, 2000)).then(() => { 45 | return { message: "This is slow data", tomorrow: new Date() }; 46 | }), 47 | }; 48 | } 49 | ``` 50 | 51 | ### Using data 52 | 53 | The `useLoaderData` and `useActionData` hooks currently expect the result to be JSON objects, so native types like `Date` will be treated as `string` via IntelliSense, but it is still actually a `Date`. 54 | 55 | To make sure your types are accurate, I've created helper functions that return the correct type (`~/hooks`) 56 | 57 | - `useTypedLoaderData` 58 | - `useTypedActionData` 59 | - `useTypedRouteLoaderData` 60 | 61 | ```ts 62 | export default function Component() { 63 | const loaderData = useTypedLoaderData(); 64 | const actionData = useTypedActionData(); 65 | const rootData = useTypedRouteLoaderData("root"); 66 | //... 67 | } 68 | ``` 69 | 70 | ### Redirects 71 | 72 | To redirect from a loader or action, use `throw redirect()` instead of returning. 73 | This will ensure that the return type is correct for type inference. 74 | 75 | ### Typed Event Source 76 | 77 | You can continue to use Event Source responses even with Single Data Fetch. 78 | 79 | To use native types like `Date` in your event source payload, use the `encode` 80 | function from `~/turbo`. Then, in your route, use the `useTypedEventSource` hook 81 | to decode the value back to its native type. 82 | 83 | ```ts 84 | // routes/sse.time.tsx 85 | type TimeEventType = Date; 86 | 87 | send({ event: "time", data: await encode(new Date()) }); 88 | 89 | // routes/eventsource.tsx 90 | const time = useTypedEventSource("/sse/time", { 91 | // ^? const time: Date | null 92 | event: "time", 93 | }); 94 | ``` 95 | -------------------------------------------------------------------------------- /app/entry.client.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * By default, Remix will handle hydrating your app on the client for you. 3 | * You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` ✨ 4 | * For more information, see https://remix.run/file-conventions/entry.client 5 | */ 6 | 7 | import { RemixBrowser } from "@remix-run/react"; 8 | import { startTransition, StrictMode } from "react"; 9 | import { hydrateRoot } from "react-dom/client"; 10 | 11 | startTransition(() => { 12 | hydrateRoot( 13 | document, 14 | 15 | 16 | 17 | ); 18 | }); 19 | -------------------------------------------------------------------------------- /app/entry.server.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * By default, Remix will handle generating the HTTP Response for you. 3 | * You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` ✨ 4 | * For more information, see https://remix.run/file-conventions/entry.server 5 | */ 6 | 7 | import { PassThrough } from "node:stream"; 8 | 9 | import type { AppLoadContext, EntryContext } from "@remix-run/node"; 10 | import { createReadableStreamFromReadable } from "@remix-run/node"; 11 | import { RemixServer } from "@remix-run/react"; 12 | import { isbot } from "isbot"; 13 | import { renderToPipeableStream } from "react-dom/server"; 14 | 15 | const ABORT_DELAY = 5_000; 16 | 17 | export default function handleRequest( 18 | request: Request, 19 | responseStatusCode: number, 20 | responseHeaders: Headers, 21 | remixContext: EntryContext, 22 | // This is ignored so we can keep it in the template for visibility. Feel 23 | // free to delete this parameter in your app if you're not using it! 24 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 25 | loadContext: AppLoadContext 26 | ) { 27 | return isbot(request.headers.get("user-agent") || "") 28 | ? handleBotRequest( 29 | request, 30 | responseStatusCode, 31 | responseHeaders, 32 | remixContext 33 | ) 34 | : handleBrowserRequest( 35 | request, 36 | responseStatusCode, 37 | responseHeaders, 38 | remixContext 39 | ); 40 | } 41 | 42 | function handleBotRequest( 43 | request: Request, 44 | responseStatusCode: number, 45 | responseHeaders: Headers, 46 | remixContext: EntryContext 47 | ) { 48 | return new Promise((resolve, reject) => { 49 | let shellRendered = false; 50 | const { pipe, abort } = renderToPipeableStream( 51 | , 56 | { 57 | onAllReady() { 58 | shellRendered = true; 59 | const body = new PassThrough(); 60 | const stream = createReadableStreamFromReadable(body); 61 | 62 | responseHeaders.set("Content-Type", "text/html"); 63 | 64 | resolve( 65 | new Response(stream, { 66 | headers: responseHeaders, 67 | status: responseStatusCode, 68 | }) 69 | ); 70 | 71 | pipe(body); 72 | }, 73 | onShellError(error: unknown) { 74 | reject(error); 75 | }, 76 | onError(error: unknown) { 77 | responseStatusCode = 500; 78 | // Log streaming rendering errors from inside the shell. Don't log 79 | // errors encountered during initial shell rendering since they'll 80 | // reject and get logged in handleDocumentRequest. 81 | if (shellRendered) { 82 | console.error(error); 83 | } 84 | }, 85 | } 86 | ); 87 | 88 | setTimeout(abort, ABORT_DELAY); 89 | }); 90 | } 91 | 92 | function handleBrowserRequest( 93 | request: Request, 94 | responseStatusCode: number, 95 | responseHeaders: Headers, 96 | remixContext: EntryContext 97 | ) { 98 | return new Promise((resolve, reject) => { 99 | let shellRendered = false; 100 | const { pipe, abort } = renderToPipeableStream( 101 | , 106 | { 107 | onShellReady() { 108 | shellRendered = true; 109 | const body = new PassThrough(); 110 | const stream = createReadableStreamFromReadable(body); 111 | 112 | responseHeaders.set("Content-Type", "text/html"); 113 | 114 | resolve( 115 | new Response(stream, { 116 | headers: responseHeaders, 117 | status: responseStatusCode, 118 | }) 119 | ); 120 | 121 | pipe(body); 122 | }, 123 | onShellError(error: unknown) { 124 | reject(error); 125 | }, 126 | onError(error: unknown) { 127 | responseStatusCode = 500; 128 | // Log streaming rendering errors from inside the shell. Don't log 129 | // errors encountered during initial shell rendering since they'll 130 | // reject and get logged in handleDocumentRequest. 131 | if (shellRendered) { 132 | console.error(error); 133 | } 134 | }, 135 | } 136 | ); 137 | 138 | setTimeout(abort, ABORT_DELAY); 139 | }); 140 | } 141 | -------------------------------------------------------------------------------- /app/hooks.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState, createContext, useContext } from "react"; 2 | import type { LoaderFunction, ActionFunction } from "@remix-run/node"; 3 | import { 4 | useLoaderData, 5 | useActionData, 6 | useRouteLoaderData, 7 | } from "@remix-run/react"; 8 | import { decode } from "~/turbo"; 9 | 10 | export function useTypedLoaderData() { 11 | return useLoaderData() as unknown as Awaited>; 12 | } 13 | 14 | export function useTypedActionData() { 15 | return useActionData() as unknown as Awaited> | undefined; 16 | } 17 | 18 | export function useTypedRouteLoaderData(id: string) { 19 | return useRouteLoaderData(id) as unknown as Awaited>; 20 | } 21 | 22 | // EventSource implementation borrowed from `remix-util` package 23 | // Updated to support async data decoding 24 | 25 | export interface EventSourceOptions { 26 | init?: EventSourceInit; 27 | event?: string; 28 | } 29 | 30 | export type EventSourceMap = Map< 31 | string, 32 | { count: number; source: EventSource } 33 | >; 34 | 35 | const context = createContext( 36 | new Map() 37 | ); 38 | 39 | export const EventSourceProvider = context.Provider; 40 | 41 | /** 42 | * Subscribe to an event source and return the latest event. 43 | * @param url The URL of the event source to connect to 44 | * @param options The options to pass to the EventSource constructor 45 | * @returns The last event received from the server 46 | */ 47 | export function useTypedEventSource( 48 | url: string | URL, 49 | { event = "message", init }: EventSourceOptions = {} 50 | ): T | null { 51 | const map = useContext(context); 52 | const [data, setData] = useState(null); 53 | 54 | useEffect(() => { 55 | const key = [url.toString(), init?.withCredentials].join("::"); 56 | 57 | const value = map.get(key) ?? { 58 | count: 0, 59 | source: new EventSource(url, init), 60 | }; 61 | 62 | ++value.count; 63 | 64 | map.set(key, value); 65 | 66 | value.source.addEventListener(event, handler); 67 | 68 | // reset data if dependencies change 69 | setData(null); 70 | 71 | // async handler to decode data 72 | async function handler(event: MessageEvent) { 73 | const data = await decode(event.data); 74 | // not sure why data type is Awaited here 75 | // @ts-expect-error data is of type T 76 | setData(data); 77 | } 78 | 79 | return () => { 80 | value.source.removeEventListener(event, handler); 81 | --value.count; 82 | if (value.count <= 0) { 83 | value.source.close(); 84 | map.delete(key); 85 | } 86 | }; 87 | }, [url, event, init, map]); 88 | 89 | return data; 90 | } 91 | -------------------------------------------------------------------------------- /app/root.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Links, 3 | Meta, 4 | Outlet, 5 | Scripts, 6 | ScrollRestoration, 7 | } from "@remix-run/react"; 8 | 9 | export async function loader() { 10 | return { user: { name: "kiliman" } }; 11 | } 12 | 13 | export function Layout({ children }: { children: React.ReactNode }) { 14 | return ( 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | {children} 24 | 25 | 26 | 27 | 28 | ); 29 | } 30 | 31 | export default function App() { 32 | return ; 33 | } 34 | -------------------------------------------------------------------------------- /app/routes/_index.tsx: -------------------------------------------------------------------------------- 1 | import type { MetaFunction } from "@remix-run/node"; 2 | import { Link } from "@remix-run/react"; 3 | 4 | export const meta: MetaFunction = () => { 5 | return [ 6 | { title: "New Remix App" }, 7 | { name: "description", content: "Welcome to Remix!" }, 8 | ]; 9 | }; 10 | 11 | export default function Index() { 12 | return ( 13 |
14 |

Welcome to Remix

15 |
    16 |
  • 17 | Test 18 |
  • 19 |
  • 20 | Defer 21 |
  • 22 |
  • 23 | Typed Event Source 24 |
  • 25 |
  • 26 | Redirect 27 |
  • 28 |
29 |
30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /app/routes/defer.tsx: -------------------------------------------------------------------------------- 1 | import { Await } from "@remix-run/react"; 2 | import { Suspense } from "react"; 3 | import { useTypedLoaderData } from "~/hooks"; 4 | 5 | export function loader() { 6 | return { 7 | fastData: { message: "This is fast data", today: new Date() }, 8 | slowData: new Promise((resolve) => setTimeout(resolve, 2000)).then(() => { 9 | return { message: "This is slow data", tomorrow: new Date() }; 10 | }), 11 | }; 12 | } 13 | 14 | export default function DeferRoute() { 15 | const data = useTypedLoaderData(); 16 | 17 | return ( 18 |
19 |

Defer Route

20 |

Fast Data

21 |

22 | {data.fastData.message} at{" "} 23 | {data.fastData.today.toLocaleString()} 24 |

25 | Loading slow data...

}> 26 | Error loading slow data!

} 29 | > 30 | {(slowData) => ( 31 |

32 | {slowData.message} at{" "} 33 | {slowData.tomorrow.toLocaleString()} 34 |

35 | )} 36 |
37 |
38 |
39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /app/routes/eventsource.tsx: -------------------------------------------------------------------------------- 1 | import { useTypedEventSource } from "~/hooks"; 2 | import { TimeEventType } from "./sse.time"; 3 | 4 | export default function Component() { 5 | return ( 6 |
7 |

Typed Event Source

8 | 9 |
10 | ); 11 | } 12 | 13 | function Counter() { 14 | // Here `/sse/time` is the resource route returning an eventStream response 15 | const time = useTypedEventSource("/sse/time", { 16 | // ^? 17 | event: "time", 18 | }); 19 | 20 | if (!time) return null; 21 | 22 | return ( 23 | <> 24 |
{time.toLocaleString()}
25 |
time instanceof Date: {time instanceof Date ? "true" : "false"}
26 | 27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /app/routes/redirect.tsx: -------------------------------------------------------------------------------- 1 | import { redirect } from "@remix-run/node"; 2 | 3 | export async function loader() { 4 | throw redirect("/"); 5 | } 6 | 7 | export default function Component() { 8 | return null; 9 | } 10 | -------------------------------------------------------------------------------- /app/routes/sse.time.tsx: -------------------------------------------------------------------------------- 1 | // app/routes/sse.time.ts 2 | import { LoaderFunctionArgs } from "@remix-run/node"; 3 | import { eventStream } from "remix-utils/sse/server"; 4 | import { encode } from "~/turbo"; 5 | 6 | export type TimeEventType = Date; 7 | 8 | export async function loader({ request }: LoaderFunctionArgs) { 9 | return eventStream(request.signal, (send, abort) => { 10 | async function run() { 11 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 12 | for await (const _ of interval(1000, { signal: request.signal })) { 13 | send({ event: "time", data: await encode(new Date()) }); 14 | } 15 | } 16 | run(); 17 | return () => { 18 | abort(); 19 | }; 20 | }); 21 | } 22 | 23 | interface Options { 24 | signal?: AbortSignal; 25 | } 26 | 27 | /** 28 | * Wait for a specified amount of time, accepts a signal to abort the timer. 29 | * @param ms The amount of time to wait in milliseconds 30 | * @param options The options for the timer 31 | * @example 32 | * let controller = new AbortController(); 33 | * await wait(1000, { signal: controller.signal }); 34 | */ 35 | export function wait(ms: number, options?: Options): Promise { 36 | return new Promise((resolve, reject) => { 37 | const timeout = setTimeout(() => { 38 | // need to remove the event listener after timeout 39 | if (options?.signal) options.signal.removeEventListener("abort", abort); 40 | if (options?.signal?.aborted) return reject(new TimersError("Aborted")); 41 | 42 | return resolve(); 43 | }, ms); 44 | 45 | function abort() { 46 | clearTimeout(timeout); 47 | reject(new TimersError("Aborted")); 48 | } 49 | 50 | if (options?.signal) { 51 | options.signal.addEventListener("abort", abort); 52 | } 53 | }); 54 | } 55 | 56 | /** 57 | * Get an async iterable that yields on an interval until aborted. 58 | * @param ms The amount of time to wait between intervals, in milliseconds 59 | * @param options The options for the timer 60 | * @returns An async iterable that yields on each intervals 61 | * @example 62 | * let controller = new AbortController(); 63 | * for await (let _ of interval(1000, { signal: controller.signal })) { 64 | * // Do something every second until aborted 65 | * } 66 | */ 67 | export async function* interval(ms: number, options?: Options) { 68 | const signal = options?.signal ?? new AbortSignal(); 69 | while (!signal.aborted) { 70 | try { 71 | yield await wait(ms, { signal }); 72 | } catch { 73 | return; 74 | } 75 | } 76 | } 77 | 78 | export class TimersError extends globalThis.Error {} 79 | -------------------------------------------------------------------------------- /app/routes/test.tsx: -------------------------------------------------------------------------------- 1 | import { type ActionFunctionArgs } from "@remix-run/node"; 2 | import { 3 | useTypedLoaderData, 4 | useTypedActionData, 5 | useTypedRouteLoaderData, 6 | } from "~/hooks"; 7 | import { loader as rootLoader } from "~/root"; 8 | import { Form } from "@remix-run/react"; 9 | export async function loader() { 10 | return { id: 123, now: new Date() }; 11 | } 12 | 13 | export async function action({ request }: ActionFunctionArgs) { 14 | const formData = await request.formData(); 15 | const id = Number(formData.get("id")); 16 | return { id }; 17 | } 18 | 19 | export default function Component() { 20 | const loaderData = useTypedLoaderData(); 21 | const actionData = useTypedActionData(); 22 | const rootData = useTypedRouteLoaderData("root"); 23 | return ( 24 |
25 |

Loader Data

26 |
{JSON.stringify(loaderData, null, 2)}
27 |

Today is {loaderData.now.toLocaleString()}

28 | 29 |

Action Data

30 |
31 | 38 | 39 |
40 |
{JSON.stringify(actionData, null, 2)}
41 | 42 |

Root Loader Data

43 |
{JSON.stringify(rootData, null, 2)}
44 |
45 | ); 46 | } 47 | -------------------------------------------------------------------------------- /app/turbo.ts: -------------------------------------------------------------------------------- 1 | import * as turbo from "turbo-stream"; 2 | import { Buffer } from "buffer"; 3 | 4 | export async function encode(payload: T): Promise { 5 | const encodedStream = turbo.encode(payload); 6 | const buffer = await streamToBuffer(encodedStream); 7 | return buffer.toString("utf-8"); 8 | } 9 | 10 | export async function decode(encoded: string): Promise { 11 | const buffer = Buffer.from(encoded, "utf-8"); 12 | const stream = bufferToStream(buffer); 13 | const result = await turbo.decode(stream); 14 | if (!result.done) return null; 15 | return result.value as T; 16 | } 17 | 18 | async function streamToBuffer(stream: ReadableStream) { 19 | const chunks = []; 20 | // @ts-expect-error async iterator 21 | for await (const chunk of stream) { 22 | chunks.push(chunk); 23 | } 24 | return Buffer.concat(chunks); 25 | } 26 | 27 | function bufferToStream(buffer: Buffer) { 28 | const blob = new Blob([buffer]); 29 | return blob.stream(); 30 | } 31 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "remix-single-fetch", 3 | "private": true, 4 | "sideEffects": false, 5 | "type": "module", 6 | "scripts": { 7 | "build": "remix vite:build", 8 | "dev": "remix vite:dev", 9 | "lint": "eslint --ignore-path .gitignore --cache --cache-location ./node_modules/.cache/eslint .", 10 | "start": "remix-serve ./build/server/index.js", 11 | "typecheck": "tsc" 12 | }, 13 | "dependencies": { 14 | "@remix-run/node": "2.9.0-pre.1", 15 | "@remix-run/react": "2.9.0-pre.1", 16 | "@remix-run/serve": "2.9.0-pre.1", 17 | "isbot": "^4.1.0", 18 | "react": "19.0.0-canary-2b036d3f1-20240327", 19 | "react-dom": "19.0.0-canary-2b036d3f1-20240327", 20 | "remix-utils": "^7.5.0" 21 | }, 22 | "devDependencies": { 23 | "@remix-run/dev": "2.9.0-pre.1", 24 | "@types/react": "^18.2.20", 25 | "@types/react-dom": "^18.2.7", 26 | "@typescript-eslint/eslint-plugin": "^6.7.4", 27 | "@typescript-eslint/parser": "^6.7.4", 28 | "eslint": "^8.38.0", 29 | "eslint-import-resolver-typescript": "^3.6.1", 30 | "eslint-plugin-import": "^2.28.1", 31 | "eslint-plugin-jsx-a11y": "^6.7.1", 32 | "eslint-plugin-react": "^7.33.2", 33 | "eslint-plugin-react-hooks": "^4.6.0", 34 | "typescript": "^5.1.6", 35 | "vite": "^5.1.0", 36 | "vite-tsconfig-paths": "^4.2.1" 37 | }, 38 | "overrides": { 39 | "@remix-run/node": "2.9.0-pre.1", 40 | "@remix-run/react": "2.9.0-pre.1", 41 | "@remix-run/serve": "2.9.0-pre.1", 42 | "@remix-run/dev": "2.9.0-pre.1", 43 | "react": "19.0.0-canary-2b036d3f1-20240327", 44 | "react-dom": "19.0.0-canary-2b036d3f1-20240327" 45 | }, 46 | "engines": { 47 | "node": ">=18.0.0" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kiliman/remix-single-fetch/6c3c7d7a6ffeec859114a1aee412230f08f1043a/public/favicon.ico -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": [ 3 | "**/*.ts", 4 | "**/*.tsx", 5 | "**/.server/**/*.ts", 6 | "**/.server/**/*.tsx", 7 | "**/.client/**/*.ts", 8 | "**/.client/**/*.tsx" 9 | ], 10 | "compilerOptions": { 11 | "lib": ["DOM", "DOM.Iterable", "ES2022"], 12 | "types": ["@remix-run/node", "vite/client"], 13 | "isolatedModules": true, 14 | "esModuleInterop": true, 15 | "jsx": "react-jsx", 16 | "module": "ESNext", 17 | "moduleResolution": "Bundler", 18 | "resolveJsonModule": true, 19 | "target": "ES2022", 20 | "strict": true, 21 | "allowJs": true, 22 | "skipLibCheck": true, 23 | "forceConsistentCasingInFileNames": true, 24 | "baseUrl": ".", 25 | "paths": { 26 | "~/*": ["./app/*"] 27 | }, 28 | 29 | // Vite takes care of building everything, not tsc. 30 | "noEmit": true 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { vitePlugin as remix } from "@remix-run/dev"; 2 | import { installGlobals } from "@remix-run/node"; 3 | import { defineConfig } from "vite"; 4 | import tsconfigPaths from "vite-tsconfig-paths"; 5 | 6 | installGlobals(); 7 | 8 | export default defineConfig({ 9 | plugins: [ 10 | remix({ 11 | future: { 12 | unstable_singleFetch: true, 13 | }, 14 | }), 15 | tsconfigPaths(), 16 | ], 17 | }); 18 | --------------------------------------------------------------------------------