[Instant] Instant validation in Dev#89077
Conversation
0518cd7 to
7bb3210
Compare
Tests Passed |
Stats from current PR🔴 1 regression
📊 All Metrics📖 Metrics GlossaryDev Server Metrics:
Build Metrics:
Change Thresholds:
⚡ Dev Server
📦 Dev Server (Webpack) (Legacy)📦 Dev Server (Webpack)
⚡ Production Builds
📦 Production Builds (Webpack) (Legacy)📦 Production Builds (Webpack)
📦 Bundle SizesBundle Sizes⚡ TurbopackClient Main Bundles: **437 kB** → **437 kB**
|
| Canary | PR | Change | |
|---|---|---|---|
| middleware-b..fest.js gzip | 764 B | 764 B | ✓ |
| Total | 764 B | 764 B | ✓ |
Build Details
Build Manifests
| Canary | PR | Change | |
|---|---|---|---|
| _buildManifest.js gzip | 452 B | 451 B | ✓ |
| Total | 452 B | 451 B | ✅ -1 B |
📦 Webpack
Client
Main Bundles
| Canary | PR | Change | |
|---|---|---|---|
| 5528-HASH.js gzip | 5.47 kB | N/A | - |
| 6280-HASH.js gzip | 56.9 kB | N/A | - |
| 6335.HASH.js gzip | 169 B | N/A | - |
| 912-HASH.js gzip | 4.53 kB | N/A | - |
| e8aec2e4-HASH.js gzip | 62.5 kB | N/A | - |
| framework-HASH.js gzip | 59.7 kB | 59.7 kB | ✓ |
| main-app-HASH.js gzip | 255 B | 253 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.52 kB | - |
| 2889.HASH.js gzip | N/A | 169 B | - |
| 5602-HASH.js gzip | N/A | 5.48 kB | - |
| 6948ada0-HASH.js gzip | N/A | 62.5 kB | - |
| 9544-HASH.js gzip | N/A | 57.5 kB | - |
| Total | 230 kB | 231 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.49 kB | 2.49 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 | ✅ -1 B |
Server
Edge SSR
| Canary | PR | Change | |
|---|---|---|---|
| edge-ssr.js gzip | 126 kB | 126 kB | ✓ |
| page.js gzip | 248 kB | 249 kB | ✓ |
| Total | 374 kB | 375 kB |
Middleware
| Canary | PR | Change | |
|---|---|---|---|
| middleware-b..fest.js gzip | 615 B | 616 B | ✓ |
| middleware-r..fest.js gzip | 156 B | 155 B | ✓ |
| middleware.js gzip | 33 kB | 33.2 kB | ✓ |
| edge-runtime..pack.js gzip | 842 B | 842 B | ✓ |
| Total | 34.6 kB | 34.8 kB |
Build Details
Build Manifests
| Canary | PR | Change | |
|---|---|---|---|
| _buildManifest.js gzip | 732 B | 736 B | ✓ |
| Total | 732 B | 736 B |
Build Cache
| Canary | PR | Change | |
|---|---|---|---|
| 0.pack gzip | 3.83 MB | 3.85 MB | 🔴 +27.5 kB (+1%) |
| index.pack gzip | 102 kB | 104 kB | 🔴 +1.5 kB (+1%) |
| index.pack.old gzip | 103 kB | 104 kB | ✓ |
| Total | 4.03 MB | 4.06 MB |
🔄 Shared (bundler-independent)
Runtimes
| Canary | PR | Change | |
|---|---|---|---|
| app-page-exp...dev.js gzip | 313 kB | 315 kB | 🔴 +2.25 kB (+1%) |
| app-page-exp..prod.js gzip | 167 kB | 167 kB | ✓ |
| app-page-tur...dev.js gzip | 313 kB | 315 kB | 🔴 +2.26 kB (+1%) |
| app-page-tur..prod.js gzip | 167 kB | 167 kB | ✓ |
| app-page-tur...dev.js gzip | 309 kB | 311 kB | 🔴 +2.26 kB (+1%) |
| app-page-tur..prod.js gzip | 165 kB | 165 kB | ✓ |
| app-page.run...dev.js gzip | 309 kB | 312 kB | 🔴 +2.25 kB (+1%) |
| app-page.run..prod.js gzip | 165 kB | 165 kB | ✓ |
| app-route-ex...dev.js gzip | 70.4 kB | 70.5 kB | ✓ |
| app-route-ex..prod.js gzip | 48.9 kB | 49 kB | ✓ |
| app-route-tu...dev.js gzip | 70.5 kB | 70.5 kB | ✓ |
| app-route-tu..prod.js gzip | 49 kB | 49 kB | ✓ |
| app-route-tu...dev.js gzip | 70.1 kB | 70.1 kB | ✓ |
| app-route-tu..prod.js gzip | 48.7 kB | 48.8 kB | ✓ |
| app-route.ru...dev.js gzip | 70 kB | 70.1 kB | ✓ |
| app-route.ru..prod.js gzip | 48.7 kB | 48.7 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.1 kB | 43.1 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 | 39.4 kB | 39.4 kB | ✓ |
| pages.runtim...dev.js gzip | 52.4 kB | 52.4 kB | ✓ |
| pages.runtim..prod.js gzip | 39.3 kB | 39.3 kB | ✓ |
| server.runti..prod.js gzip | 62.7 kB | 62.7 kB | ✓ |
| Total | 2.78 MB | 2.79 MB |
📝 Changed Files (17 files)
Files with changes:
app-page-exp..ntime.dev.jsapp-page-exp..time.prod.jsapp-page-tur..ntime.dev.jsapp-page-tur..time.prod.jsapp-page-tur..ntime.dev.jsapp-page-tur..time.prod.jsapp-page.runtime.dev.jsapp-page.runtime.prod.jsapp-route-ex..ntime.dev.jsapp-route-ex..time.prod.jsapp-route-tu..ntime.dev.jsapp-route-tu..time.prod.jsapp-route-tu..ntime.dev.jsapp-route-tu..time.prod.jsapp-route.runtime.dev.jsapp-route.ru..time.prod.jsserver.runtime.prod.js
View diffs
app-page-exp..ntime.dev.js
failed to diffapp-page-exp..time.prod.js
Diff too large to display
app-page-tur..ntime.dev.js
failed to diffapp-page-tur..time.prod.js
failed to diffapp-page-tur..ntime.dev.js
failed to diffapp-page-tur..time.prod.js
Diff too large to display
app-page.runtime.dev.js
failed to diffapp-page.runtime.prod.js
Diff too large to display
app-route-ex..ntime.dev.js
Diff too large to display
app-route-ex..time.prod.js
Diff too large to display
app-route-tu..ntime.dev.js
Diff too large to display
app-route-tu..time.prod.js
Diff too large to display
app-route-tu..ntime.dev.js
Diff too large to display
app-route-tu..time.prod.js
Diff too large to display
app-route.runtime.dev.js
Diff too large to display
app-route.ru..time.prod.js
Diff too large to display
server.runtime.prod.js
Diff too large to display
4a57a5b to
e3abb9f
Compare
e3abb9f to
fdc3f11
Compare
This stack of pull requests is managed by Graphite. Learn more about stacking. |
Merging this PR will not alter performance
Comparing Footnotes
|
acd1897 to
e8a0420
Compare
4698f1f to
a0248e3
Compare
081bdeb to
15b44d4
Compare
15b44d4 to
5bb592d
Compare
a0248e3 to
36ec4cc
Compare
5bb592d to
05c0720
Compare
781d6fa to
c768ac7
Compare
7401d75 to
e06eecd
Compare
c768ac7 to
79bbcfc
Compare
bf1b81c to
7009415
Compare
a4ad454 to
6d690a3
Compare
gnoff
left a comment
There was a problem hiding this comment.
I think we should get the validation to a state where it runs on client navs in dev. The reason the current heuristic sort of works for static shell validation is it runs when you do the initial page load which is where static shell comes into play. But performing a navigation in dev should ideally cause feedback if that same validation wouldn't have satisfied the instant config if it were done in prod.
I think we decided we would just pay the overhead of always doing a full render in dev
| () => { | ||
| // Static stage | ||
| finalStageController.advanceStage(RenderStage.Static) | ||
| startTime = performance.now() + performance.timeOrigin |
There was a problem hiding this comment.
why do we need to provide this when we aren't overriding it?
There was a problem hiding this comment.
wdym? we're returning it and use it later when reassembling the payload
if you're asking why i'm passing it into the render here, i guess that's technically not needed, just there for consistency
| seedData: CacheNodeSeedData | ||
| ) => void | ||
| ) { | ||
| // TODO: handle head as well |
There was a problem hiding this comment.
Should we fix this before merging?
There was a problem hiding this comment.
oh hmmm i'll take a look, i punted on this initially and forgot to revisit. i guess this might mean we wouldn't error for blocking things that go into the head, because we'll always use a fully resolved head
There was a problem hiding this comment.
yeah i think it's okay to punt on this for now. the only thing that can cause blocking here is dynamic generateViewport -- right now, static shell validation will tell you to use a suspense-above-body to allow it, but that won't guard you in client navs. in the instant world i guess we want to suggest instant = false on the place that does it.
but either way, i think dynamic generateViewport is rare enough that we can follow up on this later
6d690a3 to
0c51525
Compare
0c51525 to
8a8f39f
Compare
This PR implements the initial version of dev-time validation for
`export const instant = ...`
```tsx
export const instant = { prefetch: 'static' }
```
```tsx
export const instant = { prefetch: 'runtime', ... }
```
```tsx
export const instant = false
```
(the config is currently expected to be named `unstable_instant`, but
i'll be shortening it to `instant` below.)
When a segment specifies an instant config with `prefetch: 'static'` or
`prefetch: 'runtime'`, we'll validate that all (prefetched) navigations
into that segment will render instant UI, i.e. that the navigation won't
block. On the other hand, if a segment is allowed to block, it must be
marked with `export const instant = false`.
Note that our existing static shell validation is a special case of
this, but for now, we're keeping both. A future PR will reimplement
static shell validation as a special case of instant validation.
Like static shell validation, these validations currently run in dev
whenever we render the full page (i.e. no segments are omitted). This
means it'll happen on an initial load (or refresh) and HMR.
### Implementation notes
#### Validation approach
The goal is to simulate what the browser would display for a prefetched
client navigation. We do this by re-assembling the segments extracted
from the original stream for the page.
- The outer (shared) segments should already fully resolved (i.e. in the
Dynamic stage), to represent the fact that the browser loaded them
before
- The new segments are either in Static or Runtime stage, depending on
how each segment would have been prefetched.
Similar to static shell validation, we then perform a Fizz prerender on
the combined payload, abort it, and track the locations of `onError`
calls. The difference here is that unlike static shell validation, we
require a Suspense boundary *inside* the new subtree. This is detected
by adding a `InstantValidationBoundary` around the new subtree when we
construct the payload, and checking if there's a suspense below that in
the component stack.
Currently, for each `instant` config we find, we take each of its parent
layouts as a possible navigation parent (unless it's marked as
blocking). So if we have a page with parent layouts like this:
```
/layout.tsx
/foo/layout.tsx
/foo/bar/layout.tsx
/foo/bar/page.tsx <- `export const instant = { prefetch: 'static' }`
```
Then we'll check for navigations where each of `/layout.tsx`,
`/foo/layout.tsx` and `/foo/bar/layout.tsx` is a fully resolved shared
parent.
If a validation fails, we do a "discriminated error message" flow
analogous to static shell validation. Static segments are replaced with
Runtime segments to see if the error goes away, which lets us determine
if the hole is caused by runtime or dynamic data.
#### Building the combined payload
First, we need to separate the full stream into segments. The process is
similar to `collectSegmentData` -- we need to deserialize the payload
from the stream and then re-serialize each segment separately. The
complexity here comes from the fact that the full stream is separated
into stages, and we need each segment to be in staged form as well. This
lets us pick and choose which stage the segment is in to simulate what
we'd show for a static or runtime prefetch.
The other complex part is the "late release trick" (as implemented in
`createNodeStreamWithLateRelease`). This was already done for static
shell validation, but it's more complex here.
Debug info for dynamic holes is usually delayed until a further stage.
So, in the static stage we only see an unresolved promise (or lazy
reference), and then in the runtime stage we get the debugInfo telling
us that it was caused by `await cookies()`. To ensure that this debug
info is available for purposes on error reporting, we do a "late
release" -- before we abort, we advance the each segment to the dynamic
stage, which won't cause any new content to render, but will provide
Fizz with debug info and thus give us precise error locations in
`onError`. If you see `releaseSignal`, that's what it's for.
#### `instant` on layouts
We support specifying `instant` on layouts. The validation principle is
the same, but note that in the current mode (validating a single page in
dev) we can't enforce that *all* navigations into that layout satisfy
the constraint, we can only check if that's satisfied for the current
page.
#### Blocking segments
A segment can be marked as blocking with
```tsx
export const instant = false
```
This is meant to signal that this segment deliberately does not have
instant UI.
Currently, this is affects validation as follows:
If there's no `instant` config in parent segments, skip validating all
navigations where this layout would be new (because we know it'd block),
i.e. only validate navigations where it's a shared parent. Children of
this segment can still have `instant` configs of their own, and those
will be validated. This allows structures where a layout is blocking,
but once it's loaded, navigations within it should have instant UI.
If there is an `instant` config in a parent segment, the validation of
that parent is *not* skipped. The reasoning here is that if a layout
asserts that navigations into it should have static UI, then a child
with `instant = false` should not violate that. This essentially
requires that the child have a `loading.js` or that the layout has to
wrap the slot with Suspense.
Note that `instant = false` can also be used to opt segements out of
static shell validation without requiring a Suspense above body.
### Limitations and planned follow-ups
- `instant` configs in parallel routes (i.e. `@slot`) are not currently
validated.
- `expectUnableToVerify` is not implemented yet, i.e. there's no way to
say "this cannot be validated using SSR". The shared segments are not
expected to suspend. For now, `export const instant = { prefetch: ...,
unstable_disableValidation: true }` can be used to bypass a segment if
validating it causes problems.
- static shell validation is a special case of instant validation, but
is not currently implemented as such.
- we should be able to validate during client navigations, not just
full-page loads and HMR, but we currently can't because we don't have
all the segments when we do that, so we can't assemble a proper combined
payload.

This PR implements the initial version of dev-time validation for
export const instant = ...(the config is currently expected to be named
unstable_instant, but i'll be shortening it toinstantbelow.)When a segment specifies an instant config with
prefetch: 'static'orprefetch: 'runtime', we'll validate that all (prefetched) navigations into that segment will render instant UI, i.e. that the navigation won't block. On the other hand, if a segment is allowed to block, it must be marked withexport const instant = false.Note that our existing static shell validation is a special case of this, but for now, we're keeping both. A future PR will reimplement static shell validation as a special case of instant validation.
Like static shell validation, these validations currently run in dev whenever we render the full page (i.e. no segments are omitted). This means it'll happen on an initial load (or refresh) and HMR.
Implementation notes
Validation approach
The goal is to simulate what the browser would display for a prefetched client navigation. We do this by re-assembling the segments extracted from the original stream for the page.
Similar to static shell validation, we then perform a Fizz prerender on the combined payload, abort it, and track the locations of
onErrorcalls. The difference here is that unlike static shell validation, we require a Suspense boundary inside the new subtree. This is detected by adding aInstantValidationBoundaryaround the new subtree when we construct the payload, and checking if there's a suspense below that in the component stack.Currently, for each
instantconfig we find, we take each of its parent layouts as a possible navigation parent (unless it's marked as blocking). So if we have a page with parent layouts like this:Then we'll check for navigations where each of
/layout.tsx,/foo/layout.tsxand/foo/bar/layout.tsxis a fully resolved shared parent.If a validation fails, we do a "discriminated error message" flow analogous to static shell validation. Static segments are replaced with Runtime segments to see if the error goes away, which lets us determine if the hole is caused by runtime or dynamic data.
Building the combined payload
First, we need to separate the full stream into segments. The process is similar to
collectSegmentData-- we need to deserialize the payload from the stream and then re-serialize each segment separately. The complexity here comes from the fact that the full stream is separated into stages, and we need each segment to be in staged form as well. This lets us pick and choose which stage the segment is in to simulate what we'd show for a static or runtime prefetch.The other complex part is the "late release trick" (as implemented in
createNodeStreamWithLateRelease). This was already done for static shell validation, but it's more complex here.Debug info for dynamic holes is usually delayed until a further stage. So, in the static stage we only see an unresolved promise (or lazy reference), and then in the runtime stage we get the debugInfo telling us that it was caused by
await cookies(). To ensure that this debug info is available for purposes on error reporting, we do a "late release" -- before we abort, we advance the each segment to the dynamic stage, which won't cause any new content to render, but will provide Fizz with debug info and thus give us precise error locations inonError. If you seereleaseSignal, that's what it's for.instanton layoutsWe support specifying
instanton layouts. The validation principle is the same, but note that in the current mode (validating a single page in dev) we can't enforce that all navigations into that layout satisfy the constraint, we can only check if that's satisfied for the current page.Blocking segments
A segment can be marked as blocking with
This is meant to signal that this segment deliberately does not have instant UI.
Currently, this is affects validation as follows:
If there's no
instantconfig in parent segments, skip validating all navigations where this layout would be new (because we know it'd block), i.e. only validate navigations where it's a shared parent. Children of this segment can still haveinstantconfigs of their own, and those will be validated. This allows structures where a layout is blocking, but once it's loaded, navigations within it should have instant UI.If there is an
instantconfig in a parent segment, the validation of that parent is not skipped. The reasoning here is that if a layout asserts that navigations into it should have static UI, then a child withinstant = falseshould not violate that. This essentially requires that the child have aloading.jsor that the layout has to wrap the slot with Suspense.Note that
instant = falsecan also be used to opt segements out of static shell validation without requiring a Suspense above body.Limitations and planned follow-ups
instantconfigs in parallel routes (i.e.@slot) are not currently validated.expectUnableToVerifyis not implemented yet, i.e. there's no way to say "this cannot be validated using SSR". The shared segments are not expected to suspend. For now,export const instant = { prefetch: ..., unstable_disableValidation: true }can be used to bypass a segment if validating it causes problems.