Skip to content

Commit 588153b

Browse files
Implement revalidatePath functionality for the App Router (#460)
1 parent 1315dc1 commit 588153b

File tree

25 files changed

+458
-102
lines changed

25 files changed

+458
-102
lines changed

.changeset/long-sheep-play.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
---
2+
'@neshca/cache-handler': minor
3+
---
4+
5+
Add `revalidatePath` functionality.
6+
7+
#### New Features
8+
9+
##### `@neshca/cache-handler`
10+
11+
- add `implicitTags` parameter to the `get` method for Handlers
12+
- remove implicit tags filtration for the `PAGE` kind cache values
13+
14+
##### `@neshca/cache-handle/local-lru`
15+
16+
- implement `revalidatePath` functionality
17+
18+
##### `@neshca/cache-handle/redis-stack`
19+
20+
- implement `revalidatePath` functionality
21+
22+
##### `@neshca/cache-handle/redis-strings`
23+
24+
- implement `revalidatePath` functionality
25+
- refactor `revalidateTag` method to use `HSCAN` instead of `HGETALL`
26+
27+
##### `@neshca/cache-handle/helpers`
28+
29+
- add `isImplicitTag` and `getTimeoutRedisCommandOptions` functions

.changeset/tricky-cobras-pull.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@neshca/server': minor
3+
---
4+
5+
Add support for the new `implicitTags` parameter.

README.md

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,7 @@ Welcome to [**`@neshca/cache-handler`**](./packages/cache-handler/README.md), a
88

99
### Next.js Routers support
1010

11-
- Full support for Pages Router.
12-
- Almost full support for App Router. The only exception is the [`revalidatePath`](https://github.com/caching-tools/next-shared-cache/issues/382) function.
11+
- Full support for the Pages and the App Router.
1312

1413
### The importance of shared cache in distributed environments
1514

apps/cache-testing/tests/app.spec.ts

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,13 +36,37 @@ test.describe('On-demand revalidation', () => {
3636
});
3737
}
3838

39+
for (const path of paths) {
40+
test(`If revalidate by path is clicked, then page should be fresh after reload ${path}`, async ({
41+
page,
42+
baseURL,
43+
}) => {
44+
const url = new URL(path, `${baseURL}:3000`);
45+
46+
await page.goto(url.href);
47+
48+
await refreshPageCache(page, 'path');
49+
50+
const valueFromPage = Number.parseInt((await page.getByTestId('data').innerText()).valueOf(), 10);
51+
52+
await refreshPageCache(page, 'path');
53+
54+
await expect(page.getByTestId('cache-state')).toContainText('fresh');
55+
56+
const valueFromPageAfterReload = Number.parseInt(
57+
(await page.getByTestId('data').innerText()).valueOf(),
58+
10,
59+
);
60+
61+
expect(valueFromPageAfterReload - valueFromPage === 1).toBe(true);
62+
});
63+
}
64+
3965
for (const path of paths) {
4066
test(`If revalidate by path is clicked on page A, then page B should be fresh on load ${path}`, async ({
4167
context,
4268
baseURL,
4369
}) => {
44-
test.fail(true, 'This test is failing because of revalidate by path is not supported yet.');
45-
4670
const appAUrl = new URL(path, `${baseURL}:3000`);
4771

4872
const appA = await context.newPage();

docs/cache-handler-docs/src/pages/api-reference/handler.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ A descriptive name for the cache Handler. This is used to identify the cache Han
2121
#### Parameters
2222

2323
- `key` - The unique string identifier for the cache entry.
24+
- `implicitTags` - An array of tags that are implicitly associated with the cache entry.
2425

2526
#### Return value
2627

docs/cache-handler-docs/src/pages/index.mdx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,7 @@ Welcome to `@neshca/cache-handler`, a specialized ISR/Data cache API crafted for
22

33
### Next.js Routers support
44

5-
- Full support for Pages Router.
6-
- Almost full support for App Router. The only exception is the [`revalidatePath`](https://github.com/caching-tools/next-shared-cache/issues/382) function.
5+
- Full support for the Pages and the App Router.
76

87
### The importance of shared cache in distributed environments
98

docs/cache-handler-docs/src/pages/usage/creating-a-custom-handler.mdx

Lines changed: 84 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ Create a file called `cache-handler.mjs` next to your `next.config.js` with the
1818

1919
```js filename="cache-handler.mjs" copy
2020
import { CacheHandler } from '@neshca/cache-handler';
21+
import { isImplicitTag } from '@neshca/cache-handler/helpers';
2122
import createLruHandler from '@neshca/cache-handler/local-lru';
2223
import { createClient, commandOptions } from 'redis';
2324

@@ -30,6 +31,8 @@ CacheHandler.onCreation(async () => {
3031
// Ignore Redis errors: https://github.com/redis/node-redis?tab=readme-ov-file#events.
3132
client.on('error', () => {});
3233

34+
await client.connect();
35+
3336
// Define a timeout for Redis operations.
3437
const timeoutMs = 1000;
3538

@@ -51,14 +54,16 @@ CacheHandler.onCreation(async () => {
5154
}
5255
}
5356

57+
const revalidatedTagsKey = `${keyPrefix}__revalidated_tags__`;
58+
5459
// Create a custom Redis Handler
5560
const customRedisHandler = {
5661
// Give the handler a name.
5762
// It is useful for logging in debug mode.
5863
name: 'redis-strings-custom',
5964
// We do not use try/catch blocks in the Handler methods.
6065
// CacheHandler will handle errors and use the next available Handler.
61-
async get(key) {
66+
async get(key, implicitTags) {
6267
// Ensure that the client is ready before using it.
6368
// If the client is not ready, the CacheHandler will use the next available Handler.
6469
assertClientIsReady();
@@ -77,7 +82,43 @@ CacheHandler.onCreation(async () => {
7782
}
7883

7984
// Redis stores strings, so we need to parse the JSON.
80-
return JSON.parse(result);
85+
const cacheValue = JSON.parse(result);
86+
87+
// If the cache value has no tags, return it early.
88+
if (!cacheValue) {
89+
return null;
90+
}
91+
92+
// Get the set of explicit and implicit tags.
93+
// implicitTags are available only on the `get` method.
94+
const combinedTags = new Set([...cacheValue.tags, ...implicitTags]);
95+
96+
// If there are no tags, return the cache value early.
97+
if (combinedTags.size === 0) {
98+
return cacheValue;
99+
}
100+
101+
// Get the revalidation times for the tags.
102+
const revalidationTimes = await client.hmGet(
103+
commandOptions({ signal: AbortSignal.timeout(timeoutMs) }),
104+
revalidatedTagsKey,
105+
Array.from(combinedTags),
106+
);
107+
108+
// Iterate over all revalidation times.
109+
for (const timeString of revalidationTimes) {
110+
// If the revalidation time is greater than the last modified time of the cache value,
111+
if (timeString && Number.parseInt(timeString, 10) > cacheValue.lastModified) {
112+
// Delete the key from Redis.
113+
await client.unlink(commandOptions({ signal: AbortSignal.timeout(timeoutMs) }), keyPrefix + key);
114+
115+
// Return null to indicate cache miss.
116+
return null;
117+
}
118+
}
119+
120+
// Return the cache value.
121+
return cacheValue;
81122
},
82123
async set(key, cacheHandlerValue) {
83124
// Ensure that the client is ready before using it.
@@ -99,9 +140,7 @@ CacheHandler.onCreation(async () => {
99140
// If the cache handler value has tags, set the tags.
100141
// We store them separately to save time to retrieve them in the `revalidateTag` method.
101142
const setTagsOperation = cacheHandlerValue.tags.length
102-
? client.hSet(options, keyPrefix + sharedTagsKey, {
103-
[key]: JSON.stringify(cacheHandlerValue.tags),
104-
})
143+
? client.hSet(options, keyPrefix + sharedTagsKey, key, JSON.stringify(cacheHandlerValue.tags))
105144
: undefined;
106145

107146
// Wait for all operations to complete.
@@ -111,27 +150,55 @@ CacheHandler.onCreation(async () => {
111150
// Ensure that the client is ready before using it.
112151
assertClientIsReady();
113152

114-
// Create a new AbortSignal with a timeout for the Redis operation.
115-
const getOptions = commandOptions({ signal: AbortSignal.timeout(timeoutMs) });
153+
// Check if the tag is implicit.
154+
// Implicit tags are not stored in the cached values.
155+
if (isImplicitTag(tag)) {
156+
// Mark the tag as revalidated at the current time.
157+
await client.hSet(
158+
commandOptions({ signal: AbortSignal.timeout(timeoutMs) }),
159+
revalidatedTagsKey,
160+
tag,
161+
Date.now(),
162+
);
163+
}
164+
165+
// Create a map to store the tags for each key.
166+
const tagsMap = new Map();
116167

117-
// Get all keys and their tags from Redis from the hash map we've created in the `set` method.
118-
const remoteTags = await client.hGetAll(getOptions, keyPrefix + sharedTagsKey);
168+
// Cursor for the hScan operation.
169+
let cursor = 0;
119170

120-
// Create a map from the tags.
121-
const tagsMap = new Map(Object.entries(remoteTags));
171+
// Query size for the hScan operation.
172+
const querySize = 25;
173+
174+
// Iterate over all keys in the shared tags.
175+
while (true) {
176+
// Get a portion of the keys.
177+
const remoteTagsPortion = await client.hScan(keyPrefix + sharedTagsKey, cursor, { COUNT: querySize });
178+
179+
// Iterate over all keys in the portion.
180+
for (const { field, value } of remoteTagsPortion.tuples) {
181+
// Parse the tags from the value.
182+
tagsMap.set(field, JSON.parse(value));
183+
}
184+
185+
// If the cursor is 0, we have reached the end.
186+
if (remoteTagsPortion.cursor === 0) {
187+
break;
188+
}
189+
190+
// Update the cursor for the next iteration.
191+
cursor = remoteTagsPortion.cursor;
192+
}
122193

123194
// Create an array of keys to delete.
124195
const keysToDelete = [];
125196

126197
// Create an array of tags to delete form the hash map.
127198
const tagsToDelete = [];
128199

129-
// Iterate over all keys and their tags.
130-
for (const [key, tagsString] of tagsMap) {
131-
// In the set method, we store tags as a stringified array.
132-
// So, we need to parse it back to an array.
133-
const tags = JSON.parse(tagsString);
134-
200+
// Iterate over all keys and tags.
201+
for (const [key, tags] of tagsMap) {
135202
// If the tags include the specified tag, add the key to the delete list.
136203
if (tags.includes(tag)) {
137204
// Key must be prefixed because we use the key prefix in the set method.

docs/cache-handler-docs/src/pages/usage/on-demand-revalidation.mdx

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,22 @@ Next.js provides a way to revalidate a page or a result of a `fetch` call on dem
66

77
### Pages Router
88

9-
The Pages Router supports only the `revalidatePath` option.
9+
The Pages Router supports only the `response.revalidate(path)`.
1010

1111
See [Using On-Demand Revalidation](https://nextjs.org/docs/pages/building-your-application/data-fetching/incremental-static-regeneration#using-on-demand-revalidation) in the Next.js documentation.
1212

13+
#### `response.revalidate(path)` caveat
14+
15+
Calling `response.revalidate(path)` will synchronously call `getStaticProps` and render the page with a given `path`. Then it will revalidate the cache for this page. If you want to revalidate multiple paths at once, you need to call `response.revalidate(path)` multiple times.
16+
1317
### App Router
1418

15-
The App Router supports both `revalidatePath` and `revalidateTag` functions. However due to current limitations, `revalidatePath` is disabled for the App Router.
19+
The App Router supports both `revalidatePath` and `revalidateTag` functions. These functions will remove the cache values from the store.
1620

1721
See [On-demand Revalidation](https://nextjs.org/docs/app/building-your-application/data-fetching/fetching-caching-and-revalidating#on-demand-revalidation) in the Next.js documentation.
22+
23+
#### `revalidatePath` caveat
24+
25+
The `revalidatePath` function works differently from the `response.revalidate(path)` and `revalidateTag` functions. It does not revalidate the cached `fetch` result immediately. Instead, it marks the cache as revalidated and the next request will revalidate the cache.
26+
27+
If you are creating a custom cache Handler, you need to manually mark the cache as revalidated in Handler's `revalidateTag` method. Later in the Handler's `get` method, you must check if the cache is revalidated and return `null` if necessary. See [Custom Redis strings example](/usage/creating-a-custom-handler#guide)

docs/cache-handler-docs/theme.config.jsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,10 +53,10 @@ export default {
5353
),
5454
},
5555
banner: {
56-
key: 'version-1.0.0',
56+
key: 'version-1.1.0',
5757
text: (
5858
<a href="https://nextjs.org/blog/next-14-1">
59-
🎉 1.0.0 is out! It features an improved API and TTL by default.
59+
🎉 1.1.0 is out! It features Full support for the App Router!
6060
</a>
6161
),
6262
},

internal/next-common/src/next-common.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,3 +110,5 @@ export type TagsManifest = {
110110
version: 1;
111111
items: Record<string, { revalidatedAt: number }>;
112112
};
113+
114+
export const NEXT_CACHE_IMPLICIT_TAG_ID = '_N_T_';

0 commit comments

Comments
 (0)