Skip to content

[Fiber] Trigger default transition indicator if needed #33160

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

Merged
merged 2 commits into from
May 13, 2025

Conversation

sebmarkbage
Copy link
Collaborator

@sebmarkbage sebmarkbage commented May 9, 2025

Stacked on #33159.

This implements onDefaultTransitionIndicator.

The sequence is:

  1. In markRootUpdated we schedule Transition updates as needing indicatorLanes on the root. This tracks the lanes that currently need an indicator to either start or remain going until this lane commits.
  2. Track mutations during any commit. We use the same hook that view transitions use here but instead of tracking it just per view transition scope, we also track a global boolean for the whole root.
  3. If a sync/default commit had any mutations, then we clear the indicator lane for the currentEventTransitionLane. This requires that the lane is still active while we do these commits. See Reset currentEventTransitionLane after flushing sync work #33159. In other words, a sync update gets associated with the current transition and it is assumed to be rendering the loading state for that corresponding transition so we don't need a default indicator for this lane.
  4. At the end of processRootScheduleInMicrotask, right before we're about to enter a new "event transition lane" scope, it is no longer possible to render any more loading states for the current transition lane. That's when we invoke onDefaultTransitionIndicator for any roots that have new indicator lanes.
  5. When we commit, we remove the finished lanes from indicatorLanes and once that reaches zero again, then we can clean up the default indicator. This approach means that you can start multiple different transitions while an indicator is still going but it won't stop/restart each time. Instead, it'll wait until all are done before stopping.

Follow ups:

@github-actions github-actions bot added the React Core Team Opened by a member of the React Core Team label May 9, 2025
@react-sizebot
Copy link

react-sizebot commented May 9, 2025

Comparing: 0cac32d...5ac3996

Critical size changes

Includes critical production bundles, as well as any change greater than 2%:

Name +/- Base Current +/- gzip Base gzip Current gzip
oss-stable/react-dom/cjs/react-dom.production.js = 6.68 kB 6.68 kB +0.05% 1.83 kB 1.83 kB
oss-stable/react-dom/cjs/react-dom-client.production.js +0.02% 528.08 kB 528.17 kB = 93.17 kB 93.15 kB
oss-experimental/react-dom/cjs/react-dom.production.js = 6.69 kB 6.69 kB +0.05% 1.83 kB 1.83 kB
oss-experimental/react-dom/cjs/react-dom-client.production.js +0.28% 646.43 kB 648.27 kB +0.24% 113.73 kB 114.00 kB
facebook-www/ReactDOM-prod.classic.js +0.03% 674.14 kB 674.36 kB +0.03% 118.40 kB 118.44 kB
facebook-www/ReactDOM-prod.modern.js +0.03% 664.42 kB 664.64 kB +0.02% 116.80 kB 116.82 kB

Significant size changes

Includes any change greater than 0.2%:

Expand to show
Name +/- Base Current +/- gzip Base gzip Current gzip
oss-experimental/react-art/cjs/react-art.production.js +0.54% 348.49 kB 350.36 kB +0.55% 58.77 kB 59.09 kB
oss-experimental/react-reconciler/cjs/react-reconciler.production.js +0.40% 475.83 kB 477.74 kB +0.44% 75.93 kB 76.27 kB
oss-experimental/react-reconciler/cjs/react-reconciler.profiling.js +0.36% 535.88 kB 537.79 kB +0.46% 83.97 kB 84.36 kB
oss-experimental/react-art/cjs/react-art.development.js +0.31% 670.01 kB 672.06 kB +0.29% 105.49 kB 105.79 kB
oss-experimental/react-dom/cjs/react-dom-client.production.js +0.28% 646.43 kB 648.27 kB +0.24% 113.73 kB 114.00 kB
oss-experimental/react-dom/cjs/react-dom-unstable_testing.production.js +0.28% 660.84 kB 662.68 kB +0.24% 117.27 kB 117.55 kB
oss-experimental/react-dom/cjs/react-dom-profiling.profiling.js +0.26% 708.57 kB 710.41 kB +0.25% 122.76 kB 123.07 kB
oss-experimental/react-reconciler/cjs/react-reconciler.development.js +0.25% 795.41 kB 797.42 kB +0.34% 123.82 kB 124.24 kB

Generated by 🚫 dangerJS against 5ac3996

@rickhanlonii
Copy link
Member

a sync update gets associated with the current transition and it is assumed to be rendering the loading state

