From fdbf1214f4b4d5accd7da13c78245bb00b98d79a Mon Sep 17 00:00:00 2001 From: Raunak Raj <71929976+bajrangCoder@users.noreply.github.com> Date: Tue, 25 Aug 2026 18:56:05 +0530 Subject: [PATCH 1/4] feat(console): isolate execution and improve mobile REPL UX - run console JavaScript in a cancellable Web Worker - protect the app from infinite loops and recursion - preserve history with variable-height virtualization - restore the inline REPL layout - fix mobile keyboard resizing and viewport gaps - add clear and stop controls --- rspack.config.js | 1 + src/components/virtualList/index.js | 2 + src/components/virtualList/style.scss | 10 + src/components/virtualList/variable.js | 354 +++++++++++++++ src/lib/console.js | 258 +++++++---- src/lib/consoleExecutor.js | 137 ++++++ src/lib/consoleViewport.js | 27 ++ src/lib/consoleWorker.js | 166 +++++++ src/lib/run.js | 10 +- .../com/foxdebug/browser/BrowserActivity.java | 38 +- src/styles/console.m.scss | 405 ++++++++++-------- src/views/console.hbs | 11 +- tests/unit/consoleExecutor.test.js | 134 ++++++ tests/unit/consoleViewport.test.js | 41 ++ tests/unit/variableVirtualList.test.js | 125 ++++++ webpack.config.js | 1 + 16 files changed, 1438 insertions(+), 282 deletions(-) create mode 100644 src/components/virtualList/variable.js create mode 100644 src/lib/consoleExecutor.js create mode 100644 src/lib/consoleViewport.js create mode 100644 src/lib/consoleWorker.js create mode 100644 tests/unit/consoleExecutor.test.js create mode 100644 tests/unit/consoleViewport.test.js create mode 100644 tests/unit/variableVirtualList.test.js diff --git a/rspack.config.js b/rspack.config.js index a30e31154b..9efbf30197 100644 --- a/rspack.config.js +++ b/rspack.config.js @@ -144,6 +144,7 @@ module.exports = (env, options) => { boot: './src/boot.js', main: './src/main.js', console: './src/lib/console.js', + consoleWorker: './src/lib/consoleWorker.js', searchInFilesWorker: './src/sidebarApps/searchInFiles/worker.js', searchIndexWorker: './src/sidebarApps/searchInFiles/indexWorker.js', htmlLspWorker: './src/cm/lsp/workers/html.worker.ts', diff --git a/src/components/virtualList/index.js b/src/components/virtualList/index.js index bf7b7db4b1..c1ff5bafcf 100644 --- a/src/components/virtualList/index.js +++ b/src/components/virtualList/index.js @@ -1,6 +1,8 @@ import "./style.scss"; import tag from "html-tag-js"; +export { default as VariableVirtualList } from "./variable"; + /** * @typedef {object} VirtualListOptions * @property {number} [itemHeight=30] - Height of each item in pixels diff --git a/src/components/virtualList/style.scss b/src/components/virtualList/style.scss index 9af4e0df05..3ef86ce953 100644 --- a/src/components/virtualList/style.scss +++ b/src/components/virtualList/style.scss @@ -7,3 +7,13 @@ position: relative; min-width: 100%; } + +.variable-virtual-spacer { + width: 100%; + pointer-events: none; +} + +.variable-virtual-items { + position: relative; + min-width: 100%; +} diff --git a/src/components/virtualList/variable.js b/src/components/virtualList/variable.js new file mode 100644 index 0000000000..f922af2ba2 --- /dev/null +++ b/src/components/virtualList/variable.js @@ -0,0 +1,354 @@ +const DEFAULT_ESTIMATED_HEIGHT = 52; +const DEFAULT_OVERSCAN = 360; +const BOTTOM_THRESHOLD = 48; + +/** + * Variable-height virtual list for retained HTMLElement items. + * + * Every item stays in the model, while only the visible window and a small pixel + * overscan are mounted. ResizeObserver keeps changing item heights measured + * without losing the reader's scroll anchor. + */ +export default class VariableVirtualList { + constructor( + container, + { + estimatedHeight = DEFAULT_ESTIMATED_HEIGHT, + overscan = DEFAULT_OVERSCAN, + activeOverscan = overscan * 4, + maxOverscan = overscan * 10, + } = {}, + ) { + this.container = container; + this.estimatedHeight = estimatedHeight; + this.overscan = overscan; + this.activeOverscan = Math.max(overscan, activeOverscan); + this.maxOverscan = Math.max(this.activeOverscan, maxOverscan); + this.dynamicOverscan = overscan; + this.items = []; + this.itemByElement = new WeakMap(); + this.offsets = [0]; + this.offsetsDirty = false; + this.renderedRange = { start: 0, end: 0 }; + this.frame = null; + this.stickToBottom = true; + this.lastScrollTop = this.container.scrollTop; + this.lastScrollTime = this.now(); + this.touching = false; + this.releaseTimer = null; + this.destroyed = false; + + this.topSpacer = document.createElement("div"); + this.topSpacer.className = "variable-virtual-spacer"; + this.itemContainer = document.createElement("div"); + this.itemContainer.className = "variable-virtual-items"; + this.bottomSpacer = document.createElement("div"); + this.bottomSpacer.className = "variable-virtual-spacer"; + this.container.append( + this.topSpacer, + this.itemContainer, + this.bottomSpacer, + ); + + this.onScroll = () => { + this.stickToBottom = this.isNearBottom(); + const now = this.now(); + const elapsed = Math.max(8, now - this.lastScrollTime); + const distance = Math.abs(this.container.scrollTop - this.lastScrollTop); + const projectedDistance = distance * (32 / elapsed); + this.dynamicOverscan = Math.min( + this.maxOverscan, + this.activeOverscan + projectedDistance * 2, + ); + this.lastScrollTop = this.container.scrollTop; + this.lastScrollTime = now; + // Scroll rendering is intentionally synchronous. Android WebView can move + // the compositor several rows before the next animation frame. + this.render(); + this.scheduleOverscanRelease(); + }; + this.onTouchStart = () => { + this.touching = true; + this.clearOverscanRelease(); + this.dynamicOverscan = this.activeOverscan; + // Pre-paint the fling guard before compositor scrolling begins. + this.render(); + }; + this.onTouchEnd = () => { + this.touching = false; + this.scheduleOverscanRelease(); + }; + this.container.addEventListener("scroll", this.onScroll, { passive: true }); + this.container.addEventListener("touchstart", this.onTouchStart, { + passive: true, + }); + this.container.addEventListener("touchend", this.onTouchEnd, { + passive: true, + }); + this.container.addEventListener("touchcancel", this.onTouchEnd, { + passive: true, + }); + + this.resizeObserver = + typeof ResizeObserver === "function" + ? new ResizeObserver((entries) => this.onResize(entries)) + : null; + } + + now() { + return ( + this.container.ownerDocument.defaultView?.performance?.now?.() ?? + Date.now() + ); + } + + scheduleOverscanRelease() { + this.clearOverscanRelease(); + this.releaseTimer = this.container.ownerDocument.defaultView?.setTimeout( + () => { + this.releaseTimer = null; + if (this.destroyed || this.touching) return; + this.dynamicOverscan = this.overscan; + this.render(); + }, + 500, + ); + } + + clearOverscanRelease() { + if (this.releaseTimer === null) return; + this.container.ownerDocument.defaultView?.clearTimeout(this.releaseTimer); + this.releaseTimer = null; + } + + get length() { + return this.items.length; + } + + get mountedCount() { + return this.itemContainer.childElementCount; + } + + append(element) { + const wasNearBottom = this.isNearBottom(); + const item = { + element, + height: this.estimatedHeight, + index: this.items.length, + }; + this.items.push(item); + this.itemByElement.set(element, item); + this.offsetsDirty = true; + if (wasNearBottom || this.items.length === 1) this.stickToBottom = true; + this.scheduleRender(); + } + + clear() { + this.resizeObserver?.disconnect(); + this.items = []; + this.itemByElement = new WeakMap(); + this.offsets = [0]; + this.offsetsDirty = false; + this.renderedRange = { start: 0, end: 0 }; + this.stickToBottom = true; + this.dynamicOverscan = this.overscan; + this.topSpacer.style.height = "0px"; + this.bottomSpacer.style.height = "0px"; + this.itemContainer.replaceChildren(); + this.container.scrollTop = 0; + } + + invalidate() { + this.scheduleRender(); + } + + scrollToBottom() { + this.stickToBottom = true; + this.scheduleRender(); + } + + scheduleRender() { + if (this.frame !== null || this.destroyed) return; + this.frame = requestAnimationFrame(() => { + this.frame = null; + this.render(); + }); + } + + rebuildOffsets() { + if (!this.offsetsDirty) return; + + const offsets = new Array(this.items.length + 1); + offsets[0] = 0; + for (let index = 0; index < this.items.length; index++) { + offsets[index + 1] = offsets[index] + this.items[index].height; + } + this.offsets = offsets; + this.offsetsDirty = false; + } + + findIndexAt(offset) { + let low = 0; + let high = this.items.length; + while (low < high) { + const middle = Math.floor((low + high + 1) / 2); + if (this.offsets[middle] <= offset) low = middle; + else high = middle - 1; + } + return Math.min(low, Math.max(0, this.items.length - 1)); + } + + render() { + if (this.destroyed) return; + this.rebuildOffsets(); + if (!this.items.length) { + this.resizeObserver?.disconnect(); + this.itemContainer.replaceChildren(); + this.topSpacer.style.height = "0px"; + this.bottomSpacer.style.height = "0px"; + this.renderedRange = { start: 0, end: 0 }; + return; + } + + const viewportHeight = Math.max( + 1, + this.container.clientHeight || window.innerHeight, + ); + const totalHeight = this.offsets[this.items.length]; + const targetScrollTop = this.stickToBottom + ? Math.max(0, totalHeight - viewportHeight) + : this.container.scrollTop; + const start = this.findIndexAt( + Math.max(0, targetScrollTop - this.dynamicOverscan), + ); + const end = Math.min( + this.items.length, + this.findIndexAt( + targetScrollTop + viewportHeight + this.dynamicOverscan, + ) + 1, + ); + + this.resizeObserver?.disconnect(); + this.updateMountedRange(start, end); + for (const element of this.itemContainer.children) { + this.resizeObserver?.observe(element); + } + this.topSpacer.style.height = `${this.offsets[start]}px`; + this.bottomSpacer.style.height = `${Math.max( + 0, + totalHeight - this.offsets[end], + )}px`; + this.renderedRange = { start, end }; + + if (this.stickToBottom) { + this.container.scrollTop = Math.max( + 0, + this.container.scrollHeight - viewportHeight, + ); + } + if (!this.resizeObserver) this.measureMountedItems(); + } + + updateMountedRange(start, end) { + let oldStart = this.renderedRange.start; + let oldEnd = this.renderedRange.end; + const rangesOverlap = oldStart < end && start < oldEnd; + + if (!this.itemContainer.childElementCount || !rangesOverlap) { + const fragment = document.createDocumentFragment(); + for (let index = start; index < end; index++) { + fragment.append(this.items[index].element); + } + this.itemContainer.replaceChildren(fragment); + return; + } + + while (oldStart < start) { + this.itemContainer.firstElementChild?.remove(); + oldStart++; + } + while (oldEnd > end) { + this.itemContainer.lastElementChild?.remove(); + oldEnd--; + } + + if (start < oldStart) { + const leading = document.createDocumentFragment(); + for (let index = start; index < oldStart; index++) { + leading.append(this.items[index].element); + } + this.itemContainer.insertBefore(leading, this.itemContainer.firstChild); + } + if (end > oldEnd) { + const trailing = document.createDocumentFragment(); + for (let index = oldEnd; index < end; index++) { + trailing.append(this.items[index].element); + } + this.itemContainer.append(trailing); + } + } + + measureMountedItems() { + for (const element of this.itemContainer.children) { + const item = this.itemByElement.get(element); + if (item) this.updateHeight(item, element.getBoundingClientRect().height); + } + } + + onResize(entries) { + const wasNearBottom = this.isNearBottom(); + for (const entry of entries) { + const item = this.itemByElement.get(entry.target); + if (!item) continue; + const borderBox = Array.isArray(entry.borderBoxSize) + ? entry.borderBoxSize[0] + : entry.borderBoxSize; + this.updateHeight( + item, + borderBox?.blockSize || + entry.contentRect?.height || + entry.target.offsetHeight, + ); + } + if (wasNearBottom) this.stickToBottom = true; + } + + updateHeight(item, measuredHeight) { + const height = Math.max(1, Math.ceil(measuredHeight || 0)); + if (Math.abs(height - item.height) < 1) return; + + const delta = height - item.height; + item.height = height; + this.offsetsDirty = true; + if (!this.stickToBottom && item.index < this.renderedRange.start) { + this.container.scrollTop += delta; + } + this.scheduleRender(); + } + + isNearBottom() { + if (!this.items.length) return true; + return ( + this.container.scrollHeight - + this.container.scrollTop - + this.container.clientHeight <= + BOTTOM_THRESHOLD + ); + } + + destroy() { + this.destroyed = true; + if (this.frame !== null) cancelAnimationFrame(this.frame); + this.frame = null; + this.clearOverscanRelease(); + this.resizeObserver?.disconnect(); + this.container.removeEventListener("scroll", this.onScroll); + this.container.removeEventListener("touchstart", this.onTouchStart); + this.container.removeEventListener("touchend", this.onTouchEnd); + this.container.removeEventListener("touchcancel", this.onTouchEnd); + this.topSpacer.remove(); + this.itemContainer.remove(); + this.bottomSpacer.remove(); + this.items = []; + } +} diff --git a/src/lib/console.js b/src/lib/console.js index 3e10948585..7d5e4284bb 100644 --- a/src/lib/console.js +++ b/src/lib/console.js @@ -1,25 +1,94 @@ import "core-js/stable"; import "html-tag-js/dist/polyfill"; import { parse } from "acorn"; +import { VariableVirtualList } from "components/virtualList"; import css from "styles/console.m.scss"; import loadPolyFill from "utils/polyfill"; +import ConsoleExecutor from "./consoleExecutor"; +import { applyConsoleViewport } from "./consoleViewport"; (function () { loadPolyFill.apply(window); let consoleVisible = false; + let isFocused = false; + let isExecuting = false; + let viewportFrame = null; + let viewportObserver = null; + const isStandaloneConsole = sessionStorage.getItem("__mode") === "console"; const originalConsole = console; const $input = tag("textarea", { id: "__c-input", + attr: { + rows: "1", + placeholder: "Run JavaScript…", + "aria-label": "JavaScript console input", + spellcheck: "false", + autocomplete: "off", + autocapitalize: "off", + }, + oninput: resizeInput, + onfocus() { + isFocused = true; + virtualMessages.scrollToBottom(); + }, onblur() { setTimeout(() => { isFocused = false; }, 0); }, }); + const $runButton = tag("button", { + className: "__c-action __c-action-primary", + textContent: "Run", + attr: { + type: "button", + "aria-label": "Run JavaScript", + }, + onpointerdown(e) { + e.preventDefault(); + }, + onclick: runInputCode, + }); + const $stopExecution = tag("button", { + className: "__c-action __c-action-danger", + textContent: "Stop", + attr: { + type: "button", + "aria-label": "Stop JavaScript execution", + }, + onclick() { + executor.cancel(); + }, + }); + $stopExecution.hidden = true; + const $clearButton = tag("button", { + className: "__c-action", + textContent: "Clear", + attr: { + type: "button", + "aria-label": "Clear console", + }, + onclick: clearConsole, + }); + const $output = tag("c-output", { + attr: { + role: "region", + "aria-label": "JavaScript console", + }, + }); + const virtualMessages = new VariableVirtualList($output); const $inputContainer = tag("c-input", { - child: $input, + children: [ + $input, + tag("c-input-actions", { + children: [$stopExecution, $clearButton, $runButton], + }), + ], }); + // Keep the prompt in the transcript flow, after the virtualized messages. + // It behaves like a REPL row and naturally moves above the mobile keyboard. + $output.append($inputContainer); const $toggler = tag("c-toggler", { style: { transform: `translate(2px, ${innerHeight / 2}px)`, @@ -46,9 +115,13 @@ import loadPolyFill from "utils/polyfill"; }, }); const $console = tag("c-console", { - child: $inputContainer, + child: $output, + attr: { + role: "dialog", + "aria-label": "JavaScript console", + }, onclick(e) { - const el = e.target; + const el = e.target.closest?.("[action]") || e.target; const action = el.getAttribute("action"); switch (action) { @@ -64,9 +137,22 @@ import loadPolyFill from "utils/polyfill"; } }, }); + if (isStandaloneConsole) { + $console.setAttribute("standalone", ""); + document.documentElement.setAttribute("console-only", ""); + } const counter = {}; const timers = {}; - let isFocused = false; + const executor = new ConsoleExecutor({ + workerUrl: window.__consoleWorkerScript || "build/consoleWorker.js", + onConsole(message) { + if (message.action === "clear") { + window.console.clear(); + return; + } + log(message.level, {}, ...message.args); + }, + }); if (!window.__objs) window.__objs = {}; @@ -77,13 +163,12 @@ import loadPolyFill from "utils/polyfill"; window.addEventListener("error", onError); assignCustomConsole(); - if (sessionStorage.getItem("__mode") === "console") { + if (isStandaloneConsole) { showConsole(); return; } tag.get("html").append($toggler); - $console.setAttribute("title", "Console"); sessionStorage.setItem("__console_available", true); document.addEventListener("showconsole", showConsole); document.addEventListener("hideconsole", hideConsole); @@ -105,10 +190,7 @@ import loadPolyFill from "utils/polyfill"; } }, clear() { - originalConsole.clear(); - if (isFocused) $input.focus(); - $console.textContent = ""; - $console.appendChild($inputContainer); + clearConsole(); }, count(hash = "default") { originalConsole.count(hash); @@ -207,35 +289,104 @@ import loadPolyFill from "utils/polyfill"; function showConsole() { tag.get("html").append($console); $input.addEventListener("keydown", onCodeInput); + if (!isStandaloneConsole) { + bindViewportListeners(); + updateConsoleViewport(); + } + virtualMessages.invalidate(); } function hideConsole() { $console.remove(); $input.removeEventListener("keydown", onCodeInput); + unbindViewportListeners(); + } + + function bindViewportListeners() { + window.addEventListener("resize", updateConsoleViewport); + window.visualViewport?.addEventListener("resize", updateConsoleViewport); + window.visualViewport?.addEventListener("scroll", updateConsoleViewport); + if (typeof ResizeObserver === "function") { + viewportObserver = new ResizeObserver(updateConsoleViewport); + viewportObserver.observe(document.documentElement); + } + } + + function unbindViewportListeners() { + window.removeEventListener("resize", updateConsoleViewport); + window.visualViewport?.removeEventListener("resize", updateConsoleViewport); + window.visualViewport?.removeEventListener("scroll", updateConsoleViewport); + viewportObserver?.disconnect(); + viewportObserver = null; + if (viewportFrame !== null) cancelAnimationFrame(viewportFrame); + viewportFrame = null; + } + + function updateConsoleViewport() { + if (viewportFrame !== null) cancelAnimationFrame(viewportFrame); + viewportFrame = requestAnimationFrame(() => { + viewportFrame = null; + applyConsoleViewport($console); + virtualMessages.invalidate(); + }); } function onCodeInput(e) { - const key = e.key; isFocused = true; - if (key === "Enter") { - const regex = /[\[|{\(\)\}\]]/g; - let code = this.value.trim(); - let isOdd = (code.length - code.replace(regex, "").length) % 2; + if (e.key !== "Enter" || e.shiftKey) return; - if (!code || isOdd) return; - e.preventDefault(); - e.stopPropagation(); - e.stopImmediatePropagation(); - - log("code", {}, code); - $input.value = ""; - const res = execute(code); - if (res.type === "error") { - log("error", getStack(new Error()), res.value); - } else { - log("log", getStack(new Error()), res.value); - } + const code = $input.value.trim(); + const brackets = /[\[|{\(\)\}\]]/g; + const isIncomplete = + (code.length - code.replace(brackets, "").length) % 2 !== 0; + if (!code || isIncomplete) return; + + e.preventDefault(); + e.stopPropagation(); + e.stopImmediatePropagation(); + runInputCode(); + } + + async function runInputCode() { + const code = $input.value.trim(); + if (!code) return; + if (isExecuting) { + log("warn", {}, "Another console command is still running."); + return; } + + log("code", {}, code); + $input.value = ""; + resizeInput(); + setExecutionState(true); + const res = await execute(code); + setExecutionState(false); + + if (res.type === "error") { + log("error", getStack(new Error()), res.value); + } else { + log("log", getStack(new Error()), res.value); + } + $input.focus(); + } + + function setExecutionState(running) { + isExecuting = running; + $console.toggleAttribute("running", running); + $stopExecution.hidden = !running; + $runButton.hidden = running; + $input.disabled = running; + } + + function resizeInput() { + $input.style.height = "0px"; + $input.style.height = `${Math.min(120, Math.max(38, $input.scrollHeight))}px`; + } + + function clearConsole() { + originalConsole.clear(); + virtualMessages.clear(); + if (isFocused) $input.focus(); } function getBody(obj, ...keys) { @@ -454,7 +605,7 @@ import loadPolyFill from "utils/polyfill"; let $msg; if (mode === "code") { $msg = tag("c-code"); - $msg.textContent = arg.length > 50 ? arg.substring(0, 50) + "..." : arg; + $msg.textContent = arg; $msg.setAttribute("data-code", arg); $msg.setAttribute("action", "use code"); } else { @@ -486,11 +637,7 @@ import loadPolyFill from "utils/polyfill"; $messages.appendChild($stack); } - $console.insertBefore($messages, $inputContainer); - - while ($console.childElementCount > 100) { - $console.firstElementChild.remove(); - } + virtualMessages.append($messages); } /** @@ -635,48 +782,7 @@ import loadPolyFill from "utils/polyfill"; } function execute(code) { - let res = null; - try { - const parsed = parse(code, acornOptions).body; - res = execParsedCode(parsed); - } catch (e) { - res = execParsedCode([]); - } - - return res; - - function execParsedCode(parsed) { - let extra = ""; - parsed.map((st) => { - if (st.type === "VariableDeclaration") { - if (["const", "let"].indexOf(st.kind) < 0) return; - - const exCode = code.substring(st.start, st.end) + ";"; - extra += exCode; - } - }); - - if (extra) { - const script = tag("script"); - script.textContent = extra; - document.body.appendChild(script); - document.body.removeChild(script); - return exec(code); - } else { - return exec(code); - } - } - - function exec(code) { - let res = null; - try { - res = { type: "result", value: window.eval(code) }; - } catch (error) { - res = { type: "error", value: error }; - } - - return res; - } + return executor.execute(code); } function onError(err) { diff --git a/src/lib/consoleExecutor.js b/src/lib/consoleExecutor.js new file mode 100644 index 0000000000..c9f6c97662 --- /dev/null +++ b/src/lib/consoleExecutor.js @@ -0,0 +1,137 @@ +export const DEFAULT_CONSOLE_TIMEOUT = 30000; + +/** + * Runs console commands in a worker so user code can never block the page UI. + */ +export default class ConsoleExecutor { + constructor({ + workerUrl, + timeout = DEFAULT_CONSOLE_TIMEOUT, + onConsole = () => {}, + WorkerClass = globalThis.Worker, + } = {}) { + this.workerUrl = workerUrl; + this.timeout = timeout; + this.onConsole = onConsole; + this.WorkerClass = WorkerClass; + this.worker = null; + this.pending = null; + this.nextId = 0; + } + + execute(code) { + if (this.pending) { + return Promise.resolve({ + type: "error", + value: new Error("Another console command is still running."), + }); + } + + if (!this.WorkerClass) { + return Promise.resolve({ + type: "error", + value: new Error( + "This WebView does not support isolated console execution.", + ), + }); + } + + let worker; + try { + worker = this.getWorker(); + } catch (error) { + return Promise.resolve({ type: "error", value: error }); + } + const id = ++this.nextId; + + return new Promise((resolve) => { + const timer = setTimeout(() => { + this.finish(id, { + type: "error", + value: new Error( + `Execution stopped after ${this.timeout / 1000} seconds.`, + ), + }); + this.resetWorker(); + }, this.timeout); + + this.pending = { id, resolve, timer }; + try { + worker.postMessage({ id, code }); + } catch (error) { + this.finish(id, { type: "error", value: error }); + this.resetWorker(); + } + }); + } + + getWorker() { + if (this.worker) return this.worker; + + const worker = new this.WorkerClass(this.workerUrl); + worker.onmessage = ({ data }) => this.handleMessage(data); + worker.onerror = (event) => { + const error = new Error(event.message || "Console worker failed."); + this.finish(this.pending?.id, { type: "error", value: error }); + this.resetWorker(); + }; + this.worker = worker; + return worker; + } + + handleMessage(message) { + if (message.type === "console") { + this.onConsole(message); + return; + } + + if (message.type === "result") { + this.finish(message.id, { type: "result", value: message.value }); + return; + } + + if (message.type === "error") { + const error = new Error( + message.error?.message || "Console command failed.", + ); + error.name = message.error?.name || "Error"; + if (message.error?.stack) error.stack = message.error.stack; + this.finish(message.id, { type: "error", value: error }); + } + } + + finish(id, result) { + if (!this.pending || this.pending.id !== id) return; + + const { resolve, timer } = this.pending; + this.pending = null; + clearTimeout(timer); + resolve(result); + } + + resetWorker() { + this.worker?.terminate(); + this.worker = null; + } + + cancel() { + if (!this.pending) return false; + + this.finish(this.pending.id, { + type: "error", + value: new Error("Execution stopped."), + }); + this.resetWorker(); + return true; + } + + destroy() { + if (this.pending) { + this.finish(this.pending.id, { + type: "error", + value: new Error("Console execution was cancelled."), + }); + } + this.resetWorker(); + } +} diff --git a/src/lib/consoleViewport.js b/src/lib/consoleViewport.js new file mode 100644 index 0000000000..3df0a420a8 --- /dev/null +++ b/src/lib/consoleViewport.js @@ -0,0 +1,27 @@ +/** + * Returns the actually visible browser rectangle. On mobile this shrinks with + * the software keyboard even when the layout viewport remains full height. + */ +export function getConsoleViewportRect(windowObject = window) { + const viewport = windowObject.visualViewport; + const documentHeight = windowObject.document?.documentElement?.clientHeight; + const visibleHeights = [ + viewport?.height, + windowObject.innerHeight, + documentHeight, + ].filter((height) => Number.isFinite(height) && height > 0); + return { + height: Math.round(Math.min(...visibleHeights)), + width: Math.round(viewport?.width || windowObject.innerWidth), + top: Math.round(viewport?.offsetTop || 0), + left: Math.round(viewport?.offsetLeft || 0), + }; +} + +export function applyConsoleViewport(element, windowObject = window) { + const { height, width, top, left } = getConsoleViewportRect(windowObject); + element.style.setProperty("--console-viewport-height", `${height}px`); + element.style.setProperty("--console-viewport-width", `${width}px`); + element.style.setProperty("--console-viewport-top", `${top}px`); + element.style.setProperty("--console-viewport-left", `${left}px`); +} diff --git a/src/lib/consoleWorker.js b/src/lib/consoleWorker.js new file mode 100644 index 0000000000..8c0fa2ea2d --- /dev/null +++ b/src/lib/consoleWorker.js @@ -0,0 +1,166 @@ +const MAX_MESSAGES_PER_SECOND = 100; +const MESSAGE_WINDOW_MS = 1000; +const MAX_PROPERTIES = 100; +const MAX_DEPTH = 4; +const MAX_SNAPSHOT_VALUES = 500; + +let activeCommandId = 0; +let messageWindowStartedAt = 0; +let emittedInWindow = 0; +let suppressedInWindow = 0; +const counts = new Map(); +const timers = new Map(); + +function snapshot( + value, + depth = 0, + seen = new WeakSet(), + budget = { remaining: MAX_SNAPSHOT_VALUES }, +) { + const type = typeof value; + + if ( + value === null || + ["boolean", "number", "string", "undefined"].includes(type) + ) { + return value; + } + if (type === "bigint") return `${value}n`; + if (type === "symbol") return value.toString(); + if (type === "function") + return `[Function${value.name ? `: ${value.name}` : ""}]`; + if (budget.remaining-- <= 0) return "[Truncated]"; + if (value instanceof Error) return snapshotError(value); + if (value instanceof Date) return value.toISOString(); + if (value instanceof RegExp) return value.toString(); + if (value instanceof Promise) return "Promise { }"; + if (depth >= MAX_DEPTH) return `[${value.constructor?.name || "Object"}]`; + if (seen.has(value)) return "[Circular]"; + + seen.add(value); + if (Array.isArray(value)) { + const result = value + .slice(0, MAX_PROPERTIES) + .map((item) => snapshot(item, depth + 1, seen, budget)); + if (value.length > MAX_PROPERTIES) result.push("…"); + return result; + } + + const result = {}; + let keys; + try { + keys = Reflect.ownKeys(value).slice(0, MAX_PROPERTIES); + } catch (error) { + return `[Uninspectable: ${error.message}]`; + } + + for (const key of keys) { + const outputKey = typeof key === "symbol" ? key.toString() : key; + try { + result[outputKey] = snapshot(value[key], depth + 1, seen, budget); + } catch (error) { + result[outputKey] = `[Thrown: ${error.message}]`; + } + } + if (Reflect.ownKeys(value).length > MAX_PROPERTIES) result["…"] = "…"; + return result; +} + +function snapshotError(error) { + return { + name: error?.name || "Error", + message: error?.message || String(error), + stack: error?.stack || "", + }; +} + +function discardSuppressedMessages() { + suppressedInWindow = 0; +} + +function emitMessage(message) { + const now = performance.now(); + if (now - messageWindowStartedAt >= MESSAGE_WINDOW_MS) { + discardSuppressedMessages(); + messageWindowStartedAt = now; + emittedInWindow = 0; + } + + if (emittedInWindow >= MAX_MESSAGES_PER_SECOND) { + suppressedInWindow++; + return; + } + + emittedInWindow++; + self.postMessage({ type: "console", id: activeCommandId, ...message }); +} + +function emit(level, args) { + emitMessage({ + level, + args: args.map((value) => snapshot(value)), + }); +} + +self.console = { + assert(condition, ...args) { + if (!condition) emit("error", args.length ? args : ["Assertion failed"]); + }, + clear() { + emitMessage({ action: "clear" }); + }, + count(label = "default") { + const value = (counts.get(label) || 0) + 1; + counts.set(label, value); + emit("log", [`${label}: ${value}`]); + }, + countReset(label = "default") { + counts.delete(label); + }, + debug: (...args) => emit("log", args), + dir: (...args) => emit("log", args), + dirxml: (...args) => emit("log", args), + error: (...args) => emit("error", args), + group: (...args) => emit("log", args), + groupCollapsed: (...args) => emit("log", args), + groupEnd() {}, + info: (...args) => emit("info", args), + log: (...args) => emit("log", args), + table: (...args) => emit("table", args), + time(label = "default") { + timers.set(label, performance.now()); + }, + timeEnd(label = "default") { + if (!timers.has(label)) return emit("warn", [`No such label: ${label}`]); + emit("log", [ + `${label}: ${(performance.now() - timers.get(label)).toFixed(2)}ms`, + ]); + timers.delete(label); + }, + timeLog(label = "default") { + if (!timers.has(label)) return emit("warn", [`No such label: ${label}`]); + emit("log", [ + `${label}: ${(performance.now() - timers.get(label)).toFixed(2)}ms`, + ]); + }, + trace(...args) { + emit("trace", [...args, new Error().stack]); + }, + warn: (...args) => emit("warn", args), +}; + +self.onmessage = ({ data: { id, code } }) => { + activeCommandId = id; + messageWindowStartedAt = performance.now(); + emittedInWindow = 0; + suppressedInWindow = 0; + + try { + const value = (0, eval)(code); + discardSuppressedMessages(); + self.postMessage({ type: "result", id, value: snapshot(value) }); + } catch (error) { + discardSuppressedMessages(); + self.postMessage({ type: "error", id, error: snapshotError(error) }); + } +}; diff --git a/src/lib/run.js b/src/lib/run.js index 7978c4b6a7..a92a085c52 100644 --- a/src/lib/run.js +++ b/src/lib/run.js @@ -72,6 +72,7 @@ async function run( let EXECUTING_SCRIPT = uuid + "_script.js"; const MIMETYPE_HTML = mimeType.lookup("html"); const CONSOLE_SCRIPT = uuid + "_console.js"; + const CONSOLE_WORKER_SCRIPT = uuid + "_console_worker.js"; const MARKDOWN_STYLE = uuid + "_md.css"; const queue = []; @@ -205,6 +206,11 @@ async function run( sendFileContent(url, reqId, "application/javascript"); break; + case CONSOLE_WORKER_SCRIPT: + url = `${ASSETS_DIRECTORY}/build/consoleWorker.js`; + sendFileContent(url, reqId, "application/javascript"); + break; + case EXECUTING_SCRIPT: { const text = getDocText(activeFile?.session?.doc); sendText(text, reqId, "application/javascript"); @@ -228,6 +234,7 @@ async function run( sendText( mustache.render($_console, { CONSOLE_SCRIPT, + CONSOLE_WORKER_SCRIPT, EXECUTING_SCRIPT, }), reqId, @@ -384,7 +391,8 @@ async function run( * @param {string} id */ function sendHTML(text, id) { - const js = ` + const js = ` + + - \ No newline at end of file + diff --git a/tests/unit/consoleExecutor.test.js b/tests/unit/consoleExecutor.test.js new file mode 100644 index 0000000000..9da5d6a9f8 --- /dev/null +++ b/tests/unit/consoleExecutor.test.js @@ -0,0 +1,134 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import ConsoleExecutor from "lib/consoleExecutor"; + +class FakeWorker { + static instances = []; + + constructor(url) { + this.url = url; + this.terminate = vi.fn(); + this.postMessage = vi.fn(); + FakeWorker.instances.push(this); + } + + respond(data) { + this.onmessage({ data }); + } +} + +describe("ConsoleExecutor", () => { + beforeEach(() => { + FakeWorker.instances = []; + vi.useRealTimers(); + }); + + it("returns a worker result and reuses the isolated context", async () => { + const executor = new ConsoleExecutor({ + workerUrl: "/console-worker.js", + WorkerClass: FakeWorker, + }); + + const firstResult = executor.execute("1 + 1"); + const worker = FakeWorker.instances[0]; + expect(worker.url).toBe("/console-worker.js"); + expect(worker.postMessage).toHaveBeenCalledWith({ id: 1, code: "1 + 1" }); + + worker.respond({ type: "result", id: 1, value: 2 }); + await expect(firstResult).resolves.toEqual({ type: "result", value: 2 }); + + const secondResult = executor.execute("3 + 4"); + expect(FakeWorker.instances).toHaveLength(1); + worker.respond({ type: "result", id: 2, value: 7 }); + await expect(secondResult).resolves.toEqual({ type: "result", value: 7 }); + }); + + it("forwards console messages from the worker", async () => { + const onConsole = vi.fn(); + const executor = new ConsoleExecutor({ + workerUrl: "/console-worker.js", + WorkerClass: FakeWorker, + onConsole, + }); + + const result = executor.execute("console.log('hello')"); + const worker = FakeWorker.instances[0]; + const message = { + type: "console", + id: 1, + level: "log", + args: ["hello"], + }; + worker.respond(message); + worker.respond({ type: "result", id: 1 }); + + expect(onConsole).toHaveBeenCalledWith(message); + await expect(result).resolves.toEqual({ type: "result", value: undefined }); + }); + + it("terminates and replaces a worker when execution times out", async () => { + vi.useFakeTimers(); + const executor = new ConsoleExecutor({ + workerUrl: "/console-worker.js", + WorkerClass: FakeWorker, + timeout: 50, + }); + + const timedOutResult = executor.execute("while (true) {}"); + const blockedWorker = FakeWorker.instances[0]; + await vi.advanceTimersByTimeAsync(50); + + const result = await timedOutResult; + expect(result.type).toBe("error"); + expect(result.value.message).toBe("Execution stopped after 0.05 seconds."); + expect(blockedWorker.terminate).toHaveBeenCalledOnce(); + + const nextResult = executor.execute("42"); + expect(FakeWorker.instances).toHaveLength(2); + FakeWorker.instances[1].respond({ type: "result", id: 2, value: 42 }); + await expect(nextResult).resolves.toEqual({ type: "result", value: 42 }); + }); + + it("allows a running command to be stopped immediately", async () => { + const executor = new ConsoleExecutor({ + workerUrl: "/console-worker.js", + WorkerClass: FakeWorker, + }); + + const stoppedResult = executor.execute("while (true) {}"); + const blockedWorker = FakeWorker.instances[0]; + + expect(executor.cancel()).toBe(true); + await expect(stoppedResult).resolves.toMatchObject({ + type: "error", + value: { message: "Execution stopped." }, + }); + expect(blockedWorker.terminate).toHaveBeenCalledOnce(); + expect(executor.cancel()).toBe(false); + }); + + it("turns worker errors into console errors", async () => { + const executor = new ConsoleExecutor({ + workerUrl: "/console-worker.js", + WorkerClass: FakeWorker, + }); + + const result = executor.execute("missingName"); + FakeWorker.instances[0].respond({ + type: "error", + id: 1, + error: { + name: "ReferenceError", + message: "missingName is not defined", + stack: "worker stack", + }, + }); + + const errorResult = await result; + expect(errorResult.type).toBe("error"); + expect(errorResult.value).toMatchObject({ + name: "ReferenceError", + message: "missingName is not defined", + stack: "worker stack", + }); + }); +}); diff --git a/tests/unit/consoleViewport.test.js b/tests/unit/consoleViewport.test.js new file mode 100644 index 0000000000..ccd8c9030e --- /dev/null +++ b/tests/unit/consoleViewport.test.js @@ -0,0 +1,41 @@ +// @vitest-environment happy-dom + +import { describe, expect, it } from "vitest"; +import { + applyConsoleViewport, + getConsoleViewportRect, +} from "lib/consoleViewport"; + +describe("console visual viewport", () => { + it("uses the visual viewport when the mobile keyboard shrinks the screen", () => { + const windowObject = { + innerHeight: 800, + innerWidth: 400, + visualViewport: { + height: 476.4, + width: 400, + offsetTop: 12.2, + offsetLeft: 0, + }, + }; + + expect(getConsoleViewportRect(windowObject)).toEqual({ + height: 476, + width: 400, + top: 12, + left: 0, + }); + + const element = document.createElement("div"); + applyConsoleViewport(element, windowObject); + expect(element.style.getPropertyValue("--console-viewport-height")).toBe( + "476px", + ); + }); + + it("falls back to the layout viewport", () => { + expect( + getConsoleViewportRect({ innerHeight: 720, innerWidth: 360 }), + ).toEqual({ height: 720, width: 360, top: 0, left: 0 }); + }); +}); diff --git a/tests/unit/variableVirtualList.test.js b/tests/unit/variableVirtualList.test.js new file mode 100644 index 0000000000..49f466b0ec --- /dev/null +++ b/tests/unit/variableVirtualList.test.js @@ -0,0 +1,125 @@ +// @vitest-environment happy-dom + +import { describe, expect, it } from "vitest"; +import VariableVirtualList from "components/virtualList/variable"; + +function createList() { + const container = document.createElement("div"); + Object.defineProperty(container, "clientHeight", { + configurable: true, + value: 200, + }); + document.body.append(container); + return { container, list: new VariableVirtualList(container) }; +} + +describe("VariableVirtualList", () => { + it("retains all items while mounting only the viewport window", () => { + const { container, list } = createList(); + const elements = Array.from({ length: 1000 }, (_, index) => { + const element = document.createElement("div"); + element.textContent = `message-${index}`; + list.append(element); + return element; + }); + + list.render(); + + expect(list.length).toBe(1000); + expect(list.mountedCount).toBeLessThan(40); + expect(elements[999].isConnected).toBe(true); + expect(elements[0].isConnected).toBe(false); + + list.stickToBottom = false; + container.scrollTop = 0; + list.render(); + expect(elements[0].isConnected).toBe(true); + expect(elements[999].isConnected).toBe(false); + + list.destroy(); + container.remove(); + }); + + it("updates variable measurements and clears the retained model", () => { + const { container, list } = createList(); + const element = document.createElement("div"); + list.append(element); + list.render(); + + list.updateHeight(list.items[0], 96); + list.rebuildOffsets(); + expect(list.offsets).toEqual([0, 96]); + + list.clear(); + expect(list.length).toBe(0); + expect(list.mountedCount).toBe(0); + expect(container.scrollTop).toBe(0); + + list.destroy(); + container.remove(); + }); + + it("preserves an inline footer outside the virtualized rows", () => { + const { container, list } = createList(); + const footer = document.createElement("form"); + footer.textContent = "REPL prompt"; + container.append(footer); + + list.append(document.createElement("div")); + list.render(); + expect(container.lastElementChild).toBe(footer); + + list.clear(); + expect(container.lastElementChild).toBe(footer); + expect(footer.isConnected).toBe(true); + + list.destroy(); + container.remove(); + }); + + it("pre-paints a larger guard before touch momentum starts", () => { + const { container, list } = createList(); + for (let index = 0; index < 1000; index++) { + list.append(document.createElement("div")); + } + list.stickToBottom = false; + container.scrollTop = 10000; + list.render(); + const idleCount = list.mountedCount; + + container.dispatchEvent(new Event("touchstart")); + + expect(list.dynamicOverscan).toBe(list.activeOverscan); + expect(list.mountedCount).toBeGreaterThan(idleCount); + + list.destroy(); + container.remove(); + }); + + it("keeps overlapping rows mounted during incremental scrolling", () => { + const { container, list } = createList(); + Object.defineProperty(container, "scrollHeight", { + configurable: true, + value: 10400, + }); + const elements = Array.from({ length: 200 }, () => { + const element = document.createElement("div"); + list.append(element); + return element; + }); + list.stickToBottom = false; + container.scrollTop = 520; + list.render(); + const retained = elements[10]; + expect(retained.isConnected).toBe(true); + + container.scrollTop = 624; + container.dispatchEvent(new Event("scroll")); + + expect(retained.isConnected).toBe(true); + expect(list.dynamicOverscan).toBeGreaterThan(list.overscan); + + list.destroy(); + container.remove(); + }); +}); diff --git a/webpack.config.js b/webpack.config.js index 8767eaa8f6..2fbead728b 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -110,6 +110,7 @@ module.exports = (env, options) => { entry: { main: './src/main.js', console: './src/lib/console.js', + consoleWorker: './src/lib/consoleWorker.js', searchInFilesWorker: './src/sidebarApps/searchInFiles/worker.js', searchIndexWorker: './src/sidebarApps/searchInFiles/indexWorker.js', }, From d4fe64e0ba34ca00dcc8e712bbeb244307d6b0df Mon Sep 17 00:00:00 2001 From: Raunak Raj <71929976+bajrangCoder@users.noreply.github.com> Date: Tue, 25 Aug 2026 19:53:14 +0530 Subject: [PATCH 2/4] fix(console): address execution context and bottom pinning --- src/components/virtualList/variable.js | 38 ++++++- src/lib/console.js | 106 ++++++++++++++++-- .../{consoleExecutor.js => consoleRuntime.js} | 49 ++++++++ src/lib/consoleViewport.js | 27 ----- src/lib/consoleWorker.js | 2 + src/styles/console.m.scss | 23 ++++ tests/unit/consoleExecutionContext.test.js | 52 +++++++++ tests/unit/consoleExecutor.test.js | 2 +- tests/unit/consoleViewport.test.js | 2 +- tests/unit/variableVirtualList.test.js | 37 +++++- 10 files changed, 295 insertions(+), 43 deletions(-) rename src/lib/{consoleExecutor.js => consoleRuntime.js} (64%) delete mode 100644 src/lib/consoleViewport.js create mode 100644 tests/unit/consoleExecutionContext.test.js diff --git a/src/components/virtualList/variable.js b/src/components/virtualList/variable.js index f922af2ba2..9ed041f5a6 100644 --- a/src/components/virtualList/variable.js +++ b/src/components/virtualList/variable.js @@ -17,6 +17,7 @@ export default class VariableVirtualList { overscan = DEFAULT_OVERSCAN, activeOverscan = overscan * 4, maxOverscan = overscan * 10, + footer = null, } = {}, ) { this.container = container; @@ -25,6 +26,8 @@ export default class VariableVirtualList { this.activeOverscan = Math.max(overscan, activeOverscan); this.maxOverscan = Math.max(this.activeOverscan, maxOverscan); this.dynamicOverscan = overscan; + this.footer = footer; + this.footerHeight = 0; this.items = []; this.itemByElement = new WeakMap(); this.offsets = [0]; @@ -49,6 +52,7 @@ export default class VariableVirtualList { this.itemContainer, this.bottomSpacer, ); + if (this.footer) this.container.append(this.footer); this.onScroll = () => { this.stickToBottom = this.isNearBottom(); @@ -156,6 +160,8 @@ export default class VariableVirtualList { this.bottomSpacer.style.height = "0px"; this.itemContainer.replaceChildren(); this.container.scrollTop = 0; + this.footerHeight = this.getFooterHeight(); + if (this.footer) this.resizeObserver?.observe(this.footer); } invalidate() { @@ -198,11 +204,22 @@ export default class VariableVirtualList { return Math.min(low, Math.max(0, this.items.length - 1)); } + getFooterHeight() { + if (!this.footer) return 0; + return Math.ceil( + this.footer.getBoundingClientRect().height || + this.footer.offsetHeight || + 0, + ); + } + render() { if (this.destroyed) return; this.rebuildOffsets(); if (!this.items.length) { this.resizeObserver?.disconnect(); + if (this.footer) this.resizeObserver?.observe(this.footer); + this.footerHeight = this.getFooterHeight(); this.itemContainer.replaceChildren(); this.topSpacer.style.height = "0px"; this.bottomSpacer.style.height = "0px"; @@ -215,8 +232,9 @@ export default class VariableVirtualList { this.container.clientHeight || window.innerHeight, ); const totalHeight = this.offsets[this.items.length]; + this.footerHeight = this.getFooterHeight(); const targetScrollTop = this.stickToBottom - ? Math.max(0, totalHeight - viewportHeight) + ? Math.max(0, totalHeight + this.footerHeight - viewportHeight) : this.container.scrollTop; const start = this.findIndexAt( Math.max(0, targetScrollTop - this.dynamicOverscan), @@ -233,6 +251,7 @@ export default class VariableVirtualList { for (const element of this.itemContainer.children) { this.resizeObserver?.observe(element); } + if (this.footer) this.resizeObserver?.observe(this.footer); this.topSpacer.style.height = `${this.offsets[start]}px`; this.bottomSpacer.style.height = `${Math.max( 0, @@ -296,8 +315,22 @@ export default class VariableVirtualList { } onResize(entries) { - const wasNearBottom = this.isNearBottom(); + const wasNearBottom = this.stickToBottom || this.isNearBottom(); + let footerChanged = false; for (const entry of entries) { + if (entry.target === this.footer) { + const borderBox = Array.isArray(entry.borderBoxSize) + ? entry.borderBoxSize[0] + : entry.borderBoxSize; + const height = Math.ceil( + borderBox?.blockSize || + entry.contentRect?.height || + this.getFooterHeight(), + ); + footerChanged = Math.abs(height - this.footerHeight) >= 1; + this.footerHeight = height; + continue; + } const item = this.itemByElement.get(entry.target); if (!item) continue; const borderBox = Array.isArray(entry.borderBoxSize) @@ -311,6 +344,7 @@ export default class VariableVirtualList { ); } if (wasNearBottom) this.stickToBottom = true; + if (wasNearBottom && footerChanged) this.scheduleRender(); } updateHeight(item, measuredHeight) { diff --git a/src/lib/console.js b/src/lib/console.js index 7d5e4284bb..c68aba8834 100644 --- a/src/lib/console.js +++ b/src/lib/console.js @@ -4,8 +4,11 @@ import { parse } from "acorn"; import { VariableVirtualList } from "components/virtualList"; import css from "styles/console.m.scss"; import loadPolyFill from "utils/polyfill"; -import ConsoleExecutor from "./consoleExecutor"; -import { applyConsoleViewport } from "./consoleViewport"; +import ConsoleExecutor, { + applyConsoleViewport, + executeConsoleCommand, + resolveConsoleExecutionContext, +} from "./consoleRuntime"; (function () { loadPolyFill.apply(window); @@ -71,24 +74,53 @@ import { applyConsoleViewport } from "./consoleViewport"; }, onclick: clearConsole, }); + const $executionContext = tag("select", { + className: "__c-context", + attr: { + "aria-label": "JavaScript execution context", + title: "Choose isolated Worker execution or live page access", + }, + children: [ + tag("option", { + textContent: "Worker", + attr: { value: "worker" }, + }), + tag("option", { + textContent: "Page (unsafe)", + attr: { value: "page" }, + }), + ], + onchange() { + if (this.value !== "page") return; + log( + "warn", + {}, + "Page mode can access window and document, but it runs on the preview thread and cannot stop infinite code.", + ); + }, + }); const $output = tag("c-output", { attr: { role: "region", "aria-label": "JavaScript console", }, }); - const virtualMessages = new VariableVirtualList($output); const $inputContainer = tag("c-input", { children: [ $input, tag("c-input-actions", { - children: [$stopExecution, $clearButton, $runButton], + children: [ + ...(isStandaloneConsole ? [] : [$executionContext]), + $stopExecution, + $clearButton, + $runButton, + ], }), ], }); - // Keep the prompt in the transcript flow, after the virtualized messages. - // It behaves like a REPL row and naturally moves above the mobile keyboard. - $output.append($inputContainer); + const virtualMessages = new VariableVirtualList($output, { + footer: $inputContainer, + }); const $toggler = tag("c-toggler", { style: { transform: `translate(2px, ${innerHeight / 2}px)`, @@ -359,7 +391,7 @@ import { applyConsoleViewport } from "./consoleViewport"; $input.value = ""; resizeInput(); setExecutionState(true); - const res = await execute(code); + const res = await executeCommand(code); setExecutionState(false); if (res.type === "error") { @@ -373,9 +405,10 @@ import { applyConsoleViewport } from "./consoleViewport"; function setExecutionState(running) { isExecuting = running; $console.toggleAttribute("running", running); - $stopExecution.hidden = !running; + $stopExecution.hidden = !running || $executionContext.value !== "worker"; $runButton.hidden = running; $input.disabled = running; + $executionContext.disabled = running; } function resizeInput() { @@ -781,8 +814,61 @@ import { applyConsoleViewport } from "./consoleViewport"; }; } + function executeCommand(code) { + return executeConsoleCommand({ + context: resolveConsoleExecutionContext( + isStandaloneConsole, + $executionContext.value, + ), + code, + workerExecutor: executor, + pageExecutor: execute, + }); + } + function execute(code) { - return executor.execute(code); + let res = null; + try { + const parsed = parse(code, acornOptions).body; + res = execParsedCode(parsed); + } catch (e) { + res = execParsedCode([]); + } + + return res; + + function execParsedCode(parsed) { + let extra = ""; + parsed.map((st) => { + if (st.type === "VariableDeclaration") { + if (["const", "let"].indexOf(st.kind) < 0) return; + + const exCode = code.substring(st.start, st.end) + ";"; + extra += exCode; + } + }); + + if (extra) { + const script = tag("script"); + script.textContent = extra; + document.body.appendChild(script); + document.body.removeChild(script); + return exec(code); + } else { + return exec(code); + } + } + + function exec(code) { + let res = null; + try { + res = { type: "result", value: window.eval(code) }; + } catch (error) { + res = { type: "error", value: error }; + } + + return res; + } } function onError(err) { diff --git a/src/lib/consoleExecutor.js b/src/lib/consoleRuntime.js similarity index 64% rename from src/lib/consoleExecutor.js rename to src/lib/consoleRuntime.js index c9f6c97662..383ddd0946 100644 --- a/src/lib/consoleExecutor.js +++ b/src/lib/consoleRuntime.js @@ -135,3 +135,52 @@ export default class ConsoleExecutor { this.resetWorker(); } } + +/** + * Returns the actually visible browser rectangle. On mobile this shrinks with + * the software keyboard even when the layout viewport remains full height. + */ +export function getConsoleViewportRect(windowObject = window) { + const viewport = windowObject.visualViewport; + const documentHeight = windowObject.document?.documentElement?.clientHeight; + const visibleHeights = [ + viewport?.height, + windowObject.innerHeight, + documentHeight, + ].filter((height) => Number.isFinite(height) && height > 0); + return { + height: Math.round(Math.min(...visibleHeights)), + width: Math.round(viewport?.width || windowObject.innerWidth), + top: Math.round(viewport?.offsetTop || 0), + left: Math.round(viewport?.offsetLeft || 0), + }; +} + +export function applyConsoleViewport(element, windowObject = window) { + const { height, width, top, left } = getConsoleViewportRect(windowObject); + element.style.setProperty("--console-viewport-height", `${height}px`); + element.style.setProperty("--console-viewport-width", `${width}px`); + element.style.setProperty("--console-viewport-top", `${top}px`); + element.style.setProperty("--console-viewport-left", `${left}px`); +} + +export function resolveConsoleExecutionContext( + isStandaloneConsole, + selectedContext, +) { + return isStandaloneConsole ? "worker" : selectedContext; +} + +/** + * Routes a console command to the isolated worker or the live preview page. + * Page execution is deliberately opt-in because it runs on the preview thread. + */ +export function executeConsoleCommand({ + context, + code, + workerExecutor, + pageExecutor, +}) { + if (context === "page") return Promise.resolve(pageExecutor(code)); + return workerExecutor.execute(code); +} diff --git a/src/lib/consoleViewport.js b/src/lib/consoleViewport.js deleted file mode 100644 index 3df0a420a8..0000000000 --- a/src/lib/consoleViewport.js +++ /dev/null @@ -1,27 +0,0 @@ -/** - * Returns the actually visible browser rectangle. On mobile this shrinks with - * the software keyboard even when the layout viewport remains full height. - */ -export function getConsoleViewportRect(windowObject = window) { - const viewport = windowObject.visualViewport; - const documentHeight = windowObject.document?.documentElement?.clientHeight; - const visibleHeights = [ - viewport?.height, - windowObject.innerHeight, - documentHeight, - ].filter((height) => Number.isFinite(height) && height > 0); - return { - height: Math.round(Math.min(...visibleHeights)), - width: Math.round(viewport?.width || windowObject.innerWidth), - top: Math.round(viewport?.offsetTop || 0), - left: Math.round(viewport?.offsetLeft || 0), - }; -} - -export function applyConsoleViewport(element, windowObject = window) { - const { height, width, top, left } = getConsoleViewportRect(windowObject); - element.style.setProperty("--console-viewport-height", `${height}px`); - element.style.setProperty("--console-viewport-width", `${width}px`); - element.style.setProperty("--console-viewport-top", `${top}px`); - element.style.setProperty("--console-viewport-left", `${left}px`); -} diff --git a/src/lib/consoleWorker.js b/src/lib/consoleWorker.js index 8c0fa2ea2d..c5d911fe8c 100644 --- a/src/lib/consoleWorker.js +++ b/src/lib/consoleWorker.js @@ -156,6 +156,8 @@ self.onmessage = ({ data: { id, code } }) => { suppressedInWindow = 0; try { + // Intentional: this local REPL runs the command in its isolated worker. + // codeql[js/code-injection] const value = (0, eval)(code); discardSuppressedMessages(); self.postMessage({ type: "result", id, value: snapshot(value) }); diff --git a/src/styles/console.m.scss b/src/styles/console.m.scss index 8b72e03a22..bd58d3b4ca 100644 --- a/src/styles/console.m.scss +++ b/src/styles/console.m.scss @@ -62,6 +62,29 @@ c-console[standalone] { color: #fff; } +.__c-context { + box-sizing: border-box; + max-width: 112px; + min-height: 32px; + padding: 4px 20px 4px 6px; + border: 0; + border-radius: 3px; + outline: none; + background: #414141; + color: #d6d3d3; + font: inherit; + font-size: 0.7rem; +} + +.__c-context:focus-visible { + outline: 1px solid #929292; + outline-offset: -1px; +} + +.__c-context:disabled { + opacity: 0.55; +} + c-output { display: block; flex: 1 1 100%; diff --git a/tests/unit/consoleExecutionContext.test.js b/tests/unit/consoleExecutionContext.test.js new file mode 100644 index 0000000000..ab92a29872 --- /dev/null +++ b/tests/unit/consoleExecutionContext.test.js @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; +import { + executeConsoleCommand, + resolveConsoleExecutionContext, +} from "lib/consoleRuntime"; + +describe("console execution context", () => { + it("forces standalone menu consoles to use the worker", () => { + expect(resolveConsoleExecutionContext(true, "page")).toBe("worker"); + expect(resolveConsoleExecutionContext(false, "page")).toBe("page"); + }); + + it("uses isolated worker execution by default", async () => { + const calls = []; + const result = await executeConsoleCommand({ + context: "worker", + code: "document.title", + workerExecutor: { + execute(code) { + calls.push(["worker", code]); + return Promise.resolve({ type: "result", value: "isolated" }); + }, + }, + pageExecutor(code) { + calls.push(["page", code]); + }, + }); + + expect(calls).toEqual([["worker", "document.title"]]); + expect(result.value).toBe("isolated"); + }); + + it("restores live page execution when explicitly selected", async () => { + const calls = []; + const result = await executeConsoleCommand({ + context: "page", + code: 'document.querySelector("main")', + workerExecutor: { + execute(code) { + calls.push(["worker", code]); + }, + }, + pageExecutor(code) { + calls.push(["page", code]); + return { type: "result", value: "live page" }; + }, + }); + + expect(calls).toEqual([["page", 'document.querySelector("main")']]); + expect(result.value).toBe("live page"); + }); +}); diff --git a/tests/unit/consoleExecutor.test.js b/tests/unit/consoleExecutor.test.js index 9da5d6a9f8..7dcd45e645 100644 --- a/tests/unit/consoleExecutor.test.js +++ b/tests/unit/consoleExecutor.test.js @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import ConsoleExecutor from "lib/consoleExecutor"; +import ConsoleExecutor from "lib/consoleRuntime"; class FakeWorker { static instances = []; diff --git a/tests/unit/consoleViewport.test.js b/tests/unit/consoleViewport.test.js index ccd8c9030e..218a5bad69 100644 --- a/tests/unit/consoleViewport.test.js +++ b/tests/unit/consoleViewport.test.js @@ -4,7 +4,7 @@ import { describe, expect, it } from "vitest"; import { applyConsoleViewport, getConsoleViewportRect, -} from "lib/consoleViewport"; +} from "lib/consoleRuntime"; describe("console visual viewport", () => { it("uses the visual viewport when the mobile keyboard shrinks the screen", () => { diff --git a/tests/unit/variableVirtualList.test.js b/tests/unit/variableVirtualList.test.js index 49f466b0ec..60a27d7f96 100644 --- a/tests/unit/variableVirtualList.test.js +++ b/tests/unit/variableVirtualList.test.js @@ -60,10 +60,15 @@ describe("VariableVirtualList", () => { }); it("preserves an inline footer outside the virtualized rows", () => { - const { container, list } = createList(); + const container = document.createElement("div"); + Object.defineProperty(container, "clientHeight", { + configurable: true, + value: 200, + }); + document.body.append(container); const footer = document.createElement("form"); footer.textContent = "REPL prompt"; - container.append(footer); + const list = new VariableVirtualList(container, { footer }); list.append(document.createElement("div")); list.render(); @@ -77,6 +82,34 @@ describe("VariableVirtualList", () => { container.remove(); }); + it("includes the footer height when pinning and rendering the bottom", () => { + const container = document.createElement("div"); + Object.defineProperties(container, { + clientHeight: { configurable: true, value: 200 }, + scrollHeight: { configurable: true, value: 5304 }, + }); + document.body.append(container); + const footer = document.createElement("form"); + footer.getBoundingClientRect = () => ({ height: 104 }); + const list = new VariableVirtualList(container, { + footer, + overscan: 0, + }); + for (let index = 0; index < 100; index++) { + list.append(document.createElement("div")); + } + + list.render(); + + expect(list.renderedRange.start).toBe(98); + expect(list.renderedRange.end).toBe(100); + expect(container.scrollTop).toBe(5104); + expect(container.lastElementChild).toBe(footer); + + list.destroy(); + container.remove(); + }); + it("pre-paints a larger guard before touch momentum starts", () => { const { container, list } = createList(); for (let index = 0; index < 1000; index++) { From c5fd3c57b9df38774186f9c29b02c83691884e40 Mon Sep 17 00:00:00 2001 From: Raunak Raj <71929976+bajrangCoder@users.noreply.github.com> Date: Tue, 25 Aug 2026 20:03:43 +0530 Subject: [PATCH 3/4] address the pinning regression --- src/components/virtualList/variable.js | 43 ++++++++++++++++++++------ tests/unit/variableVirtualList.test.js | 36 +++++++++++++++++++++ 2 files changed, 69 insertions(+), 10 deletions(-) diff --git a/src/components/virtualList/variable.js b/src/components/virtualList/variable.js index 9ed041f5a6..e2cf193c6d 100644 --- a/src/components/virtualList/variable.js +++ b/src/components/virtualList/variable.js @@ -28,6 +28,7 @@ export default class VariableVirtualList { this.dynamicOverscan = overscan; this.footer = footer; this.footerHeight = 0; + this.viewportHeight = this.container.clientHeight; this.items = []; this.itemByElement = new WeakMap(); this.offsets = [0]; @@ -97,6 +98,7 @@ export default class VariableVirtualList { typeof ResizeObserver === "function" ? new ResizeObserver((entries) => this.onResize(entries)) : null; + this.resizeObserver?.observe(this.container); } now() { @@ -148,7 +150,6 @@ export default class VariableVirtualList { } clear() { - this.resizeObserver?.disconnect(); this.items = []; this.itemByElement = new WeakMap(); this.offsets = [0]; @@ -161,7 +162,7 @@ export default class VariableVirtualList { this.itemContainer.replaceChildren(); this.container.scrollTop = 0; this.footerHeight = this.getFooterHeight(); - if (this.footer) this.resizeObserver?.observe(this.footer); + this.observeResizeTargets(); } invalidate() { @@ -213,17 +214,26 @@ export default class VariableVirtualList { ); } + observeResizeTargets() { + if (!this.resizeObserver) return; + this.resizeObserver.disconnect(); + this.resizeObserver.observe(this.container); + for (const element of this.itemContainer.children) { + this.resizeObserver.observe(element); + } + if (this.footer) this.resizeObserver.observe(this.footer); + } + render() { if (this.destroyed) return; this.rebuildOffsets(); if (!this.items.length) { - this.resizeObserver?.disconnect(); - if (this.footer) this.resizeObserver?.observe(this.footer); this.footerHeight = this.getFooterHeight(); this.itemContainer.replaceChildren(); this.topSpacer.style.height = "0px"; this.bottomSpacer.style.height = "0px"; this.renderedRange = { start: 0, end: 0 }; + this.observeResizeTargets(); return; } @@ -231,6 +241,7 @@ export default class VariableVirtualList { 1, this.container.clientHeight || window.innerHeight, ); + this.viewportHeight = viewportHeight; const totalHeight = this.offsets[this.items.length]; this.footerHeight = this.getFooterHeight(); const targetScrollTop = this.stickToBottom @@ -246,12 +257,8 @@ export default class VariableVirtualList { ) + 1, ); - this.resizeObserver?.disconnect(); this.updateMountedRange(start, end); - for (const element of this.itemContainer.children) { - this.resizeObserver?.observe(element); - } - if (this.footer) this.resizeObserver?.observe(this.footer); + this.observeResizeTargets(); this.topSpacer.style.height = `${this.offsets[start]}px`; this.bottomSpacer.style.height = `${Math.max( 0, @@ -317,7 +324,21 @@ export default class VariableVirtualList { onResize(entries) { const wasNearBottom = this.stickToBottom || this.isNearBottom(); let footerChanged = false; + let viewportChanged = false; for (const entry of entries) { + if (entry.target === this.container) { + const borderBox = Array.isArray(entry.borderBoxSize) + ? entry.borderBoxSize[0] + : entry.borderBoxSize; + const height = Math.ceil( + borderBox?.blockSize || + entry.contentRect?.height || + this.container.clientHeight, + ); + viewportChanged = Math.abs(height - this.viewportHeight) >= 1; + this.viewportHeight = height; + continue; + } if (entry.target === this.footer) { const borderBox = Array.isArray(entry.borderBoxSize) ? entry.borderBoxSize[0] @@ -344,7 +365,9 @@ export default class VariableVirtualList { ); } if (wasNearBottom) this.stickToBottom = true; - if (wasNearBottom && footerChanged) this.scheduleRender(); + if (viewportChanged || (wasNearBottom && footerChanged)) { + this.scheduleRender(); + } } updateHeight(item, measuredHeight) { diff --git a/tests/unit/variableVirtualList.test.js b/tests/unit/variableVirtualList.test.js index 60a27d7f96..ed1e0c70d7 100644 --- a/tests/unit/variableVirtualList.test.js +++ b/tests/unit/variableVirtualList.test.js @@ -110,6 +110,42 @@ describe("VariableVirtualList", () => { container.remove(); }); + it("keeps a pinned footer visible when the viewport height changes", () => { + let viewportHeight = 200; + const container = document.createElement("div"); + Object.defineProperties(container, { + clientHeight: { + configurable: true, + get: () => viewportHeight, + }, + scrollHeight: { configurable: true, value: 5304 }, + }); + document.body.append(container); + const footer = document.createElement("form"); + footer.getBoundingClientRect = () => ({ height: 104 }); + const list = new VariableVirtualList(container, { footer }); + for (let index = 0; index < 100; index++) { + list.append(document.createElement("div")); + } + list.render(); + expect(container.scrollTop).toBe(5104); + + viewportHeight = 100; + list.onResize([ + { + target: container, + contentRect: { height: viewportHeight }, + }, + ]); + list.render(); + + expect(list.stickToBottom).toBe(true); + expect(container.scrollTop).toBe(5204); + + list.destroy(); + container.remove(); + }); + it("pre-paints a larger guard before touch momentum starts", () => { const { container, list } = createList(); for (let index = 0; index < 1000; index++) { From 7c3f37272681d0a803fa2c60b1ba427aa7090af2 Mon Sep 17 00:00:00 2001 From: Raunak Raj <71929976+bajrangCoder@users.noreply.github.com> Date: Wed, 26 Aug 2026 18:50:35 +0530 Subject: [PATCH 4/4] remove stop, run btn --- src/lib/console.js | 24 ------------------------ src/styles/console.m.scss | 7 ------- 2 files changed, 31 deletions(-) diff --git a/src/lib/console.js b/src/lib/console.js index c68aba8834..8d399ea315 100644 --- a/src/lib/console.js +++ b/src/lib/console.js @@ -41,18 +41,6 @@ import ConsoleExecutor, { }, 0); }, }); - const $runButton = tag("button", { - className: "__c-action __c-action-primary", - textContent: "Run", - attr: { - type: "button", - "aria-label": "Run JavaScript", - }, - onpointerdown(e) { - e.preventDefault(); - }, - onclick: runInputCode, - }); const $stopExecution = tag("button", { className: "__c-action __c-action-danger", textContent: "Stop", @@ -65,15 +53,6 @@ import ConsoleExecutor, { }, }); $stopExecution.hidden = true; - const $clearButton = tag("button", { - className: "__c-action", - textContent: "Clear", - attr: { - type: "button", - "aria-label": "Clear console", - }, - onclick: clearConsole, - }); const $executionContext = tag("select", { className: "__c-context", attr: { @@ -112,8 +91,6 @@ import ConsoleExecutor, { children: [ ...(isStandaloneConsole ? [] : [$executionContext]), $stopExecution, - $clearButton, - $runButton, ], }), ], @@ -406,7 +383,6 @@ import ConsoleExecutor, { isExecuting = running; $console.toggleAttribute("running", running); $stopExecution.hidden = !running || $executionContext.value !== "worker"; - $runButton.hidden = running; $input.disabled = running; $executionContext.disabled = running; } diff --git a/src/styles/console.m.scss b/src/styles/console.m.scss index bd58d3b4ca..47dbe4dbb8 100644 --- a/src/styles/console.m.scss +++ b/src/styles/console.m.scss @@ -55,13 +55,6 @@ c-console[standalone] { color: #ff9b9b; } -.__c-action-primary { - flex: none; - min-width: 44px; - background: #666; - color: #fff; -} - .__c-context { box-sizing: border-box; max-width: 112px;