oqtane / oqtane.framework

CMS & Application Framework for Blazor & .NET MAUI
http://www.oqtane.org
MIT License
1.88k stars 543 forks source link

[BUG] Module resources don't seem to load when navigating to a page with the module #4342

Closed iJungleboy closed 1 month ago

iJungleboy commented 4 months ago

Oqtane Info

Version - 5.1.2 Render Mode - Static Database - SQL Server

Describe the bug

A module can describe resources to load - JS/CSS. The idea is that these are automatically added to the page by Oqtane.

But on our tests, it only appears to work if the first full load happens on the page with the module.

Steps To Reproduce

So in our tests, this works:

  1. module on page About-Us
  2. Fully load page About us
  3. all is ok

...but this fails:

  1. Module is on page About us
  2. fully load page home
  3. browse to About us

Resources seem to not load.

Anything else?

The module is configured to have RenderMode.Static.

@tvatavuk just fyi

Raceeend commented 4 months ago

I noticed that OnInitializedAsync was not always called when the Site is set to Interactive. The setting of the module then has no influence on this. If the Site setting is set to Static, OnInitializedAsync will be called every time.

Maybe you can test by setting the Site (UI Component Settins) to Static.

zyhfish commented 4 months ago

If this is for js files, you can make your js script to have below functions:

export function onLoad() {
...
}

export function onUpdate() {
...
}

export function onDispose() {
...
}

then register your js file with below format:

new Resource { ResourceType = ResourceType.Script, Url = "[Js File URL]", Location = ResourceLocation.Body, Reload = true, RenderMode = RenderModes.Static }

in this case your code will be executed every time navigate to the page.

iJungleboy commented 4 months ago

@zyhfish that's good for code that must run every time, but not for external files that should be loaded - especially larger code.

sbwalker commented 4 months ago

@iJungleboy it is not clear if you are saying that the JavaScript references are not being added to the DOM, or if they are not being executed (ie. calling onload), or both.

The fact that Blazor does not call onload when navigating from page to page has been well documented previously - it is due to Blazor's "enhanced navigation" which I covered in the "building beautiful websites" webinar I presented 2 months ago (based on the Arsha theme). In scenarios where you need JavaScript to execute on navigation you need to hook the onupdated event.

Can you please provide more information on your specific scenario.

iJungleboy commented 4 months ago

@sbwalker thx for looking into this. The module has

    public override List<Resource> Resources => new()
    {
        new Resource { ResourceType = ResourceType.Script, Url = $"Modules/{OqtConstants.PackageName}/Module.js" }
    };

If it's on the initially loaded page - eg. ctrl-F5 - it's available.

If the user starts on another page, and hen navigates to the page with the module, it's not.

This is using SSR.

If we need to call some event to make it happen, no problem.

Note btw that we also needed some trickery to even detect if the page is being loaded "initial" or on a subsequent "magic navigation" effect (to try to implement a workaround in that scenario). We figured it out, but I think we're reading some special HTTP headers for this.

If it turns out that other modules need this regularly, I also recommend having some official property to detect this scenario.

sbwalker commented 4 months ago

@iJungleboy you did not actually answer my question. I was asking if you viewed the source code for the page in your browser dev tools and verified if the link element reference to Module.js is in the page head or not?

iJungleboy commented 4 months ago

@sbwalker I don't have a running copy, but as far as I remember it's not even added to the DOM.

@tvatavuk could you confirm / explain? The question was:

  1. if the module has the Resources with module.js
  2. ...and the first page visited was without the module
  3. ...and the user navigated to a page with the module
  4. does the module.js then appear in the source - but not load - or is in not even in the source?
tvatavuk commented 4 months ago

Thanks to everyone for helping!

To test and understand the new rendering modes and their behaviors, we are using the Oqtane 5.1.2 source code (master). Our setup includes Windows 11, Visual Studio 2022 v17.10.3, and latest .NET 8.0 SDK with all the latest updates.

We've created several sub-portals for different tests. Specifically, to test Static SSR behaviors with the default Oqtane site template and theme, we created a new sub-site. The home page is accessible at http://localhost:44357/ss10 and it is defult one, without custom module. On the page http://localhost:44357/ss10/t1, we developed a new custom Oqtane module, ToSic.Module.Test512, using the default Oqtane module template.

Here are our observations:

  1. Module.js in Resources: Yes, the custom Oqtane module, ToSic.Module.Test512, includes Module.js in its resources.

image

  1. Initial Page Visit: Yes, the first page visited did not contain the module.

  2. Navigation to Module Page: Yes, after home we are navigating to a page with the module.

