In this page
Rules for safe JavaScript interop lifecycle management in Blazor components.
Where this lives in the code
src/MediaEngine.Web/wwwroot/jssrc/MediaEngine.Web/Componentssrc/MediaEngine.Web/Services/Playback
Dashboard components that register JavaScript callbacks must also unregister them.
Pattern
- JavaScript
register...functions should replace an existing handler or return/store a handle that can be passed tounregister.... - JavaScript
unregister...ordispose...functions remove event listeners, clear timers, cancel animation frames, close channels that are owned by that registration, and clear stored .NET references. - Razor components that register listeners or create
DotNetObjectReferenceshould implementIAsyncDisposable. DisposeAsynccalls the JavaScript unregister function and then disposes theDotNetObjectReference.- Ignore
JSDisconnectedExceptionduring 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.statetuvima.playback.v2.commandtuvima.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 itsPINNED.mdfor the exact commit and what was left out) and is only edited by the one documented safety patch. Nothing butjs/book-reader.jsimports it;BookReaderGuardrailTestsenforces 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-memoryblob: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-originonly (the pinned foliate-js is patched, recorded inlib/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’sneedsScriptSandbox()gives those devicesallow-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’sscript-src 'none'policy are the only barrier; the WebKit run ofscripts/visual-qa/book-reader/run-checks.mjstests 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 ablob:address, whatever its type is called: types are compared only in their plain form (application/xhtml+xml; charset=utf-8isapplication/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 withscript-src 'none',connect-src 'none',frame-src 'none'and pictures, fonts and media limited toblob:anddata:); 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. Onlyhttp/https/mailtolinks open, in a new tab withnoopener; 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 itshrefonly for a place in the book orhttp/https/mailto; on any other elementhrefis removed unless it starts with#,blob:ordata:image(MathML elements are live links in WebKit); image maps (map,area) andtemplateelements (ashadowrootmodetemplate becomes a live shadow root when the frame reads atext/htmlpage) 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 anhrefother than a plain in-booka[href]; every other address is dead and onlyhttp/https/mailtoanchors are handed to the opener. Each layer is tested with the other switched off inrun-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 (noallow-forms) alone: the clean-up unwraps every<form>(its contents stay) and removes everyactionandformactionattribute, in pages and in SVG, andguardLinkscancels everysubmiton the page; the form fixtures (formaction, buttonformaction,<input type="image">, a form in atext/htmlpage) are run with the sandbox’sallow-formsadded 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 MathMLhref; Chromium ignores it, and the checks say so. A link written with any namespace prefix (XLinkq:hrefin inline SVG) is turned into a plainhreffirst, 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 XHTMLhtmlelement, and its Content-Security-Policy meta is the first thing in aheadthat 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-Rangewith every later request, and each answer’sContent-Rangestart 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-settingsentry itself (loadSettings), so the page does not deserialize stored JSON. - Disposal.
DisposeAsyncfirst asks the module for the latest position and saves it, then callsdispose(aborts in-flight range requests, removes the view and every listener, revokes blob URLs), disposes the module reference, then theDotNetObjectReference.JSDisconnectedExceptionis ignored. - Position. The saved place is an EPUB CFI sent through the existing progress call as the
cfiextended property (withreader=foliate);progress_pctis the book fraction. A book that was never read opens at the start. - Contents, highlights and settings (BR3).
getTocreturns the contents flattened as{ label, href, level }andgoToHrefopens an entry.setHighlights,addHighlight,recolorHighlightandremoveHighlightkeep 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 asOnSelection(cfi, text, x, y, w, h)(screen coordinates;OnSelectionClearedwhen it ends), a tap on a highlight asOnHighlightTapped(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.saveSettingswrites only this reader’s keys (Theme,FontSize,LineHeight,ReaderFont,PageWidth,Flow) intotuvima-reader-settings;loadSettingsignores 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.