Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Initial implementation of PPR client navigations #59725

Merged
merged 4 commits into from
Dec 20, 2023

Conversation

acdlite
Copy link
Contributor

@acdlite acdlite commented Dec 18, 2023

For a more detailed explanation of the algorithm, refer to the comments in ppr-navigations.ts. Below is a high-level overview.

Step 1: Render the prefetched data immediately

Immediately upon navigation, we construct a new Cache Node tree (i.e. copy-on-write) that represents the optimistic result of a navigation, using both the current Cache Node tree and data that was prefetched prior to navigation.

At this point, we haven't yet received the navigation response from the server. It could send back something completely different from the tree that was prefetched — due to rewrites, default routes, parallel routes, etc.

But in most cases, it will return the same tree that we prefetched, just with the dynamic holes filled in. So we optimistically assume this will happen, and accept that the real result could be arbitrarily different.

We'll reuse anything that was already in the previous tree, since that's what the server does.

New segments (ones that don't appear in the old tree) are assigned an unresolved promise. The data for these promises will be fulfilled later, when the navigation response is received.

The tree can be rendered immediately after it is created. Any new trees that do not have prefetch data will suspend during rendering, until the dynamic data streams in.

Step 2: Fill in the dynamic data as it streams in

When the dynamic data is received from the server, we can start filling in the unresolved promises in the tree. All the pending promises that were spawned by the navigation will be resolved, either with dynamic data from the server, or null to indicate that the data is missing.

A null value will trigger a lazy fetch during render, which will then patch up the tree using the same mechanism as the non-PPR implementation (serverPatchReducer).

Usually, the server will respond with exactly the subset of data that we're waiting for — everything below the nearest shared layout. But technically, the server can return anything it wants.

This does not create a new tree; it modifies the existing one in place. Which means it must follow the Suspense rules of cache safety.

To Do

Not all necessarily PR-blocking, since the status quo is that navigations don't work at all when PPR is enabled

  • Figure out how to handle dynamic metadata. Need to switch from prefetched metadata to final.
  • Some mistake related to parallel routes, need to look into failing tests

Closes NEXT-1894

@ijjk ijjk added created-by: Next.js team PRs by the Next.js team. type: next labels Dec 18, 2023
@ijjk
Copy link
Member

ijjk commented Dec 18, 2023

Tests Passed

@ijjk
Copy link
Member

ijjk commented Dec 18, 2023

Stats from current PR

Default Build (Increase detected ⚠️)
General Overall increase ⚠️
vercel/next.js canary acdlite/next.js ppr-navigations-initial Change
buildDuration 12.7s 12.7s N/A
buildDurationCached 7.1s 6.1s N/A
nodeModulesSize 200 MB 200 MB ⚠️ +186 kB
nextStartRea..uration (ms) 426ms 421ms N/A
Client Bundles (main, webpack) Overall increase ⚠️
vercel/next.js canary acdlite/next.js ppr-navigations-initial Change
193.HASH.js gzip 181 B 182 B N/A
3f784ff6-HASH.js gzip 53.3 kB 53.3 kB
433-HASH.js gzip 27 kB 28.3 kB ⚠️ +1.29 kB
framework-HASH.js gzip 45.2 kB 45.2 kB
main-app-HASH.js gzip 240 B 242 B N/A
main-HASH.js gzip 31.7 kB 31.7 kB N/A
webpack-HASH.js gzip 1.7 kB 1.7 kB N/A
Overall change 125 kB 127 kB ⚠️ +1.29 kB
Legacy Client Bundles (polyfills)
vercel/next.js canary acdlite/next.js ppr-navigations-initial Change
polyfills-HASH.js gzip 31 kB 31 kB
Overall change 31 kB 31 kB
Client Pages
vercel/next.js canary acdlite/next.js ppr-navigations-initial Change
_app-HASH.js gzip 194 B 195 B N/A
_error-HASH.js gzip 183 B 181 B N/A
amp-HASH.js gzip 504 B 502 B N/A
css-HASH.js gzip 321 B 321 B
dynamic-HASH.js gzip 2.5 kB 2.5 kB N/A
edge-ssr-HASH.js gzip 255 B 253 B N/A
head-HASH.js gzip 350 B 349 B N/A
hooks-HASH.js gzip 369 B 369 B
image-HASH.js gzip 4.28 kB 4.28 kB N/A
index-HASH.js gzip 255 B 256 B N/A
link-HASH.js gzip 2.61 kB 2.61 kB
routerDirect..HASH.js gzip 312 B 311 B N/A
script-HASH.js gzip 385 B 383 B N/A
withRouter-HASH.js gzip 307 B 308 B N/A
1afbb74e6ecf..834.css gzip 106 B 106 B
Overall change 3.4 kB 3.4 kB
Client Build Manifests
vercel/next.js canary acdlite/next.js ppr-navigations-initial Change
_buildManifest.js gzip 483 B 484 B N/A
Overall change 0 B 0 B
Rendered Page Sizes
vercel/next.js canary acdlite/next.js ppr-navigations-initial Change
index.html gzip 528 B 526 B N/A
link.html gzip 541 B 539 B N/A
withRouter.html gzip 525 B 522 B N/A
Overall change 0 B 0 B
Edge SSR bundle Size Overall increase ⚠️
vercel/next.js canary acdlite/next.js ppr-navigations-initial Change
edge-ssr.js gzip 93.7 kB 93.7 kB N/A
page.js gzip 146 kB 146 kB ⚠️ +131 B
Overall change 146 kB 146 kB ⚠️ +131 B
Middleware size
vercel/next.js canary acdlite/next.js ppr-navigations-initial Change
middleware-b..fest.js gzip 625 B 626 B N/A
middleware-r..fest.js gzip 151 B 151 B
middleware.js gzip 37.4 kB 37.4 kB N/A
edge-runtime..pack.js gzip 1.92 kB 1.92 kB
Overall change 2.07 kB 2.07 kB
Next Runtimes
vercel/next.js canary acdlite/next.js ppr-navigations-initial Change
app-page-exp...dev.js gzip 168 kB 168 kB
app-page-exp..prod.js gzip 94.1 kB 94.1 kB
app-page-tur..prod.js gzip 94.8 kB 94.8 kB
app-page-tur..prod.js gzip 89.3 kB 89.3 kB
app-page.run...dev.js gzip 138 kB 138 kB
app-page.run..prod.js gzip 88.6 kB 88.6 kB
app-route-ex...dev.js gzip 24 kB 24 kB
app-route-ex..prod.js gzip 16.6 kB 16.6 kB
app-route-tu..prod.js gzip 16.6 kB 16.6 kB
app-route-tu..prod.js gzip 16.2 kB 16.2 kB
app-route.ru...dev.js gzip 23.4 kB 23.4 kB
app-route.ru..prod.js gzip 16.2 kB 16.2 kB
pages-api-tu..prod.js gzip 9.37 kB 9.37 kB
pages-api.ru...dev.js gzip 9.64 kB 9.64 kB
pages-api.ru..prod.js gzip 9.37 kB 9.37 kB
pages-turbo...prod.js gzip 21.9 kB 21.9 kB
pages.runtim...dev.js gzip 22.5 kB 22.5 kB
pages.runtim..prod.js gzip 21.9 kB 21.9 kB
server.runti..prod.js gzip 49.4 kB 49.4 kB
Overall change 930 kB 930 kB
Diff details
Diff for page.js

Diff too large to display

Diff for 433-HASH.js

Diff too large to display

Commit: 57285ca

@acdlite acdlite force-pushed the ppr-navigations-initial branch 5 times, most recently from d203d58 to b9b9659 Compare December 18, 2023 06:43
@acdlite acdlite force-pushed the ppr-navigations-initial branch 5 times, most recently from 7a61b11 to 58490cf Compare December 19, 2023 18:01
@acdlite acdlite changed the title [WIP] PPR Navigations Initial implementation of PPR client navigations Dec 19, 2023
@acdlite acdlite marked this pull request as ready for review December 19, 2023 18:02
@acdlite acdlite force-pushed the ppr-navigations-initial branch from 58490cf to 8ca1f80 Compare December 20, 2023 03:03
@acdlite acdlite force-pushed the ppr-navigations-initial branch 3 times, most recently from 0622211 to 88fe459 Compare December 20, 2023 05:49
@acdlite acdlite force-pushed the ppr-navigations-initial branch 3 times, most recently from 1526aee to 8532444 Compare December 20, 2023 17:23
For PPR, we will have two versions of the head: a prefetched version,
and a full dynamic version. In preparation for this, I've updated
findHeadInCache to return the entire Cache Node instead of just the
`head` field, so in the future we can render either `head`
or `prefetchHead`.
When PPR is enabled, there are two different versions of a segment's
data we can render: a static version (that may contain holes for the
dynamic parts) and a full version that contains everything. The static
version is prefetched before navigation, and the full version is only
loaded once the navigation occurs.

Both versions are stored on the Cache Node object. Inside LayoutRouter,
we must choose which version to render. We'll use an experimental
feature of React's `useDeferredValue` hook:
facebook/react#27500

```js
const dataToRender = useDeferredValue(fullData, prefetchedData)
```

React will coordinate when to switch from the prefetch data to the full
data. For example, if the prefetch data is unable to finish rendering
(this would happen if the segment contained dynamic data that was not
wrapped in a Suspense boundary), it knows to switch to the full data
even though the first render did not commit.
Like segment data, the head data for a segment may have two versions:
a static version with dynamic holes, and a full version with everything.

This adds a `prefetchHead` field to Cache Node. It works the same as the
`prefetchRsc` field.

As with the previous commit, we use `useDeferredValue` to determine
which version to render.

One awkward bit is that although the head element belongs to a
particular segment, it's always rendered at the top of the App, by App
Router. I believe this is to allow the page metadata to update even
before the page itself appears. (Though I wonder if we could render this
in the shared layout instead.) In the meantime, in order for
useDeferredValue to work, we need to remount the head element whenever
the segment changes.
For a more detailed explanation of the algorithm, refer to the comments
in ppr-navigations.ts. Below is a high-level overview.

Step 1: Render the prefetched data immediately

Immediately upon navigation, we construct a new Cache Node tree (i.e.
copy-on-write) that represents the optimistic result of a navigation,
using both the current Cache Node tree and data that was prefetched
prior to navigation.

At this point, we haven't yet received the navigation response from the
server. It could send back something completely different from the tree
that was prefetched — due to rewrites, default routes, parallel routes,
etc.

But in most cases, it will return the same tree that we prefetched, just
with the dynamic holes filled in. So we optimistically assume this will
happen, and accept that the real result could be arbitrarily different.

We'll reuse anything that was already in the previous tree, since that's
what the server does.

New segments (ones that don't appear in the old tree) are assigned an
unresolved promise. The data for these promises will be fulfilled later,
when the navigation response is received.

The tree can be rendered immediately after it is created. Any new trees
that do not have prefetch data will suspend during rendering, until the
dynamic data streams in.

Step 2: Fill in the dynamic data as it streams in

When the dynamic data is received from the server, we can start filling
in the unresolved promises in the tree. All the pending promises that
were spawned by the navigation will be resolved, either with dynamic
data from the server, or `null` to indicate that the data is missing.

A `null` value will trigger a lazy fetch during render, which will then
patch up the tree using the same mechanism as the non-PPR implementation
(serverPatchReducer).

Usually, the server will respond with exactly the subset of data that
we're waiting for — everything below the nearest shared layout. But
technically, the server can return anything it wants.

This does _not_ create a new tree; it modifies the existing one in
place. Which means it must follow the Suspense rules of cache safety.
@acdlite acdlite force-pushed the ppr-navigations-initial branch from 8532444 to 57285ca Compare December 20, 2023 17:39
@acdlite acdlite merged commit 0f74659 into vercel:canary Dec 20, 2023
68 checks passed
acdlite added a commit to acdlite/next.js that referenced this pull request Dec 20, 2023
In vercel#59725 I skipped this test in PPR prod mode, but not dev because CI
wasn't failing for dev. The idea was to investigate the failure
post-merge because it wasn't block-worthy.

But the test did fail in dev mode when CI ran on canary. So this updates
the guard to skip in dev, too.

Will follow up with a PR to fix the test itself.
acdlite added a commit to acdlite/next.js that referenced this pull request Dec 20, 2023
In vercel#59725 I skipped this test in PPR prod mode, but not dev because CI
wasn't failing for dev. The idea was to investigate the failure
post-merge because it wasn't block-worthy.

But the test did fail in dev mode when CI ran on canary. So this updates
the guard to skip in dev, too.

Will follow up with a PR to fix the test itself.
acdlite added a commit that referenced this pull request Dec 20, 2023
In #59725 I skipped this test in PPR prod mode, but not dev because CI
wasn't failing for dev. The idea was to investigate the failure
post-merge because it wasn't block-worthy.

But the test did fail in dev mode when CI ran on canary. So this updates
the guard to skip in dev, too.

Will follow up with a PR to fix the test itself.

Closes NEXT-1913
@github-actions github-actions bot added the locked label Jan 4, 2024
@github-actions github-actions bot locked as resolved and limited conversation to collaborators Jan 4, 2024
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants