Rask
Rask
v0.23.1-alpha.0.496

Islands on WebAssembly

An island is an ordinary Rask component whose markup a front-end framework produces. The same files build unchanged on the Server host — C# owns the props, the generated types cross back into the .vue, the two .tsx and the .svelte, and the subtree is a diff boundary Rask never patches into. Only the transport differs. The colour picker and its hex field have no front-end file at all: they are react-colorful from npm, declared once as Colorful.HexColorPicker and Colorful.HexColorInput, their chain steps generated from the package's own TypeScript.

An island can also be the whole page: a route React owns outright, with its title set from C# and a skeleton in the first response.

Five runtimes in one tree, running client-side, and a package component nested inside the React island as a child. The callback that reaches C# here does so through a [JSExport] call into this tab's own runtime; on the Server host the same front-end files call back over the live socket.

using System.Globalization;
using Rask.Site;

namespace Rask.Site.Features.Islands;

/// <summary>
///     Vue, React, Svelte, Solid and Lit islands in one tree, on the WASM host.
/// </summary>
/// <remarks>
///     <para>
///         Nothing in <c>VueChart.vue</c>, <c>ReactCounter.tsx</c> or <c>SvelteMeter.svelte</c> knows
///         which host it is on. On Server a callback rides the live WebSocket; here it is a
///         <c>[JSExport]</c> call straight into this tab's own runtime. The island never opens a
///         channel of its own either way. This page is prerendered, so an island can be clicked
///         before .NET has booted: that callback is held and delivered after the first frame.
///     </para>
///     <para>
///         <see cref="LitBadge" /> is here BECAUSE this app has scoped TypeScript, not despite it. A
///         Lit island is spelled <c>Name.ts</c> beside <c>Name.cs</c>, which is exactly how a scoped
///         asset is spelled, and until #938 the build could only be told which ONE of the two
///         conventions a project used — so this page could show every runtime but the one with no
///         dependencies. Angular is still absent, for a reason of its own: its toolchain is three npm
///         packages this bundle does not carry.
///     </para>
///     <para>
///         React and Solid sit in folders of their own because they both compile <c>.tsx</c>, so their
///         Vite plugins are each scoped to their own island directory. Sharing one — or nesting one
///         inside the other — is refused by the build rather than mis-compiled.
///     </para>
///     <para>
///         <see cref="Colorful" /> is react-colorful straight from npm, with no <c>.tsx</c> of its own: two of its
///         components, a picker and a hex field bound to one colour, reached as <c>Colorful.HexColorPicker</c> and
///         <c>Colorful.HexColorInput</c>. Both are CHILDREN of <see cref="ReactCounter" />: they travel inside the
///         counter's props and React renders all three in one tree, so neither has a host element of its own and their
///         callbacks still reach C#.
///     </para>
/// </remarks>
public sealed partial class IslandsDemo : Component
{
    private const string StartColor = "#c026d3";

    private readonly List<ChartBar> _series =
    [
        new("Jan", 38),
        new("Feb", 64),
        new("Mar", 51),
        new("Apr", 82),
    ];

    private readonly List<int> _readings = [12, 30, 22, 48, 35, 61];

    private int _reading = 40;
    private int _lastClicked;
    private int _clicks;
    private int _step = 1;
    private int _reactTotal;
    private int _hoveredPoint = -1;
    private int _badgeNudges;
    private string _color = StartColor;

    protected override Component? Render() =>
    [
        VueCard(),

        ReactSvelteCard(),

        LitCard(),

        SolidCard()
    ];

    private Component VueCard() =>
        Ui.Card.Class("mb-3")[
                H6.Class("font-bold")["A Vue island calling back into C#, in WebAssembly"],
                P.Class("text-sm text-zinc-500 dark:text-white/70")[
                    Code["VueChart.vue"], " builds unchanged on either host. Clicking a bar ",
                    "re-enters C# — here through a ", Code["[JSExport]"], " call into this tab's ",
                    "runtime rather than over a socket."
                ],

                VueChart.Series(_series).Heading("Revenue by month").OnBarClick(BarClicked),

                P.Class("text-sm mt-3 mb-0")[
                    "Last bar clicked: ",
                    Code.Id("island-last-clicked")[_lastClicked == 0 ? "(none)" : _lastClicked.ToString(CultureInfo.InvariantCulture)],
                    Span.Class("ms-2")["after "],
                    Code.Id("island-clicks")[_clicks],
                    Span[" click(s)"]
                ]
            ];

