js

package
v0.7.2 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 18, 2026 License: BSD-3-Clause Imports: 25 Imported by: 0

Documentation

Overview

Package js runs a page's JavaScript against a minimal but real DOM binding on a pure-Go goja ECMAScript runtime. It is deliberately not a full WHATWG implementation: the goal is progressive-enhancement and light hydration — class toggling, attribute/style mutation, small tree edits — so that the subsequent CSS cascade and layout see what a real browser would.

All mutations write through to the shared dom.Node tree. Execution is bounded by a wall-clock budget and a script error never propagates: it is caught, optionally logged, and the render continues.

Index

Constants

View Source
const DefaultTimeout = 5 * time.Second

DefaultTimeout is the script budget when Options.Timeout is zero.

Variables

This section is empty.

Functions

func MarkJSEnabled

func MarkJSEnabled(root *dom.Node)

MarkJSEnabled sets the client-js signal on the document root (swapping the client-nojs no-JS fallback for client-js on <html>) without running any script. The engine calls it before the INITIAL cascade so the first layout — the geometry scripts read back — already reflects a JS-enabled browser.

Types

type EventInit added in v0.4.0

type EventInit struct {
	Bubbles    bool
	Cancelable bool
	Key        string // keydown / keyup
	Data       string // input
	InputType  string // input
}

EventInit carries the extra fields a synthetic event needs beyond type/target — which ones matter depends on typ, mirroring the real DOM (Key for keydown/keyup, Data/InputType for input). Bubbles/Cancelable default to false; a caller sets them per the real event's own defaults (e.g. click/keydown/keyup/input/change/submit all bubble in a real browser — Dispatch does not assume this for you, since it also serves dispatchEvent-style callers that want an exact, spec-shaped event).

type Metrics

type Metrics interface {
	// Rect returns the used border-box rectangle (document coords, CSS px) of n.
	// ok is false when n was not laid out (display:none or detached).
	Rect(n *dom.Node) (x, y, w, h float64, ok bool)
	// Computed returns the resolved used value of a CSS property (lower-case,
	// hyphenated — e.g. "width", "display", "margin-top", "font-size"). ok is
	// false for a property the resolver does not model, matching a browser
	// returning "" for an unsupported computed property.
	Computed(n *dom.Node, prop string) (string, bool)
}

Metrics supplies real used geometry and resolved (used) style values from a completed cascade+layout pass. The DOM binding reads it so that getBoundingClientRect / offset* / client* / scroll* and window.getComputedStyle answer with real numbers instead of zeros — the signal responsive scripts and MediaWiki's mw.loader consult to decide what to collapse or reveal. A nil Metrics (the legacy js.Run path) makes those APIs report zeros / inline styles, exactly as before.

type Options

type Options struct {
	// PageURL is the document's final URL, used to resolve script src and to
	// populate window.location.
	PageURL string
	// UserAgent is exposed as navigator.userAgent and sent when fetching scripts.
	UserAgent string
	// Client fetches external <script src>. When nil, external scripts are skipped
	// (inline scripts still run).
	Client *http.Client
	// Ctx bounds network fetches; its cancellation also stops script execution.
	Ctx context.Context
	// Timeout is the total wall-clock budget for all scripts and drained timers.
	// Zero selects DefaultTimeout.
	Timeout time.Duration
	// ViewportWidth / ViewportHeight back window.innerWidth / innerHeight.
	ViewportWidth  int
	ViewportHeight int
	// DevicePixelRatio backs window.devicePixelRatio and the `resolution`
	// feature of matchMedia (CSSOM View; Media Queries 4 §5.1). 0 means 1.
	DevicePixelRatio float64
	// Log, if non-nil, receives console.* output as "level: message" lines.
	Log func(string)
}

Options configures a Run.

type Result

type Result struct {
	ScriptsRun    int   // scripts that executed (inline + fetched)
	ScriptsFailed int   // scripts that threw or failed to compile
	TimersRun     int   // queued timer/animation callbacks drained
	Err           error // first fatal setup error (nil on normal completion)
}

Result reports what a Run did (used by tests and by the engine's logging).

func Run

func Run(root *dom.Node, opt Options) Result

Run builds the DOM binding on root (a dom.Document node), sets the JS-enabled signal, executes the page's scripts in document order, drains queued timers, and dispatches DOMContentLoaded/load. It never returns a script error as fatal; Result.Err is only set for setup failures. It is the one-shot, no-layout-feedback entry point (used by the js package's own tests); the engine drives a Session directly so scripts can read real geometry.

type Session

type Session struct {
	// contains filtered or unexported fields
}

Session is a live page-script execution bound to one DOM tree and one goja runtime. Unlike the one-shot Run, it stays resident across layout passes: the engine lays the page out, feeds the geometry in via SetMetrics, runs scripts (which may read that geometry and mutate the DOM / inject <script>/<style>), re-lays-out, and runs any newly-injected scripts — iterating to a bounded fixpoint before the final paint.

func Begin

func Begin(root *dom.Node, opt Options) *Session

Begin builds the DOM/BOM binding on a fresh runtime, sets the client-js signal, and starts the wall-clock/interrupt watchdog. The caller MUST call Close to release the watchdog goroutine. A binding-setup panic is contained; Begin always returns a usable (possibly inert) Session.