Does this also mean that we can associate these in the performance tracks? I've found myself explaining that "the transition starts, then there is a sync render to render the pending state, and then the transition is rendered" a ton when reviewing profiles for folks. Would be nice if this was shown inline somehow, like labeling the render "Render Loading" or something.

@@ -22,12 +35,21 @@ export function pushMutationContext(): boolean {

export function popMutationContext(prev: boolean): void {
if (enableViewTransition) {
if (viewTransitionMutationContext) {
Copy link
Member

Choose a reason for hiding this comment

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

What's the thinking here?

Copy link
Member

Choose a reason for hiding this comment

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

Oh, this is the thing below where we track it with a viewTransitionMutationContext to avoid multiple operations in the hot path.

Copy link
Member

Choose a reason for hiding this comment

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

Do you want a enableDefaultTransitionIndicator check here too then?


expect(root).toMatchRenderedOutput('Hello');
});
});
Copy link
Member

Choose a reason for hiding this comment

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

How about a test that it cleans up if the transitions errors (both with the hook, which would trigger an error boundary, and isomorphic which would not)?

Copy link
Member

Choose a reason for hiding this comment

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

And if these can setState, should we have tests for that?

Copy link
Member

@rickhanlonii rickhanlonii left a comment

Choose a reason for hiding this comment

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

siiiick

@sebmarkbage sebmarkbage force-pushed the transitionindicator2 branch from 6851af0 to 5ac3996 Compare May 13, 2025 19:22
@sebmarkbage sebmarkbage merged commit 62d3f36 into facebook:main May 13, 2025
240 checks passed
github-actions bot pushed a commit that referenced this pull request May 13, 2025
Stacked on #33159.

This implements `onDefaultTransitionIndicator`.

The sequence is:

1) In `markRootUpdated` we schedule Transition updates as needing
`indicatorLanes` on the root. This tracks the lanes that currently need
an indicator to either start or remain going until this lane commits.
2) Track mutations during any commit. We use the same hook that view
transitions use here but instead of tracking it just per view transition
scope, we also track a global boolean for the whole root.
3) If a sync/default commit had any mutations, then we clear the
indicator lane for the `currentEventTransitionLane`. This requires that
the lane is still active while we do these commits. See #33159. In other
words, a sync update gets associated with the current transition and it
is assumed to be rendering the loading state for that corresponding
transition so we don't need a default indicator for this lane.
4) At the end of `processRootScheduleInMicrotask`, right before we're
about to enter a new "event transition lane" scope, it is no longer
possible to render any more loading states for the current transition
lane. That's when we invoke `onDefaultTransitionIndicator` for any roots
that have new indicator lanes.
5) When we commit, we remove the finished lanes from `indicatorLanes`
and once that reaches zero again, then we can clean up the default
indicator. This approach means that you can start multiple different
transitions while an indicator is still going but it won't stop/restart
each time. Instead, it'll wait until all are done before stopping.

Follow ups:

- [x] Default updates are currently not enough to cancel because those
aren't flush in the same microtask. That's unfortunate. #33186
- [x] Handle async actions before the setState. Since these don't
necessarily have a root this is tricky. #33190
- [x] Disable for `useDeferredValue`. ~Since it also goes through
`markRootUpdated` and schedules a Transition lane it'll get a default
indicator even though it probably shouldn't have one.~ EDIT: Turns out
this just works because it doesn't go through `markRootUpdated` when
work is left behind.
- [x] Implement built-in DOM version by default. #33162

DiffTrain build for [62d3f36](62d3f36)
github-actions bot pushed a commit that referenced this pull request May 13, 2025
Stacked on #33159.

This implements `onDefaultTransitionIndicator`.

The sequence is:

1) In `markRootUpdated` we schedule Transition updates as needing
`indicatorLanes` on the root. This tracks the lanes that currently need
an indicator to either start or remain going until this lane commits.
2) Track mutations during any commit. We use the same hook that view
transitions use here but instead of tracking it just per view transition
scope, we also track a global boolean for the whole root.
3) If a sync/default commit had any mutations, then we clear the
indicator lane for the `currentEventTransitionLane`. This requires that
the lane is still active while we do these commits. See #33159. In other
words, a sync update gets associated with the current transition and it
is assumed to be rendering the loading state for that corresponding
transition so we don't need a default indicator for this lane.
4) At the end of `processRootScheduleInMicrotask`, right before we're
about to enter a new "event transition lane" scope, it is no longer
possible to render any more loading states for the current transition
lane. That's when we invoke `onDefaultTransitionIndicator` for any roots
that have new indicator lanes.
5) When we commit, we remove the finished lanes from `indicatorLanes`
and once that reaches zero again, then we can clean up the default
indicator. This approach means that you can start multiple different
transitions while an indicator is still going but it won't stop/restart
each time. Instead, it'll wait until all are done before stopping.

