Code Monkey home page Code Monkey logo

remix-utils's Introduction

Remix Utils

This package contains simple utility functions to use with Remix.run.

Installation

npm install remix-utils

Additional optional dependencies may be needed, all optional dependencies are:

  • @remix-run/react (also @remix-run/router but you should be using the React one)
  • @remix-run/node or @remix-run/cloudflare or @remix-run/deno (actually it's @remix-run/server-runtime but you should use one of the others)
  • crypto-js
  • is-ip
  • intl-parse-accept-language
  • react
  • zod

The utils that require an extra optional dependency mention it in their documentation.

If you want to install them all run:

npm add crypto-js is-ip intl-parse-accept-language zod

React and the @remix-run/* packages should be already installed in your project.

Upgrade from Remix Utils v6

If you used Remix Utils before, you will notice some changes.

  1. The package is published as ESM only
  2. Every util now has a specific path in the package, so you need to import them from remix-utils/<util-name>.
  3. All dependencies are now optional, so you need to install them yourself.

Usage with CJS

Since v7 of Remix Utils the package is published as ESM-only (plus type definitions). This means if you're using Remix with CJS you need to configure it to bundle Remix Utils.

In your remix.config.js file, add this:

module.exports = {
	serverDependenciesToBundle: [
		/^remix-utils.*/,
		// If you installed is-ip optional dependency you will need these too
		"is-ip",
		"ip-regex",
		"super-regex",
		"clone-regexp",
		"function-timeout",
		"time-span",
		"convert-hrtime",
		"is-regexp",
	],
};

If you're not sure if your app uses ESM or CJS, check if you have serverModuleFormat in your remix.config.js file to know.

In case you don't have one, if you're using Remix v1 it will be CJS and if you're using Remix v2 it will be ESM.

If you're using Vite, but still building to CJS, you will need to add this package to noExternal, so it's bundled with your server code. Therefore, add the following to defineConfig() in vite.config.ts:

ssr: {
      noExternal: ["remix-utils"],
    },

Note Some of the optional dependencies in Remix Utils may still be published as CJS, so you may need to add them to serverDependenciesToBundle too.

Another thing to consider if you did the upgrade from Remix v1 to Remix v2 is that in your tsconfig.json you will need to set "moduleResolution": "Bundler", otherwise TS will not resolve the new import paths.

Updated Import Paths

You will need to change your imports to use the correct one. So instead of doing:

import { eventStream, useEventSource } from "remix-utils";

You need to change it to:

import { eventStream } from "remix-utils/sse/server";
import { useEventSource } from "remix-utils/sse/react";

This adds more lines but enables the next change.

Optional Dependencies

Before, Remix Utils installed some dependencies like Zod that you may never used.

Current version marks every dependency as optional, so you need to install them yourself.

While this is more works it means the package can keep using more dependencies as needed without increasing the bundle size for everyone. Only if you use the dependency that depends on Zod you will install and include Zod in your bundle.

Every util mentions what dependencies it requires and the Installation section mentions the whole list in case you want to install them all upfront.

API Reference

promiseHash

The promiseHash function is not directly related to Remix but it's a useful function when working with loaders and actions.

This function is an object version of Promise.all which lets you pass an object with promises and get an object with the same keys with the resolved values.

import { promiseHash } from "remix-utils/promise";

export async function loader({ request }: LoaderFunctionArgs) {
	return json(
		await promiseHash({
			user: getUser(request),
			posts: getPosts(request),
		}),
	);
}

You can use nested promiseHash to get a nested object with resolved values.

import { promiseHash } from "remix-utils/promise";

export async function loader({ request }: LoaderFunctionArgs) {
	return json(
		await promiseHash({
			user: getUser(request),
			posts: promiseHash({
				list: getPosts(request),
				comments: promiseHash({
					list: getComments(request),
					likes: getLikes(request),
				}),
			}),
		}),
	);
}

timeout

The timeout function lets you attach a timeout to any promise, if the promise doesn't resolve or reject before the timeout, it will reject with a TimeoutError.

import { timeout } from "remix-utils/promise";

try {
	let result = await timeout(fetch("https://example.com"), { ms: 100 });
} catch (error) {
	if (error instanceof TimeoutError) {
		// Handle timeout
	}
}

Here the fetch needs to happen in less than 100ms, otherwise it will throw a TimeoutError.

If the promise is cancellable with an AbortSignal you can pass the AbortController to the timeout function.

import { timeout } from "remix-utils/promise";

try {
	let controller = new AbortController();
	let result = await timeout(
		fetch("https://example.com", { signal: controller.signal }),
		{ ms: 100, controller },
	);
} catch (error) {
	if (error instanceof TimeoutError) {
		// Handle timeout
	}
}

Here after 100ms, timeout will call controller.abort() which will mark the controller.signal as aborted.

cacheAssets

Note This can only be run inside entry.client.

This function lets you easily cache inside the browser's Cache Storage every JS file built by Remix.

To use it, open your entry.client file and add this:

import { cacheAssets } from "remix-utils/cache-assets";

cacheAssets().catch((error) => {
	// do something with the error, or not
});

The function receives an optional options object with two options:

  • cacheName is the name of the Cache object to use, the default value is assets.
  • buildPath is the pathname prefix for all Remix built assets, the default value is /build/ which is the default build path of Remix itself.

It's important that if you changed your build path in remix.config.js you pass the same value to cacheAssets or it will not find your JS files.

The cacheName can be left as is unless you're adding a Service Worker to your app and want to share the cache.

import { cacheAssets } from "remix-utils/cache-assets";

cacheAssests({ cacheName: "assets", buildPath: "/build/" }).catch((error) => {
	// do something with the error, or not
});

ClientOnly

Note This depends on react.

The ClientOnly component lets you render the children element only on the client-side, avoiding rendering it the server-side.

You can provide a fallback component to be used on SSR, and while optional, it's highly recommended to provide one to avoid content layout shift issues.

import { ClientOnly } from "remix-utils/client-only";

export default function Component() {
	return (
		<ClientOnly fallback={<SimplerStaticVersion />}>
			{() => <ComplexComponentNeedingBrowserEnvironment />}
		</ClientOnly>
	);
}

This component is handy when you have some complex component that needs a browser environment to work, like a chart or a map. This way, you can avoid rendering it server-side and instead use a simpler static version like an SVG or even a loading UI.

The rendering flow will be:

  • SSR: Always render the fallback.
  • CSR First Render: Always render the fallback.
  • CSR Update: Update to render the actual component.
  • CSR Future Renders: Always render the actual component, don't bother to render the fallback.

This component uses the useHydrated hook internally.

ServerOnly

Note This depends on react.

The ServerOnly component is the opposite of the ClientOnly component, it lets you render the children element only on the server-side, avoiding rendering it the client-side.

You can provide a fallback component to be used on CSR, and while optional, it's highly recommended to provide one to avoid content layout shift issues, unless you only render visually hidden elements.

import { ServerOnly } from "remix-utils/server-only";

export default function Component() {
	return (
		<ServerOnly fallback={<ComplexComponentNeedingBrowserEnvironment />}>
			{() => <SimplerStaticVersion />}
		</ServerOnly>
	);
}

This component is handy to render some content only on the server-side, like a hidden input you can later use to know if JS has loaded.

Consider it like the <noscript> HTML tag but it can work even if JS failed to load but it's enabled on the browser.

The rendering flow will be:

  • SSR: Always render the children.
  • CSR First Render: Always render the children.
  • CSR Update: Update to render the fallback component (if defined).
  • CSR Future Renders: Always render the fallback component, don't bother to render the children.

This component uses the useHydrated hook internally.

CORS

The CORS function let you implement CORS headers on your loaders and actions so you can use them as an API for other client-side applications.

There are two main ways to use the cors function.

  1. Use it on each loader/action where you want to enable it.
  2. Use it globally on entry.server handleRequest and handleDataRequest export.

If you want to use it on every loader/action, you can do it like this:

import { cors } from "remix-utils/cors";

export async function loader({ request }: LoaderFunctionArgs) {
	let data = await getData(request);
	let response = json<LoaderData>(data);
	return await cors(request, response);
}

You could also do the json and cors call in one line.

import { cors } from "remix-utils/cors";

export async function loader({ request }: LoaderFunctionArgs) {
	let data = await getData(request);
	return await cors(request, json<LoaderData>(data));
}

And because cors mutates the response, you can also call it and later return.

import { cors } from "remix-utils/cors";

export async function loader({ request }: LoaderFunctionArgs) {
	let data = await getData(request);
	let response = json<LoaderData>(data);
	await cors(request, response); // this mutates the Response object
	return response; // so you can return it here
}

If you want to setup it globally once, you can do it like this in entry.server

import { cors } from "remix-utils/cors";

const ABORT_DELAY = 5000;

export default function handleRequest(
	request: Request,
	responseStatusCode: number,
	responseHeaders: Headers,
	remixContext: EntryContext,
) {
	let callbackName = isbot(request.headers.get("user-agent"))
		? "onAllReady"
		: "onShellReady";

	return new Promise((resolve, reject) => {
		let didError = false;

		let { pipe, abort } = renderToPipeableStream(
			<RemixServer context={remixContext} url={request.url} />,
			{
				[callbackName]: () => {
					let body = new PassThrough();

					responseHeaders.set("Content-Type", "text/html");

					cors(
						request,
						new Response(body, {
							headers: responseHeaders,
							status: didError ? 500 : responseStatusCode,
						}),
					).then((response) => {
						resolve(response);
					});

					pipe(body);
				},
				onShellError: (err: unknown) => {
					reject(err);
				},
				onError: (error: unknown) => {
					didError = true;

					console.error(error);
				},
			},
		);

		setTimeout(abort, ABORT_DELAY);
	});
}

export let handleDataRequest: HandleDataRequestFunction = async (
	response,
	{ request },
) => {
	return await cors(request, response);
};

Options

Additionally, the cors function accepts a options object as a third optional argument. These are the options.

  • origin: Configures the Access-Control-Allow-Origin CORS header. Possible values are:
    • true: Enable CORS for any origin (same as "*")
    • false: Don't setup CORS
    • string: Set to a specific origin, if set to "*" it will allow any origin
    • RegExp: Set to a RegExp to match against the origin
    • Array<string | RegExp>: Set to an array of origins to match against the string or RegExp
    • Function: Set to a function that will be called with the request origin and should return a boolean indicating if the origin is allowed or not. The default value is true.
  • methods: Configures the Access-Control-Allow-Methods CORS header. The default value is ["GET", "HEAD", "PUT", "PATCH", "POST", "DELETE"].
  • allowedHeaders: Configures the Access-Control-Allow-Headers CORS header.
  • exposedHeaders: Configures the Access-Control-Expose-Headers CORS header.
  • credentials: Configures the Access-Control-Allow-Credentials CORS header.
  • maxAge: Configures the Access-Control-Max-Age CORS header.

CSRF

Note This depends on react, crypto-js, and a Remix server runtime.

The CSRF related functions let you implement CSRF protection on your application.

This part of Remix Utils needs React and server-side code.

First create a new CSRF instance.

// app/utils/csrf.server.ts
import { CSRF } from "remix-utils/csrf/server";
import { createCookie } from "@remix-run/node"; // or cloudflare/deno

export const cookie = createCookie("csrf", {
	path: "/",
	httpOnly: true,
	secure: process.env.NODE_ENV === "production",
	sameSite: "lax",
	secrets: ["s3cr3t"],
});

export const csrf = new CSRF({
	cookie,
	// what key in FormData objects will be used for the token, defaults to `csrf`
	formDataKey: "csrf",
	// an optional secret used to sign the token, recommended for extra safety
	secret: "s3cr3t",
});

Then you can use csrf to generate a new token.

import { csrf } from "~/utils/csrf.server";

export async function loader({ request }: LoaderFunctionArgs) {
	let token = csrf.generate();
}

You can customize the token size by passing the byte size, the default one is 32 bytes which will give you a string with a length of 43 after encoding.

let token = csrf.generate(64); // customize token length

You will need to save this token in a cookie and also return it from the loader. For convenience, you can use the CSRF#commitToken helper.

import { csrf } from "~/utils/csrf.server";

export async function loader({ request }: LoaderFunctionArgs) {
	let [token, cookieHeader] = await csrf.commitToken();
	return json({ token }, { headers: { "set-cookie": cookieHeader } });
}

Note You could do this on any route, but I recommend you to do it on the root loader.

Now that you returned the token and set it in a cookie, you can use the AuthenticityTokenProvider component to provide the token to your React components.

import { AuthenticityTokenProvider } from "remix-utils/csrf/react";

let { csrf } = useLoaderData<LoaderData>();
return (
	<AuthenticityTokenProvider token={csrf}>
		<Outlet />
	</AuthenticityTokenProvider>
);

Render it in your root component and wrap the Outlet with it.

When you create a form in some route, you can use the AuthenticityTokenInput component to add the authenticity token to the form.

import { Form } from "@remix-run/react";
import { AuthenticityTokenInput } from "remix-utils/csrf/react";

