Skip to content

fix(app-router): expose not-found fallback flight payload#2349

Open
james-elicx wants to merge 6 commits into
mainfrom
codex/fix-metadata-not-found-flight
Open

fix(app-router): expose not-found fallback flight payload#2349
james-elicx wants to merge 6 commits into
mainfrom
codex/fix-metadata-not-found-flight

Conversation

@james-elicx

Copy link
Copy Markdown
Member

Summary

  • mirror HTTP access-fallback Flight chunks into Next.js's inline self.__next_f transport
  • keep the compatibility mirror disabled for ordinary App Router documents
  • add a local fixture for notFound() thrown by generateMetadata() with local boundary metadata

Parity context

Run 28143992598 failed:

  • test/e2e/app-dir/metadata-navigation/metadata-navigation.test.ts
  • should support notFound in generateMetadata
  • expected the inline Flight payload to contain Local found boundary, but decoded an empty payload

The initially requested segment-cache/metadata suite was excluded because its next.config.js sets cacheComponents: true.

Next.js v16.2.6 emits inline Flight instructions through self.__next_f in packages/next/src/server/app-render/use-flight-response.tsx. Vinext already produced the correct local not-found boundary in its private navigation-runtime Flight stream, but did not expose that payload through the Next.js-compatible inline transport inspected by the parity test.

Validation

  • vp test run tests/app-ssr-stream.test.ts -t "Next.js inline Flight|non-UTF-8"
  • vp test run tests/app-router-dev-server.test.ts -t "renders dynamic metadata|generateMetadata.*calls notFound"
  • vp check on the six changed runtime/test files
  • vp run vinext#build

The exact deploy-suite wrapper was attempted with one file and concurrency 1, but the retained Next.js checkout triggered an unexpected pnpm install; the run was stopped without changing the lockfile. CI should perform the full exact-head deploy verification.

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@pkg-pr-new

pkg-pr-new Bot commented Jun 25, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@vinext/cloudflare@2349
npm i https://pkg.pr.new/vinext@2349

commit: ba6b9e3

@github-actions

Copy link
Copy Markdown
Contributor
Example Preview Production Original
app-router-cloudflare preview production
pages-router-cloudflare preview production
app-router-playground preview production original
realworld-api-rest preview production
nextra-docs-template preview production
benchmarks preview production
hackernews preview production original
workers-cache preview production
web preview production

@github-actions

github-actions Bot commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Performance benchmarks

Compared a021d3c against base de014d4 using alternating same-runner rounds. Next.js was unchanged and skipped.

1 improved · 0 regressed · 5 within ±1.5%

Scenario Framework Baseline Current Change
Client bundle size (gzip) vinext 117.1 KB 117.1 KB ⚫ -0.0%
Client entry size (gzip) vinext 112.5 KB 112.5 KB ⚫ -0.0%
Dev server cold start vinext 2.58 s 2.52 s 🟢 -2.5%
Production build time vinext 3.09 s 3.10 s ⚫ +0.1%
RSC entry closure size (gzip) vinext 93.0 KB 93.0 KB ⚫ +0.0%
Server bundle size (gzip) vinext 156.9 KB 157.1 KB ⚫ +0.1%

View detailed results and traces

🟢 improvement · 🔴 regression · ⚫ change below 1.5% · paired base/head

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@ask-bonk

ask-bonk Bot commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

@james-elicx Bonk workflow was cancelled.

View workflow run · To retry, trigger Bonk again.

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@ask-bonk ask-bonk Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Review: expose not-found fallback flight payload

Reviewed the 3 commits (10a14eb, 70b0dc2, be6fc0c). The implementation is clean, well-typed, the backward-compatible optionsOrNonce overload is handled correctly, and the chunk-tag mapping ([1, string] for text, [3, base64] for binary) matches Next.js's FlightSegment union in app-index.tsx. vp check is clean on all four runtime files and the new/changed tests pass locally.