Follow ups:

- [x] Default updates are currently not enough to cancel because those
aren't flush in the same microtask. That's unfortunate. #33186
- [x] Handle async actions before the setState. Since these don't
necessarily have a root this is tricky. #33190
- [x] Disable for `useDeferredValue`. ~Since it also goes through
`markRootUpdated` and schedules a Transition lane it'll get a default
indicator even though it probably shouldn't have one.~ EDIT: Turns out
this just works because it doesn't go through `markRootUpdated` when
work is left behind.
- [x] Implement built-in DOM version by default. #33162

DiffTrain build for [62d3f36](62d3f36)
sebmarkbage added a commit that referenced this pull request May 13, 2025
…n was scheduled (#33186)

Stacked on #33160.

The purpose of this is to avoid calling `onDefaultTransitionIndicator`
when a Default priority update acts as the loading indicator, but still
call it when unrelated Default updates happens nearby.

When we schedule Default priority work that gets batched with other
events in the same frame more or less. This helps optimize by doing less
work. However, that batching means that we can't separate work from one
setState from another. If we would consider all Default priority work in
a frame when determining whether to show the default we might never show
it in cases like when you have a recurring timer updating something.

This instead flushes the Default priority work eagerly along with the
sync work at the end of the event, if this event scheduled any
Transition work. This is then used to determine if the default indicator
needs to be shown.
github-actions bot pushed a commit that referenced this pull request May 13, 2025
…n was scheduled (#33186)

Stacked on #33160.

The purpose of this is to avoid calling `onDefaultTransitionIndicator`
when a Default priority update acts as the loading indicator, but still
call it when unrelated Default updates happens nearby.

When we schedule Default priority work that gets batched with other
events in the same frame more or less. This helps optimize by doing less
work. However, that batching means that we can't separate work from one
setState from another. If we would consider all Default priority work in
a frame when determining whether to show the default we might never show
it in cases like when you have a recurring timer updating something.

This instead flushes the Default priority work eagerly along with the
sync work at the end of the event, if this event scheduled any
Transition work. This is then used to determine if the default indicator
needs to be shown.

DiffTrain build for [b480865](b480865)
sebmarkbage added a commit that referenced this pull request May 13, 2025
…33162)

Stacked on #33160.

By default, if `onDefaultTransitionIndicator` is not overridden, this
will trigger a fake Navigation event using the Navigation API. This is
intercepted to create an on-going navigation until we complete the
Transition. Basically each default Transition is simulated as a
Navigation.

This triggers the native browser loading state (in Chrome at least). So
now by default the browser spinner spins during a Transition if no other
loading state is provided. Firefox and Safari hasn't shipped Navigation
API yet and even in the flag Safari has, it doesn't actually trigger the
native loading state.

To ensures that you can still use other Navigations concurrently, we
don't start our fake Navigation if there's one on-going already.
Similarly if our fake Navigation gets interrupted by another. We wait
for on-going ones to finish and then start a new fake one if we're
supposed to be still pending.

There might be other routers on the page that might listen to intercept
Navigation Events. Typically you'd expect them not to trigger a refetch
when navigating to the same state. However, if they want to detect this
we provide the `"react-transition"` string in the `info` field for this
purpose.
github-actions bot pushed a commit that referenced this pull request May 13, 2025
…33162)

Stacked on #33160.

By default, if `onDefaultTransitionIndicator` is not overridden, this
will trigger a fake Navigation event using the Navigation API. This is
intercepted to create an on-going navigation until we complete the
Transition. Basically each default Transition is simulated as a
Navigation.

This triggers the native browser loading state (in Chrome at least). So
now by default the browser spinner spins during a Transition if no other
loading state is provided. Firefox and Safari hasn't shipped Navigation
API yet and even in the flag Safari has, it doesn't actually trigger the
native loading state.

To ensures that you can still use other Navigations concurrently, we
don't start our fake Navigation if there's one on-going already.
Similarly if our fake Navigation gets interrupted by another. We wait
for on-going ones to finish and then start a new fake one if we're
supposed to be still pending.

There might be other routers on the page that might listen to intercept
Navigation Events. Typically you'd expect them not to trigger a refetch
when navigating to the same state. However, if they want to detect this
we provide the `"react-transition"` string in the `info` field for this
purpose.

DiffTrain build for [5944042](5944042)
github-actions bot pushed a commit that referenced this pull request May 13, 2025
…33162)