func (*Session) Close

func (s *Session) Close()

Close stops the watchdog. It is idempotent.

func (*Session) Dispatch added in v0.4.0

func (s *Session) Dispatch(n *dom.Node, typ string, init EventInit) (defaultPrevented bool)

Dispatch fires a synthetic DOM event of type typ on n — the seam a host outside this package uses to synthesize real user interaction (focus, a keystroke, a click) against the live session, exactly as if the page's own script had called element.dispatchEvent(). It bubbles per init. Bubbles (see binder.dispatch) and reports whether a listener called preventDefault(), so a caller (e.g. native form submission) can honor it. Contained against a listener panic, like every other script execution path in this package.

func (*Session) DispatchLifecycle added in v0.4.0

func (s *Session) DispatchLifecycle()

DispatchLifecycle fires DOMContentLoaded then load (draining queued timers between and after) — the second half of RunInitial, split out so the engine's settle loop (dynamic.go) can bundle and run the page's ES module graph, via RunPending, BETWEEN the two halves rather than after both.

Real-world module loaders commonly gate their own setup on window.addEventListener("load", ...) (confirmed live: pkg.go.dev's own frontend.js does exactly this to show its cookie-consent banner and wire up several other widgets) — a real, standard, one-shot browser event that never fires twice. Before this split, RunInitial fired it unconditionally right after the page's CLASSIC scripts ran, before dynamic.go had even bundled the page's <script type="module"> graph (goja cannot run ES modules natively, so they are transpiled to one classic script and injected several lines later, in settle's own module-bundling step) — so a module's own "load" listener always registered too late, after the one-and-only event had already come and gone, and so it never ran, regardless of how fast or slow that module's own network fetch was. Running the module-bundle pass first, and firing "load" only afterwards, gives such a listener the same chance to register in time that it has in a real browser (where an ES module fetched from the same origin typically completes well before the page's OWN "load" event, which additionally waits on slower resources this engine's JS timeline does not model, such as images).

func (*Session) Eval added in v0.4.0

func (s *Session) Eval(code string) (v interface{}, err error)

Eval runs code in this session's JS runtime and returns its value, converted to a plain Go value via goja's Export (string, float64, bool, nil, []interface{}, map[string]interface{}, ...). It exists for a host that needs to read a page's own JS-side state back out — e.g. a conformance harness reading testharness.js's own PASS/FAIL results — rather than only ever pushing input in via Dispatch.

A JS-level error (a thrown exception, a syntax error) comes back as an ordinary Go error; a runaway script still hits the session's own watchdog like any other script, since it runs on the same runtime.

func (*Session) Result

func (s *Session) Result() Result

Result returns the cumulative execution tally.

func (*Session) RunInitial

func (s *Session) RunInitial()

RunInitial executes every page <script> in document order, then dispatches DOMContentLoaded/load, draining queued timers/promises/XHR callbacks to quiescence within the budget. Contained against panics.

Callers with a module-bundle step of their own (the engine's settle loop, dynamic.go) should use RunInitialScripts and DispatchLifecycle separately instead — see DispatchLifecycle for why.

func (*Session) RunInitialScripts added in v0.4.0

func (s *Session) RunInitialScripts()

RunInitialScripts executes every page <script> in document order, WITHOUT dispatching DOMContentLoaded/load — the first half of RunInitial, split out for a caller that needs to do more work in between. See DispatchLifecycle.

func (*Session) RunPending

func (s *Session) RunPending() (ran bool)

RunPending executes any <script> elements inserted into the DOM since the last run (the core of a ResourceLoader-style dynamic loader) in document order, then drains the async loop. It reports whether at least one new script ran OR is now waiting to run — the latter matters because a callback drained just now (a timer, a promise) can itself inject a brand-new <script> (the standard webpack/Turbopack chunk-loading idiom: document.createElement ('script'); s.onload=...; s.src=chunkURL; document.head.appendChild(s)), which this call's own runScripts, having already run, never sees. Before this, such a script sat newly-appended in the DOM but ScriptsRun+ ScriptsFailed hadn't moved, so "ran" reported false and the caller's settle loop (dynamic.go) broke immediately instead of ever iterating again to give it a chance — confirmed live on react.dev: instrumentation showed the chunk-loader's own dynamically-created <script src="…f809.js"> WAS correctly created and appended to <head> with the right src, but its fetch was NEVER even attempted, because the settle loop had already stopped. Deliberately NOT executed inline here (an earlier version of this fix called runScripts a second time within this same call, which over-eagerly ran a whole SYNCHRONOUS self-replicating script chain in one pass instead of one generation per outer settle iteration, breaking TestSettleFixpointCap's bounded-passes guarantee) — reporting "pending" keeps the existing one-generation-per-pass pacing: the NEXT call to RunPending (from the next outer iteration) is what actually runs it.

func (*Session) SetMetrics

func (s *Session) SetMetrics(m Metrics)

SetMetrics installs (or replaces) the geometry/used-value source the DOM binding reads back. Call it after each layout pass, before running scripts.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL