Skip to content

Prevent synchronous IO from entering Cached Navigations - #98511

Merged
unstubbable merged 2 commits into
canaryfrom
hl/cached-navigations-sync-io
Sep 11, 2026
Merged

Prevent synchronous IO from entering Cached Navigations#98511
unstubbable merged 2 commits into
canaryfrom
hl/cached-navigations-sync-io

Conversation

@unstubbable

Copy link
Copy Markdown
Contributor

Cached Navigations incorrectly reused uncached timestamps as static page content. A repeat visit showed the previous request's Date.now() result while the new response was still pending. This affected both dynamic RSC responses and the RSC payload embedded in the initial HTML.

The production renderer left synchronous IO untracked because it assumed the build had already checked it. A private cache exposed the gap: the static prerender suspended at the cache call and never reached the Date.now() after it. A production request resolved the private cache during the static stage and included the uncached timestamp in the reusable response.

Production staged renders now end the static stage when synchronous request-time IO occurs. They mark the interrupted static and shell prefixes as empty, while the rest of the response renders normally. The client skips those prefixes instead of waiting for a Flight root that the interrupted stage may not have emitted.

The regression tests cover dynamic RSC navigation, initial HTML hydration, and full prefetching with a paramless private-cache fixture.

@unstubbable
unstubbable added this pull request to stack #98513 September 10, 2026 15:03
@github-actions

github-actions Bot commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

Tests Passed

Commit: df000df

@unstubbable
unstubbable marked this pull request as ready for review September 10, 2026 17:03
gnoff added a commit that referenced this pull request Sep 10, 2026
## Summary

Separate build-time prerender selection from runtime parameter matching behind
`experimental.paramMatching`. `generateStaticParams` still chooses concrete
build-time examples; `experimental_paramMatching` and the argument-free
`experimental_generateParamMatching()` configure visible parameters as
`not-found`, `blocking`, `fallback`, or permanently `dynamic`.

## Temporary stack foundation

Build on #98512 (and its prerequisite #98511), replacing our separate dev
validation fix in #98460. Staged rendering and static-shell validation share
Hendrik's selected-shell parameter set. This layer adds explicit fallback
boundaries without cloning the render context or introducing a second
validation-only parameter set.

## Example

For `/[lang]/catalog/[top]/items/[bottom]`:

```ts
// app/[lang]/layout.tsx
export const experimental_paramMatching = {
  lang: 'not-found',
} as const

// app/[lang]/catalog/[top]/items/[bottom]/page.tsx
export const experimental_paramMatching = {
  top: 'blocking',
  bottom: 'fallback',
} as const

export function generateStaticParams() {
  return [{ lang: 'en', top: 't1', bottom: 'b1' }]
}
```

A novel `lang` returns 404, a novel `top` blocks on generation, and a novel
`bottom` can receive fallback UI immediately. `/en/catalog/t1/items/b1` is
prerendered during the build. Closed prefixes use the existing fallback-false
adapter contract so the prototype does not require a Vercel proxy change.

## Composition and validation

- Layouts and pages may configure only parameters visible at or above their
  own segment; `[lang]/layout.tsx` cannot configure the later `top` parameter.
- Merge ancestor-to-descendant fragments by assignment. Generated fragments
  run independently and receive no parent argument.
- Validate explicit directives in `not-found -> blocking -> fallback -> dynamic`
  order before filling unconfigured holes using the shell heuristic. Do not
  silently rewrite inherited directives to repair incoherent overrides.
- Validate that parallel branches contributing to the same URL matcher agree.
- Reject build-time examples for explicitly dynamic parameters and constrain
  static export keys through generated types.

## Static shells and development

Explicit fallback shells must contain useful static UI rather than silently
becoming blocking. Blocking policies also require a useful reachable shell,
unless the route opts out with `instant = false`. Without an example reaching
the configured boundary, validate the reachable generic shell. Blocking-only
validation renders are not retained as cache outputs, and redundant generic
blocking renders are omitted when a more-specific validation covers them.

Development retains #98512's required-or-completed shell selection, including
mixed-depth branches. An explicit fallback adds its parameter and the suffix
to that staged set, even for a fully generated URL. For example, with a `t1/b1`
prerender and `top: 'fallback'`, requests for `t1/b1`, `t1/b2`, and `t2/b2` all
validate with both parameters unknown. Existing holes in a required partial
shell are preserved. Ordinary dev requests remain dynamic renders.

Matcher-only entries have no source artifact to validate; their runtime
metadata explicitly stages the completed shell, retaining any dynamic tail.

## Coverage