export default function Component() {
	return (
		<Form method="post">
			<AuthenticityTokenInput />
			<input type="text" name="something" />
		</Form>
	);
}

Note that the authenticity token is only really needed for a form that mutates the data somehow. If you have a search form making a GET request, you don't need to add the authenticity token there.

This AuthenticityTokenInput will get the authenticity token from the AuthenticityTokenProvider component and add it to the form as the value of a hidden input with the name csrf. You can customize the field name using the name prop.

<AuthenticityTokenInput name="customName" />

You should only customize the name if you also changed it on createAuthenticityToken.

If you need to use useFetcher (or useSubmit) instead of Form you can also get the authenticity token with the useAuthenticityToken hook.

import { useFetcher } from "@remix-run/react";
import { useAuthenticityToken } from "remix-utils/csrf/react";

export function useMarkAsRead() {
	let fetcher = useFetcher();
	let csrf = useAuthenticityToken();
	return function submit(data) {
		fetcher.submit(
			{ csrf, ...data },
			{ action: "/api/mark-as-read", method: "post" },
		);
	};
}

Finally, you need to validate the authenticity token in the action that received the request.

import { CSRFError } from "remix-utils/csrf/server";
import { redirectBack } from "remix-utils/redirect-back";
import { csrf } from "~/utils/csrf.server";

export async function action({ request }: ActionFunctionArgs) {
	try {
		await csrf.validate(request);
	} catch (error) {
		if (error instanceof CSRFError) {
			// handle CSRF errors
		}
		// handle other possible errors
	}

	// here you know the request is valid
	return redirectBack(request, { fallback: "/fallback" });
}

If you need to parse the body as FormData yourself (e.g. to support file uploads) you can also call CSRF#validate with the FormData and Headers objects.

let formData = await parseMultiPartFormData(request);
try {
	await csrf.validate(formData, request.headers);
} catch (error) {
	// handle errors
}

Warning If you call CSRF#validate with the request instance, but you already read its body, it will throw an error.

In case the CSRF validation fails, it will throw a CSRFError which can be used to correctly identify it against other possible errors that may get thrown.

The list of possible error messages are:

  • missing_token_in_cookie: The request is missing the CSRF token in the cookie.
  • invalid_token_in_cookie: The CSRF token is not valid (is not a string).
  • tampered_token_in_cookie: The CSRF token doesn't match the signature.
  • missing_token_in_body: The request is missing the CSRF token in the body (FormData).
  • mismatched_token: The CSRF token in the cookie and the body don't match.

You can use error.code to check one of the error codes above, and error.message to get a human friendly description.

Warning Don't send those error messages to the end-user, they are meant to be used for debugging purposes only.

Existing Search Params

import { ExistingSearchParams } from "remix-utils/existing-search-params";

Note This depends on react and @remix-run/react

When you submit a GET form, the browser will replace all of the search params in the URL with your form data. This component copies existing search params into hidden inputs so they will not be overwritten.

The exclude prop accepts an array of search params to exclude from the hidden inputs

  • add params handled by this form to this list
  • add params from other forms you want to clear on submit

For example, imagine a table of data with separate form components for pagination and filtering and searching. Changing the page number should not affect the search or filter params.

<Form>
	<ExistingSearchParams exclude={["page"]} />
	<button type="submit" name="page" value="1">
		1
	</button>
	<button type="submit" name="page" value="2">
		2
	</button>
	<button type="submit" name="page" value="3">
		3
	</button>
</Form>

By excluding the page param, from the search form, the user will return to the first page of search result.

<Form>
	<ExistingSearchParams exclude={["q", "page"]} />
	<input type="search" name="q" />
	<button type="submit">Search</button>
</Form>

External Scripts

Note This depends on react, @remix-run/react, and a Remix server runtime.

If you need to load different external scripts on certain routes, you can use the ExternalScripts component together with the ExternalScriptsFunction and ScriptDescriptor types.

In the route you want to load the script add a handle export with a scripts method, type the handle to be ExternalScriptsHandle. This interface is let's you define scripts as either a function or an array.

If you want to define what scripts to load based on the loader data, you can use scripts as a function:

import { ExternalScriptsHandle } from "remix-utils/external-scripts";

type LoaderData = SerializeFrom<typeof loader>;

export let handle: ExternalScriptsHandle<LoaderData> = {
  scripts({ id, data, params, matches, location, parentsData }) {
    return [
      {
        src: "https://unpkg.com/[email protected]",
        integrity: "sha384-FhXw7b6AlE/jyjlZH5iHa/tTe9EpJ1Y55RjcgPbjeWMskSxZt1v9qkxLJWNJaGni",
        crossOrigin: 'anonymous"
      }
    ];
  },
};

If the list of scripts to load is static you can define scripts as an array directly.

import { ExternalScriptsHandle } from "remix-utils/external-scripts";

export let handle: ExternalScriptsHandle = {
  scripts: [
    {
      src: "https://unpkg.com/[email protected]",
      integrity: "sha384-FhXw7b6AlE/jyjlZH5iHa/tTe9EpJ1Y55RjcgPbjeWMskSxZt1v9qkxLJWNJaGni",
      crossOrigin: 'anonymous",
      preload: true, // use it to render a <link rel="preload"> for this script
    }
  ],
};

You can also import ExternalScriptsFunction and ScriptDescriptor interfaces yourself to build a custom handle type.

import {
	ExternalScriptsFunction,
	ScriptDescriptor,
} from "remix-utils/external-scripts";

interface AppHandle<LoaderData = unknown> {
	scripts?: ExternalScriptsFunction<LoaderData> | ScriptDescriptor[];
}

export let handle: AppHandle<LoaderData> = {
	scripts, // define scripts as a function or array here
};

Or you can extend the ExternalScriptsHandle interface.

import { ExternalScriptsHandle } from "remix-utils/external-scripts";

interface AppHandle<LoaderData = unknown>
	extends ExternalScriptsHandle<LoaderData> {
	// more handle properties here
}

export let handle: AppHandle<LoaderData> = {
	scripts, // define scripts as a function or array here
};

Then, in the root route, add the ExternalScripts component somewhere, usually you want to load it either inside <head> or at the bottom of <body>, either before or after the Remix's <Scripts> component.

Where exactly to place <ExternalScripts /> will depend on your app, but a safe place is at the end of <body>.

import { Links, LiveReload, Meta, Scripts, ScrollRestoration } from "remix";
import { ExternalScripts } from "remix-utils/external-scripts";

type Props = { children: React.ReactNode; title?: string };

export function Document({ children, title }: Props) {
	return (
		<html lang="en">
			<head>
				<meta charSet="utf-8" />
				<meta name="viewport" content="width=device-width,initial-scale=1" />
				{title ? <title>{title}</title> : null}
				<Meta />
				<Links />
			</head>
			<body>
				{children}
				<ScrollRestoration />
				<ExternalScripts />
				<Scripts />
				<LiveReload />
			</body>
		</html>
	);
}

Now, any script you defined in the ScriptsFunction will be added to the HTML.

You could use this util together with useShouldHydrate to disable Remix scripts in certain routes but still load scripts for analytics or small features that need JS but don't need the full app JS to be enabled.

let shouldHydrate = useShouldHydrate();

return (
	<html lang="en">
		<head>
			<meta charSet="utf-8" />
			<meta name="viewport" content="width=device-width,initial-scale=1" />
			{title ? <title>{title}</title> : null}
			<Meta />
			<Links />
		</head>
		<body>
			{children}
			<ScrollRestoration />
			{shouldHydrate ? <Scripts /> : <ExternalScripts />}
			<LiveReload />
		</body>
	</html>
);

useGlobalNavigationState

Note This depends on react, and @remix-run/react.

This hook allows you to read the value of transition.state, every fetcher.state in the app, and revalidator.state.

import { useGlobalNavigationState } from "remix-utils/use-global-navigation-state";

export function GlobalPendingUI() {
	let states = useGlobalNavigationState();

	if (state.includes("loading")) {
		// The app is loading.
	}

	if (state.includes("submitting")) {
		// The app is submitting.
	}

	// The app is idle
}

The return value of useGlobalNavigationState can be "idle", "loading" or "submitting"

Note This is used by the hooks below to determine if the app is loading, submitting or both (pending).

useGlobalPendingState

Note This depends on react, and @remix-run/react.

This hook lets you know if the global navigation, if one of any active fetchers is either loading or submitting, or if the revalidator is running.

import { useGlobalPendingState } from "remix-utils/use-global-navigation-state";

export function GlobalPendingUI() {
  let globalState = useGlobalPendingState();

  if (globalState === "idle") return null;
  return <Spinner />;
}

The return value of useGlobalPendingState is either "idle" or "pending".

Note: This hook combines the useGlobalSubmittingState and useGlobalLoadingState hooks to determine if the app is pending.

Note: The pending state is a combination of the loading and submitting states introduced by this hook.

useGlobalSubmittingState

Note This depends on react, and @remix-run/react.

This hook lets you know if the global transition or if one of any active fetchers is submitting.

import { useGlobalSubmittingState } from "remix-utils/use-global-navigation-state";

export function GlobalPendingUI() {
  let globalState = useGlobalSubmittingState();

  if (globalState === "idle") return null;
  return <Spinner />;
}

The return value of useGlobalSubmittingState is either "idle" or "submitting".

useGlobalLoadingState

Note This depends on react, and @remix-run/react.

This hook lets you know if the global transition, if one of any active fetchers is loading, or if the revalidator is running

import { useGlobalLoadingState } from "remix-utils/use-global-navigation-state";

export function GlobalPendingUI() {
  let globalState = useGlobalLoadingState();

  if (globalState === "idle") return null;
  return <Spinner />;
}

The return value of useGlobalLoadingState is either "idle" or "loading".

useHydrated

Note This depends on react.

This hook lets you detect if your component is already hydrated. This means the JS for the element loaded client-side and React is running.

With useHydrated, you can render different things on the server and client while ensuring the hydration will not have a mismatched HTML.

import { useHydrated } from "remix-utils/use-hydrated";

export function Component() {
  let isHydrated = useHydrated();

  if (isHydrated) {
    return <ClientOnlyComponent />;
  }

  return <ServerFallback />;
}

When doing SSR, the value of isHydrated will always be false. The first client-side render isHydrated will still be false, and then it will change to true.

After the first client-side render, future components rendered calling this hook will receive true as the value of isHydrated. This way, your server fallback UI will never be rendered on a route transition.

useLocales

Note This depends on react.

This hook lets you get the locales returned by the root loader. It follows a simple convention, your root loader return value should be an objet with the key locales.

You can combine it with getClientLocal to get the locales on the root loader and return that. The return value of useLocales is a Locales type which is string | string[] | undefined.

import { useLocales } from "remix-utils/locales/react";
import { getClientLocales } from "remix-utils/locales/server";

// in the root loader
export async function loader({ request }: LoaderFunctionArgs) {
  let locales = getClientLocales(request);
  return json({ locales });
}

// in any route (including root!)
export default function Component() {
  let locales = useLocales();
  let date = new Date();
  let dateTime = date.toISOString;
  let formattedDate = date.toLocaleDateString(locales, options);
  return <time dateTime={dateTime}>{formattedDate}</time>;
}

The return type of useLocales is ready to be used with the Intl API.

useShouldHydrate

Note This depends on @remix-run/react and react.

If you are building a Remix application where most routes are static, and you want to avoid loading client-side JS, you can use this hook, plus some conventions, to detect if one or more active routes needs JS and only render the Scripts component in that case.

In your document component, you can call this hook to dynamically render the Scripts component if needed.

import type { ReactNode } from "react";
import { Links, LiveReload, Meta, Scripts } from "@remix-run/react";
import { useShouldHydrate } from "remix-utils/use-should-hydrate";

interface DocumentProps {
	children: ReactNode;
	title?: string;
}

export function Document({ children, title }: DocumentProps) {
	let shouldHydrate = useShouldHydrate();
	return (
		<html lang="en">
			<head>
				<meta charSet="utf-8" />
				<link rel="icon" href="/favicon.png" type="image/png" />
				{title ? <title>{title}</title> : null}
				<Meta />
				<Links />
			</head>
			<body>
				{children}
				{shouldHydrate && <Scripts />}
				<LiveReload />
			</body>
		</html>
	);
}

Now, you can export a handle object with the hydrate property as true in any route module.

export let handle = { hydrate: true };

This will mark the route as requiring JS hydration.

In some cases, a route may need JS based on the data the loader returned. For example, if you have a component to purchase a product, but only authenticated users can see it, you don't need JS until the user is authenticated. In that case, you can make hydrate be a function receiving your loader data.

export let handle = {
	hydrate(data: LoaderData) {
		return data.user.isAuthenticated;
	},
};

The useShouldHydrate hook will detect hydrate as a function and call it using the route data.

getClientIPAddress

Note This depends on is-ip.

This function receives a Request or Headers objects and will try to get the IP address of the client (the user) who originated the request.

import { getClientIPAddress } from "remix-utils/get-client-ip-address";

