ClientScript Shim: Zero-Rewrite Migration

Your Web Forms code-behind calls ClientScript.RegisterStartupScript(...) today. With the ClientScriptShim, those same calls work in Blazor — no rewrite needed.

💡 Recommended Migration Path

The ClientScriptShim lets you keep your existing Page.ClientScript calls intact. Just remove the Page. prefix and the shim handles everything — queuing, deduplication, and execution via IJSRuntime.


1. RegisterStartupScript — Same Code, Works in Blazor

Web Forms Code-Behind (unchanged)

protected void btnGreet_Click(object sender, EventArgs e)
{
    ClientScript.RegisterStartupScript(
        GetType(),
        "greeting",
        "alert('Hello from the server!');",
        true);
}

Blazor with ClientScriptShim (identical!)

private void HandleGreet()
{
    ClientScript.RegisterStartupScript(
        GetType(),
        "greeting",
        "alert('Hello from the server!');",
        true);
    // FlushAsync() runs automatically in OnAfterRenderAsync
}
Live Demo — Startup Script

Click the button to queue a startup script. It executes after the next render cycle, just like Web Forms' RegisterStartupScript.

Key point: The ClientScript.RegisterStartupScript() call is identical to what you'd write in Web Forms code-behind. The shim queues the script and flushes it via IJSRuntime during OnAfterRenderAsync.


2. RegisterClientScriptInclude — Dynamic Script Loading

Web Forms Code-Behind

protected void Page_Load(object sender, EventArgs e)
{
    ClientScript.RegisterClientScriptInclude(
        "moment",
        "https://cdn.jsdelivr.net/npm/moment@2.30.1/moment.min.js");
}

Blazor with ClientScriptShim

private void LoadMomentJs()
{
    ClientScript.RegisterClientScriptInclude(
        "moment",
        "https://cdn.jsdelivr.net/npm/moment@2.30.1/moment.min.js");
}
Live Demo — Script Include

Click to dynamically load the Moment.js library. The shim appends a <script> tag to the document head — the same effect as RegisterClientScriptInclude in Web Forms.

Include registered: Not yet

Key point: The API call is the same. The shim dynamically injects the <script src="..."> tag — no need for layout file changes or static references.


3. Deduplication — Same Key Runs Once

Web Forms Pattern

// Web Forms automatically deduplicates by key:
if (!ClientScript.IsStartupScriptRegistered(GetType(), "init"))
{
    ClientScript.RegisterStartupScript(
        GetType(), "init",
        "console.log('Initialized!');", true);
}

// Registering the same key again is a no-op
ClientScript.RegisterStartupScript(
    GetType(), "init",
    "console.log('Duplicate — ignored!');", true);

Blazor — Identical Behavior

// Same deduplication logic works in Blazor:
if (!ClientScript.IsStartupScriptRegistered(GetType(), "init"))
{
    ClientScript.RegisterStartupScript(
        GetType(), "init",
        "console.log('Initialized!');", true);
}

// Same key = same no-op behavior
ClientScript.RegisterStartupScript(
    GetType(), "init",
    "console.log('Duplicate — ignored!');", true);
Live Demo — Deduplication

Click the button multiple times. The IsStartupScriptRegistered check prevents duplicate registration, and even without the check, the shim uses dictionary-based keys so the same key overwrites rather than duplicates.

Registration attempts: 0

Was already registered: No (registered)

Key point: IsStartupScriptRegistered() works exactly like Web Forms — check before registering to avoid duplicate execution. The shim's internal dictionary also deduplicates by key, matching Web Forms behavior.


4. Side-by-Side: Manual IJSRuntime vs. ClientScriptShim

Compare the two migration approaches. The left column shows the rewrite you'd need with direct IJSRuntime. The right column shows the zero-rewrite shim approach.

❌ Before: Manual IJSRuntime Rewrite

Every ClientScript call must be found, understood, and rewritten:

@inject IJSRuntime JS

@code {
    // ❌ Must rewrite: RegisterStartupScript → OnAfterRenderAsync
    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        if (firstRender)
        {
            await JS.InvokeVoidAsync("eval",
                "document.getElementById('msg').innerText = 'Ready!';");
        }
    }

    // ❌ Must rewrite: RegisterClientScriptInclude → dynamic import
    private async Task LoadScript()
    {
        await JS.InvokeVoidAsync("eval",
            "(function(){var s=document.createElement('script');" +
            "s.src='lib.js';document.head.appendChild(s);})()");
    }

    // ❌ Must rewrite: IsStartupScriptRegistered → manual tracking
    private HashSet<string> _registered = new();
    private async Task RegisterOnce(string key, string script)
    {
        if (!_registered.Contains(key))
        {
            _registered.Add(key);
            await JS.InvokeVoidAsync("eval", script);
        }
    }
}
✅ After: ClientScriptShim (Zero Rewrite)

Your Web Forms code-behind stays the same:

@inject ClientScriptShim ClientScript

