@@ -2,7 +2,7 @@ import { expect } from '@playwright/test'
22import { test } from '../utils/playwright-helpers.js'
33import { nextVersionSatisfies } from '../utils/next-version-helpers.mjs'
44
5- test . describe ( 'app router on-demand revalidation' , ( ) => {
5+ test . describe ( 'app router on-demand revalidation (pre Next 16 APIs) ' , ( ) => {
66 for ( const { label, prerendered, pagePath, revalidateApiPath, expectedH1Content } of [
77 {
88 label : 'revalidatePath (prerendered page with static path)' ,
@@ -193,3 +193,297 @@ test.describe('app router on-demand revalidation', () => {
193193 } )
194194 }
195195} )
196+
197+ if ( nextVersionSatisfies ( '>=16.0.0-alpha.0' ) ) {
198+ test . describe ( 'app router on-demand revalidation (Next 16 APIs)' , ( ) => {
199+ for ( const { label, prerendered, pagePathSuffix, tagSuffix, expectedH1Content } of [
200+ {
201+ label : 'prerendered page with static path' ,
202+ prerendered : true ,
203+ pagePathSuffix : '/product-static' ,
204+ tagSuffix : 'product-static' ,
205+ expectedH1Content : 'Product product-static' ,
206+ } ,
207+ {
208+ label : 'prerendered page with dynamic path' ,
209+ prerendered : true ,
210+ pagePathSuffix : '/product/prerendered' ,
211+ tagSuffix : 'prerendered' ,
212+ expectedH1Content : 'Product prerendered' ,
213+ } ,
214+ {
215+ label : 'not prerendered page with dynamic path' ,
216+ prerendered : false ,
217+ pagePathSuffix : '/product/not-prerendered' ,
218+ tagSuffix : 'not-prerendered' ,
219+ expectedH1Content : 'Product not-prerendered' ,
220+ } ,
221+ ] ) {
222+ test . describe ( label , ( ) => {
223+ for ( const { label, revalidateApiProfileSuffix, tagPrefix } of [
224+ {
225+ label : 'revalidateTag with string profile' ,
226+ revalidateApiProfileSuffix : `profile=testCacheLife` ,
227+ tagPrefix : `revalidate-tag-string-profile` ,
228+ } ,
229+ {
230+ label : 'revalidateTag with explicit inline expire' ,
231+ revalidateApiProfileSuffix : `expire=5` ,
232+ tagPrefix : `revalidate-tag-explicit-inline-expire` ,
233+ } ,
234+ ] ) {
235+ test ( label , async ( { page, pollUntilHeadersMatch, next16TagRevalidation } ) => {
236+ const pagePath = `/${ tagPrefix } ${ pagePathSuffix } `
237+ const revalidateApiPath = `/api/revalidate-tag?tag=${ tagPrefix } -${ tagSuffix } &${ revalidateApiProfileSuffix } `
238+
239+ // in case there is retry or some other test did hit that path before
240+ // we want to make sure that cdn cache is not warmed up
241+ const purgeCdnCache = await page . goto (
242+ new URL ( `/api/purge-cdn?path=${ pagePath } ` , next16TagRevalidation . url ) . href ,
243+ )
244+ expect ( purgeCdnCache ?. status ( ) ) . toBe ( 200 )
245+
246+ // wait a bit until cdn cache purge propagates
247+ await page . waitForTimeout ( 500 )
248+
249+ const response1 = await pollUntilHeadersMatch (
250+ new URL ( pagePath , next16TagRevalidation . url ) . href ,
251+ {
252+ headersToMatch : {
253+ // either first time hitting this route or we invalidated
254+ // just CDN node in earlier step
255+ // we will invoke function and see Next cache hit status
256+ // in the response if it was prerendered at build time
257+ // or regenerated in previous attempt to run this test
258+ 'cache-status' : [
259+ / " N e t l i f y E d g e " ; f w d = ( m i s s | s t a l e ) / m,
260+ prerendered ? / " N e x t .j s " ; h i t / m : / " N e x t .j s " ; ( h i t | f w d = m i s s ) / m,
261+ ] ,
262+ } ,
263+ headersNotMatchedMessage :
264+ 'First request to tested page should be a miss or stale on the Edge and hit in Next.js' ,
265+ } ,
266+ )
267+ const headers1 = response1 ?. headers ( ) || { }
268+ expect ( response1 ?. status ( ) ) . toBe ( 200 )
269+ expect ( headers1 [ 'x-nextjs-cache' ] ) . toBeUndefined ( )
270+ expect ( headers1 [ 'debug-netlify-cdn-cache-control' ] ) . toBe ( 's-maxage=31536000, durable' )
271+
272+ const date1 = await page . getByTestId ( 'date-now' ) . textContent ( )
273+
274+ const h1 = await page . locator ( 'h1' ) . textContent ( )
275+ expect ( h1 ) . toBe ( expectedH1Content )
276+
277+ const response2 = await pollUntilHeadersMatch (
278+ new URL ( pagePath , next16TagRevalidation . url ) . href ,
279+ {
280+ headersToMatch : {
281+ // we are hitting the same page again and we most likely will see
282+ // CDN hit (in this case Next reported cache status is omitted
283+ // as it didn't actually take place in handling this request)
284+ // or we will see CDN miss because different CDN node handled request
285+ 'cache-status' : / " N e t l i f y E d g e " ; ( h i t | f w d = m i s s | f w d = s t a l e ) / m,
286+ } ,
287+ headersNotMatchedMessage :
288+ 'Second request to tested page should most likely be a hit on the Edge (optionally miss or stale if different CDN node)' ,
289+ } ,
290+ )
291+ const headers2 = response2 ?. headers ( ) || { }
292+ expect ( response2 ?. status ( ) ) . toBe ( 200 )
293+ expect ( headers2 [ 'x-nextjs-cache' ] ) . toBeUndefined ( )
294+ if ( ! headers2 [ 'cache-status' ] . includes ( '"Netlify Edge"; hit' ) ) {
295+ // if we missed CDN cache, we will see Next cache hit status
296+ // as we reuse cached response
297+ expect ( headers2 [ 'cache-status' ] ) . toMatch ( / " N e x t .j s " ; h i t / m)
298+ }
299+ expect ( headers2 [ 'debug-netlify-cdn-cache-control' ] ) . toBe ( 's-maxage=31536000, durable' )
300+
301+ // the page is cached
302+ const date2 = await page . getByTestId ( 'date-now' ) . textContent ( )
303+ expect ( date2 ) . toBe ( date1 )
304+
305+ const revalidate = await page . goto (
306+ new URL ( revalidateApiPath , next16TagRevalidation . url ) . href ,
307+ )
308+ expect ( revalidate ?. status ( ) ) . toBe ( 200 )
309+
310+ // wait a bit until cdn tags and invalidated and cdn is purged
311+ await page . waitForTimeout ( 500 )
312+
313+ // now after the revalidation with delayed expiration, it should serve stale if we are still before expiration time was not reached
314+ const response3 = await pollUntilHeadersMatch (
315+ new URL ( pagePath , next16TagRevalidation . url ) . href ,
316+ {
317+ headersToMatch : {
318+ // revalidatePath just marks the page(s) as stale and does NOT
319+ // automatically refreshes the cache. This request should result
320+ // in serving stale content and trigger background revalidation.
321+ 'cache-status' : [
322+ / " N e x t .j s " ; h i t ; f w d = s t a l e / m,
323+ / " N e t l i f y E d g e " ; f w d = ( m i s s | s t a l e ) / m,
324+ ] ,
325+ } ,
326+ headersNotMatchedMessage :
327+ 'Third request to tested page should be a miss or stale on the Edge and stale in Next.js after on-demand revalidation with delayed expiration' ,
328+ } ,
329+ )
330+ const headers3 = response3 ?. headers ( ) || { }
331+ expect ( response3 ?. status ( ) ) . toBe ( 200 )
332+ expect ( headers3 ?. [ 'x-nextjs-cache' ] ) . toBeUndefined ( )
333+ expect ( headers3 [ 'debug-netlify-cdn-cache-control' ] , 'Stale is not cacheable' ) . toBe (
334+ 'public, max-age=0, must-revalidate, durable' ,
335+ )
336+
337+ // the page is stale but still served, because we hit it before expiration
338+ const date3 = await page . getByTestId ( 'date-now' ) . textContent ( )
339+ expect ( date3 ) . toBe ( date2 )
340+
341+ // previous request should trigger background revalidation. There is 5s sleep in data fetching in tested page
342+ // so let's wait for that
343+
344+ await page . waitForTimeout ( 6000 )
345+
346+ const response4 = await pollUntilHeadersMatch (
347+ new URL ( pagePath , next16TagRevalidation . url ) . href ,
348+ {
349+ headersToMatch : {
350+ // we are hitting the same page again and we most likely will see
351+ // CDN hit (in this case Next reported cache status is omitted
352+ // as it didn't actually take place in handling this request)
353+ // or we will see CDN miss because different CDN node handled request
354+ 'cache-status' : / " N e t l i f y E d g e " ; ( h i t | f w d = m i s s | f w d = s t a l e ) / m,
355+ } ,
356+ headersNotMatchedMessage :
357+ 'Fourth request to tested page should most likely be a hit on the Edge (optionally miss or stale if different CDN node)' ,
358+ } ,
359+ )
360+ const headers4 = response4 ?. headers ( ) || { }
361+ expect ( response4 ?. status ( ) ) . toBe ( 200 )
362+ expect ( headers4 ?. [ 'x-nextjs-cache' ] ) . toBeUndefined ( )
363+ if ( ! headers4 [ 'cache-status' ] . includes ( '"Netlify Edge"; hit' ) ) {
364+ // if we missed CDN cache, we will see Next cache hit status
365+ // as we reuse cached response
366+ expect ( headers4 [ 'cache-status' ] ) . toMatch ( / " N e x t .j s " ; h i t / m)
367+ }
368+ expect ( headers4 [ 'debug-netlify-cdn-cache-control' ] ) . toBe ( 's-maxage=31536000, durable' )
369+
370+ // the page is cached
371+ const date4 = await page . getByTestId ( 'date-now' ) . textContent ( )
372+ expect ( date4 ) . not . toBe ( date3 )
373+
374+ // lets revalidate again, but now we will wait for expiration time to pass to test that we are not serving stale anymore
375+ const revalidate2 = await page . goto (
376+ new URL ( revalidateApiPath , next16TagRevalidation . url ) . href ,
377+ )
378+ expect ( revalidate2 ?. status ( ) ) . toBe ( 200 )
379+
380+ // revalidation should allow stale to be served for 5 seconds, let's wait to test case after expiration
381+ await page . waitForTimeout ( 6000 )
382+
383+ // now after the revalidation it should have a different date
384+ const response5 = await pollUntilHeadersMatch (
385+ new URL ( pagePath , next16TagRevalidation . url ) . href ,
386+ {
387+ headersToMatch : {
388+ // revalidatePath just marks the page(s) as invalid and does NOT
389+ // automatically refreshes the cache. This request will cause
390+ // Next.js cache miss and new response will be generated and cached
391+ // Depending if we hit same CDN node as previous request, we might
392+ // get either fwd=miss or fwd=stale
393+ 'cache-status' : [ / " N e x t .j s " ; f w d = m i s s / m, / " N e t l i f y E d g e " ; f w d = ( m i s s | s t a l e ) / m] ,
394+ } ,
395+ headersNotMatchedMessage :
396+ 'Third request to tested page should be a miss or stale on the Edge and miss in Next.js after on-demand revalidation' ,
397+ } ,
398+ )
399+ const headers5 = response5 ?. headers ( ) || { }
400+ expect ( response5 ?. status ( ) ) . toBe ( 200 )
401+ expect ( headers5 ?. [ 'x-nextjs-cache' ] ) . toBeUndefined ( )
402+ expect ( headers5 [ 'debug-netlify-cdn-cache-control' ] ) . toBe ( 's-maxage=31536000, durable' )
403+
404+ // the page has now an updated date
405+ const date5 = await page . getByTestId ( 'date-now' ) . textContent ( )
406+ expect ( date5 ) . not . toBe ( date4 )
407+ } )
408+ }
409+
410+ test ( 'updateTag in server action' , async ( {
411+ page,
412+ pollUntilHeadersMatch,
413+ next16TagRevalidation,
414+ } ) => {
415+ const pagePath = `/update-tag/${ pagePathSuffix } `
416+ // in case there is retry or some other test did hit that path before
417+ // we want to make sure that cdn cache is not warmed up
418+ const purgeCdnCache = await page . goto (
419+ new URL ( `/api/purge-cdn?path=${ pagePath } ` , next16TagRevalidation . url ) . href ,
420+ )
421+ expect ( purgeCdnCache ?. status ( ) ) . toBe ( 200 )
422+
423+ // wait a bit until cdn cache purge propagates
424+ await page . waitForTimeout ( 500 )
425+
426+ const response1 = await pollUntilHeadersMatch (
427+ new URL ( pagePath , next16TagRevalidation . url ) . href ,
428+ {
429+ headersToMatch : {
430+ // either first time hitting this route or we invalidated
431+ // just CDN node in earlier step
432+ // we will invoke function and see Next cache hit status
433+ // in the response if it was prerendered at build time
434+ // or regenerated in previous attempt to run this test
435+ 'cache-status' : [
436+ / " N e t l i f y E d g e " ; f w d = ( m i s s | s t a l e ) / m,
437+ prerendered ? / " N e x t .j s " ; h i t / m : / " N e x t .j s " ; ( h i t | f w d = m i s s ) / m,
438+ ] ,
439+ } ,
440+ headersNotMatchedMessage :
441+ 'First request to tested page should be a miss or stale on the Edge and hit in Next.js' ,
442+ } ,
443+ )
444+ const headers1 = response1 ?. headers ( ) || { }
445+ expect ( response1 ?. status ( ) ) . toBe ( 200 )
446+ expect ( headers1 [ 'x-nextjs-cache' ] ) . toBeUndefined ( )
447+ expect ( headers1 [ 'debug-netlify-cdn-cache-control' ] ) . toBe ( 's-maxage=31536000, durable' )
448+
449+ const date1 = await page . getByTestId ( 'date-now' ) . textContent ( )
450+
451+ const h1 = await page . locator ( 'h1' ) . textContent ( )
452+ expect ( h1 ) . toBe ( expectedH1Content )
453+
454+ const response2 = await pollUntilHeadersMatch (
455+ new URL ( pagePath , next16TagRevalidation . url ) . href ,
456+ {
457+ headersToMatch : {
458+ // we are hitting the same page again and we most likely will see
459+ // CDN hit (in this case Next reported cache status is omitted
460+ // as it didn't actually take place in handling this request)
461+ // or we will see CDN miss because different CDN node handled request
462+ 'cache-status' : / " N e t l i f y E d g e " ; ( h i t | f w d = m i s s | f w d = s t a l e ) / m,
463+ } ,
464+ headersNotMatchedMessage :
465+ 'Second request to tested page should most likely be a hit on the Edge (optionally miss or stale if different CDN node)' ,
466+ } ,
467+ )
468+ const headers2 = response2 ?. headers ( ) || { }
469+ expect ( response2 ?. status ( ) ) . toBe ( 200 )
470+ expect ( headers2 [ 'x-nextjs-cache' ] ) . toBeUndefined ( )
471+ if ( ! headers2 [ 'cache-status' ] . includes ( '"Netlify Edge"; hit' ) ) {
472+ // if we missed CDN cache, we will see Next cache hit status
473+ // as we reuse cached response
474+ expect ( headers2 [ 'cache-status' ] ) . toMatch ( / " N e x t .j s " ; h i t / m)
475+ }
476+ expect ( headers2 [ 'debug-netlify-cdn-cache-control' ] ) . toBe ( 's-maxage=31536000, durable' )
477+
478+ // the page is cached
479+ const date2 = await page . getByTestId ( 'date-now' ) . textContent ( )
480+ expect ( date2 ) . toBe ( date1 )
481+
482+ await page . getByTestId ( 'update-tag-button' ) . click ( )
483+
484+ await expect ( page . getByTestId ( 'date-now' ) ) . not . toHaveText ( date2 ! , { timeout : 15_000 } )
485+ } )
486+ } )
487+ }
488+ } )
489+ }
0 commit comments