Fixtures cover inherited and overridden policies, inferred holes, runtime-only
generation, catch-all routes, parallel slots, foreground cache behavior,
development validation, export constraints, type generation, and invalid
configurations. The separate diagnostic commit remains optional and removable.
@unstubbable
unstubbable force-pushed the hl/cached-navigations-sync-io branch from 0412531 to 14255e5 Compare September 10, 2026 19:43
unstubbable pushed a commit that referenced this pull request Sep 10, 2026
## Summary

Separate build-time prerender selection from runtime parameter matching behind
`experimental.paramMatching`. `generateStaticParams` still chooses concrete
build-time examples; `experimental_paramMatching` and the argument-free
`experimental_generateParamMatching()` configure visible parameters as
`not-found`, `blocking`, `fallback`, or permanently `dynamic`.

## Temporary stack foundation

Build on #98512 (and its prerequisite #98511), replacing our separate dev
validation fix in #98460. Staged rendering and static-shell validation share
Hendrik's selected-shell parameter set. This layer adds explicit fallback
boundaries without cloning the render context or introducing a second
validation-only parameter set.

## Example

For `/[lang]/catalog/[top]/items/[bottom]`:

```ts
// app/[lang]/layout.tsx
export const experimental_paramMatching = {
  lang: 'not-found',
} as const

// app/[lang]/catalog/[top]/items/[bottom]/page.tsx
export const experimental_paramMatching = {
  top: 'blocking',
  bottom: 'fallback',
} as const

export function generateStaticParams() {
  return [{ lang: 'en', top: 't1', bottom: 'b1' }]
}
```

A novel `lang` returns 404, a novel `top` blocks on generation, and a novel
`bottom` can receive fallback UI immediately. `/en/catalog/t1/items/b1` is
prerendered during the build. Closed prefixes use the existing fallback-false
adapter contract so the prototype does not require a Vercel proxy change.

## Composition and validation

- Layouts and pages may configure only parameters visible at or above their
  own segment; `[lang]/layout.tsx` cannot configure the later `top` parameter.
- Merge ancestor-to-descendant fragments by assignment. Generated fragments
  run independently and receive no parent argument.
- Validate explicit directives in `not-found -> blocking -> fallback -> dynamic`
  order before filling unconfigured holes using the shell heuristic. Do not
  silently rewrite inherited directives to repair incoherent overrides.
- Validate that parallel branches contributing to the same URL matcher agree.
- Reject build-time examples for explicitly dynamic parameters and constrain
  static export keys through generated types.

## Static shells and development

Explicit fallback shells must contain useful static UI rather than silently
becoming blocking. Blocking policies also require a useful reachable shell,
unless the route opts out with `instant = false`. Without an example reaching
the configured boundary, validate the reachable generic shell. Blocking-only
validation renders are not retained as cache outputs, and redundant generic
blocking renders are omitted when a more-specific validation covers them.

Development retains #98512's required-or-completed shell selection, including
mixed-depth branches. An explicit fallback adds its parameter and the suffix
to that staged set, even for a fully generated URL. For example, with a `t1/b1`
prerender and `top: 'fallback'`, requests for `t1/b1`, `t1/b2`, and `t2/b2` all
validate with both parameters unknown. Existing holes in a required partial
shell are preserved. Ordinary dev requests remain dynamic renders.

Matcher-only entries have no source artifact to validate; their runtime
metadata explicitly stages the completed shell, retaining any dynamic tail.

## Coverage

Fixtures cover inherited and overridden policies, inferred holes, runtime-only
generation, catch-all routes, parallel slots, foreground cache behavior,
development validation, export constraints, type generation, and invalid
configurations. The separate diagnostic commit remains optional and removable.
Comment on lines +1183 to +1185
stageController.getSyncInterruptReason()
? 0
: byteLengths[RenderStage.Static]

@lubieowoce lubieowoce Sep 10, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

why is this needed? StagedRenderingController already gets out of Static when we hit sync IO:

// If we're in a non-abandonable & non-abortable render,
// we need to advance to the Dynamic stage and capture the interruption reason.
// (in dev, this will be the restarted render)
this.syncInterruptReason = reason
this.advanceStage(RenderStage.Dynamic)

so any content emitted after twouldn't make it into the cached nav anyway

and just because it was aborted prematurely doesn't mean it'll be useless, because it may have been in a suspense boundary. sure, we'd error for this in build, but having some content seems better than none at all?

@lubieowoce lubieowoce Sep 10, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

oh did this hang in decodeBufferedStage because the root is blocked? in that case we should probably just reject the decode after setTimeout(_, 0), that's not the only scenario that can block the root. e.g. we can have

export default async function RootLayout({ children }) {
  // not blocking during build, blocking after revalidation
  if (await cacheThatBecomesTrueAfterRevalidation()) {
    await dynamicData() 
  }
  // ...
}

