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

What is the recommended approach for javascript inside Blazor SSR projects? #51331

Closed
1 task done
maxamundsen opened this issue Oct 12, 2023 · 6 comments
Closed
1 task done
Assignees
Labels
area-blazor Includes: Blazor, Razor Components Docs This issue tracks updating documentation

Comments

@maxamundsen
Copy link

Is there an existing issue for this?

  • I have searched the existing issues

Is your feature request related to a problem? Please describe the problem.

Our company has a Razor Pages project that utilizes third party JS libraries that manipulate the DOM. We want to migrate the project to Blazor SSR when it becomes available, but in my testing, this seems to be a struggle.

When using a <script> tag inside a component, it will only execute on an initial page load. Enhanced navigation does not load the script. In a talk given by the blazor team, they discussed a js event that fires upon successful enhanced nav. You can write an event listener that executes your code when a page is navigated to. However, with this approach, the event listeners must still be initialized by a full page load, so this does not solve problem when it comes to page specific JS.

Secondly, I tried the IJSRuntime, which works, but requires an interactive render mode. Many of our pages require JS libraries, and we don't want to use signalr circuits every time we need to execute a simple js function.

Finally, when applying data-enhance-nav="false", <script> tags load correctly on each page since each page has been fully loaded, but pages flicker on navigation (firefox only), and we would really like to utilize the enhanced navigation SPA feel.

I raised this issue since our team is wondering what the recommended approach would be when migrating to blazor 8 ssr.

Describe the solution you'd like

Ideally, code inside a script tag would be executed when the page is navigated to. This would allow for existing projects that utilize JS libraries to migrate without relying on IJSRuntime, or disabling enhanced navigation.

Additional context

No response

@dotnet-issue-labeler dotnet-issue-labeler bot added the area-blazor Includes: Blazor, Razor Components label Oct 12, 2023
@garrettlondon1
Copy link

garrettlondon1 commented Oct 17, 2023

I think this also applies the other way around, to Blazor Server folks who have been mangling JS Interop to achieve interactivity, migrating to Blazor SSR to remove as much websocket dependency as possible.

If I decide to SSR a page, have JS and HTMX calling Web API Controllers, I want to also have some InteractiveServer components where API controllers do not have to be defined to interact with EF core.

I think it's a great play, Keep Blazor Server logic abstracted away from the client (plus real-time with SignalR), create controllers for simple JS dom updates from Web API's without React nonsense.. and server side render everything else

@javiercn
Copy link
Member

@maxamundsen thanks for contacting us.

Using enhanced navigation requires a different approach when it comes down to dealing with scripts. The main reason being that without enhanced navigation, all the state is cleared on each navigation, while with it, the state on the page is "preserved".

That means that scripts that modify global state or depend on the page loading to perform an action need to be adjusted to account for the fact that with enhanced nav, those two things don't work in the same way.

I would in general avoid global state (or the fact that it gets cleared up upon navigation) and use ES6 modules that setup concrete actions when things happen on the page, and that take care of cleanup.

  • For example, if wiring up a JS validation library, instead of doing it within the page load, set up a mutation observer to trigger when to attach handlers and when to remove them.

When it comes to wiring up the scripts into the page, I don't think using script tags works very well in this mode. Specially for inline scripts (which I would avoid). A better option might be to switch the scripts to use ES6 modules and use a dynamic import or <script type="module" src="..."><script> to wire it up (I am not sure if the script type="module" bit works).

  • The reason for this is that modules are only executed once, so you can safely import/require them when you need them without having to worry about running them multiple times.

@MackinnonBuck MackinnonBuck added the Docs This issue tracks updating documentation label Oct 17, 2023
@MackinnonBuck MackinnonBuck added this to the .NET 8: Documentation milestone Oct 17, 2023
@maxamundsen
Copy link
Author

Thank you for the response. ES Modules are probably the way to go. Seeing that this is marked with the 'docs' tag, perhaps we will see some examples in future documentation? I guess I am a bit confused with the specifics as to how this would look in a project.

Here are some questions that I think documentation should answer:

  • Assuming you put a mutation observer inside a 'global' script that does get loaded on (real) page load, how would I know what page is enhanced-navigated to?
  • If I execute some javascript when there is an enhanced navigation, is there a way to have this code run with enhanced navigation disabled as well? If I start a project using enhanced navigation, I do not want to have to refactor my javascript in case my team decides to turn it off.

Not sure if these are the right questions to ask as I am not super proficient in javascript, and only really use it when needed. From the perspective of a "C# dev who doesn't know JS" some of this info would be very nice to include in documentation.

I'm sure I can come up with some way of accomplishing my goal based on your response, but I would definitely like to see an example of the "clean" and "correct" way of doing it.

@MackinnonBuck
Copy link
Member

MackinnonBuck commented Nov 1, 2023

I was able to put together an example that lets you define per-page JavaScript callbacks to run when a page gets loaded, updated, or disposed. It can be used with or without enhanced navigation.

Edit: See the updated comment here and the docs here for the most up-to-date sample.

Original content

Example usage

MyPage.razor

@page "/my-page"

<PageTitle>My page</PageTitle>

<PageScript Src="js/pages/home.js" />

Welcome to my page.

js/pages/home.js:

// Called when the page first gets loaded
export function onLoad() {
    console.log('Loaded');
}

// Called each time an enhanced load occurs on the page,
// plus once when the page first gets loaded
export function onUpdate() {
    console.log('Updated');
}

// Called when the user leaves the page due to an enhanced navigation
export function onDispose() {
    console.log('Disposed');
}

Implementation

pageScript.js

let currentPageModule = null;
let currentPathname = null;

