///|
/// JavaScript Runtime - V8 implementation (native target)
///
/// Uses mizchi/v8 (rusty_v8 bindings) for real JavaScript execution
/// with full mock DOM support.
///|
pub struct V8JsRuntime {
mut initialized : Bool
mut dom_initialized : Bool
mut dom_synced : Bool
mut runtime : @v8.Runtime?
mock_dom_source : String?
id_map : Map[Int, @dom.NodeId]
}
///|
/// Create a V8JsRuntime without mock DOM (basic eval only).
pub fn V8JsRuntime::new() -> V8JsRuntime {
{
initialized: false,
dom_initialized: false,
dom_synced: false,
runtime: None,
mock_dom_source: None,
id_map: {},
}
}
///|
/// Create a V8JsRuntime with mock DOM source code.
/// The mock DOM JS will be evaluated on first execute() call.
pub fn V8JsRuntime::new_with_mock_dom(mock_dom_js : String) -> V8JsRuntime {
{
initialized: false,
dom_initialized: false,
dom_synced: false,
runtime: None,
mock_dom_source: Some(mock_dom_js),
id_map: {},
}
}
///|
/// Get V8JsRuntime as a JsRuntime trait object for use with JsContext.
pub fn V8JsRuntime::as_js_runtime(self : V8JsRuntime) -> &@js.JsRuntime {
self as &@js.JsRuntime
}
///|
/// Console setup for no-mock-DOM mode (basic eval only)
let console_setup_js : String = "const __logs = [];\nconst console = {\n log: (...args) => __logs.push(args.map(a => typeof a === 'object' ? JSON.stringify(a) : String(a)).join(' ')),\n warn: (...args) => __logs.push('[WARN] ' + args.map(a => String(a)).join(' ')),\n error: (...args) => __logs.push('[ERROR] ' + args.map(a => String(a)).join(' ')),\n info: (...args) => __logs.push(args.map(a => String(a)).join(' ')),\n debug: (...args) => {},\n};\nfunction __getLogs() { const l = [...__logs]; __logs.length = 0; return JSON.stringify(l); }"
///|
/// Logs setup for mock-DOM mode (mock DOM defines its own console that writes to `logs`)
let logs_bridge_js : String = "function __getLogs() { const l = [...logs]; logs.length = 0; return JSON.stringify(l); }"
///|
let element_internals_polyfill_js : String =
#|(function() {
#| if (typeof document === 'undefined' || typeof Element === 'undefined' || typeof DOMException === 'undefined') {
#| return;
#| }
#| function containsAsciiWhitespace(value) {
#| return /[\t\n\f\r ]/.test(value);
#| }
#| function syncCustomStates(host, states) {
#| if (!host) return;
#| host.__customStates = new Set(states);
#| if (host._mockId) {
#| domOps.push({
#| op: 'setCustomStates',
#| id: host._mockId,
#| value: Array.from(states).join(' ')
#| });
#| }
#| }
#| function CustomStateSet(host) {
#| this._host = host || null;
#| this._states = new Set();
#| if (host && host.__customStates instanceof Set) {
#| host.__customStates.forEach((state) => {
#| this._states.add(String(state));
#| });
#| }
#| }
#| CustomStateSet.prototype._validateState = function(value) {
#| const str = String(value);
#| if (str.length === 0) {
#| throw new DOMException('The custom state token provided must not be empty.', 'SyntaxError');
#| }
#| if (containsAsciiWhitespace(str)) {
#| throw new DOMException(
#| 'The custom state token provided contains HTML space characters, which are not valid in tokens.',
#| 'InvalidCharacterError'
#| );
#| }
#| return str;
#| };
#| Object.defineProperty(CustomStateSet.prototype, 'size', {
#| get() { return this._states.size; },
#| configurable: true
#| });
#| CustomStateSet.prototype.add = function(value) {
#| const state = this._validateState(value);
#| this._states.add(state);
#| syncCustomStates(this._host, this._states);
#| return this;
#| };
#| CustomStateSet.prototype.clear = function() {
#| this._states.clear();
#| syncCustomStates(this._host, this._states);
#| };
#| CustomStateSet.prototype['delete'] = function(value) {
#| const state = this._validateState(value);
#| const deleted = this._states.delete(state);
#| if (deleted) syncCustomStates(this._host, this._states);
#| return deleted;
#| };
#| CustomStateSet.prototype.has = function(value) {
#| return this._states.has(String(value));
#| };
#| CustomStateSet.prototype.forEach = function(callback, thisArg) {
#| this._states.forEach((value) => callback.call(thisArg, value, value, this));
#| };
#| CustomStateSet.prototype.keys = function() { return this._states.keys(); };
#| CustomStateSet.prototype.values = function() { return this._states.values(); };
#| CustomStateSet.prototype.entries = function() { return this._states.entries(); };
#| CustomStateSet.prototype[Symbol.iterator] = function() { return this.values(); };
#|
#| function ElementInternals(host) {
#| this._host = host || null;
#| this._validity = {};
#| this._validationMessage = '';
#| }
#| function resolveFormOwnerForSubmitter(element) {
#| if (!element || element.nodeType !== 1) return null;
#| const formAttr = element.getAttribute && element.getAttribute('form');
#| if (formAttr !== null && formAttr !== undefined && String(formAttr) !== '') {
#| return document.getElementById(String(formAttr));
#| }
#| return element.closest ? element.closest('form') : null;
#| }
#| function isSubmitButtonElement(element) {
#| if (!element || element.nodeType !== 1) return false;
#| const localName = String(element.localName || element._localName || element.tagName || '').toLowerCase();
#| const type = element.getAttribute ? String(element.getAttribute('type') || '').toLowerCase() : '';
#| if (localName === 'button') return type === '' || type === 'submit';
#| if (localName === 'input') return type === 'submit' || type === 'image';
#| return false;
#| }
#| function isResetButtonElement(element) {
#| if (!element || element.nodeType !== 1) return false;
#| const localName = String(element.localName || element._localName || element.tagName || '').toLowerCase();
#| const type = element.getAttribute ? String(element.getAttribute('type') || '').toLowerCase() : '';
#| if (localName === 'button') return type === 'reset';
#| if (localName === 'input') return type === 'reset';
#| return false;
#| }
#| function rememberInputDefaultValue(element) {
#| if (!element || !element.hasAttribute || !element.setAttribute) return;
#| if (element.hasAttribute('data-crater-default-value')) return;
#| const current = element.getAttribute ? element.getAttribute('value') : null;
#| element.setAttribute(
#| 'data-crater-default-value',
#| current === null || current === undefined ? '' : String(current),
#| );
#| }
#| function rememberTextAreaDefaultValue(element) {
#| if (!element || !element.hasAttribute || !element.setAttribute) return;
#| if (element.hasAttribute('data-crater-default-value')) return;
#| element.setAttribute(
#| 'data-crater-default-value',
#| element.textContent === null || element.textContent === undefined ? '' : String(element.textContent),
#| );
#| }
#| function rememberInputDefaultChecked(element) {
#| if (!element || !element.hasAttribute || !element.setAttribute) return;
#| if (element.hasAttribute('data-crater-default-checked')) return;
#| element.setAttribute('data-crater-default-checked', element.hasAttribute('checked') ? 'true' : 'false');
#| }
#| function rememberOptionDefaultSelected(element) {
#| if (!element || !element.hasAttribute || !element.setAttribute) return;
#| if (element.hasAttribute('data-crater-default-selected')) return;
#| element.setAttribute('data-crater-default-selected', element.hasAttribute('selected') ? 'true' : 'false');
#| }
#| function setBooleanAttribute(element, name, value) {
#| if (!element) return;
#| if (value) {
#| if (element.setAttribute) element.setAttribute(name, '');
#| } else if (element.removeAttribute) {
#| element.removeAttribute(name);
#| }
#| }
#| function collectSelectOptions(select) {
#| const items = [];
#| function walk(node) {
#| if (!node || !node.childNodes) return;
#| for (let i = 0; i < node.childNodes.length; i++) {
#| const child = node.childNodes[i];
#| if (!child || child.nodeType !== 1) continue;
#| const tag = String(child.localName || child._localName || child.tagName || '').toLowerCase();
#| if (tag === 'option') items.push(child);
#| else if (tag === 'optgroup') walk(child);
#| }
#| }
#| walk(select);
#| return items;
#| }
#| function resetFormControls(form) {
#| if (!form || !form.querySelectorAll) return;
#| const controls = form.querySelectorAll('input, textarea, select');
#| for (let i = 0; i < controls.length; i++) {
#| const control = controls[i];
#| const localName = String(control.localName || control._localName || control.tagName || '').toLowerCase();
#| if (localName === 'input') {
#| const type = control.getAttribute ? String(control.getAttribute('type') || '').toLowerCase() : '';
#| if (type === 'checkbox' || type === 'radio') {
#| const defaultChecked = control.getAttribute ? control.getAttribute('data-crater-default-checked') : null;
#| setBooleanAttribute(
#| control,
#| 'checked',
#| defaultChecked === null ? control.hasAttribute('checked') : defaultChecked === 'true',
#| );
#| } else if (
#| type !== 'submit' &&
#| type !== 'button' &&
#| type !== 'image' &&
#| type !== 'reset' &&
#| type !== 'file'
#| ) {
#| const defaultValue = control.getAttribute ? control.getAttribute('data-crater-default-value') : null;
#| control.setAttribute(
#| 'value',
#| defaultValue === null ? String(control.getAttribute('value') || '') : String(defaultValue),
#| );
#| }
#| } else if (localName === 'textarea') {
#| const defaultValue = control.getAttribute ? control.getAttribute('data-crater-default-value') : null;
#| control.textContent = defaultValue === null
#| ? (control.textContent === null || control.textContent === undefined ? '' : String(control.textContent))
#| : String(defaultValue);
#| } else if (localName === 'select') {
#| const options = collectSelectOptions(control);
#| let selectedIndex = -1;
#| for (let j = 0; j < options.length; j++) {
#| const option = options[j];
#| const defaultSelected = option.getAttribute ? option.getAttribute('data-crater-default-selected') : null;
#| const selected = defaultSelected === null
#| ? !!(option.hasAttribute && option.hasAttribute('selected'))
#| : defaultSelected === 'true';
#| setBooleanAttribute(option, 'selected', selected);
#| if (selected && selectedIndex < 0) selectedIndex = j;
#| }
#| if (selectedIndex < 0 && options.length > 0) {
#| setBooleanAttribute(options[0], 'selected', true);
#| }
#| }
#| }
#| }
#| function isValidationIgnoredInputType(type) {
#| switch (String(type || '').toLowerCase()) {
#| case 'hidden':
#| case 'button':
#| case 'reset':
#| case 'submit':
#| case 'image':
#| return true;
#| default:
#| return false;
#| }
#| }
#| function computeControlWillValidate(element) {
#| if (!element || element.nodeType !== 1) return false;
#| const localName = String(element.localName || element._localName || element.tagName || '').toLowerCase();
#| if (localName === 'button' || localName === 'fieldset' || localName === 'output') return false;
#| if (element.hasAttribute && (element.hasAttribute('disabled') || element.hasAttribute('readonly'))) return false;
#| if (localName === 'input') {
#| const type = element.getAttribute ? String(element.getAttribute('type') || '').toLowerCase() : '';
#| if (isValidationIgnoredInputType(type)) return false;
#| }
#| let current = element.parentNode || null;
#| while (current) {
#| const ancestorName = String(current.localName || current._localName || current.tagName || '').toLowerCase();
#| if (ancestorName === 'datalist') return false;
#| if (ancestorName === 'fieldset' && current.hasAttribute && current.hasAttribute('disabled')) return false;
#| current = current.parentNode || null;
#| }
#| return localName === 'input' || localName === 'textarea' || localName === 'select';
#| }
#| function findCheckedRadioInGroup(element) {
#| if (!element || !document || !document.querySelectorAll) return null;
#| const ownerForm = resolveFormOwnerForSubmitter(element);
#| const name = element.getAttribute ? String(element.getAttribute('name') || '') : '';
#| const radios = document.querySelectorAll('input');
#| for (let i = 0; i < radios.length; i++) {
#| const radio = radios[i];
#| if (!radio || radio === element) continue;
#| const type = radio.getAttribute ? String(radio.getAttribute('type') || '').toLowerCase() : '';
#| if (type !== 'radio') continue;
#| const radioName = radio.getAttribute ? String(radio.getAttribute('name') || '') : '';
#| if (radioName !== name) continue;
#| if (resolveFormOwnerForSubmitter(radio) !== ownerForm) continue;
#| if (radio.checked) return radio;
#| }
#| return null;
#| }
#| function computeRequiredValueMissing(element) {
#| if (!element || !element.hasAttribute || !element.hasAttribute('required')) return false;
#| const localName = String(element.localName || element._localName || element.tagName || '').toLowerCase();
#| if (localName === 'textarea') return String(element.value || '') === '';
#| if (localName === 'select') {
#| const options = collectSelectOptions(element);
#| const selected = options.find((option) => !!option.selected) || options[0] || null;
#| if (!selected) return true;
#| let value = selected.getAttribute ? selected.getAttribute('value') : null;
#| if (value === null || value === undefined) value = selected.textContent || '';
#| return String(value) === '';
#| }
#| if (localName === 'input') {
#| const type = element.getAttribute ? String(element.getAttribute('type') || '').toLowerCase() : '';
#| if (type === 'checkbox') return !element.checked;
#| if (type === 'radio') {
#| if (element.checked) return false;
#| return findCheckedRadioInGroup(element) === null;
#| }
#| if (isValidationIgnoredInputType(type) || type === 'file') return false;
#| return String(element.value || '') === '';
#| }
#| return false;
#| }
#| function computeValidationStateForElement(element) {
#| const validity = { valueMissing: false, customError: false, valid: true };
#| let message = '';
#| if (!computeControlWillValidate(element)) return { validity, message };
#| const customMessage = Object.prototype.hasOwnProperty.call(element, '__customValidationMessage')
#| ? String(element.__customValidationMessage || '')
#| : '';
#| if (customMessage !== '') {
#| validity.customError = true;
#| message = customMessage;
#| }
#| if (computeRequiredValueMissing(element)) {
#| validity.valueMissing = true;
#| if (message === '') message = 'Please fill out this field.';
#| }
#| validity.valid = !validity.valueMissing && !validity.customError;
#| if (validity.valid) message = '';
#| return { validity, message };
#| }
#| function dispatchInvalidEventForElement(element) {
#| if (!element || typeof element.dispatchEvent !== 'function') return;
#| element.dispatchEvent(new Event('invalid', { bubbles: false, cancelable: true }));
#| }
#| function checkFormControlValidity(element, report) {
#| const state = computeValidationStateForElement(element);
#| if (state.validity.valid) return true;
#| if (report) dispatchInvalidEventForElement(element);
#| return false;
#| }
#| function shouldSkipFormValidation(form, submitter) {
#| if (submitter && submitter.hasAttribute && submitter.hasAttribute('formnovalidate')) return true;
#| return !!(form && form.hasAttribute && form.hasAttribute('novalidate'));
#| }
#| function validateFormForSubmission(form, report, submitter) {
#| if (!form || !form.querySelectorAll) return true;
#| if (shouldSkipFormValidation(form, submitter)) return true;
#| const controls = form.querySelectorAll('input, textarea, select');
#| let allValid = true;
#| for (let i = 0; i < controls.length; i++) {
#| if (!checkFormControlValidity(controls[i], report)) allValid = false;
#| }
#| return allValid;
#| }
#| function wrapPatchedProperty(element, propertyName, beforeSet) {
#| const marker = '__craterPatched_' + propertyName;
#| if (!element || element[marker]) return;
#| let prototype = element;
#| let descriptor = null;
#| while (prototype && !descriptor) {
#| descriptor = Object.getOwnPropertyDescriptor(prototype, propertyName);
#| prototype = Object.getPrototypeOf(prototype);
#| }
#| if (!descriptor || typeof descriptor.get !== 'function' || typeof descriptor.set !== 'function') return;
#| Object.defineProperty(element, propertyName, {
#| get() { return descriptor.get.call(this); },
#| set(value) {
#| beforeSet(this);
#| descriptor.set.call(this, value);
#| },
#| configurable: true,
#| enumerable: !!descriptor.enumerable,
#| });
#| element[marker] = true;
#| }
#| if (typeof globalThis.SubmitEvent !== 'function') {
#| class SubmitEvent extends Event {
#| constructor(type, options) {
#| options = options || {};
#| super(type, options);
#| this._submitter = Object.prototype.hasOwnProperty.call(options, 'submitter')
#| ? options.submitter
#| : null;
#| }
#| get submitter() { return this._submitter; }
#| }
#| globalThis.SubmitEvent = SubmitEvent;
#| if (typeof window !== 'undefined') {
#| window.SubmitEvent = SubmitEvent;
#| }
#| }
#| Object.defineProperties(ElementInternals.prototype, {
#| shadowRoot: {
#| get() {
#| return this._host ? (this._host.__elementInternalsShadowRoot || null) : null;
#| }
#| },
#| form: { get() { return null; } },
#| willValidate: { get() { return false; } },
#| validity: { get() { return this._validity; } },
#| validationMessage: { get() { return this._validationMessage; } },
#| labels: { get() { return []; } },
#| states: {
#| get() {
#| if (!this._statesSet) this._statesSet = new CustomStateSet(this._host);
#| return this._statesSet;
#| }
#| }
#| });
#|
#| function attachInternalsImpl() {
#| const localName = String(this.localName || this._localName || this.tagName || '').toLowerCase();
#| if (localName.indexOf('-') < 0) {
#| throw new DOMException('attachInternals is only supported on custom elements', 'NotSupportedError');
#| }
#| if (this.__elementInternals) {
#| throw new DOMException('ElementInternals already attached', 'NotSupportedError');
#| }
#| const internals = new ElementInternals(this);
#| this.__elementInternals = internals;
#| this.__elementInternalsShadowRoot = this.shadowRoot || null;
#| return internals;
#| }
#|
#| function defineValidationSurface(element) {
#| if (!element || element.__craterValidationSurface) return;
#| const localName = String(element.localName || element._localName || element.tagName || '').toLowerCase();
#| if (localName === 'form') {
#| Object.defineProperty(element, 'noValidate', {
#| get() { return !!(this.hasAttribute && this.hasAttribute('novalidate')); },
#| set(v) { setBooleanAttribute(this, 'novalidate', v); },
#| configurable: true,
#| });
#| element.reset = function() {
#| const event = new Event('reset', { bubbles: true, cancelable: true });
#| if (!this.dispatchEvent(event)) return;
#| resetFormControls(this);
#| };
#| element.submit = function() {
#| return undefined;
#| };
#| element.checkValidity = function() {
#| return validateFormForSubmission(this, true, null);
#| };
#| element.reportValidity = function() {
#| return validateFormForSubmission(this, true, null);
#| };
#| element.requestSubmit = function(submitter) {
#| if (submitter !== undefined && submitter !== null) {
#| if (!isSubmitButtonElement(submitter)) {
#| throw new TypeError(
#| "Failed to execute 'requestSubmit' on 'HTMLFormElement': parameter 1 is not a submit button"
#| );
#| }
#| if (resolveFormOwnerForSubmitter(submitter) !== this) {
#| throw new DOMException(
#| "Failed to execute 'requestSubmit' on 'HTMLFormElement': the specified element is not owned by this form element",
#| 'NotFoundError'
#| );
#| }
#| }
#| const normalizedSubmitter = submitter === undefined ? null : submitter;
#| if (!validateFormForSubmission(this, true, normalizedSubmitter)) return undefined;
#| const event = new SubmitEvent('submit', {
#| bubbles: true,
#| cancelable: true,
#| submitter: normalizedSubmitter,
#| });
#| this.dispatchEvent(event);
#| return undefined;
#| };
#| } else if (localName === 'input' || localName === 'textarea' || localName === 'select' || localName === 'button') {
#| Object.defineProperties(element, {
#| willValidate: {
#| get() { return computeControlWillValidate(this); },
#| configurable: true,
#| },
#| validity: {
#| get() { return computeValidationStateForElement(this).validity; },
#| configurable: true,
#| },
#| validationMessage: {
#| get() { return computeValidationStateForElement(this).message; },
#| configurable: true,
#| },
#| });
#| element.setCustomValidity = function(message) {
#| this.__customValidationMessage = message === undefined || message === null ? '' : String(message);
#| };
#| element.checkValidity = function() {
#| return checkFormControlValidity(this, true);
#| };
#| element.reportValidity = function() {
#| return checkFormControlValidity(this, true);
#| };
#| if (localName === 'input' || localName === 'button') {
#| Object.defineProperty(element, 'formNoValidate', {
#| get() { return !!(this.hasAttribute && this.hasAttribute('formnovalidate')); },
#| set(v) { setBooleanAttribute(this, 'formnovalidate', v); },
#| configurable: true,
#| });
#| }
#| }
#| element.__craterValidationSurface = true;
#| }
#|
#| function patchElement(element) {
#| if (!element || element.nodeType !== 1) return element;
#| element.attachInternals = attachInternalsImpl;
#| const localName = String(element.localName || element._localName || element.tagName || '').toLowerCase();
#| defineValidationSurface(element);
#| if (localName === 'input') {
#| wrapPatchedProperty(element, 'value', rememberInputDefaultValue);
#| wrapPatchedProperty(element, 'checked', rememberInputDefaultChecked);
#| } else if (localName === 'textarea') {
#| wrapPatchedProperty(element, 'value', rememberTextAreaDefaultValue);
#| } else if (localName === 'option') {
#| wrapPatchedProperty(element, 'selected', rememberOptionDefaultSelected);
#| }
#| if (!element.__craterClickWrapped) {
#| element.click = function() {
#| if (this.hasAttribute && this.hasAttribute('disabled')) return;
#| const event = typeof MouseEvent === 'function'
#| ? new MouseEvent('click', { bubbles: true, cancelable: true, composed: true })
#| : new Event('click', { bubbles: true, cancelable: true });
#| const allowed = this.dispatchEvent(event);
#| if (!allowed) return;
#| if (isSubmitButtonElement(this)) {
#| const form = resolveFormOwnerForSubmitter(this);
#| if (form && typeof form.requestSubmit === 'function') {
#| form.requestSubmit(this);
#| }
#| return;
#| }
#| if (isResetButtonElement(this)) {
#| const form = resolveFormOwnerForSubmitter(this);
#| if (form && typeof form.reset === 'function') {
#| form.reset();
#| }
#| }
#| };
#| element.__craterClickWrapped = true;
#| }
#| if (typeof element.attachShadow === 'function' && !element.__craterInternalsShadowWrapped) {
#| const originalAttachShadow = element.attachShadow;
#| element.attachShadow = function(init) {
#| const shadow = originalAttachShadow.call(this, init);
#| if (this.__elementInternals) {
#| this.__elementInternalsShadowRoot = shadow;
#| }
#| return shadow;
#| };
#| element.__craterInternalsShadowWrapped = true;
#| }
#| return element;
#| }
#|
#| function patchTree(node) {
#| if (!node) return;
#| if (node.nodeType === 1) patchElement(node);
#| if (node.shadowRoot) patchTree(node.shadowRoot);
#| if (!node.childNodes) return;
#| for (let i = 0; i < node.childNodes.length; i++) {
#| patchTree(node.childNodes[i]);
#| }
#| }
#|
#| const originalCreateElement = document.createElement.bind(document);
#| document.createElement = function(tagName, options) {
#| return patchElement(originalCreateElement(tagName, options));
#| };
#| const originalCreateElementNS = document.createElementNS ? document.createElementNS.bind(document) : null;
#| if (originalCreateElementNS) {
#| document.createElementNS = function(ns, qualifiedName, options) {
#| return patchElement(originalCreateElementNS(ns, qualifiedName, options));
#| };
#| }
#|
#| patchTree(document.documentElement);
#| patchTree(document.head);
#| patchTree(document.body);
#|
#| globalThis.CustomStateSet = CustomStateSet;
#| globalThis.ElementInternals = ElementInternals;
#| if (typeof window !== 'undefined') {
#| window.CustomStateSet = CustomStateSet;
#| window.ElementInternals = ElementInternals;
#| window.SubmitEvent = globalThis.SubmitEvent;
#| }
#|})();
///|
/// Process-wide cache of the V8 startup snapshot that bakes in the page-
/// independent JS API surface (the mock DOM classes / `document` / `window`
/// globals, console, the logs bridge, and the ElementInternals polyfill). Keyed
/// by the mock-DOM source so a changed source rebuilds. Like a real browser, the
/// DOM / global API is pre-injected into the isolate via the snapshot instead of
/// re-evaluating the (large) setup JS on every runtime; only per-page data
/// (`create_dom_init_code`) and user code run at execute time.
let mock_dom_snapshot_cache : Ref[(String, Bytes)?] = { val: None }
///|
/// Toggle the snapshot path (default on). Set to false to force the
/// eval-on-init fallback — useful to isolate a snapshot-specific issue.
let v8_snapshot_enabled : Ref[Bool] = { val: true }
///|
/// Enable/disable building runtimes from the pre-injected API snapshot.
pub fn set_v8_snapshot_enabled(enabled : Bool) -> Unit {
v8_snapshot_enabled.val = enabled
}
///|
/// Build (or reuse) the startup snapshot for a given mock-DOM source: a snapshot
/// whose realm already has the static API surface installed. Returns `None` if
/// snapshotting fails so the caller can fall back to eval-on-init.
fn build_mock_dom_snapshot(src : String) -> Bytes? {
match mock_dom_snapshot_cache.val {
Some((cached_src, bytes)) => if cached_src == src { return Some(bytes) }
None => ()
}
// The same page-independent setup the eval path runs, captured once into the
// snapshot heap. `create_dom_init_code` (per-page) is intentionally NOT here.
let builder = @v8.snapshot_builder_new()
.eval(src)
.eval(logs_bridge_js)
.eval(element_internals_polyfill_js)
match builder.build() {
Ok(bytes) => {
mock_dom_snapshot_cache.val = Some((src, bytes))
Some(bytes)
}
Err(_) => None
}
}
///|
impl @js.JsRuntime for V8JsRuntime with fn init(self : V8JsRuntime) -> Unit {
if self.initialized {
return
}
match self.mock_dom_source {
// Mock-DOM path: prefer a runtime created from the pre-injected API
// snapshot (the static surface is already present — no per-runtime eval of
// the setup JS). Fall back to a plain runtime + eval-on-first-execute if
// snapshotting is disabled or fails.
Some(src) => {
let snapshot = if v8_snapshot_enabled.val {
build_mock_dom_snapshot(src)
} else {
None
}
match snapshot {
Some(snapshot) =>
match @v8.runtime_new_with_snapshot(snapshot) {
Ok(rt) => {
self.runtime = Some(rt)
self.initialized = true
// The API surface is already in the realm — skip the per-runtime
// eval of mock_dom_source / logs bridge / polyfill in execute().
self.dom_initialized = true
}
Err(_) => self.init_plain_runtime()
}
None => self.init_plain_runtime()
}
}
// No mock DOM: basic eval with console capture.
None =>
match @v8.runtime_new() {
Ok(rt) => {
self.runtime = Some(rt)
self.initialized = true
ignore(rt.eval_string(console_setup_js))
}
Err(_) => ()
}
}
}
///|
/// Fallback initializer: a plain runtime whose mock-DOM API is evaluated lazily
/// on the first `execute()` (the pre-snapshot behavior). `dom_initialized` stays
/// false so `execute()` runs the setup JS.
fn V8JsRuntime::init_plain_runtime(self : V8JsRuntime) -> Unit {
match @v8.runtime_new() {
Ok(rt) => {
self.runtime = Some(rt)
self.initialized = true
}
Err(_) => ()
}
}
///|
impl @js.JsRuntime for V8JsRuntime with fn execute(
self : V8JsRuntime,
_context_id : Int,
dom : @dom.DomTree,
code : String,
async_mode : @js.AsyncExecutionMode,
) -> @js.JsResult raise @js.JsError {
let rt = match self.runtime {
Some(rt) => rt
None => raise @js.JsError::execution_error("V8 runtime not initialized")
}
// Initialize mock DOM on first execute if source is available
if !self.dom_initialized {
match self.mock_dom_source {
Some(src) =>
match rt.eval_string(src) {
Ok(_) => {
self.dom_initialized = true
// Bridge: mock DOM uses `logs` array, __getLogs reads from it
ignore(rt.eval_string(logs_bridge_js))
ignore(rt.eval_string(element_internals_polyfill_js))
}
Err(e) =>
raise @js.JsError::execution_error(
"mock DOM init failed: " + e.to_string(),
)
}
None => ()
}
}
// If mock DOM is loaded, initialize DOM on first call only
if self.dom_initialized {
// Reset logs and domOps for this execution
ignore(rt.eval_string("logs.length = 0; domOps.length = 0;"))
// Serialize DomTree into JS DOM only on first execute (avoid duplicating nodes)
if !self.dom_synced {
let init_code = @js.create_dom_init_code(dom)
match rt.eval_string(init_code) {
Ok(_) => ()
Err(e) => println("[V8 initCode error] " + e.to_string())
}
populate_id_map(dom, self.id_map)
self.dom_synced = true
}
}
// Execute user code
let flush = match async_mode {
@js.ImmediateFlush => true
@js.DeferredFlush => false
}
// Step 1: Execute user code and capture result
let exec_code = "try {\n globalThis.__lastResult = (function() { " +
code +
"\n})();\n globalThis.__lastSuccess = true;\n} catch(e) {\n globalThis.__lastError = e.message || String(e);\n globalThis.__lastSuccess = false;\n}"
ignore(rt.eval_string(exec_code))
// Step 2: Flush microtasks (mock DOM's _SyncPromise + V8 native)
// Mock DOM replaces Promise with _SyncPromise which uses _flushMicrotasks()
if self.dom_initialized {
ignore(rt.eval_string("_flushMicrotasks()"))
}
ignore(rt.perform_microtask_checkpoint())
// Step 3: Flush mock DOM timers (setTimeout) with microtask interleaving
if flush && self.dom_initialized {
for i = 0; i < 100; i = i + 1 {
match rt.eval_string("_runOneTimeout()") {
Ok(v) => if v != "true" { break }
Err(_) => break
}
ignore(rt.perform_microtask_checkpoint())
}
}
// Step 4: Collect results
let collect_code = if self.dom_initialized {
"(function() {\n const __logsResult = __getLogs();\n const v = globalThis.__lastSuccess\n ? JSON.stringify({ success: true, value: globalThis.__lastResult === undefined ? 'undefined' : String(globalThis.__lastResult), logs: JSON.parse(__logsResult), domOps: domOps || [] })\n : JSON.stringify({ success: false, error: globalThis.__lastError || 'unknown', logs: JSON.parse(__logsResult), domOps: domOps || [] });\n return v;\n})()"
} else {
"(function() {\n const __logsResult = __getLogs();\n const v = globalThis.__lastSuccess\n ? JSON.stringify({ success: true, value: globalThis.__lastResult === undefined ? 'undefined' : String(globalThis.__lastResult), logs: JSON.parse(__logsResult) })\n : JSON.stringify({ success: false, error: globalThis.__lastError || 'unknown', logs: JSON.parse(__logsResult) });\n return v;\n})()"
}
match rt.eval_string(collect_code) {
Ok(result_json) =>
if self.dom_initialized {
let (result, dom_ops) = parse_result_with_ops(result_json)
if dom_ops.length() > 0 {
apply_dom_ops(dom, dom_ops, self.id_map)
}
result
} else {
parse_v8_result(result_json)
}
Err(v8_err) => {
let logs = collect_logs(rt)
@js.JsResult::new(v8_err.to_string(), logs, false)
}
}
}
///|
impl @js.JsRuntime for V8JsRuntime with fn tick(
self : V8JsRuntime,
_context_id : Int,
dom : @dom.DomTree,
) -> @js.JsResult raise @js.JsError {
match self.runtime {
Some(rt) => {
ignore(rt.perform_microtask_checkpoint())
if self.dom_initialized {
// Run one timer + flush microtasks
let tick_code = "logs.length = 0; domOps.length = 0;\nconst __hadWork = _runOneTimeout() || _runOneAnimationFrame();\n_flushMicrotasks();\nJSON.stringify({ success: true, value: String(__hadWork), logs: logs.slice(), domOps: domOps.slice() })"
match rt.eval_string(tick_code) {
Ok(json) => {
let (result, dom_ops) = parse_result_with_ops(json)
apply_dom_ops(dom, dom_ops, self.id_map)
result
}
Err(_) => @js.JsResult::new("false", [], true)
}
} else {
let logs = collect_logs(rt)
@js.JsResult::new("false", logs, true)
}
}
None => @js.JsResult::new("false", [], true)
}
}
// ============================================================
// ID map management
// ============================================================
///|
/// Populate id_map with identity mappings for all existing DomTree nodes.
/// The mock DOM's `create_dom_init_code` sets `_mockId = domtree_id` for
/// serialized elements, so existing nodes have mock_id == domtree_id.
fn populate_id_map(dom : @dom.DomTree, id_map : Map[Int, @dom.NodeId]) -> Unit {
// Clear previous mappings
id_map.clear()
// Traverse all existing nodes by walking the tree from document root
populate_id_map_recursive(dom, dom.get_document(), id_map)
}
///|
fn populate_id_map_recursive(
dom : @dom.DomTree,
node_id : @dom.NodeId,
id_map : Map[Int, @dom.NodeId],
) -> Unit {
let id_int = node_id.to_int()
id_map[id_int] = node_id
match dom.get_children(node_id) {
Ok(children) =>
for child in children {
populate_id_map_recursive(dom, child, id_map)
}
Err(_) => ()
}
}
// ============================================================
// DOM operation parsing and application
// ============================================================
///|
priv struct DomOp {
op : String
id : Int
parent_id : Int
child_id : Int
ref_id : Int
tag_name : String
text : String
name : String
value : String
delegates_focus : Bool
slot_assignment : String
clonable : Bool
serializable : Bool
}
///|
fn parse_result_with_ops(json : String) -> (@js.JsResult, Array[DomOp]) {
let success = json.contains("\"success\":true")
let value = if success {
extract_json_field(json, "value")
} else {
extract_json_field(json, "error")
}
let logs = extract_json_array(json, "logs")
let dom_ops = parse_dom_ops(json)
(@js.JsResult::new(value, logs, success), dom_ops)
}
///|
fn parse_dom_ops(json : String) -> Array[DomOp] {
let ops : Array[DomOp] = []
let pattern = "\"domOps\":["
match json.find(pattern) {
Some(start) => {
let array_start = start + pattern.length()
let chars = json.to_array()
let mut i = array_start
let mut depth = 0
let mut obj_start = -1
while i < chars.length() {
let c = chars[i]
if c == '{' {
if depth == 0 {
obj_start = c.to_int()
ignore(obj_start)
obj_start = i
}
depth += 1
} else if c == '}' {
depth -= 1
if depth == 0 && obj_start >= 0 {
let obj_str = json[obj_start:i + 1].to_owned()
ops.push(parse_single_dom_op(obj_str))
obj_start = -1
}
} else if c == ']' && depth == 0 {
break
}
i += 1
}
}
None => ()
}
ops
}
///|
fn parse_single_dom_op(json : String) -> DomOp {
{
op: extract_json_field(json, "op"),
id: parse_json_int(json, "id"),
parent_id: parse_json_int(json, "parentId"),
child_id: parse_json_int(json, "childId"),
ref_id: parse_json_int(json, "refId"),
tag_name: extract_json_field(json, "tagName"),
text: extract_json_field(json, "text"),
name: extract_json_field(json, "name"),
value: extract_json_field(json, "value"),
delegates_focus: parse_json_bool(json, "delegatesFocus"),
slot_assignment: extract_json_field(json, "slotAssignment"),
clonable: parse_json_bool(json, "clonable"),
serializable: parse_json_bool(json, "serializable"),
}
}
///|
fn parse_json_int(json : String, field : String) -> Int {
let pattern = "\"" + field + "\":"
match json.find(pattern) {
Some(start) => {
let value_start = start + pattern.length()
let chars = json.to_array()
let mut i = value_start
// Skip whitespace
while i < chars.length() && (chars[i] == ' ' || chars[i] == '\t') {
i += 1
}
let mut result = 0
let mut neg = false
if i < chars.length() && chars[i] == '-' {
neg = true
i += 1
}
while i < chars.length() && chars[i] >= '0' && chars[i] <= '9' {
result = result * 10 + (chars[i].to_int() - '0'.to_int())
i += 1
}
if neg {
-result
} else {
result
}
}
None => 0
}
}
///|
fn parse_json_bool(json : String, field : String) -> Bool {
let pattern = "\"" + field + "\":"
match json.find(pattern) {
Some(start) => {
let value_start = start + pattern.length()
let remaining = json.unsafe_substring(
start=value_start,
end=json.length(),
)
remaining.has_prefix("true")
}
None => false
}
}
///|
fn parse_custom_states_value(value : String) -> Array[String] {
let states : Array[String] = []
if value.is_empty() {
return states
}
for part in value.split(" ") {
let state = part.trim().to_owned()
if !state.is_empty() {
states.push(state)
}
}
states
}
///|
fn apply_dom_ops(
dom : @dom.DomTree,
ops : Array[DomOp],
id_map : Map[Int, @dom.NodeId],
) -> Unit {
ensure_mock_document_shell(dom, id_map)
for op in ops {
match op.op {
"createElement" => {
let node_id = dom.create_element(op.tag_name)
// Map mock DOM id to actual DomTree id
id_map[op.id] = node_id
}
"createTextNode" => {
let node_id = dom.create_text(op.text)
id_map[op.id] = node_id
}
"createDocumentFragment" => {
let node_id = dom.create_document_fragment()
id_map[op.id] = node_id
}
"attachShadow" => {
let host = id_map.get(op.parent_id)
let shadow_root = id_map.get(op.child_id)
match (host, shadow_root) {
(Some(host), Some(shadow_root)) => {
let init = @dom.ShadowRootInit::new(
mode=op.value,
delegates_focus=op.delegates_focus,
slot_assignment=if op.slot_assignment.length() > 0 {
op.slot_assignment
} else {
"named"
},
clonable=op.clonable,
serializable=op.serializable,
)
ignore(
dom.attach_existing_shadow_root_with_init(host, shadow_root, init),
)
}
_ => ()
}
}
"appendChild" => {
let parent = id_map.get(op.parent_id)
let child = id_map.get(op.child_id)
match (parent, child) {
(Some(p), Some(c)) => ignore(dom.append_child(p, c))
_ => ()
}
}
"removeChild" => {
let parent = id_map.get(op.parent_id)
let child = id_map.get(op.child_id)
match (parent, child) {
(Some(p), Some(c)) => ignore(dom.remove_child(p, c))
_ => ()
}
}
"insertBefore" => {
let parent = id_map.get(op.parent_id)
let child = id_map.get(op.child_id)
let ref_node = if op.ref_id != 0 { id_map.get(op.ref_id) } else { None }
match (parent, child) {
(Some(p), Some(c)) => ignore(dom.insert_before(p, c, ref_node))
_ => ()
}
}
"replaceChild" => {
// replaceChild: parentId, childId (new), refId (old)
let parent = id_map.get(op.parent_id)
let new_child = id_map.get(op.child_id)
let old_child = id_map.get(op.ref_id)
match (parent, new_child, old_child) {
(Some(p), Some(nc), Some(oc)) => {
// Insert new before old, then remove old
ignore(dom.insert_before(p, nc, Some(oc)))
ignore(dom.remove_child(p, oc))
}
_ => ()
}
}
"setAttribute" =>
match id_map.get(op.id) {
Some(node) => ignore(dom.set_attribute(node, op.name, op.value))
None => ()
}
"removeAttribute" =>
match id_map.get(op.id) {
Some(node) => ignore(dom.remove_attribute(node, op.name))
None => ()
}
"setCustomStates" =>
match id_map.get(op.id) {
Some(node) =>
ignore(
dom.set_custom_states(node, parse_custom_states_value(op.value)),
)
None => ()
}
"setTextContent" =>
match id_map.get(op.id) {
Some(node) => {
let text = if op.value.length() > 0 { op.value } else { op.text }
ignore(dom.set_text_content(node, text))
}
None => ()
}
_ => () // Unknown op, skip
}
}
}
///|
fn ensure_mock_document_shell(
dom : @dom.DomTree,
id_map : Map[Int, @dom.NodeId],
) -> Unit {
let doc = dom.get_document()
if !id_map.contains(1) {
match dom.create_runtime_node(1, @dom.Element, tag_name="html") {
Ok(html) => {
id_map[1] = html
ignore(dom.append_child(doc, html))
}
Err(_) => ()
}
}
let html = id_map.get(1)
if !id_map.contains(2) {
match (html, dom.create_runtime_node(2, @dom.Element, tag_name="body")) {
(Some(html), Ok(body)) => {
id_map[2] = body
ignore(dom.append_child(html, body))
}
_ => ()
}
}
}
// ============================================================
// JSON parsing helpers
// ============================================================
///|
fn collect_logs(rt : @v8.Runtime) -> Array[String] {
match rt.eval_string("__getLogs()") {
Ok(json) => parse_string_array(json)
Err(_) => []
}
}
///|
fn parse_v8_result(json : String) -> @js.JsResult {
let success = json.contains("\"success\":true")
let value = if success {
extract_json_field(json, "value")
} else {
extract_json_field(json, "error")
}
let logs = extract_json_array(json, "logs")
@js.JsResult::new(value, logs, success)
}
///|
fn extract_json_field(json : String, field : String) -> String {
let pattern = "\"" + field + "\":\""
match json.find(pattern) {
Some(start) => {
let value_start = start + pattern.length()
let chars = json.to_array()
let mut i = value_start
while i < chars.length() {
if chars[i] == '"' && (i == value_start || chars[i - 1] != '\\') {
break
}
i += 1
}
unescape_json_string(json[value_start:i].to_owned())
}
None => ""
}
}
///|
fn extract_json_array(json : String, field : String) -> Array[String] {
let pattern = "\"" + field + "\":["
match json.find(pattern) {
Some(start) => {
let array_start = start + pattern.length()
let chars = json.to_array()
let result : Array[String] = []
let mut i = array_start
while i < chars.length() && chars[i] != ']' {
if chars[i] == '"' {
let str_start = i + 1
let mut str_end = str_start
while str_end < chars.length() {
if chars[str_end] == '"' &&
(str_end == str_start || chars[str_end - 1] != '\\') {
break
}
str_end += 1
}
result.push(unescape_json_string(json[str_start:str_end].to_owned()))
i = str_end + 1
} else {
i += 1
}
}
result
}
None => []
}
}
///|
fn parse_string_array(json : String) -> Array[String] {
if json.length() < 2 {
return []
}
let result : Array[String] = []
let chars = json.to_array()
let mut i = 0
while i < chars.length() && chars[i] != '[' {
i += 1
}
i += 1
while i < chars.length() && chars[i] != ']' {
if chars[i] == '"' {
i += 1
let start = i
while i < chars.length() {
if chars[i] == '"' && (i == start || chars[i - 1] != '\\') {
break
}
i += 1
}
result.push(unescape_json_string(json[start:i].to_owned()))
i += 1
} else {
i += 1
}
}
result
}
///|
fn unescape_json_string(value : String) -> String {
let chars = value.to_array()
let buf = StringBuilder::new()
let mut i = 0
while i < chars.length() {
if chars[i] == '\\' && i + 1 < chars.length() {
i += 1
match chars[i] {
'"' => buf.write_char('"')
'\\' => buf.write_char('\\')
'/' => buf.write_char('/')
'b' => buf.write_char('\b')
'f' => buf.write_char('\f')
'n' => buf.write_char('\n')
'r' => buf.write_char('\r')
't' => buf.write_char('\t')
_ => {
buf.write_char('\\')
buf.write_char(chars[i])
}
}
} else {
buf.write_char(chars[i])
}
i += 1
}
buf.to_string()
}