← UI Rendering & SSR

UI RENDERING & SSR FEATURE

Script Injection

Deferred blocks carry their own JS — injected once when the block arrives, never duplicated.

Feature Guide

A quick orientation block that answers the essential questions: what this feature does, how it works, why it matters, and the key concepts behind it.

What this does

Deferred blocks carry their own JS — injected once when the block arrives, never duplicated.

Client Modules

Inject block JS exactly once

Each deferred slot declares its client modules in the attribute, and the framework de-duplicates them across the page.

#[AsSlotResource(
    handle: 'demo_deferred_scripts',
    slot: 'deferred_chart_widget',
    template: '...chart-widget.html.twig',
    deferred: true,
    clientModules: ['@project-static-semitexa-demo/deferred/chart-widget.js'],
)]

The module initializes only after semitexa:block:rendered fires for the delivered block.

clientModules semitexa:block:rendered auto-play script isolation #[AsUiBehavior] inline script

Verified against Semitexa Ultimate 2026.09.19.1020

Script Injection

Deferred slots can declare client modules that are injected once when the block lands. Deferred blocks carry their own JS — injected once when the block arrives, never duplicated.

How it works

The clientModules key in #[AsSlotResource] declares the JavaScript module paths the deferred block needs. When the block HTML arrives over SSE, the framework injects those modules into the page exactly once. The module initializes only after semitexa:block:rendered fires for the delivered block.

#[AsSlotResource(
    handle: 'demo_deferred_scripts',
    slot: 'deferred_chart_widget',
    template: '...chart-widget.html.twig',
    deferred: true,
    clientModules: ['@project-static-semitexa-demo/deferred/chart-widget.js'],
)]

Key mechanisms

  • clientModules — declares the JS module paths the block needs, directly in the slot attribute.
  • semitexa:block:rendered — browser event fired after the block HTML has been inserted into the DOM.
  • auto-play — the framework handles deduplication, so the same module is not loaded twice even if the block appears multiple times.
  • script isolation — each block's scripts activate after its own rendered event, keeping timing scoped to the correct block.

Not an inline <script>

Writing the script inside the block's own template is the obvious thing to do, and it works — until the block starts arriving over SSE. Markup that arrives in a live document changes what a script tag means, in three ways that nothing announces:

  • It is inert. A script parsed out of a fragment and inserted does not run. It has to be re-created — and re-created with this document's CSP nonce, not the one it was parsed with, which under a strict script-src is the difference between running and being blocked on every arrival. A re-creation that carries the current nonce is fine; one that omits it, or copies the nonce from the response the fragment came from, is refused while the page otherwise looks like it works.
  • It runs more than once. Every arrival re-creates it, so every binding it makes has to be idempotent. A listener attached to document accumulates silently, once per delivery.
  • DOMContentLoaded has already fired. Code waiting for it never runs. A field-autocomplete partial that bound on that event simply stopped binding for any block that arrived by SSE.

Two mechanisms avoid all three, and lint:deferred-twig reports an inline <script> in a deferred template naming them (that is the template-compatibility lint; the similarly named lint:deferred-slots is a different check — it compares a slot's deferred: true against whether any page actually defers it):

  • clientModules for code that belongs to this block — a module served from your own origin, injected once, initialized on semitexa:block:rendered.
  • #[AsUiBehavior] for an interaction with no server state — a dropdown, a modal, tabs, a tooltip. The behavior runtime watches the document with a single MutationObserver and connects late-arriving markup wherever it appears, so timing stops being your problem.

make:page --with-assets scaffolds the module shape rather than an inline block for the same reason.

Why this matters

Without a structured mechanism, deferred blocks that need JavaScript require either page-level script includes (which load unconditionally) or runtime dynamic imports that need custom timing logic. Declaring clientModules on the slot attribute keeps the dependency co-located and the injection lifecycle automatic.

© Harold Abelson: "Programs must be written for people to read, and only incidentally for machines to execute."

Chart Slot Secondary page region
<?phpdeclare(strict_types=1);namespace App\Application\Resource\Slot;use Semitexa\Ssr\Attribute\AsSlotResource;#[AsSlotResource(    handle: 'dashboard',    slot: 'dashboard.sidebar',    template: '@project/dashboard/deferred-sidebar.html.twig',    deferred: true,)]final class DeferredSidebarSlot{    /**     * @param list<string> $items     */    public function __construct(        public readonly string $headline,        public readonly array $items,    ) {}}

Support Semitexa
Built for developers who prefer control over magic. Your support helps keep it fast, open, and evolving.

Donate via PayPal