Skip to content
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

[Blazor] Framework support for per-page JavaScript logic #52273

Open
MackinnonBuck opened this issue Nov 22, 2023 · 9 comments
Open

[Blazor] Framework support for per-page JavaScript logic #52273

MackinnonBuck opened this issue Nov 22, 2023 · 9 comments
Labels
area-blazor Includes: Blazor, Razor Components enhancement This issue represents an ask for new feature or an enhancement to an existing one feature-blazor-jsinterop This issue is related to JSInterop in Blazor Pillar: Complete Blazor Web Priority:1 Work that is critical for the release, but we could probably ship without

Comments

@MackinnonBuck
Copy link
Member

Overview

One might expect that placing a <script> element in a statically rendered page component should result in the script getting executed each time the page loads. This would be consistent with how other frameworks like Razor Pages and MVC work. However, scripts won't execute if they get dynamically added to the page via enhanced navigation, and this creates inconsistency between full page reloads and enhanced page updates.

We added the blazor.addEventListener("enhancedload", callback) API in .NET 8 so that customers could implement logic that reacts to enhanced page updates. However, this API alone doesn't provide the level of convenience that placing <script> elements directly inside a component does.

Potential solution

Feedback such as #51331 prompted me to create a library to help make it easier to have per-page JavaScript in Blazor Web apps: https://github.com/MackinnonBuck/blazor-page-script.

We should consider bringing a feature like this into the framework. The package's API is a component that takes a path to a JS module, but it would be worthwhile to consider alternative and/or more sophisticated designs. For example, we could allow <script> tags to be placed directly in components, since that's what many customers intuitively try first, and the framework could preprocess them in a special manner so that the code gets executed even with enhanced navigation enabled.

@MackinnonBuck MackinnonBuck added the area-blazor Includes: Blazor, Razor Components label Nov 22, 2023
@javiercn
Copy link
Member

I've been thinking a bit more about this, and we should also extend this type of functionality to CSS, so people can wire-up a stylesheet declaratively instead of relying on JS to inject it (which many of them do wrong).

@javiercn javiercn added this to the .NET 9 Planning milestone Nov 30, 2023
@ghost
Copy link

ghost commented Nov 30, 2023

Thanks for contacting us.

We're moving this issue to the .NET 9 Planning milestone for future evaluation / consideration. We would like to keep this around to collect more feedback, which can help us with prioritizing this work. We will re-evaluate this issue, during our next planning meeting(s).
If we later determine, that the issue has no community involvement, or it's very rare and low-impact issue, we will close it - so that the team can focus on more important and high impact issues.
To learn more about what to expect next and how this issue will be handled you can read more about our triage process here.

@davhdavh
Copy link
Contributor

davhdavh commented Dec 12, 2023

The workaround I found so far that works the best for both static script and dynamic script loading when using hash-navigation is:

  1. Add your script to HeadContent, here I use requirejs as example, notice it must NOT be marked with async or defer:
<HeadContent>
...
<script src="https://cdnjs.cloudflare.com/ajax/libs/require.js/2.3.6/require.js" crossorigin="anonymous"></script>
</HeadContent>
  1. Add a PageScript module on the page.
  2. Add a undefined-type element in your page that is marked with data-permanent, e.g. <scriptdummy id="scriptdummy" data-permanent></scriptdummy>.
  3. Add a MutationObserver in the PageScript module that monitors head for added scripts and css.
const observer = new MutationObserver(mutations => {
 let scriptDummy = document.getElementById("scriptdummy");
 mutations.forEach(mutation => {
  if (mutation.type === 'childList') {
   mutation.addedNodes.forEach(node => {
    if (node.hasAttribute('data-move-to-dummy')) {
     scriptDummy.appendChild(node);
    }
   });
  }
 });
});
  1. In the onload add a check if the page being enchanced loaded from navigation from another page:
