Skip to content

Cached Navigations: Cache visited fully static pages in the segment cache#90306

Draft
unstubbable wants to merge 12 commits intocanaryfrom
hl/cached-navs-2
Draft

Cached Navigations: Cache visited fully static pages in the segment cache#90306
unstubbable wants to merge 12 commits intocanaryfrom
hl/cached-navs-2

Conversation

@unstubbable
Copy link
Contributor

@unstubbable unstubbable commented Feb 21, 2026

When a fully static page is loaded (via initial HTML or client-side navigation), the segments are now written into the segment cache so subsequent navigations can be served entirely from cache without server requests.

Initial HTML: The RSC payload inlined in the HTML is written during hydration via getStaleAt + writeStaticStageResponseIntoCache, reusing the same cache write path as navigations. A StaleTimeIterable (s field) is included in the InitialRSCPayload during the Cache Components prerender path to provide the stale time.

Navigation: The server prepends a ResponseCompletenessMarker byte to prerendered flightData, extending the mechanism introduced for runtime prefetching in #90160. Three markers are now used: # (Static) for fully static prerenders, * (Complete) for complete runtime prefetches, and ~ (Partial) for partial responses with dynamic holes. The client strips it via processFetch in createFetch. The * marker prevents runtime prefetches of non-fully-static pages from incorrectly setting isFullyStatic.

