Skip to content

JavaScript Interop Lifecycle

In this page

Rules for safe JavaScript interop lifecycle management in Blazor components.

Where this lives in the code

  • src/MediaEngine.Web/wwwroot/js
  • src/MediaEngine.Web/Components
  • src/MediaEngine.Web/Services/Playback

Dashboard components that register JavaScript callbacks must also unregister them.

Pattern

  1. JavaScript register... functions should replace an existing handler or return/store a handle that can be passed to unregister....
  2. JavaScript unregister... or dispose... functions remove event listeners, clear timers, cancel animation frames, close channels that are owned by that registration, and clear stored .NET references.
  3. Razor components that register listeners or create DotNetObjectReference should implement IAsyncDisposable.
  4. DisposeAsync calls the JavaScript unregister function and then disposes the DotNetObjectReference.
  5. Ignore JSDisconnectedException during disposal. Log other disposal failures at Debug when a logger is available.

Current High-Risk Registrations

  • Ctrl+K command palette: registerCtrlK / unregisterCtrlK.
  • Media tile hover: registerMediaTileHover / unregisterMediaTileHover.
  • Listen playback callbacks: configure, registerStateHandler, registerCommandHandler, registerAudioStateObserver, registerPlayerShortcuts, and popup unload registration have paired unregister methods where they register listeners or .NET references.
  • EPUB reader and Cytoscape registrations expose dispose/destroy methods and should keep using them from component disposal.

Route-Scoped Script Loading

Large, single-route scripts are not loaded globally from App.razor. Cytoscape (lib/cytoscape/cytoscape.min.js + js/cytoscape-interop.js) is loaded on demand by the Chronicle Explorer page, through the shared window.lazyLoad.loadScript(src) helper in js/lazy-load.js (dedupes by src, preserves insertion order for UMD bundles). Pages that lazy-load a script must guard their disposal calls behind a loaded flag so navigating away before the script loads does not throw.

Avoid adding fire-and-forget JavaScript cleanup from synchronous Dispose; use IAsyncDisposable when JS interop is involved.

Render updates and element ownership

Use the rendered element’s identity and the relevant binding inputs to decide when registration is required. Unrelated renders must not recreate listeners or observers. Element replacement, portal reopening, and a changed callback owner can still require registration. Keep genuine state updates separate from registration; native playback timing and focus reconciliation must remain current.

AppSelect caches its playback binding by root, popover class, accessible label, and parent owner. Its intrinsic-sizing helper retains one ResizeObserver and updates the selected label in place. PlaybackIdentityLink retains its listener for the current anchor while refreshing the rendered snapshot on every render, so activation still uses current playback authority. Disposal releases the owned registration.

See the CSS and interop cleanup evidence for measured calls, retained registrations, and verification limits.

Listen Playback Storage

The Listen playback bridge stores only Web client mechanics under tuvima.playback.v2.* localStorage keys:

  • tuvima.playback.v2.state
  • tuvima.playback.v2.command
  • tuvima.playback.v2.device-id

Do not read the retired listen-playback-state or listen-playback-command keys. Browser timing and popup defaults are injected from config/ui/playback-client.json through listenPlayback.configure(...); user listening preferences stay in the playback settings API.

Native control hosts

The shared popup, dialog, toast, select and autocomplete components own their JavaScript registrations and release them from DisposeAsync. Register against the rendered element and current callback owner, update relevant binding state, and preserve portal release/focus restoration before removing popup content. Native dialogs use the browser modal top layer; popup placement must follow the active modal/fullscreen owner rather than relying on a larger z-index. Nested Escape handling closes only the permitted top interaction and still honors the editor’s unsaved-change guard. Native temporary drawers use native-structure.js to pair showModal/close and return focus while reserving actual app-bar and playback/navigation dock offsets.

Use scripts/visual-qa/css-ownership/keyboard.mjs with an existing documented CUA tab for declared key/click actions and semantic focus/control traces. It does not launch a browser or expose a debugging connection. Record actual interaction coverage; unit tests of the helper are not browser keyboard evidence.

Application-owned dialog dismissal

app-dialog.js sets closedby="none" and intercepts Escape in the topmost managed dialog before a browser close request can run. The cancel event alone cannot protect a draft: repeated Escape can produce a non-cancelable close request. The listener respects composition, native picker controls and first-party popovers. An editing field declares data-app-escape-owner="field"; its key still reaches the Blazor handler while native dialog dismissal is prevented. Capture runs before field handlers, so checking defaultPrevented alone cannot establish ownership.

The last update instruction supplies the expected open state. If a native close arrives while that state remains open, the module reopens the dialog without taking another scroll lock or replacing its opener, restores its inner focus and asks the .NET close guard when Escape dismissal is enabled. Cancellation requests are coalesced while pending. An intentional close or detach clears expected state, releases the lock and restores eligible opener focus. Reopening retains popup/toast top-layer ordering. Keep editing/Discard remains the editor’s responsibility.

route-smoke.mjs checks every source-derived Settings destination and representative fixture routes using an existing authenticated browser tab. It checks the expected canonical path, loaded page content and heading as well as the shell and error states. Prefer ordinary visible navigation to retain the Blazor circuit; full document loads can exhaust the fixture’s connection limiter even with spacing.

Book reader module (foliate-js)

js/book-reader.js is the only file that touches the book. Components/Pages/BookReader.razor (shown by the /read/{AssetId} route for every EPUB) owns the chrome and the saved position; it calls the module’s open, setAppearance, next, prev, goTo, getLocation, getStats and dispose, and the module calls back through one DotNetObjectReference (OnRelocated, OnToggleChrome, OnEscape).

  • One adapter. The pinned foliate-js copy lives in wwwroot/lib/foliate-js/ (see its PINNED.md for the exact commit and what was left out) and is only edited by the one documented safety patch. Nothing but js/book-reader.js imports it; BookReaderGuardrailTests enforces this, so a library upgrade is one file plus the pin.
  • The browser reads the book, not the Engine. The module opens the EPUB straight from the Dashboard book connection (/engine-book/{id}/file) with HTTP range requests, so only the parts being read are downloaded and the Engine never unpacks the book. Chapters, pictures and styles are turned into in-memory blob: addresses by the library, so they never become separate authenticated requests and a missing resource inside a book is a plain missing picture, not a sign-in error.
  • Books cannot run code. Two independent layers. (1) The book frames are sandboxed with allow-same-origin only (the pinned foliate-js is patched, recorded in lib/foliate-js/PINNED.md, to take the value from the adapter), so the browser itself refuses to run any script in a book frame, except on Apple WebKit (iPhone, iPad, Safari): there WebKit does not deliver events from a script-less sandboxed frame (bug 218086), so the adapter’s needsScriptSandbox() gives those devices allow-same-origin allow-scripts. With scripts allowed the frame could in principle remove its own sandbox, so on Apple devices the second layer below (page clean-up) and the page’s script-src 'none' policy are the only barrier; the WebKit run of scripts/visual-qa/book-reader/run-checks.mjs tests inline script, handlers, javascript: links and frames, nested frames and objects, SVG script, outside fetches and a page that deletes its own policy, and none may run or reach the network. (2) The adapter hardens every resource before it becomes a blob: address, whatever its type is called: types are compared only in their plain form (application/xhtml+xml; charset=utf-8 is application/xhtml+xml, and the manifest is normalised before loading); the book’s own pages must be XHTML, HTML or SVG and are always cleaned, whether they arrive as text or a Blob (scripts, inline handlers, javascript: addresses, frames, objects, embeds, noscript/xmp, meta refresh, processing instructions such as XSLT and the DOCTYPE removed; outside addresses removed; a Content-Security-Policy meta with script-src 'none', connect-src 'none', frame-src 'none' and pictures, fonts and media limited to blob: and data:); SVG, which cannot carry a policy, is rebuilt from an allow-list (SVG namespace only) and shown inside a policy-carrying XHTML page; any other page type, or a page that cannot be parsed, becomes a plain notice. Other resources must be picture, font, sound, video or style-sheet types or they are served as opaque downloads. Script items are never loaded. Only http/https/mailto links open, in a new tab with noopener; a tap never loads an outside page in the book frame. The clean-up and the page’s click handler (guardLinks, which runs before the library’s own) agree on what a link is: only an <a> is a link, and it keeps its href only for a place in the book or http/https/mailto; on any other element href is removed unless it starts with #, blob: or data:image (MathML elements are live links in WebKit); image maps (map, area) and template elements (a shadowrootmode template becomes a live shadow root when the frame reads a text/html page) are removed; addresses are read the way the browser reads them (tabs, newlines and leading control characters ignored). The click handler looks at the whole click path, shadow roots included, and cancels the default action of anything with an href other than a plain in-book a[href]; every other address is dead and only http/https/mailto anchors are handed to the opener. Each layer is tested with the other switched off in run-checks.mjs. A book has nowhere to submit a form to, so forms are cut off in the same two places and do not rely on the page policy (form-action 'none') and the frame sandbox (no allow-forms) alone: the clean-up unwraps every <form> (its contents stay) and removes every action and formaction attribute, in pages and in SVG, and guardLinks cancels every submit on the page; the form fixtures (form action, button formaction, <input type="image">, a form in a text/html page) are run with the sandbox’s allow-forms added and the page policy removed so that each of the two layers is shown to hold on its own, in Chromium and WebKit. The MathML link fixture is asserted live (it must move an unprotected frame) in WebKit, the only engine that follows a MathML href; Chromium ignores it, and the checks say so. A link written with any namespace prefix (XLink q:href in inline SVG) is turned into a plain href first, so the reader sees and intercepts it, and one with a script address or on anything but a link is removed; animation elements (animate, set, animateMotion, animateTransform) are removed from every page because they can set an address after load. A page is shown only when its root is the XHTML html element, and its Content-Security-Policy meta is the first thing in a head that is a direct child of that root (a head inside SVG, nested, or in another namespace does not count; the page is refused if that cannot be true).
  • A changed file is detected. The first range answer’s ETag (or Last-Modified when the ETag is weak) is sent as If-Range with every later request, and each answer’s Content-Range start and total are checked. A full answer, a different size or a different ETag means the book was replaced while it was being read, which is reported to the page (OnFailed("changed")) and shown as a recoverable error with Try again, rather than mixing old and new bytes.
  • Settings. The script reads the per-device tuvima-reader-settings entry itself (loadSettings), so the page does not deserialize stored JSON.
  • Disposal. DisposeAsync first asks the module for the latest position and saves it, then calls dispose (aborts in-flight range requests, removes the view and every listener, revokes blob URLs), disposes the module reference, then the DotNetObjectReference. JSDisconnectedException is ignored.
  • Position. The saved place is an EPUB CFI sent through the existing progress call as the cfi extended property (with reader=foliate); progress_pct is the book fraction. A book that was never read opens at the start.
  • Contents, highlights and settings (BR3). getToc returns the contents flattened as { label, href, level } and goToHref opens an entry. setHighlights, addHighlight, recolorHighlight and removeHighlight keep one map of saved highlights ({ id, cfi, color }); they are drawn as foliate overlays and redrawn each time a section is loaded, so they survive text-size changes and page turns. A selection inside a book frame is reported after a short pause as OnSelection(cfi, text, x, y, w, h) (screen coordinates; OnSelectionCleared when it ends), a tap on a highlight as OnHighlightTapped(id, x, y, w, h) and does not turn the page. setInputPaused(true) stops taps and page keys while a panel or the highlight editor is open (Escape still works); typing in a note box and arrow keys on bar/panel buttons are never taken by the page, so a TV remote can move between controls. saveSettings writes only this reader’s keys (Theme, FontSize, LineHeight, ReaderFont, PageWidth, Flow) into tuvima-reader-settings; loadSettings ignores values it does not recognise. Fonts are system stacks only, because book frames cannot load web fonts.
  • Browser check. scripts/visual-qa/book-reader/ holds the real-browser rig (fixtures, local server, checks, size measurements). It is the evidence for the behaviours above; the .NET tests only guard the shape.