Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add full page caching #13

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@
PUBLIC_STORE_DOMAIN="mock.shop"
SESSION_SECRET="mock secret"

# Populate your own values here and above for a real store
# PUBLIC_STOREFRONT_API_TOKEN=""
# PRIVATE_STOREFRONT_API_TOKEN=""
# PUBLIC_STOREFRONT_ID=""
# PUBLIC_STOREFRONT_ID=""
# PUBLIC_CUSTOMER_ACCOUNT_API_CLIENT_ID=""
# PUBLIC_CUSTOMER_ACCOUNT_API_URL=""
# SHOP_ID=""
# PUBLIC_CHECKOUT_DOMAIN=""
10 changes: 6 additions & 4 deletions app/components/CartMain.tsx
Original file line number Diff line number Diff line change
@@ -1,26 +1,28 @@
import {useOptimisticCart} from '@shopify/hydrogen';
import {Link} from '@remix-run/react';
import type {CartApiQueryFragment} from 'storefrontapi.generated';
import {useAside} from '~/components/Aside';
import {CartLineItem} from '~/components/CartLineItem';
import {CartSummary} from './CartSummary';
import {CartSummary} from '~/components/CartSummary';
import {useCart} from '~/components/CartProvider';

export type CartLayout = 'page' | 'aside';

export type CartMainProps = {
cart: CartApiQueryFragment | null;
layout: CartLayout;
};