    private Component ReactSvelteCard() =>
        Ui.Card.Class("mb-3")[
                H6.Class("font-bold")["React and Svelte keeping their own state"],
                P.Class("text-sm text-zinc-500 dark:text-white/70")[
                    "Both hold state C# never sees. Raising the reading re-renders this component, and ",
                    "the counters below have to survive it — a remount would reset them, and nothing ",
                    "else on the page would look any different. Inside the React counter are a colour ",
                    "picker and its hex field straight from npm, nested as child islands: no ", Code[".tsx"],
                    " of their own, their steps generated from the package's TypeScript."
                ],

                ReactCounter.Caption("Clicks since mount").Step(_step).OnTotal(TotalChanged)[
                    "Pick a colour: ",
                    Colorful.HexColorPicker.Color(_color).OnChange(ColorChanged),
                    Colorful.HexColorInput.Color(_color).Prefixed(true).OnChange(ColorChanged)
                ],

                Div.Class("mt-3")[
                    SvelteMeter.Value(_reading).Label("Capacity")
                ],

                Div.Class("flex gap-2 mt-3")[
                    Ui.Button.Primary.Id("island-raise").OnClick(Raise)["Raise the reading"],
                    Ui.Button.Id("island-reset").OnClick(Reset)["Reset"]
                ],

                P.Class("text-sm mt-3 mb-0")[
                    "React reported a total of ",
                    Code.Id("island-react-total")[_reactTotal],
                    Span[" back to C#, and the picker the colour "],
                    Code.Id("island-color")[_color],
                    Span["."]
                ]
            ];

    private Component LitCard() =>
        Ui.Card.Class("mb-3")[
                H6.Class("font-bold")["A Lit island, beside this app's own scoped TypeScript"],
                P.Class("text-sm text-zinc-500 dark:text-white/70")[
                    Code["LitBadge.ts"], " imports nothing at all — a custom element needs no ",
                    "framework and no npm package. It sits in a project that also has scoped ",
                    "TypeScript, which is spelled identically; the build tells them apart by reading ",
                    "the C# base class."
                ],

                LitBadge.Label("Capacity").Value(_reading).OnNudged(BadgeNudged),

                P.Class("text-sm mt-3 mb-0")[
                    "The badge reported ",
                    Code.Id("island-badge-nudges")[_badgeNudges],
                    Span[" nudge(s) back to C#."]
                ]
            ];

    private Component SolidCard() =>
        Ui.Card.Class("mb-3")[
                H6.Class("font-bold")["A Solid island, keeping its own state"],
                P.Class("text-sm text-zinc-500 dark:text-white/70")[
                    Code["SolidSpark.tsx"], " knows nothing of the host it runs on. Its hover count ",
                    "belongs to Solid and C# never sees it, so raising the reading has to reach it as a ",
                    "prop change rather than a remount."
                ],

                SolidSpark.Readings(_readings).Caption("Throughput").OnPointHovered(PointHovered),

                P.Class("text-sm mt-3 mb-0")[
                    "Last point hovered: ",
                    Code.Id("island-hovered")[_hoveredPoint < 0 ? "(none)" : _hoveredPoint.ToString(CultureInfo.InvariantCulture)]
                ]
            ];

    private void BarClicked(int value)
    {
        _lastClicked = value;
        _clicks++;
    }

    private void TotalChanged(int total) => _reactTotal = total;

    private void ColorChanged(string color) => _color = color;

    private void PointHovered(int index) => _hoveredPoint = index;

    private void BadgeNudged(int nudges) => _badgeNudges = nudges;

    private void Raise()
    {
        _reading = Math.Min(100, _reading + 15);
        _step++;

        for (var i = 0; i < _series.Count; i++)
        {
            _series[i] = _series[i] with { Value = Math.Min(100, _series[i].Value + 4) };
        }

        for (var i = 0; i < _readings.Count; i++)
        {
            _readings[i] = Math.Min(100, _readings[i] + 5);
        }
    }

    private void Reset()
    {
        _reading = 40;
        _lastClicked = 0;
        _clicks = 0;
        _step = 1;
        _reactTotal = 0;
        _hoveredPoint = -1;
        _badgeNudges = 0;
        _color = StartColor;

        _readings.Clear();
        _readings.AddRange([12, 30, 22, 48, 35, 61]);

        _series.Clear();
        _series.AddRange([new("Jan", 38), new("Feb", 64), new("Mar", 51), new("Apr", 82)]);
    }
}
Live result
A Vue island calling back into C#, in WebAssembly

VueChart.vue builds unchanged on either host. Clicking a bar re-enters C# — here through a [JSExport] call into this tab's runtime rather than over a socket.

Last bar clicked: (none)after 0 click(s)

React and Svelte keeping their own state

Both hold state C# never sees. Raising the reading re-renders this component, and the counters below have to survive it — a remount would reset them, and nothing else on the page would look any different. Inside the React counter are a colour picker and its hex field straight from npm, nested as child islands: no .tsx of their own, their steps generated from the package's TypeScript.

React reported a total of 0 back to C#, and the picker the colour #c026d3.

A Lit island, beside this app's own scoped TypeScript

LitBadge.ts imports nothing at all — a custom element needs no framework and no npm package. It sits in a project that also has scoped TypeScript, which is spelled identically; the build tells them apart by reading the C# base class.

The badge reported 0 nudge(s) back to C#.

A Solid island, keeping its own state

SolidSpark.tsx knows nothing of the host it runs on. Its hover count belongs to Solid and C# never sees it, so raising the reading has to reach it as a prop change rather than a remount.

Last point hovered: (none)

Confirm