@code {
    // ✅ Unchanged: RegisterStartupScript works as-is
    protected override void OnInitialized()
    {
        ClientScript.RegisterStartupScript(
            GetType(), "init",
            "document.getElementById('msg').innerText = 'Ready!';",
            true);
    }

    // ✅ Unchanged: RegisterClientScriptInclude works as-is
    private void LoadScript()
    {
        ClientScript.RegisterClientScriptInclude(
            "myLib", "lib.js");
    }

    // ✅ Unchanged: IsStartupScriptRegistered works as-is
    private void RegisterOnce(string key, string script)
    {
        if (!ClientScript.IsStartupScriptRegistered(
                GetType(), key))
        {
            ClientScript.RegisterStartupScript(
                GetType(), key, script, true);
        }
    }
}
Live Demo — Both Approaches, Same Result

Click each button to see the same effect achieved two different ways:

Via IJSRuntime (manual):
Via ClientScriptShim (zero-rewrite):

⏳ Click a button above...

Bottom line: Both produce the same result, but the shim approach preserves your original code-behind logic. Less rewriting = fewer bugs = faster migration.


Migration Quick Reference

Web Forms API ClientScriptShim Method Change Needed
Page.ClientScript.RegisterStartupScript(...) ClientScript.RegisterStartupScript(...) Remove Page. prefix only
Page.ClientScript.RegisterClientScriptBlock(...) ClientScript.RegisterClientScriptBlock(...) Remove Page. prefix only
Page.ClientScript.RegisterClientScriptInclude(...) ClientScript.RegisterClientScriptInclude(...) Remove Page. prefix only
Page.ClientScript.IsStartupScriptRegistered(...) ClientScript.IsStartupScriptRegistered(...) Remove Page. prefix only
Page.ClientScript.GetPostBackEventReference(...) ❌ Not supported Use @onclick / EventCallback
Page.ClientScript.GetCallbackEventReference(...) ❌ Not supported Use IJSRuntime JS-to-.NET interop
How to Get the Shim
  • Components inheriting BaseWebFormsComponent: Access it as ClientScript property — it's built in.
  • Razor pages or non-BWFC components: Inject it with @inject ClientScriptShim ClientScript
  • Service registration: Call builder.Services.AddBlazorWebFormsComponents() in Program.cs

Source Code

Complete @code block for this page:

@inject BlazorWebFormsComponents.ClientScriptShim ClientScript
@inject IJSRuntime JS

@code {
    private bool _momentRegistered;
    private string _momentStatus = "";
    private int _dedupAttempts;
    private bool _wasAlreadyRegistered;
    private string _dedupMessage = "";

    // Demo 1: RegisterStartupScript — identical to Web Forms code-behind
    private void HandleGreet()
    {
        ClientScript.RegisterStartupScript(
            GetType(), "greeting",
            "alert('Hello from the ClientScriptShim!');", true);
    }

    // Demo 2: RegisterClientScriptInclude — same API as Web Forms
    private void LoadMomentJs()
    {
        ClientScript.RegisterClientScriptInclude(
            "moment",
            "https://cdn.jsdelivr.net/npm/moment@2.30.1/moment.min.js");
        _momentRegistered = ClientScript.IsClientScriptIncludeRegistered("moment");
    }

    // Demo 3: Deduplication with IsStartupScriptRegistered
    private void RegisterWithDedup()
    {
        _dedupAttempts++;
        _wasAlreadyRegistered = ClientScript.IsStartupScriptRegistered(GetType(), "dedup-demo");

        if (!_wasAlreadyRegistered)
        {
            ClientScript.RegisterStartupScript(GetType(), "dedup-demo",
                "document.getElementById('dedup-output').innerText = " +
                "'✅ Script executed (first time only!)';", true);
            _dedupMessage = "Registered and queued for execution.";
        }
        else
        {
            _dedupMessage = $"Already registered — skipped (attempt #{_dedupAttempts}).";
        }
    }

    // Demo 4: Side-by-side comparison handlers
    private async Task SetMessageViaJs()
    {
        await JS.InvokeVoidAsync("eval",
            "document.getElementById('comparison-output').innerText = " +
            "'✅ Message set via IJSRuntime (manual rewrite)';");
    }

    private void SetMessageViaShim()
    {
        ClientScript.RegisterStartupScript(GetType(), "comparison",
            "document.getElementById('comparison-output').innerText = " +
            "'✅ Message set via ClientScriptShim (zero rewrite!)';", true);
    }

    // Flush queued scripts after each render
    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        await ClientScript.FlushAsync(JS);
    }

    // Reset helpers
    private void CheckMomentLoaded()
    {
        _momentRegistered = ClientScript.IsClientScriptIncludeRegistered("moment");
        _momentStatus = _momentRegistered
            ? "Moment.js include is registered ✓"
            : "Moment.js not yet registered";
    }

    private void ResetDedup()
    {
        _dedupAttempts = 0;
        _wasAlreadyRegistered = false;
        _dedupMessage = "";
    }
}