diff --git a/examples/README.md b/examples/README.md
index b306a609d0..dceb812e71 100644
--- a/examples/README.md
+++ b/examples/README.md
@@ -27,6 +27,7 @@ These are some of the most commonly used Hydrogen examples. Browse the folders i
| [Multipass](/examples/multipass/) | Connect your existing third-party authentication method to Shopify’s customer accounts, so buyers can use a single login across multiple services. |
| [Optimistic Cart UI](/examples/optimistic-cart-ui/) | How to optimistically remove a cart line item from the cart. |
| [Partytown](/examples/partytown/) | Lazy-loading [Google Tag Manager](https://support.google.com/tagmanager) using [Partytown](https://partytown.builder.io/).feature. |
+| [Single Fetch](/examples/single-fetch/) | Using Remix's unstable [Single Fetch](https://remix.run/docs/en/main/guides/single-fetch) feature. |
| [Subscriptions](/examples/subscriptions/) | Implementation of [subscriptions](https://shopify.dev/docs/apps/selling-strategies/subscriptions) for Hydrogen. |
| [Third-party Queries and Caching](/examples/third-party-queries-caching/) | How to leverage Oxygen's sub-request caching when querying third-party GraphQL API in Hydrogen. |
diff --git a/examples/single-fetch/README.md b/examples/single-fetch/README.md
new file mode 100644
index 0000000000..d7620c0b9d
--- /dev/null
+++ b/examples/single-fetch/README.md
@@ -0,0 +1,14 @@
+# Hydrogen example: Single Fetch
+
+> [!NOTE]
+> This example is based on Remix's unstable [Single Fetch](https://remix.run/docs/en/main/guides/single-fetch) feature.
+
+This example is meant for early adopter to test out and prepare for [Remix v3 = React Router v7](https://remix.run/blog/incremental-path-to-react-19). Single Fetch will be the only fetching strategy for React Router v7.
+
+## Install
+
+Setup a new project with this example:
+
+```bash
+npm create @shopify/hydrogen@latest -- --template single-fetch
+```
diff --git a/packages/hydrogen/src/customer/customer.ts b/packages/hydrogen/src/customer/customer.ts
index 25a6e0971f..0a49f1dbe9 100644
--- a/packages/hydrogen/src/customer/customer.ts
+++ b/packages/hydrogen/src/customer/customer.ts
@@ -60,7 +60,9 @@ function defaultAuthStatusHandler(request: CrossRuntimeRequest) {
const redirectTo =
DEFAULT_LOGIN_URL +
- `?${new URLSearchParams({return_to: pathname}).toString()}`;
+ `?${new URLSearchParams({
+ return_to: pathname.replace(/\.data$/, ''), // for single fetch
+ }).toString()}`;
return redirect(redirectTo);
}
diff --git a/packages/mini-oxygen/src/common/compat.ts b/packages/mini-oxygen/src/common/compat.ts
index 390f112014..d4835a2c98 100644
--- a/packages/mini-oxygen/src/common/compat.ts
+++ b/packages/mini-oxygen/src/common/compat.ts
@@ -1,4 +1,7 @@
export const OXYGEN_COMPAT_PARAMS = {
- compatibilityFlags: ['streams_enable_constructors' as const],
+ compatibilityFlags: [
+ 'streams_enable_constructors' as const,
+ 'http_headers_getsetcookie' as any,
+ ],
compatibilityDate: '2022-10-31' as const,
};
diff --git a/templates/skeleton/app/entry.server.tsx b/templates/skeleton/app/entry.server.tsx
index a645a41078..8b8fe95288 100644
--- a/templates/skeleton/app/entry.server.tsx
+++ b/templates/skeleton/app/entry.server.tsx
@@ -14,7 +14,7 @@ export default async function handleRequest(
const body = await renderToReadableStream(
-
+
,
{
nonce,
diff --git a/templates/skeleton/app/lib/root-data.ts b/templates/skeleton/app/lib/root-data.ts
index d54664e417..d2e732a321 100644
--- a/templates/skeleton/app/lib/root-data.ts
+++ b/templates/skeleton/app/lib/root-data.ts
@@ -1,5 +1,5 @@
import {useMatches} from '@remix-run/react';
-import type {UIMatch} from '@remix-run/react';
+import type {UIMatch_SingleFetch} from '@remix-run/react';
import type {loader} from '~/root';
/**
@@ -7,5 +7,5 @@ import type {loader} from '~/root';
*/
export function useRootLoaderData() {
const [root] = useMatches();
- return (root as UIMatch)?.data;
+ return (root as UIMatch_SingleFetch)?.data;
}
diff --git a/templates/skeleton/app/root.tsx b/templates/skeleton/app/root.tsx
index 363b8e5ea8..d4cb93e454 100644
--- a/templates/skeleton/app/root.tsx
+++ b/templates/skeleton/app/root.tsx
@@ -1,5 +1,5 @@
import {useNonce} from '@shopify/hydrogen';
-import {defer, type LoaderFunctionArgs} from '@shopify/remix-oxygen';
+import {type LoaderFunctionArgs} from '@shopify/remix-oxygen';
import {
Links,
Meta,
@@ -76,13 +76,13 @@ export async function loader({context}: LoaderFunctionArgs) {
},
});
- return defer({
+ return {
cart: cartPromise,
footer: footerPromise,
header: await headerPromise,
isLoggedIn: isLoggedInPromise,
publicStoreDomain,
- });
+ };
}
export default function App() {
diff --git a/templates/skeleton/app/routes/$.tsx b/templates/skeleton/app/routes/$.tsx
index c6d26a3594..a74d3baa4a 100644
--- a/templates/skeleton/app/routes/$.tsx
+++ b/templates/skeleton/app/routes/$.tsx
@@ -1,9 +1,8 @@
import type {LoaderFunctionArgs} from '@shopify/remix-oxygen';
-export async function loader({request}: LoaderFunctionArgs) {
- throw new Response(`${new URL(request.url).pathname} not found`, {
- status: 404,
- });
+export async function loader({request, response}: LoaderFunctionArgs) {
+ response!.status = 404;
+ throw new Error(`${new URL(request.url).pathname} not found`);
}
export default function CatchAllPage() {
diff --git a/templates/skeleton/app/routes/[robots.txt].tsx b/templates/skeleton/app/routes/[robots.txt].tsx
index 3feeb475c9..59f544bfe0 100644
--- a/templates/skeleton/app/routes/[robots.txt].tsx
+++ b/templates/skeleton/app/routes/[robots.txt].tsx
@@ -1,8 +1,7 @@
import {type LoaderFunctionArgs} from '@shopify/remix-oxygen';
-import {useRouteError, isRouteErrorResponse} from '@remix-run/react';
import {parseGid} from '@shopify/hydrogen';
-export async function loader({request, context}: LoaderFunctionArgs) {
+export async function loader({request, context, response}: LoaderFunctionArgs) {
const url = new URL(request.url);
const {shop} = await context.storefront.query(ROBOTS_QUERY);
@@ -10,14 +9,9 @@ export async function loader({request, context}: LoaderFunctionArgs) {
const shopId = parseGid(shop.id).id;
const body = robotsTxtData({url: url.origin, shopId});
- return new Response(body, {
- status: 200,
- headers: {
- 'Content-Type': 'text/plain',
-
- 'Cache-Control': `max-age=${60 * 60 * 24}`,
- },
- });
+ response!.headers.set('Content-Type', 'text/plain');
+ response!.headers.set('Cache-Control', `max-age=${60 * 60 * 24}`);
+ return body;
}
function robotsTxtData({url, shopId}: {shopId?: string; url?: string}) {
diff --git a/templates/skeleton/app/routes/[sitemap.xml].tsx b/templates/skeleton/app/routes/[sitemap.xml].tsx
index be4ab94f33..fd54375b59 100644
--- a/templates/skeleton/app/routes/[sitemap.xml].tsx
+++ b/templates/skeleton/app/routes/[sitemap.xml].tsx
@@ -22,6 +22,7 @@ type Entry = {
export async function loader({
request,
context: {storefront},
+ response,
}: LoaderFunctionArgs) {
const data = await storefront.query(SITEMAP_QUERY, {
variables: {
@@ -31,18 +32,15 @@ export async function loader({
});
if (!data) {
- throw new Response('No data found', {status: 404});
+ response!.status = 404;
+ throw new Error('No data found');
}
const sitemap = generateSitemap({data, baseUrl: new URL(request.url).origin});
- return new Response(sitemap, {
- headers: {
- 'Content-Type': 'application/xml',
-
- 'Cache-Control': `max-age=${60 * 60 * 24}`,
- },
- });
+ response!.headers.set('Content-Type', 'application/xml');
+ response!.headers.set('Cache-Control', `max-age=${60 * 60 * 24}`);
+ return sitemap;
}
function xmlEncode(string: string) {
diff --git a/templates/skeleton/app/routes/_index.tsx b/templates/skeleton/app/routes/_index.tsx
index 04d2ef6d26..9e199f8761 100644
--- a/templates/skeleton/app/routes/_index.tsx
+++ b/templates/skeleton/app/routes/_index.tsx
@@ -1,4 +1,4 @@
-import {defer, type LoaderFunctionArgs} from '@shopify/remix-oxygen';
+import {type LoaderFunctionArgs} from '@shopify/remix-oxygen';
import {Await, useLoaderData, Link, type MetaFunction} from '@remix-run/react';
import {Suspense} from 'react';
import {Image, Money} from '@shopify/hydrogen';
@@ -17,7 +17,7 @@ export async function loader({context}: LoaderFunctionArgs) {
const featuredCollection = collections.nodes[0];
const recommendedProducts = storefront.query(RECOMMENDED_PRODUCTS_QUERY);
- return defer({featuredCollection, recommendedProducts});
+ return {featuredCollection, recommendedProducts};
}
export default function Homepage() {
diff --git a/templates/skeleton/app/routes/account.$.tsx b/templates/skeleton/app/routes/account.$.tsx
index 53543f62be..d740bfcd12 100644
--- a/templates/skeleton/app/routes/account.$.tsx
+++ b/templates/skeleton/app/routes/account.$.tsx
@@ -1,8 +1,14 @@
-import {redirect, type LoaderFunctionArgs} from '@shopify/remix-oxygen';
+import {type LoaderFunctionArgs} from '@shopify/remix-oxygen';
// fallback wild card for all unauthenticated routes in account section
-export async function loader({context}: LoaderFunctionArgs) {
+export async function loader({context, response}: LoaderFunctionArgs) {
await context.customerAccount.handleAuthStatus();
- return redirect('/account');
+ response!.status = 302;
+ response!.headers.set('Location', '/account');
+ throw response;
+}
+
+export default function FakeNotResourceRoute() {
+ return null;
}
diff --git a/templates/skeleton/app/routes/account._index.tsx b/templates/skeleton/app/routes/account._index.tsx
index e7e4b44e5a..f422194c01 100644
--- a/templates/skeleton/app/routes/account._index.tsx
+++ b/templates/skeleton/app/routes/account._index.tsx
@@ -1,5 +1,11 @@
-import {redirect} from '@shopify/remix-oxygen';
+import {type LoaderFunctionArgs} from '@shopify/remix-oxygen';
-export async function loader() {
- return redirect('/account/orders');
+export async function loader({response}: LoaderFunctionArgs) {
+ response!.status = 302;
+ response!.headers.set('Location', '/account/orders');
+ throw response;
+}
+
+export default function FakeNotResourceRoute() {
+ return null;
}
diff --git a/templates/skeleton/app/routes/account.addresses.tsx b/templates/skeleton/app/routes/account.addresses.tsx
index b45bced01f..01698bd80a 100644
--- a/templates/skeleton/app/routes/account.addresses.tsx
+++ b/templates/skeleton/app/routes/account.addresses.tsx
@@ -4,7 +4,6 @@ import type {
CustomerFragment,
} from 'customer-accountapi.generated';
import {
- json,
type ActionFunctionArgs,
type LoaderFunctionArgs,
} from '@shopify/remix-oxygen';
@@ -36,11 +35,10 @@ export const meta: MetaFunction = () => {
export async function loader({context}: LoaderFunctionArgs) {
await context.customerAccount.handleAuthStatus();
-
- return json({});
+ return {};
}
-export async function action({request, context}: ActionFunctionArgs) {
+export async function action({request, context, response}: ActionFunctionArgs) {
const {customerAccount} = context;
try {
@@ -56,12 +54,8 @@ export async function action({request, context}: ActionFunctionArgs) {
// this will ensure redirecting to login never happen for mutatation
const isLoggedIn = await customerAccount.isLoggedIn();
if (!isLoggedIn) {
- return json(
- {error: {[addressId]: 'Unauthorized'}},
- {
- status: 401,
- },
- );
+ response!.status = 401;
+ return {};
}
const defaultAddress = form.has('defaultAddress')
@@ -111,26 +105,16 @@ export async function action({request, context}: ActionFunctionArgs) {
throw new Error('Customer address create failed.');
}
- return json({
+ return {
error: null,
createdAddress: data?.customerAddressCreate?.customerAddress,
defaultAddress,
- });
+ };
} catch (error: unknown) {
- if (error instanceof Error) {
- return json(
- {error: {[addressId]: error.message}},
- {
- status: 400,
- },
- );
- }
- return json(
- {error: {[addressId]: error}},
- {
- status: 400,
- },
- );
+ response!.status = 400;
+ return error instanceof Error
+ ? {error: {[addressId]: error.message}}
+ : {error: {[addressId]: error}};
}
}
@@ -160,26 +144,16 @@ export async function action({request, context}: ActionFunctionArgs) {
throw new Error('Customer address update failed.');
}
- return json({
+ return {
error: null,
updatedAddress: address,
defaultAddress,
- });
+ };
} catch (error: unknown) {
- if (error instanceof Error) {
- return json(
- {error: {[addressId]: error.message}},
- {
- status: 400,
- },
- );
- }
- return json(
- {error: {[addressId]: error}},
- {
- status: 400,
- },
- );
+ response!.status = 400;
+ return error instanceof Error
+ ? {error: {[addressId]: error.message}}
+ : {error: {[addressId]: error}};
}
}
@@ -205,49 +179,23 @@ export async function action({request, context}: ActionFunctionArgs) {
throw new Error('Customer address delete failed.');
}
- return json({error: null, deletedAddress: addressId});
+ return {error: null, deletedAddress: addressId};
} catch (error: unknown) {
- if (error instanceof Error) {
- return json(
- {error: {[addressId]: error.message}},
- {
- status: 400,
- },
- );
- }
- return json(
- {error: {[addressId]: error}},
- {
- status: 400,
- },
- );
+ response!.status = 400;
+ return error instanceof Error
+ ? {error: {[addressId]: error.message}}
+ : {error: {[addressId]: error}};
}
}
default: {
- return json(
- {error: {[addressId]: 'Method not allowed'}},
- {
- status: 405,
- },
- );
+ response!.status = 405;
+ return {error: {[addressId]: 'Method not allowed'}};
}
}
} catch (error: unknown) {
- if (error instanceof Error) {
- return json(
- {error: error.message},
- {
- status: 400,
- },
- );
- }
- return json(
- {error},
- {
- status: 400,
- },
- );
+ response!.status = 400;
+ return error instanceof Error ? {error: error.message} : {error};
}
}
diff --git a/templates/skeleton/app/routes/account.orders.$id.tsx b/templates/skeleton/app/routes/account.orders.$id.tsx
index 18e9f596ce..3e8464c240 100644
--- a/templates/skeleton/app/routes/account.orders.$id.tsx
+++ b/templates/skeleton/app/routes/account.orders.$id.tsx
@@ -1,16 +1,18 @@
-import {json, redirect, type LoaderFunctionArgs} from '@shopify/remix-oxygen';
-import {useLoaderData, type MetaFunction} from '@remix-run/react';
+import {type LoaderFunctionArgs} from '@shopify/remix-oxygen';
+import {useLoaderData, type MetaArgs_SingleFetch} from '@remix-run/react';
import {Money, Image, flattenConnection} from '@shopify/hydrogen';
import type {OrderLineItemFullFragment} from 'customer-accountapi.generated';
import {CUSTOMER_ORDER_QUERY} from '~/graphql/customer-account/CustomerOrderQuery';
-export const meta: MetaFunction = ({data}) => {
+export function meta({data}: MetaArgs_SingleFetch) {
return [{title: `Order ${data?.order?.name}`}];
-};
+}
-export async function loader({params, context}: LoaderFunctionArgs) {
+export async function loader({params, context, response}: LoaderFunctionArgs) {
if (!params.id) {
- return redirect('/account/orders');
+ response!.status = 302;
+ response!.headers.set('Location', '/account/orders');
+ throw response;
}
const orderId = atob(params.id);
@@ -40,13 +42,13 @@ export async function loader({params, context}: LoaderFunctionArgs) {
firstDiscount?.__typename === 'PricingPercentageValue' &&
firstDiscount?.percentage;
- return json({
+ return {
order,
lineItems,
discountValue,
discountPercentage,
fulfillmentStatus,
- });
+ };
}
export default function OrderRoute() {
diff --git a/templates/skeleton/app/routes/account.orders._index.tsx b/templates/skeleton/app/routes/account.orders._index.tsx
index 76ae6a63a2..1972ec8f6f 100644
--- a/templates/skeleton/app/routes/account.orders._index.tsx
+++ b/templates/skeleton/app/routes/account.orders._index.tsx
@@ -5,7 +5,7 @@ import {
getPaginationVariables,
flattenConnection,
} from '@shopify/hydrogen';
-import {json, redirect, type LoaderFunctionArgs} from '@shopify/remix-oxygen';
+import {type LoaderFunctionArgs} from '@shopify/remix-oxygen';
import {CUSTOMER_ORDERS_QUERY} from '~/graphql/customer-account/CustomerOrdersQuery';
import type {
CustomerOrdersFragment,
@@ -34,7 +34,7 @@ export async function loader({request, context}: LoaderFunctionArgs) {
throw Error('Customer orders not found');
}
- return json({customer: data.customer});
+ return {customer: data.customer};
}
export default function Orders() {
diff --git a/templates/skeleton/app/routes/account.profile.tsx b/templates/skeleton/app/routes/account.profile.tsx
index 2068e8fae5..4b8d0155aa 100644
--- a/templates/skeleton/app/routes/account.profile.tsx
+++ b/templates/skeleton/app/routes/account.profile.tsx
@@ -2,7 +2,6 @@ import type {CustomerFragment} from 'customer-accountapi.generated';
import type {CustomerUpdateInput} from '@shopify/hydrogen/customer-account-api-types';
import {CUSTOMER_UPDATE_MUTATION} from '~/graphql/customer-account/CustomerUpdateMutation';
import {
- json,
type ActionFunctionArgs,
type LoaderFunctionArgs,
} from '@shopify/remix-oxygen';
@@ -25,15 +24,15 @@ export const meta: MetaFunction = () => {
export async function loader({context}: LoaderFunctionArgs) {
await context.customerAccount.handleAuthStatus();
-
- return json({});
+ return {};
}
-export async function action({request, context}: ActionFunctionArgs) {
+export async function action({request, context, response}: ActionFunctionArgs) {
const {customerAccount} = context;
if (request.method !== 'PUT') {
- return json({error: 'Method not allowed'}, {status: 405});
+ response!.status = 405;
+ return {error: 'Method not allowed'};
}
const form = await request.formData();
@@ -68,17 +67,13 @@ export async function action({request, context}: ActionFunctionArgs) {
throw new Error('Customer profile update failed.');
}
- return json({
+ return {
error: null,
customer: data?.customerUpdate?.customer,
- });
+ };
} catch (error: any) {
- return json(
- {error: error.message, customer: null},
- {
- status: 400,
- },
- );
+ response!.status = 400;
+ return {error: error.message, customer: null};
}
}
diff --git a/templates/skeleton/app/routes/account.tsx b/templates/skeleton/app/routes/account.tsx
index 583e62c549..ff13d0965d 100644
--- a/templates/skeleton/app/routes/account.tsx
+++ b/templates/skeleton/app/routes/account.tsx
@@ -1,4 +1,4 @@
-import {json, type LoaderFunctionArgs} from '@shopify/remix-oxygen';
+import {type LoaderFunctionArgs} from '@shopify/remix-oxygen';
import {Form, NavLink, Outlet, useLoaderData} from '@remix-run/react';
import {CUSTOMER_DETAILS_QUERY} from '~/graphql/customer-account/CustomerDetailsQuery';
@@ -6,7 +6,7 @@ export function shouldRevalidate() {
return true;
}
-export async function loader({context}: LoaderFunctionArgs) {
+export async function loader({context, response}: LoaderFunctionArgs) {
const {data, errors} = await context.customerAccount.query(
CUSTOMER_DETAILS_QUERY,
);
@@ -15,14 +15,8 @@ export async function loader({context}: LoaderFunctionArgs) {
throw new Error('Customer not found');
}
- return json(
- {customer: data.customer},
- {
- headers: {
- 'Cache-Control': 'no-cache, no-store, must-revalidate',
- },
- },
- );
+ response!.headers.set('Cache-Control', 'no-cache, no-store, must-revalidate');
+ return {customer: data.customer};
}
export default function AccountLayout() {
diff --git a/templates/skeleton/app/routes/account_.logout.tsx b/templates/skeleton/app/routes/account_.logout.tsx
index 2ca7531e47..528ddadce8 100644
--- a/templates/skeleton/app/routes/account_.logout.tsx
+++ b/templates/skeleton/app/routes/account_.logout.tsx
@@ -1,8 +1,13 @@
-import {redirect, type ActionFunctionArgs} from '@shopify/remix-oxygen';
+import type {
+ LoaderFunctionArgs,
+ ActionFunctionArgs,
+} from '@shopify/remix-oxygen';
// if we dont implement this, /account/logout will get caught by account.$.tsx to do login
-export async function loader() {
- return redirect('/');
+export async function loader({response}: LoaderFunctionArgs) {
+ response!.status = 302;
+ response!.headers.set('Location', '/');
+ throw response;
}
export async function action({context}: ActionFunctionArgs) {
diff --git a/templates/skeleton/app/routes/api.predictive-search.tsx b/templates/skeleton/app/routes/api.predictive-search.tsx
index 680f8e7af6..8c23259fcb 100644
--- a/templates/skeleton/app/routes/api.predictive-search.tsx
+++ b/templates/skeleton/app/routes/api.predictive-search.tsx
@@ -1,4 +1,4 @@
-import {json, type LoaderFunctionArgs} from '@shopify/remix-oxygen';
+import {type LoaderFunctionArgs} from '@shopify/remix-oxygen';
import type {
NormalizedPredictiveSearch,
NormalizedPredictiveSearchResults,
@@ -36,16 +36,24 @@ export type PredictiveSearchAPILoader = typeof loader;
* Fetches the search results from the predictive search API
* requested by the SearchForm component
*/
-export async function loader({request, params, context}: LoaderFunctionArgs) {
+export async function loader({
+ request,
+ params,
+ context,
+ response,
+}: LoaderFunctionArgs) {
const search = await fetchPredictiveSearchResults({
params,
request,
context,
});
- return json(search, {
- headers: {'Cache-Control': `max-age=${search.searchTerm ? 60 : 3600}`},
- });
+ response!.headers.append(
+ 'Cache-Control',
+ `max-age=${search.searchTerm ? 60 : 3600}`,
+ );
+
+ return search;
}
async function fetchPredictiveSearchResults({
diff --git a/templates/skeleton/app/routes/blogs.$blogHandle.$articleHandle.tsx b/templates/skeleton/app/routes/blogs.$blogHandle.$articleHandle.tsx
index d7cac79e77..990e736f38 100644
--- a/templates/skeleton/app/routes/blogs.$blogHandle.$articleHandle.tsx
+++ b/templates/skeleton/app/routes/blogs.$blogHandle.$articleHandle.tsx
@@ -1,4 +1,4 @@
-import {json, type LoaderFunctionArgs} from '@shopify/remix-oxygen';
+import {type LoaderFunctionArgs} from '@shopify/remix-oxygen';
import {useLoaderData, type MetaFunction} from '@remix-run/react';
import {Image} from '@shopify/hydrogen';
@@ -6,11 +6,12 @@ export const meta: MetaFunction = ({data}) => {
return [{title: `Hydrogen | ${data?.article.title ?? ''} article`}];
};
-export async function loader({params, context}: LoaderFunctionArgs) {
+export async function loader({params, context, response}: LoaderFunctionArgs) {
const {blogHandle, articleHandle} = params;
if (!articleHandle || !blogHandle) {
- throw new Response('Not found', {status: 404});
+ response!.status = 404;
+ throw new Error('Not found');
}
const {blog} = await context.storefront.query(ARTICLE_QUERY, {
@@ -18,12 +19,13 @@ export async function loader({params, context}: LoaderFunctionArgs) {
});
if (!blog?.articleByHandle) {
- throw new Response(null, {status: 404});
+ response!.status = 404;
+ throw response;
}
const article = blog.articleByHandle;
- return json({article});
+ return {article};
}
export default function Article() {
diff --git a/templates/skeleton/app/routes/blogs.$blogHandle._index.tsx b/templates/skeleton/app/routes/blogs.$blogHandle._index.tsx
index 46725e27dd..a52db913eb 100644
--- a/templates/skeleton/app/routes/blogs.$blogHandle._index.tsx
+++ b/templates/skeleton/app/routes/blogs.$blogHandle._index.tsx
@@ -1,4 +1,4 @@
-import {json, type LoaderFunctionArgs} from '@shopify/remix-oxygen';
+import {type LoaderFunctionArgs} from '@shopify/remix-oxygen';
import {Link, useLoaderData, type MetaFunction} from '@remix-run/react';
import {Image, Pagination, getPaginationVariables} from '@shopify/hydrogen';
import type {ArticleItemFragment} from 'storefrontapi.generated';
@@ -11,13 +11,15 @@ export async function loader({
request,
params,
context: {storefront},
+ response,
}: LoaderFunctionArgs) {
const paginationVariables = getPaginationVariables(request, {
pageBy: 4,
});
if (!params.blogHandle) {
- throw new Response(`blog not found`, {status: 404});
+ response!.status = 404;
+ throw new Error('Blog not found');
}
const {blog} = await storefront.query(BLOGS_QUERY, {
@@ -28,10 +30,11 @@ export async function loader({
});
if (!blog?.articles) {
- throw new Response('Not found', {status: 404});
+ response!.status = 404;
+ throw new Error('Not found');
}
- return json({blog});
+ return {blog};
}
export default function Blog() {
diff --git a/templates/skeleton/app/routes/blogs._index.tsx b/templates/skeleton/app/routes/blogs._index.tsx
index 8d92c6afd2..6ce6c8be66 100644
--- a/templates/skeleton/app/routes/blogs._index.tsx
+++ b/templates/skeleton/app/routes/blogs._index.tsx
@@ -1,4 +1,4 @@
-import {json, type LoaderFunctionArgs} from '@shopify/remix-oxygen';
+import {type LoaderFunctionArgs} from '@shopify/remix-oxygen';
import {Link, useLoaderData, type MetaFunction} from '@remix-run/react';
import {Pagination, getPaginationVariables} from '@shopify/hydrogen';
@@ -20,7 +20,7 @@ export const loader = async ({
},
});
- return json({blogs});
+ return {blogs};
};
export default function Blogs() {
diff --git a/templates/skeleton/app/routes/cart.$lines.tsx b/templates/skeleton/app/routes/cart.$lines.tsx
index fab617e264..ba33013de0 100644
--- a/templates/skeleton/app/routes/cart.$lines.tsx
+++ b/templates/skeleton/app/routes/cart.$lines.tsx
@@ -1,4 +1,4 @@
-import {redirect, type LoaderFunctionArgs} from '@shopify/remix-oxygen';
+import {type LoaderFunctionArgs} from '@shopify/remix-oxygen';
/**
* Automatically creates a new cart based on the URL and redirects straight to checkout.
@@ -18,10 +18,19 @@ import {redirect, type LoaderFunctionArgs} from '@shopify/remix-oxygen';
*
* ```
*/
-export async function loader({request, context, params}: LoaderFunctionArgs) {
+export async function loader({
+ request,
+ context,
+ params,
+ response,
+}: LoaderFunctionArgs) {
const {cart} = context;
const {lines} = params;
- if (!lines) return redirect('/cart');
+ if (!lines) {
+ response!.status = 302;
+ response!.headers.set('Location', '/cart');
+ throw response;
+ }
const linesMap = lines.split(',').map((line) => {
const lineDetails = line.split(':');
const variantId = lineDetails[0];
@@ -48,17 +57,22 @@ export async function loader({request, context, params}: LoaderFunctionArgs) {
const cartResult = result.cart;
if (result.errors?.length || !cartResult) {
- throw new Response('Link may be expired. Try checking the URL.', {
- status: 410,
- });
+ response!.status = 410;
+ throw new Error('Link may be expired. Try checking the URL.');
}
// Update cart id in cookie
const headers = cart.setCartId(cartResult.id);
+ const cookieValue = headers.get('Set-Cookie');
+ if (cookieValue) {
+ response!.headers.set('Set-Cookie', cookieValue);
+ }
// redirect to checkout
if (cartResult.checkoutUrl) {
- return redirect(cartResult.checkoutUrl, {headers});
+ response!.status = 302;
+ response!.headers.set('Location', cartResult.checkoutUrl);
+ throw response;
} else {
throw new Error('No checkout URL found');
}
diff --git a/templates/skeleton/app/routes/cart.tsx b/templates/skeleton/app/routes/cart.tsx
index ce13bb5f65..ef05489e83 100644
--- a/templates/skeleton/app/routes/cart.tsx
+++ b/templates/skeleton/app/routes/cart.tsx
@@ -2,7 +2,7 @@ import {Await, type MetaFunction} from '@remix-run/react';
import {Suspense} from 'react';
import type {CartQueryDataReturn} from '@shopify/hydrogen';
import {CartForm} from '@shopify/hydrogen';
-import {json, type ActionFunctionArgs} from '@shopify/remix-oxygen';
+import {type ActionFunctionArgs} from '@shopify/remix-oxygen';
import {CartMain} from '~/components/Cart';
import {useRootLoaderData} from '~/lib/root-data';
@@ -10,7 +10,7 @@ export const meta: MetaFunction = () => {
return [{title: `Hydrogen | Cart`}];
};
-export async function action({request, context}: ActionFunctionArgs) {
+export async function action({request, context, response}: ActionFunctionArgs) {
const {cart} = context;
const formData = await request.formData();
@@ -21,9 +21,7 @@ export async function action({request, context}: ActionFunctionArgs) {
throw new Error('No action provided');
}
- let status = 200;
let result: CartQueryDataReturn;
-
switch (action) {
case CartForm.ACTIONS.LinesAdd:
result = await cart.addLines(inputs.lines);
@@ -60,24 +58,26 @@ export async function action({request, context}: ActionFunctionArgs) {
const cartId = result?.cart?.id;
const headers = cartId ? cart.setCartId(result.cart.id) : new Headers();
+ const cookieValue = headers.get('Set-Cookie');
+ if (cookieValue) {
+ response!.headers.set('Set-Cookie', cookieValue);
+ }
+
const {cart: cartResult, errors} = result;
const redirectTo = formData.get('redirectTo') ?? null;
if (typeof redirectTo === 'string') {
- status = 303;
- headers.set('Location', redirectTo);
+ response!.status = 303;
+ response!.headers.set('Location', redirectTo);
}
- return json(
- {
- cart: cartResult,
- errors,
- analytics: {
- cartId,
- },
+ return {
+ cart: cartResult,
+ errors,
+ analytics: {
+ cartId,
},
- {status, headers},
- );
+ };
}
export default function Cart() {
diff --git a/templates/skeleton/app/routes/collections.$handle.tsx b/templates/skeleton/app/routes/collections.$handle.tsx
index aa64fc69d1..cca2da5253 100644
--- a/templates/skeleton/app/routes/collections.$handle.tsx
+++ b/templates/skeleton/app/routes/collections.$handle.tsx
@@ -1,5 +1,5 @@
-import {json, redirect, type LoaderFunctionArgs} from '@shopify/remix-oxygen';
-import {useLoaderData, Link, type MetaFunction} from '@remix-run/react';
+import {type LoaderFunctionArgs} from '@shopify/remix-oxygen';
+import {useLoaderData, Link, type MetaArgs_SingleFetch} from '@remix-run/react';
import {
Pagination,
getPaginationVariables,
@@ -9,11 +9,16 @@ import {
import type {ProductItemFragment} from 'storefrontapi.generated';
import {useVariantUrl} from '~/lib/variants';
-export const meta: MetaFunction = ({data}) => {
+export function meta({data}: MetaArgs_SingleFetch) {
return [{title: `Hydrogen | ${data?.collection.title ?? ''} Collection`}];
-};
+}
-export async function loader({request, params, context}: LoaderFunctionArgs) {
+export async function loader({
+ request,
+ params,
+ context,
+ response,
+}: LoaderFunctionArgs) {
const {handle} = params;
const {storefront} = context;
const paginationVariables = getPaginationVariables(request, {
@@ -21,7 +26,9 @@ export async function loader({request, params, context}: LoaderFunctionArgs) {
});
if (!handle) {
- return redirect('/collections');
+ response!.status = 302;
+ response!.headers.set('Location', '/collections');
+ throw response;
}
const {collection} = await storefront.query(COLLECTION_QUERY, {
@@ -29,11 +36,10 @@ export async function loader({request, params, context}: LoaderFunctionArgs) {
});
if (!collection) {
- throw new Response(`Collection ${handle} not found`, {
- status: 404,
- });
+ response!.status = 404;
+ throw new Error(`Collection ${handle} not found`);
}
- return json({collection});
+ return {collection};
}
export default function Collection() {
diff --git a/templates/skeleton/app/routes/collections._index.tsx b/templates/skeleton/app/routes/collections._index.tsx
index af55f26807..21f6f68ed9 100644
--- a/templates/skeleton/app/routes/collections._index.tsx
+++ b/templates/skeleton/app/routes/collections._index.tsx
@@ -1,5 +1,5 @@
import {useLoaderData, Link} from '@remix-run/react';
-import {json, type LoaderFunctionArgs} from '@shopify/remix-oxygen';
+import {type LoaderFunctionArgs} from '@shopify/remix-oxygen';
import {Pagination, getPaginationVariables, Image} from '@shopify/hydrogen';
import type {CollectionFragment} from 'storefrontapi.generated';
@@ -12,7 +12,7 @@ export async function loader({context, request}: LoaderFunctionArgs) {
variables: paginationVariables,
});
- return json({collections});
+ return {collections};
}
export default function Collections() {
diff --git a/templates/skeleton/app/routes/collections.all.tsx b/templates/skeleton/app/routes/collections.all.tsx
index 5e353ec226..f56f2d2ce0 100644
--- a/templates/skeleton/app/routes/collections.all.tsx
+++ b/templates/skeleton/app/routes/collections.all.tsx
@@ -1,4 +1,4 @@
-import {json, type LoaderFunctionArgs} from '@shopify/remix-oxygen';
+import {type LoaderFunctionArgs} from '@shopify/remix-oxygen';
import {useLoaderData, Link, type MetaFunction} from '@remix-run/react';
import {
Pagination,
@@ -23,7 +23,7 @@ export async function loader({request, context}: LoaderFunctionArgs) {
variables: {...paginationVariables},
});
- return json({products});
+ return {products};
}
export default function Collection() {
diff --git a/templates/skeleton/app/routes/discount.$code.tsx b/templates/skeleton/app/routes/discount.$code.tsx
index c613c82ea7..bcbc5f0eb9 100644
--- a/templates/skeleton/app/routes/discount.$code.tsx
+++ b/templates/skeleton/app/routes/discount.$code.tsx
@@ -1,4 +1,4 @@
-import {redirect, type LoaderFunctionArgs} from '@shopify/remix-oxygen';
+import {type LoaderFunctionArgs} from '@shopify/remix-oxygen';
/**
* Automatically applies a discount found on the url
@@ -11,7 +11,12 @@ import {redirect, type LoaderFunctionArgs} from '@shopify/remix-oxygen';
*
* ```
*/
-export async function loader({request, context, params}: LoaderFunctionArgs) {
+export async function loader({
+ request,
+ context,
+ params,
+ response,
+}: LoaderFunctionArgs) {
const {cart} = context;
const {code} = params;
@@ -31,7 +36,9 @@ export async function loader({request, context, params}: LoaderFunctionArgs) {
const redirectUrl = `${redirectParam}?${searchParams}`;
if (!code) {
- return redirect(redirectUrl);
+ response!.status = 302;
+ response!.headers.set('Location', redirectUrl);
+ throw response;
}
const result = await cart.updateDiscountCodes([code]);
@@ -40,8 +47,7 @@ export async function loader({request, context, params}: LoaderFunctionArgs) {
// Using set-cookie on a 303 redirect will not work if the domain origin have port number (:3000)
// If there is no cart id and a new cart id is created in the progress, it will not be set in the cookie
// on localhost:3000
- return redirect(redirectUrl, {
- status: 303,
- headers,
- });
+ response!.status = 303;
+ response!.headers.set('Location', redirectUrl);
+ throw response;
}
diff --git a/templates/skeleton/app/routes/pages.$handle.tsx b/templates/skeleton/app/routes/pages.$handle.tsx
index d8a8e4ab96..df28ada89b 100644
--- a/templates/skeleton/app/routes/pages.$handle.tsx
+++ b/templates/skeleton/app/routes/pages.$handle.tsx
@@ -1,11 +1,11 @@
-import {json, type LoaderFunctionArgs} from '@shopify/remix-oxygen';
+import {type LoaderFunctionArgs} from '@shopify/remix-oxygen';
import {useLoaderData, type MetaFunction} from '@remix-run/react';
export const meta: MetaFunction = ({data}) => {
return [{title: `Hydrogen | ${data?.page.title ?? ''}`}];
};
-export async function loader({params, context}: LoaderFunctionArgs) {
+export async function loader({params, context, response}: LoaderFunctionArgs) {
if (!params.handle) {
throw new Error('Missing page handle');
}
@@ -17,10 +17,11 @@ export async function loader({params, context}: LoaderFunctionArgs) {
});
if (!page) {
- throw new Response('Not Found', {status: 404});
+ response!.status = 404;
+ throw new Error('Not Found');
}
- return json({page});
+ return {page};
}
export default function Page() {
diff --git a/templates/skeleton/app/routes/policies.$handle.tsx b/templates/skeleton/app/routes/policies.$handle.tsx
index 367d4c431b..197d266038 100644
--- a/templates/skeleton/app/routes/policies.$handle.tsx
+++ b/templates/skeleton/app/routes/policies.$handle.tsx
@@ -1,4 +1,4 @@
-import {json, type LoaderFunctionArgs} from '@shopify/remix-oxygen';
+import {type LoaderFunctionArgs} from '@shopify/remix-oxygen';
import {Link, useLoaderData, type MetaFunction} from '@remix-run/react';
import {type Shop} from '@shopify/hydrogen/storefront-api-types';
@@ -11,9 +11,10 @@ export const meta: MetaFunction = ({data}) => {
return [{title: `Hydrogen | ${data?.policy.title ?? ''}`}];
};
-export async function loader({params, context}: LoaderFunctionArgs) {
+export async function loader({params, context, response}: LoaderFunctionArgs) {
if (!params.handle) {
- throw new Response('No handle was passed in', {status: 404});
+ response!.status = 404;
+ throw new Error('No handle was passed in');
}
const policyName = params.handle.replace(
@@ -35,10 +36,11 @@ export async function loader({params, context}: LoaderFunctionArgs) {
const policy = data.shop?.[policyName];
if (!policy) {
- throw new Response('Could not find the policy', {status: 404});
+ response!.status = 404;
+ throw new Error('Could not find the policy');
}
- return json({policy});
+ return {policy};
}
export default function Policy() {
diff --git a/templates/skeleton/app/routes/policies._index.tsx b/templates/skeleton/app/routes/policies._index.tsx
index 243ee1fa52..34fdda7f67 100644
--- a/templates/skeleton/app/routes/policies._index.tsx
+++ b/templates/skeleton/app/routes/policies._index.tsx
@@ -1,15 +1,16 @@
-import {json, type LoaderFunctionArgs} from '@shopify/remix-oxygen';
+import {type LoaderFunctionArgs} from '@shopify/remix-oxygen';
import {useLoaderData, Link} from '@remix-run/react';
-export async function loader({context}: LoaderFunctionArgs) {
+export async function loader({context, response}: LoaderFunctionArgs) {
const data = await context.storefront.query(POLICIES_QUERY);
const policies = Object.values(data.shop || {});
if (!policies.length) {
- throw new Response('No policies found', {status: 404});
+ response!.status = 404;
+ throw new Error('No policies found');
}
- return json({policies});
+ return {policies};
}
export default function Policies() {
diff --git a/templates/skeleton/app/routes/products.$handle.tsx b/templates/skeleton/app/routes/products.$handle.tsx
index 21d21dc3fc..a84d470867 100644
--- a/templates/skeleton/app/routes/products.$handle.tsx
+++ b/templates/skeleton/app/routes/products.$handle.tsx
@@ -1,5 +1,5 @@
import {Suspense} from 'react';
-import {defer, redirect, type LoaderFunctionArgs} from '@shopify/remix-oxygen';
+import {type LoaderFunctionArgs} from '@shopify/remix-oxygen';
import {
Await,
Link,
@@ -24,12 +24,18 @@ import {
import type {SelectedOption} from '@shopify/hydrogen/storefront-api-types';
import {getVariantUrl} from '~/lib/variants';
import {useAside} from '~/components/Aside';
+import {ResponseStub} from '@remix-run/server-runtime/dist/single-fetch';
export const meta: MetaFunction = ({data}) => {
return [{title: `Hydrogen | ${data?.product.title ?? ''}`}];
};
-export async function loader({params, request, context}: LoaderFunctionArgs) {
+export async function loader({
+ params,
+ request,
+ context,
+ response,
+}: LoaderFunctionArgs) {
const {handle} = params;
const {storefront} = context;
@@ -43,7 +49,8 @@ export async function loader({params, request, context}: LoaderFunctionArgs) {
});
if (!product?.id) {
- throw new Response(null, {status: 404});
+ response!.status = 404;
+ throw response;
}
const firstVariant = product.variants.nodes[0];
@@ -60,7 +67,7 @@ export async function loader({params, request, context}: LoaderFunctionArgs) {
// if no selected variant was returned from the selected options,
// we redirect to the first variant's url with it's selected options applied
if (!product.selectedVariant) {
- throw redirectToFirstVariant({product, request});
+ throw redirectToFirstVariant({product, request, response: response!});
}
}
@@ -73,30 +80,32 @@ export async function loader({params, request, context}: LoaderFunctionArgs) {
variables: {handle},
});
- return defer({product, variants});
+ return {product, variants};
}
function redirectToFirstVariant({
product,
request,
+ response,
}: {
product: ProductFragment;
request: Request;
+ response: ResponseStub;
}) {
const url = new URL(request.url);
const firstVariant = product.variants.nodes[0];
- return redirect(
+ response!.status = 302;
+ response!.headers.set(
+ 'Location',
getVariantUrl({
pathname: url.pathname,
handle: product.handle,
selectedOptions: firstVariant.selectedOptions,
searchParams: new URLSearchParams(url.search),
}),
- {
- status: 302,
- },
);
+ return response;
}
export default function Product() {
diff --git a/templates/skeleton/app/routes/search.tsx b/templates/skeleton/app/routes/search.tsx
index d73f6fd32b..cac2db18d4 100644
--- a/templates/skeleton/app/routes/search.tsx
+++ b/templates/skeleton/app/routes/search.tsx
@@ -1,4 +1,4 @@
-import {defer, type LoaderFunctionArgs} from '@shopify/remix-oxygen';
+import {type LoaderFunctionArgs} from '@shopify/remix-oxygen';
import {useLoaderData, type MetaFunction} from '@remix-run/react';
import {getPaginationVariables} from '@shopify/hydrogen';
@@ -41,10 +41,10 @@ export async function loader({request, context}: LoaderFunctionArgs) {
totalResults,
};
- return defer({
+ return {
searchTerm,
searchResults,
- });
+ };
}
export default function SearchPage() {
diff --git a/templates/skeleton/server.ts b/templates/skeleton/server.ts
index 448553607c..ff1ce2ba87 100644
--- a/templates/skeleton/server.ts
+++ b/templates/skeleton/server.ts
@@ -96,10 +96,6 @@ export default {
const response = await handleRequest(request);
- if (session.isPending) {
- response.headers.set('Set-Cookie', await session.commit());
- }
-
if (response.status === 404) {
/**
* Check for redirects only when there's a 404 from the app.
diff --git a/templates/skeleton/tsconfig.json b/templates/skeleton/tsconfig.json
index dcd7c7237a..0ec332f3fb 100644
--- a/templates/skeleton/tsconfig.json
+++ b/templates/skeleton/tsconfig.json
@@ -14,7 +14,10 @@
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"baseUrl": ".",
- "types": ["@shopify/oxygen-workers-types"],
+ "types": [
+ "@shopify/oxygen-workers-types",
+ "@remix-run/react/future/single-fetch.d.ts"
+ ],
"paths": {
"~/*": ["app/*"]
},
diff --git a/templates/skeleton/vite.config.ts b/templates/skeleton/vite.config.ts
index f2da56a0a9..293f35ed4a 100644
--- a/templates/skeleton/vite.config.ts
+++ b/templates/skeleton/vite.config.ts
@@ -2,8 +2,11 @@ import {defineConfig} from 'vite';
import {hydrogen} from '@shopify/hydrogen/vite';
import {oxygen} from '@shopify/mini-oxygen/vite';
import {vitePlugin as remix} from '@remix-run/dev';
+import {installGlobals} from '@remix-run/node';
import tsconfigPaths from 'vite-tsconfig-paths';
+installGlobals({nativeFetch: true});
+
export default defineConfig({
plugins: [
hydrogen(),
@@ -14,6 +17,7 @@ export default defineConfig({
v3_fetcherPersist: true,
v3_relativeSplatPath: true,
v3_throwAbortReason: true,
+ unstable_singleFetch: true,
},
}),
tsconfigPaths(),