Skip to content
Merged
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
5 changes: 3 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -67,8 +67,9 @@ STATIC_STORE_TOKEN=
# Note that FALSE does not disable Cache Components itself, which stays on so
# the app's `use cache` directives still compile — it swaps the cache
# lifetime profiles for zero-second ones, so no entry is ever reused. See
# next.config.ts and the caching section of docs/ARCHITECTURE.md.
CACHE_COMPONENTS_ENABLED=TRUE
# lib/cache/cache-profiles.ts and the caching section of
# docs/ARCHITECTURE.md.
CACHE_ENABLED=TRUE

# Set to "true" to log every outgoing BigCommerce API request (method, URL,
# status, duration) to the console. Off by default. Since a request only
Expand Down
20 changes: 11 additions & 9 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -406,10 +406,12 @@ each. `MockRestApiClient` itself never changes either way.
## Caching

This app uses Next's Cache Components (`cacheComponents: true`). Two
`cacheLife` profiles are configured: `standard` (5 min, most data) and
`extended` (10 min, slower-changing data like channels).
lifetime profiles are defined in `lib/cache/cache-profiles.ts`:
`standard` (5 min, most data) and `extended` (10 min, slower-changing data
like channels). Each `use cache` boundary selects one by calling
`cacheLife(cacheProfile("standard"))`.

Caching is controlled by `CACHE_COMPONENTS_ENABLED`, which `.env.example`
Caching is controlled by `CACHE_ENABLED`, which `.env.example`
ships as `TRUE` so the behavior is visible out of the box. The app's own
fallback when the var is undefined is *off* (see below).

Expand All @@ -434,7 +436,7 @@ which is invisible to and not invalidated by `cacheTag`/`updateTag`.

### Enabling and disabling caching

Caching is controlled by `CACHE_COMPONENTS_ENABLED`, and is off unless that
Caching is controlled by `CACHE_ENABLED`, and is off unless that
is explicitly `true`. Two defaults are worth keeping apart:

- **The code's fallback is off.** An undefined var means no caching, so
Expand All @@ -454,13 +456,13 @@ they can't be wrapped in a runtime condition (a directive nested inside an
`if` is silently ignored rather than honored), and disabling
`cacheComponents` outright would stop the app compiling at all.

Instead, `next.config.ts` swaps both `cacheLife` profiles for a zero-second
one (`{ stale: 0, revalidate: 0, expire: 1 }` — Next requires `expire` to
Instead, `cacheProfile()` returns a zero-second profile
(`{ stale: 0, revalidate: 0, expire: 1 }` — Next requires `expire` to
exceed `revalidate`, so `1` is the floor). A `revalidate` of `0` means every
entry is already expired by the time the next request tries to read it, so
nothing is ever reused and each request re-fetches. Overriding the profiles
covers every cached boundary in the app, since each one selects `standard` or
`extended`.
nothing is ever reused and each request re-fetches. Because every cached
boundary selects its profile through `cacheProfile()`, the switch covers all
of them, and call sites never have to check the variable themselves.

This keeps the caching code paths intact and observable while removing the
staleness: with `LOG_API_REQUESTS=true`, every page load logs its upstream
Expand Down
2 changes: 1 addition & 1 deletion docs/TUTORIAL.md
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@ Completes the full set of single-click app callbacks with support for Uninstall
Demonstrates Next.js caching and memoization patterns that
optimize the app by avoiding repeat DB lookups and API calls.

Caching is controlled by the `CACHE_COMPONENTS_ENABLED` environment variable.
Caching is controlled by the `CACHE_ENABLED` environment variable.
`.env.example` ships it as `TRUE`, so if you copied your `.env.local` from
there you're already exercising the behavior this step builds — pair it with
`LOG_API_REQUESTS=true` to watch cache hits and misses, since an upstream
Expand Down
37 changes: 5 additions & 32 deletions next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,28 +14,12 @@ if (process.env.APP_ORIGIN) {
allowedOrigins.push(new URL(process.env.APP_ORIGIN).host);
}

// Caching is opt-in, and off unless CACHE_COMPONENTS_ENABLED is explicitly
// "true". An admin-privileged app showing stale data is usually the worse
// trade-off (see the caching section of docs/ARCHITECTURE.md), so the safe
// behavior is the default and enabling it is a deliberate choice.
function isCachingEnabled(): boolean {
return process.env.CACHE_COMPONENTS_ENABLED?.toLowerCase() === "true";
}

// Cache Components stays enabled either way — the `use cache` directives and
// cacheTag/updateTag calls throughout the app are compile-time constructs
// that can't be conditionally applied (a directive nested in an `if` is
// silently ignored, not honored), and turning cacheComponents off entirely
// would stop the app compiling. The lever that does work is cacheLife: a
// profile with revalidate: 0 makes every entry already-expired by the time
// the next request reads it, so nothing is ever reused and each request
// re-fetches. Next requires expire > revalidate, hence 1 rather than 0.
//
// Both configured profiles are overridden, since every `use cache` boundary
// in the app selects one of them.
const CACHE_DISABLED_PROFILE = { stale: 0, revalidate: 0, expire: 1 };

const nextConfig: NextConfig = {
// Cache Components (PPR). The lifetime profiles each `use cache` boundary
// selects, and the CACHE_ENABLED switch that turns caching on and off, live
// in lib/cache/cache-profiles.ts rather than in a `cacheLife` block
// here — cacheLife accepts an inline profile object, so keeping them in one
// module avoids splitting the caching configuration across two places.
cacheComponents: true,
// Swaps the Postgres credentials-store driver for a `pg`-free stub
// whenever CREDENTIALS_STORE_DRIVER isn't "POSTGRES" — see
Expand Down Expand Up @@ -72,17 +56,6 @@ const nextConfig: NextConfig = {
allowedOrigins,
},
},
cacheLife: {
// This is an admin-privileged app, so most fetches use a short lifetime —
// changes made directly in the BigCommerce control panel, or by another
// admin, shouldn't stay stale for long even where no cache tag invalidates
// them.
standard: isCachingEnabled() ? { stale: 300, revalidate: 300, expire: 300 } : CACHE_DISABLED_PROFILE,
// Channels change far less often than gift certificates or customers
// (they're a store configuration concern, not day-to-day transactional
// data), so this can tolerate a much longer lifetime.
extended: isCachingEnabled() ? { stale: 600, revalidate: 600, expire: 600 } : CACHE_DISABLED_PROFILE,
},
};

