From 80842735a3d3fdc6d634bae417d0f521c961ddaa Mon Sep 17 00:00:00 2001 From: Michelle Chen Date: Mon, 13 May 2024 14:08:36 -0400 Subject: [PATCH] turn on single fetch feature flag & make single fetch works remove any defer & json loader or action return remove all of redirect not sure how to solve these ... replace UIMatch remove deprecate methods from remix-oxygen change new Response in loader/action to use response arg instead remove question mark from response when it is coming from defineLoader/defineAction, also use headers.set when its right before the return fix CSP with Inline Scripts fix search type with loader type update cart utility and put response into options update customerAccount client to use response to set session header fix redirect in loader (resource route), ensure to return a Response instead of manipulating the response object get positive flow working. Looks like action & loader works slightly differently run codemod on skeleton resource route, return response object instead add nativeFetch fully use ResponseStub get rid of session.helper revert to response object return revert Hydrogen package use Response Object for utilities and ResponseStub for skeleton template add cf compat flag add example fix --- examples/README.md | 1 + examples/single-fetch/README.md | 14 +++ packages/hydrogen/src/customer/customer.ts | 4 +- packages/mini-oxygen/src/common/compat.ts | 5 +- templates/skeleton/app/entry.server.tsx | 2 +- templates/skeleton/app/lib/root-data.ts | 4 +- templates/skeleton/app/root.tsx | 6 +- templates/skeleton/app/routes/$.tsx | 7 +- .../skeleton/app/routes/[robots.txt].tsx | 14 +-- .../skeleton/app/routes/[sitemap.xml].tsx | 14 ++- templates/skeleton/app/routes/_index.tsx | 4 +- templates/skeleton/app/routes/account.$.tsx | 12 ++- .../skeleton/app/routes/account._index.tsx | 12 ++- .../skeleton/app/routes/account.addresses.tsx | 102 +++++------------- .../app/routes/account.orders.$id.tsx | 18 ++-- .../app/routes/account.orders._index.tsx | 4 +- .../skeleton/app/routes/account.profile.tsx | 21 ++-- templates/skeleton/app/routes/account.tsx | 14 +-- .../skeleton/app/routes/account_.logout.tsx | 11 +- .../app/routes/api.predictive-search.tsx | 18 +++- .../blogs.$blogHandle.$articleHandle.tsx | 12 ++- .../app/routes/blogs.$blogHandle._index.tsx | 11 +- .../skeleton/app/routes/blogs._index.tsx | 4 +- templates/skeleton/app/routes/cart.$lines.tsx | 28 +++-- templates/skeleton/app/routes/cart.tsx | 30 +++--- .../app/routes/collections.$handle.tsx | 26 +++-- .../app/routes/collections._index.tsx | 4 +- .../skeleton/app/routes/collections.all.tsx | 4 +- .../skeleton/app/routes/discount.$code.tsx | 20 ++-- .../skeleton/app/routes/pages.$handle.tsx | 9 +- .../skeleton/app/routes/policies.$handle.tsx | 12 ++- .../skeleton/app/routes/policies._index.tsx | 9 +- .../skeleton/app/routes/products.$handle.tsx | 27 +++-- templates/skeleton/app/routes/search.tsx | 6 +- templates/skeleton/server.ts | 4 - templates/skeleton/tsconfig.json | 5 +- templates/skeleton/vite.config.ts | 4 + 37 files changed, 262 insertions(+), 240 deletions(-) create mode 100644 examples/single-fetch/README.md 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(),