One correctness concern worth resolving before merge, plus a couple of minor notes.

1. Per-flush self.__next_f.length=0 can wipe a multi-flush payload (functional)

be6fc0c moved the cleanup script out of finalize() into the end of every flush() batch:

// app-ssr-stream.ts flush()
if (options.mirrorNextFlight) {
  scripts += createInlineScriptTag(createNextFlightCleanupScript(), options.scriptNonce);
}

rscEmbed.flush() is called on every Fizz flush tick in the streaming transform (app-ssr-stream.ts:606), so a fallback render that flushes more than once produces:

  • flush 1: push([0]), push([1, A]), length=0 ← drops the [0] bootstrap and chunk A
  • flush 2: push([1, B]) (no new [0], since mirroredNextFlightBootstrap stays true), length=0 ← drops chunk B

The net self.__next_f buffer ends empty, and the bootstrap [0] is gone for every batch after the first. Next.js's own consumer (nextServerDataCallback) throws "Unexpected server data: missing bootstrap script" when a [1,...] arrives with no preceding [0], so this is a genuine divergence from the transport this PR is trying to emulate.

Why the current tests still pass:

  • The dev-server / ported parity assertions scrape push([1,"..."]) from the HTML text and ignore length=0, so they're insensitive to runtime wipe.
  • The e2e test only asserts __next_f.length === 0 after hydration — which the per-flush cleanup trivially satisfies.
  • The targeted notFound() boundary renders as a small synchronous tree, so in practice it flushes once and the bug doesn't surface.