export async function loader({ request }: LoaderFunctionArgs) {
	// using the request
	let ipAddress = getClientIPAddress(request);
	// or using the headers
	let ipAddress = getClientIPAddress(request.headers);
}

If it can't find he IP address the return value will be null. Remember to check if it was able to find it before using it.

The function uses the following list of headers, in order of preference:

  • X-Client-IP
  • X-Forwarded-For
  • HTTP-X-Forwarded-For
  • Fly-Client-IP
  • CF-Connecting-IP
  • Fastly-Client-Ip
  • True-Client-Ip
  • X-Real-IP
  • X-Cluster-Client-IP
  • X-Forwarded
  • Forwarded-For
  • Forwarded
  • DO-Connecting-IP
  • oxygen-buyer-ip

When a header is found that contains a valid IP address, it will return without checking the other headers.

Warning On local development the function is most likely to return null. This is because the browser doesn't send any of the above headers, if you want to simulate those headers you will need to either add it to the request Remix receives in your HTTP server or run a reverse proxy like NGINX that can add them for you.

getClientLocales

Note This depends on intl-parse-accept-language.

This function let you get the locales of the client (the user) who originated the request.

import { getClientLocales } from "remix-utils/locales/server";

export async function loader({ request }: LoaderFunctionArgs) {
	// using the request
	let locales = getClientLocales(request);
	// or using the headers
	let locales = getClientLocales(request.headers);
}

The return value is a Locales type, which is string | string[] | undefined.

The returned locales can be directly used on the Intl API when formatting dates, numbers, etc.

import { getClientLocales } from "remix-utils/locales/server";
export async function loader({ request }: LoaderFunctionArgs) {
	let locales = getClientLocales(request);
	let nowDate = new Date();
	let formatter = new Intl.DateTimeFormat(locales, {
		year: "numeric",
		month: "long",
		day: "numeric",
	});
	return json({ now: formatter.format(nowDate) });
}

The value could also be returned by the loader and used on the UI to ensure the user's locales is used on both server and client formatted dates.

isPrefetch

This function let you identify if a request was created because of a prefetch triggered by using <Link prefetch="intent"> or <Link prefetch="render">.

This will let you implement a short cache only for prefetch requests so you avoid the double data request.

import { isPrefetch } from "remix-utils/is-prefetch";

export async function loader({ request }: LoaderFunctionArgs) {
	let data = await getData(request);
	let headers = new Headers();

	if (isPrefetch(request)) {
		headers.set("Cache-Control", "private, max-age=5, smax-age=0");
	}

	return json(data, { headers });
}

Responses

Redirect Back

This function is a wrapper of the redirect helper from Remix. Unlike Remix's version, this one receives the whole request object as the first value and an object with the response init and a fallback URL.

The response created with this function will have the Location header pointing to the Referer header from the request, or if not available, the fallback URL provided in the second argument.

import { redirectBack } from "remix-utils/redirect-back";

export async function action({ request }: ActionFunctionArgs) {
	throw redirectBack(request, { fallback: "/" });
}

This helper is most useful when used in a generic action to send the user to the same URL it was before.

Not Modified

Helper function to create a Not Modified (304) response without a body and any header.

import { notModified } from "remix-utils/responses";

export async function loader({ request }: LoaderFunctionArgs) {
	return notModified();
}

JavaScript

Helper function to create a JavaScript file response with any header.

This is useful to create JS files based on data inside a Resource Route.

import { javascript } from "remix-utils/responses";

export async function loader({ request }: LoaderFunctionArgs) {
	return javascript("console.log('Hello World')");
}

Stylesheet

Helper function to create a CSS file response with any header.

This is useful to create CSS files based on data inside a Resource Route.

import { stylesheet } from "remix-utils/responses";

export async function loader({ request }: LoaderFunctionArgs) {
	return stylesheet("body { color: red; }");
}

PDF

Helper function to create a PDF file response with any header.

This is useful to create PDF files based on data inside a Resource Route.

import { pdf } from "remix-utils/responses";

export async function loader({ request }: LoaderFunctionArgs) {
	return pdf(await generatePDF(request.formData()));
}

HTML

Helper function to create a HTML file response with any header.

This is useful to create HTML files based on data inside a Resource Route.

import { html } from "remix-utils/responses";

export async function loader({ request }: LoaderFunctionArgs) {
	return html("<h1>Hello World</h1>");
}

XML

Helper function to create a XML file response with any header.

This is useful to create XML files based on data inside a Resource Route.

import { xml } from "remix-utils/responses";

export async function loader({ request }: LoaderFunctionArgs) {
	return xml("<?xml version='1.0'?><catalog></catalog>");
}

Plain Text

Helper function to create a TXT file response with any header.

This is useful to create TXT files based on data inside a Resource Route.

import { txt } from "remix-utils/responses";

export async function loader({ request }: LoaderFunctionArgs) {
	return txt(`
    User-agent: *
    Allow: /
  `);
}

Typed Cookies

Note This depends on zod, and a Remix server runtime.

Cookie objects in Remix allows any type, the typed cookies from Remix Utils lets you use Zod to parse the cookie values and ensure they conform to a schema.

import { createCookie } from "@remix-run/node";
import { createTypedCookie } from "remix-utils/typed-cookie";
import { z } from "zod";

let cookie = createCookie("returnTo", cookieOptions);
// I recommend you to always add `nullable` to your schema, if a cookie didn't
// come with the request Cookie header Remix will return null, and it can be
// useful to remove it later when clearing the cookie
let schema = z.string().url().nullable();

// pass the cookie and the schema
let typedCookie = createTypedCookie({ cookie, schema });

// this will be a string and also a URL
let returnTo = await typedCookie.parse(request.headers.get("Cookie"));

// this will not pass the schema validation and throw a ZodError
await typedCookie.serialize("a random string that's not a URL");
// this will make TS yell because it's not a string, if you ignore it it will
// throw a ZodError
await typedCookie.serialize(123);

You could also use typed cookies with any sessionStorage mechanism from Remix.

let cookie = createCookie("session", cookieOptions);
let schema = z.object({ token: z.string() }).nullable();

let sessionStorage = createCookieSessionStorage({
	cookie: createTypedCookie({ cookie, schema }),
});

// if this works then the correct data is stored in the session
let session = sessionStorage.getSession(request.headers.get("Cookie"));

session.unset("token"); // remove a required key from the session

// this will throw a ZodError because the session is missing the required key
await sessionStorage.commitSession(session);

Now Zod will ensure the data you try to save to the session is valid removing any extra field and throwing if you don't set the correct data in the session.

Note The session object is not really typed so doing session.get will not return the correct type, you can do schema.parse(session.data) to get the typed version of the session data.

You can also use async refinements in your schemas because typed cookies uses parseAsync method from Zod.

let cookie = createCookie("session", cookieOptions);

let schema = z
	.object({
		token: z.string().refine(async (token) => {
			let user = await getUserByToken(token);
			return user !== null;
		}, "INVALID_TOKEN"),
	})
	.nullable();

let sessionTypedCookie = createTypedCookie({ cookie, schema });

// this will throw if the token stored in the cookie is not valid anymore
sessionTypedCookie.parse(request.headers.get("Cookie"));

Finally, to be able to delete a cookie, you can add .nullable() to your schema and serialize it with null as value.

// Set the value as null and expires as current date - 1 second so the browser expires the cookie
await typedCookie.serialize(null, { expires: new Date(Date.now() - 1) });

If you didn't add .nullable() to your schema, you will need to provide a mock value and set the expires date to the past.

let cookie = createCookie("returnTo", cookieOptions);
let schema = z.string().url().nullable();

let typedCookie = createTypedCookie({ cookie, schema });

await typedCookie.serialize("some fake url to pass schema validation", {
	expires: new Date(Date.now() - 1),
});

Typed Sessions

Note This depends on zod, and a Remix server runtime.

Session objects in Remix allows any type, the typed sessions from Remix Utils lets you use Zod to parse the session data and ensure they conform to a schema.

import { createCookieSessionStorage } from "@remix-run/node";
import { createTypedSessionStorage } from "remix-utils/typed-session";
import { z } from "zod";

let schema = z.object({
	token: z.string().optional(),
	count: z.number().default(1),
});

// you can use a Remix's Cookie container or a Remix Utils' Typed Cookie container
let sessionStorage = createCookieSessionStorage({ cookie });

// pass the session storage and the schema
let typedSessionStorage = createTypedSessionStorage({ sessionStorage, schema });

Now you can use typedSessionStorage as a drop-in replacement for your normal sessionStorage.

let session = typedSessionStorage.getSession(request.headers.get("Cookie"));

session.get("token"); // this will be a string or undefined
session.get("count"); // this will be a number
session.get("random"); // this will make TS yell because it's not in the schema

session.has("token"); // this will be a boolean
session.has("count"); // this will be a boolean

// this will make TS yell because it's not a string, if you ignore it it will
// throw a ZodError
session.set("token", 123);

Now Zod will ensure the data you try to save to the session is valid by not allowing you to get, set or unset data.

Note Remember that you either need to mark fields as optional or set a default value in the schema, otherwise it will be impossible to call getSession to get a new session object.

You can also use async refinements in your schemas because typed sesions uses parseAsync method from Zod.

let schema = z.object({
	token: z
		.string()
		.optional()
		.refine(async (token) => {
			if (!token) return true; // handle optionallity
			let user = await getUserByToken(token);
			return user !== null;
		}, "INVALID_TOKEN"),
});

let typedSessionStorage = createTypedSessionStorage({ sessionStorage, schema });

// this will throw if the token stored in the session is not valid anymore
typedSessionStorage.getSession(request.headers.get("Cookie"));

Server-Sent Events

Note This depends on react.

Server-Sent Events are a way to send data from the server to the client without the need for the client to request it. This is useful for things like chat applications, live updates, and more.

There are two utils provided to help with the usage inside Remix:

  • eventStream
  • useEventSource

The eventStream function is used to create a new event stream response needed to send events to the client. This must live in a Resource Route.

// app/routes/sse.time.ts
import { eventStream } from "remix-utils/sse/server";
import { interval } from "remix-utils/timers";

export async function loader({ request }: LoaderFunctionArgs) {
	return eventStream(request.signal, function setup(send) {
		async function run() {
			for await (let _ of interval(1000, { signal: request.signal })) {
				send({ event: "time", data: new Date().toISOString() });
			}
		}

		run();
	});
}

Then, inside any component, you can use the useEventSource hook to connect to the event stream.

// app/components/counter.ts
import { useEventSource } from "remix-utils/sse/react";

function Counter() {
	// Here `/sse/time` is the resource route returning an eventStream response
	let time = useEventSource("/sse/time", { event: "time" });

	if (!time) return null;

	return (
		<time dateTime={time}>
			{new Date(time).toLocaleTimeString("en", {
				minute: "2-digit",
				second: "2-digit",
				hour: "2-digit",
			})}
		</time>
	);
}

The event name in both the event stream and the hook is optional, in which case it will default to message, if defined you must use the same event name in both sides, this also allows you to emit different events from the same event stream.

For Server-Sent Events to work, your server must support HTTP streaming. If you don't get SSE to work check if your deployment platform has support for it.

Because SSE count towards the limit of HTTP connections per domain, the useEventSource hook keeps a global map of connections based on the provided URL and options. As long as they are the same, the hook will open a single SSE connection and share it between instances of the hook.

Once there are no more instances of the hook re-using a connection, it will be closed and removed from the map.

You can use the <EventSourceProvider /> component to control the map.

let map: EventSourceMap = new Map();
return (
	<EventSourceProvider value={map}>
		<YourAppOrPartOfIt />
	</EventSourceProvider>
);

This way, you can overwrite the map with a new one for a specific part of your app. Note that this provider is optional and a default map will be used if you don't provide one.

Rolling Cookies

Note This depends on zod, and a Remix server runtime.

Rolling cookies allows you to prolong the expiration of a cookie by updating the expiration date of every cookie.

The rollingCookie function is prepared to be used in entry.server exported function to update the expiration date of a cookie if no loader set it.

For document request you can use it on the handleRequest function:

import { rollingCookie } from "remix-utils/rolling-cookie";

import { sessionCookie } from "~/session.server";

export default function handleRequest(
	request: Request,
	responseStatusCode: number,
	responseHeaders: Headers,
	remixContext: EntryContext,
) {
	await rollingCookie(sessionCookie, request, responseHeaders);

	return isbot(request.headers.get("user-agent"))
		? handleBotRequest(
				request,
				responseStatusCode,
				responseHeaders,
				remixContext,
		  )
		: handleBrowserRequest(
				request,
				responseStatusCode,
				responseHeaders,
				remixContext,
		  );
}

And for data request you can do it on the handleDataRequest function:

import { rollingCookie } from "remix-utils/rolling-cookie";

export let handleDataRequest: HandleDataRequestFunction = async (
	response: Response,
	{ request },
) => {
	let cookieValue = await sessionCookie.parse(
		responseHeaders.get("set-cookie"),
	);
	if (!cookieValue) {
		cookieValue = await sessionCookie.parse(request.headers.get("cookie"));
		responseHeaders.append(
			"Set-Cookie",
			await sessionCookie.serialize(cookieValue),
		);
	}

	return response;
};

