diff --git a/src/Components/Server.AutoPause/src/js/autopause.lib.module.ts b/src/Components/Server.AutoPause/src/js/autopause.lib.module.ts index de5965c577dc..807b0f12eb64 100644 --- a/src/Components/Server.AutoPause/src/js/autopause.lib.module.ts +++ b/src/Components/Server.AutoPause/src/js/autopause.lib.module.ts @@ -11,20 +11,30 @@ interface WebStartOptionsLike { circuit?: Record; } +type ServerStartOptionsLike = Record; + let config: AutoPauseConfig | undefined; let manager: AutoPauseManager | undefined; -function beforeWebStart(options: WebStartOptionsLike): void { - const enabled = options.circuit?.['autoPauseEnabled'] as boolean | undefined; +function configure(options: Record | undefined): void { + const enabled = options?.['autoPauseEnabled'] as boolean | undefined; if (enabled === undefined) { return; } config = { enabled, - hiddenDelayMilliseconds: options.circuit?.['autoPauseHiddenDelayMilliseconds'] as number | undefined ?? 120000, + hiddenDelayMilliseconds: options?.['autoPauseHiddenDelayMilliseconds'] as number | undefined ?? 120000, }; } +function beforeWebStart(options: WebStartOptionsLike): void { + configure(options.circuit); +} + +function beforeServerStart(options: ServerStartOptionsLike): void { + configure(options); +} + // Called by the framework once Blazor has started; activates auto-pause when AddAutoPause // enabled it. A second call disposes the previous manager so listeners never accumulate. function afterWebStarted(blazor: BlazorLike): void { @@ -41,4 +51,4 @@ function afterWebStarted(blazor: BlazorLike): void { mgr.start(); } -export { beforeWebStart, beforeWebStart as beforeServerStart, afterWebStarted, afterWebStarted as afterServerStarted }; +export { beforeWebStart, beforeServerStart, afterWebStarted, afterWebStarted as afterServerStarted }; diff --git a/src/Components/Server.AutoPause/src/js/test/AutoPauseInitializer.test.ts b/src/Components/Server.AutoPause/src/js/test/AutoPauseInitializer.test.ts index d997fd3de460..c6825e6e3a2c 100644 --- a/src/Components/Server.AutoPause/src/js/test/AutoPauseInitializer.test.ts +++ b/src/Components/Server.AutoPause/src/js/test/AutoPauseInitializer.test.ts @@ -100,4 +100,16 @@ describe('autopause initializer', () => { const attached = blazor.added.filter(h => !blazor.removed.includes(h)); expect(attached).toHaveLength(1); }); + + it('server start reads auto-pause configuration discovered during enhanced navigation', () => { + const blazor = createBlazor(); + + beforeWebStart({ circuit: { autoPauseEnabled: false } }); + afterWebStarted(blazor); + expect(blazor.added).toHaveLength(0); + + beforeServerStart({ autoPauseEnabled: true, autoPauseHiddenDelayMilliseconds: 100 }); + afterServerStarted(blazor); + expect(blazor.added).toHaveLength(1); + }); }); diff --git a/src/Components/Web.JS/src/Boot.Server.Common.ts b/src/Components/Web.JS/src/Boot.Server.Common.ts index b6d29f6405a1..3861a4c8d857 100644 --- a/src/Components/Web.JS/src/Boot.Server.Common.ts +++ b/src/Components/Web.JS/src/Boot.Server.Common.ts @@ -14,7 +14,7 @@ import { RootComponentManager } from './Services/RootComponentManager'; import { WebRendererId } from './Rendering/WebRendererId'; import { addDispatchEventMiddleware } from './Rendering/WebRendererInteropMethods'; -let initializersPromise: Promise | undefined; +let circuitOptionsPromise: Promise> | undefined; let appState: string; let circuit: CircuitManager; let options: CircuitStartOptions; @@ -22,17 +22,12 @@ let logger: ConsoleLogger; let serverStartPromise: Promise; let circuitStarting: Promise | undefined; -export function setCircuitOptions(initializersReady: Promise>) { - if (options) { +export function setCircuitOptions(optionsReady: Promise>) { + if (circuitOptionsPromise) { throw new Error('Circuit options have already been configured.'); } - initializersPromise = setOptions(initializersReady); - - async function setOptions(initializers: Promise>): Promise { - const configuredOptions = await initializers; - options = resolveOptions(configuredOptions); - } + circuitOptionsPromise = optionsReady; } export function startServer(components: RootComponentManager, jsEventRegistry: JSEventRegistry): Promise { @@ -46,7 +41,7 @@ export function startServer(components: RootComponentManager, jsEventRegistry: JSEventRegistry, resolve: () => void, _: any) { - await initializersPromise; + options = resolveOptions(await circuitOptionsPromise); const jsInitializer = await fetchAndInvokeInitializers(options); appState = discoverServerPersistedState(document) || ''; diff --git a/src/Components/Web.JS/src/Boot.Web.ts b/src/Components/Web.JS/src/Boot.Web.ts index a46d37b77707..bb4c88cb4ffd 100644 --- a/src/Components/Web.JS/src/Boot.Web.ts +++ b/src/Components/Web.JS/src/Boot.Web.ts @@ -59,6 +59,9 @@ function boot(options?: Partial) : Promise { enhancedNavigationStarted: () => { jsEventRegistry.dispatchEvent('enhancednavigationstart', {}); }, + beforeDomUpdate: (source) => { + updateOptionsFromBrowserConfiguration(options, source); + }, documentUpdated: () => { rootComponentManager.onDocumentUpdated(); resetScrollIfNeeded(ScrollResetSchedule.AfterDocumentUpdate); @@ -99,8 +102,31 @@ function boot(options?: Partial) : Promise { } function onInitialDomContentLoaded(options: Partial) { - // Discover server-emitted browser configuration and merge into options - const browserConfig = discoverBrowserConfiguration(document); + updateOptionsFromBrowserConfiguration(options); + + // Retrieve and start invoking the initializers. + // Blazor server options get defaults that are configured before we invoke the initializers + // so we do the same here. + const initialCircuitOptions = resolveOptions(options?.circuit || {}); + options.circuit = initialCircuitOptions; + options.webAssembly = options.webAssembly || ({} as WebAssemblyStartOptions); + const logger = new ConsoleLogger(initialCircuitOptions.logLevel); + const initializersPromise = fetchAndInvokeInitializers(options, logger); + setCircuitOptions(resolveConfiguredOptions(initializersPromise, initialCircuitOptions)); + setWebAssemblyOptions(resolveConfiguredOptions(initializersPromise, options.webAssembly)); + + registerAllComponentDescriptors(document); + + rootComponentManager.onDocumentUpdated(); + + // Initialize client-side validation if the page has validatable fields. + initFormValidationIfNeeded(); + + callAfterStartedCallbacks(initializersPromise); +} + +function updateOptionsFromBrowserConfiguration(options: Partial, source: Node = document): void { + const browserConfig = discoverBrowserConfiguration(source); if (browserConfig) { if (browserConfig.logLevel !== undefined) { options.logLevel = browserConfig.logLevel; @@ -141,37 +167,16 @@ function onInitialDomContentLoaded(options: Partial) { } } } - } - - // Retrieve and start invoking the initializers. - // Blazor server options get defaults that are configured before we invoke the initializers - // so we do the same here. - const initialCircuitOptions = resolveOptions(options?.circuit || {}); - options.circuit = initialCircuitOptions; - options.webAssembly = options.webAssembly || ({} as WebAssemblyStartOptions); - const logger = new ConsoleLogger(initialCircuitOptions.logLevel); - const initializersPromise = fetchAndInvokeInitializers(options, logger); - setCircuitOptions(resolveConfiguredOptions(initializersPromise, initialCircuitOptions)); - setWebAssemblyOptions(resolveConfiguredOptions(initializersPromise, options.webAssembly)); - // If BrowserConfiguration had WebAssembly server options, apply them - // before registering component descriptors, since registration triggers - // WebAssembly platform loading which captures these options. - if (browserConfig?.webAssembly) { - rootComponentManager.setWebAssemblyOptions({ - environmentName: browserConfig.webAssembly.environmentName ?? '', - environmentVariables: browserConfig.webAssembly.environmentVariables ?? {}, - }); + // Apply WebAssembly server options before processing component descriptors, since + // registration can trigger platform loading that captures these options. + if (browserConfig.webAssembly) { + rootComponentManager.setWebAssemblyOptions({ + environmentName: browserConfig.webAssembly.environmentName ?? '', + environmentVariables: browserConfig.webAssembly.environmentVariables ?? {}, + }); + } } - - registerAllComponentDescriptors(document); - - rootComponentManager.onDocumentUpdated(); - - // Initialize client-side validation if the page has validatable fields. - initFormValidationIfNeeded(); - - callAfterStartedCallbacks(initializersPromise); } function initFormValidationIfNeeded(): void { diff --git a/src/Components/Web.JS/src/Rendering/StreamingRendering.ts b/src/Components/Web.JS/src/Rendering/StreamingRendering.ts index 378178d513d4..de34c2030697 100644 --- a/src/Components/Web.JS/src/Rendering/StreamingRendering.ts +++ b/src/Components/Web.JS/src/Rendering/StreamingRendering.ts @@ -97,6 +97,9 @@ function redirect(node: HTMLTemplateElement, changeUrl: boolean, isEnhancedNav: } function insertStreamingContentIntoDocument(componentIdAsString: string, docFrag: DocumentFragment): void { + // Apply configuration before streamed components can activate. + navigationEnhancementCallbacks.beforeDomUpdate(docFrag); + const markers = findStreamingMarkers(componentIdAsString); if (markers) { const { startMarker, endMarker } = markers; diff --git a/src/Components/Web.JS/src/Services/NavigationEnhancement.ts b/src/Components/Web.JS/src/Services/NavigationEnhancement.ts index 8482c027161f..7bd29bee95bb 100644 --- a/src/Components/Web.JS/src/Services/NavigationEnhancement.ts +++ b/src/Components/Web.JS/src/Services/NavigationEnhancement.ts @@ -44,6 +44,7 @@ let currentContentUrl = location.href; export interface NavigationEnhancementCallbacks { enhancedNavigationStarted: () => void; + beforeDomUpdate: (source: Node) => void; documentUpdated: () => void; enhancedNavigationCompleted: () => void; } @@ -309,6 +310,7 @@ export async function performEnhancedPageLoad(internalDestinationHref: string, i if (responseContentType?.startsWith('text/html') && initialContent) { // For HTML responses, regardless of the status code, display it const parsedHtml = new DOMParser().parseFromString(initialContent, 'text/html'); + navigationEnhancementCallbacks.beforeDomUpdate(parsedHtml); synchronizeDomContent(document, parsedHtml); navigationEnhancementCallbacks.documentUpdated(); } else if (responseContentType?.startsWith('text/') && initialContent) { diff --git a/src/Components/test/E2ETest/ServerExecutionTests/AutoPauseEnhancedNavigationTests.cs b/src/Components/test/E2ETest/ServerExecutionTests/AutoPauseEnhancedNavigationTests.cs new file mode 100644 index 000000000000..d2c67576fc30 --- /dev/null +++ b/src/Components/test/E2ETest/ServerExecutionTests/AutoPauseEnhancedNavigationTests.cs @@ -0,0 +1,55 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Components.TestServer.RazorComponents; +using Microsoft.AspNetCore.Components.E2ETest.Infrastructure; +using Microsoft.AspNetCore.Components.E2ETest.Infrastructure.ServerFixtures; +using Microsoft.AspNetCore.E2ETesting; +using OpenQA.Selenium; +using TestServer; +using Xunit.Abstractions; + +namespace Microsoft.AspNetCore.Components.E2ETest.ServerExecutionTests; + +public class AutoPauseEnhancedNavigationTests : AutoPauseTestBase +{ + public AutoPauseEnhancedNavigationTests( + BrowserFixture browserFixture, + BasicTestAppServerSiteFixture> serverFixture, + ITestOutputHelper output) + : base(browserFixture, serverFixture, output) + { + serverFixture.AdditionalArguments.AddRange("--DisableReconnectionCache", "true"); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void AutoPause_StartsWhenInteractiveServerStartsAfterEnhancedNavigation(bool streaming) + { + Navigate("/subdir/persistent-state/auto-pause-enhanced-navigation-landing"); + var htmlElement = Browser.Exists(By.TagName("html")); + + var linkId = streaming ? "navigate-to-streaming-auto-pause" : "navigate-to-auto-pause"; + Browser.Exists(By.Id(linkId)).Click(); + Browser.Exists(By.Id("increment-persistent-counter-count")); + Browser.False(() => htmlElement.IsStale()); + + Browser.Exists(By.Id("increment-persistent-counter-count")).Click(); + Browser.Equal("1", () => Browser.Exists(By.Id("persistent-counter-count")).Text); + Browser.Exists(By.Id("increment-non-persisted-counter")).Click(); + Browser.Equal("6", () => Browser.Exists(By.Id("non-persisted-counter")).Text); + + SetVisibility("hidden"); + WaitForPausedUI(); + + SetVisibility("visible"); + WaitForResumedUI(); + + Browser.Equal("1", () => Browser.Exists(By.Id("persistent-counter-count")).Text); + Browser.Equal("0", () => Browser.Exists(By.Id("non-persisted-counter")).Text); + Browser.Exists(By.Id("increment-persistent-counter-count")).Click(); + Browser.Equal("2", () => Browser.Exists(By.Id("persistent-counter-count")).Text); + } + +} diff --git a/src/Components/test/E2ETest/ServerExecutionTests/AutoPauseTestBase.cs b/src/Components/test/E2ETest/ServerExecutionTests/AutoPauseTestBase.cs new file mode 100644 index 000000000000..7389d0585e9c --- /dev/null +++ b/src/Components/test/E2ETest/ServerExecutionTests/AutoPauseTestBase.cs @@ -0,0 +1,45 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.Components.E2ETest.Infrastructure; +using Microsoft.AspNetCore.Components.E2ETest.Infrastructure.ServerFixtures; +using Microsoft.AspNetCore.E2ETesting; +using OpenQA.Selenium; +using TestServer; +using Xunit.Abstractions; + +namespace Microsoft.AspNetCore.Components.E2ETest.ServerExecutionTests; + +public abstract class AutoPauseTestBase + : ServerTestBase>> +{ + protected AutoPauseTestBase( + BrowserFixture browserFixture, + BasicTestAppServerSiteFixture> serverFixture, + ITestOutputHelper output) + : base(browserFixture, serverFixture, output) + { + } + + protected void SetVisibility(string state) + { + var js = (IJavaScriptExecutor)Browser; + js.ExecuteScript($@" + Object.defineProperty(document, 'visibilityState', {{ configurable: true, get: () => '{state}' }}); + Object.defineProperty(document, 'hidden', {{ configurable: true, get: () => {(state == "hidden" ? "true" : "false")} }}); + document.dispatchEvent(new Event('visibilitychange')); + "); + } + + protected void WaitForPausedUI() + { + Browser.Equal("block", () => + Browser.Exists(By.Id("components-reconnect-modal")).GetCssValue("display")); + } + + protected void WaitForResumedUI() + { + Browser.Equal("none", () => + Browser.Exists(By.Id("components-reconnect-modal")).GetCssValue("display")); + } +} diff --git a/src/Components/test/E2ETest/ServerExecutionTests/AutoPauseTests.cs b/src/Components/test/E2ETest/ServerExecutionTests/AutoPauseTests.cs index 3c2f2d97fe0f..79479c17bb22 100644 --- a/src/Components/test/E2ETest/ServerExecutionTests/AutoPauseTests.cs +++ b/src/Components/test/E2ETest/ServerExecutionTests/AutoPauseTests.cs @@ -11,7 +11,7 @@ namespace Microsoft.AspNetCore.Components.E2ETest.ServerExecutionTests; -public class AutoPauseTests : ServerTestBase>> +public class AutoPauseTests : AutoPauseTestBase { public AutoPauseTests( BrowserFixture browserFixture, @@ -104,16 +104,6 @@ public void HiddenTab_BecomesVisibleBeforeDelay_DoesNotPause() Assert.Empty(GetAutoPauseEvents()); } - private void SetVisibility(string state) - { - var js = (IJavaScriptExecutor)Browser; - js.ExecuteScript($@" - Object.defineProperty(document, 'visibilityState', {{ configurable: true, get: () => '{state}' }}); - Object.defineProperty(document, 'hidden', {{ configurable: true, get: () => {(state == "hidden" ? "true" : "false")} }}); - document.dispatchEvent(new Event('visibilitychange')); - "); - } - private IReadOnlyList GetAutoPauseEvents() { var js = (IJavaScriptExecutor)Browser; @@ -132,15 +122,4 @@ private void WaitForBlazorPause() "return !!(window.Blazor && window.myApp && typeof window.myApp.deferPause === 'function')")); } - private void WaitForPausedUI() - { - Browser.Equal("block", () => - Browser.Exists(By.Id("components-reconnect-modal")).GetCssValue("display")); - } - - private void WaitForResumedUI() - { - Browser.Equal("none", () => - Browser.Exists(By.Id("components-reconnect-modal")).GetCssValue("display")); - } } diff --git a/src/Components/test/E2ETest/ServerRenderingTests/BlazorWebJsInitializersTest.cs b/src/Components/test/E2ETest/ServerRenderingTests/BlazorWebJsInitializersTest.cs index aae17509589d..3c1eba433ace 100644 --- a/src/Components/test/E2ETest/ServerRenderingTests/BlazorWebJsInitializersTest.cs +++ b/src/Components/test/E2ETest/ServerRenderingTests/BlazorWebJsInitializersTest.cs @@ -81,6 +81,38 @@ public void InitializersRunsClassicInitializersWhenEnabled(bool streaming, bool } } + [Fact] + public void ServerInitializerActivatedAfterEnhancedNavigationFromStaticPage() + { + Navigate($"{ServerPathBase}/initializers?streaming=false&wasm=false&server=false&auto-pause=true&auto-pause-delay-ms=10"); + + Browser.True(() => (bool)((IJavaScriptExecutor)Browser).ExecuteScript( + "return typeof Blazor.pauseCircuit === 'undefined'")); + + ((IJavaScriptExecutor)Browser).ExecuteScript( + "Blazor.navigateTo('persistent-state/server-pause?auto-pause=true&auto-pause-delay-ms=10')"); + + Browser.Exists(By.Id("render-mode-interactive")); + Browser.True(() => (bool)((IJavaScriptExecutor)Browser).ExecuteScript( + "return typeof Blazor.pauseCircuit === 'function'")); + + ((IJavaScriptExecutor)Browser).ExecuteScript( + """ + window.autoPauseCallCount = 0; + const pauseCircuit = Blazor.pauseCircuit; + Blazor.pauseCircuit = (...args) => { + window.autoPauseCallCount++; + return pauseCircuit(...args); + }; + Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => 'hidden' }); + Object.defineProperty(document, 'hidden', { configurable: true, get: () => true }); + document.dispatchEvent(new Event('visibilitychange')); + """); + + Browser.Equal(1L, () => (long)((IJavaScriptExecutor)Browser).ExecuteScript( + "return window.autoPauseCallCount")); + } + private void EnableClassicInitializers(IWebDriver browser) { browser.Navigate().GoToUrl($"{new Uri(_serverFixture.RootUri, ServerPathBase)}/"); diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponents/App.razor b/src/Components/test/testassets/Components.TestServer/RazorComponents/App.razor index 20e91f9f1f0e..bba61161f582 100644 --- a/src/Components/test/testassets/Components.TestServer/RazorComponents/App.razor +++ b/src/Components/test/testassets/Components.TestServer/RazorComponents/App.razor @@ -22,6 +22,24 @@ [SupplyParameterFromQuery(Name = "appSetsEventArgsPath")] public bool AppSetsEventArgsPath { get; set; } + [Parameter] + [SupplyParameterFromQuery(Name = "auto-pause")] + public bool AutoPause { get; set; } + + [Parameter] + [SupplyParameterFromQuery(Name = "auto-pause-delay-ms")] + public int AutoPauseDelayMs { get; set; } + + private BrowserOptions AutoPauseConfig + { + get + { + var options = new BrowserOptions(); + options.AddAutoPause(pause => pause.HiddenDelay = TimeSpan.FromMilliseconds(AutoPauseDelayMs > 0 ? AutoPauseDelayMs : 200)); + return options; + } + } + [Parameter] [SupplyParameterFromQuery(Name = "useOnNavigateAsync")] public bool ShouldDelayOnNavigateAsync { get; set; } @@ -104,6 +122,11 @@ + @if (AutoPause) + { + + } + @if(string.Equals(UseCustomRouter, "true", StringComparison.OrdinalIgnoreCase)) { diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/PersistentState/AutoPauseEnhancedNavigationLanding.razor b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/PersistentState/AutoPauseEnhancedNavigationLanding.razor new file mode 100644 index 000000000000..0dcbcede4f9a --- /dev/null +++ b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/PersistentState/AutoPauseEnhancedNavigationLanding.razor @@ -0,0 +1,17 @@ +@page "/persistent-state/auto-pause-enhanced-navigation-landing" + +

@(RendererInfo.IsInteractive ? "interactive" : "static")

+ +

+ + Go to interactive server page + +

+ +

+ + Go to streaming interactive server page + +

diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/PersistentState/AutoPauseEnhancedNavigationStreaming.razor b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/PersistentState/AutoPauseEnhancedNavigationStreaming.razor new file mode 100644 index 000000000000..23058b544561 --- /dev/null +++ b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/PersistentState/AutoPauseEnhancedNavigationStreaming.razor @@ -0,0 +1,21 @@ +@page "/persistent-state/auto-pause-enhanced-navigation-streaming" +@attribute [StreamRendering] + +@if (_ready) +{ + +} +else +{ +

Streaming

+} + +@code { + private bool _ready; + + protected override async Task OnInitializedAsync() + { + await Task.Yield(); + _ready = true; + } +}