@@ -17,7 +17,7 @@ import {
1717 type RequestContext ,
1818} from "../../server/request-context.js" ;
1919import { MemorySegmentCacheStore } from "../../cache/memory-segment-store.js" ;
20- import { cacheTag } from "../../cache/cache-tag.js" ;
20+ import { cacheTag , recordRequestTags } from "../../cache/cache-tag.js" ;
2121import type { HandlerContext } from "../handler-context.js" ;
2222import type { SSRModule } from "../types.js" ;
2323
@@ -241,6 +241,10 @@ describe("captureAndStoreShell", () => {
241241 return {
242242 _cacheStore : putShell ? { putShell } : undefined ,
243243 _reportBackgroundError : vi . fn ( ) ,
244+ // The real derived context always seeds a fresh _requestTags (shell-capture
245+ // attemptCapture); the putShell write barrier snapshots it (issue #676), so
246+ // the direct-call stub must model the same required RequestContext field.
247+ _requestTags : new Set < string > ( ) ,
244248 } ;
245249 }
246250
@@ -1258,6 +1262,93 @@ describe("runShellCapture", () => {
12581262 expect ( await store . getShell ( "/p:shell" ) ) . toBeNull ( ) ;
12591263 } ) ;
12601264
1265+ // #676: a cacheTag() recorded AFTER an await inside an async shell server
1266+ // component. React renders the synchronous tree during RSC-stream construction,
1267+ // so a sync cacheTag lands; a tag recorded on a later microtask/macrotask (past
1268+ // the await) lands after construction. The shell tag snapshot now sits at the
1269+ // putShell WRITE BARRIER — after the capture quiesces — so the late tag is
1270+ // collected, unioned with the static ppr.tags, and revalidateTag() evicts the
1271+ // shell. captureShellHTML gates on the same promise the component awaits, so the
1272+ // tag records within the capture's quiesce window (the real async component the
1273+ // shell waits for).
1274+ it ( "collects a cacheTag() recorded after an await in async shell content (#676)" , async ( ) => {
1275+ const putShell = makePutShell ( ) ;
1276+ let recorded : Promise < void > = Promise . resolve ( ) ;
1277+ const { ctx, ssrModule } = makeCtx (
1278+ okMatch ,
1279+ vi . fn ( async ( ) => {
1280+ await recorded ;
1281+ return { prelude : enc ( "<body>x</body>" ) , postponed : null } ;
1282+ } ) ,
1283+ ) ;
1284+ ( ctx as any ) . renderToReadableStream = vi . fn ( ( ) => {
1285+ recorded = ( async ( ) => {
1286+ await new Promise ( ( r ) => setTimeout ( r , 0 ) ) ;
1287+ cacheTag ( "async-shell-tag" ) ;
1288+ } ) ( ) ;
1289+ return emptyStream ( ) ;
1290+ } ) ;
1291+
1292+ await runShellCapture (
1293+ ctx ,
1294+ new Request ( "http://localhost/p" ) ,
1295+ { } ,
1296+ new URL ( "http://localhost/p" ) ,
1297+ makeReqCtx ( ) ,
1298+ ssrModule ,
1299+ { key : "/p:shell" , store : { putShell } as any , tags : [ "ppr:static" ] } ,
1300+ 0 ,
1301+ ) ;
1302+
1303+ expect ( putShell ) . toHaveBeenCalledTimes ( 1 ) ;
1304+ const tags = new Set ( putShell . mock . calls [ 0 ] ! [ 4 ] ) ;
1305+ expect ( tags ) . toEqual ( new Set ( [ "ppr:static" , "async-shell-tag" ] ) ) ;
1306+ } ) ;
1307+
1308+ // #676 (async "use cache" propagation): a "use cache" read that HITs during the
1309+ // capture render propagates its entry tags to the document artifact via the
1310+ // cache runtime's recordRequestTags(entry.tags) — and an async cached function
1311+ // records them AFTER its await resolves. Simulate that timing: the render kicks
1312+ // off a delayed continuation that recordRequestTags() after an await, gated the
1313+ // same way the cache read-HIT test gates on the read. The late tag must reach
1314+ // the stored entry, same as the render-callable cacheTag form above.
1315+ it ( "collects an async use-cache read's tags recorded after an await (#676)" , async ( ) => {
1316+ const putShell = makePutShell ( ) ;
1317+ let recorded : Promise < void > = Promise . resolve ( ) ;
1318+ const { ctx, ssrModule } = makeCtx (
1319+ okMatch ,
1320+ vi . fn ( async ( ) => {
1321+ await recorded ;
1322+ return { prelude : enc ( "<body>x</body>" ) , postponed : null } ;
1323+ } ) ,
1324+ ) ;
1325+ ( ctx as any ) . renderToReadableStream = vi . fn ( ( ) => {
1326+ const reqCtx = getRequestContext ( ) ;
1327+ recorded = ( async ( ) => {
1328+ await new Promise ( ( r ) => setTimeout ( r , 0 ) ) ;
1329+ // What cache-runtime.ts does on a "use cache" read-HIT: propagate the
1330+ // resolved entry's tags to the request's document artifact.
1331+ recordRequestTags ( [ "async-use-cache-tag" ] , reqCtx ) ;
1332+ } ) ( ) ;
1333+ return emptyStream ( ) ;
1334+ } ) ;
1335+
1336+ await runShellCapture (
1337+ ctx ,
1338+ new Request ( "http://localhost/p" ) ,
1339+ { } ,
1340+ new URL ( "http://localhost/p" ) ,
1341+ makeReqCtx ( ) ,
1342+ ssrModule ,
1343+ { key : "/p:shell" , store : { putShell } as any } ,
1344+ 0 ,
1345+ ) ;
1346+
1347+ expect ( putShell ) . toHaveBeenCalledTimes ( 1 ) ;
1348+ const tags = new Set ( putShell . mock . calls [ 0 ] ! [ 4 ] ) ;
1349+ expect ( tags . has ( "async-use-cache-tag" ) ) . toBe ( true ) ;
1350+ } ) ;
1351+
12611352 // Capture data snapshot: a cache read-HIT the capture render performs through
12621353 // the ambient (recording) store is recorded onto entry.snapshot, so a HIT can
12631354 // replay it and match the frozen prelude. See cache/shell-snapshot.ts.
0 commit comments