image

  1. Module.js appeared in the source code, but Module.js is not Loading.

image

I will provide more details in the next comment.

tvatavuk commented 4 months ago

The solution using Reload = true is working, but it has some limitations for our specific cases.

new Resource { ResourceType = ResourceType.Script, Url = ModulePath() + "Module.js", Reload = true }

image

As @iJungleboy mentioned, the page-script magic doesn't work with some external .js resources, as illustrated in this example:

<script integrity="sha256-0af2VbC4vmPsa8OLBAKBmLoyuKq4bBlKK2KOgMWayio=" crossorigin="anonymous" src="https://cdn.jsdelivr.net/npm/@fancyapps/ui@4.0.31/dist/fancybox.umd.js"></script>
tvatavuk commented 4 months ago

To better support different cases of loading resources in Static SSR, we created a simple service to distinguish between the first full page load on Static SSR and subsequent page loads that occur with Blazor enhanced navigation.

Here is the helper service implementation:

Interface Definition

 namespace ToSic.Sxc.Oqt.Shared.Interfaces
{
    /// <summary>
    /// Service for providing rendering information based on the current HTTP context and module render mode.
    /// </summary>
    public interface IRenderInfoService
    {

        /// <summary>
        /// Checks if the module render mode is static SSR (Server-Side Rendering).
        /// </summary>
        /// <param name="renderMode">The module render mode.</param>
        /// <returns>True if the render mode is static SSR, otherwise false.</returns>
        bool IsStaticSsr(string renderMode);

        /// <summary>
        /// Checks if Blazor Enhanced Navigation is enabled for static SSR.
        /// </summary>
        /// <param name="renderMode">The module render mode.</param>
        /// <returns>True if Blazor Enhanced Navigation is enabled, otherwise false.</returns>
        /// <remarks>
        /// Blazor Enhanced Navigation is a feature in .NET 8 that allows for progressively-enhanced navigation in multi-page apps.
        /// This is enabled when the app loads `blazor.web.js` and does not use an interactive Router. It works by intercepting 
        /// navigation within the base href URI space, loading content via `fetch` requests, and syncing the DOM.
        /// </remarks>
        bool IsBlazorEnhancedNav(string renderMode);

        /// <summary>
        /// Checks if the SSR Framing response header exists, indicating a fetch page request in Blazor Enhanced Navigation.
        /// </summary>
        /// <param name="renderMode">The module render mode.</param>
        /// <returns>
        /// True if the SSR Framing response header exists (indicating a fetch page request during Blazor Enhanced Navigation).
        /// False if it is a standard full page load in the browser.
        /// </returns>
        /// <remarks>
        /// This helps distinguish between a full page load (initial load) and a fetch page request (subsequent navigation within the app).
        /// This behavior occurs when Blazor Enhanced Navigation is in use for static SSR.
        /// </remarks>
        bool IsSsrFraming(string renderMode);
    }
}

Server-Side Implementation

using Microsoft.AspNetCore.Http;
using Oqtane.Shared;
using System;
using System.Collections.Generic;
using System.Linq;
using ToSic.Sxc.Oqt.Shared.Interfaces;

namespace ToSic.Sxc.Oqt.Server.Services
{
    /// <inheritdoc />
    public class RenderInfoService(IHttpContextAccessor httpContextAccessor) : IRenderInfoService
    {
        private readonly List<string> _enhancedNavValues = ["allow", "on"];
        private const string BlazorEnhancedNav = "blazor-enhanced-nav";
        private const string SsrFraming = "ssr-framing";

        /// <inheritdoc />
        public bool IsStaticSsr(string renderMode = RenderModes.Interactive)
          => string.Equals($"{renderMode}", RenderModes.Static, StringComparison.OrdinalIgnoreCase);

        /// <inheritdoc />
        public bool IsBlazorEnhancedNav(string renderMode)
        {
            if (!IsStaticSsr(renderMode))
                return false;

            var context = httpContextAccessor.HttpContext;
            if (context != null && context.Response.Headers.TryGetValue(BlazorEnhancedNav, out var enhancedNav))
                return _enhancedNavValues.Contains($"{enhancedNav}", StringComparer.OrdinalIgnoreCase);

            return false;
        }

        /// <inheritdoc />
        public bool IsSsrFraming(string renderMode)
        {
            if (!IsBlazorEnhancedNav(renderMode))
                return false;

            var context = httpContextAccessor.HttpContext;
            return context != null && context.Response.Headers.TryGetValue(SsrFraming, out _);
        }
    }
}

Client-Side Implementation

using ToSic.Sxc.Oqt.Shared.Interfaces;

