Skip to content
Draft
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
11 changes: 7 additions & 4 deletions packages/nextjs-cache-handler/src/handlers/cache-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
Handler,
OnCreationHook,
Revalidate,
SetContext,
} from "./cache-handler.types";
import { PrerenderManifest } from "next/dist/build";
import {
Expand Down Expand Up @@ -579,10 +580,10 @@ export class CacheHandler implements NextCacheHandler {

return null;
},
async set(key, cacheHandlerValue) {
async set(key, cacheHandlerValue, ctx) {
const operationsResults = await Promise.allSettled(
handlersList.map((handler) =>
handler.set(key, { ...cacheHandlerValue }),
handler.set(key, { ...cacheHandlerValue }, ctx),
),
);

Expand Down Expand Up @@ -721,7 +722,7 @@ export class CacheHandler implements NextCacheHandler {
internal_lastModified?: number;
tags?: string[];
revalidate?: Revalidate;
},
} & SetContext,
): Promise<void> {
await CacheHandler.#ensureConfigured();

Expand Down Expand Up @@ -773,7 +774,9 @@ export class CacheHandler implements NextCacheHandler {
value: value,
};

await CacheHandler.#mergedHandler.set(cacheKey, cacheHandlerValue);
await CacheHandler.#mergedHandler.set(cacheKey, cacheHandlerValue, {
isInitialHydration: ctx?.isInitialHydration,
});

if (
process.env.NEXT_PHASE === PHASE_PRODUCTION_BUILD &&
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,20 @@ export type CacheHandlerParametersGetWithTags = [
string[],
];

/**
* Context information provided during cache set operations.
*/
export type SetContext = {
/**
* Indicates whether this set operation is part of initial cache hydration.
* When true, cache handlers should use non-destructive write operations (e.g., Redis NX)
* to avoid overwriting fresher runtime-generated cache entries with stale build-time values.
*
* @default false
*/
isInitialHydration?: boolean;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps setOnlyIfNotExists? I think isInitialHydration is a breaking change that you are forced to opt in in hydration. You should rather to have a choice to pick a strategy.

};

/**
* Represents an internal Next.js metadata for a `get` method.
* This metadata is available in the `get` method of the cache handler.
Expand Down Expand Up @@ -125,6 +139,8 @@ export type Handler = {
*
* @param value - The value to be stored in the cache. See {@link CacheHandlerValue}.
*
* @param ctx - Optional context information for the set operation. See {@link SetContext}.
*
* @returns A Promise that resolves when the value has been successfully set in the cache.
*
* @remarks
Expand All @@ -137,7 +153,7 @@ export type Handler = {
*
* Use the absolute time (`expireAt`) to set and expiration time for the cache entry in your cache store to be in sync with the file system cache.
*/
set: (key: string, value: CacheHandlerValue) => Promise<void>;
set: (key: string, value: CacheHandlerValue, ctx?: SetContext) => Promise<void>;
/**
* Deletes all cache entries that are associated with the specified tag.
* See [fetch `options.next.tags` and `revalidateTag` ↗](https://nextjs.org/docs/app/building-your-application/caching#fetch-optionsnexttags-and-revalidatetag)
Expand Down
4 changes: 2 additions & 2 deletions packages/nextjs-cache-handler/src/handlers/composite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,10 @@ export default function createHandler({
return null;
},

async set(key, data) {
async set(key, data, ctx) {
const index = strategy?.(data) ?? 0;
const handler = handlers[index] ?? handlers[0]!;
await handler.set(key, data);
await handler.set(key, data, ctx);
},

async revalidateTag(tag) {
Expand Down
4 changes: 3 additions & 1 deletion packages/nextjs-cache-handler/src/handlers/local-lru.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,9 @@ export default function createHandler({

return Promise.resolve(cacheValue);
},
set(key, cacheHandlerValue) {
set(key, cacheHandlerValue, ctx) {
// LRU cache is in-memory and ephemeral, so NX behavior is not applicable
// We always set the value regardless of isInitialHydration flag
Comment on lines +103 to +104
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NX is a redis specific acronym, right? It shouldn't be mentioned in LRU implementation.

You could also call get before setting, to verify existing, if the flag is set.

lruCacheStore.set(key, cacheHandlerValue);

return Promise.resolve();
Expand Down
26 changes: 15 additions & 11 deletions packages/nextjs-cache-handler/src/handlers/redis-strings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,7 @@ export default function createHandler({

return cacheValue;
},
async set(key, cacheHandlerValue) {
async set(key, cacheHandlerValue, ctx) {
assertClientIsReady();

let setOperation: Promise<string | null>;
Expand Down Expand Up @@ -260,23 +260,27 @@ export default function createHandler({

switch (keyExpirationStrategy) {
case "EXAT": {
const setOptions =
typeof lifespan?.expireAt === "number"
? {
EXAT: lifespan.expireAt,
...(ctx?.isInitialHydration ? { NX: true } : {}),
}
: ctx?.isInitialHydration
? { NX: true }
: undefined;
Comment on lines +263 to +271
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

const hasExpireAt = typeof lifespan?.expireAt === "number";
const isNX = ctx?.isInitialHydration === true;

const setOptions =
  hasExpireAt || isNX
    ? {
        ...(hasExpireAt && { EXAT: lifespan.expireAt }),
        ...(isNX && { NX: true }),
      }
    : undefined;


setOperation = client
.withAbortSignal(AbortSignal.timeout(timeoutMs))
.set(
keyPrefix + key,
serializedValue,
typeof lifespan?.expireAt === "number"
? {
EXAT: lifespan.expireAt,
}
: undefined,
);
.set(keyPrefix + key, serializedValue, setOptions);
break;
}
case "EXPIREAT": {
const setOptions = ctx?.isInitialHydration ? { NX: true } : undefined;

setOperation = client
.withAbortSignal(AbortSignal.timeout(timeoutMs))
.set(keyPrefix + key, serializedValue);
.set(keyPrefix + key, serializedValue, setOptions);

expireOperation = lifespan
? client
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,7 @@ export async function registerInitialCache(
revalidate,
internal_lastModified: lastModified,
tags: getTagsFromHeaders(meta.headers),
isInitialHydration: true,
});
} catch (error) {
if (debug) {
Expand Down Expand Up @@ -376,6 +377,7 @@ export async function registerInitialCache(
await cacheHandler.set(cachePath, value, {
revalidate,
internal_lastModified: lastModified,
isInitialHydration: true,
});

if (debug) {
Expand Down Expand Up @@ -490,6 +492,7 @@ export async function registerInitialCache(
revalidate,
internal_lastModified: lastModified,
tags: fetchCache.tags,
isInitialHydration: true,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please add this as a feature flag in RegisterInitialCacheOptions with default value false to allow a way opt-in/out. This is not something everybody expects from their application and is also a breaking change.

We could call it writeOnlyIfNotExists or writeOnlyIfAbsent something similar.

});
} catch (error) {
if (debug) {
Expand Down