Skip to content

Commit 4bc0aea

Browse files
authored
feat(brouter): disable animation on initial load when view transition is enabled bitfoundation#12746 (bitfoundation#12747)
1 parent f1b4232 commit 4bc0aea

6 files changed

Lines changed: 78 additions & 21 deletions

File tree

src/Brouter/Bit.Brouter/Brouter.cs

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2285,7 +2285,12 @@ private async ValueTask ProcessNavigationAsync(BrouterLocation from, BrouterLoca
22852285
// held across arbitrary awaits) and immediately before the renders below mutate the page.
22862286
// The completion (which lets the browser animate to the new state) runs in
22872287
// OnAfterRenderAsync once the new DOM is committed.
2288-
if (Options.ViewTransitions)
2288+
//
2289+
// The initial load (empty `from`, matching SaveScrollPositionAsync's convention) never
2290+
// animates: there is no meaningful outgoing page - just the blank document, or, after
2291+
// prerendering, static HTML visually identical to what the interactive pass is about to
2292+
// render, where a transition reads as a spurious double-render flash of the same page.
2293+
if (Options.ViewTransitions && string.IsNullOrEmpty(from.FullUri) is false)
22892294
{
22902295
viewTransitionStarted = await service.BeginViewTransitionAsync(navType);
22912296
if (token.IsCancellationRequested || version != _navVersion) return;
@@ -2354,16 +2359,20 @@ private async ValueTask ProcessNavigationAsync(BrouterLocation from, BrouterLoca
23542359
// supersession bail-outs above so an abandoned pipeline never strands stale arrivals.
23552360
StageArrivals(ctx, matchedChain);
23562361

2357-
StateHasChanged();
2358-
2359-
// Hand the open transition to OnAfterRenderAsync: it completes once the render above has
2360-
// been applied to the DOM, which is exactly when the browser should snapshot the new state.
2362+
// Hand the open transition to OnAfterRenderAsync: it completes once the render below has
2363+
// been applied to the DOM, which is exactly when the browser should snapshot the new
2364+
// state. Staged BEFORE StateHasChanged: when every await in OnAfterRenderAsync completes
2365+
// synchronously (bUnit's mocked interop; any environment without a real yield), the
2366+
// whole OnAfterRenderAsync runs inside StateHasChanged - staging afterwards would let it
2367+
// consume a still-false flag and strand the transition until the JS watchdog frees it.
23612368
if (viewTransitionStarted)
23622369
{
23632370
viewTransitionStaged = true;
23642371
_pendingViewTransitionCompletion = true;
23652372
}
23662373

2374+
StateHasChanged();
2375+
23672376
ResolveNavigationOutcome(to.FullUri, BrouterNavigationOutcome.Success());
23682377

23692378
// Stale-while-revalidate: some chain node rendered a stale cached result above - kick

src/Brouter/Bit.Brouter/BrouterOptions.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,9 @@ public sealed class BrouterOptions
169169
/// the outgoing and incoming pages by default and enabling per-element morph animations via the
170170
/// standard <c>view-transition-name</c> CSS property - no Blazor-specific animation code needed.
171171
/// Mirrors Angular's <c>withViewTransitions</c> and React Router's <c>viewTransition</c>.
172+
/// Only actual navigations animate - the initial load never does (with prerendering it would
173+
/// otherwise re-animate over static HTML identical to the interactive render, appearing as a
174+
/// double render of the first page).
172175
/// Gracefully inert on browsers without the API, during prerender, and in non-browser hosts.
173176
/// Defaults to <c>false</c>. See also <see cref="ViewTransitionDefaultAnimations"/>.
174177
/// </summary>

src/Brouter/Demos/Core/Pages/LandingPage.razor

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
<section class="bb-hero">
1212
<div class="bb-hero-copy">
1313
<span class="bb-hero-eyebrow">Open source · MIT · net8.0 / net9.0 / net10.0</span>
14-
<h1>Client-side routing for Blazor,<br /><span class="bb-hero-accent">done right.</span></h1>
14+
<h1>Routing for Blazor,<br /><span class="bb-hero-accent">done right!</span></h1>
1515
<p class="bb-hero-sub">
1616
Brouter is a modern, declarative, nestable router with everything today's router
1717
libraries are measured by: async guards, data loaders with stale-while-revalidate

src/Brouter/Demos/Core/Pages/TransitionsPage.razor

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,9 @@
2020
navigations glide in, Back/Forward mirror the motion, replaces cross-fade - the router
2121
detects direction from the navigation type (<code>Push</code> / <code>Replace</code> /
2222
<code>Pop</code>) and exposes it as <code>data-brouter-nav</code> on the root element for
23-
your own CSS. On browsers without the API everything is inert; nothing breaks.
23+
your own CSS. On browsers without the API everything is inert; nothing breaks. Only actual
24+
navigations animate - the initial load never does, so with prerendering the interactive
25+
takeover doesn't replay the animation over the already-visible page.
2426
</p>
2527
<pre class="bb-code">services.AddBitBrouterServices(o =&gt;
2628
{

src/Brouter/README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -590,6 +590,10 @@ animations out of the box (`o.ViewTransitionDefaultAnimations`, enabled by defau
590590
bypass it with `o.ViewTransitionRespectReducedMotion = false` - think twice, though: for
591591
motion-sensitive users `reduce` is a genuine request.
592592

593+
Only actual navigations animate - the **initial load never does**. This matters with prerendering:
594+
the prerendered HTML is already on screen when the router becomes interactive, and animating the
595+
first (identical) interactive render would show an annoying double render of the same page.
596+
593597
The defaults live in the CSS layer `bit-brouter`, so **any unlayered `::view-transition-*` rule in
594598
your own CSS overrides them automatically** - customize without specificity fights, or set
595599
`o.ViewTransitionDefaultAnimations = false` to opt out entirely. The current direction is exposed as

src/Brouter/Tests/Bit.Brouter.Tests/ViewTransitionTests.cs

Lines changed: 53 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,21 @@ private BunitJSModuleInterop SetupModule(bool beginReturns)
1818
return module;
1919
}
2020

21+
// Waits for a JS invocation that no render follows (e.g. the transition completion fired from
22+
// OnAfterRenderAsync after the navigation's final render): render-driven WaitForAssertion never
23+
// re-checks in that window, so poll the interop log instead.
24+
private static async Task WaitForInvocationAsync(BunitJSModuleInterop module, string identifier)
25+
{
26+
var deadline = DateTime.UtcNow.AddSeconds(5);
27+
while (module.Invocations.All(i => i.Identifier != identifier))
28+
{
29+
if (DateTime.UtcNow > deadline) Assert.Fail($"JS invocation '{identifier}' was never made.");
30+
await Task.Delay(10);
31+
}
32+
}
33+
2134
[TestMethod]
22-
public void Navigation_with_ViewTransitions_enabled_runs_the_begin_complete_handshake()
35+
public async Task Navigation_with_ViewTransitions_enabled_runs_the_begin_complete_handshake()
2336
{
2437
Services.Configure<BrouterOptions>(o => o.ViewTransitions = true);
2538
var module = SetupModule(beginReturns: true);
@@ -29,45 +42,71 @@ public void Navigation_with_ViewTransitions_enabled_runs_the_begin_complete_hand
2942
var cut = RenderComponent<NavigationTypeHost>();
3043
cut.WaitForAssertion(() => cut.Find("[data-testid=a]"));
3144

45+
var brouter = Services.GetRequiredService<IBrouter>();
46+
await cut.InvokeAsync(() => brouter.Navigate("/b"));
47+
48+
cut.WaitForAssertion(() => cut.Find("[data-testid=b]"));
49+
50+
// Begin fired before the render, complete after it landed.
51+
Assert.IsTrue(module.Invocations.Count(i => i.Identifier == "beginViewTransition") >= 1);
52+
await WaitForInvocationAsync(module, "completeViewTransition");
53+
54+
// Begin carries the direction token (a programmatic Navigate is a push), the
55+
// default-animations flag and the reduced-motion flag (both on by default), which
56+
// drive the built-in direction-aware animations.
57+
var begin = module.Invocations.First(i => i.Identifier == "beginViewTransition");
58+
Assert.AreEqual("push", begin.Arguments[0]);
59+
Assert.AreEqual(true, begin.Arguments[1]);
60+
Assert.AreEqual(true, begin.Arguments[2]);
61+
}
62+
63+
[TestMethod]
64+
public void Unsupported_browser_skips_the_completion_round_trip()
65+
{
66+
Services.Configure<BrouterOptions>(o => o.ViewTransitions = true);
67+
var module = SetupModule(beginReturns: false);
68+
69+
var nav = Services.GetRequiredService<BunitNavigationManager>();
70+
nav.NavigateTo("http://localhost/a");
71+
var cut = RenderComponent<NavigationTypeHost>();
72+
cut.WaitForAssertion(() => cut.Find("[data-testid=a]"));
73+
3274
var brouter = Services.GetRequiredService<IBrouter>();
3375
cut.InvokeAsync(() => brouter.Navigate("/b"));
3476

3577
cut.WaitForAssertion(() =>
3678
{
3779
Assert.IsNotNull(cut.Find("[data-testid=b]"));
38-
// Begin fired before the render, complete after it landed.
3980
Assert.IsTrue(module.Invocations.Count(i => i.Identifier == "beginViewTransition") >= 1);
40-
Assert.IsTrue(module.Invocations.Count(i => i.Identifier == "completeViewTransition") >= 1);
41-
42-
// Begin carries the direction token (a programmatic Navigate is a push), the
43-
// default-animations flag and the reduced-motion flag (both on by default), which
44-
// drive the built-in direction-aware animations.
45-
var begin = module.Invocations.First(i => i.Identifier == "beginViewTransition");
46-
Assert.AreEqual("push", begin.Arguments[0]);
47-
Assert.AreEqual(true, begin.Arguments[1]);
48-
Assert.AreEqual(true, begin.Arguments[2]);
81+
Assert.AreEqual(0, module.Invocations.Count(i => i.Identifier == "completeViewTransition"));
4982
});
5083
}
5184

5285
[TestMethod]
53-
public void Unsupported_browser_skips_the_completion_round_trip()
86+
public void Initial_load_does_not_start_a_transition()
5487
{
5588
Services.Configure<BrouterOptions>(o => o.ViewTransitions = true);
56-
var module = SetupModule(beginReturns: false);
89+
var module = SetupModule(beginReturns: true);
5790

5891
var nav = Services.GetRequiredService<BunitNavigationManager>();
5992
nav.NavigateTo("http://localhost/a");
6093
var cut = RenderComponent<NavigationTypeHost>();
6194
cut.WaitForAssertion(() => cut.Find("[data-testid=a]"));
6295

96+
// The first mount renders the initial route without any transition interop: there is no
97+
// outgoing page to animate from, and after prerendering a transition here would replay the
98+
// animation over the already-visible static HTML (a "double render" of the first page).
99+
Assert.AreEqual(0, module.Invocations.Count(i => i.Identifier == "beginViewTransition"));
100+
Assert.AreEqual(0, module.Invocations.Count(i => i.Identifier == "completeViewTransition"));
101+
102+
// A real navigation afterwards still animates.
63103
var brouter = Services.GetRequiredService<IBrouter>();
64104
cut.InvokeAsync(() => brouter.Navigate("/b"));
65105

66106
cut.WaitForAssertion(() =>
67107
{
68108
Assert.IsNotNull(cut.Find("[data-testid=b]"));
69109
Assert.IsTrue(module.Invocations.Count(i => i.Identifier == "beginViewTransition") >= 1);
70-
Assert.AreEqual(0, module.Invocations.Count(i => i.Identifier == "completeViewTransition"));
71110
});
72111
}
73112

0 commit comments

Comments
 (0)