namespace ToSic.Sxc.Oqt.Client.Services
{
    /// <inheritdoc />
    public class RenderInfoService : IRenderInfoService
    {
        /// <inheritdoc />
        public bool IsStaticSsr(string renderMode) => false;

        /// <inheritdoc />
        public bool IsBlazorEnhancedNav(string renderMode) => false;

        /// <inheritdoc />
        public bool IsSsrFraming(string renderMode) => false;
    }
}

This service helps distinguish between a full page load and a fetch page request during Blazor Enhanced Navigation. This way, you can handle resource loading more appropriately based on the context of the page load.

tvatavuk commented 4 months ago

To ensure loading of external .js resource like one in example we used solution based on turnOn that was invented by @iJungleboy.

Example of external resource that we loaded with turnOn.

<script integrity="sha256-0af2VbC4vmPsa8OLBAKBmLoyuKq4bBlKK2KOgMWayio=" crossorigin="anonymous" src="https://cdn.jsdelivr.net/npm/@fancyapps/ui@4.0.31/dist/fancybox.umd.js"></script>

More info about turnOn: https://docs.2sxc.org/js-code/turn-on/index.html

iJungleboy commented 4 months ago

just to clarify what @tvatavuk said - turnOn doesn't ensure that the JS is loaded into the browser, it's job is to determine when all the requirements of a script are given, and then start some code.

A bit like a browser onLoad, but in a way that can handle late-added scripts, complicated dependencies, and CSP safe since it's configuration only, meaning that it can be used even in very strict CSP scenarios. CSP = Client Security Policy. because it can receive parameters as JSON in the source code - which doesn't require CSP fingerprinting.

sbwalker commented 3 months ago

@tvatavuk thank you for confirming that Oqtane is injecting the reference to the JavaScript resource into the page DOM - this means that Oqtane is behaving properly. The fact that the JavaScript was not being executed is not an Oqtane issue - it is related to Blazor SSR and how Enhanced Navigation behaves in .NET 8. There is plenty of information available online about this behavior (ie. https://learn.microsoft.com/en-us/aspnet/core/blazor/javascript-interoperability/static-server-rendering?view=aspnetcore-8.0). As explained in the Building Beautiful Websites webinar, Oqtane is utilizing a solution created by Mackinnon Buck from the Blazor Dev Team which enables JavaScript navigation callbacks (https://github.com/MackinnonBuck/blazor-page-script). If this solution does not work for you, you are free to use your own custom solution.

sbwalker commented 3 months ago

@tvatavuk you mentioned that the page-script magic doesn't work with some external .js resources, as illustrated in this example:

<script integrity="sha256-0af2VbC4vmPsa8OLBAKBmLoyuKq4bBlKK2KOgMWayio=" crossorigin="anonymous" src="https://cdn.jsdelivr.net/npm/@fancyapps/ui@4.0.31/dist/fancybox.umd.js"></script>

I assume this is because the page-script does not support integrity or crossorigin attributes. Would it make sense for me to reach out to Mackinnon Buck to provide this feedback and explore a potential solution? A lot of developers are using the page-script project as it is the official example provided by Microsoft, so it would benefit the community to address this scenario.

Page-script (https://github.com/MackinnonBuck/blazor-page-script) already provides the ability to distinguish between the first full page load on Static SSR and subsequent page loads that occur with Blazor enhanced navigation. It exposes different events for these purposes - onLoad and onUpdate. It would be useful to familiarize yourself with the capabilities as I would assume the page-script approach will eventually become the "standard" approach in Blazor for dealing with JavaScript in SSR Enhanced Navigation.

tvatavuk commented 3 months ago

Thank you for bringing up the issue with the page-script magic and external JavaScript resources. Let me clarify the situation.

The problem arises because the external JavaScript module we're using, https://cdn.jsdelivr.net/npm/@fancyapps/ui@4.0.31/dist/fancybox.umd.js, is in the classic UMD (Universal Module Definition) format, whereas page-script magic relies on the standard dynamic import, which expects modules in the ESM (ECMAScript module) format. Due to this format discrepancy, the page-script magic does not work as expected.

A potential solution would be to use an external JavaScript module in the ESM format, such as https://cdn.jsdelivr.net/npm/@fancyapps/ui@4.0.31/dist/fancybox.esm.js. However, this requires changes to the existing code in production, which might not be feasible immediately.

Regarding the inclusion of additional script attributes like integrity and crossorigin, more investigation is necessary. These attributes enhance security of the external scripts in browser. However, their interaction with the dynamic import() functionality, which already has specific handling for CORS, needs to be understood better. If @iJungleboy or anyone, has more experience or insights on this matter, suggestions would be greatly appreciated.

Thanks for explaining Page-script (https://github.com/MackinnonBuck/blazor-page-script) and its ability to distinguish between the first full page load on Static SSR and subsequent page loads that occur with Blazor enhanced navigation. Yes, I will familiarize myself with these capabilities and find how to use them in 2sxc. In our case we needed info about Static SSR and enhanced navigation during razor rendering that happens on server to provide correct list of resources (js, css) to browser.

iJungleboy commented 3 months ago

@sbwalker thanks for your help and support 🙏🏼

sbwalker commented 3 months ago

@tvatavuk I reached out to Mackinnon Buck and he explained that that page-script custom element can include additional attributes - he created an example here:

https://github.com/MackinnonBuck/blazor-page-script/commit/44ad6833b8375a9eaa9b0fa9ace70e024b823f2d

The most important part is that the attribute rel="modulepreload" needs to be included so that the dynamic module imports will respect the attributes set on the link element:

https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/rel/modulepreload

Oqtane will need to be modified to include the additional attributes - and then it will be able to support remote resources.

sbwalker commented 3 months ago

One other thing I want to explore is if it is really necessary to force developers to include the Reload = True property in their Resources declaration. Instead, the framework could determine that the site is using Static rendering and use the page-script approach by default for Script resources. This would automatically wire up the onLoad, onUpdate, onDispose events if they exist - and if a JavaScript library does not utilize them, it should not cause any issues. This would simplify the experience for developers as they would not need to worry about setting an extra property which is only applicable for a specific render mode.

mdmontesinos commented 3 months ago

One other thing I want to explore is if it is really necessary to force developers to include the Reload = True property in their Resources declaration. Instead, the framework could determine that the site is using Static rendering and use the page-script approach by default for Script resources. This would automatically wire up the onLoad, onUpdate, onDispose events if they exist - and if a JavaScript library does not utilize them, it should not cause any issues. This would simplify the experience for developers as they would not need to worry about setting an extra property which is only applicable for a specific render mode.

This would actually be a really nice addition for the developer, as I was also getting a bit confused with javascript in static ssr (see #4038). Although you would still need to implement the onLoad, onUpdate and onDispose events when required, so the problem does not totally disappear.

tvatavuk commented 3 months ago

Absolutely, I agree! 🎉 Simplifying the developer experience by having the framework automatically determine the need for Reload = True based on the rendering mode is a great idea. 🚀 This enhancement will undoubtedly reduce the complexity of custom module development in Oqtane. Thank you 👏

iJungleboy commented 3 months ago

After some more research I think we really need to clarify what Reload = true should mean. @sbwalker I'll also document it, as soon as it's clear to me 😉

Basically my interpretation is that Reload = true should mean

  1. it should get loaded anew on every "load" (whatever that means)
  2. ...and that it should be re-executed (not just loaded) on every "load"

This would still pose the question:

  1. load of page? <-- probably this
  2. load of module - e.g. what should happen, if the module is on the page multiple times?

Because I assume that's what Reload = true was originally meant to say.

ATM we're forced to use it to ensure loading of our scripts when soft-navigating to a page with the module on it - which is probably not the real intent of this. I assume that a module can expect it's resources to always be loaded by Oqtane - no matter how the page was accessed. ...which in turn means that the behavior in 5.1.2 where we must provide Reload=true is a bug.

But since it's not fully clear what the official meaning Reload=true is, it's hard to create a bug report.

@sbwalker I would appreciate clarification on this, so I can document it - and then it would also be clear if this is a bug or something developers must be educated about.

sbwalker commented 3 months ago

The Reload parameter was discussed in March in this thread: https://github.com/oqtane/oqtane.framework/issues/4038 when it was originally introduced.

The Reload parameter is only applicable when a module component is using Static Render Mode and it is only applicable to JavaScript resources. In this scenario, Blazor is using Enhanced Navigation which means that when you navigate to a new page, a full page refresh is not occurring - Blazor is patching the DOM based on the differences in the output (ie. it is behaving like a SPA). This means that <script> references are not refreshed in the DOM - which means they will not be reloaded by the browser. The Reload parameter instructs the framework that a JavaScript resource needs to be reloaded on page navigations.

sbwalker commented 3 months ago

@iJungleboy just to clarify so that I am able to reproduce the behavior you are describing:

correct?

iJungleboy commented 3 months ago

Almost correct, but not quite. the last line:

when you say "load" you mean that the Githubissues.

  • Githubissues is a development platform for aggregating issues.