Segments from writeStaticStageResponseIntoCache are always written as partial, even for fully static routes. Writing them as non-partial would prevent refreshes and shared parallel route slots from being overwritten on subsequent navigations. Instead, the isFullyStatic flag is stored on the route cache entry (set only for #) and checked at read time (in createCacheNodeForSegment) to skip the dynamic follow-up request when cached segments are available.

The runtime prefetch path (fetchSegmentPrefetchesUsingDynamicRequest) now reads completenessMarker from cacheData on RSCResponse instead of calling stripCompletenessMarker separately, since the marker has already been stripped by processFetch in createFetch.

Partially static pages (where the static stage needs to be extracted via byte-level truncation of the initial HTML Flight stream) will be handled in a follow-up.

@nextjs-bot
Copy link
Collaborator

nextjs-bot commented Feb 21, 2026

Failing test suites

Commit: 963e5dc | About building and testing Next.js

pnpm test-start test/e2e/app-dir/concurrent-navigations/mismatching-prefetch.test.ts (job)

  • mismatching prefetch > recovers when a navigation rewrites to a different route than the one that was prefetched (DD)
Expand output

● mismatching prefetch › recovers when a navigation rewrites to a different route than the one that was prefetched

The same expected substring was sent multiple times by the server:

Dynamic page b

Choose a more specific substring to assert on.

  39 |       // When we click the link to navigate, the navigation will rewrite to
  40 |       // a different route than the one that was prefetched.
> 41 |       await act(
     |             ^
  42 |         async () => {
  43 |           const link = await browser.elementByCss(
  44 |             'a[href="/mismatching-prefetch/dynamic-page/a?mismatch-rewrite=./b"]'

  at Object.act (e2e/app-dir/concurrent-navigations/mismatching-prefetch.test.ts:41:13)

pnpm test-start-turbo test/e2e/app-dir/segment-cache/revalidation/segment-cache-revalidation.test.ts (turbopack) (job)

  • segment cache (revalidation) > re-fetch visible links after a navigation, if needed (DD)
Expand output

● segment cache (revalidation) › re-fetch visible links after a navigation, if needed

Expected a response containing the given string:

Page B content

  305 |
  306 |     // Reveal the links to trigger prefetches
> 307 |     await act(async () => {
      |           ^
  308 |       await linkALinkVisibilityToggle.click()
  309 |       await linkBLinkVisibilityToggle.click()
  310 |     }, [

  at Object.act (e2e/app-dir/segment-cache/revalidation/segment-cache-revalidation.test.ts:307:11)

pnpm test-start test/e2e/app-dir/segment-cache/vary-params/vary-params.test.ts (job)

  • segment cache - vary params > does not reuse prefetched segment when page accesses searchParams (DD)
Expand output

● segment cache - vary params › does not reuse prefetched segment when page accesses searchParams

thrown: "Exceeded timeout of 60000 ms for a test.
Add a timeout value to this test to increase the timeout, if this is a long-running test. See https://jestjs.io/docs/api#testname-fn-timeout."

  50 |       }
  51 |
> 52 |       const result = Reflect.apply(target, thisArg, args)
     |                              ^
  53 |       return typeof result === 'function' ? wrapJestTestFn(result) : result
  54 |     },
  55 |     get(target, prop, receiver) {

  at Object.apply (lib/e2e-utils/index.ts:52:30)
  at it (e2e/app-dir/segment-cache/vary-params/vary-params.test.ts:153:3)
  at Object.describe (e2e/app-dir/segment-cache/vary-params/vary-params.test.ts:21:1)

pnpm test-start test/production/app-dir/metadata-static-route-cache/metadata-static-route-cache.test.ts (job)

  • app dir - metadata static routes cache > should generate different content after replace the static metadata file (DD)
Expand output

● app dir - metadata static routes cache › should generate different content after replace the static metadata file

thrown: "Exceeded timeout of 60000 ms for a test.
Add a timeout value to this test to increase the timeout, if this is a long-running test. See https://jestjs.io/docs/api#testname-fn-timeout."

  50 |       }
  51 |
> 52 |       const result = Reflect.apply(target, thisArg, args)
     |                              ^
  53 |       return typeof result === 'function' ? wrapJestTestFn(result) : result
  54 |     },
  55 |     get(target, prop, receiver) {

  at Object.apply (lib/e2e-utils/index.ts:52:30)
  at it (production/app-dir/metadata-static-route-cache/metadata-static-route-cache.test.ts:16:3)
  at Object.describe (production/app-dir/metadata-static-route-cache/metadata-static-route-cache.test.ts:10:1)

pnpm test-start test/e2e/app-dir/next-after-app-static/build-time/build-time.test.ts (job)

  • app dir - prefetching > should show layout eagerly when prefetched with loading one level down (DD)
  • app dir - prefetching > should immediately render the loading state for a dynamic segment when fetched from higher up in the tree (DD)
Expand output

● app dir - prefetching › should show layout eagerly when prefetched with loading one level down

thrown: "Exceeded timeout of 60000 ms for a test.
Add a timeout value to this test to increase the timeout, if this is a long-running test. See https://jestjs.io/docs/api#testname-fn-timeout."

  50 |       }
  51 |
> 52 |       const result = Reflect.apply(target, thisArg, args)
     |                              ^
  53 |       return typeof result === 'function' ? wrapJestTestFn(result) : result
  54 |     },
  55 |     get(target, prop, receiver) {

  at Object.apply (lib/e2e-utils/index.ts:52:30)
  at it (e2e/app-dir/app-prefetch/prefetching.test.ts:28:3)
  at Object.describe (e2e/app-dir/app-prefetch/prefetching.test.ts:11:1)

● app dir - prefetching › should immediately render the loading state for a dynamic segment when fetched from higher up in the tree

thrown: "Exceeded timeout of 60000 ms for a test.
Add a timeout value to this test to increase the timeout, if this is a long-running test. See https://jestjs.io/docs/api#testname-fn-timeout."

  50 |       }
  51 |
> 52 |       const result = Reflect.apply(target, thisArg, args)
     |                              ^
  53 |       return typeof result === 'function' ? wrapJestTestFn(result) : result
  54 |     },
  55 |     get(target, prop, receiver) {

  at Object.apply (lib/e2e-utils/index.ts:52:30)
  at it (e2e/app-dir/app-prefetch/prefetching.test.ts:304:3)
  at Object.describe (e2e/app-dir/app-prefetch/prefetching.test.ts:11:1)

@nextjs-bot
Copy link
Collaborator

nextjs-bot commented Feb 21, 2026

Stats from current PR

🔴 1 regression

Metric Canary PR Change Trend
node_modules Size 475 MB 475 MB 🔴 +320 kB (+0%) ▁▁▁▁▁
📊 All Metrics
📖 Metrics Glossary

Dev Server Metrics:

  • Listen = TCP port starts accepting connections
  • First Request = HTTP server returns successful response
  • Cold = Fresh build (no cache)
  • Warm = With cached build artifacts

Build Metrics:

  • Fresh = Clean build (no .next directory)
  • Cached = With existing .next directory

Change Thresholds:

  • Time: Changes < 50ms AND < 10%, OR < 2% are insignificant
  • Size: Changes < 1KB AND < 1% are insignificant
  • All other changes are flagged to catch regressions

⚡ Dev Server

Metric Canary PR Change Trend
Cold (Listen) 455ms 456ms ▁▁▁▁▁
Cold (Ready in log) 437ms 436ms ▂▁▂▁▂
Cold (First Request) 1.252s 1.227s ▄▄▄▂▄
Warm (Listen) 457ms 456ms ▁▁▁▁▁
Warm (Ready in log) 440ms 440ms ▁▁▁▁▁
Warm (First Request) 340ms 343ms ▁▁▁▂▁
📦 Dev Server (Webpack) (Legacy)

📦 Dev Server (Webpack)

Metric Canary PR Change Trend
Cold (Listen) 455ms 455ms █▁█▁▁
Cold (Ready in log) 439ms 439ms ▆▂▆▆▆
Cold (First Request) 1.951s 1.945s ▅▁▆▅▅
Warm (Listen) 456ms 456ms ▃▃▆▃▁
Warm (Ready in log) 439ms 440ms ▆▁███
Warm (First Request) 1.979s 1.952s ▄▁▅▅▅

⚡ Production Builds

Metric Canary PR Change Trend
Fresh Build 4.004s 3.969s ▁▁▁▃▁
Cached Build 3.992s 4.039s ▁▁▁▃▁
📦 Production Builds (Webpack) (Legacy)

📦 Production Builds (Webpack)

Metric Canary PR Change Trend
Fresh Build 14.097s 14.156s ▂▁▄▂▂
Cached Build 14.251s 14.214s ▂▁▃▂▂
node_modules Size 475 MB 475 MB 🔴 +320 kB (+0%) ▁▁▁▁▁
📦 Bundle Sizes

Bundle Sizes

⚡ Turbopack

Client

Main Bundles: **400 kB** → **400 kB** ⚠️ +318 B

80 files with content-based hashes (individual files not comparable between builds)

Server

Middleware
Canary PR Change
middleware-b..fest.js gzip 764 B 766 B
Total 764 B 766 B ⚠️ +2 B
Build Details
Build Manifests
Canary PR Change
_buildManifest.js gzip 451 B 452 B
Total 451 B 452 B ⚠️ +1 B

📦 Webpack

Client

Main Bundles
Canary PR Change
5528-HASH.js gzip 5.54 kB N/A -
6280-HASH.js gzip 58.3 kB N/A -
6335.HASH.js gzip 169 B N/A -
912-HASH.js gzip 4.59 kB N/A -
e8aec2e4-HASH.js gzip 62.6 kB N/A -
framework-HASH.js gzip 59.7 kB 59.7 kB
main-app-HASH.js gzip 256 B 254 B
main-HASH.js gzip 39.1 kB 39.1 kB
webpack-HASH.js gzip 1.68 kB 1.68 kB
262-HASH.js gzip N/A 4.59 kB -
2889.HASH.js gzip N/A 169 B -
5602-HASH.js gzip N/A 5.55 kB -
6948ada0-HASH.js gzip N/A 62.6 kB -
9544-HASH.js gzip N/A 59.4 kB -
Total 232 kB 233 kB ⚠️ +1.12 kB
Polyfills
Canary PR Change
polyfills-HASH.js gzip 39.4 kB 39.4 kB
Total 39.4 kB 39.4 kB
Pages
Canary PR Change
_app-HASH.js gzip 194 B 194 B
_error-HASH.js gzip 183 B 180 B 🟢 3 B (-2%)
css-HASH.js gzip 331 B 330 B
dynamic-HASH.js gzip 1.81 kB 1.81 kB
edge-ssr-HASH.js gzip 256 B 256 B
head-HASH.js gzip 351 B 352 B
hooks-HASH.js gzip 384 B 383 B
image-HASH.js gzip 580 B 581 B
index-HASH.js gzip 260 B 260 B
link-HASH.js gzip 2.5 kB 2.5 kB
routerDirect..HASH.js gzip 320 B 319 B
script-HASH.js gzip 386 B 386 B
withRouter-HASH.js gzip 315 B 315 B
1afbb74e6ecf..834.css gzip 106 B 106 B
Total 7.97 kB 7.97 kB ✅ -2 B

Server

Edge SSR
Canary PR Change
edge-ssr.js gzip 125 kB 125 kB
page.js gzip 254 kB 255 kB
Total 379 kB 380 kB ⚠️ +690 B
Middleware
Canary PR Change
middleware-b..fest.js gzip 617 B 617 B
middleware-r..fest.js gzip 156 B 155 B
middleware.js gzip 43.8 kB 43.7 kB
edge-runtime..pack.js gzip 842 B 842 B
Total 45.4 kB 45.3 kB ✅ -93 B
Build Details
Build Manifests
Canary PR Change
_buildManifest.js gzip 715 B 718 B
Total 715 B 718 B ⚠️ +3 B
Build Cache
Canary PR Change
0.pack gzip 4.02 MB 4.03 MB 🔴 +13.9 kB (+0%)
index.pack gzip 103 kB 103 kB
index.pack.old gzip 103 kB 101 kB 🟢 2.04 kB (-2%)
Total 4.22 MB 4.23 MB ⚠️ +12 kB

🔄 Shared (bundler-independent)

Runtimes
Canary PR Change
app-page-exp...dev.js gzip 320 kB 321 kB
app-page-exp..prod.js gzip 170 kB 170 kB
app-page-tur...dev.js gzip 319 kB 320 kB
app-page-tur..prod.js gzip 169 kB 170 kB
app-page-tur...dev.js gzip 316 kB 317 kB
app-page-tur..prod.js gzip 168 kB 168 kB
app-page.run...dev.js gzip 316 kB 317 kB
app-page.run..prod.js gzip 168 kB 168 kB
app-route-ex...dev.js gzip 70.8 kB 70.8 kB
app-route-ex..prod.js gzip 49.2 kB 49.2 kB
app-route-tu...dev.js gzip 70.8 kB 70.8 kB
app-route-tu..prod.js gzip 49.2 kB 49.2 kB
app-route-tu...dev.js gzip 70.4 kB 70.4 kB
app-route-tu..prod.js gzip 49 kB 49 kB
app-route.ru...dev.js gzip 70.4 kB 70.4 kB
app-route.ru..prod.js gzip 49 kB 49 kB
dist_client_...dev.js gzip 324 B 324 B
dist_client_...dev.js gzip 326 B 326 B
dist_client_...dev.js gzip 318 B 318 B
dist_client_...dev.js gzip 317 B 317 B
pages-api-tu...dev.js gzip 43.2 kB 43.2 kB
pages-api-tu..prod.js gzip 32.9 kB 32.9 kB
pages-api.ru...dev.js gzip 43.2 kB 43.2 kB
pages-api.ru..prod.js gzip 32.8 kB 32.8 kB
pages-turbo....dev.js gzip 52.5 kB 52.5 kB
pages-turbo...prod.js gzip 38.5 kB 38.5 kB
pages.runtim...dev.js gzip 52.5 kB 52.5 kB
pages.runtim..prod.js gzip 38.4 kB 38.4 kB
server.runti..prod.js gzip 62 kB 62 kB
Total 2.82 MB 2.83 MB ⚠️ +5.28 kB
📝 Changed Files (8 files)

Files with changes:

  • app-page-exp..ntime.dev.js
  • app-page-exp..time.prod.js
  • app-page-tur..ntime.dev.js
  • app-page-tur..time.prod.js
  • app-page-tur..ntime.dev.js
  • app-page-tur..time.prod.js
  • app-page.runtime.dev.js
  • app-page.runtime.prod.js
View diffs
app-page-exp..ntime.dev.js
failed to diff
app-page-exp..time.prod.js
failed to diff
app-page-tur..ntime.dev.js
failed to diff
app-page-tur..time.prod.js
failed to diff
app-page-tur..ntime.dev.js
failed to diff
app-page-tur..time.prod.js
failed to diff
app-page.runtime.dev.js
failed to diff
app-page.runtime.prod.js
failed to diff
📎 Tarball URL
next@https://vercel-packages.vercel.app/next/prs/90306/next

@unstubbable unstubbable force-pushed the hl/cached-navs-1 branch 2 times, most recently from 2218724 to d5633db Compare February 22, 2026 12:06
@unstubbable unstubbable force-pushed the hl/cached-navs-2 branch 2 times, most recently from 34ea36d to 457083c Compare February 22, 2026 16:32
@unstubbable unstubbable changed the title Cached Navigations: Cache fully static initial HTML RSC payload Cached Navigations: Cache visited fully static pages in the segment cache Feb 23, 2026
@unstubbable unstubbable force-pushed the hl/cached-navs-2 branch 2 times, most recently from fdaa15f to 5c3b674 Compare February 24, 2026 10:31
@vercel
Copy link
Contributor

vercel bot commented Feb 25, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
next-js Error Error Feb 25, 2026 3:05pm

@unstubbable unstubbable marked this pull request as ready for review February 25, 2026 21:11
@unstubbable unstubbable requested a review from acdlite February 25, 2026 21:11
@unstubbable unstubbable changed the base branch from hl/cached-navs-1 to graphite-base/90306 February 25, 2026 21:44
@graphite-app graphite-app bot changed the base branch from graphite-base/90306 to canary February 25, 2026 21:45
@unstubbable unstubbable removed the request for review from acdlite February 25, 2026 21:53
@unstubbable unstubbable marked this pull request as draft February 25, 2026 21:53
* with Cache Components enabled).
*/
async function stripIsPartialByte(
export async function writeInitialSeedDataIntoCache(
Copy link
Contributor Author

Choose a reason for hiding this comment

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

should be sync

When a fully static page is loaded, the RSC payload inlined in the HTML
is now written into the client-side segment cache during hydration. This
allows subsequent client-side navigations to the same route to be served
entirely from cache without any server requests, until the stale time
expires.

Server: Include a `StaleTimeIterable` (`s` field) in the
`InitialRSCPayload` during the Cache Components prerender path. The
iterable is tracked on `finalServerPrerenderStore` and closed alongside
the vary params accumulator in the sequential task after prerender
completes.

Client: Thread the stale time and head vary params from the decoded
payload through `createInitialRouterState` into a new
`writeInitialSeedDataIntoCache` function that writes each segment and
the head into the segment cache using the existing
`writeSeedDataIntoCache` / `fulfillEntrySpawnedByRuntimePrefetch`
functions.

Partially static pages (resumed at runtime) are not handled yet. Those
need byte-level truncation of the Flight stream to extract only the
static stage (same as for dynamic RSC requests).
The `isResponsePartial` value from `cacheData` (set by `processFetch`)
defaults to `true` when no marker byte is found. This is correct for
navigation requests (conservative default), but wrong for Full and
LoadingBoundary prefetches which are by definition complete.

Before the `processFetch` refactoring, the prefetch path only read
`isResponsePartial` from `stripIsPartialByte` for PPRRuntime, and
defaulted to `false` for other strategies. The refactoring changed it to
always read from `cacheData`, which overrode the correct `false` default
with an incorrect `true` for non-PPRRuntime strategies.

Fix: only use `cacheData.isResponsePartial` for PPRRuntime prefetches;
default to `false` for Full and LoadingBoundary.
Replace the `isResponsePartial` boolean in
`writeDynamicRenderResponseIntoCache` and `writeSeedDataIntoCache` with
a three-state `ResponseCompleteness` enum that distinguishes:

- `Partial`: segments have dynamic holes (static stage responses,
  PPRRuntime prefetches with `~` marker, postponed responses)
- `Complete`: all segments are complete, but the head may still be
  partial per the server's flag (Full/LoadingBoundary prefetches)
- `FullyStatic`: server explicitly marked the response as fully static
  (marker byte `#`) — both segments and head are complete

The boolean conflated two independent concerns: segment partiality and
whether the server's `isHeadPartial` flag can be overridden. This caused
Full prefetches (which are complete but may have partial heads) to
incorrectly override `isHeadPartial` to `false`, breaking loading
boundary display for pages with dynamic metadata.
The server's `#` marker byte signals that a prerendered response is
fully static (no dynamic follow-up needed). Previously this was used to
control `ResponseCompleteness` during segment cache writes, marking
segments as non-partial. This caused two regressions:

1. Loading boundaries not showing for pages with dynamic metadata (head
   cached as non-partial when it should be partial)
2. Server action refresh breaking parallel routes (default navbar slot
   cached as non-partial, preventing dynamic data from overwriting it)

The root cause: the marker is a route-level signal but was applied as a
segment-level signal at write time.

Now, `isFullyStatic` is stored on the `FulfilledRouteCacheEntry` and
checked at read time in `createCacheNodeForSegment`. When navigating to
a fully static route with cached segment data available, the read path
overrides `isCachedRscPartial` and `isCachedHeadPartial` to skip the
dynamic request. This respects per-segment freshness: once entries
expire, the override no longer applies and a dynamic request is made.

The navigation write path (`writeStaticStageResponseIntoCache`) now
always uses `Partial` completeness. The prefetch write path still uses
`FullyStatic` for the head override since prefetch entries don't
conflict with refresh data.
The three-value `ResponseCompleteness` enum (`Partial`, `Complete`,
`FullyStatic`) was conflating two concerns: segment partiality and the
head override for fully static routes. Now that `isFullyStatic` lives on
the route cache entry, we can use it directly for the head override and
reduce the enum to a simple boolean (same as on `canary`).

- Set `route.isFullyStatic` from the prefetch write path (consistent
  with the navigation and initial HTML paths)
- Use `route.isFullyStatic` for the head partiality override in
  `writeDynamicRenderResponseIntoCache`
- Replace the enum with `isResponsePartial: boolean` in
  `writeDynamicRenderResponseIntoCache` and `writeSeedDataIntoCache`
The server prepends a completeness marker byte to RSC Flight responses.
Previously, both fully static prerenders and complete runtime prefetches
used `#` (0x23), which caused the client to incorrectly set
`route.isFullyStatic` for non-fully-static pages whose runtime render
happened to complete without aborting. This made the segment cache skip
the dynamic follow-up request at navigation time, leaving content from
non-runtime-prefetchable segments missing.

Introduce a third marker byte `*` (0x2a) for complete runtime prefetches
from `finalRuntimeServerPrerender`, reserving `#` for build-time
prerendered fully static pages. The client now only sets
`route.isFullyStatic` when it sees `#`, which is only served for pages
that are genuinely fully static.

The three markers are defined in a shared `ResponseCompletenessMarker`
enum:
- `#` (Static)   — fully static prerender, all segments present
- `*` (Complete)  — complete runtime prefetch, included segments are
                    complete but the response may omit segments
- `~` (Partial)   — partial, contains dynamic holes
Server action redirects to fully static pages include the prerendered
flight data for the redirect target, which has the
`ResponseCompletenessMarker` byte prepended at build time. The server
action reducer passed this response directly to React's
`createFromFetch` without stripping the marker, causing a `Connection
closed` error because the marker byte is not valid RSC Flight data.

Fix by running the response through `processFetch` (which calls
`stripCompletenessMarker`) before passing it to `createFromFetch`. Also
export `processFetch` so it can be reused, and preserve the `redirected`
property on the reconstructed `Response` since the `Response`
constructor does not carry it over from the original.
Delete `writeInitialSeedDataIntoCache` and `processStaticStageResponse`,
replacing their usage with `writeStaticStageResponseIntoCache` and
inline `getStaleAt` calls at each call site. This removes an async cache
write function (all cache writes should be sync, with async computations
at the call site) and eliminates duplicated logic between the initial
HTML and navigation cache write paths.

`writeDynamicRenderResponseIntoCache` and
`writeStaticStageResponseIntoCache` now take `flightData` and `buildId`
as explicit params instead of a full response object, and
`writeStaticStageResponseIntoCache` takes `headVaryParamsThenable`
instead of pre-resolved `headVaryParams`. The `responseHeaders` param is
removed — callers resolve the build ID from the deployment header before
calling.

The build ID check in `writeDynamicRenderResponseIntoCache` is changed
from `buildId !== getNavigationBuildId()` to `buildId && buildId !==
getNavigationBuildId()` so that a missing build ID (e.g. from the
initial HTML path) is treated as permissive rather than as a mismatch.

All `getStaleAt` call sites now use `.then().catch()` instead of
`.then(onFulfilled, onRejected)` so errors from both the stale time
computation and the cache write are caught.

`getStaleAt` is exported for use by external call sites.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants