├── .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 |
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 |
--------------------------------------------------------------------------------