Stacked on #33160.

By default, if `onDefaultTransitionIndicator` is not overridden, this
will trigger a fake Navigation event using the Navigation API. This is
intercepted to create an on-going navigation until we complete the
Transition. Basically each default Transition is simulated as a
Navigation.

This triggers the native browser loading state (in Chrome at least). So
now by default the browser spinner spins during a Transition if no other
loading state is provided. Firefox and Safari hasn't shipped Navigation
API yet and even in the flag Safari has, it doesn't actually trigger the
native loading state.

To ensures that you can still use other Navigations concurrently, we
don't start our fake Navigation if there's one on-going already.
Similarly if our fake Navigation gets interrupted by another. We wait
for on-going ones to finish and then start a new fake one if we're
supposed to be still pending.

There might be other routers on the page that might listen to intercept
Navigation Events. Typically you'd expect them not to trigger a refetch
when navigating to the same state. However, if they want to detect this
we provide the `"react-transition"` string in the `info` field for this
purpose.

DiffTrain build for [5944042](5944042)
sebmarkbage added a commit that referenced this pull request May 13, 2025
…o root associated (#33190)

Stacked on #33160, #33162, #33186 and #33188.

We have a special case that's awkward for default indicators. When you
start a new async Transition from `React.startTransition` then there's
not yet any associated root with the Transition because you haven't
necessarily `setState` on anything yet until the promise resolves.
That's what `entangleAsyncAction` handles by creating a lane that
everything entangles with until all async actions are done.

If there are no sync updates before the end of the event, we should
trigger a default indicator until either the async action completes
without update or if it gets entangled with some roots we should keep it
going until those roots are done.
github-actions bot pushed a commit that referenced this pull request May 13, 2025
…o root associated (#33190)

Stacked on #33160, #33162, #33186 and #33188.

We have a special case that's awkward for default indicators. When you
start a new async Transition from `React.startTransition` then there's
not yet any associated root with the Transition because you haven't
necessarily `setState` on anything yet until the promise resolves.
That's what `entangleAsyncAction` handles by creating a lane that
everything entangles with until all async actions are done.

If there are no sync updates before the end of the event, we should
trigger a default indicator until either the async action completes
without update or if it gets entangled with some roots we should keep it
going until those roots are done.

DiffTrain build for [3a5b326](3a5b326)
github-actions bot pushed a commit that referenced this pull request May 13, 2025
…o root associated (#33190)

Stacked on #33160, #33162, #33186 and #33188.

We have a special case that's awkward for default indicators. When you
start a new async Transition from `React.startTransition` then there's
not yet any associated root with the Transition because you haven't
necessarily `setState` on anything yet until the promise resolves.
That's what `entangleAsyncAction` handles by creating a lane that
everything entangles with until all async actions are done.

If there are no sync updates before the end of the event, we should
trigger a default indicator until either the async action completes
without update or if it gets entangled with some roots we should keep it
going until those roots are done.

DiffTrain build for [3a5b326](3a5b326)
github-actions bot pushed a commit to code/lib-react that referenced this pull request May 14, 2025
…o root associated (facebook#33190)

Stacked on facebook#33160, facebook#33162, facebook#33186 and facebook#33188.

We have a special case that's awkward for default indicators. When you
start a new async Transition from `React.startTransition` then there's
not yet any associated root with the Transition because you haven't
necessarily `setState` on anything yet until the promise resolves.
That's what `entangleAsyncAction` handles by creating a lane that
everything entangles with until all async actions are done.

If there are no sync updates before the end of the event, we should
trigger a default indicator until either the async action completes
without update or if it gets entangled with some roots we should keep it
going until those roots are done.

DiffTrain build for [3a5b326](facebook@3a5b326)
github-actions bot pushed a commit to code/lib-react that referenced this pull request May 14, 2025
…o root associated (facebook#33190)

Stacked on facebook#33160, facebook#33162, facebook#33186 and facebook#33188.

We have a special case that's awkward for default indicators. When you
start a new async Transition from `React.startTransition` then there's
not yet any associated root with the Transition because you haven't
necessarily `setState` on anything yet until the promise resolves.
That's what `entangleAsyncAction` handles by creating a lane that
everything entangles with until all async actions are done.

If there are no sync updates before the end of the event, we should
trigger a default indicator until either the async action completes
without update or if it gets entangled with some roots we should keep it
going until those roots are done.

DiffTrain build for [3a5b326](facebook@3a5b326)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
CLA Signed React Core Team Opened by a member of the React Core Team
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants