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(),