Note > Read more about rolling cookies in Remix.

Named actions

Note This depends on a Remix server runtime.

It's common to need to handle more than one action in the same route, there are many options here like sending the form to a resource route or using an action reducer, the namedAction function uses some conventions to implement the action reducer pattern.

import { namedAction } from "remix-utils/named-action";

export async function action({ request }: ActionFunctionArgs) {
	return namedAction(request, {
		async create() {
			// do create
		},
		async update() {
			// do update
		},
		async delete() {
			// do delete
		},
	});
}

export default function Component() {
	return (
		<>
			<Form method="post" action="?/create">
				...
			</Form>

			<Form method="post" action="?/update">
				...
			</Form>

			<Form method="post" action="?/delete">
				...
			</Form>
		</>
	);
}

This function can follow many conventions

You can pass a FormData object to the namedAction, then it will try to

  • Find a field named /something and use it as the action name removing the /
  • Find a field named intent and use the value as the action name
  • Find a field named action and use the value as the action name
  • Find a field named _action and use the value as the action name

You can pass an URLSearchParams object to the namedAction, then it will try to

  • Find a query parameter named /something and use it as the action name removing the /
  • Find a query parameter named intent and use the value as the action name
  • Find a query parameter named action and use the value as the action name
  • Find a query parameter named _action and use the value as the action name

You can pass an URL object to the namedAction, it will behave as with a URLSearchParams object.

You can pass a Request object to the namedAction, then it will try to

  • Call new URL(request.url) and use it as the URL object
  • Call request.formData() and use it as the FormData object

If, in any case, the action name is not found, the actionName then the library will try to call an action named default, similar to a switch in JavaScript.

If the default is not defined it will throw a ReferenceError with the message Action "${name}" not found.

If the library couldn't found the name at all, it will throw a ReferenceError with the message Action name not found

Preload Route Assets

Caution

This can potentialy create big Link header and can cause extremely hard to debug issues. Some provider's load balancers have set certain buffer for parsing outgoing response's headers and thanks to preloadRouteAssets you can easily reach that in a medium sized application. Your load balancer can randomly stop responding or start throwing 502 error. To overcome this either don't use preloadRouteAssets, set bigger buffer for processing response headers if you own the loadbalancer or use the experimentalMinChunkSize option in Vite config (this does not solve the issue permanently, only delays it)

The Link header allows responses to push to the browser assets that are needed for the document, this is useful to improve the performance of the application by sending those assets earlier.

Once Early Hints is supported this will also allows you to send the assets even before the document is ready, but for now you can benefit to send assets to preload before the browser parse the HTML.

You can do this with the functions preloadRouteAssets, preloadLinkedAssets and preloadModuleAssets.

All functions follows the same signature:

import { preloadRouteAssets, preloadLinkedAssets, preloadModuleAssets } from "remix-utils/preload-route-assets";

// entry.server.tsx
export default function handleRequest(
  request: Request,
  statusCode: number,
  headers: Headers,
  context: EntryContext,
) {
  let markup = renderToString(
    <RemixServer context={context} url={request.url} />,
  );
  headers.set("Content-Type", "text/html");

  preloadRouteAssets(context, headers); // add this line
  // preloadLinkedAssets(context, headers);
  // preloadModuleAssets(context, headers);

  return new Response("<!DOCTYPE html>" + markup, {
    status: statusCode,
    headers: headers,
  });
}

The preloadRouteAssets is a combination of both preloadLinkedAssets and preloadModuleAssets so you can use it to preload all assets for a route, if you use this one you don't need the other two

The preloadLinkedAssets function will preload any link with rel: "preload" added with the Remix's LinkFunction, so you can configure assets to preload in your route and send them in the headers automatically. It will additionally preload any linked stylesheet file (with rel: "stylesheet") even if not preloaded so it will load faster.

The preloadModuleAssets function will preload all the JS files Remix adds to the page when hydrating it, Remix already renders a <link rel="modulepreload"> for each now before the <script type="module"> used to start the application, this will use Link headers to preload those assets.

Safe Redirects

When performing a redirect, if the URL is user provided we can't trust it, if you do you're opening a vulnerability to phishing scam by allowing bad actors to redirect the user to malicious websites.

https://remix.utills/?redirectTo=https://malicious.app

To help you prevent this Remix Utils gives you a safeRedirect function which can be used to check if the URL is "safe".

Note In this context, safe means the URL starts with / but not //, this means the URL is a pathname inside the same app and not an external link.

import { safeRedirect } from "remix-utils/safe-redirect";

export async function loader({ request }: LoaderFunctionArgs) {
	let { searchParams } = new URL(request.url);
	let redirectTo = searchParams.get("redirectTo");
	return redirect(safeRedirect(redirectTo, "/home"));
}

The second argumento of safeRedirect is the default redirect which by when not configured is /, this lets you tell safeRedirect where to redirect the user if the value is not safe.

JSON Hash Response

When returning a json from a loader function, you may need to get data from different DB queries or API requests, typically you would something like this

export async function loader({ params }: LoaderData) {
	let postId = z.string().parse(params.postId);
	let [post, comments] = await Promise.all([getPost(), getComments()]);
	return json({ post, comments });

	async function getPost() {
		/* … */
	}
	async function getComments() {
		/* … */
	}
}

The jsonHash function lets you define those functions directly in the json, reducing the need to create extra functions and variables.

import { jsonHash } from "remix-utils/json-hash";

export async function loader({ params }: LoaderData) {
	let postId = z.string().parse(params.postId);
	return jsonHash({
		async post() {
			// Implement me
		},
		async comments() {
			// Implement me
		},
	});
}

It also calls your functions using Promise.all so you can be sure the data is retrieved in parallel.

Additionally, you can pass non-async functions, values and promises.

import { jsonHash } from "remix-utils/json-hash";

export async function loader({ params }: LoaderData) {
	let postId = z.string().parse(params.postId);
	return jsonHash({
		postId, // value
		comments: getComments(), // Promise
		slug() {
			// Non-async function
			return postId.split("-").at(1); // get slug from postId param
		},
		async post() {
			// Async function
			return await getPost(postId);
		},
	});

	async function getComments() {
		/* … */
	}
}

The result of jsonHash is a TypedResponse and it's correctly typed so using it with typeof loader works flawlessly.

export default function Component() {
	// all correctly typed
	let { postId, comments, slug, post } = useLoaderData<typeof loader>();

	// more code…
}

Delegate Anchors to Remix

When using Remix, you can use the <Link> component to navigate between pages. However, if you have a <a href> that links to a page in your app, it will cause a full page refresh. This can be what you want, but sometimes you want to use client-side navigation here instead.

The useDelegatedAnchors hook lets you add client-side navigation to anchor tags in a portion of your app. This can be specially useful when working with dynamic content like HTML or Markdown from a CMS.

import { useDelegatedAnchors } from "remix-utils/use-delegated-anchors";

export async function loader() {
	let content = await fetchContentFromCMS();
	return json({ content });
}

export default function Component() {
	let { content } = useLoaderData<typeof loader>();

	let ref = useRef<HTMLDivElement>(null);
	useDelegatedAnchors(ref);

	return <article ref={ref} dangerouslySetInnerHTML={{ __html: content }} />;
}

Prefetch Anchors

If additionally you want to be able to prefetch your anchors you can use the PrefetchPageAnchors components.

This components wraps your content with anchors inside, it detects any hovered anchor to prefetch it, and it delegates them to Remix.

import { PrefetchPageAnchors } from "remix-utils/use-delegated-anchors";

export async function loader() {
	let content = await fetchContentFromCMS();
	return json({ content });
}

export default function Component() {
	let { content } = useLoaderData<typeof loader>();

	return (
		<PrefetchPageAnchors>
			<article ref={ref} dangerouslySetInnerHTML={{ __html: content }} />
		</PrefetchPageAnchors>
	);
}

Now you can see in your DevTools that when the user hovers an anchor it will prefetch it, and when the user clicks it will do a client-side navigation.

Debounced Fetcher and Submit

Note This depends on react, and @remix-run/react.

useDebounceFetcher and useDebounceSubmit are wrappers of useFetcher and useSubmit that add debounce support.

These hooks are based on @JacobParis' article.

import { useDebounceFetcher } from "remix-utils/use-debounce-fetcher";

export function Component({ data }) {
	let fetcher = useDebounceFetcher<Type>();

	function handleClick() {
		fetcher.submit(data, { debounceTimeout: 1000 });
	}

	return (
		<button type="button" onClick={handleClick}>
			Do Something
		</button>
	);
}

Usage with useDebounceSubmit is similar.

import { useDebounceSubmit } from "remix-utils/use-debounce-submit";

export function Component({ name }) {
	let submit = useDebounceSubmit();

	return (
		<input
			name={name}
			type="text"
			onChange={(event) => {
				submit(event.target.form, {
					navigate: false, // use a fetcher instead of a page navigation
					fetcherKey: name, // cancel any previous fetcher with the same key
					debounceTimeout: 1000,
				});
			}}
			onBlur={() => {
				submit(event.target.form, {
					navigate: false,
					fetcherKey: name,
					debounceTimeout: 0, // submit immediately, canceling any pending fetcher
				});
			}}
		/>
	);
}

Derive Fetcher Type

Note This depends on @remix-route/react.

Derive the value of the deprecated fetcher.type from the fetcher and navigation data.

import { getFetcherType } from "remix-utils/fetcher-type";

function Component() {
	let fetcher = useFetcher();
	let navigation = useNavigation();
	let fetcherType = getFetcherType(fetcher, navigation);
	useEffect(() => {
		if (fetcherType === "done") {
			// do something once the fetcher is done submitting the data
		}
	}, [fetcherType]);
}

You can also use the React Hook API which let's you avoid calling useNavigation.

import { useFetcherType } from "remix-utils/fetcher-type";

function Component() {
	let fetcher = useFetcher();
	let fetcherType = useFetcherType(fetcher);
	useEffect(() => {
		if (fetcherType === "done") {
			// do something once the fetcher is done submitting the data
		}
	}, [fetcherType]);
}

If you need to pass the fetcher type around, you can also import FetcherType type.

import { type FetcherType } from "remix-utils/fetcher-type";

function useCallbackOnDone(type: FetcherType, cb) {
	useEffect(() => {
		if (type === "done") cb();
	}, [type, cb]);
}

respondTo for Content Negotiation

If you're building a resource route and wants to send a different response based on what content type the client requested (e.g. send the same data as PDF or XML or JSON), you will need to implement content negotiation, this can be done with the respondTo header.

import { respondTo } from "remix-utils/respond-to";

export async function loader({ request }: LoaderFunctionArgs) {
  // do any work independent of the response type before respondTo
  let data = await getData(request);

  let headers = new Headers({ vary: "accept" });

  // Here we will decide how to respond to different content types
  return respondTo(request, {
    // The handler can be a subtype handler, in `text/html` html is the subtype
    html() {
      // We can call any function only really need to respond to this
      // content-type
      let body = ReactDOMServer.renderToString(<UI {...data} />);
      headers.append("content-type", "text/html");
      return new Response(body, { headers });
    },
    // It can also be a highly specific type
    async "application/rss+xml"() {
      // we can do more async work inside this code if needed
      let body = await generateRSSFeed(data);
      headers.append("content-type", "application/rss+xml");
      return new Response(body, { headers });
    },
    // Or a generic type
    async text() {
      // To respond to any text type, e.g. text/plain, text/csv, etc.
      let body = generatePlain(data);
      headers.append("content-type", "text/plain");
      return new Response(body, { headers });
    },
    // The default will be used if the accept header doesn't match any of the
    // other handlers
    default() {
      // Here we could have a default type of response, e.g. use json by
      // default, or we can return a 406 which means the server can't respond
      // with any of the requested content types
      return new Response("Not Acceptable", { status: 406 });
    },
  });
}

Now, the respondTo function will check the Accept header and call the correct handler, to know which one to call it will use the parseAcceptHeader function also exported from Remix Utils

import { parseAcceptHeader } from "remix-utils/parse-accept-header";

let parsed = parseAcceptHeader(
	"text/html, application/xhtml+xml, application/xml;q=0.9, image/webp, image/*, */*;q=0.8",
);

The result is an array with the type, subtype and extra params (e.g. the q value). The order will be the same encountered in the header, in the example aabove text/html will be the first, followed by application/xhtml+xml.

This means that the respondTo helper will prioritize any handler that match text/html, in our example above, that will be the html handler, but if we remove it then the text handler will be called instead.67

Form Honeypot

Note This depends on react and crypto-js.

Honeypot is a simple technique to prevent spam bots from submitting forms. It works by adding a hidden field to the form that bots will fill, but humans won't.

There's a pair of utils in Remix Utils to help you implement this.

First, create a honeypot.server.ts where you will instantiate and configure your Honeypot.