customElements.define('page-script', class extends HTMLElement {
    static observedAttributes = ['src'];

    // We use attributeChangedCallback instead of connectedCallback
    // because a page script element might get reused between pages
    // due to enhanced navigation.
    attributeChangedCallback(name, oldValue, newValue) {
        if (name === 'src') {
            loadPageScript(newValue);
        }
    }
});

Blazor.addEventListener('enhancedload', onEnhancedLoad);

async function loadPageScript(src) {
    const pathnameOnLoad = document.location.pathname;

    if (pathnameOnLoad === currentPathname) {
        // We don't reload the page script if we're already on the same page.
        return;
    }

    currentPathname = pathnameOnLoad;
    currentPageModule?.onDispose?.();
    currentPageModule = null;

    const module = await import(`${document.location.origin}/${src}`);

    if (location.pathname !== pathnameOnLoad) {
        // We changed pages since we started loading the module - nothing left to do.
        return;
    }

    currentPageModule = module;
    currentPageModule.onLoad?.();
    currentPageModule.onUpdate?.();
}

function onEnhancedLoad() {
    if (location.pathname !== currentPathname) {
        currentPathname = null;
        currentPageModule?.onDispose?.();
        currentPageModule = null;
        return;
    }

    currentPageModule?.onUpdate?.();
}

PageScript.razor

<page-script src="@Src"></page-script>

@code {
    [Parameter]
    [EditorRequired]
    public string Src { get; set; } = default!;
}

You also have to add <script src="js/pageScript.js"></script> to your App.razor below the Blazor script for this to work.

This is quite a bit of code to include in documentation, but I believe we've had samples as long as this before (example).

We could also add some information to the docs describing how to wire up mutation observers and use those to react to document changes.

cc @SteveSandersonMS @javiercn in case you have any input about this!

@MackinnonBuck
Copy link
Member

MackinnonBuck commented Nov 2, 2023

@guardrex Could we include this example in the docs? The topic would be something like "Using JavaScript with Blazor Static Server Rendering".

Edit: See the updated comment here and the docs here for the most up-to-date sample.

Original content

Some applications may depend on JavaScript to perform initialization tasks that are specific to each page. However, when using Blazor's enhanced navigation feature, which allows the user to navigate between pages without reloading the entire page, JavaScript code may not be re-executed as expected. Therefore, page-specific <script> elements may not be executed again when the user navigates to a different page.

To avoid this problem, it is not recommended to rely on having page-specific <script> elements outside the app's common layout. Instead, scripts should register an afterWebStarted(blazor) JavaScript initializer to perform initialization logic, and use blazor.addEventListener("enhancedload", callback) to listen to page updates caused by enhanced navigation.

Following is an example demonstrating one way to configure JavaScript code that runs when a page gets initially loaded or updated.

Blazor Web App

MyPage.razor

@page "/my-page"
@using BlazorPageScript

<PageTitle>My page</PageTitle>

<PageScript Src="js/pages/home.js" />

Welcome to my page.

wwwroot/js/pages/home.js:

// Called when the page first gets loaded
export function onLoad() {
    console.log('Loaded');
}

// Called each time an enhanced load occurs on the page,
// plus once when the page first gets loaded
export function onUpdate() {
    console.log('Updated');
}

// Called when the user leaves the page due to an enhanced navigation
export function onDispose() {
    console.log('Disposed');
}

BlazorPageScript (Razor Class Library)

wwwroot/BlazorPageScript.lib.module.js:

let currentPageModule = null;
let currentPathname = null;

export function afterWebStarted(blazor) {
    customElements.define('page-script', class extends HTMLElement {
        static observedAttributes = ['src'];

        // We use attributeChangedCallback instead of connectedCallback
        // because a page script element might get reused between enhanced
        // navigations.
        attributeChangedCallback(name, oldValue, newValue) {
            if (name === 'src') {
                loadPageScript(newValue);
            }
        }
    });

    blazor.addEventListener('enhancedload', onEnhancedLoad);
}

async function loadPageScript(src) {
    const pathnameOnLoad = document.location.pathname;

    if (pathnameOnLoad === currentPathname) {
        // We don't reload the page script if we're already on the same page.
        return;
    }

    currentPathname = pathnameOnLoad;
    currentPageModule?.onDispose?.();
    currentPageModule = null;

    const module = await import(`${document.location.origin}/${src}`);

    if (location.pathname !== pathnameOnLoad) {
        // We changed pages since we started loading the module - nothing left to do.
        return;
    }

    currentPageModule = module;
    currentPageModule.onLoad?.();
    currentPageModule.onUpdate?.();
}

function onEnhancedLoad() {
    if (location.pathname !== currentPathname) {
        currentPathname = null;
        currentPageModule?.onDispose?.();
        currentPageModule = null;
        return;
    }

    currentPageModule?.onUpdate?.();
}

PageScript.razor

<page-script src="@Src"></page-script>

@code {
    [Parameter]
    [EditorRequired]
    public string Src { get; set; } = default!;
}

To monitor changes in specific DOM elements, use the MutationObserver API.

@MackinnonBuck
Copy link
Member

Closing because this was fixed in dotnet/AspNetCore.Docs#30922.

Side note: I also created a personal repository with additional usage examples, plus a BlazorPageScript NuGet package: https://github.com/MackinnonBuck/blazor-page-script, in case that's of interest to anyone.

@ghost ghost locked as resolved and limited conversation to collaborators Feb 7, 2024
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
area-blazor Includes: Blazor, Razor Components Docs This issue tracks updating documentation
Projects
None yet
Development

No branches or pull requests

4 participants