@@ -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