export default nextConfig;
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { cacheLife, cacheTag } from "next/cache";
import { notFound } from "next/navigation";
import { cacheProfile, CACHE_PROFILE_STANDARD } from "@/lib/cache/cache-profiles";
import { Box, Flex, Panel } from "@bigcommerce/big-design";
import { ArrowBackIcon } from "@bigcommerce/big-design-icons";
import { AppLink } from "@/components/ui/app-link";
Expand Down Expand Up @@ -27,7 +28,7 @@ export async function CustomerView({
storeHash: string | undefined;
}) {
"use cache: remote";
cacheLife("standard");
cacheLife(cacheProfile(CACHE_PROFILE_STANDARD));
cacheTag(customerTag(id));
cacheTag(GIFT_CERTIFICATES_LIST_TAG);

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { cacheLife, cacheTag } from "next/cache";
import { cacheProfile, CACHE_PROFILE_STANDARD } from "@/lib/cache/cache-profiles";
import { Box, Panel } from "@bigcommerce/big-design";
import { ControlPanelLink } from "@/components/ui/control-panel-link";
import { CustomerTable } from "@/components/gift-certs-manager/customers/list/customer-table";
Expand All @@ -22,7 +23,7 @@ export async function CustomerListView({
storeHash: string | undefined;
}) {
"use cache: remote";
cacheLife("standard");
cacheLife(cacheProfile(CACHE_PROFILE_STANDARD));
cacheTag(CUSTOMERS_LIST_TAG);

const query = parseCustomersQuery(searchParams);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { cacheLife, cacheTag } from "next/cache";
import { notFound } from "next/navigation";
import { cacheProfile, CACHE_PROFILE_STANDARD } from "@/lib/cache/cache-profiles";
import { Box, Flex } from "@bigcommerce/big-design";
import { ArrowBackIcon } from "@bigcommerce/big-design-icons";
import { AppLink } from "@/components/ui/app-link";
Expand All @@ -21,7 +22,7 @@ export async function GiftCertificateView({
storeHash: string | undefined;
}) {
"use cache: remote";
cacheLife("standard");
cacheLife(cacheProfile(CACHE_PROFILE_STANDARD));
cacheTag(giftCertificateTag(id));

// A missing id is a real 404 from BigCommerce's v2 single-resource
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { cacheLife, cacheTag } from "next/cache";
import { cacheProfile, CACHE_PROFILE_STANDARD } from "@/lib/cache/cache-profiles";
import { Box, Panel } from "@bigcommerce/big-design";
import { ControlPanelLink } from "@/components/ui/control-panel-link";
import { GiftCertificateTable } from "@/components/gift-certs-manager/gift-certificates/list/gift-certificate-table";
Expand All @@ -19,7 +20,7 @@ export async function GiftCertificateListView({
storeHash: string | undefined;
}) {
"use cache: remote";
cacheLife("standard");
cacheLife(cacheProfile(CACHE_PROFILE_STANDARD));
cacheTag(GIFT_CERTIFICATES_LIST_TAG);

const query = parseGiftCertificatesQuery(searchParams);
Expand Down
59 changes: 59 additions & 0 deletions src/lib/cache/cache-profiles.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
// The app's cache lifetime profiles, and the switch that turns caching on and
// off. Every `use cache` boundary selects one by calling
// `cacheLife(cacheProfile(CACHE_PROFILE_STANDARD))`.
//

// A cache lifetime, in seconds. Structurally compatible with Next's own
// CacheLife type, but all three fields are required: every profile here sets
// all of them, and leaving one implicit would silently inherit Next's default
// rather than this app's intent.
//
// - stale: how long a client may serve its own cached copy without rechecking.
// - revalidate: how long before the server refreshes the entry in the
// background.
// - expire: how long before the entry is treated as unusable and a read has to
// wait for fresh data. Next requires this to exceed revalidate.
export interface CacheLifetimeProfile {
stale: number;
revalidate: number;
expire: number;
}

// Caching is opt-in, and off unless CACHE_ENABLED is explicitly "true"
function isCachingEnabled(): boolean {
return process.env.CACHE_ENABLED?.toLowerCase() === "true";
}

// Cache Components stays enabled either way — the `use cache` directives and
// cacheTag/updateTag calls throughout the app are compile-time constructs that
// can't be conditionally applied (a directive nested in an `if` is silently
// ignored, not honored), and turning cacheComponents off entirely would stop
// the app compiling. The lever that does work is the lifetime: a profile with
// revalidate: 0 makes every entry already-expired by the time the next request
// reads it, so nothing is ever reused and each request re-fetches. Next
// requires expire > revalidate, hence 1 rather than 0.
const CACHE_DISABLED_PROFILE: CacheLifetimeProfile = { stale: 0, revalidate: 0, expire: 1 };

// Profile names
export const CACHE_PROFILE_STANDARD = "standard";
export const CACHE_PROFILE_EXTENDED = "extended";

// This is an admin-privileged app, so most fetches use a short lifetime —
// changes made directly in the BigCommerce control panel, or by another admin,
// shouldn't stay stale for long even where no cache tag invalidates them.
const STANDARD_PROFILE: CacheLifetimeProfile = { stale: 300, revalidate: 300, expire: 300 };

// For data that changes very infrequently
const EXTENDED_PROFILE: CacheLifetimeProfile = { stale: 600, revalidate: 600, expire: 600 };

const PROFILES = {
[CACHE_PROFILE_STANDARD]: STANDARD_PROFILE,
[CACHE_PROFILE_EXTENDED]: EXTENDED_PROFILE,
} as const satisfies Record<string, CacheLifetimeProfile>;

export type CacheProfile = keyof typeof PROFILES;

// What every `use cache` boundary passes to cacheLife
export function cacheProfile(profile: CacheProfile): CacheLifetimeProfile {
return isCachingEnabled() ? PROFILES[profile] : CACHE_DISABLED_PROFILE;
}
3 changes: 2 additions & 1 deletion src/lib/gift-certs-manager/app-extension-status.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { cacheLife, cacheTag } from "next/cache";
import { cacheProfile, CACHE_PROFILE_EXTENDED } from "@/lib/cache/cache-profiles";
import { getCredentialsStore } from "@/lib/credentials-store/get-credentials-store";

// One shared tag per store (only one extension is ever registered). Exported
Expand All @@ -9,7 +10,7 @@ export function appExtensionStatusTag(storeHash: string): string {

async function fetchStoreExtensionStatus(storeHash: string): Promise<{ isRegistered: boolean }> {
"use cache: remote";
cacheLife("extended");
cacheLife(cacheProfile(CACHE_PROFILE_EXTENDED));
cacheTag(appExtensionStatusTag(storeHash));

const extensionId = await getCredentialsStore().getStoreExtension(storeHash);
Expand Down
3 changes: 2 additions & 1 deletion src/lib/gift-certs-manager/channels/channels-api.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { cacheLife, cacheTag } from "next/cache";
import { cacheProfile, CACHE_PROFILE_EXTENDED } from "@/lib/cache/cache-profiles";
import { getRestApiClient } from "@/lib/bc-api-client/get-rest-api-client";
import { V3ListResponse } from "@/lib/bc-api-client/rest-client/types";
import { CHANNELS_PATH, Channel } from "@/lib/gift-certs-manager/channels/types";
Expand All @@ -13,7 +14,7 @@ export interface ChannelsResult {
// "standard" lifetime the calling view uses for its own data.
export async function fetchChannels(storeHash: string | undefined): Promise<ChannelsResult> {
"use cache: remote";
cacheLife("extended");
cacheLife(cacheProfile(CACHE_PROFILE_EXTENDED));
cacheTag("channels:list");

const apiClient = await getRestApiClient(storeHash);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { cacheLife, cacheTag } from "next/cache";
import { cacheProfile, CACHE_PROFILE_STANDARD } from "@/lib/cache/cache-profiles";
import { getRestApiClient } from "@/lib/bc-api-client/get-rest-api-client";
import { giftCertificateTag, GIFT_CERTIFICATES_LIST_TAG } from "@/lib/gift-certs-manager/gift-certificates/cache-tags";
import {
Expand Down Expand Up @@ -31,7 +32,7 @@ async function fetchGiftCertificatesPage(
storeHash: string | undefined,
): Promise<GiftCertificateWireRecord[]> {
"use cache: remote";
cacheLife("standard");
cacheLife(cacheProfile(CACHE_PROFILE_STANDARD));
cacheTag(GIFT_CERTIFICATES_LIST_TAG);

const apiClient = await getRestApiClient(storeHash);
Expand Down