export async function onLoad() {
 try {
  if (typeof(require) === "undefined" || !require || typeof (require.s.contexts._.urlFetched["/MySPA/app/main.js"]) !== 'undefined') {
   Blazor.navigateTo(window.location.href, true); //force reload
   return;
  }
 } catch (e) {
  //fail means it probably wasnt loaded yet, so just assume we are ok
 }

This works because when the page is loaded from non-enhanced load the script tag in the Head is ensured to be loaded, but when enhanced load is called the onload is called immediately after the element was added to head and thus is not yet loaded.
6. Fix your dynamic script loader to work with the MutationObserver. E.g. requirejs can only add elements to head, but we can make a hook to mark the scripts before:

export async function onLoad() {
...above script...
 require.config({
  onNodeCreated: node => {
   node.setAttribute('data-move-to-dummy', '');
   return document.getElementById("scriptdummy");
   },

I noticed Firefox did not like when you moved the css immediately after adding the link to head, so had to hack the requirejs css loader to take the return value of onNodeCreated to be the element to add to instead.
7. Hook up the observer:

export async function onLoad() {
...above script...
observer.observe(document.head, { childList: true });
...
export function onDispose() {
observer.disconnect();
  1. Fix that Blazor sets <meta base, but that doesn't work if your page has url params:
export function onUpdate() {
 //Blazor sets the base as /, but that means that any hash url we use would kill any url parameters
 //so we need to add them back in
 const queryParams = window.location.search;
 const baseTag = document.querySelector('base');
 if (baseTag) {
  // Update the href attribute of the <base> tag
  if (queryParams && !baseTag.getAttribute('href').includes('?'))
    baseTag.setAttribute('href', baseTag.getAttribute('href') + queryParams);
 } else {
  console.error('Base tag not found.');
 }
  1. Fix that Blazor eats the popstate and hashchange events:
export function onUpdate() {
...
 mySpaNavigation && mySpaNavigation.checkUrl();
}

This "solves" the 3 major problems:

  1. Same page navigation (hash) no longer kills your dynamic scripts, due to the MutationObserver moving them to a "safe" place.
  2. Inter-page navigation TO our script page is detected and a full page reload is issued on the page.
  3. Inter-page navigation AWAY from our script page still cleans up the scriptdummy element and thus the browser will attempt to unload the script.

All-in-all very fragile and soooo many issues in Blazor with legacy javascript.

@mkArtakMSFT mkArtakMSFT added enhancement This issue represents an ask for new feature or an enhancement to an existing one feature-blazor-jsinterop This issue is related to JSInterop in Blazor labels Dec 20, 2023
@ghost
Copy link

ghost commented Dec 20, 2023

Thanks for contacting us.

We're moving this issue to the .NET 9 Planning milestone for future evaluation / consideration. We would like to keep this around to collect more feedback, which can help us with prioritizing this work. We will re-evaluate this issue, during our next planning meeting(s).
If we later determine, that the issue has no community involvement, or it's very rare and low-impact issue, we will close it - so that the team can focus on more important and high impact issues.
To learn more about what to expect next and how this issue will be handled you can read more about our triage process here.

@mkArtakMSFT mkArtakMSFT added the Priority:1 Work that is critical for the release, but we could probably ship without label Jan 11, 2024
@schmellow
Copy link

schmellow commented Jan 15, 2024

I may be wrong, but it appears to me that PageScript solves a very specific problem (of js that needs to be run onload) in a very specific way (the onLoad/onUpdate/onDispose contract) .
What about more general cases? Like loading 3rd party script (that will never adhere to contract above), or maybe having your own script with helper functions, that does not need to do anything on load, and just needs to be there and only on that specific page? Is this even in scope?
The only solution as i see so far (and documentation does not talk about such cases at all) is disabling enhanced nav entirely on link or app level. Are there other options?

@mdmontesinos
Copy link

I totally agree with @schmellow. The proposed methods do not work well when trying to load 3rd party scripts such as Flowbite (Tailwind).

For example, an image Caroussel from Flowbite that requires js is properly rendered on first load but after enhanced navigating to another page and returning back, it does not appear at all

@Pvxtotal
Copy link

Pvxtotal commented Apr 8, 2024

Anyone got scripts like SweetAlert2 working with enhanced navigation turned on?

@jamescarter-le
Copy link

I totally agree with @schmellow. The proposed methods do not work well when trying to load 3rd party scripts such as Flowbite (Tailwind).

For example, an image Caroussel from Flowbite that requires js is properly rendered on first load but after enhanced navigating to another page and returning back, it does not appear at all

I'd like to +1 this requirement, I want to add a <script> tag in my component to initalize a graph. It doesn't need to callback to the server for anything as I can embed the data inside the <script> tag using SSR.

@jamescarter-le
Copy link

I totally agree with @schmellow. The proposed methods do not work well when trying to load 3rd party scripts such as Flowbite (Tailwind).
For example, an image Caroussel from Flowbite that requires js is properly rendered on first load but after enhanced navigating to another page and returning back, it does not appear at all

I'd like to +1 this requirement, I want to add a <script> tag in my component to initalize a graph. It doesn't need to callback to the server for anything as I can embed the data inside the <script> tag using SSR.

At the moment I'm using PageScript component and embedding the data in the DOM:

    <div id="leaderPerformanceGraphLabels" style="display: none;">@string.Concat("[", string.Join(",", _graphData.Select(x => new DateTimeOffset(x.Key).ToUnixTimeMilliseconds())), "]")</div>
    <div id="leaderPerformanceGraphData" style="display: none;">@string.Concat("[", string.Join(",", _graphData.Select(x => x.Value)), "]")</div>
    
    var labels = JSON.parse(document.getElementById('leaderPerformanceGraphLabels').innerHTML);
    var dataset = JSON.parse(document.getElementById('leaderPerformanceGraphData').innerHTML);

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
area-blazor Includes: Blazor, Razor Components enhancement This issue represents an ask for new feature or an enhancement to an existing one feature-blazor-jsinterop This issue is related to JSInterop in Blazor Pillar: Complete Blazor Web Priority:1 Work that is critical for the release, but we could probably ship without
Projects
None yet
Development

No branches or pull requests

8 participants