import { Honeypot } from "remix-utils/honeypot/server";

// Create a new Honeypot instance, the values here are the defaults, you can
// customize them
export const honeypot = new Honeypot({
	randomizeNameFieldName: false,
	nameFieldName: "name__confirm",
	validFromFieldName: "from__confirm", // null to disable it
	encryptionSeed: undefined, // Ideally it should be unique even between processes
});

Then, in your app/root loader, call honeypot.getInputProps() and return it.

// app/root.tsx
import { honeypot } from "~/honeypot.server";

export async function loader() {
	// more code here
	return json({ honeypotInputProps: honeypot.getInputProps() });
}

And in the app/root component render the HoneypotProvider component wrapping the rest of the UI.

import { HoneypotProvider } from "remix-utils/honeypot/react";

export default function Component() {
	// more code here
	return (
		// some JSX
		<HoneypotProvider {...honeypotInputProps}>
			<Outlet />
		</HoneypotProvider>
		// end that JSX
	);
}

Now, in every public form you want protect against spam (like a login form), render the HoneypotInputs component.

import { HoneypotInputs } from "remix-utils/honeypot/react";

function SomePublicForm() {
	return (
		<Form method="post">
			<HoneypotInputs label="Please leave this field blank" />
			{/* more inputs and some buttons */}
		</Form>
	);
}

Note The label value above is the default one, use it to allow the label to be localized, or remove it if you don't want to change it.

Finally, in the action the form submits to, you can call honeypot.check.

import { SpamError } from "remix-utils/honeypot/server";
import { honeypot } from "~/honeypot.server";

export async function action({ request }) {
	let formData = await request.formData();
	try {
		honeypot.check(formData);
	} catch (error) {
		if (error instanceof SpamError) {
			// handle spam requests here
		}
		// handle any other possible error here, e.g. re-throw since nothing else
		// should be thrown
	}
	// the rest of your action
}

Sec-Fetch Parsers

Note This depends on zod.

The Sec-Fetch headers include information about the request, e.g. where is the data going to be used, or if it was initiated by the user.

You can use the remix-utils/sec-fetch utils to parse those headers and get the information you need.

import {
	fetchDest,
	fetchMode,
	fetchSite,
	isUserInitiated,
} from "remix-utils/sec-fetch";

Sec-Fetch-Dest

The Sec-Fetch-Dest header indicates the destination of the request, e.g. document, image, script, etc.

If the value is empty it means it will be used by a fetch call, this means you can differentiate between a request made with and without JS by checking if it's document (no JS) or empty (JS enabled).

import { fetchDest } from "remix-utils/sec-fetch";

export async function action({ request }: ActionFunctionArgs) {
	let data = await getDataSomehow();

	// if the request was made with JS, we can just return json
	if (fetchDest(request) === "empty") return json(data);
	// otherwise we redirect to avoid a reload to trigger a new submission
	return redirect(destination);
}

Sec-Fetch-Mode

The Sec-Fetch-Mode header indicates how the request was initiated, e.g. if the value is navigate it was triggered by the user loading the page, if the value is no-cors it could be an image being loaded.

import { fetchMode } from "remix-utils/sec-fetch";

export async function loader({ request }: LoaderFunctionArgs) {
	let mode = fetchMode(request);
	// do something based on the mode value
}

Sec-Fetch-Site

The Sec-Fetch-Site header indicates where the request is being made, e.g. same-origin means the request is being made to the same domain, cross-site means the request is being made to a different domain.

import { fetchSite } from "remix-utils/sec-fetch";

export async function loader({ request }: LoaderFunctionArgs) {
	let site = fetchSite(request);
	// do something based on the site value
}

Sec-Fetch-User

The Sec-Fetch-User header indicates if the request was initiated by the user, this can be used to differentiate between a request made by the user and a request made by the browser, e.g. a request made by the browser to load an image.

import { isUserInitiated } from "remix-utils/sec-fetch";

export async function loader({ request }: LoaderFunctionArgs) {
	let userInitiated = isUserInitiated(request);
	// do something based on the userInitiated value
}

Timers

The timers utils gives you a way to wait a certain amount of time before doing something or to run some code every certain amount of time.

Using the wait combined with an AbortSignal we can cancel a timeout if the user navigates away from the page.

import { wait } from "remix-utils/timers";

export async function loader({ request }: LoaderFunctionArgs) {
	await wait(1000, { signal: request.signal });
	// do something after 1 second
}

Using the interval combined with eventStream we could send a value to the client every certain amount of time. And ensure the interval is cancelled if the connection is closed.

import { eventStream } from "remix-utils/sse/server";
import { interval } from "remix-utils/timers";

export async function loader({ request }: LoaderFunctionArgs) {
	return eventStream(request.signal, function setup(send) {
		async function run() {
			for await (let _ of interval(1000, { signal: request.signal })) {
				send({ event: "time", data: new Date().toISOString() });
			}
		}

		run();
	});
}

Author

License

  • MIT License

remix-utils's People

Contributors

acusti avatar aiji42 avatar albisseradrian avatar ananni13 avatar arjunyel avatar arnaudambro avatar bbonamin avatar brandonpittman avatar buildtheui avatar cjoecker avatar cliffordfajardo avatar dependabot[bot] avatar dev-afzalansari avatar dusty avatar dvnrsn avatar erij-maherzia-ben-brahim avatar fvaldes33 avatar g-rath avatar hakimlyon avatar jacobparis avatar jansedlon avatar joelazar avatar jorisberinger avatar kandros avatar nichtsam avatar piotrkulpinski avatar prodbygr avatar ruisaraiva19 avatar sergiodxa avatar tom-sherman avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

remix-utils's Issues

TypedSessionStorage break api

Describe the bug

the api of SessionStorage.getSession is the following:

image

but TypedSessionStorage doesn't behave the same, it throw exception if there is no session instead of creating one

Your Example Website or App

NA

Steps to Reproduce the Bug or Issue

this line throw exception if there is no session
image

Expected behavior

should create a session if not exist if the library suppose to be drop in replacement

Screenshots or Videos

No response

Platform

NA

Additional context

No response

ClientOnly Not Protecting Component

Describe the bug

Using https://github.com/rmariuzzo/react-new-window which calls window.document inside ClientOnly, like this:

<ClientOnly fallback={<div />}>
	{() => (
		<NewWindow>
			<h1>Hi 👋</h1>
		</NewWindow>
	)}
</ClientOnly>

gives the following error:

TypeError: Cannot read properties of null (reading 'document')
    at u2.value (https://node47yqlu-arbv--3000.local-credentialless.webcontainer.io/build/routes/index-GKHCLL4X.js:139:60)
    at u2.value (https://node47yqlu-arbv--3000.local-credentialless.webcontainer.io/build/routes/index-GKHCLL4X.js:124:50)
    at commitLifeCycles (https://node47yqlu-arbv--3000.local-credentialless.webcontainer.io/build/_shared/chunk-FBU2B6A6.js:15154:30)
    at commitLayoutEffects (https://node47yqlu-arbv--3000.local-credentialless.webcontainer.io/build/_shared/chunk-FBU2B6A6.js:16903:15)
    at HTMLUnknownElement.callCallback2 (https://node47yqlu-arbv--3000.local-credentialless.webcontainer.io/build/_shared/chunk-FBU2B6A6.js:3793:22)
    at Object.invokeGuardedCallbackDev (https://node47yqlu-arbv--3000.local-credentialless.webcontainer.io/build/_shared/chunk-FBU2B6A6.js:3818:24)
    at invokeGuardedCallback (https://node47yqlu-arbv--3000.local-credentialless.webcontainer.io/build/_shared/chunk-FBU2B6A6.js:3852:39)
    at commitRootImpl (https://node47yqlu-arbv--3000.local-credentialless.webcontainer.io/build/_shared/chunk-FBU2B6A6.js:16725:17)
    at unstable_runWithPriority (https://node47yqlu-arbv--3000.local-credentialless.webcontainer.io/build/_shared/chunk-FBU2B6A6.js:344:20)
    at runWithPriority$1 (https://node47yqlu-arbv--3000.local-credentialless.webcontainer.io/build/_shared/chunk-FBU2B6A6.js:8596:18)

A full repro is available at https://stackblitz.com/edit/node-47yqlu?file=app/routes/index.tsx You must click the button at the top right of StackBlitz to "Open in New Tab" to avoid the cross-origin frame error:
image

Your Example Website or App

https://stackblitz.com/edit/node-47yqlu?file=app/routes/index.tsx

Steps to Reproduce the Bug or Issue

  1. Install remix-utils and react-new-window
  2. Use react-new-window's NewWindow inside remix-utils' ClientOnly

Expected behavior

I expect a new window to open. It does seem to work when you start on another page and then go to that page. But when the page that has this code is the first render, it fails.

Screenshots or Videos

No response

Platform

  • OS: macOS
  • Browser: Chrome
  • Version: 108.0.5359.124

Additional context

No response

Cannot find module 'schema-dts' or its corresponding type declarations.

Describe the bug

Seems like schema-dts needs to be added as a peer or main dependency, as it's included in build/react/structured-data.d.ts.

Your Example Website or App

https://github.com/salper/remix-utils-issue

Steps to Reproduce the Bug or Issue

Checkout the repo, install dependencies and run ./node_modules/.bin/tsc

Expected behavior

No error raised.

Screenshots or Videos

No response

Platform

  • OS: macOS
  • Version: 2.9

Additional context

No response

`cors` function inside `entry.server.tsx` doesn't do anything

Describe the bug

As indicated in the documentation, I have made a global configuration for all loaders

export const handleDataRequest: HandleDataRequestFunction = async (
  response,
  { request }
) => {
  return await cors(request, response);
};

But the response will not get changed in any way.
image

Your Example Website or App

https://stackblitz.com/edit/node-9lk4se?file=app/entry.server.tsx

Steps to Reproduce the Bug or Issue

  1. go to devtools
  2. open network section
  3. select response with _data query param

Expected behavior

CORS headers with default values will be setup on each loader with the _data param

Screenshots or Videos

No response

Platform

  • OS: Ubuntu 22.04.1 LTS
  • Browser: Chrome
  • Version: 108.0.5359

Additional context

No response

Error when using useDataRefresh

Describe the bug

Refresh failed with an error when using useDataRefresh.
スクリーンショット 2022-04-13 午前0 53 04

The way this works is by sending a fetcher.submit to /dev/null to trigger all loaders to run..

Since the README states this, I solved the problem by creating app/routes/dev/null.ts and exporting it with an empty action defined.
Is this the right way to do it? Or is it originally expected to function without this route file present?
If it is necessary to create this file, it should be clearly stated in the README.

Your Example Website or App

https://github.com/aiji42/remix-utils-sample

Steps to Reproduce the Bug or Issue

Use useDataRefresh without app/routes/dev/null.ts.

Expected behavior

Refresh by useDataRefresh without app/routes/dev/null.ts. Alternatively, the README specifies that app/routes/dev/null.ts needs to be created.

Screenshots or Videos

No response

Platform

  • OS: macOS
  • Browser: Chrome
  • Version:
    • remix-utils: 3.0.0
    • remix: 1.3.5

Additional context

No response

Cannot find module '@remix-run/react/routeData' or its corresponding type declarations.

Describe the bug

When using "@remix-run/react": "1.7.5" with DynamicLinks from "remix-utils": "^4.2.1", I see the following error after running a build:

npm run build
Done in 320ms.
Building Remix app in production mode...
Built in 258ms
node_modules/remix-utils/build/react/handle-conventions.d.ts:1:32 - error TS2307: Cannot find module '@remix-run/react/routeData' or its corresponding type declarations.

1 import type { RouteData } from "@remix-run/react/routeData";
                                 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~


Found 1 error.

GitHub Repo

https://github.com/brick-hub/brickhub.dev/actions/runs/3562380363

Steps to Reproduce the Bug or Issue

  1. Run a remix.run app using remix >=1.7.5 and remix-utils 4.2.1 with DynamicLinks
  2. Do a build (npm run build)

Expected behavior

I expect the build to complete successfully with no errors.

Screenshots or Videos

No response

Platform

  • OS: macOS
  • Browser: Chrome

Additional context

No response

Allow accessing parent route data from structuredData()

Discussed in #79

Originally posted by machour May 12, 2022
Scenario:

On an automotive website, I'm displaying a car in a nested route, so I have the following setup:

occasion/$id.tsx // parent route, loading the car data
occasion/$id/index.tsx // index route, displaying the car data, this is where I want to export my schema
occasion/$id/quote.tsx // to ask for a quotation. I don't want the schema exported here

The current API don't allow me to do this.
A quick & dirty solution would be to provide all matches as a second parameters (useMatches()) to allow the user to get whatever data he needs. I'm sure you'll think of something better.

CORS Helpers

Discussed in #54

Originally posted by sergiodxa March 9, 2022
Setting CORS headers to allow using a Remix app as an API for another website is tedious and error prone.

Remix Utils could provide a simple helper to setup that.

cors(response)

Should set all the required headers on the Response object. It can work in two ways:

  1. Change the Response object directly so you can keep using it later.
  2. Return the Response so you can do return cors(json<LoaderData>(data)) to compose them.

Additionally, cors could receive a second object with options to configure it easily.

This helper, could also be used inside entry.server's handleDataRequest function to apply CORS to every data request or manually to each loader and action you want to enable CORS for.

client-only: require() is not supported

Describe the bug

Trying to get remix-utils client-only working in a cjs project and getting the following error:

image

Your Example Website or App

none

Steps to Reproduce the Bug or Issue

Create a new remix project.
Add remix-utils.
Convert to cjs - serverModuleFormat: "cjs" - and remove "type": "module" from the package.json
Create a new route with the ClientOnly tag: return <ClientOnly>{() => <div>Client only</div>}</ClientOnly>;

Expected behavior

No error

Screenshots or Videos

No response

Platform

  • OS: Windows, Linux
  • Browser: Chrome
  • Version: 7.0.2

Additional context

No response

File containing getClientIpAddress is named incorrectly.

Describe the bug

The file which contains the getClientIpAddress is named get-client-id-address. This function gets the clients IP address, not an ID.

Your Example Website or App

n/a

Steps to Reproduce the Bug or Issue

View file at https://github.com/sergiodxa/remix-utils/blob/main/src/server/get-client-id-address.ts

Expected behavior

Function an file name should align.

Screenshots or Videos

No response

Platform

N/A

Additional context

No response

Add getClienIPAddress helper

The helper should receive a Request or Headers object and try to get the IP address from the following headers:

  1. X-Client-IP
  2. X-Forwarded-For
  3. CF-Connecting-IP
  4. Fastly-Client-Ip
  5. True-Client-Ip
  6. X-Real-IP
  7. X-Cluster-Client-IP
  8. X-Forwarded, Forwarded-For and Forwarded

Using useEventSource in multiple places

Discussed in #214

Originally posted by lockr7 July 20, 2023
If I am using useEventSource with the same url/options, does it open a new connection for each instance? or is the same connection reused across all components?

Cannot use useLocales or getClientLocales

Describe the bug

The documentation lists these imports:

import { getClientLocales  } from 'remix-utils/get-client-locales'
import { useLocales } from 'remix-utils/use-locales'

However, these imports cannot be resolved. When I look at the package.json I see two different entries here:

"./locales/server": "./build/server/get-client-locales.js",

So I tried to this import:

import { getClientLocales } from 'remix-utils/locales/server'

But that also does not seem to work (TS complains that it cannot find this export). This stuff used to work in 7.0.0-pre3.

Your Example Website or App

N/A

Steps to Reproduce the Bug or Issue

See description above.

Expected behavior

The export should either match the docs or the docs should match the package.json and it should work :)

Screenshots or Videos

No response

Platform

  • OS: macOS
  • Browser: N/A
  • Version: 7.0.1

Additional context

No response

V7 ESM import aren't the same as in the Documents

Describe the bug

I'm upgrading to V7, and imports are very different from Documentations.
for example, the AuthenticityTokenProvider import in docs is like this:

import { AuthenticityTokenProvider } from "remix-utils/authenticity-token";

let { csrf } = useLoaderData<LoaderData>();
return (
	<AuthenticityTokenProvider token={csrf}>
		<Outlet />
	</AuthenticityTokenProvider>
);

but really usage is:

import { AuthenticityTokenProvider } from "remix-utils/build/react/authenticity-token";

i have flowed the upgrade guide.

my remixConfig:

/** @type {import('@remix-run/dev').AppConfig} */
module.exports = {
  ignoredRouteFiles: ["**/.*"],
  cacheDirectory: "./node_modules/.cache/remix",
};

tsConfig:

{
  "exclude": ["node_modules"],
  "extends": "./other/shared-tsconfig.json",
  "compilerOptions": {
    "baseUrl": ".",
    "forceConsistentCasingInFileNames": true,
    "target": "ESNext",
    "lib": ["DOM", "DOM.Iterable", "ESNext"],
    "allowJs": true,
    "strict": true,
    "paths": {
      "~/*": ["./app/*"]
    },
    "esModuleInterop": true,
    "isolatedModules": true,
    "jsx": "react-jsx",
    "moduleResolution": "Bundler",
    "resolveJsonModule": true,
    "noEmit": true
  },
  "include": ["remix.env.d.ts", "**/*.ts", "**/*.tsx"]
}

Your Example Website or App

i can't share :(

Steps to Reproduce the Bug or Issue

Remix : 2.0.1
remix-utils: 7.0.1
typescript: 4.9.5,

Expected behavior

to be like the docs

Screenshots or Videos

No response

Platform

  • OS: macOS
  • Browser: Brave
  • Version: 7.0.1

Additional context

No response

Question: useRevalidate hook

Quick backstory.
I am building a radio web app and have a page where I can like the episode, but also have a global audio player that sits on top of every route (like Soundcloud). I have a like button on both the episode detail page and on the player. The player, being a global component which can be rendered on any page, obviously has to fetch its data using useFetcher.

When I like the episode on the player, I would like to update the detail page's "like" button to reflect the change too, so my idea was on response from the useFetcher I use the revalidate hook to refetch the current pages loaders. However when using the useRevalidate hook it just navigates back to the home page i.e. "localhost:3000" every time, is there something I'm missing? Is there a better way to do what I need? I managed to get the functionality I wanted by directly using useNavigate + current location like below, but am getting some weird stuff where im now getting double flash messages, so feel like im doing something a bit weird still.

const location = useLocation()
const navigate = useNavigate()
React.useEffect(() => {
    if (savedSubmit.data?.success) {
      navigate(location.pathname, { replace: true })
    }
}, [savedSubmit.data, location.pathname])

Invalid Hook Call

Describe the bug

I'm getting an Invalid Hook Call error when using <ClientOnly>.

If I comment out the ClientOnly tags, it works fine. When I put ClientOnly in, I get the error below

Error: Invalid hook call. Hooks can only be called inside of the body of a function component. This could happen for one of the following reasons:

  1. You might have mismatching versions of React and the renderer (such as React DOM)
  2. You might be breaking the Rules of Hooks
  3. You might have more than one copy of React in the same app
    See https://reactjs.org/link/invalid-hook-call for tips about how to debug and fix this problem.
    at resolveDispatcher (/Users/connorvanooyen/Desktop/node_modules/react/cjs/react.development.js:1476:13)
    at Object.useState (/Users/connorvanooyen/Desktop/node_modules/react/cjs/react.development.js:1507:20)
    at Object.useHydrated (/Users/connorvanooyen/Desktop/node_modules/remix-utils/build/react/use-hydrated.js:24:43)
    at ClientOnly (/Users/connorvanooyen/Desktop/node_modules/remix-utils/build/react/client-only.js:22:27)
    at processChild (/Users/connorvanooyen/Desktop/Yellowbrick/yb-stats-dash/node_modules/react-dom/cjs/react-dom-server.node.development.js:3353:14)
    at resolve (/Users/connorvanooyen/Desktop/Yellowbrick/yb-stats-dash/node_modules/react-dom/cjs/react-dom-server.node.development.js:3270:5)
    at ReactDOMServerRenderer.render (/Users/connorvanooyen/Desktop/Yellowbrick/yb-stats-dash/node_modules/react-dom/cjs/react-dom-server.node.development.js:3753:22)
    at ReactDOMServerRenderer.read (/Users/connorvanooyen/Desktop/Yellowbrick/yb-stats-dash/node_modules/react-dom/cjs/react-dom-server.node.development.js:3690:29)
    at renderToString (/Users/connorvanooyen/Desktop/Yellowbrick/yb-stats-dash/node_modules/react-dom/cjs/react-dom-server.node.development.js:4298:27)
    at handleRequest (/Users/connorvanooyen/Desktop/Yellowbrick/yb-stats-dash/api/index.js:48:49)

Your Example Website or App

localhost

Steps to Reproduce the Bug or Issue

  1. Create Remix Run app
  2. In Index, import and add ClientOnly with any test code in it
  3. Run npm run dev
  4. Error

Expected behavior

Should not have error

Screenshots or Videos

No response

Platform

  • OS: macOS
  • Browser: Chrome

Additional context

No response

Weird TypeScript behavior of promiseHash

Describe the bug

When I'm manipulating the promise inside of the promiseHash, it breaks the typesafety and resolves my promise to unknown

Example code:

const { works, broken } = await promiseHash({
	works: Promise.resolve(42),
	broken: Promise.resolve(42).then((x) => x),
});

Your Example Website or App

https://www.typescriptlang.org/play?#code/PQKgBAyglgtgDgGwKZiQDwIb2WEwBQ+AxgPYB2AzgC5gDeYA7iQE4DWFANGAEbMmtIyYAL5gAvGAwMMUGnD4woFJAAkMFABYAKWvgCQTNhQBcYAAoKlSAHTMkFEggBuSLQBYATAEoO+3v0FTCxJFZVt7Rxd3b2sqDUEtLTQvcQA+MGT8YS9CfFAwAFkWFHQsRHtcAnwAMwBXMiIqKHIwGAwBYNDXFN0wMDsqWuYhTqtwh2dXAG0ARi4PLgBmAF0c4VzSShpmKRnxVvakUeUtHOBgPr6APQB+Qk3qfqkPfbaOyxOvWPiyLQATDBUDBpMAAoFnC6XW73ciPHYMRavQ7HbrfBJg4FidIY6xtOBaWRIGAgwkwLwQy5gaGEc5gDTqDR7JRgapQMhIYiwmj0zR7CTyEJWNSaHRgMi1GDcJDMEwHd6Cz4iClQu55C48jQvZn+ARkTlbOkMl78j6qBmi8WS6Wyt5HU2nNG-DEgjEpbJqynU2kaxHavi6-WPH37AVdYXaeiWqUy0y2lEOuLowGY7HJ3EYfGkklUInkpUelUbLmMFisDB8ep-PlgUNC82RiXR2Xwvbu2mFwM0QxlitkP7Gmum8MWxvW0zwl5tyHXVUPLul8skSuIk0Ks0ihtWmNPBH59sz3L5AAygPsNFg5RggiBTRaWjsijQAFpak0EBQAAIAdmsM2sAAYUjwQh0DgFgaCoABPOAUBRcN9gAJSQUhmD+AAeahmDZABzLgUTQ+pWDIEgGDIVJUgAbnwUDwLAKCYLAABBaRCT+OCGTQ+D0BzPsKHMIcGXSCRekuKYAGkkEgsA2TAARIJIaowHDZZTGYmQc3Q8NxMk5ZKKyKiaOYGg-mQhByxQOoGlvIRa2UcNOIZVA0B4v4+PYzRUi0DVTHDLwglNNC1NY9yNAcjy9KAA

Steps to Reproduce the Bug or Issue

Go to playground & check hash2 and hash3 variable types.

Expected behavior

I should be able to manipulate the promise with .then inside of the promiseHash object.

Screenshots or Videos

No response

Platform

typescript 5.2.2
remix-utils 7.1.0

Additional context

No response

remix-utils: missing type declarations (regression)

Describe the bug

Latest version of remix-util is broke for me (cannot import from). A clean remix-install results in following when attempting to import:

app/routes/_index.tsx:1:36 - error TS2307: Cannot find module 'remix-utils' or its corresponding type declarations.

1 import { getClientIPAddress } from "remix-utils";
                                     ~~~~~~~~~~~~~


Found 1 error in app/routes/_index.tsx:1

Repro avail here: https://github.com/rolanday/repro-remix-util

Same result using bun and npm package managers

Your Example Website or App

https://github.com/rolanday/repro-remix-util

Steps to Reproduce the Bug or Issue

  1. Create new remix project (typescript)
  2. npm i remix-utils
  3. Try to import remix-utils
  4. Note cannot find module error

Expected behavior

To be able to import from remix-utils

Screenshots or Videos

No response

Platform

  • OS: macOS (Sonoma v14.0)
  • Browser: N/A

Additional context

No response

Example of `promiseHash` not correct

Describe the bug

the example on promiseHash is not correct,

export async function loader({ request }: LoaderArgs) {
  return json(
     promiseHash({
       user: getUser(request),
        posts: getPosts(request),
     }) // => `promiseHash` return promise which not resolve yet
   ); // => so the `json` get promise object not the data which resolved by promise
  }

Your Example Website or App

https://stackblitz.com/edit/vitest-dev-vitest-bimku4?file=test/basic.test.ts

Steps to Reproduce the Bug or Issue

Go to example above will see the test.

Expected behavior

update example on promiseHash to

export async function loader({ request }: LoaderArgs) {
   return promiseHash({
       user: getUser(request),
        posts: getPosts(request),
   })
   .then(json)
}

Screenshots or Videos

No response

Platform

  • OS: [e.g. macOS, Windows, Linux]
  • Browser: [e.g. Chrome, Safari, Firefox]
  • Version: [e.g. 91.1]

Additional context

No response

useDelegatedAnchors breaks links to resource routes, links with reloadDocument set

Describe the bug

The Remix docs state that you must use reloadDocument to link to resource routes, like this:

<Link to="this-is-a-resource-route" reloadDocument>This is a link to a resource route.</Link>

If you have such a Link inside a component to which you have applied useDelegatedAnchors, then when you follow the link, it fails with the following client-side error:

error: Element type is invalid: expected a string (for built-in components) or a class/function (for composite components) but got: object. You likely forgot to export your component from the file it's defined in, or you might have mixed up default and named imports. Check the render method of RemixRoute. %E2%80%94 https://.../build/entry.client-VNG4R5DS.js:20463

Your Example Website or App

https://stackblitz.com/edit/remix-run-remix-a55xcd?file=app/routes/_index.tsx

Steps to Reproduce the Bug or Issue

Open and run the StackBlitz. In the Remix app, click the link called "A resource route".

Expected behavior

Your browser should follow the link and display the resource route, which is JSON and looks like this:

{"hello":"world"}

Screenshots or Videos

Screen.Recording.2023-05-17.at.17.36.34.mov

Platform

  • OS: macOS
  • Browser: Safari
  • Version: [e.g. 91.1]

Additional context

No response

using remix-utils@next - No "exports" main defined

Describe the bug

Using remix-utils@next

After installing remix-utils from npm the key "main" seems to get added and causing issues,

"main": "./build/index.js",

Error: No "exports" main defined in /home/afi/Documents/retentionScript/node_modules/remix-utils/package.json imported from /home/afi/Documents/retentionScript/build/index.js
    at new NodeError (node:internal/errors:405:5)
    at exportsNotFound (node:internal/modules/esm/resolve:359:10)
    at packageExportsResolve (node:internal/modules/esm/resolve:695:9)
    at packageResolve (node:internal/modules/esm/resolve:870:14)
    at moduleResolve (node:internal/modules/esm/resolve:936:20)
    at defaultResolve (node:internal/modules/esm/resolve:1129:11)
    at nextResolve (node:internal/modules/esm/loader:163:28)
    at ESMLoader.resolve (node:internal/modules/esm/loader:835:30)
    at ESMLoader.getModuleJob (node:internal/modules/esm/loader:424:18)
    at ModuleWrap.<anonymous> (node:internal/modules/esm/module_job:77:40)

Also there seems to be a swiggly line at any import statement.

import { namedAction } from "remix-utils/named-action" <- swiggly line here;

Not sure if typescript is working either.

Your Example Website or App

private-app

Steps to Reproduce the Bug or Issue

  1. install remix-utils@next
  2. import { namedAction } from "remix-utils/named-action";
  3. start remix server

Expected behavior

No swigly lines on the import statement and things works fine just like remix-utils v6

Screenshots or Videos

No response

Platform

  • OS: Linux
  • Browser: Chorme
  • Version: 117.0

Additional context

No response

Would you be interested in this util function?

Hey Sergio!

Thanks for this package. It's got some really nice utilities!

I recently had a need for the following function and was wondering if you'd be interested in a PR for adding it to the package:

export async function promiseAllWithDeterministicThrow<
  Values extends readonly unknown[] | [],
>(
  values: Values,
): Promise<{ -readonly [Index in keyof Values]: Awaited<Values[Index]> }> {
  const promises = values.map(value =>
    Promise.resolve(value)
      .then(resolved => ({ resolved }))
      .catch((error: unknown) => ({ error })),
  )

  const results: unknown[] = Array.from({ length: promises.length })

  for (let index = 0; index < promises.length; index++) {
    const result = await promises[index]

    if (`error` in result) {
      throw result.error
    }

    results[index] = result.resolved
  }

  return results as unknown as {
    -readonly [Index in keyof Values]: Awaited<Values[Index]>
  }
}

The case where I needed this is if I have multiple async authorization functions that may redirect by throwing a redirect response when the user is unauthorized, then a plain Promise.all call can result in nondeterministic redirects in the case where the user is unauthorized for multiple reasons. However, running the calls in series is not ideal either because it is slower.

So instead I was writing something like this if I had a pair of authorization calls to make:

const loader: LoaderFunction = ({ request }) => {
  const secondAuthorizationPromise = requireSecondThing(request)

  try {
    await requireFirstThing(request)
  } catch (e) {
    // No unhandled rejection in case this one throws too
    secondAuthorizationPromise.catch(() => {})

    throw e
  }

  await secondAuthorizationPromise
}

With this code the promise are still running concurrently, but I always respect the first promise's redirect or error before the second one regardless of which promise finishes first.

So the function I wrote in the beginning is a generalized version of this for N promises. Also note that this is a little different than Promise.allSettled because I don't actually have to wait for all of them to settle in all cases.

Let me know what you think!

Add image response helper

let loader: LoaderFunction = async ({ request }) => {
  let buffer = await generateImage(request);
  return image(buffer, { type: "png", headers: {}, status: 200 })
}

ExternalScriptsFunction does not take effect

source: https://github.com/willin/willin.wang/blob/main/app/root.tsx#L41-L51

// create the scripts function with the correct type
const scripts: ExternalScriptsFunction = () => [
  {
    async: true,
    src: 'https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-pub-5059418763237956',
    crossOrigin: 'anonymous'
  }
];

// and export it through the handle, you could also create it inline here
// if you don't care about the type
export const handle = { scripts };

output: https://willin.wang

useData hooks create unstable reference

Describe the bug

Using the useLoaderData and useActionData hooks create an unstable reference with potential performance implications.

These hooks function by taking stringified data and parsing it, with a supplied reviver function. This might not be technically a bug, but given the implications it can have when combined with useEffect, I would personally classify it as one.

Every time the consuming components are rendered, the hook re-parses the stringified json contained within the loader/action data.

This has two performance related implications:

  1. Parsing every render, in itself, can create a performance implication - particularly for components that have re-renders triggered frequently.
  2. JSON.parse creates a new object every call, which makes it difficult to use as a dependency for derived data (using useMemo/useCallback) as the useMemo/useCallback will always trigger.

This can be improved by wrapping the JSON.parse/validate function in useMemo - given the loader data is stringified json, it can be used as a dependency for useMemo, which will result in re-parsing & re-validating the loader/action data when it changes, rather than every render.

Your Example Website or App

N/A

Steps to Reproduce the Bug or Issue

Create a route with loader data (in form of an object) using remix-utils json & loaderData

eg.

type Data = { /* some object type */ }
const reviver = (k, v) => v; // some reviver function
export default function SomeRoute() {
  const loaderData = useLoaderData<Data>({reviver});
  useEffect(() => {
    console.log('This should only be called once per render, unless the loader data changes');
  }, [loaderData])

  const shouldBeStableReference = useCallback(() => {
    // This should be a stable reference, and only invalidate when loaderData changes
  }, [loaderData]);
  return null;
}

The above message "This should only be called once per render, unless the loader data changes" will output every time the route is rendered, and the callback will be an unstable reference which is different every render.

Expected behavior

In above route, the console should not render except for the initial render, and once for every time the loader data changes.
In the above route, the shouldBeStableReference function should remain a stable reference to a function as long as loaderData doesn't change.

Screenshots or Videos

No response

Platform

All

Additional context

Might be as simple as wrapping the whole loaderData in useMemo, although validator and reviver would either need to be:

  1. Explicitly removed from the dependency array, causing potential incorrect behaviour if the functions change
  2. Assumed to be stable references and included in the dependency array, which would eliminate any benefit if the references are not stable

e.g.

// Option A, will work but if `validator` or `reviver` ever change
// behaviour/are closures which rely on captured data, will
// potentially introduce subtle bugs
export function useLoaderData<Data>({
  reviver,
  validator,
}: UseLoaderDataArgs<Data>) {
  let loaderData = useRemixLoaderData<string>();
  return useMemo<Data>(() => {
    let parsedValue = JSON.parse(loaderData, reviver);
    if (!validator) return parsedValue as Data;
    return validator(parsedValue);
  }, [loaderData]); // eslint-disable-line react-hooks/exhaustive-deps
}
// Option B. `validator` and `reviver` are passed as dependencies,
// and the whole hook will continue to have the performance/stable
// reference issue if those two functions are not themselves stable
// references
export function useLoaderData<Data>({
  reviver,
  validator,
}: UseLoaderDataArgs<Data>) {
  let loaderData = useRemixLoaderData<string>();
  return useMemo<Data>(() => {
    let parsedValue = JSON.parse(loaderData, reviver);
    if (!validator) return parsedValue as Data;
    return validator(parsedValue);
  }, [loaderData, reviver, validator]);
}

Cannot find module '@remix-run/react/dist/routeData' or its corresponding type declarations.

Describe the bug

Duplicate of an old issue. @remix-run/react file structure changed again and this export is no longer there. Seems like the latest way to get this type might be:

import type { RouterState } from "@remix-run/router";
type RouteData = RouterState["loaderData"];

as per https://github.com/remix-run/remix/blob/b30a61157d89f21eef1063d4dd8fca2326f9210e/packages/remix-react/routeModules.ts#L15

Your Example Website or App

n/a

Steps to Reproduce the Bug or Issue

  1. install package
  2. run typescript

Expected behavior

No typescript errors

Screenshots or Videos

No response

Platform

  • OS: Linux
  • Browser: n/a
  • Version: 6.1.0

Additional context

No response

Could not resolve module "crypto"

While working with the Cloudflare Remix template, using the following code in root.tsx

import { Links, LinksFunction, LiveReload, Outlet, Scripts } from "remix";
import { useShouldHydrate } from "remix-utils";
import styles from "./styles/app.css";

export let links: LinksFunction = () => {
  return [{ rel: "stylesheet", href: styles }];
};

export default function App() {
  let shouldHydrate = useShouldHydrate();
  return (
    <html lang="en">
      <head>
        <meta charSet="utf-8" />
        <title>Remix: So great, it's funny!</title>
        <Links />
      </head>
      <body>
        {shouldHydrate && <Scripts />}
        <Outlet />
        {process.env.NODE_ENV === "development" ? <LiveReload /> : null}
      </body>
    </html>
  );
}

Produces the following error when the app is started using npm run start

> dev:worker
> esbuild --define:process.env.NODE_ENV='"development"' --bundle --sourcemap --outdir=dist ./worker

 > node_modules/remix-utils/browser/server/csrf.js:1:28: error: Could not resolve "crypto" (use "--platform=node" when building for node)
    1 │ ...{ randomBytes } from "crypto";
      ╵                         ~~~~~~~~

1 error
[mf:err] BuildError [1]: Build failed with exit code 1
    at ChildProcess.<anonymous> (file:///Users/mackenziekieran/Desktop/remix-cloudflare/node_modules/@miniflare/core/src/plugins/build.ts:71:25)
    at ChildProcess.emit (node:events:390:28)
    at Process.ChildProcess._handle.onexit (node:internal/child_process:290:12)

Node and NPM versions

{
  npm: '8.1.0',
  node: '16.13.0',
}

package.json

{
  "private": true,
  "name": "remix-app-template",
  "description": "",
  "license": "",
  "scripts": {
    "build": "remix build",
    "dev": "remix watch",
    "watch:css": "postcss styles --base styles --dir app/styles -w",
    "build:css": "postcss styles --base styles --dir app/styles --env production",
    "postinstall": "remix setup cloudflare-workers",
    "build:worker": "esbuild --define:process.env.NODE_ENV='\"production\"' --minify --bundle --sourcemap --outdir=dist ./worker",
    "dev:worker": "esbuild --define:process.env.NODE_ENV='\"development\"' --bundle --sourcemap --outdir=dist ./worker",
    "start": "miniflare --build-command \"npm run dev:worker\" --watch",
    "deploy": "npm run build && wrangler publish"
  },
  "dependencies": {
    "@remix-run/cloudflare-workers": "^1.0.5",
    "@remix-run/node": "^1.0.6",
    "@remix-run/react": "^1.0.6",
    "magic-sdk": "^6.2.1",
    "react": "^17.0.2",
    "react-dom": "^17.0.2",
    "remix": "^1.0.6",
    "remix-utils": "^2.1.1"
  },
  "devDependencies": {
    "@cloudflare/workers-types": "^2.2.2",
    "@remix-run/dev": "^1.0.5",
    "@types/react": "^17.0.24",
    "@types/react-dom": "^17.0.9",
    "autoprefixer": "^10.4.0",
    "esbuild": "0.13.14",
    "miniflare": "2.0.0-next.3",
    "postcss": "^8.4.0",
    "postcss-cli": "^9.0.2",
    "tailwindcss": "^2.2.19",
    "typescript": "^4.1.2"
  },
  "engines": {
    "node": ">=14"
  },
  "sideEffects": false,
  "main": "dist/worker.js"
}

getClientIPAddress fails on remix 1.7.2

Describe the bug

When I use remix-utils with remix-run 1.7.2, getClientIPAddress starts to fail:

TypeError: headers.get is not a function
    at /home/cldellow/src/webapp/node_modules/remix-utils/build/server/get-client-id-address.js:32:29
    at Array.flatMap (<anonymous>)
    at getClientIPAddress (/home/cldellow/src/webapp/node_modules/remix-utils/build/server/get-client-id-address.js:31:10)
...

It seems like in

if (requestOrHeaders instanceof Request) {
, the requestOrHeaders object is still a Request, but the instanceof check is no longer returning true, which causes remix-utils to return the Request object. Calling .get(...) on that then fails.

`namedAction` expects type of FormData instead of request

Describe the bug

Passing a Request object to namedAction results in a TypeScript error. Seems to want a FormData object.

Your Example Website or App

https://stackblitz.com/edit/node-imtmir?file=app/routes/index.tsx

Steps to Reproduce the Bug or Issue

  1. Create action with named action
  2. Try to pass it request as shown in the remix-utils example

Expected behavior

If should accept a Request object.

Screenshots or Videos

CleanShot 2022-12-10 at 20 33 45@2x

Platform

  • OS: macOS
  • Browser: Chrome
  • Version: 108

Additional context

Looks similar to this.

https://stackoverflow.com/questions/57314805/in-typescript-why-function-overloading-order-matters-only-when-the-overloaded-f

Add support for Remix v2.0.0

Describe the bug

Remix v2 has deprecated useTransition in favor of useNavigation as documented here: https://remix.run/docs/en/2.0.0/start/v2#usetransition

After upgrading to Remix v2, the following error occurs:

X [ERROR] No matching export in "node_modules/@remix-run/react/dist/esm/index.js" for import "useTransition"

    node_modules/remix-utils/browser/react/use-global-pending-state.js:1:9:
      1 │ import { useTransition, useFetchers } from "@remix-run/react";

Your Example Website or App

https://github.com/jgentes/mixpoint

Steps to Reproduce the Bug or Issue

Go to package.json, update all remix dependencies to "2.0.0" and run "yarn", then "yarn dev"

Expected behavior

Probably ought to update remix-utils to the latest version of Remix

Screenshots or Videos

No response

Platform

Windows, edge

Additional context

No response

DynamicLinks only produce an empty <links> tag

Defined a dynamicLinks function in the root file returning an array of link objects, export let handle = { dynamicLinks } and <DynamicLinks /> added to the Document component.

The only observable addition to <head> is a single empty <link> tag.

Using remix-utils 2.5.0, remix 1.1.3, miniflare 2.3.0.

Helmet works fine with this setup.

useGlobalPendingState etc. doesn't listen to useRevalidator state

Describe the bug

Triggering loaders via the useRevalidator hook from remix doesn't reflect in the useGlobalPendingState hooks etc.

Your Example Website or App

https://codesandbox.io/p/sandbox/peaceful-galileo-8v5qlq?file=%2Fapp%2Froutes%2Findex.tsx%3A20%2C11

Steps to Reproduce the Bug or Issue

  1. do something with pending state from useGlobalPendingState
  2. trigger revalidate via useRevalidator

Expected behavior

Expected behaviour would be to map useRevalidator state loading into pending

Screenshots or Videos

No response

Platform

  • OS: [e.g. macOS, Windows, Linux]
  • Browser: [e.g. Chrome, Safari, Firefox]
  • Version: [e.g. 91.1]

Additional context

No response

useLocales and getClientLocales not exported from package.json

Describe the bug

Like title says, these are not exported, most other utils are.

The imports in the documentation (https://github.com/sergiodxa/remix-utils#uselocales) also erroneously points to a folder that does not exist (remix-utils/locales)

Thx. for the nice utils btw and keep up the good work!

Your Example Website or App

..

Steps to Reproduce the Bug or Issue

Just by looking at package.json / docs.

Expected behavior

I guess, all utils should be exported, when some are. And docs. should be correct.

Screenshots or Videos

No response

Platform

  • OS: Windows
  • Browser: Chrome

Additional context

No response

Cloudflare Pages - Could not resolve "crypto"

Describe the bug

Does the latest remix-utils support Remix v2 on Cloudflare Pages? I'm getting Could not resolve "crypto" and Could not resolve "stream" as there is no Node with Cloudflare Pages.

Your Example Website or App

Steps to Reproduce the Bug or Issue

  1. Upgraded to Remix v2
  2. Upgraded to remix-utils 7.1.0
  3. "moduleResolution": "Bundler"
  4. npm run dev -> getting the error

Expected behavior

It was working ok on v6

Screenshots or Videos

No response

Platform

Mac OS

Additional context

No response

Support `Graph` type for `StructuredDataFunction`

Describe the bug

It should be possible to use @graph for structured data:

const structuredData: StructuredDataFunction<
  SerializeFrom<typeof loader>,
  Graph
> = (data) => {
  return {
    "@context": "https://schema.org",
    "@graph": [{
        "@type": "Organization",
        ...
    }]
 }
}

Currently, the above script would raise Type 'Graph' does not satisfy the constraint 'Thing'. because of this type constraint StructuredDataSchema extends Thing = Thing on HandleStructureData

Your Example Website or App

https://stackblitz.com/edit/remix-run-remix-vu3jgx?file=app/routes/index.tsx

Steps to Reproduce the Bug or Issue

Check typing error from structuredData.

Expected behavior

StructuredDataFunction should accept Graph as the second generic type.

Screenshots or Videos

No response

Platform

  • OS: [e.g. macOS, Windows, Linux]
  • Browser: [e.g. Chrome, Safari, Firefox]
  • Version: [e.g. 91.1]

Additional context

No response

CSRF utility breaks on _multipart uploads

I had a form with the and an

in the action if the "await verifyAuthenticitiyToken(request, session)" is before the form the unstable_parseMultipartFormData function is unable to process the supplied form with:

Tried to parse multipart file upload for field "picture" but no uploadHandler was provided. Read more here: https://remix.run/api/remix#parseMultipartFormData-node

if I do the verify after the multipart form, the verify dies..

I assume the utility is just not multipart aware? or I could be going down another rabbit hole...

Remix not found error when using pnpm

Minimal reproduction

npx create-remix@latest

Use App server template -> typescript -> no npm install

cd [created-folder]

pnpm i remix-utils

Command fails with error in remix-utils postintall cannot find remix

Dependency Issue with remix example

Describe the bug

I'm unable to install remix-utils for the client-only example in the remix-examples repository: https://github.com/remix-run/examples/tree/main/client-only-components

Here is the output:

(base) nick@nick-Laptop:~/Documents/GitHub/examples/client-only-components$ npm i
npm ERR! code ERESOLVE
npm ERR! ERESOLVE unable to resolve dependency tree
npm ERR! 
npm ERR! While resolving: undefined@undefined
npm ERR! Found: [email protected]
npm ERR! node_modules/react
npm ERR!   react@"^18.2.0" from the root project
npm ERR!   peer react@">=16.8" from @remix-run/[email protected]
npm ERR!   node_modules/@remix-run/react
npm ERR!     @remix-run/react@"*" from the root project
npm ERR!     peer @remix-run/react@"^1.1.1" from [email protected]
npm ERR!     node_modules/remix-utils
npm ERR!       remix-utils@"^2.7.0" from the root project
npm ERR!   1 more (react-dom)
npm ERR! 
npm ERR! Could not resolve dependency:
npm ERR! peer react@"^17.0.2" from [email protected]
npm ERR! node_modules/remix-utils
npm ERR!   remix-utils@"^2.7.0" from the root project
npm ERR! 
npm ERR! Fix the upstream dependency conflict, or retry
npm ERR! this command with --force, or --legacy-peer-deps
npm ERR! to accept an incorrect (and potentially broken) dependency resolution.
npm ERR! 
npm ERR! See /home/nick/.npm/eresolve-report.txt for a full report.

npm ERR! A complete log of this run can be found in:
npm ERR!     /home/nick/.npm/_logs/2023-02-07T17_04_21_010Z-debug-0.log
```__

### Your Example Website or App

https://github.com/remix-run/examples/tree/main/client-only-components

### Steps to Reproduce the Bug or Issue

1. Clone remix-run-examples repo: https://github.com/remix-run/examples/tree/main/client-only-components
2. `cd client-only-components`
3. `npm i`

### Expected behavior

I expected a clean install. 

### Screenshots or Videos

_No response_

### Platform

- OS: [e.g. macOS, Windows, Linux]
- Browser: [e.g. Chrome, Safari, Firefox]
- Version: [e.g. 91.1]

Ubuntu 22.04

### Additional context

N/A

eventSource is not working on Grunge Stack

Describe the bug

I followed the example in the docs for eventSource inside of the Remix Grunge Stack. Then, I'm becoming a 500 error on http://localhost:3000/time.

Here you have a repository where you can replicate the error:
https://github.com/cjoecker/remix-event-source

Your Example Website or App

https://github.com/cjoecker/remix-event-source

Steps to Reproduce the Bug or Issue

  1. Clone this repo: https://github.com/cjoecker/remix-event-source
  2. Run npm i and npx remix init
  3. Start the app with npm run dev
  4. Go to http://localhost:3000/time
  5. Open the console and see the error

Expected behavior

No 500 error should be shown

Screenshots or Videos

No response

Platform

  • OS: macOS
  • Browser: Chrome
  • Remix Grunge Stack

Additional context

No response

exported `json` function and functions using it are double stringifying

Describe the bug

This code stringifies json and then calls the remix json function which will stringify the data again:
https://github.com/sergiodxa/remix-utils/blob/main/src/server/responses.ts#L45-L54

Your Example Website or App

https://stackblitz.com/edit/node-mlftqr?file=app/routes/index.tsx

Steps to Reproduce the Bug or Issue

  1. Use the exported json function in a loader or action or any function using it (eg. badRequest)
  2. Visit page, double stringified json is returned ("{\"message\":\"Not implemented\"}")

Expected behavior

JSON should be returned. For example {"message":"Not implemented"}

Screenshots or Videos

No response

Platform

n/a

Additional context

No response

eventStream doesn't connect properly after browser refresh, on node server setup

Describe the bug

It works good until I refresh the browser and issue a event.

TypeError: The stream is not in a state that permits enqueue
    at ReadableStreamDefaultController2.enqueue (/Users/bob/web/remix-run-sse/build/server.js:3136:21)
    at send (/Users/bob/web/remix-run-sse/node_modules/.pnpm/[email protected]_@[email protected]_@[email protected][email protected]/node_modules/remix-utils/build/server/event-stream.js:12:28)
    at handle (/Users/bob/web/remix-run-sse/app/routes/report.subscribe.ts:11:9)
    at EventEmitter.<anonymous> (/Users/bob/web/remix-run-sse/app/routes/report.subscribe.ts:18:7)
    at EventEmitter.emit (node:events:525:35)
    at doAsyncWork (/Users/bob/web/remix-run-sse/app/statusEmitter.ts:11:13)
    at statusEmitter (/Users/bob/web/remix-run-sse/app/statusEmitter.ts:14:3) Error at the `send()`

Your Example Website or App

https://stackblitz.com/edit/remix-run-remix-p98wmt?file=server.ts,remix.config.js,app%2FstatusEmitter.ts,app%2Froutes%2Freport.subscribe.ts

Steps to Reproduce the Bug or Issue

NOTE: the error won't appear in StackBlitz, you need to clone the repo and run it locally https://github.com/hilja/remix-run-sse

  1. Restart the server and don't refresh
  2. Navigate to the /report/foo page
  3. Click the button and wait for the events to trigger, should work
  4. Then refresh and try again and the errors should appear

Expected behavior

No errors, the stream should stay open.

Screenshots or Videos

No response

Platform

  • Remix 2.1.0
  • remix-utils 7.1.0
  • Browser: Brave/Chrome

Additional context

I don't know if this is just a development environment thing or what, haven't deployed it to a real server yet. But I've tried to run the built project, same thing.

There was a header flush update on Remix just remix-run/remix#7619. It made it better, the stream is not in pending state at first. But didn't fix my issue.

v7.0.0 released accidentally?

Describe the bug

renovate opened up an MR to our repo with an update from v7.0.0-pre3 to v7.0.0. Some checks are failing, so I went here to find the release notes. However, there are none. So, I'm wondering whether this release happened accidentally.

Your Example Website or App

https://github.com/sergiodxa/remix-utils/releases

Steps to Reproduce the Bug or Issue

I guess npm i remix-utils should give you 7.0.0 now instead of 6.6.0

Expected behavior

I should know which release the latest one is, that I can use :)

Screenshots or Videos

No response

Platform

Doesn't really apply here, I think.

Additional context

No response

ExternalScripts not loading script when doing client navigation

Describe the bug

ExternalScripts seems to not load on client -side navigation

Your Example Website or App

https://github.com/jacargentina/my-remix

Steps to Reproduce the Bug or Issue

  1. remix dev
  2. clic on "Recaptcha"
  3. clic the Test button
  4. The alert shows typeof window.grecaptcha which is undefined

Expected behavior

  1. The alert must show 'object' as the recaptcha script is loaded & run

Screenshots or Videos

No response

Platform

  • OS: macOS
  • Browser: Chrome
  • Version: current

Additional context

No response

Wrong type on session.get

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.