So it works for the fixture, but the mechanism is incorrect for any multi-flush fallback. Two options:

  • Preferred: emit the cleanup once in finalize() (the pre-be6fc0c behavior), and if the e2e "length must be 0" goal is the real requirement, keep length=0 only at the very end after all chunks. That preserves a decodable buffer for any consumer while still ending at length 0.
  • If per-batch clearing is truly intentional, document why the buffer is meant to be transient (i.e. that vinext never runs Next.js's app-index.tsx consumer) and add a multi-flush test that pins the expected behavior, so a future reader doesn't "fix" it back.

2. Add a multi-flush regression test

The new app-ssr-stream.test.ts cases all use a single batch (or a single chunk before an error). Regardless of which direction you take #1, please add a test that flushes twice with mirrorNextFlight: true and asserts the resulting script sequence — that's the case that actually distinguishes correct from incorrect cleanup placement and would have caught this.

3. Minor: not-found.tsx metadata export is not a Next.js convention

The fixture not-found.tsx exports a static metadata object. Next.js does not read a metadata export from not-found files (only layout/page/template participate in metadata resolution). vinext apparently honors it here, but if the intent is parity, the title/description should come from the route's generateMetadata/layout, not from not-found.tsx. Not blocking, but flag it so the fixture isn't mistaken for documented Next.js behavior.

Otherwise the change is focused and the gating (mirror only enabled for renderAppPageHttpAccessFallback, off for ordinary documents) is correct.

@ask-bonk

ask-bonk Bot commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2349. Summary of findings:

Review summary

The PR is well-scoped and clean — correct typing, backward-compatible overload for createRscEmbedTransform, chunk tags ([1, string] / [3, base64]) match Next.js's FlightSegment union, vp check clean, and the targeted tests pass locally.

One functional concern (should resolve before merge):

  • Per-flush self.__next_f.length=0 wipes multi-flush payloads. Commit be6fc0c moved the cleanup script from finalize() into the end of every flush() batch. Since rscEmbed.flush() runs on each Fizz flush tick, a fallback that streams in more than one flush ends with an empty __next_f buffer and loses the [0] bootstrap for all batches after the first — which Next.js's own consumer would reject with "missing bootstrap script". The current tests don't catch this because they scrape push([1,...]) from HTML text and the e2e test only checks length === 0, and the notFound() fixture happens to flush once. Recommended fix: emit cleanup once at end-of-stream.

Minor notes:

  • Add a multi-flush regression test for mirrorNextFlight: true (the case that distinguishes correct vs incorrect cleanup placement).
  • The not-found.tsx fixture's static metadata export isn't a real Next.js convention (only layout/page participate in metadata resolution) — flagged so it's not mistaken for documented parity behavior.

github run

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

Comment thread tests/app-ssr-stream.test.ts Fixed
@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@ask-bonk ask-bonk Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Review: expose not-found fallback flight payload

Reviewed all 4 commits (10a14eb, 70b0dc2, be6fc0c, 2f01bf2). The latest commit 2f01bf2 ("preserve deferred flight consumers") directly resolves the multi-flush concern raised in my earlier review on be6fc0c. The implementation is now correct and well-tested. Verified locally: vp check clean on all 5 changed runtime/test files, tests/app-ssr-stream.test.ts (43 tests) and the targeted tests/app-router-dev-server.test.ts metadata cases pass.

Multi-flush cleanup is now correct ✅

2f01bf2 fixes the per-flush length=0 wipe I flagged before. The cleanup script is now:

  1. Emitted once, gated by mirroredNextFlightBootstrap, alongside the [0] bootstrap on the first chunk (not at the end of every flush() batch).
  2. Deferred to DOMContentLoaded instead of running synchronously.
  3. Guarded by self.__next_f?.push === Array.prototype.push, so it only clears the buffer when no real consumer has taken over push.

This is the right shape and matches the Next.js transport contract. In app-index.tsx, Next.js replaces self.__next_f.push with nextServerDataCallback (nextServerDataLoadingGlobal.push = nextServerDataCallback), and that callback throws "missing bootstrap script" if a [1,…] arrives without a preceding [0]. The new code emits exactly one [0], all chunks in order, then a single deferred cleanup — so the buffer stays decodable for the entire stream regardless of flush count, and the guard means vinext never stomps a Next.js consumer if one is installed. The new app-ssr-stream.test.ts "preserves progressive chunks until a deferred Next.js consumer claims the buffer" test pins exactly this ([[0],[1,"first"],[1,"second"]] across two flushes, with push reassignment). Good regression coverage — this is the test I asked for.

For the vinext-only browser case, self.__next_f is never consumed by vinext's own browser entry (grep confirms no client-side reader), so push stays Array.prototype.push, the DOMContentLoaded listener fires and clears to length=0, satisfying the e2e poll assertion. Consistent end-to-end.

Correction to my earlier minor note #3 (not-found.tsx metadata) ✅

My previous review claimed the fixture's static metadata export in not-found.tsx "is not a Next.js convention." That was wrong. The upstream fixture this is ported from does exactly the same thing:

test/e2e/app-dir/metadata-navigation/app/async/not-found/not-found.tsx exports metadata = { title: 'Local not found', description: 'Local not found description' }, and the test (metadata-navigation.test.ts:30 "should support notFound in generateMetadata") asserts getTitle(browser) is 'Local not found' and the description/keywords/robots come from exactly the sources this PR's fixture uses. So the vinext fixture is a faithful port and the static metadata on not-found.tsx is documented parity behavior. Disregard my earlier flag.

Other checks

  • Chunk-tag mapping is correct: [1, string] for text and [3, base64] for binary match Next.js's FlightSegment union (app-index.tsx:60-64[1, responsePartial], [3, responseBase64Partial]). The new "non-UTF-8" test asserts self.__next_f.push([3,"/wABAgM="]).
  • Gating is correct and dev/prod-parity-safe: mirrorNextFlight: true is set only in renderAppPageHttpAccessFallback (app-page-boundary-render.ts:415), flows through the single app-page-streamapp-ssr-entry path, and App Router prod inherits it via the built RSC entry. Ordinary documents leave it undefined (off).
  • Backward-compatible optionsOrNonce: RscEmbedTransformOptions | string overload is handled correctly; no empty [0]-only mirror is emitted because flush() returns early on zero pending chunks and the bootstrap is emitted inside the per-chunk loop.

No blocking issues

The earlier functional concern is resolved and the requested multi-flush regression test was added. LGTM.

@ask-bonk

ask-bonk Bot commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2349.

Summary

I reviewed all 4 commits on PR #2349 (the prior review only saw 3). The newest commit 2f01bf2 directly resolves the multi-flush concern from the earlier review.

Findings — no blocking issues:

  1. Multi-flush cleanup is now correct. 2f01bf2 moved the self.__next_f.length=0 cleanup out of the per-flush() batch and into the one-time bootstrap path (gated by mirroredNextFlightBootstrap), deferred it to a DOMContentLoaded listener, and guarded it with self.__next_f?.push === Array.prototype.push. This emits a single [0] bootstrap, all chunks in order, then one deferred cleanup — keeping the buffer decodable for the whole stream and never stomping a real Next.js consumer. A multi-flush regression test was added that pins exactly this behavior.

  2. Corrected my own earlier minor note. The previous review wrongly claimed the fixture's not-found.tsx static metadata export "is not a Next.js convention." It actually faithfully mirrors the upstream Next.js fixture (metadata-navigation/app/async/not-found/not-found.tsx), and the ported test asserts the title/description come from it. This is documented parity behavior — disregard the earlier flag.

  3. Other checks pass: chunk-tag mapping ([1, string]/[3, base64]) matches Next.js's FlightSegment union; gating is dev/prod-parity-safe (mirror only in renderAppPageHttpAccessFallback); backward-compatible overload handled correctly. vp check clean, app-ssr-stream.test.ts (43 tests) and targeted dev-server tests pass.

LGTM.

github run

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@ask-bonk ask-bonk Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Review: expose not-found fallback flight payload

Reviewed all 5 commits (10a14eb, 70b0dc2, be6fc0c, 2f01bf2, a021d3c). The two open concerns from the prior review rounds are both resolved, and the newest commit a021d3c ("avoid parsing script tags with regex") addresses the CodeQL/github-advanced-security alert. Verified locally: vp check clean on all changed runtime + test files, tests/app-ssr-stream.test.ts (43 tests) green, and the targeted tests/app-router-dev-server.test.ts metadata cases (renders dynamic metadata, generateMetadata() calls notFound) pass.

CodeQL "bad HTML filtering regexp" is resolved ✅

The advanced-security alert flagged a regex in tests/app-ssr-stream.test.ts that wouldn't match upper-case <SCRIPT> tags. a021d3c removes the regex-based script extraction in the multi-flush test and replaces it with node:vm (createContext/runInContext) plus a literal </script><script> String.split. The test no longer attempts to filter/parse HTML with a regex, so the alert is addressed without weakening coverage — it now actually executes the emitted scripts in a VM context and asserts the resulting self.__next_f buffer is [[0],[1,"first"],[1,"second"]], which is stronger than the prior text scrape. The only remaining regex in the file (/<script>once<\/script>/g) is a fixed-literal occurrence count, not an HTML sanitizer, so it is not in scope for the alert.

Multi-flush cleanup remains correct ✅

2f01bf2's shape is preserved: a single [0] bootstrap + a single deferred DOMContentLoaded cleanup, both gated by mirroredNextFlightBootstrap, with the cleanup guarded by self.__next_f?.push === Array.prototype.push. Per-chunk [1,…]/[3,…] pushes are emitted in order across every flush, and the buffer is only zeroed once at DOMContentLoaded if no real consumer claimed push. This keeps the buffer decodable for the whole stream and never stomps a Next.js consumer. The new VM-based regression test pins exactly this behavior.

Other checks

  • </script> breakout safety: mirrored chunks go through safeJsonStringify (html.ts:21), which escapes </>/&/U+2028/U+2029 to unicode escapes, matching the existing navigation-runtime chunk path. No injection risk from RSC text containing </script>.
  • Chunk-tag mapping is correct: [1, string] for text, [3, base64] for binary, matching Next.js's FlightSegment union; the non-UTF-8 test asserts self.__next_f.push([3,"/wABAgM="]).
  • Dev/prod parity: mirrorNextFlight: true is set only in renderAppPageHttpAccessFallback (app-page-boundary-render.ts:412) and flows through the single app-fallback-rendererapp-page-streamapp-ssr-entry path. App Router prod inherits this via the built RSC entry (app-rsc-entry.ts imports app-fallback-renderer.js), so there is no separate prod path to update. Ordinary documents leave the flag undefined (off) — confirmed by the does not mirror … by default test and the renders dynamic metadata assertion that ordinary pages contain no self.__next_f.
  • No vinext client-side __next_f reader: grep confirms the __next_f mentions in app-ssr-entry.ts/app-page-stream.ts are doc comments only, so vinext's own browser never consumes the buffer — the DOMContentLoaded clear-to-zero satisfies the e2e poll while the guard protects a hypothetical Next.js consumer.
  • Backward-compatible overload (optionsOrNonce: RscEmbedTransformOptions | string) is handled correctly; flush() returns early on zero pending chunks so no empty [0]-only mirror is emitted.
  • The not-found.tsx static metadata export is a faithful port of the upstream Next.js fixture (documented parity behavior, per the earlier correction).

No blocking issues

All prior functional concerns are resolved, the requested multi-flush regression test is present, and the CodeQL alert is addressed. LGTM.

@ask-bonk

ask-bonk Bot commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2349.

Summary

I reviewed all 5 commits (the prior reviews only covered 4). The newest commit a021d3c directly resolves the open github-advanced-security / CodeQL alert. Verified locally: vp check clean on all changed files, tests/app-ssr-stream.test.ts (43 tests) and the targeted dev-server metadata tests pass.

Findings — no blocking issues:

  1. CodeQL "bad HTML filtering regexp" resolved. a021d3c removes the regex-based script extraction from the multi-flush test and replaces it with node:vm + literal </script><script> string splitting. The test now actually executes the emitted scripts and asserts the resulting self.__next_f buffer — stronger coverage, and the alert no longer applies. The only remaining regex in the file is a fixed-literal occurrence count, not a sanitizer.

  2. Multi-flush cleanup remains correct (from 2f01bf2): single [0] bootstrap, single deferred DOMContentLoaded cleanup guarded by push === Array.prototype.push.

  3. Independent checks I added beyond prior reviews:

    • </script> breakout safety — mirrored chunks go through safeJsonStringify which unicode-escapes </>/&, matching the existing navigation-runtime path.
    • Dev/prod parity — mirrorNextFlight: true flows through the single shared app-fallback-renderer path; App Router prod inherits it via the built RSC entry, so no separate prod path needs updating.
    • No vinext client-side __next_f reader (grep-confirmed), so clear-to-zero is safe.

LGTM.

github run

@james-elicx james-elicx marked this pull request as ready for review June 25, 2026 17:25
…t-found-flight

# Conflicts:
#	packages/vinext/src/server/app-ssr-entry.ts
#	packages/vinext/src/server/app-ssr-stream.ts
#	tests/app-ssr-stream.test.ts
@james-elicx

Copy link
Copy Markdown
Member Author

Worker B note: I checked this against GitHub Actions run 28478866791 / job 84413308650. This PR appears to cover the metadata-navigation notFound-in-generateMetadata failure in that run: it targets the fallback Flight payload for that exact metadata-navigation case and adds focused local coverage. I am treating that item as covered here and will skip duplicating this fix.

@james-elicx

Copy link
Copy Markdown
Member Author

Deploy-suite run 28478866791 / job 84413308650 triage: I think this PR already covers the metadata navigation notFound flight failure: test/e2e/app-dir/metadata-navigation/metadata-navigation.test.ts (1 failure for notFound in generateMetadata). I am skipping a duplicate backlog fix for that item and leaving this note so the overlap is visible.

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.

2 participants