Integrate a view framework

What a widget controller does for a view framework, shown by a widget written without one, in plain HTML, with a client controller and a server controller; then how the supplied React 19, Vue and Svelte adapters do the same, and what a new adapter must own.

  • Availability: Experimental
  • Evidence: Recorded run
  • How-to guide

Compilation and rendering are two extensions

A processor of Beyond Packages teaches the compiler a source format: the supplied ts bundler compiles .ts/.tsx sources, .vue and .svelte single-file components and .css/.scss stylesheets. A controller adapter teaches Widgets how to mount, hydrate, refresh and unmount a view of a framework. A framework written in plain JavaScript needs only the adapter; a component syntax needs a processor too. Read Create a bundler or a processor for the compiler side; this page is the controller side.

A widget without a view framework

The module declares one entry point per platform, and both entry points export a Controller:

JSONmodule.json
{
	"platforms": ["web", "node"],
	"widget": {
		"element": { "name": "hello-html", "attrs": ["label"] }
	},
	"conditionals": {
		"web": { "entry": "client.ts", "excludes": ["server.ts"] },
		"node": { "entry": "server.ts", "excludes": ["client.ts"] }
	}
}

The markup is one function used on both sides, so the browser can keep what the server rendered:

TypeScriptmarkup.ts
import { label } from '@testbed/boundaries/shared';

/**
 * The markup of the widget, written the same way by the server and by the browser, so that the browser
 * can keep what the server rendered and only attach its behavior to it
 */
export const markup = (title: string, count: number): string =>
	`<div class="html"><h2 class="title">${title}</h2><p class="shared-label">${label('HTML count', count)}</p><button class="counter">Add</button></div>`;

The client controller extends WidgetClientController. Everything a framework adapter does is visible here: mount writes the markup into the holder of the element unless server markup is already there, links the stylesheets the styles manager collected inside the root and reports each one as it loads, and attaches its behavior; unmount releases the listener, the subscription and the markup.

TypeScriptclient.ts
import { WidgetClientController } from '@beyond-js/widgets/controller';
import { markup } from './markup';

/**
 * A widget without a view framework: the controller writes its markup into the holder of the element and
 * attaches its behavior with plain DOM APIs. It shows what a framework adapter does for a widget: mount the
 * view, adopt the stylesheets the styles manager collects, keep server markup on hydration, refresh after
 * an update of the module and release everything on unmount.
 */
export class Controller extends WidgetClientController {
	#count = 0;
	#listener: () => void;

	#mounted = false;
	get mounted() {
		return this.#mounted;
	}

	get #holder(): HTMLElement {
		return (<any>this.widget).holder;
	}

	get #title(): string {
		return this.attributes.get('label') ?? '';
	}

	/**
	 * A link for each stylesheet of the widget, inside its root, reported to the manager as it loads
	 */
	#stylesheets = () => {
		const root = this.#holder.parentNode as ShadowRoot;
		for (const href of this.styles.resources.keys()) {
			if (root.querySelector(`link[href="${href}"]`)) continue;
			const link = document.createElement('link');
			link.rel = 'stylesheet';
			link.href = href;
			link.onload = () => this.styles.onloaded(href);
			link.onerror = () => this.styles.onerror(href);
			root.insertBefore(link, this.#holder);
		}
	};

	mount() {
		if (this.#mounted) return;
		this.#mounted = true;

		const holder = this.#holder;
		this.#stylesheets();
		this.styles.on('change', this.#stylesheets);

		// Server markup is kept as it is; a fresh mount writes the markup itself
		holder.children.length || (holder.innerHTML = markup(this.#title, this.#count));
		holder.style.display = '';

		this.#listener = () => {
			this.#count++;
			holder.querySelector('.shared-label').textContent = markup(this.#title, this.#count).match(/<p class="shared-label">([^<]*)<\/p>/)[1];
		};
		holder.querySelector('button.counter').addEventListener('click', this.#listener);
	}

	unmount() {
		if (!this.#mounted) return;
		this.#mounted = false;

		this.styles.off('change', this.#stylesheets);
		this.#holder.querySelector('button.counter')?.removeEventListener('click', this.#listener);
		this.#holder.innerHTML = '';
	}
}

The server controller extends WidgetServerController and returns the same markup as a string, with a link for each stylesheet of the dependencies of the module:

TypeScriptserver.ts
import { WidgetServerController } from '@beyond-js/widgets/controller';
import type { IWidgetRendered } from '@beyond-js/widgets/controller';
import { markup } from './markup';

/**
 * The server side of the widget: the same markup as a string, with a link for each stylesheet the
 * dependencies of the module register, which the browser hydrates
 */
export class Controller extends WidgetServerController {
	render(props: { attributes?: Map<string, string> }): IWidgetRendered {
		const title = props.attributes?.get('label') ?? '';
		const links = this.styles.map(href => `<link rel="stylesheet" href="${href}">`).join('');
		return { html: `${links}${markup(title, 0)}` };
	}
}
CSShtml.css
.title {
	color: rgb(124, 45, 18);
	margin: 0;
}

What the element does around the controller is the same for every adapter:

  1. Construction opens the shadow root and starts importing the module of the widget.
  2. On the first connection, the server and static rendering paths complete before the controller is constructed, so what they put in the holder is what mount hydrates.
  3. initialise creates the store, hydrates it from server data, renders, and subscribes to the updates of the module and to the stylesheets of its dependencies.
  4. An update of the module, or of a module it imports, calls refresh, which by default unmounts and mounts again; an adapter can do better, as React does.
  5. Disconnection calls disconnect, which unmounts and releases the subscriptions; connecting the element again mounts the same controller, with its store and attributes, in the same holder.

What the supplied adapters own

Adapter Mount and hydrate Refresh after an update Unmount
React 19 (@beyond-js/react-19-widgets/base) createRoot in an empty holder, hydrateRoot over server markup The root component renders again with the current view; state survives when the view module did not change root.unmount(), synchronously
Vue (@beyond-js/vue-widgets/base) createApp or createSSRApp, mounted in the holder The root component keys the view by a version, so the view is created again app.unmount()
Svelte 5 (@beyond-js/svelte-widgets/base) mount or hydrate from svelte The root component renders the view inside {#key version} unmount

Each adapter has a root component that renders the stylesheet links of the widget beside the view and shows the view once the sheets are loaded, and a server controller that renders the view to a string with react-dom/server, vue/server-renderer or svelte/server.

Writing a new adapter

  • Own the framework: a client controller with mount, unmount and refresh, and a server controller with render(props) returning { html, errors? }. Keep one public identity for both, with one entry per platform in the manifest, as the supplied adapters do.
  • Mount into widget.holder, never into the shadow root directly, and keep server markup on hydration.
  • Render the links of styles.resources inside the root, report styles.onloaded(url) and styles.onerror(url), and subscribe to styles.on('change') for replacements.
  • Release everything on unmount: the framework root, listeners and subscriptions. The element mounts the same controller again on reconnection.
  • Do not make the compiler part of the adapter: a component syntax is a processor of the bundler, and its output must keep the public identity, the bare references, the source maps and the separate stylesheet of the module.

Next

Embed the widget in a page built without Beyond: Embed a widget in an existing page. Run it on a server: Create a modular execution environment.