which (off the top of my head, untested) should be fixed by something like this:

export function decodeBufferedStage<T>(
  buffer: Uint8Array,
  headers: RequestHeaders | undefined
): Promise<T> {
  const stream = new ReadableStream<Uint8Array>({
    start(controller) {
      controller.enqueue(buffer)
      controller.close()
    },
  })
  return new Promise<T>((resolve, reject) => {
    // The stream is buffered, so the decode should yield a root microtaskily.
    // If it doesn't, then the root must be blocked.
    const timeout = setTimeout(() => {
      reject(new Error('Root is blocked.'))
      stream.cancel()
    });

    createFromNextReadableStream<T>(stream, headers, {
      allowPartialStream: true,
    }).then((root) => {
      clearTimeout(timeout)
      resolve(root);
    }, reject);
  });
}

(we may also just want to return a hanging promise, but if we know it's useless bc it blocks, then i guess there's no point)

syncIO: SyncIOMode.Untracked,
// Synchronous request-time data ends the static stage before its result can
// enter the Cached Navigation.
syncIO: SyncIOMode.AllowedInRuntimeOrDynamic,

@lubieowoce lubieowoce Sep 11, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

may want to use getSyncIOMode(prefetchMode) for consistency. in PPF we only allow sync IO in the dynamic stage so if we hit some at any point we should go straight to dynamic

...not that it matters very much, because we don't care about recovering stages past Static here anyway, but at least then it won't look like an exception when it really doesn't need to be

Cached Navigations incorrectly reused uncached timestamps as static page
content. A repeat visit showed the previous request's `Date.now()` result
while the new response was still pending. This affected both dynamic RSC
responses and the RSC payload embedded in the initial HTML.

The production renderer left synchronous IO untracked because it assumed
the build had already checked it. A private cache exposed the gap: the
static prerender suspended at the cache call and never reached the
`Date.now()` after it. A production request resolved the private cache
during the static stage and included the uncached timestamp in the
reusable response.

Production staged renders now end the static stage when synchronous
request-time IO occurs. They mark the interrupted static and shell
prefixes as empty, while the rest of the response renders normally. The
client skips those prefixes instead of waiting for a Flight root that
the interrupted stage may not have emitted.

The regression tests cover dynamic RSC navigation, initial HTML
hydration, and full prefetching with a paramless private-cache fixture.
Sync interruptions currently discard the entire static prefix, including
usable content. This change preserves that content and bounds optional
prefix decoding so an unresolved Flight root cannot block navigation.
unstubbable pushed a commit that referenced this pull request Sep 11, 2026
## Summary

Separate build-time prerender selection from runtime parameter matching behind
`experimental.paramMatching`. `generateStaticParams` still chooses concrete
build-time examples; `experimental_paramMatching` and the argument-free
`experimental_generateParamMatching()` configure visible parameters as
`not-found`, `blocking`, `fallback`, or permanently `dynamic`.

## Temporary stack foundation

Build on #98512 (and its prerequisite #98511), replacing our separate dev
validation fix in #98460. Staged rendering and static-shell validation share
Hendrik's selected-shell parameter set. This layer adds explicit fallback
boundaries without cloning the render context or introducing a second
validation-only parameter set.

## Example

For `/[lang]/catalog/[top]/items/[bottom]`:

```ts
// app/[lang]/layout.tsx
export const experimental_paramMatching = {
  lang: 'not-found',
} as const

// app/[lang]/catalog/[top]/items/[bottom]/page.tsx
export const experimental_paramMatching = {
  top: 'blocking',
  bottom: 'fallback',
} as const

export function generateStaticParams() {
  return [{ lang: 'en', top: 't1', bottom: 'b1' }]
}
```

A novel `lang` returns 404, a novel `top` blocks on generation, and a novel
`bottom` can receive fallback UI immediately. `/en/catalog/t1/items/b1` is
prerendered during the build. Closed prefixes use the existing fallback-false
adapter contract so the prototype does not require a Vercel proxy change.

## Composition and validation

- Layouts and pages may configure only parameters visible at or above their
  own segment; `[lang]/layout.tsx` cannot configure the later `top` parameter.
- Merge ancestor-to-descendant fragments by assignment. Generated fragments
  run independently and receive no parent argument.
- Validate explicit directives in `not-found -> blocking -> fallback -> dynamic`
  order before filling unconfigured holes using the shell heuristic. Do not
  silently rewrite inherited directives to repair incoherent overrides.
- Validate that parallel branches contributing to the same URL matcher agree.
- Reject build-time examples for explicitly dynamic parameters and constrain
  static export keys through generated types.

## Static shells and development

