Skip to content

JavaScript DOM and Events

DooDesch edited this page Jul 27, 2026 · 1 revision

JavaScript, DOM and Events

🛟 Need help or found a bug? Get support at support.doodesch.de/sideload.

The language

Jint with every experimental feature on: ES2015 through ES2024. Classes with #private fields, optional chaining, nullish coalescing, destructuring, template literals, generators, async/await syntax, spread, replaceChildren, at(-1). Nothing is transpiled or polyfilled - write modern JavaScript.

Globals

document, s1, console, fetch, Promise, setTimeout, setInterval, clearTimeout, clearInterval.

That is the whole list. There is no window, no navigator, no localStorage - s1.storage replaces the last one. Timers are driven by the mod's update loop rather than by threads, so they fire on the Unity main thread and never race with your C#.

Engine limits

  • 250 ms budget per handler. A runaway loop is one hitched frame, not a hung game; the handler is aborted and the error is logged with file:line.
  • A page is refused before it is rendered if it exceeds 20000 elements or 200 nesting levels. Styling, tree building and painting are each recursive, and blowing the managed stack takes the process down before an error page could be shown.
  • One script engine per page. A reload drops it, so JS state and every listener start over - deliberately, because the script itself may be what changed.

document

document.getElementById('entry')
document.querySelector('.thread.on')
document.querySelectorAll('.bubble')
document.createElement('div')
document.addEventListener('back', fn)     // binds at <body>

element

el.textContent          el.className        el.id            el.value
el.appendChild(child)   el.replaceChildren()                 el.remove()
el.setAttribute(n, v)   el.getAttribute(n)  el.removeAttribute(n)
el.classList.add/remove/toggle/contains
el.style.setProperty(name, value)
el.querySelector(sel)   el.querySelectorAll(sel)
el.addEventListener(type, fn)
el.scrollToEnd()        // the one method the host adds and a browser does not have

replaceChildren() takes no arguments - it clears the element. Append the new children yourself.

Every mutation goes through these wrappers, so the renderer marks the subtree dirty itself and coalesces a frame's worth of changes into one rebuild. You never call a render function.

Events

Five types are dispatched, and no others. pointerdown, wheel, change, focus, blur, keyup and the rest do not fire, however plausible the name - registering them is silently useless.

Event Where Carries Raised when
click the element - the player clicks a wired element
input the control e.value the text in an input/textarea changed
keydown the control e.key === 'Enter', e.value Enter in a single-line field. Enter only
back document e.source, cancellable right-click or Escape
orientationchange document e.value the phone turned, after the page was laid out again

Events bubble to the document in registration order per node; stopPropagation() ends the walk. The event object carries type, target, currentTarget, value, key, source, defaultPrevented, preventDefault() and stopPropagation().

A hit target is only wired on elements that a state rule targets, that are button/a/input/textarea, or that the script has a click listener on. Everything else lets the pointer straight through, which is what keeps a page of forty rows from being forty raycast targets.

back: right-click and Escape

Both mean back, exactly as they do in the vanilla apps - right-click steps out of a conversation before it closes Messages. Taking the event is what keeps your app open:

document.addEventListener('back', (e) => {
  if (!deepInside()) return;   // nothing to go back to, so Sideload closes the app
  e.preventDefault();
  goUpOneLevel();
});

Not listening is fine and closes the app, which is the right behaviour for a single-screen app. An app with internal navigation that does not listen traps the player one level deep, so if you have panes, tabs or a detail view, handle this.

Check the orientation in that handler. In landscape a two-pane app usually shows both panes, so there is nothing to step back from and the press must close the app:

document.addEventListener('back', (e) => {
  if (s1.orientation !== 'portrait' || pane !== 'detail') return;
  e.preventDefault();
  showList();
});

Without that check the app silently switches a pane nobody can see and stops closing at all.

e.source is "rightClick" or "escape" for the rare page that must tell them apart. Most should not.

orientationchange

@media moves boxes. It cannot decide which pane the player should land on after a turn - that is a question about what they were just looking at, and only your script knows:

document.addEventListener('orientationchange', (e) => {
  if (e.value === 'portrait') showDetail();   // landscape had the detail on screen, so keep showing it
  render();
});

It fires after the page has been laid out at the new shape, so reading sizes in it is safe, and a change you make gets one more rebuild on the next tick.

console

console.log, .info, .warn, .error. Output goes to the MelonLoader log prefixed with your app id, and to an attached Chrome DevTools console. Errors are also kept for the F9 overlay.

Three patterns you will write anyway

Build an element in one expression. Almost every render line needs this shape:

const el = (tag, className, text) => {
  const node = document.createElement(tag);
  if (className) node.className = className;
  if (text != null) node.textContent = String(text);
  return node;
};

Bind per iteration, not after the loop. for (const x of xs) binds x per iteration, so a handler closes over that item rather than the loop's last one. A var loop does not.

Do not call scrollToEnd() on every render. The host captures and restores scroll offsets across a rebuild, so doing nothing keeps the reader's place. Call it only when the thing being read actually grew - remember the identity and item count you last pinned for, and return early when neither moved.

Clone this wiki locally