/**
* The main cart component that displays the cart items and summary.
* It is used by both the /cart route and the cart aside dialog.
*/
export function CartMain({layout, cart: originalCart}: CartMainProps) {
export function CartMain({layout}: CartMainProps) {
const originalCart = useCart();
// The useOptimisticCart hook applies pending actions to the cart
// so the user immediately sees feedback when they modify the cart.
const cart = useOptimisticCart(originalCart);

if (!originalCart) return <p>Loading cart ...</p>;

const linesCount = Boolean(cart?.lines?.nodes?.length || 0);
const withDiscount =
cart &&
Expand Down
24 changes: 24 additions & 0 deletions app/components/CartProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import {createContext, useContext, useEffect} from 'react';
import {useFetcher} from '@remix-run/react';
import type {Cart} from '@shopify/hydrogen-react/storefront-api-types';

const CartContext = createContext<Cart | undefined>(undefined);

export function CartProvider({children}: {children: React.ReactNode}) {
const fetcher = useFetcher<Cart | undefined>();

useEffect(() => {
if (fetcher.state === 'loading') return;
if (fetcher.data) return;

fetcher.load('/cart');
}, [fetcher]);

return (
<CartContext.Provider value={fetcher.data}>{children}</CartContext.Provider>
);
}

export function useCart() {
return useContext(CartContext);
}
25 changes: 25 additions & 0 deletions app/components/CustomerProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import {createContext, useContext, useEffect} from 'react';
import {useFetcher} from '@remix-run/react';
import type {Customer} from '@shopify/hydrogen-react/storefront-api-types';

const CustomerContext = createContext<Customer | undefined>(undefined);

export function CustomerProvider({children}: {children: React.ReactNode}) {
const fetcher = useFetcher<Customer>();

useEffect(() => {
if (fetcher.data || fetcher.state === 'loading') return;

fetcher.load('/account');
}, [fetcher]);

return (
<CustomerContext.Provider value={fetcher.data}>
{children}
</CustomerContext.Provider>
);
}

export function useCustomer() {
return useContext(CustomerContext);
}
47 changes: 14 additions & 33 deletions app/components/Header.tsx
Original file line number Diff line number Diff line change
@@ -1,24 +1,18 @@
import {Suspense} from 'react';
import {Await, NavLink} from '@remix-run/react';
import {NavLink} from '@remix-run/react';
import {type CartViewPayload, useAnalytics} from '@shopify/hydrogen';
import type {HeaderQuery, CartApiQueryFragment} from 'storefrontapi.generated';
import type {HeaderQuery} from 'storefrontapi.generated';
import {useAside} from '~/components/Aside';
import {useCart} from '~/components/CartProvider';
import {useCustomer} from '~/components/CustomerProvider';

interface HeaderProps {
header: HeaderQuery;
cart: Promise<CartApiQueryFragment | null>;
isLoggedIn: Promise<boolean>;
publicStoreDomain: string;
}

type Viewport = 'desktop' | 'mobile';

export function Header({
header,
isLoggedIn,
cart,
publicStoreDomain,
}: HeaderProps) {
export function Header({header, publicStoreDomain}: HeaderProps) {
const {shop, menu} = header;
return (
<header className="header">
Expand All @@ -31,7 +25,7 @@ export function Header({
primaryDomainUrl={header.shop.primaryDomain.url}
publicStoreDomain={publicStoreDomain}
/>
<HeaderCtas isLoggedIn={isLoggedIn} cart={cart} />
<HeaderCtas />
</header>
);
}
Expand Down Expand Up @@ -91,22 +85,16 @@ export function HeaderMenu({
);
}

function HeaderCtas({
isLoggedIn,
cart,
}: Pick<HeaderProps, 'isLoggedIn' | 'cart'>) {
function HeaderCtas() {
const customer = useCustomer();
return (
<nav className="header-ctas" role="navigation">
<HeaderMenuMobileToggle />
<NavLink prefetch="intent" to="/account" style={activeLinkStyle}>
<Suspense fallback="Sign in">
<Await resolve={isLoggedIn} errorElement="Sign in">
{(isLoggedIn) => (isLoggedIn ? 'Account' : 'Sign in')}
</Await>
</Suspense>
{customer != null ? 'Account' : 'Sign in'}
</NavLink>
<SearchToggle />
<CartToggle cart={cart} />
<CartToggle />
</nav>
);
}
Expand Down Expand Up @@ -155,17 +143,10 @@ function CartBadge({count}: {count: number | null}) {
);
}

function CartToggle({cart}: Pick<HeaderProps, 'cart'>) {
return (
<Suspense fallback={<CartBadge count={null} />}>
<Await resolve={cart}>
{(cart) => {
if (!cart) return <CartBadge count={0} />;
return <CartBadge count={cart.totalQuantity || 0} />;
}}
</Await>
</Suspense>
);
function CartToggle() {
const cart = useCart();
if (!cart) return <CartBadge count={0} />;
return <CartBadge count={cart.totalQuantity || 0} />;
}

const FALLBACK_HEADER_MENU = {
Expand Down
32 changes: 6 additions & 26 deletions app/components/PageLayout.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,5 @@
import {Await, Link} from '@remix-run/react';
import {Suspense} from 'react';
import type {
CartApiQueryFragment,
FooterQuery,
HeaderQuery,
} from 'storefrontapi.generated';
import {Link} from '@remix-run/react';
import type {FooterQuery, HeaderQuery} from 'storefrontapi.generated';
import {Aside} from '~/components/Aside';
import {Footer} from '~/components/Footer';
import {Header, HeaderMenu} from '~/components/Header';
Expand All @@ -16,34 +11,25 @@ import {
import {SearchResultsPredictive} from '~/components/SearchResultsPredictive';

interface PageLayoutProps {
cart: Promise<CartApiQueryFragment | null>;
footer: Promise<FooterQuery | null>;
header: HeaderQuery;
isLoggedIn: Promise<boolean>;
publicStoreDomain: string;
children?: React.ReactNode;
}

export function PageLayout({
cart,
children = null,
footer,
header,
isLoggedIn,
publicStoreDomain,
}: PageLayoutProps) {
return (
<Aside.Provider>
<CartAside cart={cart} />
<CartAside />
<SearchAside />
<MobileMenuAside header={header} publicStoreDomain={publicStoreDomain} />
{header && (
<Header
header={header}
cart={cart}
isLoggedIn={isLoggedIn}
publicStoreDomain={publicStoreDomain}
/>
<Header header={header} publicStoreDomain={publicStoreDomain} />
)}
<main>{children}</main>
<Footer
Expand All @@ -55,16 +41,10 @@ export function PageLayout({
);
}

function CartAside({cart}: {cart: PageLayoutProps['cart']}) {
function CartAside() {
return (
<Aside type="cart" heading="CART">
<Suspense fallback={<p>Loading cart ...</p>}>
<Await resolve={cart}>
{(cart) => {
return <CartMain cart={cart} layout="aside" />;
}}
</Await>
</Suspense>
<CartMain layout="aside" />
</Aside>
);
}
Expand Down
30 changes: 30 additions & 0 deletions app/entry.server.tsx
Original file line number Diff line number Diff line change
@@ -1,2 +1,32 @@
import type {
ActionFunctionArgs,
LoaderFunctionArgs,
} from '@netlify/remix-runtime';
import {CACHE_1_HOUR_SWR} from '~/lib/page-cache';

// @ts-ignore -- This is a Vite virtual module. It will be resolved at build time.
export {default} from 'virtual:netlify-server-entry';

export function handleDataRequest(
response: Response,
{request}: LoaderFunctionArgs | ActionFunctionArgs,
) {
// If a loader has defined custom cache headers, assume they know what they're doing. Otherwise,
// apply these defaults. We do this here because there is no Remix mechanism to define default
// loader headers, nor is there a mechanism for routes to inherit parent route loader headers.
const hasCustomCacheControl =
response.headers.has('Netlify-CDN-Cache-Control') ||
response.headers.has('CDN-Cache-Control') ||
response.headers.has('Cache-Control');
// FIXME(serhalp) This is probably incomplete. I was just doing enough for a proof of concept here.
const isCacheable =
// X-Remix-Response indicates this is sending a "response" as opposed to a redirect (I think).
// Probably should do something less leaky here if we merge this.
request.method === 'GET' && response.headers.has('X-Remix-Response');
if (!hasCustomCacheControl && isCacheable) {
for (const [key, value] of Object.entries(CACHE_1_HOUR_SWR)) {
response.headers.set(key, value);
}
}
return response;
}
23 changes: 23 additions & 0 deletions app/lib/page-cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
// Remix resource routes result in the same URL serving both an HTML page response and a JSON data
// response, so we need to check for `?_data=` to avoid conflating them in the cache.
// See https://remix.run/docs/en/main/guides/resource-routes.
const CACHE_REMIX_RESOURCE_ROUTES = {
'Netlify-Vary': 'query=_data',
};

export const NO_CACHE = {
...CACHE_REMIX_RESOURCE_ROUTES,
'Cache-Control': 'private, no-store, no-cache, must-revalidate',
};

export const CACHE_1_DAY = {
...CACHE_REMIX_RESOURCE_ROUTES,
'Cache-Control': 'public, max-age=0',
};

export const CACHE_1_HOUR_SWR = {
...CACHE_REMIX_RESOURCE_ROUTES,
'Cache-Control': 'public, max-age=0, must-revalidate',
'Netlify-CDN-Cache-Control':
'public, max-age=3600, stale-while-revalidate=60',
};
33 changes: 22 additions & 11 deletions app/root.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import {useNonce, getShopAnalytics, Analytics} from '@shopify/hydrogen';
import {defer, type LoaderFunctionArgs} from '@netlify/remix-runtime';
import {
defer,
type HeadersFunction,
type LoaderFunctionArgs,
} from '@netlify/remix-runtime';
import {
Links,
Meta,
Expand All @@ -15,7 +19,13 @@ import favicon from '~/assets/favicon.svg';
import resetStyles from '~/styles/reset.css?url';
import appStyles from '~/styles/app.css?url';
import {PageLayout} from '~/components/PageLayout';
import {CartProvider} from '~/components/CartProvider';
import {FOOTER_QUERY, HEADER_QUERY} from '~/lib/fragments';
import {CACHE_1_HOUR_SWR} from '~/lib/page-cache';

export const headers: HeadersFunction = () => ({
...CACHE_1_HOUR_SWR,
});

export type RootLoader = typeof loader;

Expand Down Expand Up @@ -105,7 +115,7 @@ async function loadCriticalData({context}: LoaderFunctionArgs) {
* Make sure to not throw any errors here, as it will cause the page to 500.
*/
function loadDeferredData({context}: LoaderFunctionArgs) {
const {storefront, customerAccount, cart} = context;
const {storefront} = context;

// defer the footer query (below the fold)
const footer = storefront
Expand All @@ -121,8 +131,6 @@ function loadDeferredData({context}: LoaderFunctionArgs) {
return null;
});
return {
cart: cart.get(),
isLoggedIn: customerAccount.isLoggedIn(),
footer,
};
}
Expand All @@ -141,13 +149,16 @@ export function Layout({children}: {children?: React.ReactNode}) {
</head>
<body>
{data ? (
<Analytics.Provider
cart={data.cart}
shop={data.shop}
consent={data.consent}
>
<PageLayout {...data}>{children}</PageLayout>
</Analytics.Provider>
<CartProvider>
<Analytics.Provider
// XXX(serhalp) How can we get this here?
cart={null}
shop={data.shop}
consent={data.consent}
>
<PageLayout {...data}>{children}</PageLayout>
</Analytics.Provider>
</CartProvider>
) : (
children
)}
Expand Down
4 changes: 2 additions & 2 deletions app/routes/[robots.txt].tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {type LoaderFunctionArgs} from '@netlify/remix-runtime';
import {useRouteError, isRouteErrorResponse} from '@remix-run/react';
import {parseGid} from '@shopify/hydrogen';
import {CACHE_1_DAY} from '~/lib/page-cache';

export async function loader({request, context}: LoaderFunctionArgs) {
const url = new URL(request.url);
Expand All @@ -13,9 +14,8 @@ export async function loader({request, context}: LoaderFunctionArgs) {
return new Response(body, {
status: 200,
headers: {
...CACHE_1_DAY,
'Content-Type': 'text/plain',

'Cache-Control': `max-age=${60 * 60 * 24}`,
},
});
}
Expand Down
Loading