Explicit fallback shells must contain useful static UI rather than silently
becoming blocking. Blocking policies also require a useful reachable shell,
unless the route opts out with `instant = false`. Without an example reaching
the configured boundary, validate the reachable generic shell. Blocking-only
validation renders are not retained as cache outputs, and redundant generic
blocking renders are omitted when a more-specific validation covers them.

Development retains #98512's required-or-completed shell selection, including
mixed-depth branches. An explicit fallback adds its parameter and the suffix
to that staged set, even for a fully generated URL. For example, with a `t1/b1`
prerender and `top: 'fallback'`, requests for `t1/b1`, `t1/b2`, and `t2/b2` all
validate with both parameters unknown. Existing holes in a required partial
shell are preserved. Ordinary dev requests remain dynamic renders.

Matcher-only entries have no source artifact to validate; their runtime
metadata explicitly stages the completed shell, retaining any dynamic tail.

## Coverage

Fixtures cover inherited and overridden policies, inferred holes, runtime-only
generation, catch-all routes, parallel slots, foreground cache behavior,
development validation, export constraints, type generation, and invalid
configurations. The separate diagnostic commit remains optional and removable.
@unstubbable
unstubbable force-pushed the hl/cached-navigations-sync-io branch from 14255e5 to df000df Compare September 11, 2026 08:01
@unstubbable
unstubbable merged commit 79daac3 into canary Sep 11, 2026
218 of 220 checks passed
@unstubbable
unstubbable deleted the hl/cached-navigations-sync-io branch September 11, 2026 08:46
unstubbable pushed a commit that referenced this pull request Sep 11, 2026
## Summary

Separate build-time prerender selection from runtime parameter matching behind
`experimental.paramMatching`. `generateStaticParams` still chooses concrete
build-time examples; `experimental_paramMatching` and the argument-free
`experimental_generateParamMatching()` configure visible parameters as
`not-found`, `blocking`, `fallback`, or permanently `dynamic`.

## Temporary stack foundation

Build on #98512 (and its prerequisite #98511), replacing our separate dev
validation fix in #98460. Staged rendering and static-shell validation share
Hendrik's selected-shell parameter set. This layer adds explicit fallback
boundaries without cloning the render context or introducing a second
validation-only parameter set.

## Example

For `/[lang]/catalog/[top]/items/[bottom]`:

```ts
// app/[lang]/layout.tsx
export const experimental_paramMatching = {
  lang: 'not-found',
} as const

// app/[lang]/catalog/[top]/items/[bottom]/page.tsx
export const experimental_paramMatching = {
  top: 'blocking',
  bottom: 'fallback',
} as const

export function generateStaticParams() {
  return [{ lang: 'en', top: 't1', bottom: 'b1' }]
}
```

A novel `lang` returns 404, a novel `top` blocks on generation, and a novel
`bottom` can receive fallback UI immediately. `/en/catalog/t1/items/b1` is
prerendered during the build. Closed prefixes use the existing fallback-false
adapter contract so the prototype does not require a Vercel proxy change.

## Composition and validation

- Layouts and pages may configure only parameters visible at or above their
  own segment; `[lang]/layout.tsx` cannot configure the later `top` parameter.
- Merge ancestor-to-descendant fragments by assignment. Generated fragments
  run independently and receive no parent argument.
- Validate explicit directives in `not-found -> blocking -> fallback -> dynamic`
  order before filling unconfigured holes using the shell heuristic. Do not
  silently rewrite inherited directives to repair incoherent overrides.
- Validate that parallel branches contributing to the same URL matcher agree.
- Reject build-time examples for explicitly dynamic parameters and constrain
  static export keys through generated types.

## Static shells and development

Explicit fallback shells must contain useful static UI rather than silently
becoming blocking. Blocking policies also require a useful reachable shell,
unless the route opts out with `instant = false`. Without an example reaching
the configured boundary, validate the reachable generic shell. Blocking-only
validation renders are not retained as cache outputs, and redundant generic
blocking renders are omitted when a more-specific validation covers them.

Development retains #98512's required-or-completed shell selection, including
mixed-depth branches. An explicit fallback adds its parameter and the suffix
to that staged set, even for a fully generated URL. For example, with a `t1/b1`
prerender and `top: 'fallback'`, requests for `t1/b1`, `t1/b2`, and `t2/b2` all
validate with both parameters unknown. Existing holes in a required partial
shell are preserved. Ordinary dev requests remain dynamic renders.

Matcher-only entries have no source artifact to validate; their runtime
metadata explicitly stages the completed shell, retaining any dynamic tail.

## Coverage

Fixtures cover inherited and overridden policies, inferred holes, runtime-only
generation, catch-all routes, parallel slots, foreground cache behavior,
development validation, export constraints, type generation, and invalid
configurations. The separate diagnostic commit remains optional and removable.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants